feat: pluma standalone — autoría multilienzo (haz de cuerpos) + notebook reactivo (front-door, git-dep al monorepo)

Front-door limpio: solo crates del dominio; Llimphi y lo fundacional por
git-dep del monorepo gioser.git. cargo check pasa (40 crates, 0 errores).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 12:18:01 +00:00
commit 30467600bc
195 changed files with 38852 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/target
**/*.rs.bk
*.pdb
+91
View File
@@ -0,0 +1,91 @@
# pluma
> Documentos vivos. Markdown como grafo de átomos editables; LLM como transformador, no como autor.
`pluma` trata un documento como un DAG de párrafos (átomos) con identidad estable. La edición preserva ids; el LLM se invoca como **transformación pura** sobre subgrafos (resumir esta sección, traducir aquel párrafo) — siempre con diff visible y reversible. Incluye notebook (con kernels Python/WASM/LLM/cosmos/dominium), editor visual, deck (slides) y reader web.
## Instalación
```sh
# editor de markdown (Llimphi desktop)
cargo run --release -p pluma-app
# notebook
cargo run --release -p pluma-notebook-app
# reader web (WASM)
./scripts/build-gioser-web.sh
```
## Compatibilidad
- **Linux / macOS / Windows** — apps Llimphi nativas.
- **Wawa** — `pluma` viaja como app del kernel (`03_ukupacha/wawa/apps/pluma/`).
- **Web** — `pluma-md-reader-web` renderiza markdown en navegador (el reader que usa este sitio).
## Crates
| Crate | Rol |
|---|---|
| [`pluma-core`](pluma-core/README.md) | Modelo de documento: átomos, grafo, ids. |
| [`pluma-cuerpo`](pluma-cuerpo/README.md) | Texto del documento como secuencia de átomos. |
| [`pluma-store`](pluma-store/README.md) | Persistencia (`$XDG_DATA_HOME/pluma/`). |
| [`pluma-md`](pluma-md/README.md) | Parser GFM (pulldown-cmark) → HTML temable. |
| [`pluma-md-reader-web`](pluma-md-reader-web/README.md) | Reader markdown para WASM. |
| [`pluma-graph`](pluma-graph/README.md) | DAG de átomos con identidad. |
| [`pluma-graph-transform`](pluma-graph-transform/README.md) | Mutaciones del DAG (insert/mutar/eliminar). |
| [`pluma-transform`](pluma-transform/README.md) | Marco general de transformaciones puras. |
| [`pluma-transform-llm`](pluma-transform-llm/README.md) | Transforms LLM (resumir, traducir, ...). |
| [`pluma-transform-tabla`](pluma-transform-tabla/README.md) | Transforms tabulares. |
| [`pluma-llm`](pluma-llm/README.md) | Fachada `Arc<dyn ChatClient>` con autodetect. |
| [`pluma-llm-core`](pluma-llm-core/README.md) | Trait `ChatClient`. |
| [`pluma-llm-anthropic`](pluma-llm-anthropic/README.md) | Backend Claude API. |
| [`pluma-llm-gemini`](pluma-llm-gemini/README.md) | Backend Gemini. |
| [`pluma-llm-cohere`](pluma-llm-cohere/README.md) | Backend Cohere. |
| [`pluma-llm-openai-compatible`](pluma-llm-openai-compatible/README.md) | OpenAI / DeepSeek / Ollama / proxies. |
| [`pluma-llm-mock`](pluma-llm-mock/README.md) | Backend mock para tests. |
| [`pluma-align`](pluma-align/README.md) | Alineamiento textotexto. |
| [`pluma-align-embeddings`](pluma-align-embeddings/README.md) | Alineamiento por embeddings. |
| [`pluma-semantic`](pluma-semantic/README.md) | Anotaciones semánticas del documento. |
| [`pluma-editor-cuerpo`](pluma-editor-cuerpo/README.md) | Editor texto↔átomos con diff (greedy). |
| [`pluma-editor-llimphi`](pluma-editor-llimphi/README.md) | Editor visual Llimphi. |
| [`pluma-app`](pluma-app/README.md) | Binario del editor. |
| [`pluma-render-plan`](pluma-render-plan/README.md) | Plan de render del documento. |
| [`pluma-deck-core`](pluma-deck-core/README.md) | Deck (slides) sobre pluma. |
| [`pluma-deck-web`](pluma-deck-web/README.md) | Deck en navegador. |
| [`pluma-notebook-core`](pluma-notebook-core/README.md) | Notebook: celdas + outputs addressable. |
| [`pluma-notebook-store`](pluma-notebook-store/README.md) | Persistencia notebook. |
| [`pluma-notebook-exec`](pluma-notebook-exec/README.md) | Despacho a kernels. |
| [`pluma-notebook-kernel-python`](pluma-notebook-kernel-python/README.md) | Python via RustPython/WASM. |
| [`pluma-notebook-kernel-wasm`](pluma-notebook-kernel-wasm/README.md) | WASM genérico (cranelift AOT). |
| [`pluma-notebook-kernel-llm`](pluma-notebook-kernel-llm/README.md) | Celdas LLM. |
| [`pluma-notebook-kernel-cosmos`](pluma-notebook-kernel-cosmos/README.md) | Kernel astronomía (cosmos-sky). |
| [`pluma-notebook-kernel-dominium`](pluma-notebook-kernel-dominium/README.md) | Kernel simulador (dominium). |
| [`pluma-notebook-llimphi`](pluma-notebook-llimphi/README.md) | Notebook UI Llimphi. |
| [`pluma-notebook-graph-llimphi`](pluma-notebook-graph-llimphi/README.md) | Vista grafo del notebook (celdas como nodos). |
| [`pluma-notebook-app`](pluma-notebook-app/README.md) | Binario del notebook. |
## Estado (2026-05-31)
### Hecho
- Núcleo de documento vivo: `pluma-core`/`pluma-cuerpo`/`pluma-graph` (DAG de átomos con ids estables) + `pluma-graph-transform` + `pluma-store` (sled).
- Transformaciones puras: `pluma-transform` + `pluma-transform-llm` (resumir/traducir) + `pluma-transform-tabla`, con diff visible y reversible.
- Fachada LLM `pluma-llm` con autodetect + backends anthropic/gemini/cohere/openai-compatible/mock.
- Alineación texto-texto (`pluma-align`) + por embeddings (`pluma-align-embeddings`) + anotaciones semánticas (`pluma-semantic`).
- Editor visual `pluma-editor-llimphi` + binario `pluma-app`; reader web `pluma-md-reader-web`.
- Notebook: `pluma-notebook-core`/`-exec`/`-store` + UI `pluma-notebook-llimphi` + vista grafo `pluma-notebook-graph-llimphi` + binario. Kernels reales: LLM, cosmos, dominium, media, tinkuy.
- Deck: `pluma-deck-core`/`-web` + modo Recorrido tipo Prezi (`pluma-deck-recorrido-llimphi`) con autoría completa, persistencia (postcard), camino narrativo visible, modo presentador (autoplay/bucle) y undo/redo; binario `pluma-deck`.
- Menú principal + menús contextuales cableados en las apps.
### Pendiente
- `pluma-notebook-kernel-python` (RustPython) y `-wasm` (wasmi): cimientos funcionando; falta el camino WASM-AOT cranelift completo y librerías nativas.
- Puente `foreign-docx`: import/export DOCX aún parcial; resto de la familia `foreign-*` (xlsx/pptx/psd) no en disco.
- Deuda del deck: split de tullpu + `Camara` (ver PLAN §6.sexies).
## Consideraciones
- **El LLM no escribe; transforma.** No hay "modo redacción libre" — cada llamada devuelve una mutación atómica que el usuario aprueba o rechaza.
- Los IDs de átomo son la unidad de verdad: rename/move conservan referencias internas y links externos.
- Kernels del notebook son **WASM-first** (sandboxing del notebook por defecto).
+390
View File
@@ -0,0 +1,390 @@
# Cómo probar pluma — guía rápida
Pluma es la familia de crates para edición de documentos multilienzo: un
documento es un *haz de cuerpos* (lienzos) — el original, sus
traducciones, resúmenes, anotaciones — alineados párrafo a párrafo por
*hebras*. Esta guía muestra qué correr para ver cada pieza funcionando.
> Última actualización: 2026-05-26. Las versiones de modelo y nombres de
> env vars están vigentes al cierre de la primera iteración del stack
> LLM+multilienzo.
## TL;DR
```bash
# Tests unitarios del haz multilienzo (sin red, sin GUI):
cargo test \
-p pluma-cuerpo \
-p pluma-align \
-p pluma-align-embeddings \
-p pluma-transform \
-p pluma-transform-tabla \
-p pluma-transform-llm \
-p pluma-graph-transform \
-p pluma-store \
-p pluma-llm-core \
-p pluma-llm-mock \
-p pluma-llm-anthropic \
-p pluma-llm-gemini \
-p pluma-llm-openai-compatible \
-p pluma-llm \
-p pluma-notebook-kernel-llm \
-p pluma-editor-llimphi
# Demo visual sin red ni keys (mock pre-poblado):
cargo run -p pluma-editor-llimphi --example multilienzo_demo --release
```
Lo demás en esta guía es para ir cubriendo el resto del stack en
profundidad y/o usar IAs reales.
## 1. Validar tu API key contra un servicio real (gasto mínimo)
Antes de meterse a generar un multilienzo entero, corré el smoke de
Gemini para confirmar que tu key funciona. Una sola request,
~30 tokens, fracción de centavo:
```bash
GEMINI_API_KEY=tu_key cargo run \
-p pluma-llm-gemini --example smoke --release
```
Salida esperada:
```
smoke :: usando modelo gemini-2.5-flash
respuesta: Rimay
tokens: input=28 output=2 cache_read=0 cache_creation=0
stop_reason: MAX_TOKENS
```
Existe el mismo patrón para los otros backends en cuanto se sumen
smokes — Anthropic y DeepSeek implementan la misma `ChatClient` y se
prueban igual contra sus respectivas API keys.
## 2. El multilienzo visual
Tres demos visuales del editor, todos ejecutables sin recompilar entre
ellos:
### 2.1 Estático — datos hardcoded
Lo más sencillo. No necesita ni LLM ni embeddings.
```bash
cargo run -p pluma-editor-llimphi --example multilienzo_demo --release
```
Tres cuerpos (`es` / `qu` runa simi / `en` resumen) con los cuatro
estados de hebra: Derivada fresca verde, Embeddings azul atenuado por
fuerza, Manual ámbar, Stale gris punteado.
### 2.2 LLM-driven — un solo flujo, cinco backends
El factory `pluma_llm::from_env` elige el backend según env vars.
Cambiar de IA = una variable.
```bash
# Sin keys → mock predecible con traducciones hardcoded:
cargo run -p pluma-editor-llimphi --example multilienzo_llm_demo --release
# Gemini real:
GEMINI_API_KEY=... PLUMA_LLM_BACKEND=gemini \
cargo run -p pluma-editor-llimphi --example multilienzo_llm_demo --release
# Anthropic:
ANTHROPIC_API_KEY=sk-ant-... \
cargo run -p pluma-editor-llimphi --example multilienzo_llm_demo --release
# DeepSeek:
DEEPSEEK_API_KEY=... PLUMA_LLM_BACKEND=deepseek \
cargo run -p pluma-editor-llimphi --example multilienzo_llm_demo --release
# Ollama 100% local (requiere `ollama serve` y el modelo pulled):
PLUMA_LLM_BACKEND=ollama PLUMA_LLM_MODEL=llama3.1 \
cargo run -p pluma-editor-llimphi --example multilienzo_llm_demo --release
```
Para que las hebras `qu↔en` sean semánticamente verdaderas (no
random del mock), levanta el embedder global en paralelo:
```bash
verbo-daemon --provider fastembed &
# (descarga ~120 MB de multilingual-e5-small la primera vez)
# después correr cualquiera de los demos
```
El demo detecta el socket en `$XDG_RUNTIME_DIR/verbo.sock` y se conecta
automáticamente.
### 2.3 Dinámico — botones de transformación
Toolbar con cuatro botones (`→ qu`, `→ en`, `tono formal`, `resumir 30p`).
Click → spawn thread con runtime tokio efímero → LLM transparente →
columna nueva al volver. Un trabajo a la vez (los botones quedan
deshabilitados durante la ejecución).
```bash
GEMINI_API_KEY=... PLUMA_LLM_BACKEND=gemini \
cargo run -p pluma-editor-llimphi \
--example multilienzo_dinamico_demo --release
```
### 2.3.0 Importar un archivo `.docx` como cuerpo madre
```rust
use foreign_docx::parse_docx;
let bytes = std::fs::read("informe.docx")?;
let imp = parse_docx(&bytes, "es", "informe.docx", ahora_unix())?;
// imp.cuerpo: Original, branch_id "es", lengua None
// imp.atoms: un NarrativeAtom por <w:p> con texto no vacío
```
Mismo shape que `pluma_md::DocumentoImportado` para que el caller los
trate uniforme. Formato Word (negrita, cursiva, estilos, headers,
footers, tablas, comments) se descarta — solo contenido legible.
### 2.3.1 Importar un archivo `.md` como cuerpo madre
```rust
use pluma_md::parse_md;
let texto = std::fs::read_to_string("notas.md")?;
let imp = parse_md(&texto, "es", "notas.md", ahora_unix());
// imp.atoms: un NarrativeAtom por bloque (párrafo, lista, encabezado…)
// imp.cuerpo: Intencion::Original con todos los atoms en orden.
for atom in &imp.atoms {
graph.insert(atom.clone());
}
// Pasar imp.cuerpo como madre a cualquier ejecutor LLM.
```
Formato inline (negrita, cursiva, code inline) se aplana — el LLM
recibe texto limpio. Encabezados preservan jerarquía vía prefijo `# `,
`## `, etc.
### 2.4 Completo — toolbar dinámica CON persistencia + focus + búsqueda
El más cercano a "app real": botones LLM como en 2.3, pero CADA
transformación se persiste en `~/.cache/gioser/pluma-multilienzo-completo/`
antes de mostrarse. Cierra el demo, volvé a abrirlo: cuerpos y hebras
siguen ahí + podés seguir generando.
```bash
GEMINI_API_KEY=... PLUMA_LLM_BACKEND=gemini \
cargo run -p pluma-editor-llimphi \
--example multilienzo_completo_demo --release
# Reset:
MULTILIENZO_COMPLETO_RESET=1 cargo run -p pluma-editor-llimphi \
--example multilienzo_completo_demo --release
```
Tras unas cuantas corridas tendrás un haz crecido: una madre `es` con
varias derivadas (`qu`, `en`, formal, resumen). El editor las muestra
todas alineadas por hebras Derivadas 1↔1.
**Atajos y botones del demo completo**:
| Acción | Cómo |
|---|---|
| Derivar cuerpo nuevo | Botones `→ qu` · `→ en` · `tono formal` · `resumir 30p` |
| Editar la madre | Botón `editar madre` (anexa marca incremental al 1er párrafo) |
| Marcar todas las hijas stale | Botón `tocar madre` |
| Regenerar hija stale | Botón `regenerar stale (N)` — una a la vez |
| Cambiar IA en runtime | Botón `modelo: X` — cicla por los 6 backends |
| Scroll horizontal | `Shift + rueda del mouse` · o eje X de touchpad |
| Focus mode | Botón `solo madre` / `todos` |
| Búsqueda transversal | Tipeá cualquier texto · `Backspace` borra · `Esc` limpia |
| Persistencia UI | Automática: scroll/focus/búsqueda se restauran al reabrir |
| Reset cache | `MULTILIENZO_COMPLETO_RESET=1` en el env |
### 2.5 Persistente — sobrevive entre corridas
Primera vez: genera y guarda en `~/.cache/gioser/pluma-multilienzo/`.
Siguientes: lee y muestra instantáneo, sin red.
```bash
# Primera corrida — pega al LLM:
GEMINI_API_KEY=... PLUMA_LLM_BACKEND=gemini \
cargo run -p pluma-editor-llimphi \
--example multilienzo_store_demo --release
# Segunda corrida — sin red ni tokens:
cargo run -p pluma-editor-llimphi --example multilienzo_store_demo --release
# Resetear el cache:
MULTILIENZO_RESET=1 cargo run -p pluma-editor-llimphi \
--example multilienzo_store_demo --release
```
## 3. El stack LLM por crate (sin GUI)
Si querés ver cómo encajan las piezas sin abrir una ventana, los tests
unitarios cuentan bien la historia. Cada crate tiene su `pruebas` mod
con happy path + failure modes:
```bash
# El contrato + tipos:
cargo test -p pluma-llm-core
# Determinista para tests:
cargo test -p pluma-llm-mock
# Cada backend:
cargo test -p pluma-llm-anthropic
cargo test -p pluma-llm-gemini
cargo test -p pluma-llm-openai-compatible
# Fachada / factory:
cargo test -p pluma-llm
# Ejecutores de transformación:
cargo test -p pluma-transform-tabla
cargo test -p pluma-transform-llm
# Pegamento con el grafo y la store:
cargo test -p pluma-graph-transform
cargo test -p pluma-store
# Notebook con kernel LLM:
cargo test -p pluma-notebook-kernel-llm
```
## 4. Embeddings: provider real vs mock
`pluma-align-embeddings` calcula hebras `qu↔en` (o cualquier par) con
cualquier `rimay_verbo_core::Provider`. Tres formas de servirlo:
```bash
# Mock determinista (sin descarga, sin red, vectores random estables):
verbo-daemon --provider mock
# BGE local sin API key (descarga ~120 MB la primera vez):
verbo-daemon --provider fastembed
# Otro socket o dimensión:
verbo-daemon --provider mock --socket /tmp/mi.sock --dim 768
```
Y en la app:
```rust
let daemon = DaemonClient::connect("$XDG_RUNTIME_DIR/verbo.sock").await?;
let carta = alinear_por_embeddings(&qu, &en, &idx, &daemon, &params, ahora).await?;
```
Cualquier consumidor de `&dyn Provider` (pluma-semantic, chasqui-core,
khipu) habla igual.
## 5. Notebook + LLM — demo CLI ejecutable
`notebook_llm_demo` arma un notebook con cuatro celdas (markdown fuente
+ traducir-qu + tono-formal + resumir-20), corre `run_all` con el
`LlmKernel`, e imprime los outputs por consola. No usa ventana — útil
para verificar que el flujo entero funciona en un servidor sin GUI.
```bash
# Sin keys (mock que diferencia acciones por system):
cargo run -p pluma-notebook-kernel-llm --example notebook_llm_demo --release
# Con Gemini:
GEMINI_API_KEY=... PLUMA_LLM_BACKEND=gemini \
cargo run -p pluma-notebook-kernel-llm --example notebook_llm_demo --release
# Con Ollama:
PLUMA_LLM_BACKEND=ollama PLUMA_LLM_MODEL=llama3.1 \
cargo run -p pluma-notebook-kernel-llm --example notebook_llm_demo --release
```
Salida esperada (modo mock):
```
notebook_llm_demo :: LLM = mock-nb
=== ejecución ===
ejecutadas: 4 fallidas: 0 skipped: 0
=== outputs ===
[1/markdown] sin output
[2/llm-traducir-qu] Kuntur wayqu hanaqpachatakta…
[3/llm-tono-formal] El cóndor surcó con majestuosidad…
[4/llm-resumir-20] Amanecer andino: cóndor, llamas, tejedora.
```
## 5.bis Notebook + LLM — uso programático
```rust
use pluma_llm::{build_client, BackendKind, LlmConfig};
use pluma_notebook_kernel_llm::LlmKernel;
use pluma_notebook_exec::run_all;
let chat = build_client(&LlmConfig {
kind: BackendKind::Gemini,
..Default::default()
})?;
let kernel = LlmKernel::from_arc(chat);
let mut notebook = /* armar celdas con language="llm-traducir-qu", etc. */;
let reporte = run_all(&mut notebook, &kernel).await.unwrap();
println!("ejecutadas: {} | fallidas: {}", reporte.executed.len(), reporte.failed.len());
```
Lenguajes que `LlmKernel` entiende:
| `language` | Qué hace |
|------------------------|-------------------------------------------------------|
| `llm-prompt` | El source ES el prompt completo. Sin system. |
| `llm-traducir-{LANG}` | Traduce al idioma dado (qu, en, fr…). |
| `llm-tono-{ETIQUETA}` | Reescribe con tono (formal, casual, infantil…). |
| `llm-resumir` | Resumen libre. |
| `llm-resumir-{N}` | Resumen a aproximadamente N palabras. |
| `llm-reescribir` | Primera línea del source = prompt, resto = texto. |
## 6. Mapa del stack
```
pluma-core NarrativeAtom + coherencia
pluma-graph NarrativeGraph (DAG con propagación PendingEvaluation)
pluma-cuerpo Cuerpo + MetaCuerpo + Intencion
pluma-align Alineamiento + CartaHebras + alineadores manuales
pluma-align-embeddings alineador semántico async via Provider
pluma-transform trait Ejecutor async + EjecutorIdentidad
pluma-transform-tabla EjecutorTraducirTabla (tabla explícita)
pluma-transform-llm Ejecutor{Traducir,Tono,Resumir,Reescribir}Llm
pluma-graph-transform indice_atoms + persistir_producto (pegamento)
pluma-store PlumaStore (atoms+cuerpos+transformaciones+cartas+EstadoUi en sled)
pluma-editor-llimphi::multilienzo vista columnas+carriles+hebras
pluma-editor-llimphi::cuerpo_ide text-editor IDE de Llimphi sobre EditorCuerpo
(apply_key → buffer; diff → CambioAtom)
pluma-editor-cuerpo sincronia cuerpo ↔ buffer plano (diff Mutar/Crear/Eliminar)
reusa Uuids al mutar → hebras vivas tras edit
shared/foreign-docx importar .docx como cuerpo madre
pluma-llm-core trait ChatClient (contrato agnóstico de proveedor)
pluma-llm-mock determinista para tests
pluma-llm-anthropic Claude con prompt caching del system
pluma-llm-gemini Gemini con cachedContentTokenCount
pluma-llm-cohere Cohere Command (Anthropic-shape de response)
pluma-llm-openai-compatible DeepSeek + Ollama + Groq/Together/vLLM
pluma-llm fachada transparente: build_client(&cfg)/from_env
pluma-notebook-{core,exec,store} notebook reactivo (DAG de celdas)
pluma-notebook-kernel-llm conecta el notebook al LLM transparente
pluma-notebook-kernel-{python,wasm} los otros kernels
rimay-verbo-{core,mock,daemon,daemon-bin,fastembed} embedder global
```
## 7. Variables de entorno reconocidas
| Variable | Quién | Para qué |
|---|---|---|
| `PLUMA_LLM_BACKEND` | `pluma_llm::from_env` | `anthropic`/`gemini`/`deepseek`/`ollama`/`mock` |
| `PLUMA_LLM_MODEL` | `pluma_llm::from_env` | sobrescribe el default del backend |
| `PLUMA_LLM_ENDPOINT` | `pluma_llm::from_env` | proxy interno o endpoint custom |
| `ANTHROPIC_API_KEY` | `pluma-llm-anthropic` | Claude |
| `GEMINI_API_KEY` o `GOOGLE_API_KEY` | `pluma-llm-gemini` | Gemini AI Studio |
| `DEEPSEEK_API_KEY` | `pluma-llm-openai-compatible` | DeepSeek |
| `COHERE_API_KEY` | `pluma-llm-cohere` | Cohere Command |
| `MULTILIENZO_RESET` | `multilienzo_store_demo` | `=1` para limpiar el cache |
| `XDG_RUNTIME_DIR` | `verbo-daemon` y clientes | path del socket del embedder |
| `XDG_CACHE_HOME` | `multilienzo_store_demo` | base del cache de PlumaStore |
+46
View File
@@ -0,0 +1,46 @@
# pluma
> Living documents. Markdown as a graph of editable atoms; LLM as transformer, not author.
`pluma` treats a document as a DAG of paragraphs (atoms) with stable identity. Editing preserves ids; the LLM is invoked as **pure transformation** over subgraphs (summarize this section, translate that paragraph) — always with visible, reversible diff. Includes notebook (with Python/WASM/LLM/cosmos/dominium kernels), visual editor, deck (slides) and web reader.
## Install
```sh
# markdown editor (Llimphi desktop)
cargo run --release -p pluma-app
# notebook
cargo run --release -p pluma-notebook-app
# web reader (WASM)
./scripts/build-gioser-web.sh
```
## Compatibility
- **Linux / macOS / Windows** — native Llimphi apps.
- **Wawa** — `pluma` ships as a kernel app (`03_ukupacha/wawa/apps/pluma/`).
- **Web** — `pluma-md-reader-web` renders markdown in the browser (the reader this site uses).
## Crates
Core + parser: `pluma-core`, `pluma-cuerpo`, `pluma-store`, `pluma-md`, `pluma-md-reader-web`, `pluma-graph`, `pluma-graph-transform`, `pluma-semantic`, `pluma-align`, `pluma-align-embeddings`, `pluma-render-plan`.
Transforms: `pluma-transform`, `pluma-transform-llm`, `pluma-transform-tabla`.
LLM facade: `pluma-llm`, `pluma-llm-core`, `pluma-llm-anthropic`, `pluma-llm-gemini`, `pluma-llm-cohere`, `pluma-llm-openai-compatible`, `pluma-llm-mock`.
Editor: `pluma-editor-cuerpo`, `pluma-editor-llimphi`, `pluma-app`.
Deck: `pluma-deck-core`, `pluma-deck-web`.
Notebook: `pluma-notebook-{core,store,exec,llimphi,graph-llimphi,app,kernel-python,kernel-wasm,kernel-llm,kernel-cosmos,kernel-dominium}`.
Full table in [README.md](README.md).
## Considerations
- **The LLM doesn't write; it transforms.** No "free writing mode" — each call returns an atomic mutation that the user approves or rejects.
- Atom IDs are the unit of truth: rename/move preserves internal refs and outside links.
- Notebook kernels are **WASM-first** (notebook sandboxing by default).
+43
View File
@@ -0,0 +1,43 @@
<!-- Quechua (Cusco/Collao). Revisión bienvenida. -->
# pluma
> Kawsasqa qillqakuna. Markdown — átomu-grafu hina, LLM transformadormi, manataq autor.
`pluma` qillqasqata DAG hina qhawan: párrafokuna (átomokuna) sumaq sutiyuq. Tikraspa idskuna waqaychasqa; LLM **ch'uya transformación** hina sub-grafukunapi qharin (kay parte huñuy, chay párrafo tikray) — qhawana diff-wan, kutichinapaq atisqa. Notebook (Python/WASM/LLM/cosmos/dominium kernels), rikuna editor, deck (slides), web reader.
## Churay
```sh
# markdown editor (Llimphi desktop)
cargo run --release -p pluma-app
# notebook
cargo run --release -p pluma-notebook-app
# web reader (WASM)
./scripts/build-gioser-web.sh
```
## Tinkuy
- **Linux / macOS / Windows** — Llimphi natural apps.
- **Wawa** — `pluma` kernel-pa apps-nin hina (`03_ukupacha/wawa/apps/pluma/`).
- **Web** — `pluma-md-reader-web` markdown navegador ukhupi riqsichiq (kay sitio chayta usanqa).
## Crateskuna
Sumaq tabla [README.md](README.md)-pi. Familiakuna:
- **Core + parser**: `pluma-core`, `pluma-cuerpo`, `pluma-md`, `pluma-md-reader-web`, `pluma-graph`, `pluma-semantic`, `pluma-align*`, `pluma-render-plan`, `pluma-store`.
- **Transforms**: `pluma-transform`, `pluma-transform-llm`, `pluma-transform-tabla`.
- **LLM**: `pluma-llm`, `pluma-llm-{core,anthropic,gemini,cohere,openai-compatible,mock}`.
- **Editor**: `pluma-editor-{cuerpo,llimphi}`, `pluma-app`.
- **Deck**: `pluma-deck-{core,web}`.
- **Notebook**: `pluma-notebook-{core,store,exec,llimphi,graph-llimphi,app}` + `pluma-notebook-kernel-{python,wasm,llm,cosmos,dominium}`.
## Yuyaykunaq
- **LLM manan qillqaqchu; tikraqmi.** Mana "ch'iqaq qillqana modo"; sapanka llamayqa atómica mutación kutichin, runa allichaspa utaq mana munaspa.
- Átomu IDmi cheqaq unidad: tikrakuna sutikunata waqaychan, ukhupi rikchakuna hawapi linkkuna ima.
- Notebook kernelkuna **WASM-ñawpaq** (notebook sandbox kasqa).
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "foreign-docx"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "pluma/foreign-docx — importa `.docx` (Word 2007+ y compatibles) como cuerpo madre del multilienzo. Un párrafo `<w:p>` del XML → un NarrativeAtom; runs `<w:t>` concatenados, formato (negrita/cursiva) descartado."
[dependencies]
zip = { version = "2", default-features = false, features = ["deflate"] }
quick-xml = { workspace = true }
thiserror = { workspace = true }
pluma-core = { path = "../pluma-core" }
pluma-cuerpo = { path = "../pluma-cuerpo" }
uuid = { workspace = true }
+460
View File
@@ -0,0 +1,460 @@
//! `foreign-docx` — importa archivos `.docx` (Office Open XML) como
//! cuerpos madre del multilienzo de pluma.
//!
//! Un `.docx` es un zip que contiene `word/document.xml` con el cuerpo
//! del documento serializado en XML. Este crate hace lo mínimo
//! necesario: descomprime, abre `document.xml`, recorre los párrafos
//! `<w:p>`, junta los runs `<w:t>` de cada uno, y produce un
//! `NarrativeAtom` por párrafo.
//!
//! NO interpreta formato (negrita, cursiva, color), estilos, headers,
//! footers, tablas, comments, ni elementos avanzados. La meta es
//! ingestar el contenido legible — quien quiera fidelidad de formato
//! debería trabajar el doc en su editor nativo, no traerlo a pluma.
//!
//! ## Ejemplo
//!
//! ```no_run
//! use foreign_docx::parse_docx;
//! let bytes = std::fs::read("informe.docx").unwrap();
//! let imp = parse_docx(&bytes, "es", "informe.docx", 0).unwrap();
//! // imp.cuerpo: Intencion::Original
//! // imp.atoms: un NarrativeAtom por párrafo del .docx
//! ```
#![forbid(unsafe_code)]
use std::collections::HashMap;
use std::io::{Cursor, Read, Write};
use quick_xml::events::Event;
use quick_xml::reader::Reader;
use thiserror::Error;
use uuid::Uuid;
use pluma_core::NarrativeAtom;
use pluma_cuerpo::{Cuerpo, Intencion};
/// Resultado del import: el cuerpo madre + sus átomos. Misma shape que
/// `pluma_md::DocumentoImportado` para que los demos puedan tratar
/// ambos igual.
#[derive(Debug)]
pub struct DocumentoImportado {
pub cuerpo: Cuerpo,
pub atoms: Vec<NarrativeAtom>,
}
#[derive(Debug, Error)]
pub enum DocxError {
#[error("no es un zip válido (.docx): {0}")]
Zip(#[from] zip::result::ZipError),
#[error("falta `word/document.xml` en el archivo")]
DocumentoFaltante,
#[error("lectura del archivo interno falló: {0}")]
Io(#[from] std::io::Error),
#[error("XML malformado: {0}")]
Xml(#[from] quick_xml::Error),
}
/// Importa un `.docx` (bytes crudos del archivo, p. ej. devuelto por
/// `std::fs::read`) como cuerpo Original. `branch_id`, `nombre` y
/// `ahora` se anotan en `MetaCuerpo`.
pub fn parse_docx(
bytes: &[u8],
branch_id: impl Into<String>,
nombre: impl Into<String>,
ahora: u64,
) -> Result<DocumentoImportado, DocxError> {
let cursor = Cursor::new(bytes);
let mut zip = zip::ZipArchive::new(cursor)?;
let mut xml = String::new();
{
let mut entry = zip
.by_name("word/document.xml")
.map_err(|_| DocxError::DocumentoFaltante)?;
entry.read_to_string(&mut xml)?;
}
let parrafos = extraer_parrafos(&xml)?;
let branch = branch_id.into();
let mut cuerpo = Cuerpo::nuevo(branch.clone(), nombre, Intencion::Original, ahora);
let mut atoms = Vec::with_capacity(parrafos.len());
for texto in parrafos {
let texto = texto.trim();
if texto.is_empty() {
continue;
}
let atom = NarrativeAtom::new(texto.to_string(), &branch);
cuerpo.agregar(atom.id, ahora);
atoms.push(atom);
}
Ok(DocumentoImportado { cuerpo, atoms })
}
/// Parser SAX mínimo: cada `<w:p>` abre un buffer; cada `<w:t>` añade
/// su texto; `</w:p>` cierra y empuja al output.
///
/// Reconocemos tanto `w:t` como `t` (algunos generadores omiten el
/// prefijo; el namespace queda igual al ser el default del documento).
/// Saltos `<w:br/>` se ignoran — quedan como espacio implícito entre
/// los runs adyacentes.
fn extraer_parrafos(xml: &str) -> Result<Vec<String>, quick_xml::Error> {
let mut reader = Reader::from_str(xml);
reader.trim_text(false);
let mut parrafos = Vec::new();
let mut buf = String::new();
let mut en_parrafo = false;
let mut en_text = false;
loop {
match reader.read_event() {
Ok(Event::Start(e)) => {
let name = e.name();
let local = nombre_local(name.as_ref());
if local == b"p" {
en_parrafo = true;
buf.clear();
} else if en_parrafo && local == b"t" {
en_text = true;
}
}
Ok(Event::End(e)) => {
let name = e.name();
let local = nombre_local(name.as_ref());
if local == b"p" {
parrafos.push(buf.clone());
buf.clear();
en_parrafo = false;
} else if local == b"t" {
en_text = false;
}
}
Ok(Event::Text(e)) => {
if en_parrafo && en_text {
let s = e.unescape()?;
buf.push_str(s.as_ref());
}
}
// Word usa `<w:br/>` para saltos de línea suaves dentro de
// un párrafo. Lo tratamos como espacio para no concatenar.
Ok(Event::Empty(e)) => {
let name = e.name();
let local = nombre_local(name.as_ref());
if en_parrafo && local == b"br" {
if !buf.ends_with(' ') && !buf.is_empty() {
buf.push(' ');
}
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(e),
_ => {}
}
}
Ok(parrafos)
}
/// Exporta `cuerpo` + atoms como un `.docx` mínimo válido (zip con
/// `[Content_Types].xml`, `_rels/.rels`, `word/_rels/document.xml.rels`
/// y `word/document.xml`). Suficiente para que Word, LibreOffice y
/// nuestro propio `parse_docx` lo abran sin quejarse.
///
/// Cada atom del `cuerpo.orden` se vuelca como un `<w:p>` con un solo
/// `<w:r><w:t xml:space="preserve">…</w:t></w:r>`. Sin formato,
/// estilos, headings (los prefijos `# `/`## ` quedan como texto crudo
/// del párrafo — no se traducen a `pStyle="Heading1"` etc.). Lossy en
/// formato es deliberado: la idea del exportador es entregar el
/// contenido alineable, no recrear la apariencia del Word de turno.
///
/// Atoms ausentes del índice se saltan en silencio (igual semántica que
/// [`pluma_md::to_md`]).
pub fn write_docx(
cuerpo: &Cuerpo,
atoms: &HashMap<Uuid, NarrativeAtom>,
) -> Result<Vec<u8>, DocxError> {
let mut buf = Vec::new();
{
let mut w = zip::ZipWriter::new(Cursor::new(&mut buf));
let opts: zip::write::SimpleFileOptions = Default::default();
// [Content_Types].xml — declara el tipo MIME del documento.
w.start_file("[Content_Types].xml", opts)?;
w.write_all(CONTENT_TYPES_XML.as_bytes())?;
// _rels/.rels — el package relationship que apunta al document.
w.start_file("_rels/.rels", opts)?;
w.write_all(PACKAGE_RELS_XML.as_bytes())?;
// word/_rels/document.xml.rels — vacío pero presente, Word/LO
// lo esperan para abrir el archivo.
w.start_file("word/_rels/document.xml.rels", opts)?;
w.write_all(DOCUMENT_RELS_XML.as_bytes())?;
// word/document.xml — el contenido.
let mut xml = String::from(DOCUMENT_HEAD);
for atom_id in &cuerpo.orden {
let Some(atom) = atoms.get(atom_id) else {
continue;
};
xml.push_str("<w:p><w:r><w:t xml:space=\"preserve\">");
xml.push_str(&escapar_xml(&atom.content));
xml.push_str("</w:t></w:r></w:p>");
}
// Si el cuerpo está vacío, igual escribimos un párrafo vacío —
// un .docx sin <w:p> a veces lo rechazan parsers estrictos.
if cuerpo.orden.is_empty() || cuerpo.orden.iter().all(|id| !atoms.contains_key(id)) {
xml.push_str("<w:p/>");
}
xml.push_str(DOCUMENT_TAIL);
w.start_file("word/document.xml", opts)?;
w.write_all(xml.as_bytes())?;
w.finish()?;
}
Ok(buf)
}
/// Misma lógica que [`write_docx`] pero con índice por `&NarrativeAtom`
/// — útil para callers que tienen prestados los atoms (mismo patrón
/// que `pluma_md::to_md_borrow`).
pub fn write_docx_borrow(
cuerpo: &Cuerpo,
atoms: &HashMap<Uuid, &NarrativeAtom>,
) -> Result<Vec<u8>, DocxError> {
let mut buf = Vec::new();
{
let mut w = zip::ZipWriter::new(Cursor::new(&mut buf));
let opts: zip::write::SimpleFileOptions = Default::default();
w.start_file("[Content_Types].xml", opts)?;
w.write_all(CONTENT_TYPES_XML.as_bytes())?;
w.start_file("_rels/.rels", opts)?;
w.write_all(PACKAGE_RELS_XML.as_bytes())?;
w.start_file("word/_rels/document.xml.rels", opts)?;
w.write_all(DOCUMENT_RELS_XML.as_bytes())?;
let mut xml = String::from(DOCUMENT_HEAD);
for atom_id in &cuerpo.orden {
let Some(atom) = atoms.get(atom_id) else {
continue;
};
xml.push_str("<w:p><w:r><w:t xml:space=\"preserve\">");
xml.push_str(&escapar_xml(&atom.content));
xml.push_str("</w:t></w:r></w:p>");
}
if cuerpo.orden.is_empty() || cuerpo.orden.iter().all(|id| !atoms.contains_key(id)) {
xml.push_str("<w:p/>");
}
xml.push_str(DOCUMENT_TAIL);
w.start_file("word/document.xml", opts)?;
w.write_all(xml.as_bytes())?;
w.finish()?;
}
Ok(buf)
}
const CONTENT_TYPES_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>"#;
const PACKAGE_RELS_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>"#;
const DOCUMENT_RELS_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
</Relationships>"#;
const DOCUMENT_HEAD: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>"#;
const DOCUMENT_TAIL: &str = r#"</w:body>
</w:document>"#;
/// Escapa los cinco caracteres XML que romperían el documento. Word es
/// flexible con & y < dentro de `xml:space="preserve"`, pero los
/// parsers strictos no.
fn escapar_xml(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
_ => out.push(c),
}
}
out
}
/// Quita el prefijo de namespace (`w:p` → `p`). Acepta tanto QName con
/// prefijo (`w:p`) como sin (`p`). El default-ns del documento Word es
/// el namespace `w`, pero hay docs ajenos sin prefijo — soportamos los
/// dos.
fn nombre_local(qname: &[u8]) -> &[u8] {
match qname.iter().position(|b| *b == b':') {
Some(i) => &qname[i + 1..],
None => qname,
}
}
#[cfg(test)]
mod pruebas {
use super::*;
use std::io::Write;
/// Crea un `.docx` mínimo en memoria con los párrafos dados.
/// Genera solo el `[Content_Types].xml` mínimo, `_rels/.rels`,
/// `word/_rels/document.xml.rels` y `word/document.xml` con los
/// párrafos. Suficiente para que ZipArchive lo abra y nuestro
/// parser extraiga el contenido.
fn docx_de(parrafos: &[&str]) -> Vec<u8> {
let mut buf = Vec::new();
{
let mut w = zip::ZipWriter::new(Cursor::new(&mut buf));
let opts: zip::write::SimpleFileOptions = Default::default();
// Content_Types mínimo. Word lo requiere para abrir el doc,
// pero nuestro parser no lo consulta — basta con que el zip
// tenga la entrada `word/document.xml`.
w.start_file("[Content_Types].xml", opts).unwrap();
w.write_all(
br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>"#,
)
.unwrap();
// El documento.
let mut xml = String::from(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>"#,
);
for p in parrafos {
xml.push_str(&format!(
"<w:p><w:r><w:t xml:space=\"preserve\">{}</w:t></w:r></w:p>",
p
));
}
xml.push_str("</w:body></w:document>");
w.start_file("word/document.xml", opts).unwrap();
w.write_all(xml.as_bytes()).unwrap();
w.finish().unwrap();
}
buf
}
#[test]
fn parrafos_se_separan_en_atoms() {
let docx = docx_de(&["Primero.", "Segundo.", "Tercero."]);
let imp = parse_docx(&docx, "es", "test.docx", 1).unwrap();
assert_eq!(imp.atoms.len(), 3);
assert_eq!(imp.atoms[0].content.as_str(), "Primero.");
assert_eq!(imp.atoms[1].content.as_str(), "Segundo.");
assert_eq!(imp.atoms[2].content.as_str(), "Tercero.");
assert_eq!(imp.cuerpo.metadatos.intencion, Intencion::Original);
assert_eq!(imp.cuerpo.branch_id, "es");
assert_eq!(imp.cuerpo.orden.len(), 3);
}
#[test]
fn parrafos_vacios_se_omiten() {
let docx = docx_de(&["", "Solo este.", "", " "]);
let imp = parse_docx(&docx, "es", "x", 0).unwrap();
assert_eq!(imp.atoms.len(), 1);
assert_eq!(imp.atoms[0].content.as_str(), "Solo este.");
}
#[test]
fn archivo_sin_document_xml_devuelve_error() {
// Zip vacío.
let mut buf = Vec::new();
{
let mut w = zip::ZipWriter::new(Cursor::new(&mut buf));
w.start_file("hola.txt", zip::write::SimpleFileOptions::default())
.unwrap();
w.write_all(b"no soy docx").unwrap();
w.finish().unwrap();
}
match parse_docx(&buf, "es", "x", 0) {
Err(DocxError::DocumentoFaltante) => {}
otro => panic!("esperaba DocumentoFaltante, fue {otro:?}"),
}
}
#[test]
fn bytes_no_zip_devuelve_zip_error() {
let basura = b"esto no es un zip ni en broma";
assert!(matches!(
parse_docx(basura, "es", "x", 0),
Err(DocxError::Zip(_))
));
}
#[test]
fn cuerpo_orden_y_atoms_referencian_los_mismos_uuids() {
let docx = docx_de(&["a", "b", "c"]);
let imp = parse_docx(&docx, "es", "x", 0).unwrap();
for (atom, uuid) in imp.atoms.iter().zip(imp.cuerpo.orden.iter()) {
assert_eq!(&atom.id, uuid);
}
}
fn cuerpo_y_atoms(textos: &[&str]) -> (Cuerpo, HashMap<Uuid, NarrativeAtom>) {
let mut c = Cuerpo::nuevo("es", "es", Intencion::Original, 0);
let mut map = HashMap::new();
for t in textos {
let atom = NarrativeAtom::new(*t, "es");
c.agregar(atom.id, 0);
map.insert(atom.id, atom);
}
(c, map)
}
#[test]
fn write_docx_roundtrip_preserva_orden_y_texto() {
let textos = vec!["Primero.", "Segundo párrafo.", "Tercero & último."];
let (c, atoms) = cuerpo_y_atoms(&textos);
let bytes = write_docx(&c, &atoms).unwrap();
// Releemos con nuestro parser — el camino más estricto: si
// parse_docx puede con lo que escribimos, Word puede también.
let imp = parse_docx(&bytes, "es", "roundtrip.docx", 0).unwrap();
assert_eq!(imp.atoms.len(), textos.len());
for (a, esperado) in imp.atoms.iter().zip(textos.iter()) {
assert_eq!(a.content.as_str(), *esperado);
}
}
#[test]
fn write_docx_cuerpo_vacio_genera_doc_minimo_valido() {
let c = Cuerpo::nuevo("es", "es", Intencion::Original, 0);
let atoms: HashMap<Uuid, NarrativeAtom> = HashMap::new();
let bytes = write_docx(&c, &atoms).unwrap();
// Releer no debe fallar; sin atoms el resultado del parse es vacío.
let imp = parse_docx(&bytes, "es", "vacio.docx", 0).unwrap();
assert!(imp.atoms.is_empty());
}
#[test]
fn write_docx_escapa_caracteres_xml() {
let (c, atoms) = cuerpo_y_atoms(&["<x> & \"comillas\" 'simples'"]);
let bytes = write_docx(&c, &atoms).unwrap();
let imp = parse_docx(&bytes, "es", "esc.docx", 0).unwrap();
assert_eq!(imp.atoms.len(), 1);
// El parser des-escapa los entities — el roundtrip debe devolver
// el texto original.
assert_eq!(imp.atoms[0].content.as_str(), "<x> & \"comillas\" 'simples'");
}
}
@@ -0,0 +1,21 @@
[package]
name = "pluma-align-embeddings"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "pluma — alineador de cuerpos por embeddings: produce hebras (CartaHebras) calculando similitud coseno entre los párrafos de dos cuerpos, vía cualquier rimay_verbo_core::Provider (mock determinista, BGE local, Cohere remoto)."
[dependencies]
uuid = { workspace = true, features = ["serde"] }
async-trait = { workspace = true }
anyhow = { workspace = true }
pluma-core = { path = "../pluma-core" }
pluma-cuerpo = { path = "../pluma-cuerpo" }
pluma-align = { path = "../pluma-align" }
rimay-verbo-core = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
rimay-verbo-mock = { workspace = true }
@@ -0,0 +1,19 @@
# pluma-align-embeddings
> Alineamiento por embeddings para [pluma](../README.md).
Refina el alineamiento naïve de [`pluma-align`](../pluma-align/README.md) usando similaridad coseno entre embeddings de párrafo. Consulta a [`rimay-verbo`](../../rimay/README.md) o a un mock; resuelve casos donde la heurística por longitud falla (textos reordenados, sinónimos).
## API
```rust
use pluma_align_embeddings::alinear_con_embeddings;
let pares = alinear_con_embeddings(&doc_a, &doc_b, &verbo).await?;
```
## Deps
- [`pluma-align`](../pluma-align/README.md), [`pluma-core`](../pluma-core/README.md)
- [`rimay-verbo-core`](../../rimay/rimay-verbo-core/README.md)
- `serde`, `uuid`
@@ -0,0 +1,19 @@
# pluma-align-embeddings
> Embeddings-based alignment for [pluma](../README.md).
Refines the naïve [`pluma-align`](../pluma-align/README.md) using cosine similarity between paragraph embeddings. Queries [`rimay-verbo`](../../rimay/README.md) or a mock; solves cases where length heuristics fail (reordered text, synonyms, distant paraphrases).
## API
```rust
use pluma_align_embeddings::alinear_con_embeddings;
let pares = alinear_con_embeddings(&doc_a, &doc_b, &verbo).await?;
```
## Deps
- [`pluma-align`](../pluma-align/README.md), [`pluma-core`](../pluma-core/README.md)
- [`rimay-verbo-core`](../../rimay/rimay-verbo-core/README.md)
- `serde`, `uuid`
@@ -0,0 +1,372 @@
//! `pluma-align-embeddings` — alineador de cuerpos por embeddings.
//!
//! Toma dos cuerpos, calcula embeddings de cada párrafo vía un
//! `rimay_verbo_core::Provider` (mock determinista, BGE local, Cohere
//! remoto — quien implemente el rasgo) y produce una [`CartaHebras`]
//! cuya fuerza es la similitud coseno entre los embeddings. La política
//! de selección de pares la elige el caller.
//!
//! Este crate vive APARTE de `pluma-align` porque introduce async +
//! dependencia a `rimay-verbo-core`. Mantener `pluma-align` sincrónico y
//! agnóstico permite usarlo en contextos sin runtime async (UI thread,
//! tests rápidos, wawa userspace).
#![forbid(unsafe_code)]
use std::collections::HashMap;
use anyhow::{Context, Result};
use uuid::Uuid;
use pluma_align::{Alineamiento, CartaHebras, OrigenAlineamiento};
use pluma_core::NarrativeAtom;
use pluma_cuerpo::Cuerpo;
use rimay_verbo_core::Provider;
/// Política con la que se eligen pares ganadores en la matriz NxM de
/// similitudes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModoAlineacion {
/// Para cada átomo del cuerpo A, registrar UNA hebra con el átomo de
/// B de mayor similitud (siempre que pase el umbral). Un átomo de B
/// puede recibir varias hebras — caso típico cuando la traducción
/// fusiona dos párrafos del original en uno.
MejorParaCadaA,
/// Greedy mutuo: registrar el par `(a, b)` solo si `a` es el mejor
/// candidato de `b` Y `b` es el mejor de `a`. Hebras 1↔1, más limpias.
/// Reduce las hebras y deja sin emparejar lo que es genuinamente
/// ambiguo — la UI muestra los huérfanos como "sin contraparte".
MutuoMejor,
}
/// Parámetros de la alineación. Defaults pensados para que un MockProvider
/// (vectores aleatorios) NO produzca hebras espurias: umbral elevado.
#[derive(Debug, Clone, Copy)]
pub struct ParamsAlineacion {
/// Umbral mínimo de similitud coseno para registrar una hebra. Por
/// debajo, el par se descarta. Rango razonable con modelos reales:
/// 0.450.7. Para mock random, conviene >= 0.95 (efectivamente nada).
pub umbral_minimo: f32,
/// Política de selección.
pub modo: ModoAlineacion,
}
impl Default for ParamsAlineacion {
fn default() -> Self {
Self {
umbral_minimo: 0.55,
modo: ModoAlineacion::MutuoMejor,
}
}
}
/// Alinea dos cuerpos calculando embeddings de cada párrafo y emparejando
/// según `params`. La carta resultante tiene `OrigenAlineamiento::Embeddings
/// { modelo: provider.model_id().name, timestamp: ahora }`.
///
/// `ahora` es el timestamp (segundos UNIX) que el caller decide — el crate
/// no lee el reloj, para mantener el flujo testeable.
///
/// Errores propagados: del provider, si falla la inferencia. Átomos
/// referenciados que no existan en `atoms` se omiten en silencio (no
/// son alineables si no hay texto).
pub async fn alinear_por_embeddings(
cuerpo_a: &Cuerpo,
cuerpo_b: &Cuerpo,
atoms: &HashMap<Uuid, &NarrativeAtom>,
provider: &dyn Provider,
params: &ParamsAlineacion,
ahora: u64,
) -> Result<CartaHebras> {
let (ids_a, textos_a) = recolectar_textos(cuerpo_a, atoms);
let (ids_b, textos_b) = recolectar_textos(cuerpo_b, atoms);
if ids_a.is_empty() || ids_b.is_empty() {
return Ok(CartaHebras::nueva().con_par(cuerpo_a.id, cuerpo_b.id));
}
let emb_a = provider
.embed_batch(&textos_a)
.await
.context("embedding lote del cuerpo A falló")?;
let emb_b = provider
.embed_batch(&textos_b)
.await
.context("embedding lote del cuerpo B falló")?;
// Matriz NxM de similitudes coseno.
let n = emb_a.len();
let m = emb_b.len();
let mut sim = vec![0.0f32; n * m];
for i in 0..n {
for j in 0..m {
let s = emb_a[i].cosine(&emb_b[j]).context(
"similitud coseno cruzando modelos distintos — provider inconsistente",
)?;
sim[i * m + j] = s;
}
}
let modelo = provider.model_id().name.clone();
let origen = OrigenAlineamiento::Embeddings {
modelo,
timestamp: ahora,
};
let pares = match params.modo {
ModoAlineacion::MejorParaCadaA => mejores_por_fila(&sim, n, m, params.umbral_minimo),
ModoAlineacion::MutuoMejor => mutuos_mejor(&sim, n, m, params.umbral_minimo),
};
let mut carta = CartaHebras::nueva().con_par(cuerpo_a.id, cuerpo_b.id);
for (i, j, s) in pares {
carta.agregar(Alineamiento::nuevo(ids_a[i], ids_b[j], s, origen.clone()));
}
Ok(carta)
}
/// Recolecta (uuids_en_orden, textos_en_orden) de un cuerpo, omitiendo
/// átomos no presentes en el índice.
fn recolectar_textos(
cuerpo: &Cuerpo,
atoms: &HashMap<Uuid, &NarrativeAtom>,
) -> (Vec<Uuid>, Vec<String>) {
let mut ids = Vec::with_capacity(cuerpo.orden.len());
let mut textos = Vec::with_capacity(cuerpo.orden.len());
for &id in &cuerpo.orden {
if let Some(atom) = atoms.get(&id) {
ids.push(id);
textos.push(atom.content.as_str().to_string());
}
}
(ids, textos)
}
/// Para cada fila `i` de la matriz, encuentra el `j` con mayor sim y lo
/// emite si supera el umbral. Genera hasta N pares (uno por átomo de A).
fn mejores_por_fila(sim: &[f32], n: usize, m: usize, umbral: f32) -> Vec<(usize, usize, f32)> {
let mut out = Vec::with_capacity(n);
for i in 0..n {
let mut mejor = (usize::MAX, f32::NEG_INFINITY);
for j in 0..m {
let s = sim[i * m + j];
if s > mejor.1 {
mejor = (j, s);
}
}
if mejor.0 != usize::MAX && mejor.1 >= umbral {
out.push((i, mejor.0, mejor.1));
}
}
out
}
/// Greedy mutuo: i↔j solo si i es el mejor candidato de j y j es el mejor
/// de i. Garantiza 1↔1 y descarta pares ambiguos.
fn mutuos_mejor(sim: &[f32], n: usize, m: usize, umbral: f32) -> Vec<(usize, usize, f32)> {
// Mejor j por cada i.
let mut mejor_de_a = vec![usize::MAX; n];
let mut mejor_de_a_s = vec![f32::NEG_INFINITY; n];
for i in 0..n {
for j in 0..m {
let s = sim[i * m + j];
if s > mejor_de_a_s[i] {
mejor_de_a_s[i] = s;
mejor_de_a[i] = j;
}
}
}
// Mejor i por cada j.
let mut mejor_de_b = vec![usize::MAX; m];
let mut mejor_de_b_s = vec![f32::NEG_INFINITY; m];
for j in 0..m {
for i in 0..n {
let s = sim[i * m + j];
if s > mejor_de_b_s[j] {
mejor_de_b_s[j] = s;
mejor_de_b[j] = i;
}
}
}
// Emisión: solo pares mutuos sobre umbral.
let mut out = Vec::new();
for i in 0..n {
let j = mejor_de_a[i];
if j == usize::MAX {
continue;
}
if mejor_de_b[j] == i && mejor_de_a_s[i] >= umbral {
out.push((i, j, mejor_de_a_s[i]));
}
}
out
}
#[cfg(test)]
mod pruebas {
use super::*;
use pluma_align::OrigenAlineamiento;
use pluma_cuerpo::Intencion;
use rimay_verbo_mock::MockProvider;
fn cuerpo_con_atomos(
branch: &str,
intencion: Intencion,
textos: &[&str],
) -> (Cuerpo, Vec<NarrativeAtom>) {
let mut c = Cuerpo::nuevo(branch, branch, intencion, 100);
let atoms: Vec<NarrativeAtom> =
textos.iter().map(|t| NarrativeAtom::new(*t, branch)).collect();
for a in &atoms {
c.agregar(a.id, 101);
}
(c, atoms)
}
fn indice<'a>(atoms: &'a [NarrativeAtom]) -> HashMap<Uuid, &'a NarrativeAtom> {
atoms.iter().map(|a| (a.id, a)).collect()
}
#[tokio::test]
async fn textos_identicos_dan_fuerza_uno() {
let (a, atoms_a) = cuerpo_con_atomos(
"a",
Intencion::Original,
&["alpha", "beta", "gamma"],
);
let (b, atoms_b) = cuerpo_con_atomos(
"b",
Intencion::Traduccion,
&["alpha", "beta", "gamma"],
);
let mut atoms_all = atoms_a;
atoms_all.extend(atoms_b);
let idx = indice(&atoms_all);
let provider = MockProvider::default();
// Mock es determinista por texto: misma cadena → mismo vector →
// coseno = 1. Subimos el umbral para asegurar que solo registra
// pares de coseno ≈ 1.
let params = ParamsAlineacion {
umbral_minimo: 0.99,
modo: ModoAlineacion::MutuoMejor,
};
let carta = alinear_por_embeddings(&a, &b, &idx, &provider, &params, 7).await.unwrap();
assert_eq!(carta.hebras.len(), 3);
for h in &carta.hebras {
assert!(h.fuerza > 0.99);
match &h.origen {
OrigenAlineamiento::Embeddings { timestamp, .. } => assert_eq!(*timestamp, 7),
_ => panic!("origen debería ser Embeddings"),
}
}
}
#[tokio::test]
async fn textos_random_no_pasan_umbral_alto() {
let (a, atoms_a) = cuerpo_con_atomos(
"a",
Intencion::Original,
&["alfa beta gamma", "delta epsilon"],
);
let (b, atoms_b) = cuerpo_con_atomos(
"b",
Intencion::Traduccion,
&["lorem ipsum dolor", "consectetur adipiscing"],
);
let mut atoms_all = atoms_a;
atoms_all.extend(atoms_b);
let idx = indice(&atoms_all);
let provider = MockProvider::default();
// Umbral alto: el mock (vectores aleatorios entre textos no
// idénticos) no debe producir hebras.
let params = ParamsAlineacion {
umbral_minimo: 0.95,
modo: ModoAlineacion::MutuoMejor,
};
let carta = alinear_por_embeddings(&a, &b, &idx, &provider, &params, 1).await.unwrap();
assert!(carta.hebras.is_empty());
}
#[tokio::test]
async fn cuerpo_vacio_devuelve_carta_vacia_sin_llamar_al_provider() {
// Si uno de los cuerpos está vacío, no hay nada que embeddear.
let (a, atoms_a) = cuerpo_con_atomos("a", Intencion::Original, &["solo a"]);
let b = Cuerpo::nuevo("b", "vacio", Intencion::Traduccion, 0);
let idx = indice(&atoms_a);
let provider = MockProvider::default();
let carta = alinear_por_embeddings(
&a,
&b,
&idx,
&provider,
&ParamsAlineacion::default(),
1,
)
.await
.unwrap();
assert!(carta.hebras.is_empty());
assert_eq!(carta.cuerpo_a, Some(a.id));
assert_eq!(carta.cuerpo_b, Some(b.id));
}
#[tokio::test]
async fn mejor_para_cada_a_puede_apuntar_dos_a_uno() {
// Cuerpo A con 2 textos idénticos a UN texto de B.
let (a, atoms_a) = cuerpo_con_atomos(
"a",
Intencion::Original,
&["compartido", "compartido"],
);
let (b, atoms_b) = cuerpo_con_atomos(
"b",
Intencion::Traduccion,
&["compartido", "diferente"],
);
let mut atoms_all = atoms_a;
atoms_all.extend(atoms_b);
let idx = indice(&atoms_all);
let provider = MockProvider::default();
let params = ParamsAlineacion {
umbral_minimo: 0.99,
modo: ModoAlineacion::MejorParaCadaA,
};
let carta = alinear_por_embeddings(&a, &b, &idx, &provider, &params, 1).await.unwrap();
// Las dos filas de A apuntan al mismo j=0 (texto "compartido" de B).
assert_eq!(carta.hebras.len(), 2);
let target = carta.hebras[0].atom_b;
assert_eq!(carta.hebras[1].atom_b, target);
}
#[tokio::test]
async fn mutuo_mejor_descarta_pares_ambiguos() {
// Dos textos de A idénticos a UN texto de B: con MutuoMejor, solo
// UN par mutuo gana (B prefiere su mejor único; la otra fila de A
// queda huérfana).
let (a, atoms_a) = cuerpo_con_atomos(
"a",
Intencion::Original,
&["compartido", "compartido"],
);
let (b, atoms_b) = cuerpo_con_atomos(
"b",
Intencion::Traduccion,
&["compartido", "diferente"],
);
let mut atoms_all = atoms_a;
atoms_all.extend(atoms_b);
let idx = indice(&atoms_all);
let provider = MockProvider::default();
let params = ParamsAlineacion {
umbral_minimo: 0.99,
modo: ModoAlineacion::MutuoMejor,
};
let carta = alinear_por_embeddings(&a, &b, &idx, &provider, &params, 1).await.unwrap();
// Solo una hebra: el primer "compartido" de A con "compartido" de B
// (los dos j=0 candidatos empatan; el algoritmo se queda con el primer i).
assert_eq!(carta.hebras.len(), 1);
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "pluma-align"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "pluma — alineamientos entre cuerpos paralelos: las hebras que la UI pinta como barras de color entre columnas."
[dependencies]
uuid = { workspace = true, features = ["serde"] }
serde = { workspace = true }
postcard = { workspace = true }
pluma-cuerpo = { path = "../pluma-cuerpo" }
+18
View File
@@ -0,0 +1,18 @@
# pluma-align
> Alineamiento textotexto para [pluma](../README.md).
Dados dos documentos (ej: original + traducción, draft + revisión), produce un mapping átomo–átomo usando heurística greedy por longitud + LCS sobre la secuencia. Resultado: `Vec<Par>` donde cada par puede ser `(a, b)`, `(a, ∅)`, `(∅, b)`. Base para diff visual y para apoyar las traducciones de [pluma-transform-llm](../pluma-transform-llm/README.md).
## API
```rust
use pluma_align::alinear;
let pares = alinear(&doc_a, &doc_b);
```
## Deps
- [`pluma-core`](../pluma-core/README.md), [`pluma-cuerpo`](../pluma-cuerpo/README.md)
- `serde`, `uuid`
+18
View File
@@ -0,0 +1,18 @@
# pluma-align
> Text-text alignment for [pluma](../README.md).
Given two documents (e.g., original + translation, draft + revision), produces atom-atom mapping using length-greedy heuristic + LCS over the sequence. Result: `Vec<Par>` where each pair can be `(a, b)`, `(a, ∅)`, `(∅, b)`. Foundation for visual diff and for backing the translations of [pluma-transform-llm](../pluma-transform-llm/README.md).
## API
```rust
use pluma_align::alinear;
let pares = alinear(&doc_a, &doc_b);
```
## Deps
- [`pluma-core`](../pluma-core/README.md), [`pluma-cuerpo`](../pluma-cuerpo/README.md)
- `serde`, `uuid`
+385
View File
@@ -0,0 +1,385 @@
//! `pluma-align` — los alineamientos entre cuerpos paralelos.
//!
//! Un *alineamiento* enlaza un átomo de un cuerpo madre con un átomo de un
//! cuerpo hija (o de cualquier cuerpo del haz: la noción no impone dirección).
//! Llevan asociados una *fuerza* (qué tan correspondientes son los párrafos)
//! y un *origen* (cómo se calculó: a mano, por embeddings o como subproducto
//! de una transformación). La UI los pinta como *hebras*: barras de color
//! verticales entre columnas, con saturación = fuerza y tipo de trazo = origen.
//!
//! Un átomo puede tener múltiples alineamientos:
//! - 1↔1 — caso traducción típica;
//! - 1↔N o N↔1 — un párrafo del original se traduce en dos del destino;
//! - 0↔1 — un párrafo del destino sin contraparte en el original
//! (texto añadido por el traductor);
//! - 1↔0 — un párrafo del original que el destino eliminó.
//!
//! El crate no decide la política — solo modela. Las hebras 0↔X se representan
//! como *ausencia* de alineamientos para ese átomo, no como un alineamiento
//! degenerado: que no tenga hebra ES la información.
#![forbid(unsafe_code)]
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
use pluma_cuerpo::Cuerpo;
/// Cómo se produjo un alineamiento. La UI lo usa para distinguir hebras:
/// continuas (Derivado), punteadas (Embeddings de baja confianza),
/// trazos manuales con marca.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum OrigenAlineamiento {
/// Lo trazó un humano. La autoría queda anotada para auditoría.
Manual {
autor: String,
timestamp: u64,
},
/// Lo dedujo un calculador de embeddings (rimay/iniy). `modelo` identifica
/// la versión del calculador — si cambia, los alineamientos viejos pueden
/// requerir recálculo.
Embeddings {
modelo: String,
timestamp: u64,
},
/// Cae como subproducto de una transformación de `pluma-transform`. Cuando
/// `Identidad` deriva un cuerpo hija desde una madre, cada átomo
/// arrastra su contraparte 1:1 — esos son los alineamientos `Derivado`.
Derivado {
/// `Uuid` de la `Transformacion` (en `pluma-transform`) que lo emitió.
transformacion: Uuid,
timestamp: u64,
},
}
impl OrigenAlineamiento {
/// El instante en que este origen fue establecido. Útil para invalidar
/// alineamientos viejos en bloque ("todos los que tengan modelo X
/// anterior a hace una semana").
pub fn timestamp(&self) -> u64 {
match self {
OrigenAlineamiento::Manual { timestamp, .. } => *timestamp,
OrigenAlineamiento::Embeddings { timestamp, .. } => *timestamp,
OrigenAlineamiento::Derivado { timestamp, .. } => *timestamp,
}
}
}
/// Un alineamiento entre dos átomos de dos cuerpos. La dirección
/// (`atom_a → atom_b`) no implica jerarquía — el grafo de cuerpos decide
/// quién deriva de quién; aquí solo se anota el par. La UI pinta una sola
/// hebra por par independientemente del orden.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Alineamiento {
/// Identidad estable del alineamiento — sobrevive a recálculos del par
/// (si el caller mantiene el mismo `Uuid`).
pub id: Uuid,
/// Átomo del cuerpo de la izquierda (el primer término del par).
pub atom_a: Uuid,
/// Átomo del cuerpo de la derecha.
pub atom_b: Uuid,
/// `[0.0, 1.0]` — qué tan correspondientes son los dos párrafos. `1.0` es
/// "el mismo párrafo" (o derivación directa); `0.0` no se almacena
/// (mejor no almacenar nada). La UI mapea esto a saturación de color.
pub fuerza: f32,
/// Cómo se calculó este alineamiento.
pub origen: OrigenAlineamiento,
/// `true` si el alineamiento sigue siendo válido frente al estado actual
/// de los cuerpos; `false` si el algoritmo lo marcó como stale tras una
/// edición. La UI lo usa para desaturar la hebra.
pub fresco: bool,
}
impl Alineamiento {
/// Crea un alineamiento nuevo con id aleatorio. La fuerza se sujeta al
/// intervalo `[0, 1]` — un input fuera de rango se acepta como saturado.
pub fn nuevo(
atom_a: Uuid,
atom_b: Uuid,
fuerza: f32,
origen: OrigenAlineamiento,
) -> Self {
Self {
id: Uuid::new_v4(),
atom_a,
atom_b,
fuerza: fuerza.clamp(0.0, 1.0),
origen,
fresco: true,
}
}
/// `true` si este alineamiento toca el átomo dado, sea por `atom_a` o por
/// `atom_b`. Útil para listar todas las hebras incidentes a un párrafo.
pub fn toca(&self, atom_id: Uuid) -> bool {
self.atom_a == atom_id || self.atom_b == atom_id
}
/// Dado uno de los dos átomos del par, devuelve el otro. `None` si el
/// átomo no participa.
pub fn contraparte(&self, atom_id: Uuid) -> Option<Uuid> {
if self.atom_a == atom_id {
Some(self.atom_b)
} else if self.atom_b == atom_id {
Some(self.atom_a)
} else {
None
}
}
}
/// Una *carta de hebras* entre dos cuerpos: la colección de alineamientos
/// que viven en ese par. Es la unidad que la UI consume: pasa por las hebras
/// en orden y pinta una barra por cada una.
///
/// `cuerpo_a` y `cuerpo_b` se anotan para detectar al vuelo que la carta
/// corresponde al par que la UI espera (evita confundir hebras de pares
/// distintos). El crate no fuerza qué par es a/b vs b/a — el caller decide
/// y se mantiene consistente.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct CartaHebras {
pub cuerpo_a: Option<Uuid>,
pub cuerpo_b: Option<Uuid>,
pub hebras: Vec<Alineamiento>,
}
impl CartaHebras {
/// Una carta vacía, sin par anotado.
pub fn nueva() -> Self {
Self::default()
}
/// Anota el par al que pertenece esta carta. Útil al ingestarla.
pub fn con_par(mut self, cuerpo_a: Uuid, cuerpo_b: Uuid) -> Self {
self.cuerpo_a = Some(cuerpo_a);
self.cuerpo_b = Some(cuerpo_b);
self
}
/// Agrega una hebra. La carta no deduplica; eso es del caller (las hebras
/// son por id, no por pares — un par puede tener varias hebras de orígenes
/// distintos coexistiendo, p. ej. una Manual y una Embeddings de respaldo).
pub fn agregar(&mut self, hebra: Alineamiento) {
self.hebras.push(hebra);
}
/// Itera las hebras que tocan el átomo dado.
pub fn hebras_de(&self, atom_id: Uuid) -> impl Iterator<Item = &Alineamiento> {
self.hebras.iter().filter(move |h| h.toca(atom_id))
}
/// Marca como stale todas las hebras cuyo origen sea anterior a
/// `umbral_ts` — útil tras una edición sustancial de un cuerpo. Devuelve
/// cuántas se marcaron.
pub fn marcar_stale_anteriores_a(&mut self, umbral_ts: u64) -> usize {
let mut n = 0;
for h in self.hebras.iter_mut() {
if h.fresco && h.origen.timestamp() < umbral_ts {
h.fresco = false;
n += 1;
}
}
n
}
/// Serializa la carta a postcard.
pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
postcard::to_allocvec(self).map_err(|_| "carta :: serializacion fallida")
}
/// Reconstruye la carta desde postcard.
pub fn deserializar(bytes: &[u8]) -> Result<CartaHebras, &'static str> {
postcard::from_bytes::<CartaHebras>(bytes)
.map_err(|_| "carta :: deserializacion fallida")
}
}
// =============================================================================
// Alineadores — estrategias concretas para producir CartaHebras
// =============================================================================
/// Alinea dos cuerpos posición-a-posición, hasta agotar el más corto. Es el
/// alineador trivial: útil como baseline, como salida natural de una
/// transformación `Identidad`, o cuando el usuario importa textos que sabe
/// estructurados con la misma cantidad de párrafos. La `fuerza` es siempre
/// 1.0 — el alineador no juzga similitud semántica, asume que la posición
/// es verdad. Los párrafos sobrantes del cuerpo más largo quedan sin hebra
/// (no se inventan alineamientos huérfanos).
///
/// `origen` se aplica idéntico a cada hebra producida.
pub fn alinear_uno_a_uno(
cuerpo_a: &Cuerpo,
cuerpo_b: &Cuerpo,
origen: OrigenAlineamiento,
) -> CartaHebras {
let mut carta = CartaHebras::nueva().con_par(cuerpo_a.id, cuerpo_b.id);
for (a, b) in cuerpo_a.orden.iter().zip(cuerpo_b.orden.iter()) {
carta.agregar(Alineamiento::nuevo(*a, *b, 1.0, origen.clone()));
}
carta
}
/// Alinea dos cuerpos a partir de una *tabla* explícita de pares
/// `(atom_a, atom_b, fuerza)`. Es el camino para alineamientos manuales —
/// el editor recibe los pares del usuario y los confirma de un golpe—.
/// Los pares cuyos átomos no aparezcan en los respectivos cuerpos se
/// descartan en silencio: alinear lo que no existe no tiene sentido.
pub fn alinear_explicito(
cuerpo_a: &Cuerpo,
cuerpo_b: &Cuerpo,
pares: &[(Uuid, Uuid, f32)],
origen: OrigenAlineamiento,
) -> CartaHebras {
// Indexar los cuerpos para chequeo de pertenencia O(1).
let en_a: HashMap<Uuid, ()> = cuerpo_a.orden.iter().map(|&id| (id, ())).collect();
let en_b: HashMap<Uuid, ()> = cuerpo_b.orden.iter().map(|&id| (id, ())).collect();
let mut carta = CartaHebras::nueva().con_par(cuerpo_a.id, cuerpo_b.id);
for &(a, b, fuerza) in pares {
if en_a.contains_key(&a) && en_b.contains_key(&b) {
carta.agregar(Alineamiento::nuevo(a, b, fuerza, origen.clone()));
}
}
carta
}
#[cfg(test)]
mod pruebas {
use super::*;
use pluma_cuerpo::Intencion;
fn cuerpos_paralelos(n: usize) -> (Cuerpo, Cuerpo, Vec<Uuid>, Vec<Uuid>) {
let mut a = Cuerpo::nuevo("es", "es", Intencion::Original, 100);
let mut b = Cuerpo::nuevo("qu", "qu", Intencion::Traduccion, 100);
// En este test no validamos consistencia: nos importa la alineación.
let ids_a: Vec<Uuid> = (0..n).map(|_| Uuid::new_v4()).collect();
let ids_b: Vec<Uuid> = (0..n).map(|_| Uuid::new_v4()).collect();
for &id in &ids_a { a.agregar(id, 101); }
for &id in &ids_b { b.agregar(id, 101); }
(a, b, ids_a, ids_b)
}
#[test]
fn nuevo_alineamiento_clampea_fuerza() {
let h_alto = Alineamiento::nuevo(
Uuid::new_v4(), Uuid::new_v4(), 5.0,
OrigenAlineamiento::Manual { autor: "yo".into(), timestamp: 1 },
);
let h_bajo = Alineamiento::nuevo(
Uuid::new_v4(), Uuid::new_v4(), -0.3,
OrigenAlineamiento::Manual { autor: "yo".into(), timestamp: 1 },
);
assert_eq!(h_alto.fuerza, 1.0);
assert_eq!(h_bajo.fuerza, 0.0);
assert!(h_alto.fresco);
}
#[test]
fn contraparte_y_toca_funcionan_en_ambos_sentidos() {
let a = Uuid::new_v4();
let b = Uuid::new_v4();
let h = Alineamiento::nuevo(
a, b, 0.8,
OrigenAlineamiento::Manual { autor: "x".into(), timestamp: 0 },
);
assert!(h.toca(a));
assert!(h.toca(b));
assert!(!h.toca(Uuid::new_v4()));
assert_eq!(h.contraparte(a), Some(b));
assert_eq!(h.contraparte(b), Some(a));
assert_eq!(h.contraparte(Uuid::new_v4()), None);
}
#[test]
fn alinear_uno_a_uno_empareja_hasta_el_mas_corto() {
let (a, b, ids_a, ids_b) = cuerpos_paralelos(3);
// Acortar b a 2 párrafos.
let mut b = b;
b.remover(ids_b[2], 102);
let carta = alinear_uno_a_uno(
&a, &b,
OrigenAlineamiento::Manual { autor: "x".into(), timestamp: 200 },
);
assert_eq!(carta.hebras.len(), 2);
assert_eq!(carta.cuerpo_a, Some(a.id));
assert_eq!(carta.cuerpo_b, Some(b.id));
// Los pares son (ids_a[0], ids_b[0]) y (ids_a[1], ids_b[1]).
assert_eq!(carta.hebras[0].atom_a, ids_a[0]);
assert_eq!(carta.hebras[0].atom_b, ids_b[0]);
assert_eq!(carta.hebras[1].atom_a, ids_a[1]);
assert_eq!(carta.hebras[1].atom_b, ids_b[1]);
// Fuerza al máximo — es alineamiento posicional, no semántico.
assert!(carta.hebras.iter().all(|h| h.fuerza == 1.0));
// ids_a[2] queda huérfano: ninguna hebra lo toca.
assert!(carta.hebras_de(ids_a[2]).next().is_none());
}
#[test]
fn alinear_explicito_descarta_pares_ajenos() {
let (a, b, ids_a, ids_b) = cuerpos_paralelos(2);
let huerfano = Uuid::new_v4();
let pares = vec![
(ids_a[0], ids_b[0], 0.95),
(ids_a[1], ids_b[1], 0.80),
(huerfano, ids_b[0], 0.99), // este se descarta
(ids_a[0], huerfano, 0.50), // y este también
];
let carta = alinear_explicito(
&a, &b, &pares,
OrigenAlineamiento::Manual { autor: "ana".into(), timestamp: 300 },
);
assert_eq!(carta.hebras.len(), 2);
assert!((carta.hebras[0].fuerza - 0.95).abs() < 1e-6);
assert!((carta.hebras[1].fuerza - 0.80).abs() < 1e-6);
}
#[test]
fn hebras_de_filtra_por_atomo_en_ambos_lados() {
let (a, b, ids_a, ids_b) = cuerpos_paralelos(3);
let carta = alinear_uno_a_uno(
&a, &b,
OrigenAlineamiento::Manual { autor: "x".into(), timestamp: 1 },
);
// Cada átomo de a participa en exactamente UNA hebra.
for &id in &ids_a {
assert_eq!(carta.hebras_de(id).count(), 1);
}
for &id in &ids_b {
assert_eq!(carta.hebras_de(id).count(), 1);
}
}
#[test]
fn marcar_stale_solo_toca_anteriores_al_umbral() {
let (a, b, ids_a, ids_b) = cuerpos_paralelos(2);
let mut carta = CartaHebras::nueva().con_par(a.id, b.id);
carta.agregar(Alineamiento::nuevo(
ids_a[0], ids_b[0], 1.0,
OrigenAlineamiento::Embeddings { modelo: "m1".into(), timestamp: 100 },
));
carta.agregar(Alineamiento::nuevo(
ids_a[1], ids_b[1], 1.0,
OrigenAlineamiento::Embeddings { modelo: "m1".into(), timestamp: 500 },
));
let n = carta.marcar_stale_anteriores_a(300);
assert_eq!(n, 1);
assert!(!carta.hebras[0].fresco);
assert!(carta.hebras[1].fresco);
// Llamarlo de nuevo no vuelve a contar el ya stale.
let n2 = carta.marcar_stale_anteriores_a(300);
assert_eq!(n2, 0);
}
#[test]
fn roundtrip_postcard_de_carta() {
let (a, b, _, _) = cuerpos_paralelos(2);
let carta = alinear_uno_a_uno(
&a, &b,
OrigenAlineamiento::Derivado { transformacion: Uuid::new_v4(), timestamp: 7 },
);
let bytes = carta.serializar().unwrap();
let recuperada = CartaHebras::deserializar(&bytes).unwrap();
assert_eq!(recuperada, carta);
}
}
+46
View File
@@ -0,0 +1,46 @@
[package]
name = "pluma-app"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "pluma-app — editor de escritura multilienzo: panel de documentos del sled, cuerpo_ide central editable, panel LLM con cycler de backends y derivación de hijas."
[[bin]]
name = "pluma-app"
path = "src/main.rs"
[dependencies]
pluma-core = { path = "../pluma-core" }
pluma-cuerpo = { path = "../pluma-cuerpo" }
pluma-md = { path = "../pluma-md" }
foreign-docx = { path = "../foreign-docx" }
pluma-align = { path = "../pluma-align" }
pluma-editor-cuerpo = { path = "../pluma-editor-cuerpo" }
pluma-editor-llimphi = { path = "../pluma-editor-llimphi" }
pluma-store = { path = "../pluma-store" }
pluma-transform = { path = "../pluma-transform" }
pluma-transform-llm = { path = "../pluma-transform-llm" }
pluma-llm = { path = "../pluma-llm" }
pluma-llm-core = { path = "../pluma-llm-core" }
pluma-llm-mock = { path = "../pluma-llm-mock" }
llimphi-ui = { workspace = true }
# Rail hospedado: pluma puede prestar sus secciones (Documentos/LLM/Buscar/Diff)
# al rail de pata y colapsar sus columnas (PLUMA_DELEGATE_SIDEBAR).
pata-host = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-button = { workspace = true }
llimphi-widget-splitter = { workspace = true }
llimphi-widget-list = { workspace = true }
llimphi-widget-text-editor = { workspace = true }
llimphi-widget-text-input = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-edit-menu = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
llimphi-motion = { workspace = true }
app-bus = { workspace = true }
tokio = { workspace = true }
uuid = { workspace = true }
arboard = { workspace = true }
+16
View File
@@ -0,0 +1,16 @@
# pluma-app
> Binario del editor de [pluma](../README.md).
Wrapper mínimo que arranca [`pluma-editor-llimphi`](../pluma-editor-llimphi/README.md) con la `Config` cargada desde `wawa-config`. Sin lógica propia — todo vive en los crates de soporte.
## Uso
```sh
cargo run --release -p pluma-app
```
## Deps
- [`pluma-editor-llimphi`](../pluma-editor-llimphi/README.md)
- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/)
+16
View File
@@ -0,0 +1,16 @@
# pluma-app
> Editor binary of [pluma](../README.md).
Minimal wrapper that starts [`pluma-editor-llimphi`](../pluma-editor-llimphi/README.md) with the `Config` loaded from `wawa-config`. No logic of its own — everything lives in the supporting crates.
## Usage
```sh
cargo run --release -p pluma-app
```
## Deps
- [`pluma-editor-llimphi`](../pluma-editor-llimphi/README.md)
- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/)
@@ -0,0 +1,30 @@
//! Puente al portapapeles del sistema vía `arboard`.
use llimphi_widget_text_editor::Clipboard;
/// Wrapper sobre `arboard::Clipboard`. Si el sistema no expone uno
/// (headless CI, sin Wayland/X), `inner` queda en `None` y los métodos
/// son no-op silenciosos — exactamente la semántica documentada del
/// trait [`Clipboard`].
pub(crate) struct ArboardClipboard {
inner: Option<arboard::Clipboard>,
}
impl ArboardClipboard {
pub(crate) fn new() -> Self {
Self {
inner: arboard::Clipboard::new().ok(),
}
}
}
impl Clipboard for ArboardClipboard {
fn get(&mut self) -> Option<String> {
self.inner.as_mut()?.get_text().ok()
}
fn set(&mut self, s: &str) {
if let Some(c) = self.inner.as_mut() {
let _ = c.set_text(s.to_owned());
}
}
}
+149
View File
@@ -0,0 +1,149 @@
//! Inicialización del modelo: apertura del sled, carga de cuerpos/atoms/
//! cartas/transformaciones, sembrado del primer documento y autodetección
//! del backend LLM.
use std::collections::HashMap;
use std::sync::Arc;
use pluma_align::CartaHebras;
use pluma_core::NarrativeAtom;
use pluma_cuerpo::{Cuerpo, Intencion};
use pluma_editor_llimphi::cuerpo_ide::CuerpoIde;
use pluma_llm::{build_client, BackendKind, LlmConfig};
use pluma_llm_core::ChatClient;
use pluma_store::PlumaStore;
use pluma_transform::Transformacion;
use llimphi_widget_text_input::TextInputState;
use uuid::Uuid;
use crate::clipboard::ArboardClipboard;
use crate::model::{Model, BACKENDS};
use crate::util::{ahora_unix, ruta_sled};
pub(crate) fn init_modelo() -> Model {
let path = ruta_sled();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let store = match PlumaStore::open(&path) {
Ok(s) => Arc::new(s),
Err(e) => panic!("pluma-app :: PlumaStore::open({path:?}) falló: {e:?}"),
};
let mut atoms: HashMap<Uuid, NarrativeAtom> = store
.iter_atoms()
.filter_map(|r| r.ok())
.map(|a| (a.id, a))
.collect();
let mut cuerpos: Vec<Cuerpo> = store.iter_cuerpos().filter_map(|r| r.ok()).collect();
let transformaciones: Vec<Transformacion> = store
.iter_transformaciones()
.filter_map(|r| r.ok())
.collect();
let cartas: Vec<CartaHebras> = store.iter_cartas().filter_map(|r| r.ok()).collect();
// Si el sled está vacío, sembrar un documento Original para que la
// ventana no esté muerta al primer arranque.
if cuerpos.is_empty() {
let ahora = ahora_unix();
let atom = NarrativeAtom::new("Empieza a escribir aquí…", "es");
let mut cuerpo = Cuerpo::nuevo("es", "documento sin título", Intencion::Original, ahora);
cuerpo.agregar(atom.id, ahora);
let _ = store.put_atom(&atom);
let _ = store.put_cuerpo(&cuerpo);
let _ = store.flush();
atoms.insert(atom.id, atom);
cuerpos.push(cuerpo);
}
// Cuerpo activo = primer Original; si no hay, el primero a secas.
let activo = cuerpos
.iter()
.find(|c| !c.metadatos.intencion.es_derivada())
.map(|c| c.id)
.or_else(|| cuerpos.first().map(|c| c.id));
let ide = match activo {
Some(id) => {
let cuerpo = cuerpos.iter().find(|c| c.id == id).cloned().unwrap();
let idx: HashMap<Uuid, &NarrativeAtom> =
atoms.iter().map(|(k, v)| (*k, v)).collect();
CuerpoIde::from_cuerpo(&cuerpo, &idx)
}
None => CuerpoIde::nuevo_vacio(),
};
let (chat, backend_idx) = inicializar_backend();
Model {
store,
cuerpos,
atoms,
cartas,
transformaciones,
activo,
ide,
clipboard: ArboardClipboard::new(),
drag_accum: (0.0, 0.0),
chat,
backend_idx,
en_curso: false,
ultimo_error: None,
ultimo_status: "listo".to_string(),
path_input: TextInputState::new(),
path_focused: false,
find_input: TextInputState::new(),
find_visible: false,
find_matches: Vec::new(),
find_idx: 0,
diff_visible: false,
side_izq_w: 280.0,
side_der_w: 340.0,
menu_open: None,
menu_active: usize::MAX,
menu_anim: llimphi_motion::Tween::idle(1.0),
edit_menu: None,
edit_active: usize::MAX,
edit_anim: llimphi_motion::Tween::idle(1.0),
// Rail hospedado: leemos el opt-in acá; el HostClient se conecta en `init`
// (necesita el Handle). Las columnas arrancan visibles aunque se delegue —
// el usuario las colapsa desde el rail de pata.
delegated: std::env::var_os("PLUMA_DELEGATE_SIDEBAR").is_some(),
side_izq_visible: true,
side_der_visible: true,
_host: None,
}
}
/// Intenta el primer backend con env key configurada; si ninguno
/// matchea, cae a Mock. Devuelve también el índice en `BACKENDS`.
fn inicializar_backend() -> (Arc<dyn ChatClient>, usize) {
let preferencias: &[(BackendKind, &[&str])] = &[
(BackendKind::Anthropic, &["ANTHROPIC_API_KEY"]),
(BackendKind::Gemini, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]),
(BackendKind::DeepSeek, &["DEEPSEEK_API_KEY"]),
(BackendKind::Cohere, &["COHERE_API_KEY"]),
];
for (kind, envs) in preferencias {
if envs.iter().any(|e| std::env::var(e).is_ok()) {
if let Ok(c) = build_client(&LlmConfig {
kind: *kind,
..Default::default()
}) {
let idx = BACKENDS.iter().position(|k| k == kind).unwrap_or(0);
return (c, idx);
}
}
}
// Fallback: Mock (siempre construye).
let c = build_client(&LlmConfig {
kind: BackendKind::Mock,
..Default::default()
})
.expect("Mock backend no debería fallar");
let idx = BACKENDS
.iter()
.position(|k| *k == BackendKind::Mock)
.unwrap_or(0);
(c, idx)
}
+200
View File
@@ -0,0 +1,200 @@
//! `pluma-app` — editor de escritura multilienzo.
//!
//! Layout en tres columnas (splitters draggables):
//!
//! ```text
//! ┌─────────────┬───────────────────────────┬───────────────┐
//! │ documentos │ cuerpo_ide editable │ panel LLM │
//! │ (lista de │ (cuerpo activo) │ - backend ▼ │
//! │ cuerpos │ │ - botones LLM │
//! │ del sled) │ │ - lista hijas │
//! └─────────────┴───────────────────────────┴───────────────┘
//! ```
//!
//! Persistencia automática en `~/.cache/gioser/pluma-app/pluma.sled`
//! vía [`PlumaStore`]. Al primer arranque siembra un documento vacío
//! para que la ventana no esté muerta. Tras ese punto, todo doc/atom/
//! transformación/carta vive en sled.
//!
//! Atajos:
//! - `Ctrl+S` guarda el cuerpo activo (diff buffer → atoms → sled).
//! - `Ctrl+N` crea un documento Original nuevo.
//! - `Ctrl+J` togglea la junction anterior al caret (zonas).
//! - `Ctrl+Shift+]/[` saltan entre zonas.
//!
//! Botones del panel derecho dispara una transformación LLM sobre el
//! cuerpo activo completo (Traducir → qu/en, Tono formal, Resumir 30p).
//! La hija aparece como un cuerpo nuevo en la lista izquierda — click
//! la activa.
//!
//! El crate está partido en módulos: `model` (Model+Msg+consts),
//! `clipboard` (arboard), `util` (paths/etiquetas/reloj), `init`
//! (apertura del sled + backend), `update` (lógica + LLM) y `view`
//! (las tres columnas). Acá queda el `impl App` y el ruteo de teclado.
mod clipboard;
mod init;
mod model;
mod update;
mod util;
mod view;
use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey};
use llimphi_ui::View;
use crate::init::init_modelo;
use crate::model::{Model, Msg};
use crate::update::actualizar;
use crate::view::{vista, vista_overlay};
fn main() {
llimphi_ui::run::<Pluma>();
}
struct Pluma;
impl App for Pluma {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"pluma · editor multilienzo"
}
/// `app_id` Wayland: pata lo usa para correlacionar foco ↔ dientes hospedados.
fn app_id() -> Option<&'static str> {
Some("gioser.pluma")
}
fn initial_size() -> (u32, u32) {
(1600, 900)
}
fn init(handle: &Handle<Msg>) -> Model {
let mut m = init_modelo();
// Rail hospedado: si delega, publica sus secciones como dientes en pata.
if m.delegated {
let teeth = vec![
pata_host::HostedTooth::new(0, "folder", "Documentos"),
pata_host::HostedTooth::new(1, "tools", "LLM"),
pata_host::HostedTooth::new(2, "files", "Buscar"),
pata_host::HostedTooth::new(3, "tools", "Diff"),
];
let h = handle.clone();
m._host = pata_host::HostClient::connect("gioser.pluma", "Pluma", teeth, move |id| {
h.dispatch(Msg::HostActivate(id))
});
}
m
}
fn update(model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
actualizar(model, msg, handle)
}
fn on_key(model: &Self::Model, event: &KeyEvent) -> Option<Self::Msg> {
if event.state != KeyState::Pressed {
return None;
}
// Menús abiertos: las flechas navegan y tienen prioridad sobre todo.
if let Some(mi) = model.menu_open {
let n = crate::update::menu_principal(model).menus.len().max(1);
return match &event.key {
Key::Named(NamedKey::Escape) => Some(Msg::CloseMenus),
Key::Named(NamedKey::ArrowLeft) => Some(Msg::MenuOpen(Some((mi + n - 1) % n))),
Key::Named(NamedKey::ArrowRight) => Some(Msg::MenuOpen(Some((mi + 1) % n))),
Key::Named(NamedKey::ArrowDown) => Some(Msg::MenuNav(1)),
Key::Named(NamedKey::ArrowUp) => Some(Msg::MenuNav(-1)),
Key::Named(NamedKey::Enter) => Some(Msg::MenuActivate),
_ => None,
};
}
if model.edit_menu.is_some() {
return match &event.key {
Key::Named(NamedKey::Escape) => Some(Msg::CloseMenus),
Key::Named(NamedKey::ArrowDown) => Some(Msg::EditNav(1)),
Key::Named(NamedKey::ArrowUp) => Some(Msg::EditNav(-1)),
Key::Named(NamedKey::Enter) => Some(Msg::EditActivate),
_ => None,
};
}
// Si el input de ruta tiene foco, las teclas van ahí — incluso
// Ctrl/Shift combos. Esc lo apaga; cualquier otra cosa edita.
if model.path_focused {
if matches!(&event.key, Key::Named(NamedKey::Escape)) {
return Some(Msg::DefocusPath);
}
return Some(Msg::PathInputKey(event.clone()));
}
let ctrl = event.modifiers.ctrl || event.modifiers.meta;
let shift = event.modifiers.shift;
let alt = event.modifiers.alt;
// Alt+Flecha: mover el átomo bajo el caret. Lo capturamos antes
// que el editor para que no procese el evento como navegación.
if alt && !ctrl {
if matches!(&event.key, Key::Named(NamedKey::ArrowUp)) {
return Some(Msg::MoverAtomArriba);
}
if matches!(&event.key, Key::Named(NamedKey::ArrowDown)) {
return Some(Msg::MoverAtomAbajo);
}
}
// Find overlay capturado: Esc cierra, Enter/Shift+Enter ciclan
// matches, todo lo demás edita el query.
if model.find_visible {
if matches!(&event.key, Key::Named(NamedKey::Escape)) {
return Some(Msg::FindClose);
}
if matches!(&event.key, Key::Named(NamedKey::Enter)) {
return Some(if shift {
Msg::FindAnterior
} else {
Msg::FindSiguiente
});
}
// Ctrl+F otra vez cierra (atajo simétrico a abrir).
if ctrl {
if let Key::Character(s) = &event.key {
if s.eq_ignore_ascii_case("f") {
return Some(Msg::FindClose);
}
}
}
return Some(Msg::FindKey(event.clone()));
}
if ctrl {
if let Key::Character(s) = &event.key {
if s.eq_ignore_ascii_case("s") {
return Some(Msg::Guardar);
}
if s.eq_ignore_ascii_case("n") {
return Some(Msg::NuevoDoc);
}
if s.eq_ignore_ascii_case("f") {
return Some(Msg::FindToggle);
}
if s.eq_ignore_ascii_case("d") {
return Some(Msg::DiffToggle);
}
if shift && (s == "}" || s == "]") {
return Some(Msg::ZonaSiguiente);
}
if shift && (s == "{" || s == "[") {
return Some(Msg::ZonaAnterior);
}
if s.eq_ignore_ascii_case("j") {
return Some(Msg::ToglearFusion);
}
}
}
Some(Msg::EditorKey(event.clone()))
}
fn view(model: &Model) -> View<Msg> {
vista(model)
}
fn view_overlay(model: &Model) -> Option<View<Msg>> {
vista_overlay(model)
}
}
+168
View File
@@ -0,0 +1,168 @@
//! Modelo de la app y mensajes del bucle Elm.
use std::collections::HashMap;
use std::sync::Arc;
use llimphi_ui::KeyEvent;
use llimphi_widget_text_editor::{EditorMetrics, PointerEvent};
use llimphi_widget_text_input::TextInputState;
use pluma_align::CartaHebras;
use pluma_core::NarrativeAtom;
use pluma_cuerpo::Cuerpo;
use pluma_editor_llimphi::cuerpo_ide::CuerpoIde;
use pluma_llm::BackendKind;
use pluma_llm_core::ChatClient;
use pluma_store::PlumaStore;
use pluma_transform::Transformacion;
use uuid::Uuid;
use crate::clipboard::ArboardClipboard;
pub(crate) const METRICS: EditorMetrics = EditorMetrics::for_font_size(13.0);
pub(crate) const VISIBLE_LINES: usize = 200;
pub(crate) const BACKENDS: [BackendKind; 6] = [
BackendKind::Mock,
BackendKind::Gemini,
BackendKind::Anthropic,
BackendKind::DeepSeek,
BackendKind::Cohere,
BackendKind::Ollama,
];
#[derive(Clone, Debug)]
pub(crate) enum Msg {
EditorKey(KeyEvent),
EditorPointer(PointerEvent),
AbrirDoc(Uuid),
NuevoDoc,
Guardar,
PathInputKey(KeyEvent),
FocusPath,
DefocusPath,
AbrirArchivo,
ExportarMd,
FindToggle,
FindKey(KeyEvent),
FindSiguiente,
FindAnterior,
FindClose,
DiffToggle,
/// Rail hospedado: pata reenvió un clic en un diente prestado (0=Documentos,
/// 1=LLM, 2=Buscar, 3=Diff). Togglea esa sección.
HostActivate(u32),
MoverAtomArriba,
MoverAtomAbajo,
TocarMadre,
RegenerarStale,
ToglearFusion,
ZonaSiguiente,
ZonaAnterior,
CicloBackend,
PedirTraducir(String),
PedirTono(String),
PedirResumir(Option<u32>),
LlmListo {
hija: Cuerpo,
atoms_nuevos: Vec<NarrativeAtom>,
carta: CartaHebras,
transformacion: Transformacion,
},
LlmError(String),
ResizeIzq(f32),
ResizeDer(f32),
// --- Menú principal + menú de edición contextual ---
/// Abre/cierra un dropdown del menú principal (índice del menú raíz).
MenuOpen(Option<usize>),
/// Comando string del menú principal (rebota desde `on_command`).
MenuCommand(String),
/// Navegación por teclado en el menú principal (`+1` baja, `-1` sube).
MenuNav(i32),
/// Enter en el menú principal: ejecuta la fila activa.
MenuActivate,
/// Tick de animación de menús (sólo re-render).
MenuTick,
/// Navegación por teclado en el menú de edición.
EditNav(i32),
/// Enter en el menú de edición: ejecuta la fila activa.
EditActivate,
/// Right-click: abre el menú de edición anclado en (x, y) de ventana.
EditMenuOpen(f32, f32),
/// Acción elegida en el menú de edición contextual.
EditMenuAction(llimphi_widget_edit_menu::EditAction),
/// Cierra cualquier menú abierto (dropdown o edición).
CloseMenus,
}
pub(crate) struct Model {
pub(crate) store: Arc<PlumaStore>,
pub(crate) cuerpos: Vec<Cuerpo>,
pub(crate) atoms: HashMap<Uuid, NarrativeAtom>,
pub(crate) cartas: Vec<CartaHebras>,
pub(crate) transformaciones: Vec<Transformacion>,
/// `id` del `Cuerpo` activo (el que se ve en `ide`). `None` sólo si
/// la lista de cuerpos está vacía — el init siembra uno para evitarlo.
pub(crate) activo: Option<Uuid>,
pub(crate) ide: CuerpoIde,
pub(crate) clipboard: ArboardClipboard,
pub(crate) drag_accum: (f32, f32),
pub(crate) chat: Arc<dyn ChatClient>,
pub(crate) backend_idx: usize,
pub(crate) en_curso: bool,
pub(crate) ultimo_error: Option<String>,
pub(crate) ultimo_status: String,
/// Ruta del archivo a abrir/exportar — input compartido.
/// Se interpreta según qué botón clickea el usuario.
pub(crate) path_input: TextInputState,
/// Cuando es `true`, las teclas del usuario van al `path_input` en
/// vez del editor. Click sobre el input lo enciende; Esc, o un
/// click fuera (en realidad, sólo Esc) lo apaga.
pub(crate) path_focused: bool,
/// Find-in-page sobre el cuerpo activo. `Ctrl+F` muestra el overlay
/// y lo enfoca; Esc lo cierra; Enter/Shift+Enter cyclan matches.
pub(crate) find_input: TextInputState,
pub(crate) find_visible: bool,
pub(crate) find_matches: Vec<(usize, usize)>,
pub(crate) find_idx: usize,
/// Cuando es `true` y el cuerpo activo es una hija, el centro
/// muestra la madre y la hija lado a lado con las hebras pintadas
/// (read-only). Cuando el activo es Original o no se encuentra la
/// madre, el flag igual existe pero la vista cae al cuerpo_ide
/// normal con un cartel.
pub(crate) diff_visible: bool,
pub(crate) side_izq_w: f32,
pub(crate) side_der_w: f32,
/// Índice del menú raíz cuyo dropdown está abierto (`None` = cerrado).
pub(crate) menu_open: Option<usize>,
/// Fila resaltada por teclado en el menú principal (`usize::MAX` = ninguna).
pub(crate) menu_active: usize,
/// Animación de aparición/swap del dropdown del menú principal (0→1).
pub(crate) menu_anim: llimphi_motion::Tween<f32>,
/// Ancla (x, y) en coords de ventana del menú de edición contextual,
/// o `None` si no está abierto.
pub(crate) edit_menu: Option<(f32, f32)>,
/// Fila resaltada por teclado en el menú de edición (`usize::MAX` = ninguna).
pub(crate) edit_active: usize,
/// Animación de aparición del menú de edición (0→1).
pub(crate) edit_anim: llimphi_motion::Tween<f32>,
// --- Rail hospedado (sidebar delegado a pata) ---
/// `true` si pluma delega su sidebar a pata (`PLUMA_DELEGATE_SIDEBAR`): sus
/// secciones aparecen como dientes en el rail de pata cuando tiene foco, y las
/// columnas laterales se pueden colapsar (editor a pantalla completa).
pub(crate) delegated: bool,
/// Visibilidad de la columna de Documentos (sólo aplica en modo delegado).
pub(crate) side_izq_visible: bool,
/// Visibilidad de la columna LLM (sólo aplica en modo delegado).
pub(crate) side_der_visible: bool,
/// Cliente del rail hospedado; sólo se retiene (las activaciones llegan por
/// callback). `_` evita el lint de campo sin leer.
pub(crate) _host: Option<pata_host::HostClient>,
}
File diff suppressed because it is too large Load Diff
+101
View File
@@ -0,0 +1,101 @@
//! Helpers puros: rutas, expansión de `~`, etiquetas legibles de
//! backend/intención/transformación, y el reloj unix.
use std::path::{Path, PathBuf};
use pluma_cuerpo::Intencion;
use pluma_llm::BackendKind;
use pluma_transform::TipoTransformacion;
pub(crate) fn expandir_ruta(raw: &str) -> PathBuf {
if let Some(rest) = raw.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join(rest);
}
} else if raw == "~" {
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home);
}
}
PathBuf::from(raw)
}
pub(crate) fn extension_lower(p: &Path) -> Option<String> {
p.extension().map(|e| e.to_string_lossy().to_lowercase())
}
pub(crate) fn etiqueta_backend(k: BackendKind) -> &'static str {
match k {
BackendKind::Mock => "mock",
BackendKind::Gemini => "gemini",
BackendKind::Anthropic => "anthropic",
BackendKind::DeepSeek => "deepseek",
BackendKind::Cohere => "cohere",
BackendKind::Ollama => "ollama",
}
}
pub(crate) fn etiqueta_intencion(i: &Intencion) -> String {
match i {
Intencion::Original => "original".into(),
Intencion::Traduccion => "traducción".into(),
Intencion::Tono { etiqueta } => format!("tono {etiqueta}"),
Intencion::Resumen {
palabras_objetivo: Some(n),
} => format!("resumen ≈{n}p"),
Intencion::Resumen {
palabras_objetivo: None,
} => "resumen".into(),
Intencion::Reescritura { .. } => "reescritura".into(),
Intencion::Anotacion => "anotación".into(),
Intencion::Custom { kind } => kind.clone(),
}
}
pub(crate) fn etiqueta_tipo(t: &TipoTransformacion) -> String {
match t {
TipoTransformacion::Identidad => "identidad".into(),
TipoTransformacion::Traducir { lengua_destino } => format!("traducir → {lengua_destino}"),
TipoTransformacion::Tono { etiqueta } => format!("tono {etiqueta}"),
TipoTransformacion::Resumir {
palabras_objetivo: Some(n),
} => format!("resumir ≈{n}p"),
TipoTransformacion::Resumir {
palabras_objetivo: None,
} => "resumir".into(),
TipoTransformacion::Reescribir { .. } => "reescribir".into(),
TipoTransformacion::Custom { kind, .. } => kind.clone(),
}
}
pub(crate) fn recortar(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut o: String = s.chars().take(max.saturating_sub(1)).collect();
o.push('…');
o
}
}
pub(crate) fn ruta_sled() -> PathBuf {
if let Ok(p) = std::env::var("PLUMA_APP_SLED") {
return PathBuf::from(p);
}
let base = std::env::var("XDG_CACHE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
std::env::var("HOME")
.map(|h| PathBuf::from(h).join(".cache"))
.unwrap_or_else(|_| PathBuf::from(".cache"))
});
base.join("gioser").join("pluma-app").join("pluma.sled")
}
pub(crate) fn ahora_unix() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
+821
View File
@@ -0,0 +1,821 @@
//! Vistas: las tres columnas (documentos · editor/diff · panel LLM), la
//! barra de status, el overlay de find, y las secciones de hijas/historial.
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_theme::Theme;
use llimphi_ui::{DragPhase, View};
use llimphi_widget_button::{button_view, ButtonPalette};
use llimphi_widget_list::{list_view, ListPalette, ListRow, ListSpec};
use llimphi_widget_splitter::{splitter_two, Direction, PaneSize, SplitterPalette};
use llimphi_widget_context_menu::{context_menu_view_ex, ContextMenuExtras};
use llimphi_widget_edit_menu::{self as editmenu, EditFlags};
use llimphi_widget_menubar::{
menubar_overlay_animated, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H,
};
use llimphi_widget_text_editor::{EditorPalette as TEPalette, Language};
use llimphi_widget_text_input::{text_input_view, TextInputPalette};
use pluma_align::CartaHebras;
use pluma_cuerpo::Cuerpo;
use pluma_editor_llimphi::cuerpo_ide::cuerpo_ide_view;
use pluma_editor_llimphi::multilienzo::{
multilienzo_view, IndiceAtoms, MultilienzoConfig, PaletaHebras,
};
use pluma_editor_llimphi::Palette as MultPalette;
use pluma_transform::Transformacion;
use uuid::Uuid;
use crate::model::{Model, Msg, BACKENDS, METRICS, VISIBLE_LINES};
use crate::update::{contar_stale_del_activo, menu_principal};
use crate::util::{etiqueta_backend, etiqueta_intencion, etiqueta_tipo, recortar};
/// Tamaño de ventana del init — usado como viewport para clampear los
/// dropdowns del menú (la app no trackea el tamaño real hoy).
const VIEWPORT: (f32, f32) = (1600.0, 900.0);
/// Arma el `MenuBarSpec` compartido entre `menubar_view` (barra) y
/// `menubar_overlay` (dropdown). El `menu` se construye afuera y se
/// presta por referencia para no clonarlo dos veces.
fn menubar_spec<'a>(
menu: &'a app_bus::AppMenu,
model: &Model,
theme: &'a Theme,
) -> MenuBarSpec<'a, Msg> {
MenuBarSpec {
menu,
open: model.menu_open,
theme,
viewport: VIEWPORT,
height: MENU_H,
on_open: std::sync::Arc::new(Msg::MenuOpen),
on_command: std::sync::Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
}
}
pub(crate) fn vista(model: &Model) -> View<Msg> {
let theme = Theme::dark();
let editor_palette = TEPalette::default();
let splitter_palette = SplitterPalette::from_theme(&theme);
let menu = menu_principal(model);
let menubar = menubar_view(&menubar_spec(&menu, model, &theme));
let status = barra_status(model, &theme);
let panel_centro = panel_editor(model, &editor_palette);
// En modo delegado (sidebar prestado a pata) las columnas laterales se pueden
// colapsar desde el rail de pata → editor a pantalla completa ("puro canvas").
// Sin delegar, ambas van siempre (comportamiento original).
let show_izq = !model.delegated || model.side_izq_visible;
let show_der = !model.delegated || model.side_der_visible;
// Splitter anidado: izq | (centro | der). Cada lado oculto se saca del árbol
// (y con él su splitter), no sólo se esconde.
let centro_der = if show_der {
splitter_two(
Direction::Row,
panel_centro,
PaneSize::Flex,
panel_llm(model, &theme),
PaneSize::Fixed(model.side_der_w),
|phase, dx| match phase {
DragPhase::Move => Some(Msg::ResizeDer(dx)),
DragPhase::End => None,
},
&splitter_palette,
)
} else {
panel_centro
};
let body = if show_izq {
splitter_two(
Direction::Row,
panel_documentos(model, &theme),
PaneSize::Fixed(model.side_izq_w),
centro_der,
PaneSize::Flex,
|phase, dx| match phase {
DragPhase::Move => Some(Msg::ResizeIzq(dx)),
DragPhase::End => None,
},
&splitter_palette,
)
} else {
centro_der
};
// El right-click se engancha en la raíz (origen 0,0 → las coords
// locales que llegan al handler ya son de ventana) y abre el menú de
// edición sobre el cuerpo_ide. La barra de menú va de primer hijo.
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)
.on_right_click_at(|x, y, _w, _h| Some(Msg::EditMenuOpen(x, y)))
.children(vec![menubar, status, body])
}
/// Overlay flotante: el menú de edición contextual tiene prioridad; si no
/// está abierto, cae al dropdown del menú principal. La app no tenía otros
/// popups flotantes (find es inline), así que estos dos son todo.
pub(crate) fn vista_overlay(model: &Model) -> Option<View<Msg>> {
let theme = Theme::dark();
if let Some((x, y)) = model.edit_menu {
let flags = EditFlags::from_editor(&model.ide.state, false);
let mut spec = editmenu::edit_context_menu(
(x, y),
VIEWPORT,
&theme,
flags,
Msg::EditMenuAction,
Msg::CloseMenus,
);
spec.active = model.edit_active;
return Some(context_menu_view_ex(
spec,
ContextMenuExtras {
appear: model.edit_anim.value(),
..Default::default()
},
));
}
let menu = menu_principal(model);
menubar_overlay_animated(
&menubar_spec(&menu, model, &theme),
model.menu_active,
model.menu_anim.value(),
)
}
fn barra_status(model: &Model, theme: &Theme) -> View<Msg> {
let nombre = model
.activo
.and_then(|id| model.cuerpos.iter().find(|c| c.id == id))
.map(|c| c.metadatos.nombre_legible.clone())
.unwrap_or_else(|| "(sin doc)".to_string());
let zona = model.ide.zona_del_caret();
let n_zonas = model.ide.n_zonas();
let backend = etiqueta_backend(BACKENDS[model.backend_idx]);
let estado = if model.en_curso {
""
} else if model.ultimo_error.is_some() {
""
} else {
""
};
let texto = format!(
"pluma · {nombre} · zona {zona}/{n_zonas} · backend {backend} · {estado} {}",
model.ultimo_status
);
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(30.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()
})
.fill(theme.bg_panel)
.text_aligned(texto, 12.0, theme.fg_text, Alignment::Start)
}
fn panel_documentos(model: &Model, theme: &Theme) -> View<Msg> {
let palette_btn = ButtonPalette::from_theme(theme);
let palette_list = ListPalette::from_theme(theme);
// Originales primero, luego derivadas — el orden en la lista es
// estable porque clonamos `model.cuerpos` y particionamos.
let mut originales: Vec<&Cuerpo> = Vec::new();
let mut derivadas: Vec<&Cuerpo> = Vec::new();
for c in &model.cuerpos {
if c.metadatos.intencion.es_derivada() {
derivadas.push(c);
} else {
originales.push(c);
}
}
let mut rows: Vec<ListRow<Msg>> = Vec::new();
for c in originales.iter().chain(derivadas.iter()) {
let prefijo = if c.metadatos.intencion.es_derivada() {
""
} else {
""
};
let label = format!(
"{prefijo}{} · {}",
c.metadatos.nombre_legible, c.branch_id
);
rows.push(ListRow {
label,
selected: model.activo == Some(c.id),
on_click: Msg::AbrirDoc(c.id),
});
}
let n = rows.len();
let lista = list_view(ListSpec {
rows,
total: n,
caption: Some(format!("{n} documentos")),
truncated_hint: None,
row_height: 22.0,
palette: palette_list,
});
let boton_nuevo = button_view::<Msg>(" nuevo doc (Ctrl+N)", &palette_btn, Msg::NuevoDoc);
let boton_guardar = button_view::<Msg>("💾 guardar (Ctrl+S)", &palette_btn, Msg::Guardar);
let header = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
padding: Rect {
left: length(4.0_f32),
right: length(4.0_f32),
top: length(2.0_f32),
bottom: length(2.0_f32),
},
..Default::default()
})
.text_aligned("DOCUMENTOS".to_string(), 10.0, theme.fg_muted, Alignment::Start);
let archivo = seccion_archivo(model, theme);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(10.0_f32),
right: length(10.0_f32),
top: length(10.0_f32),
bottom: length(10.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(6.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel)
.clip(true)
.children(vec![header, boton_nuevo, boton_guardar, archivo, lista])
}
fn seccion_archivo(model: &Model, theme: &Theme) -> View<Msg> {
let palette_btn = ButtonPalette::from_theme(theme);
let palette_input = TextInputPalette::from_theme(theme);
let header = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(18.0_f32),
},
padding: Rect {
left: length(4.0_f32),
right: length(4.0_f32),
top: length(2.0_f32),
bottom: length(2.0_f32),
},
..Default::default()
})
.text_aligned(
"ARCHIVO".to_string(),
10.0,
theme.fg_muted,
Alignment::Start,
);
let input = text_input_view::<Msg>(
&model.path_input,
"ruta .md o .docx (Esc para salir)",
model.path_focused,
&palette_input,
Msg::FocusPath,
);
let fila_botones = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(28.0_f32),
},
gap: Size {
width: length(6.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(vec![
button_view::<Msg>("📂 abrir", &palette_btn, Msg::AbrirArchivo),
button_view::<Msg>("⬆ exportar (md/docx)", &palette_btn, Msg::ExportarMd),
]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: length(82.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(4.0_f32),
},
..Default::default()
})
.children(vec![header, input, fila_botones])
}
fn panel_editor(model: &Model, palette_editor: &TEPalette) -> View<Msg> {
let cuerpo_central: View<Msg> = if model.diff_visible {
vista_diff(model, palette_editor)
} else {
cuerpo_ide_view::<Msg>(
&model.ide,
palette_editor,
METRICS,
VISIBLE_LINES,
Language::Plain,
|ev| Some(Msg::EditorPointer(ev)),
)
};
let mut hijos: Vec<View<Msg>> = Vec::new();
if model.find_visible {
hijos.push(barra_find(model));
}
hijos.push(cuerpo_central);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(10.0_f32),
right: length(10.0_f32),
top: length(8.0_f32),
bottom: length(8.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(6.0_f32),
},
..Default::default()
})
.fill(palette_editor.bg)
.clip(true)
.children(hijos)
}
fn vista_diff(model: &Model, palette_editor: &TEPalette) -> View<Msg> {
// Resolver activo + madre. Si activo no es derivado o la madre no
// se encuentra, mostramos un cartel y volvemos a `cuerpo_ide_view`.
let theme = Theme::dark();
let activo_id = match model.activo {
Some(id) => id,
None => return cartel_diff("sin doc activo", palette_editor),
};
let activo = match model.cuerpos.iter().find(|c| c.id == activo_id) {
Some(c) => c,
None => return cartel_diff("activo no encontrado", palette_editor),
};
let madre_id = match activo.metadatos.derivado_de {
Some(id) => id,
None => {
// Activo es Original — fallback al editor normal con cartel.
return View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(4.0_f32),
},
..Default::default()
})
.children(vec![
cartel_diff(
"este cuerpo es Original — no tiene madre con que diffear (Ctrl+D para cerrar)",
palette_editor,
),
cuerpo_ide_view::<Msg>(
&model.ide,
palette_editor,
METRICS,
VISIBLE_LINES,
Language::Plain,
|ev| Some(Msg::EditorPointer(ev)),
),
]);
}
};
let madre = match model.cuerpos.iter().find(|c| c.id == madre_id) {
Some(c) => c,
None => return cartel_diff(
"madre referenciada no está en el sled — ¿borrada?",
palette_editor,
),
};
// Buscar la carta de hebras entre estos dos. `pluma_align::CartaHebras`
// anota su par; consideramos cualquier orden.
let carta = model.cartas.iter().find(|c| {
(c.cuerpo_a == Some(madre.id) && c.cuerpo_b == Some(activo.id))
|| (c.cuerpo_a == Some(activo.id) && c.cuerpo_b == Some(madre.id))
});
let cuerpos_ref: Vec<&Cuerpo> = vec![madre, activo];
let cartas_ref: Vec<Option<&CartaHebras>> = vec![carta];
let atoms_idx: IndiceAtoms = model.atoms.iter().map(|(k, v)| (*k, v)).collect();
let cfg = MultilienzoConfig::default();
let paleta_hebras = PaletaHebras::default();
let palette_mult = MultPalette::from_theme(&theme);
let mult = multilienzo_view::<Msg>(
&cuerpos_ref,
&atoms_idx,
&cartas_ref,
&cfg,
&paleta_hebras,
&palette_mult,
);
let header_text = format!(
"DIFF · madre «{}» ↔ hija «{}» ({})",
madre.metadatos.nombre_legible,
activo.metadatos.nombre_legible,
if carta.is_some() {
"con hebras"
} else {
"sin carta — hebras no disponibles"
},
);
let header = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
padding: Rect {
left: length(4.0_f32),
right: length(4.0_f32),
top: length(2.0_f32),
bottom: length(2.0_f32),
},
..Default::default()
})
.text_aligned(header_text, 11.0, theme.fg_muted, Alignment::Start);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(4.0_f32),
},
..Default::default()
})
.children(vec![header, mult])
}
fn cartel_diff(texto: &str, palette_editor: &TEPalette) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(40.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(10.0_f32),
bottom: length(10.0_f32),
},
..Default::default()
})
.text_aligned(
texto.to_string(),
12.0,
palette_editor.fg_line_number,
Alignment::Start,
)
}
fn barra_find(model: &Model) -> View<Msg> {
let theme = Theme::dark();
let palette_input = TextInputPalette::from_theme(&theme);
let palette_btn = ButtonPalette::from_theme(&theme);
let input = text_input_view::<Msg>(
&model.find_input,
"buscar (Enter siguiente · Shift+Enter previo · Esc cerrar)",
true, // find_visible implica que tiene foco
&palette_input,
Msg::FindToggle, // click en el input no cambia foco — siempre vivo
);
let total = model.find_matches.len();
let pos = if total == 0 {
0
} else {
model.find_idx + 1
};
let counter = View::new(Style {
size: Size {
width: length(80.0_f32),
height: length(34.0_f32),
},
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(8.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.text_aligned(
format!("{pos}/{total}"),
12.0,
theme.fg_muted,
Alignment::Center,
);
let prev = button_view::<Msg>("", &palette_btn, Msg::FindAnterior);
let next = button_view::<Msg>("", &palette_btn, Msg::FindSiguiente);
let cerrar = button_view::<Msg>("", &palette_btn, Msg::FindClose);
let input_wrap = View::new(Style {
flex_grow: 1.0,
flex_shrink: 1.0,
size: Size {
width: percent(1.0_f32),
height: length(34.0_f32),
},
..Default::default()
})
.children(vec![input]);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(40.0_f32),
},
gap: Size {
width: length(6.0_f32),
height: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.children(vec![input_wrap, counter, prev, next, cerrar])
}
fn panel_llm(model: &Model, theme: &Theme) -> View<Msg> {
let palette_btn_activo = ButtonPalette::from_theme(theme);
let palette_btn_off = ButtonPalette {
bg: Color::from_rgba8(60, 60, 60, 255),
bg_hover: Color::from_rgba8(60, 60, 60, 255),
fg: Color::from_rgba8(140, 140, 140, 255),
radius: palette_btn_activo.radius,
};
let pal = if model.en_curso {
&palette_btn_off
} else {
&palette_btn_activo
};
let pal_backend = &palette_btn_activo;
let etiqueta_back = format!(
"🔀 backend: {}",
etiqueta_backend(BACKENDS[model.backend_idx])
);
let cycler = button_view::<Msg>(&etiqueta_back, pal_backend, Msg::CicloBackend);
let etiqueta_diff = if model.diff_visible {
"↔ diff: ON (Ctrl+D)"
} else {
"↔ diff: off (Ctrl+D)"
};
let diff_btn = button_view::<Msg>(etiqueta_diff, pal_backend, Msg::DiffToggle);
let header = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
padding: Rect {
left: length(4.0_f32),
right: length(4.0_f32),
top: length(2.0_f32),
bottom: length(2.0_f32),
},
..Default::default()
})
.text_aligned("LLM".to_string(), 10.0, theme.fg_muted, Alignment::Start);
let mk = |label: &str, m: Msg| button_view::<Msg>(label, pal, m);
let botones: Vec<View<Msg>> = vec![
mk("→ traducir qu", Msg::PedirTraducir("qu".into())),
mk("→ traducir en", Msg::PedirTraducir("en".into())),
mk("✎ tono formal", Msg::PedirTono("formal".into())),
mk("✂ resumir 30p", Msg::PedirResumir(Some(30))),
];
let n_stale = contar_stale_del_activo(model);
let label_regen = if n_stale > 0 {
format!("⟳ regenerar stale ({n_stale})")
} else {
"⟳ regenerar stale (0)".to_string()
};
let tocar_btn = button_view::<Msg>("⏰ tocar madre", pal, Msg::TocarMadre);
let regen_btn = button_view::<Msg>(&label_regen, pal, Msg::RegenerarStale);
// Lista de hijas del cuerpo activo — para abrirlas con click.
let hijas_seccion = seccion_hijas(model, theme);
let mut hijos: Vec<View<Msg>> = Vec::new();
hijos.push(header);
hijos.push(cycler);
hijos.push(diff_btn);
hijos.extend(botones);
hijos.push(tocar_btn);
hijos.push(regen_btn);
hijos.push(divider(theme));
hijos.push(hijas_seccion);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(10.0_f32),
right: length(10.0_f32),
top: length(10.0_f32),
bottom: length(10.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(6.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel)
.clip(true)
.children(hijos)
}
fn seccion_hijas(model: &Model, theme: &Theme) -> View<Msg> {
let palette_list = ListPalette::from_theme(theme);
let activo = model.activo;
let hijas: Vec<&Cuerpo> = model
.cuerpos
.iter()
.filter(|c| {
c.metadatos.intencion.es_derivada() && c.metadatos.derivado_de == activo
})
.collect();
let mut rows: Vec<ListRow<Msg>> = Vec::new();
for h in &hijas {
let label = format!("{} · {}", h.branch_id, etiqueta_intencion(&h.metadatos.intencion));
rows.push(ListRow {
label,
selected: false,
on_click: Msg::AbrirDoc(h.id),
});
}
let n = rows.len();
let lista = list_view(ListSpec {
rows,
total: n,
caption: Some(format!("hijas: {n}")),
truncated_hint: None,
row_height: 20.0,
palette: palette_list,
});
let historial = seccion_historial(model, theme);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: auto(),
},
flex_grow: 1.0,
gap: Size {
width: length(0.0_f32),
height: length(6.0_f32),
},
..Default::default()
})
.children(vec![lista, divider(theme), historial])
}
fn seccion_historial(model: &Model, theme: &Theme) -> View<Msg> {
let palette_list = ListPalette::from_theme(theme);
// Index para resolver Uuid → Cuerpo, cuerpo.metadatos.nombre_legible.
let cuerpo_de = |id: Uuid| model.cuerpos.iter().find(|c| c.id == id);
// Transformaciones del cuerpo activo: ya sea como madre o como hija.
// Lo más útil al usuario suele ser "todo lo que pasó alrededor de
// este doc" — así una hija de cuya madre vengo, lo veo.
let activo = model.activo;
let mut filtradas: Vec<&Transformacion> = model
.transformaciones
.iter()
.filter(|t| match activo {
Some(id) => t.madre == id || t.hija == id,
None => true,
})
.collect();
// Más recientes arriba.
filtradas.sort_by(|a, b| b.creada_en.cmp(&a.creada_en));
let mut rows: Vec<ListRow<Msg>> = Vec::new();
for t in &filtradas {
let madre = cuerpo_de(t.madre)
.map(|c| c.metadatos.nombre_legible.as_str())
.unwrap_or("?");
let hija = cuerpo_de(t.hija)
.map(|c| c.metadatos.nombre_legible.as_str())
.unwrap_or("?");
let tipo = etiqueta_tipo(&t.tipo);
// Truncar nombres largos para que la fila no se rompa visual.
let label = format!(
"{}{} · {}",
recortar(madre, 18),
recortar(hija, 18),
tipo,
);
rows.push(ListRow {
label,
selected: false,
on_click: Msg::AbrirDoc(t.hija),
});
}
let n = rows.len();
let lista = list_view(ListSpec {
rows,
total: n,
caption: Some(if activo.is_some() {
format!("historial activo: {n}")
} else {
format!("historial: {n}")
}),
truncated_hint: None,
row_height: 20.0,
palette: palette_list,
});
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: auto(),
},
..Default::default()
})
.children(vec![lista])
}
fn divider(theme: &Theme) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(1.0_f32),
},
..Default::default()
})
.fill(theme.border)
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "pluma-core"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "pluma-app — átomo narrativo (NarrativeAtom) + estado de coherencia. Tipos puros del editor DAG, agnósticos de UI."
[dependencies]
uuid = { workspace = true, features = ["serde"] }
sha2 = { workspace = true }
serde = { workspace = true, features = ["rc"] }
+19
View File
@@ -0,0 +1,19 @@
# pluma-core
> Modelo de documento de [pluma](../README.md): átomos, grafo, ids.
`Atomo` = unidad mínima (un párrafo, una celda, un bloque de código) con `Uuid` estable. `Documento` es un DAG de átomos con orden lineal por default + referencias laterales (links, tags). Las mutaciones se aplican como `CambioAtom { Crear, Mutar, Eliminar }`.
## API
```rust
use pluma_core::{Documento, Atomo, Uuid};
let mut doc = Documento::new();
let id = doc.crear_atomo("texto")?;
```
## Deps
- `serde`, [`uuid`](https://crates.io/crates/uuid)
- Cero deps gráficas / network
+19
View File
@@ -0,0 +1,19 @@
# pluma-core
> Document model of [pluma](../README.md): atoms, graph, ids.
`Atomo` = minimal unit (a paragraph, a cell, a code block) with stable `Uuid`. `Documento` is a DAG of atoms with default linear order + lateral references (links, tags). Mutations apply as `CambioAtom { Crear, Mutar, Eliminar }`.
## API
```rust
use pluma_core::{Documento, Atomo, Uuid};
let mut doc = Documento::new();
let id = doc.crear_atomo("text")?;
```
## Deps
- `serde`, [`uuid`](https://crates.io/crates/uuid)
- Zero graphics / network deps
+138
View File
@@ -0,0 +1,138 @@
//! `pluma_app-core` — el átomo narrativo y su estado de coherencia.
//!
//! Tipos puros del editor DAG de pluma_app: sin UI, sin storage, sin red. El
//! documento es un grafo de [`NarrativeAtom`]s; cada átomo comparte su
//! texto vía `Arc<String>` para que ramificar una línea temporal sea
//! O(1) (structural sharing).
//!
//! Invariante: `content_hash` siempre corresponde a `content` —
//! ver [`NarrativeAtom::hash_matches`].
#![forbid(unsafe_code)]
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use uuid::Uuid;
/// Estado de coherencia lógica de un átomo dentro del grafo narrativo.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum CoherenceState {
/// Consistente con sus dependencias.
Valid,
/// En conflicto: una dependencia cambió y lo contradice.
InConflict { origin: Uuid, reason: String },
/// Marcado para re-evaluación (una dependencia mutó; falta verificar).
PendingEvaluation,
}
/// Un átomo narrativo: la unidad atómica del documento.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NarrativeAtom {
pub id: Uuid,
/// SHA-256 del contenido — verifica integridad de toda mutación.
pub content_hash: [u8; 32],
/// Texto compartido. Clonar una rama no duplica el texto.
pub content: Arc<String>,
/// Concepto → intensidad. Lo puebla `pluma_app-semantic`.
pub semantic_vectors: HashMap<String, f32>,
/// Átomos prerrequisito (sus "padres" lógicos).
pub dependencies: Vec<Uuid>,
/// Identificador de la rama / línea temporal.
pub branch_id: String,
pub coherence: CoherenceState,
}
impl NarrativeAtom {
/// Crea un átomo nuevo con id aleatorio. Hashea el contenido.
pub fn new(content: impl Into<String>, branch_id: impl Into<String>) -> Self {
let content = content.into();
let content_hash = sha256(content.as_bytes());
Self {
id: Uuid::new_v4(),
content_hash,
content: Arc::new(content),
semantic_vectors: HashMap::new(),
dependencies: Vec::new(),
branch_id: branch_id.into(),
coherence: CoherenceState::Valid,
}
}
/// Declara una dependencia (prerrequisito lógico).
pub fn depends_on(mut self, dep: Uuid) -> Self {
if !self.dependencies.contains(&dep) {
self.dependencies.push(dep);
}
self
}
/// Reemplaza el contenido: re-hashea y vuelve a `PendingEvaluation`
/// (toda mutación exige re-verificar la coherencia).
pub fn set_content(&mut self, content: impl Into<String>) {
let content = content.into();
self.content_hash = sha256(content.as_bytes());
self.content = Arc::new(content);
self.coherence = CoherenceState::PendingEvaluation;
}
/// `true` si `content_hash` corresponde al `content` actual.
/// El editor valida esto en toda mutación de texto.
pub fn hash_matches(&self) -> bool {
sha256(self.content.as_bytes()) == self.content_hash
}
}
/// SHA-256 de un buffer de bytes.
pub fn sha256(bytes: &[u8]) -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(bytes);
h.finalize().into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_atom_is_valid_with_matching_hash() {
let a = NarrativeAtom::new("había una vez", "main");
assert_eq!(a.coherence, CoherenceState::Valid);
assert!(a.hash_matches());
assert_eq!(a.branch_id, "main");
}
#[test]
fn set_content_rehashes_and_marks_pending() {
let mut a = NarrativeAtom::new("v1", "main");
let h1 = a.content_hash;
a.set_content("v2 distinto");
assert_ne!(a.content_hash, h1);
assert!(a.hash_matches());
assert_eq!(a.coherence, CoherenceState::PendingEvaluation);
}
#[test]
fn branch_shares_content_arc() {
let a = NarrativeAtom::new("texto largo compartido", "main");
let b = a.clone();
// Clonar la rama NO duplica el String — comparten el Arc.
assert!(Arc::ptr_eq(&a.content, &b.content));
}
#[test]
fn depends_on_dedups() {
let d = Uuid::new_v4();
let a = NarrativeAtom::new("x", "main").depends_on(d).depends_on(d);
assert_eq!(a.dependencies.len(), 1);
}
#[test]
fn tampered_content_fails_hash_check() {
let mut a = NarrativeAtom::new("original", "main");
// Forzar desincronización (lo que el editor debe detectar).
a.content = Arc::new("manipulado".to_string());
assert!(!a.hash_matches());
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "pluma-cuerpo"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "pluma — modelo de Cuerpo (lienzo): secuencia ordenada de párrafos del mismo documento bajo una mirada (idioma, tono, resumen, derivación). Hueso del paralelismo multilienzo."
[dependencies]
uuid = { workspace = true, features = ["serde"] }
serde = { workspace = true }
postcard = { workspace = true }
pluma-core = { path = "../pluma-core" }
+19
View File
@@ -0,0 +1,19 @@
# pluma-cuerpo
> Texto del documento como secuencia de átomos para [pluma](../README.md).
`Cuerpo` es la vista lineal del documento: lista ordenada de `Atomo` ids, con su texto concatenado por un separador (default `"\n\n"`). Sirve como "vista plana" para el editor y para serialización a Markdown.
## API
```rust
use pluma_cuerpo::Cuerpo;
let cuerpo = Cuerpo::from_doc(&doc);
let texto = cuerpo.como_string();
```
## Deps
- [`pluma-core`](../pluma-core/README.md)
- `serde`, `uuid`
+19
View File
@@ -0,0 +1,19 @@
# pluma-cuerpo
> Document text as a sequence of atoms for [pluma](../README.md).
`Cuerpo` is the linear view of the document: ordered list of `Atomo` ids with their text concatenated by a separator (default `"\n\n"`). Used as the "flat view" for the editor and for Markdown serialization.
## API
```rust
use pluma_cuerpo::Cuerpo;
let cuerpo = Cuerpo::from_doc(&doc);
let text = cuerpo.como_string();
```
## Deps
- [`pluma-core`](../pluma-core/README.md)
- `serde`, `uuid`
+379
View File
@@ -0,0 +1,379 @@
//! `pluma-cuerpo` — el *cuerpo* (lienzo) de un documento multivista.
//!
//! Un documento pluma deja de ser una sola secuencia lineal de párrafos: es un
//! *haz* de cuerpos. Cada cuerpo recorre el mismo material desde una mirada
//! distinta — el original en español, su traducción al quechua, el resumen
//! en inglés, el comentario crítico, la versión "tono infantil". Todos viven
//! en paralelo, sincronizados por alineamientos párrafo-a-párrafo
//! (ver `pluma-align`).
//!
//! Este crate define solo el cuerpo: una colección ordenada de [`Uuid`]s de
//! `NarrativeAtom`s, con metadatos que explican qué *intención* tiene este
//! cuerpo dentro del haz, y de qué cuerpo madre deriva si es derivado.
//!
//! No define la UI ni la alineación entre cuerpos. Esos viven en
//! `pluma-editor-llimphi` y `pluma-align` respectivamente.
#![forbid(unsafe_code)]
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// La identidad de una lengua dentro de un cuerpo: un código corto estilo
/// ISO 639 (`"es"`, `"qu"`, `"en"`). No se restringe la enumeracion para no
/// encerrarnos en un catalogo fijo; el campo es un `String`, y `pluma-localize`
/// (o `rimay-localize`) decide qué significa cada código.
pub type Lengua = String;
/// La razón por la que existe un cuerpo dentro del haz. Si es `Original`, el
/// cuerpo no deriva de ningún otro — es la madre. Cualquier otra variante
/// implica que existe un `derivado_de` apuntando a un cuerpo madre.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Intencion {
/// Cuerpo "madre" del haz — no deriva de ningún otro.
Original,
/// Traducción de la madre a otra lengua.
Traduccion,
/// Reescritura con un tono distinto (formal, casual, técnico, infantil…).
/// La `etiqueta` es libre — la UI la presenta como label de la columna.
Tono { etiqueta: String },
/// Resumen de la madre, con un objetivo opcional de palabras.
Resumen { palabras_objetivo: Option<u32> },
/// Reescritura libre dictada por un prompt humano.
Reescritura { prompt: String },
/// Comentario crítico, glosa o anotación marginal — cada átomo del cuerpo
/// anotación se alinea al átomo del cuerpo madre que comenta.
Anotacion,
/// Cualquier otra cosa que la app quiera categorizar — la etiqueta es libre.
Custom { kind: String },
}
impl Intencion {
/// `true` si esta intención implica que el cuerpo deriva de otro. Solo
/// `Original` es la excepción: vive solo.
pub fn es_derivada(&self) -> bool {
!matches!(self, Intencion::Original)
}
}
/// Metadatos descriptivos de un cuerpo: lo que le explica al usuario y al
/// sistema qué representa este cuerpo dentro del haz.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MetaCuerpo {
/// Nombre legible — el rótulo que la UI muestra en la cabecera de la
/// columna. Libre. Ejemplos: `"es"`, `"qu (Cuzco)"`, `"borrador 2"`,
/// `"comentario de Ana"`.
pub nombre_legible: String,
/// Lengua del cuerpo, si aplica. Útil para encadenar con `rimay-localize`,
/// motores de embeddings y exportadores. `None` para cuerpos sin lengua
/// específica (notas formales, listas de tareas, etc.).
pub lengua: Option<Lengua>,
/// Por qué existe este cuerpo dentro del haz.
pub intencion: Intencion,
/// Cuerpo madre del que deriva, si lo hay. Si `intencion` no es
/// `Original`, este campo debería estar `Some`; el constructor lo respeta,
/// y `valida_consistencia` lo verifica.
pub derivado_de: Option<Uuid>,
/// Marca temporal (segundos UNIX) del estado de la madre cuando este
/// cuerpo se regeneró por última vez. Si la madre cambia después de este
/// timestamp, el cuerpo queda *stale* — la UI lo pinta con la hebra
/// desaturada. `None` mientras el cuerpo nunca se haya regenerado o sea
/// `Original`.
pub fresco_hasta: Option<u64>,
/// Instante de creación (segundos UNIX). Se fija en el constructor.
pub creado_en: u64,
/// Instante de la última modificación de la estructura del cuerpo
/// (inserciones, removidos, reordenamientos). NO se actualiza cuando
/// cambia el contenido de un átomo — eso lo gestiona el átomo.
pub modificado_en: u64,
}
/// El cuerpo (lienzo) en sí: una secuencia ORDENADA de identidades de
/// `NarrativeAtom`s. La identidad estable de un párrafo es su `Uuid`; los
/// alineamientos entre cuerpos hablan en términos de esos `Uuid`s, no de
/// posiciones — así un párrafo movido dentro del cuerpo no rompe sus hebras.
///
/// El cuerpo NO posee los átomos: solo los referencia. La posesión vive en el
/// `NarrativeGraph` (en `pluma-graph`). Esa separación permite que distintos
/// cuerpos compartan átomos (un párrafo idéntico en madre e hija — caso
/// común tras `Intencion::Identidad`) sin duplicación.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Cuerpo {
/// Identidad del cuerpo. Estable: ni una traducción ni un renombre lo
/// cambian. Es el ancla a la que los alineamientos apuntan.
pub id: Uuid,
/// Identificador de rama, en el sentido que ya entiende `pluma-core`. Por
/// convención: `"es"`, `"qu"`, `"borrador-2"`, `"resumen-en"`. La rama da
/// también la identidad del `branch_id` que los `NarrativeAtom`s
/// referencian.
pub branch_id: String,
/// Metadatos del cuerpo.
pub metadatos: MetaCuerpo,
/// Orden de presentación de los párrafos del cuerpo. Cada `Uuid` debe
/// existir en el `NarrativeGraph` que aloja al documento; el cuerpo no
/// lo valida (no conoce el grafo), eso queda para el caller.
pub orden: Vec<Uuid>,
}
impl Cuerpo {
/// Crea un cuerpo vacío con la identidad y los metadatos esenciales.
/// `ahora` es el timestamp (segundos UNIX) que el caller decide — el
/// crate no toca el reloj para mantenerse `no_std`-amigable y testable.
pub fn nuevo(
branch_id: impl Into<String>,
nombre_legible: impl Into<String>,
intencion: Intencion,
ahora: u64,
) -> Self {
Self {
id: Uuid::new_v4(),
branch_id: branch_id.into(),
metadatos: MetaCuerpo {
nombre_legible: nombre_legible.into(),
lengua: None,
intencion,
derivado_de: None,
fresco_hasta: None,
creado_en: ahora,
modificado_en: ahora,
},
orden: Vec::new(),
}
}
/// Marca este cuerpo como derivado de `madre`, fijando también el
/// timestamp de frescura con el que se regeneró por primera vez. Útil al
/// crear cuerpos via `pluma-transform`.
pub fn deriva_de(mut self, madre: Uuid, fresco_hasta: u64) -> Self {
self.metadatos.derivado_de = Some(madre);
self.metadatos.fresco_hasta = Some(fresco_hasta);
self
}
/// Anota la lengua del cuerpo. Útil al encadenar — `Cuerpo::nuevo(...).con_lengua("qu")`.
pub fn con_lengua(mut self, lengua: impl Into<Lengua>) -> Self {
self.metadatos.lengua = Some(lengua.into());
self
}
/// Agrega un átomo al final del cuerpo. Actualiza `modificado_en`. No
/// verifica duplicados: un mismo átomo PUEDE aparecer dos veces si el
/// caller lo necesita (caso raro, pero no se prohíbe — el cuerpo es
/// agnóstico).
pub fn agregar(&mut self, atom_id: Uuid, ahora: u64) {
self.orden.push(atom_id);
self.metadatos.modificado_en = ahora;
}
/// Inserta un átomo en `indice`. Si `indice > len`, se inserta al final.
pub fn insertar(&mut self, indice: usize, atom_id: Uuid, ahora: u64) {
let pos = indice.min(self.orden.len());
self.orden.insert(pos, atom_id);
self.metadatos.modificado_en = ahora;
}
/// Remueve el primer átomo con `atom_id`. Devuelve `true` si removió.
pub fn remover(&mut self, atom_id: Uuid, ahora: u64) -> bool {
if let Some(pos) = self.orden.iter().position(|&id| id == atom_id) {
self.orden.remove(pos);
self.metadatos.modificado_en = ahora;
true
} else {
false
}
}
/// Mueve la primera ocurrencia de `atom_id` al nuevo índice. Devuelve
/// `true` si efectuó el cambio. Un movimiento al mismo índice es no-op
/// y devuelve `true`. Si `nuevo_indice > len-1`, se mueve al final.
pub fn mover(&mut self, atom_id: Uuid, nuevo_indice: usize, ahora: u64) -> bool {
let Some(actual) = self.orden.iter().position(|&id| id == atom_id) else {
return false;
};
let destino = nuevo_indice.min(self.orden.len().saturating_sub(1));
if actual == destino {
return true;
}
let id = self.orden.remove(actual);
self.orden.insert(destino, id);
self.metadatos.modificado_en = ahora;
true
}
/// Posición del primer átomo con `atom_id`, si existe en el cuerpo.
pub fn posicion(&self, atom_id: Uuid) -> Option<usize> {
self.orden.iter().position(|&id| id == atom_id)
}
/// `true` si el cuerpo deriva de otro (su intención no es `Original`).
pub fn es_derivado(&self) -> bool {
self.metadatos.intencion.es_derivada()
}
/// `true` si la madre cambió después de `fresco_hasta`. Si no es derivado
/// o nunca se regeneró, devuelve `false` (no aplica el concepto).
pub fn es_stale(&self, modificado_madre_en: u64) -> bool {
match self.metadatos.fresco_hasta {
Some(ts) => modificado_madre_en > ts,
None => false,
}
}
/// Marca el cuerpo como recién regenerado: `fresco_hasta = ahora`.
pub fn marcar_fresco(&mut self, ahora: u64) {
self.metadatos.fresco_hasta = Some(ahora);
self.metadatos.modificado_en = ahora;
}
/// Verifica la consistencia interna del cuerpo. Útil al cargar de disco o
/// tras transformaciones que prometen no romper invariantes. Reglas:
///
/// - Si `intencion.es_derivada()`, `derivado_de` debe ser `Some`.
/// - Si `intencion == Original`, `derivado_de` debe ser `None`.
/// - `modificado_en >= creado_en`.
pub fn valida_consistencia(&self) -> Result<(), &'static str> {
let m = &self.metadatos;
if m.intencion.es_derivada() && m.derivado_de.is_none() {
return Err("cuerpo :: intencion derivada sin `derivado_de`");
}
if !m.intencion.es_derivada() && m.derivado_de.is_some() {
return Err("cuerpo :: intencion Original con `derivado_de` no nulo");
}
if m.modificado_en < m.creado_en {
return Err("cuerpo :: `modificado_en` anterior a `creado_en`");
}
Ok(())
}
/// Serializa el cuerpo a su forma binaria `postcard` — el codec ya
/// canónico del workspace (lo usa `format`/`akasha` en wawa).
pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
postcard::to_allocvec(self).map_err(|_| "cuerpo :: serializacion fallida")
}
/// Reconstruye un cuerpo desde su forma binaria.
pub fn deserializar(bytes: &[u8]) -> Result<Cuerpo, &'static str> {
postcard::from_bytes::<Cuerpo>(bytes)
.map_err(|_| "cuerpo :: deserializacion fallida")
}
}
#[cfg(test)]
mod pruebas {
use super::*;
fn ahora_test() -> u64 {
1_716_724_800
}
#[test]
fn nuevo_es_consistente_y_vacio() {
let c = Cuerpo::nuevo("es", "es (original)", Intencion::Original, ahora_test());
assert!(c.orden.is_empty());
assert!(!c.es_derivado());
assert!(c.metadatos.derivado_de.is_none());
assert_eq!(c.metadatos.creado_en, c.metadatos.modificado_en);
c.valida_consistencia().unwrap();
}
#[test]
fn derivado_exige_madre() {
// Construir un cuerpo derivado sin madre — debe detectarse.
let mut c = Cuerpo::nuevo("qu", "qu", Intencion::Traduccion, ahora_test());
assert!(c.es_derivado());
assert!(c.valida_consistencia().is_err());
c.metadatos.derivado_de = Some(Uuid::new_v4());
c.valida_consistencia().unwrap();
}
#[test]
fn original_no_puede_tener_madre() {
let mut c = Cuerpo::nuevo("es", "es", Intencion::Original, ahora_test());
c.metadatos.derivado_de = Some(Uuid::new_v4());
assert!(c.valida_consistencia().is_err());
}
#[test]
fn agregar_insertar_remover_mover_actualiza_modificado() {
let mut c = Cuerpo::nuevo("es", "es", Intencion::Original, 100);
let a = Uuid::new_v4();
let b = Uuid::new_v4();
let d = Uuid::new_v4();
c.agregar(a, 101);
c.agregar(b, 102);
c.insertar(1, d, 103);
// orden: [a, d, b]
assert_eq!(c.orden, vec![a, d, b]);
assert_eq!(c.metadatos.modificado_en, 103);
assert_eq!(c.posicion(d), Some(1));
// Mover b al inicio.
assert!(c.mover(b, 0, 104));
assert_eq!(c.orden, vec![b, a, d]);
assert_eq!(c.metadatos.modificado_en, 104);
// Remover a.
assert!(c.remover(a, 105));
assert_eq!(c.orden, vec![b, d]);
// Remover algo que no está.
assert!(!c.remover(Uuid::new_v4(), 106));
// El timestamp NO se mueve si no removió.
assert_eq!(c.metadatos.modificado_en, 105);
}
#[test]
fn insertar_mas_alla_del_final_aterriza_al_final() {
let mut c = Cuerpo::nuevo("es", "es", Intencion::Original, 0);
let a = Uuid::new_v4();
let b = Uuid::new_v4();
c.agregar(a, 1);
c.insertar(99, b, 2);
assert_eq!(c.orden, vec![a, b]);
}
#[test]
fn stale_solo_aplica_si_madre_se_modifico_despues() {
let c = Cuerpo::nuevo("qu", "qu", Intencion::Traduccion, 100)
.deriva_de(Uuid::new_v4(), 200);
// La madre no se ha tocado desde la regeneración.
assert!(!c.es_stale(150));
assert!(!c.es_stale(200));
// La madre cambió DESPUES.
assert!(c.es_stale(201));
}
#[test]
fn marcar_fresco_actualiza_ambos_timestamps() {
let mut c = Cuerpo::nuevo("qu", "qu", Intencion::Traduccion, 100)
.deriva_de(Uuid::new_v4(), 100);
c.marcar_fresco(500);
assert_eq!(c.metadatos.fresco_hasta, Some(500));
assert_eq!(c.metadatos.modificado_en, 500);
}
#[test]
fn roundtrip_postcard_es_simetrico() {
let mut c = Cuerpo::nuevo("qu", "quechua del Cuzco", Intencion::Traduccion, 1000)
.deriva_de(Uuid::new_v4(), 1000)
.con_lengua("qu");
c.agregar(Uuid::new_v4(), 1001);
c.agregar(Uuid::new_v4(), 1002);
let bytes = c.serializar().unwrap();
let recuperado = Cuerpo::deserializar(&bytes).unwrap();
assert_eq!(recuperado, c);
recuperado.valida_consistencia().unwrap();
}
#[test]
fn intencion_es_derivada_distingue_original_de_lo_demas() {
assert!(!Intencion::Original.es_derivada());
assert!(Intencion::Traduccion.es_derivada());
assert!(Intencion::Tono { etiqueta: "formal".into() }.es_derivada());
assert!(Intencion::Resumen { palabras_objetivo: Some(200) }.es_derivada());
assert!(Intencion::Reescritura { prompt: "p".into() }.es_derivada());
assert!(Intencion::Anotacion.es_derivada());
assert!(Intencion::Custom { kind: "x".into() }.es_derivada());
}
}
@@ -0,0 +1,28 @@
[package]
name = "pluma-deck-app"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "App de presentaciones espaciales (modo Recorrido, tipo Prezi): abre .deck/.md, presenta, autorea y guarda. Frontend Llimphi sobre pluma-deck-core."
[[bin]]
name = "pluma-deck"
path = "src/main.rs"
[dependencies]
# Modelo + cámara + ruta + autoría + persistencia (postcard) + adaptador pluma.
pluma-deck-core = { path = "../pluma-deck-core", features = ["pluma", "serde"] }
# Frontend Llimphi (pintura + vistas presentar/editar).
pluma-deck-recorrido-llimphi = { path = "../pluma-deck-recorrido-llimphi" }
# Para abrir documentos markdown reales → Recorrido vía el adaptador del core.
pluma-md = { path = "../pluma-md" }
llimphi-ui = { workspace = true }
# Barra de menú principal + menú contextual sobre el marco seleccionado.
llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-motion = { workspace = true }
app-bus = { workspace = true }
@@ -0,0 +1,706 @@
//! `pluma-deck` — app de **presentaciones espaciales** (modo Recorrido, tipo Prezi).
//!
//! Unifica en un solo binario lo que antes vivía repartido en demos: abrir un
//! documento, **presentarlo** (la cámara vuela por la ruta) y **autorearlo**
//! (mover/crear/borrar/rotar marcos), con guardar/cargar en el formato nativo.
//! Toda la lógica vive en `pluma-deck-core` (cámara, ruta, autoría, persistencia,
//! adaptador pluma); aquí sólo se cablean eventos y se elige la vista por modo.
//!
//! Uso:
//! `cargo run -p pluma-deck-app -- [archivo.deck | archivo.md]`
//! - `*.deck` → carga el recorrido binario (postcard) y guarda sobre él.
//! - `*.md` → importa markdown como recorrido (guarda en `recorrido.deck`).
//! - sin arg → recorrido de bienvenida (guarda en `recorrido.deck`).
//!
//! Controles comunes: **flechas / Espacio / Enter** vuela por la ruta ·
//! **Home/Esc** vista general · **p** modo presentador (autoplay) · **rueda**
//! zoom-a-cursor · **Tab** alterna presentar/editar · **Ctrl+S / Ctrl+O**
//! guarda / carga · **Ctrl+Z / Ctrl+Shift+Z** deshace / rehace autoría. En
//! **editar**: arrastrar mueve/selecciona un marco (o panea el vacío), **n**
//! crea, **Supr** elimina, **[ ]** rota. En **presentar**: arrastrar panea libre.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, FlexDirection, Size, Style};
use llimphi_ui::{App, DragPhase, Handle, Key, KeyEvent, KeyState, Modifiers, NamedKey, View, WheelDelta};
use llimphi_widget_context_menu::{
context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
};
use llimphi_widget_menubar::{
menubar_command_at, menubar_nav, menubar_overlay_animated, menubar_view, MenuBarSpec,
DEFAULT_HEIGHT as MENU_H,
};
use llimphi_motion::{animate, motion, Tween};
use pluma_deck_core::adaptador::recorrido_desde_atomos;
use pluma_deck_core::{Autoplay, ContenidoMarco, Marco, Recorrido, RecorridoState, Rect, RejillaOpts};
use pluma_deck_recorrido_llimphi::{dentro, panel_actual, recorrido_view, recorrido_view_editor, ZOOM_BASE};
use app_bus::{AppMenu, Menu, MenuItem};
const PANEL_INICIAL: Rect = Rect { x: 0.0, y: 0.0, w: 1200.0, h: 760.0 };
#[derive(Clone, Copy, PartialEq)]
enum Modo {
Presentar,
Editar,
}
#[derive(Clone)]
enum Msg {
Zoom { mult: f64, cursor: (f32, f32) },
/// Pan libre (modo presentar): delta de pantalla.
Pan { dx: f32, dy: f32 },
/// Arrastre de autoría (modo editar): delta + posición del press.
Arrastre { dx: f32, dy: f32, lx: f32, ly: f32 },
FinArrastre,
NuevoMarco,
Eliminar,
Rotar(f64),
Deshacer,
Rehacer,
Guardar,
Cargar,
ToggleModo,
VistaGeneral,
ToggleAutoplay,
Siguiente,
Anterior,
Tick,
/// Cicla el tema del chrome (barra de menú / overlays).
CambiarTema,
/// Barra de menú principal: abrir/cerrar un menú raíz (`None` cierra).
MenuOpen(Option<usize>),
/// Comando elegido en el menú principal — se traduce al `Msg` real.
MenuCommand(String),
/// Navegación de teclado en el dropdown del menú principal (±1 fila).
MenuNav(i32),
/// Enter sobre la fila activa del menú principal.
MenuActivate,
/// Tick de re-render para la animación de aparición del dropdown.
MenuTick,
/// Cierra cualquier menú abierto (click-fuera / Esc).
CloseMenus,
/// Right-click en el lienzo → menú contextual anclado en `(x, y)` de
/// ventana. En modo editar selecciona el marco bajo el cursor (si lo hay).
ContextMenuOpen(f32, f32),
}
struct Model {
rec: Recorrido,
state: RecorridoState,
autoplay: Autoplay,
modo: Modo,
seleccionado: Option<u64>,
/// `None` = sin arrastre. `Some(None)` = paneando. `Some(Some(id))` = moviendo ese marco.
arrastrando: Option<Option<u64>>,
/// Destino de Ctrl+S (postcard).
guardar_en: PathBuf,
/// Undo/redo de autoría (snapshots del recorrido).
historial: Historial<Recorrido>,
/// Tema del chrome (barra de menú / overlays). El lienzo usa su paleta propia.
theme: Theme,
/// Barra de menú principal: índice del menú raíz abierto (`None` cerrado).
menu_open: Option<usize>,
/// Fila activa (resaltada por teclado) del dropdown del menú principal.
menu_active: usize,
/// Animación de aparición/swap del dropdown del menú principal (0→1).
menu_anim: Tween<f32>,
/// Menú contextual del lienzo: `(x, y)` ancla en ventana. `None` cerrado.
context_menu: Option<(f32, f32)>,
}
/// Recorrido de bienvenida cuando no se pasa archivo.
fn bienvenida() -> Recorrido {
let slide = |t: &str, ps: &[&str]| ContenidoMarco::Texto {
titulo: Some(t.into()),
parrafos: ps.iter().map(|s| s.to_string()).collect(),
};
Recorrido::en_rejilla(
vec![
slide("pluma · deck", &["Presentaciones espaciales tipo Prezi.", "Pasá un .deck o un .md, o autoreá desde cero."]),
slide("Presentar", &["Flechas / Espacio vuelan por la ruta.", "Home/Esc: vista general. p: autoplay."]),
slide("Editar (Tab)", &["Arrastrá para mover/seleccionar un marco.", "n: nuevo Supr: borrar [ ]: rotar."]),
slide("Guardar", &["Ctrl+S guarda, Ctrl+O carga.", "Formato nativo postcard (.deck)."]),
],
RejillaOpts { cols: 2, marco_w: 640.0, marco_h: 400.0, gap_x: 220.0, gap_y: 180.0 },
)
}
/// Abre un archivo como Recorrido: `.md` se importa con el adaptador pluma;
/// cualquier otro se intenta como `.deck` binario. `None` si falla.
fn abrir(ruta: &str) -> Option<Recorrido> {
let bytes = std::fs::read(ruta).ok()?;
if ruta.ends_with(".md") {
let md = String::from_utf8(bytes).ok()?;
let doc = pluma_md::parse_md(&md, "es", "deck", 0);
Some(recorrido_desde_atomos(&doc.atoms, RejillaOpts::default()))
} else {
Recorrido::deserializar(&bytes).ok()
}
}
struct Deck;
impl App for Deck {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"pluma · deck — presentaciones espaciales (Tab: presentar/editar)"
}
fn initial_size() -> (u32, u32) {
(1200, 760)
}
fn init(handle: &Handle<Self::Msg>) -> Self::Model {
// Resuelve el recorrido inicial y el destino de guardado según el arg.
let (rec, guardar_en) = match std::env::args().nth(1) {
Some(p) if p.ends_with(".deck") => {
let r = abrir(&p).unwrap_or_else(bienvenida);
(r, PathBuf::from(p))
}
// .md u otro: importa si puede, pero guarda en un .deck para no
// sobrescribir el fuente con binario.
Some(p) => (abrir(&p).unwrap_or_else(bienvenida), PathBuf::from("recorrido.deck")),
None => (bienvenida(), PathBuf::from("recorrido.deck")),
};
let mut state = RecorridoState::new();
state.saltar_a_paso(&rec, 0, PANEL_INICIAL);
handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
Model {
rec,
state,
autoplay: Autoplay::default(),
modo: Modo::Presentar,
seleccionado: None,
arrastrando: None,
guardar_en,
historial: Historial::new(64),
theme: Theme::dark(),
menu_open: None,
menu_active: usize::MAX,
menu_anim: Tween::idle(1.0),
context_menu: None,
}
}
fn update(mut model: Self::Model, msg: Self::Msg, handle: &Handle<Self::Msg>) -> Self::Model {
let panel = panel_actual().unwrap_or(PANEL_INICIAL);
match msg {
Msg::Zoom { mult, cursor } => {
model.state.wheel(mult, (cursor.0 as f64, cursor.1 as f64), panel);
}
Msg::Pan { dx, dy } => model.state.arrastrar_delta(dx as f64, dy as f64),
Msg::Arrastre { dx, dy, lx, ly } => {
let modo = match model.arrastrando {
Some(m) => m,
None => {
let world = model.state.camara.screen_to_world((lx as f64, ly as f64), panel);
let m = model.rec.marco_en_punto(world);
model.arrastrando = Some(m);
if m.is_some() {
model.seleccionado = m;
// Una instantánea por arrastre (al agarrar el marco),
// no por cada move — undo revierte el movimiento entero.
model.historial.registrar(&model.rec);
}
m
}
};
match modo {
Some(id) => {
let (wdx, wdy) = model.state.camara.delta_pantalla_a_mundo(dx as f64, dy as f64);
model.rec.mover_marco(id, wdx, wdy);
}
None => model.state.arrastrar_delta(dx as f64, dy as f64),
}
}
Msg::FinArrastre => model.arrastrando = None,
Msg::NuevoMarco => {
model.historial.registrar(&model.rec);
let id = model.rec.marcos.iter().map(|m| m.id).max().unwrap_or(0) + 1;
let (cx, cy) = model.state.camara.centro;
let (w, h) = (520.0, 320.0);
model.rec.agregar_marco(Marco::new(
id,
Rect::new(cx - w * 0.5, cy - h * 0.5, w, h),
ContenidoMarco::Texto { titulo: Some(format!("marco {id}")), parrafos: vec![] },
));
model.rec.pasos.push(id);
model.seleccionado = Some(id);
}
Msg::Eliminar => {
if let Some(id) = model.seleccionado.take() {
model.historial.registrar(&model.rec);
model.rec.eliminar_marco(id);
let idx = model.state.paso.min(model.rec.n_pasos().saturating_sub(1));
model.state.saltar_a_paso(&model.rec, idx, panel);
}
}
Msg::Rotar(d) => {
if let Some(id) = model.seleccionado {
model.historial.registrar(&model.rec);
model.rec.rotar_marco(id, d);
}
}
Msg::Deshacer | Msg::Rehacer => {
let nuevo = match msg {
Msg::Deshacer => model.historial.deshacer(&model.rec),
_ => model.historial.rehacer(&model.rec),
};
if let Some(rec) = nuevo {
model.rec = rec;
// La selección puede haber dejado de existir; el paso se clampa.
if model.seleccionado.map_or(false, |id| model.rec.marco(id).is_none()) {
model.seleccionado = None;
}
let idx = model.state.paso.min(model.rec.n_pasos().saturating_sub(1));
model.state.saltar_a_paso(&model.rec, idx, panel);
}
}
Msg::Guardar => match model.rec.serializar() {
Ok(bytes) => {
let _ = std::fs::write(&model.guardar_en, &bytes);
eprintln!("guardado {} ({} bytes)", model.guardar_en.display(), bytes.len());
}
Err(e) => eprintln!("error al guardar: {e}"),
},
Msg::Cargar => {
let r = std::fs::read(&model.guardar_en)
.map_err(|_| "no se pudo leer")
.and_then(|b| Recorrido::deserializar(&b));
match r {
Ok(rec) => {
model.rec = rec;
model.seleccionado = None;
model.state.saltar_a_paso(&model.rec, 0, panel);
eprintln!("cargado {}", model.guardar_en.display());
}
Err(e) => eprintln!("error al cargar: {e}"),
}
}
Msg::ToggleModo => {
model.modo = match model.modo {
Modo::Presentar => Modo::Editar,
Modo::Editar => Modo::Presentar,
};
model.autoplay.pausa();
eprintln!("modo: {}", if model.modo == Modo::Editar { "editar" } else { "presentar" });
}
Msg::VistaGeneral => {
model.state.vista_general(&model.rec, panel);
}
Msg::ToggleAutoplay => {
model.autoplay.toggle();
}
Msg::Siguiente => {
model.state.siguiente(&model.rec, panel);
}
Msg::Anterior => {
model.state.anterior(&model.rec, panel);
}
Msg::Tick => {
model.state.avanzar(1.0 / 60.0);
model.autoplay.tick(1.0 / 60.0, &mut model.state, &model.rec, panel);
}
Msg::CambiarTema => {
model.theme = Theme::next_after(model.theme.name);
}
Msg::MenuOpen(which) => {
model.menu_open = which;
model.menu_active = usize::MAX;
// Abrir un menú raíz cierra cualquier contextual.
model.context_menu = None;
// Animación de aparición/swap: cada vez que se abre (o se
// cambia de) menú, el dropdown se funde+desliza de nuevo.
if which.is_some() {
model.menu_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic);
animate(handle, motion::FAST, || Msg::MenuTick);
}
}
Msg::MenuNav(dir) => {
if let Some(mi) = model.menu_open {
let menu = app_menu(&model);
model.menu_active = menubar_nav(&menu, mi, model.menu_active, dir);
}
}
Msg::MenuActivate => {
if let Some(mi) = model.menu_open {
let menu = app_menu(&model);
if let Some(cmd) = menubar_command_at(&menu, mi, model.menu_active) {
return Deck::update(model, Msg::MenuCommand(cmd), handle);
}
}
}
Msg::MenuTick => {}
Msg::CloseMenus => {
model.menu_open = None;
model.menu_active = usize::MAX;
model.context_menu = None;
}
Msg::MenuCommand(cmd) => {
model.menu_open = None;
model.menu_active = usize::MAX;
if let Some(next) = comando_a_msg(&cmd) {
return Deck::update(model, next, handle);
}
// Comandos sin Msg directo (salir / no-op).
if cmd == "file.quit" {
std::process::exit(0);
}
}
Msg::ContextMenuOpen(x, y) => {
model.menu_open = None;
// En editar, el right-click también selecciona el marco bajo el
// cursor (si lo hay) para que las acciones del menú apliquen a él.
if model.modo == Modo::Editar {
let world = model.state.camara.screen_to_world((x as f64, y as f64), panel);
if let Some(id) = model.rec.marco_en_punto(world) {
model.seleccionado = Some(id);
}
}
model.context_menu = Some((x, y));
}
}
model
}
fn view(model: &Self::Model) -> View<Self::Msg> {
let menu = app_menu(model);
let menubar = menubar_view(&menubar_spec(&menu, model));
let lienzo = match model.modo {
Modo::Editar => recorrido_view_editor(&model.rec, &model.state, model.seleccionado)
.draggable_at(|phase, dx, dy, lx, ly| match phase {
DragPhase::Move => Some(Msg::Arrastre { dx, dy, lx, ly }),
DragPhase::End => Some(Msg::FinArrastre),
}),
Modo::Presentar => recorrido_view(&model.rec, &model.state).draggable(|phase, dx, dy| match phase {
DragPhase::Move => Some(Msg::Pan { dx, dy }),
DragPhase::End => None,
}),
};
// Column raíz: barra de menú arriba + lienzo a pantalla completa debajo.
// El right-click va en la RAÍZ (origen 0,0 ⇒ local == ventana) para anclar
// el menú contextual en coords de ventana, igual que el panel del lienzo.
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(model.theme.bg_app)
.on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
.children(vec![menubar, lienzo])
}
fn view_overlay(model: &Self::Model) -> Option<View<Self::Msg>> {
// Menú contextual del lienzo tiene prioridad sobre el dropdown principal.
if let Some((x, y)) = model.context_menu {
let viewport = viewport_of(model);
// Acciones según el modo. En editar, sobre el marco seleccionado;
// en presentar, navegación. Sólo comandos que mapean a Msg reales.
let (header, items, on_pick): (String, Vec<ContextMenuItem>, Arc<dyn Fn(usize) -> Msg + Send + Sync>) =
if model.modo == Modo::Editar {
let hay_sel = model.seleccionado.is_some();
// Las acciones sobre marco se deshabilitan (gris) sin selección.
let opt = |it: ContextMenuItem| if hay_sel { it } else { it.disabled() };
let rotar_l = opt(ContextMenuItem::action("Rotar ⟲"));
let rotar_r = opt(ContextMenuItem::action("Rotar ⟳"));
let borrar = opt(ContextMenuItem::action("Eliminar marco").destructive());
let items = vec![
ContextMenuItem::action("Nuevo marco"),
rotar_l,
rotar_r,
ContextMenuItem::separator(),
borrar,
];
let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(|i: usize| match i {
0 => Msg::NuevoMarco,
1 => Msg::Rotar(-0.08),
2 => Msg::Rotar(0.08),
4 => Msg::Eliminar,
_ => Msg::CloseMenus,
});
("Editar marco".to_string(), items, on_pick)
} else {
let items = vec![
ContextMenuItem::action("Siguiente"),
ContextMenuItem::action("Anterior"),
ContextMenuItem::action("Vista general"),
ContextMenuItem::separator(),
ContextMenuItem::action("Presentar (autoplay)"),
];
let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(|i: usize| match i {
0 => Msg::Siguiente,
1 => Msg::Anterior,
2 => Msg::VistaGeneral,
4 => Msg::ToggleAutoplay,
_ => Msg::CloseMenus,
});
("Presentar".to_string(), items, on_pick)
};
return Some(context_menu_view(ContextMenuSpec {
anchor: (x, y),
viewport,
header: Some(header),
items,
active: usize::MAX,
on_pick,
on_dismiss: Msg::CloseMenus,
palette: ContextMenuPalette::from_theme(&model.theme),
}));
}
// Si no, el dropdown del menú principal (con nav por teclado + animación).
let menu = app_menu(model);
menubar_overlay_animated(
&menubar_spec(&menu, model),
model.menu_active,
model.menu_anim.value(),
)
}
fn on_wheel(_m: &Self::Model, delta: WheelDelta, cursor: (f32, f32), _mods: Modifiers) -> Option<Self::Msg> {
let panel = panel_actual()?;
if !dentro(panel, cursor.0, cursor.1) {
return None;
}
Some(Msg::Zoom { mult: ZOOM_BASE.powf(-delta.y as f64), cursor })
}
fn on_key(model: &Self::Model, ev: &KeyEvent) -> Option<Self::Msg> {
if ev.state != KeyState::Pressed {
return None;
}
// Menú principal abierto: las flechas navegan. ←/→ cambian de menú
// raíz (con wrap), ↑/↓ mueven la fila activa, Enter ejecuta, Esc
// cierra. Tiene prioridad sobre todo lo demás.
if let Some(mi) = model.menu_open {
let n = app_menu(model).menus.len().max(1);
return match &ev.key {
Key::Named(NamedKey::Escape) => Some(Msg::CloseMenus),
Key::Named(NamedKey::ArrowLeft) => Some(Msg::MenuOpen(Some((mi + n - 1) % n))),
Key::Named(NamedKey::ArrowRight) => Some(Msg::MenuOpen(Some((mi + 1) % n))),
Key::Named(NamedKey::ArrowDown) => Some(Msg::MenuNav(1)),
Key::Named(NamedKey::ArrowUp) => Some(Msg::MenuNav(-1)),
Key::Named(NamedKey::Enter) => Some(Msg::MenuActivate),
_ => None,
};
}
// Guardar/cargar en cualquier modo.
if ev.modifiers.ctrl {
return match &ev.key {
Key::Character(c) if c.eq_ignore_ascii_case("s") => Some(Msg::Guardar),
Key::Character(c) if c.eq_ignore_ascii_case("o") => Some(Msg::Cargar),
// Ctrl+Z deshace; Ctrl+Shift+Z o Ctrl+Y rehacen.
Key::Character(c) if c.eq_ignore_ascii_case("z") => {
Some(if ev.modifiers.shift { Msg::Rehacer } else { Msg::Deshacer })
}
Key::Character(c) if c.eq_ignore_ascii_case("y") => Some(Msg::Rehacer),
_ => None,
};
}
// Comunes a ambos modos.
match &ev.key {
Key::Named(NamedKey::Tab) => return Some(Msg::ToggleModo),
Key::Named(NamedKey::Home | NamedKey::Escape) => return Some(Msg::VistaGeneral),
Key::Named(NamedKey::ArrowRight | NamedKey::ArrowDown | NamedKey::Enter | NamedKey::Space) => {
return Some(Msg::Siguiente)
}
Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowUp) => return Some(Msg::Anterior),
Key::Character(c) if c.eq_ignore_ascii_case("p") => return Some(Msg::ToggleAutoplay),
_ => {}
}
// Sólo en modo editar.
if model.modo == Modo::Editar {
return match &ev.key {
Key::Character(c) if c.as_str() == "n" => Some(Msg::NuevoMarco),
Key::Character(c) if c.as_str() == "[" => Some(Msg::Rotar(-0.08)),
Key::Character(c) if c.as_str() == "]" => Some(Msg::Rotar(0.08)),
Key::Named(NamedKey::Delete | NamedKey::Backspace) => Some(Msg::Eliminar),
_ => None,
};
}
None
}
}
/// Pila de undo/redo genérica para autoría. Antes de cada cambio se `registrar`a
/// el estado previo; `deshacer`/`rehacer` mueven el estado entre pasado y futuro.
/// Acotada a `max` entradas (descarta las más viejas).
struct Historial<T> {
pasado: Vec<T>,
futuro: Vec<T>,
max: usize,
}
impl<T: Clone> Historial<T> {
fn new(max: usize) -> Self {
Self { pasado: Vec::new(), futuro: Vec::new(), max: max.max(1) }
}
/// Registra `actual` (estado **previo** al cambio que está por aplicarse) y
/// limpia el redo — una rama nueva invalida los rehacer pendientes.
fn registrar(&mut self, actual: &T) {
self.pasado.push(actual.clone());
if self.pasado.len() > self.max {
self.pasado.remove(0);
}
self.futuro.clear();
}
/// Deshace: devuelve el último estado registrado y manda `actual` al futuro.
fn deshacer(&mut self, actual: &T) -> Option<T> {
let prev = self.pasado.pop()?;
self.futuro.push(actual.clone());
Some(prev)
}
/// Rehace: devuelve el último estado deshecho y manda `actual` al pasado.
fn rehacer(&mut self, actual: &T) -> Option<T> {
let next = self.futuro.pop()?;
self.pasado.push(actual.clone());
Some(next)
}
}
/// Viewport para clampear overlays. El deck no trackea el resize, así que
/// usamos el tamaño inicial — basta para anclar los menús dentro de pantalla.
fn viewport_of(_model: &Model) -> (f32, f32) {
let (w, h) = Deck::initial_size();
(w as f32, h as f32)
}
/// Arma el `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`.
fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
MenuBarSpec {
menu,
open: model.menu_open,
theme: &model.theme,
viewport: viewport_of(model),
height: MENU_H,
on_open: Arc::new(Msg::MenuOpen),
on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
}
}
/// Menú principal del deck. Sólo comandos que mapean a acciones reales del
/// `update`. "Editar" (Slide) aparece siempre porque la autoría no necesita
/// foco de texto; sus ítems se deshabilitan según el modo / selección reales.
fn app_menu(model: &Model) -> AppMenu {
let editar = model.modo == Modo::Editar;
let hay_sel = model.seleccionado.is_some();
// Helper: ítem habilitado/deshabilitado según condición real.
let item = |label: &str, cmd: &str, on: bool| {
let it = MenuItem::new(label, cmd);
if on { it } else { it.disabled() }
};
AppMenu::new()
.menu(
Menu::new("Archivo")
.item(MenuItem::new("Guardar", "file.save").shortcut("Ctrl+S"))
.item(MenuItem::new("Cargar", "file.open").shortcut("Ctrl+O")),
)
.menu(
Menu::new("Editar")
.item(MenuItem::new("Deshacer", "edit.undo").shortcut("Ctrl+Z"))
.item(MenuItem::new("Rehacer", "edit.redo").shortcut("Ctrl+Shift+Z").separated())
.item(item("Rotar ⟲", "edit.rotar_l", editar && hay_sel))
.item(item("Rotar ⟳", "edit.rotar_r", editar && hay_sel)),
)
.menu(
Menu::new("Slide")
.item(item("Nuevo marco", "slide.nuevo", editar).shortcut("n"))
.item(item("Eliminar marco", "slide.eliminar", editar && hay_sel).shortcut("Supr"))
.item(MenuItem::new("Siguiente", "slide.siguiente").shortcut("").separated())
.item(MenuItem::new("Anterior", "slide.anterior").shortcut("")),
)
.menu(
Menu::new("Ver")
.item(MenuItem::new(
if editar { "Presentar (Tab)" } else { "Editar (Tab)" },
"view.modo",
))
.item(MenuItem::new("Vista general", "view.general").shortcut("Home"))
.item(MenuItem::new("Autoplay", "view.autoplay").shortcut("p"))
.item(MenuItem::new("Cambiar tema", "view.theme").separated()),
)
.menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
}
/// Traduce un command id del menú principal al `Msg` real del deck. `None` para
/// los que no tienen Msg directo (`file.quit` se maneja aparte; `help.about` no-op).
fn comando_a_msg(cmd: &str) -> Option<Msg> {
Some(match cmd {
"file.save" => Msg::Guardar,
"file.open" => Msg::Cargar,
"edit.undo" => Msg::Deshacer,
"edit.redo" => Msg::Rehacer,
"edit.rotar_l" => Msg::Rotar(-0.08),
"edit.rotar_r" => Msg::Rotar(0.08),
"slide.nuevo" => Msg::NuevoMarco,
"slide.eliminar" => Msg::Eliminar,
"slide.siguiente" => Msg::Siguiente,
"slide.anterior" => Msg::Anterior,
"view.modo" => Msg::ToggleModo,
"view.general" => Msg::VistaGeneral,
"view.autoplay" => Msg::ToggleAutoplay,
"view.theme" => Msg::CambiarTema,
_ => return None,
})
}
fn main() {
llimphi_ui::run::<Deck>();
}
#[cfg(test)]
mod pruebas {
use super::Historial;
#[test]
fn deshacer_rehacer_round_trip() {
let mut h = Historial::new(10);
// estado: 0 → (registrar 0) → 1 → (registrar 1) → 2
h.registrar(&0);
h.registrar(&1);
let mut actual = 2;
// deshacer dos veces: 2→1→0
actual = h.deshacer(&actual).unwrap();
assert_eq!(actual, 1);
actual = h.deshacer(&actual).unwrap();
assert_eq!(actual, 0);
assert!(h.deshacer(&actual).is_none(), "sin más pasado");
// rehacer dos veces: 0→1→2
actual = h.rehacer(&actual).unwrap();
assert_eq!(actual, 1);
actual = h.rehacer(&actual).unwrap();
assert_eq!(actual, 2);
assert!(h.rehacer(&actual).is_none(), "sin más futuro");
}
#[test]
fn registrar_tras_deshacer_limpia_el_futuro() {
let mut h = Historial::new(10);
h.registrar(&0);
let actual = h.deshacer(&1).unwrap(); // actual ahora = 0, futuro = [1]
assert_eq!(actual, 0);
h.registrar(&actual); // rama nueva: el futuro se descarta
assert!(h.rehacer(&actual).is_none());
}
#[test]
fn respeta_el_tope_descartando_lo_mas_viejo() {
let mut h = Historial::new(2);
h.registrar(&1);
h.registrar(&2);
h.registrar(&3); // descarta el 1
assert_eq!(h.deshacer(&99), Some(3));
assert_eq!(h.deshacer(&3), Some(2));
assert!(h.deshacer(&2).is_none());
}
}
@@ -0,0 +1,25 @@
[package]
name = "pluma-deck-core"
description = "Vista — máquina de estados agnóstica para deck horizontal con snap. Sin dependencias web/DOM."
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
[features]
# Adaptador opcional pluma → Recorrido. El core sigue agnóstico por defecto
# (cero dependencias); activar `pluma` trae el puente desde el modelo de
# documento de pluma (NarrativeAtom / Cuerpo) hacia los slides del Recorrido.
pluma = ["dep:pluma-core", "dep:pluma-cuerpo", "dep:uuid"]
# Persistencia del Recorrido en el codec nativo del workspace (postcard, el
# mismo de format/akasha/pluma-cuerpo). Deriva serde sobre el modelo de datos
# (Rect/Camara/Marco/ContenidoMarco/Recorrido) + helpers serializar/deserializar.
serde = ["dep:serde", "dep:postcard"]
[dependencies]
pluma-core = { path = "../pluma-core", optional = true }
pluma-cuerpo = { path = "../pluma-cuerpo", optional = true }
uuid = { workspace = true, optional = true }
serde = { workspace = true, optional = true, features = ["derive"] }
postcard = { workspace = true, optional = true }
@@ -0,0 +1,18 @@
# pluma-deck-core
> Deck (slides) sobre [pluma](../README.md).
Modelo de slides: un `Deck` es una secuencia de `Slide`, cada slide es un subgrafo del documento + layout hint (full · split · cover). Sin UI ni render. Sirve como capa de organización para presentaciones cuya fuente sigue siendo el doc pluma.
## API
```rust
use pluma_deck_core::{Deck, Slide};
let deck = Deck::from_doc(&doc, &slide_breaks);
```
## Deps
- [`pluma-core`](../pluma-core/README.md), [`pluma-cuerpo`](../pluma-cuerpo/README.md)
- `serde`
@@ -0,0 +1,18 @@
# pluma-deck-core
> Deck (slides) on [pluma](../README.md).
Slide model: a `Deck` is a sequence of `Slide`, each slide is a subgraph of the document + layout hint (full · split · cover). No UI or render. Acts as an organization layer for presentations whose source remains the pluma doc.
## API
```rust
use pluma_deck_core::{Deck, Slide};
let deck = Deck::from_doc(&doc, &slide_breaks);
```
## Deps
- [`pluma-core`](../pluma-core/README.md), [`pluma-cuerpo`](../pluma-cuerpo/README.md)
- `serde`
@@ -0,0 +1,141 @@
//! Adaptador opcional **pluma → Recorrido** (feature `pluma`).
//!
//! Puente entre el modelo de documento de pluma ([`NarrativeAtom`] /
//! [`Cuerpo`]) y los slides agnósticos de [`crate::ContenidoMarco`]. Promovido
//! desde el glue que vivía inline en el example `recorrido_md_demo` — ahora es
//! reusable y testeado. El core sigue agnóstico: este módulo sólo compila con
//! la feature `pluma` activa; sin ella, `pluma-deck-core` no conoce pluma.
//!
//! Regla de agrupación: un átomo cuyo texto arranca con `#`+espacio (encabezado
//! markdown) abre un slide nuevo cuyo título es ese encabezado; los demás átomos
//! son párrafos del slide actual. Es el mismo criterio que `pluma-md` usa al
//! emitir un átomo por bloque.
use pluma_core::NarrativeAtom;
use pluma_cuerpo::Cuerpo;
use uuid::Uuid;
use crate::{ContenidoMarco, Recorrido, RejillaOpts};
/// Cierra el slide en construcción (si tiene algo) y lo empuja a `slides`.
fn empujar(slides: &mut Vec<ContenidoMarco>, titulo: &mut Option<String>, parrafos: &mut Vec<String>) {
if titulo.is_some() || !parrafos.is_empty() {
slides.push(ContenidoMarco::Texto {
titulo: titulo.take(),
parrafos: std::mem::take(parrafos),
});
}
}
/// Agrupa una secuencia de textos (en orden de documento) en slides. Un texto
/// que arranca con `#`+espacio abre un slide nuevo (su título = el encabezado
/// sin los `#`); los textos vacíos se ignoran; el resto son párrafos del slide
/// actual. Es la pieza pura — `recorrido_desde_*` la alimentan.
pub fn slides_desde_textos<'a>(textos: impl IntoIterator<Item = &'a str>) -> Vec<ContenidoMarco> {
let mut slides = Vec::new();
let mut titulo: Option<String> = None;
let mut parrafos: Vec<String> = Vec::new();
for c in textos {
let hashes = c.chars().take_while(|&ch| ch == '#').count();
let es_encabezado = hashes > 0 && c[hashes..].starts_with(' ');
if es_encabezado {
empujar(&mut slides, &mut titulo, &mut parrafos);
titulo = Some(c[hashes..].trim().to_string());
} else if !c.trim().is_empty() {
parrafos.push(c.to_string());
}
}
empujar(&mut slides, &mut titulo, &mut parrafos);
slides
}
/// Recorrido desde una secuencia de átomos en **orden de documento** (un
/// encabezado abre slide). `opts` controla el auto-layout en rejilla.
pub fn recorrido_desde_atomos(atomos: &[NarrativeAtom], opts: RejillaOpts) -> Recorrido {
Recorrido::en_rejilla(slides_desde_textos(atomos.iter().map(|a| a.content.as_str())), opts)
}
/// Recorrido desde un [`Cuerpo`]: recorre `cuerpo.orden` resolviendo cada `Uuid`
/// a su átomo con `resolver` (el cuerpo no conoce el grafo, lo resuelve el
/// caller); los ids que no resuelven se omiten. Así un lienzo del haz multilienzo
/// alimenta una presentación sin que el core conozca el `NarrativeGraph`.
pub fn recorrido_desde_cuerpo<'a>(
cuerpo: &Cuerpo,
resolver: impl Fn(&Uuid) -> Option<&'a NarrativeAtom>,
opts: RejillaOpts,
) -> Recorrido {
let textos = cuerpo.orden.iter().filter_map(|id| resolver(id)).map(|a| a.content.as_str());
Recorrido::en_rejilla(slides_desde_textos(textos), opts)
}
#[cfg(test)]
mod tests {
use super::*;
use pluma_cuerpo::Intencion;
fn atom(c: &str) -> NarrativeAtom {
NarrativeAtom::new(c, "es")
}
fn como_texto(c: &ContenidoMarco) -> (Option<&str>, &[String]) {
match c {
ContenidoMarco::Texto { titulo, parrafos } => (titulo.as_deref(), parrafos.as_slice()),
_ => panic!("se esperaba ContenidoMarco::Texto"),
}
}
#[test]
fn slides_agrupan_por_encabezado() {
let textos = ["# Título A", "p1", "", "p2", "## Título B", "p3"];
let slides = slides_desde_textos(textos.iter().copied());
assert_eq!(slides.len(), 2);
let (t0, p0) = como_texto(&slides[0]);
assert_eq!(t0, Some("Título A"));
assert_eq!(p0, &["p1".to_string(), "p2".to_string()]); // el vacío se ignora
let (t1, p1) = como_texto(&slides[1]);
assert_eq!(t1, Some("Título B"));
assert_eq!(p1, &["p3".to_string()]);
}
#[test]
fn parrafos_sueltos_sin_encabezado_forman_un_slide_sin_titulo() {
let slides = slides_desde_textos(["solo párrafo", "y otro"].iter().copied());
assert_eq!(slides.len(), 1);
let (t, p) = como_texto(&slides[0]);
assert_eq!(t, None);
assert_eq!(p.len(), 2);
}
#[test]
fn recorrido_desde_atomos_rutea_en_orden_de_lectura() {
let atomos = vec![atom("# Uno"), atom("cuerpo de uno"), atom("# Dos")];
let rec = recorrido_desde_atomos(&atomos, RejillaOpts::default());
assert_eq!(rec.marcos.len(), 2);
assert_eq!(rec.pasos, vec![1, 2]);
}
#[test]
fn recorrido_desde_cuerpo_resuelve_el_orden_y_omite_faltantes() {
let a = atom("# Hola");
let b = atom("mundo");
let mut cuerpo = Cuerpo::nuevo("es", "doc", Intencion::Original, 0);
cuerpo.agregar(a.id, 0);
cuerpo.agregar(b.id, 0);
cuerpo.agregar(Uuid::new_v4(), 0); // id huérfano: no resuelve
let resolver = |id: &Uuid| {
if *id == a.id {
Some(&a)
} else if *id == b.id {
Some(&b)
} else {
None
}
};
let rec = recorrido_desde_cuerpo(&cuerpo, resolver, RejillaOpts::default());
// Un solo slide: "Hola" con el párrafo "mundo"; el huérfano se omitió.
assert_eq!(rec.marcos.len(), 1);
let (t, p) = como_texto(&rec.marcos[0].contenido);
assert_eq!(t, Some("Hola"));
assert_eq!(p, &["mundo".to_string()]);
}
}
@@ -0,0 +1,257 @@
//! Cámara 2D para el modo `Recorrido` (presentación espacial, tipo Prezi).
//!
//! Matemática agnóstica del lienzo infinito: convierte entre coordenadas de
//! *mundo* (donde viven los marcos) y coordenadas de *pantalla* (px del panel),
//! con zoom + paneo + giro. Es la versión extraída y generalizada del zoom/pan
//! que `tullpu-app-llimphi` resolvió inline (`factor_zoom`/`pan`/zoom-a-cursor):
//! aquí vive como tipo reusable, sin render ni DOM.
//!
//! Convención: `centro` es el punto de mundo que cae en el centro del panel;
//! `zoom` es el factor mundo→px (zoom 2.0 ⇒ 1 unidad de mundo mide 2 px);
//! `rot_rad` gira la vista (la pantalla rota `-rot` respecto al mundo, de modo
//! que un marco con `rot_rad` propio se ve recto cuando la cámara lo iguala).
/// Clamp inferior del zoom — más allá la presentación se pierde en el infinito.
pub const ZOOM_MIN: f64 = 0.02;
/// Clamp superior del zoom — más allá se pixela el contenido.
pub const ZOOM_MAX: f64 = 64.0;
/// Fracción del panel que ocupa un marco al hacer `fit_marco` (deja aire).
pub const FIT_MARGEN: f64 = 0.9;
/// Rectángulo axis-aligned. Sirve tanto para el panel (px de pantalla) como
/// para el `rect` de un marco (coords de mundo).
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Rect {
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
}
impl Rect {
pub fn new(x: f64, y: f64, w: f64, h: f64) -> Self {
Self { x, y, w, h }
}
/// Centro geométrico.
pub fn centro(&self) -> (f64, f64) {
(self.x + self.w * 0.5, self.y + self.h * 0.5)
}
}
/// Función de suavizado para la interpolación entre pasos del recorrido.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Ease {
/// Velocidad constante.
Lineal,
/// Arranca y frena suave (`smoothstep`). Es el sabor "vuelo Prezi".
#[default]
SuaveInOut,
}
impl Ease {
/// Mapea `t ∈ [0,1]` a la curva elegida (también en `[0,1]`).
pub fn aplicar(self, t: f64) -> f64 {
let t = t.clamp(0.0, 1.0);
match self {
Ease::Lineal => t,
Ease::SuaveInOut => t * t * (3.0 - 2.0 * t),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Camara {
/// Punto de mundo que cae en el centro del panel.
pub centro: (f64, f64),
/// Factor mundo→px. Siempre dentro de `[ZOOM_MIN, ZOOM_MAX]`.
pub zoom: f64,
/// Giro de la vista en radianes.
pub rot_rad: f64,
}
impl Default for Camara {
fn default() -> Self {
Self { centro: (0.0, 0.0), zoom: 1.0, rot_rad: 0.0 }
}
}
impl Camara {
pub fn new(centro: (f64, f64), zoom: f64, rot_rad: f64) -> Self {
Self { centro, zoom: zoom.clamp(ZOOM_MIN, ZOOM_MAX), rot_rad }
}
/// Mundo → pantalla. `panel` son los px del viewport donde se pinta.
pub fn world_to_screen(&self, p: (f64, f64), panel: Rect) -> (f64, f64) {
let (cx, cy) = panel.centro();
let dx = p.0 - self.centro.0;
let dy = p.1 - self.centro.1;
// La pantalla rota -rot respecto al mundo.
let (s, c) = (-self.rot_rad).sin_cos();
let rx = dx * c - dy * s;
let ry = dx * s + dy * c;
(cx + rx * self.zoom, cy + ry * self.zoom)
}
/// Pantalla → mundo. Inversa exacta de [`world_to_screen`](Self::world_to_screen).
pub fn screen_to_world(&self, p: (f64, f64), panel: Rect) -> (f64, f64) {
let (cx, cy) = panel.centro();
let sx = (p.0 - cx) / self.zoom;
let sy = (p.1 - cy) / self.zoom;
let (s, c) = self.rot_rad.sin_cos();
let wx = sx * c - sy * s;
let wy = sx * s + sy * c;
(self.centro.0 + wx, self.centro.1 + wy)
}
/// Wheel zoom anclado al cursor: el punto de mundo bajo `cursor` queda
/// fijo en pantalla tras escalar por `mult` (`mult>1` acerca). Réplica del
/// zoom-a-cursor de tullpu. `panel`/`cursor` en px de pantalla.
pub fn zoom_a_cursor(&mut self, mult: f64, cursor: (f64, f64), panel: Rect) {
let ancla = self.screen_to_world(cursor, panel);
self.zoom = (self.zoom * mult).clamp(ZOOM_MIN, ZOOM_MAX);
// Recolocar `centro` para que `ancla` vuelva a caer bajo `cursor`.
let (cx, cy) = panel.centro();
let sx = (cursor.0 - cx) / self.zoom;
let sy = (cursor.1 - cy) / self.zoom;
let (s, c) = self.rot_rad.sin_cos();
let wx = sx * c - sy * s;
let wy = sx * s + sy * c;
self.centro = (ancla.0 - wx, ancla.1 - wy);
}
/// Convierte un delta de pantalla (px) en su delta de mundo equivalente,
/// deshaciendo zoom + giro. Útil para mover un objeto siguiendo al cursor
/// (`objeto += delta`) o para panear (`centro -= delta`).
pub fn delta_pantalla_a_mundo(&self, dx: f64, dy: f64) -> (f64, f64) {
let (s, c) = self.rot_rad.sin_cos();
((dx * c - dy * s) / self.zoom, (dx * s + dy * c) / self.zoom)
}
/// Paneo: arrastra el contenido `(dx, dy)` px de pantalla. El punto de
/// mundo bajo el cursor sigue al dedo.
pub fn pan(&mut self, dx: f64, dy: f64) {
let (wx, wy) = self.delta_pantalla_a_mundo(dx, dy);
self.centro = (self.centro.0 - wx, self.centro.1 - wy);
}
/// Cámara que centra y encuadra (`contain`) `marco` en `panel`, igualando
/// su giro para que se vea recto. Deja `FIT_MARGEN` de aire.
pub fn fit(marco: Rect, marco_rot_rad: f64, panel: Rect) -> Camara {
let zw = if marco.w > 0.0 { panel.w / marco.w } else { 1.0 };
let zh = if marco.h > 0.0 { panel.h / marco.h } else { 1.0 };
let zoom = (zw.min(zh) * FIT_MARGEN).clamp(ZOOM_MIN, ZOOM_MAX);
Camara { centro: marco.centro(), zoom, rot_rad: marco_rot_rad }
}
/// Interpola dos cámaras. El zoom se mezcla en **espacio logarítmico**
/// (un acercamiento percibido constante — el "vuelo" suave de Prezi);
/// centro y giro, linealmente. `t` se pasa por `ease` antes de mezclar.
pub fn interpolar(a: &Camara, b: &Camara, t: f64, ease: Ease) -> Camara {
let u = ease.aplicar(t);
let lerp = |x: f64, y: f64| x + (y - x) * u;
Camara {
centro: (lerp(a.centro.0, b.centro.0), lerp(a.centro.1, b.centro.1)),
zoom: (a.zoom.ln() * (1.0 - u) + b.zoom.ln() * u).exp(),
rot_rad: lerp(a.rot_rad, b.rot_rad),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const PANEL: Rect = Rect { x: 0.0, y: 0.0, w: 800.0, h: 600.0 };
fn aprox(a: (f64, f64), b: (f64, f64)) {
assert!((a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6, "{a:?} != {b:?}");
}
#[test]
fn centro_cae_en_centro_de_panel() {
let cam = Camara::new((10.0, 20.0), 2.0, 0.0);
aprox(cam.world_to_screen((10.0, 20.0), PANEL), (400.0, 300.0));
}
#[test]
fn round_trip_world_screen_sin_giro() {
let cam = Camara::new((5.0, -3.0), 1.7, 0.0);
let p = (123.0, 456.0);
aprox(cam.world_to_screen(cam.screen_to_world(p, PANEL), PANEL), p);
}
#[test]
fn round_trip_world_screen_con_giro() {
let cam = Camara::new((5.0, -3.0), 1.7, 0.6);
let p = (123.0, 456.0);
aprox(cam.world_to_screen(cam.screen_to_world(p, PANEL), PANEL), p);
}
#[test]
fn zoom_a_cursor_deja_fijo_el_punto_bajo_el_cursor() {
let mut cam = Camara::new((0.0, 0.0), 1.0, 0.0);
let cursor = (650.0, 120.0);
let mundo_antes = cam.screen_to_world(cursor, PANEL);
cam.zoom_a_cursor(1.1, cursor, PANEL);
// El mismo punto de mundo debe seguir bajo el cursor.
aprox(cam.world_to_screen(mundo_antes, PANEL), cursor);
assert!((cam.zoom - 1.1).abs() < 1e-9);
}
#[test]
fn zoom_a_cursor_respeta_clamps() {
let mut cam = Camara::new((0.0, 0.0), ZOOM_MAX, 0.0);
cam.zoom_a_cursor(10.0, (400.0, 300.0), PANEL);
assert_eq!(cam.zoom, ZOOM_MAX);
cam.zoom = ZOOM_MIN;
cam.zoom_a_cursor(0.001, (400.0, 300.0), PANEL);
assert_eq!(cam.zoom, ZOOM_MIN);
}
#[test]
fn pan_mueve_el_punto_bajo_el_cursor() {
let mut cam = Camara::new((0.0, 0.0), 2.0, 0.0);
let antes = cam.world_to_screen((0.0, 0.0), PANEL);
cam.pan(30.0, -10.0);
let despues = cam.world_to_screen((0.0, 0.0), PANEL);
aprox((despues.0 - antes.0, despues.1 - antes.1), (30.0, -10.0));
}
#[test]
fn fit_centra_y_encuadra() {
let marco = Rect::new(100.0, 100.0, 400.0, 200.0);
let cam = Camara::fit(marco, 0.0, PANEL);
// Centra el marco.
assert_eq!(cam.centro, (300.0, 200.0));
// Encaja por el eje más ajustado (800/400=2 vs 600/200=3 → 2) con margen.
assert!((cam.zoom - 2.0 * FIT_MARGEN).abs() < 1e-9);
// El centro del marco cae en el centro del panel.
aprox(cam.world_to_screen((300.0, 200.0), PANEL), (400.0, 300.0));
}
#[test]
fn interpolar_extremos_y_zoom_logaritmico() {
let a = Camara::new((0.0, 0.0), 1.0, 0.0);
let b = Camara::new((10.0, 20.0), 4.0, 1.0);
assert_eq!(Camara::interpolar(&a, &b, 0.0, Ease::Lineal), a);
assert_eq!(Camara::interpolar(&a, &b, 1.0, Ease::Lineal), b);
// En t=0.5 lineal el zoom es la media geométrica (2.0), no la aritmética (2.5).
let m = Camara::interpolar(&a, &b, 0.5, Ease::Lineal);
assert!((m.zoom - 2.0).abs() < 1e-9);
assert_eq!(m.centro, (5.0, 10.0));
}
#[test]
fn ease_suave_extremos_y_simetria() {
assert_eq!(Ease::SuaveInOut.aplicar(0.0), 0.0);
assert_eq!(Ease::SuaveInOut.aplicar(1.0), 1.0);
assert!((Ease::SuaveInOut.aplicar(0.5) - 0.5).abs() < 1e-9);
// Clamp fuera de rango.
assert_eq!(Ease::SuaveInOut.aplicar(-1.0), 0.0);
assert_eq!(Ease::SuaveInOut.aplicar(2.0), 1.0);
}
}
@@ -0,0 +1,195 @@
//! Deck core — máquinas de estado agnósticas para presentar, en dos modos:
//!
//! - **Lineal** ([`DeckState`], este archivo): strip horizontal de páginas con
//! drag/snap. Dados los eventos crudos de pointer (coords + viewport width
//! + n_pages) decide cuándo arrancar drag horizontal, cuánto trasladar el
//! strip y a qué página snapear al soltar.
//! - **Espacial** ([`recorrido`], tipo Prezi): lienzo 2D infinito con marcos en
//! coordenadas de mundo y una ruta de cámara ([`camara::Camara`]) que vuela
//! entre ellos con zoom/pan/giro. El modo lineal es su caso degenerado.
//!
//! Todo agnóstico: sin DOM, sin wasm-bindgen, sin render.
pub mod camara;
pub mod recorrido;
/// Adaptador opcional pluma → Recorrido. Sólo con la feature `pluma`.
#[cfg(feature = "pluma")]
pub mod adaptador;
pub use camara::{Camara, Ease, Rect, FIT_MARGEN, ZOOM_MAX, ZOOM_MIN};
pub use recorrido::{
Autoplay, ContenidoMarco, Marco, MarcoId, Recorrido, RecorridoState, RejillaOpts,
DURACION_PASO_S, DWELL_S,
};
/// Umbral en pixels para confirmar gesto horizontal vs vertical.
pub const DRAG_DECISION_PX: f64 = 8.0;
/// Cuán más horizontal que vertical debe ser el delta para considerarse "swipe".
pub const HORIZONTAL_BIAS: f64 = 1.3;
#[derive(Clone, Debug, Default)]
pub struct DeckState {
pub current_index: usize,
pointer_start: Option<(f64, f64, i32)>,
drag_active: bool,
drag_start_offset: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub enum DragOutcome {
/// Aún no hay decisión — esperar más movimiento.
Idle,
/// Empezar drag horizontal: el host debe capturar el pointer.
StartHorizontal { pointer_id: i32 },
/// Movimiento vertical predominante — host debe ceder al scroll nativo.
CancelVertical,
/// Drag en curso — host debe trasladar el strip a este offset.
DragOffset(f64),
}
#[derive(Clone, Debug, PartialEq)]
pub struct SnapResult {
pub target_index: usize,
pub offset_px: f64,
pub changed: bool,
}
impl DeckState {
pub fn new() -> Self { Self::default() }
/// Marca el inicio de un gesto. `viewport_width` se usa para anclar el
/// drag_start_offset a la página visible actual.
pub fn pointer_down(&mut self, x: f64, y: f64, pointer_id: i32, viewport_width: f64) {
self.pointer_start = Some((x, y, pointer_id));
self.drag_active = false;
self.drag_start_offset = -(self.current_index as f64) * viewport_width;
}
/// Procesa un movimiento. Devuelve la acción que el host debe ejecutar.
pub fn pointer_move(&mut self, x: f64, y: f64) -> DragOutcome {
let Some((sx, sy, pid)) = self.pointer_start else {
return DragOutcome::Idle;
};
let dx = x - sx;
let dy = y - sy;
if !self.drag_active {
let abs_dx = dx.abs();
let abs_dy = dy.abs();
if abs_dx > DRAG_DECISION_PX && abs_dx > abs_dy * HORIZONTAL_BIAS {
self.drag_active = true;
return DragOutcome::StartHorizontal { pointer_id: pid };
} else if abs_dy > DRAG_DECISION_PX {
self.pointer_start = None;
return DragOutcome::CancelVertical;
} else {
return DragOutcome::Idle;
}
}
DragOutcome::DragOffset(self.drag_start_offset + dx)
}
/// Finaliza el gesto. Si había drag activo, calcula la página snap y
/// actualiza `current_index`. `current_offset` viene del estado real
/// del strip (el host lee CSS transform / variable).
pub fn pointer_end(
&mut self,
current_offset: f64,
viewport_width: f64,
n_pages: usize,
) -> Option<SnapResult> {
let was_active = self.drag_active;
self.drag_active = false;
self.pointer_start = None;
if !was_active || viewport_width <= 0.0 || n_pages == 0 {
return None;
}
let raw = -current_offset / viewport_width;
let target = (raw.round().max(0.0) as usize).min(n_pages - 1);
let offset_px = -(target as f64) * viewport_width;
let changed = self.current_index != target;
self.current_index = target;
Some(SnapResult { target_index: target, offset_px, changed })
}
/// Salto programático (click en tabs externos). Devuelve el offset
/// resultante para que el host lo aplique al strip.
pub fn goto(&mut self, index: usize, viewport_width: f64) -> SnapResult {
let offset_px = -(index as f64) * viewport_width;
let changed = self.current_index != index;
self.current_index = index;
SnapResult { target_index: index, offset_px, changed }
}
/// Reposiciona tras un resize. Devuelve el offset que el host debe
/// aplicar sin animación.
pub fn reposition(&self, viewport_width: f64) -> f64 {
-(self.current_index as f64) * viewport_width
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vertical_drag_is_cancelled() {
let mut s = DeckState::new();
s.pointer_down(100.0, 100.0, 1, 800.0);
// Movimiento vertical mayor que el umbral.
let r = s.pointer_move(100.0, 120.0);
assert_eq!(r, DragOutcome::CancelVertical);
}
#[test]
fn horizontal_drag_starts_after_threshold() {
let mut s = DeckState::new();
s.pointer_down(100.0, 100.0, 7, 800.0);
// Justo por debajo del umbral → Idle.
assert_eq!(s.pointer_move(105.0, 100.0), DragOutcome::Idle);
// Sobre el umbral con bias horizontal → Start.
let r = s.pointer_move(120.0, 100.0);
assert_eq!(r, DragOutcome::StartHorizontal { pointer_id: 7 });
}
#[test]
fn snap_rounds_to_nearest_page() {
let mut s = DeckState::new();
s.current_index = 1;
s.pointer_down(0.0, 0.0, 1, 1000.0); // drag_start_offset = -1000
// Forzar drag activo
s.pointer_move(20.0, 0.0);
// Offset actual = -1000 + 20 = -980 → target round(980/1000) = 1, sin cambio
let r = s.pointer_end(-980.0, 1000.0, 3).unwrap();
assert_eq!(r.target_index, 1);
assert!(!r.changed);
// Mover lo suficiente para snapear a página 0
s.pointer_down(0.0, 0.0, 1, 1000.0);
s.pointer_move(600.0, 0.0);
let r = s.pointer_end(-400.0, 1000.0, 3).unwrap();
assert_eq!(r.target_index, 0);
assert!(r.changed);
}
#[test]
fn snap_clamps_to_bounds() {
let mut s = DeckState::new();
s.current_index = 2;
s.pointer_down(0.0, 0.0, 1, 500.0);
s.pointer_move(50.0, 0.0);
// Offset muy a la izquierda → debería clamp a n_pages-1
let r = s.pointer_end(-9999.0, 500.0, 3).unwrap();
assert_eq!(r.target_index, 2);
}
#[test]
fn goto_updates_index_and_offset() {
let mut s = DeckState::new();
let r = s.goto(2, 800.0);
assert_eq!(r.target_index, 2);
assert_eq!(r.offset_px, -1600.0);
assert!(r.changed);
// segundo goto al mismo índice → changed=false
let r = s.goto(2, 800.0);
assert!(!r.changed);
}
}
@@ -0,0 +1,850 @@
//! Modo `Recorrido` — presentación espacial sobre lienzo infinito (tipo Prezi).
//!
//! Un `Recorrido` coloca `Marco`s en coordenadas de mundo y define una **ruta**
//! ordenada (`pasos`) que la cámara recorre: avanzar un paso encuadra el marco
//! destino animando zoom/pan/giro desde la cámara actual. Entre pasos el usuario
//! puede volar libre (drag = pan, wheel = zoom-a-cursor).
//!
//! El strip lineal de [`crate::DeckState`] es el caso degenerado: marcos del
//! mismo tamaño en fila, zoom fijo, sin giro. Aquí el lienzo es 2D.
//!
//! Como toda pieza `*-core` del repo, esto es una máquina de estados pura: el
//! host traduce pointer/wheel/teclado → llamadas, y tick'ea la animación con
//! [`RecorridoState::avanzar`]; no hay render ni reloj propio.
use crate::camara::{Camara, Ease, Rect};
/// Duración por defecto del vuelo entre dos pasos, en segundos.
pub const DURACION_PASO_S: f64 = 0.8;
/// Tamaño mínimo (mundo) al redimensionar un marco — evita marcos degenerados.
pub const MIN_MARCO: f64 = 20.0;
pub type MarcoId = u64;
/// Qué pinta el host dentro de un marco. El core es agnóstico: guarda una
/// referencia o etiqueta y deja la resolución (cuerpo, subgrafo de átomos,
/// imagen, página de deck) al frontend vía `pluma-render-plan` u otro.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ContenidoMarco {
#[default]
Vacio,
/// Texto plano de una línea — títulos de sección, hitos del recorrido.
Etiqueta(String),
/// Contenido de "slide": título opcional + párrafos. Agnóstico (sólo
/// strings); un adaptador convierte un cuerpo/subgrafo de pluma a esto.
Texto { titulo: Option<String>, parrafos: Vec<String> },
/// Imagen rasterizada: bytes **codificados** (PNG/JPEG/WebP) + dimensiones
/// en px. El core es agnóstico — guarda los bytes sin decodificar y deja la
/// rasterización al frontend; `ancho`/`alto` permiten encuadrar/aspectar el
/// marco sin tener que decodificar.
Imagen { bytes: Vec<u8>, ancho: u32, alto: u32 },
/// Referencia opaca que el host resuelve (hash BLAKE3, id de cuerpo, ruta…).
Ref(String),
}
/// Un marco colocado en el lienzo: su rectángulo en coordenadas de mundo, su
/// giro propio y su contenido.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Marco {
pub id: MarcoId,
pub rect: Rect,
pub rot_rad: f64,
pub contenido: ContenidoMarco,
}
impl Marco {
pub fn new(id: MarcoId, rect: Rect, contenido: ContenidoMarco) -> Self {
Self { id, rect, rot_rad: 0.0, contenido }
}
pub fn con_giro(mut self, rot_rad: f64) -> Self {
self.rot_rad = rot_rad;
self
}
/// Cámara que encuadra este marco en `panel`.
pub fn fit(&self, panel: Rect) -> Camara {
Camara::fit(self.rect, self.rot_rad, panel)
}
/// Bounding box axis-aligned (en coordenadas de mundo) que contiene al
/// marco **ya girado** alrededor de su centro. Para un marco recto coincide
/// con su `rect`; para uno girado lo envuelve sin recortar esquinas. Base de
/// [`Recorrido::bbox`] → vista general.
pub fn aabb(&self) -> Rect {
let (cx, cy) = self.rect.centro();
let (hw, hh) = (self.rect.w * 0.5, self.rect.h * 0.5);
let (s, c) = self.rot_rad.sin_cos();
// Las cuatro esquinas relativas al centro, giradas; el AABB lo fija la
// mayor extensión en cada eje (simétrico, así que basta el máximo).
let ex = (hw * c).abs() + (hh * s).abs();
let ey = (hw * s).abs() + (hh * c).abs();
Rect::new(cx - ex, cy - ey, ex * 2.0, ey * 2.0)
}
/// `true` si el punto de mundo `p` cae dentro del marco, considerando su
/// giro propio (deshace la rotación con que se dibuja antes del aabb test).
pub fn contiene(&self, p: (f64, f64)) -> bool {
let (cx, cy) = self.rect.centro();
// Inversa de la rotación de dibujo: local = centro + R(-rot)·(p-centro).
let (s, c) = (-self.rot_rad).sin_cos();
let dx = p.0 - cx;
let dy = p.1 - cy;
let lx = dx * c - dy * s + cx;
let ly = dx * s + dy * c + cy;
lx >= self.rect.x
&& lx <= self.rect.x + self.rect.w
&& ly >= self.rect.y
&& ly <= self.rect.y + self.rect.h
}
}
/// Lienzo + ruta narrativa. `pasos` es una secuencia de `MarcoId` (puede
/// repetir un marco, saltarse otros, o recorrerlos en cualquier orden).
#[derive(Clone, Debug, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Recorrido {
pub marcos: Vec<Marco>,
pub pasos: Vec<MarcoId>,
}
impl Recorrido {
pub fn new() -> Self {
Self::default()
}
pub fn agregar_marco(&mut self, marco: Marco) -> MarcoId {
let id = marco.id;
self.marcos.push(marco);
id
}
pub fn marco(&self, id: MarcoId) -> Option<&Marco> {
self.marcos.iter().find(|m| m.id == id)
}
/// Marco bajo un punto de mundo, si hay — el de más arriba (último
/// dibujado) gana cuando se solapan. Para hit-test de autoría.
pub fn marco_en_punto(&self, p: (f64, f64)) -> Option<MarcoId> {
self.marcos.iter().rev().find(|m| m.contiene(p)).map(|m| m.id)
}
/// Traslada el marco `id` por un delta de mundo `(dx, dy)`. No-op si el id
/// no existe. Para arrastrar marcos en modo edición.
pub fn mover_marco(&mut self, id: MarcoId, dx: f64, dy: f64) {
if let Some(m) = self.marcos.iter_mut().find(|m| m.id == id) {
m.rect.x += dx;
m.rect.y += dy;
}
}
/// Elimina el marco `id` y purga **todos** los pasos que lo referencian
/// (manteniendo el resto del guion). Devuelve `true` si el marco existía.
/// Para autoría — deja la ruta consistente sin ids colgantes.
pub fn eliminar_marco(&mut self, id: MarcoId) -> bool {
let antes = self.marcos.len();
self.marcos.retain(|m| m.id != id);
let elimino = self.marcos.len() != antes;
if elimino {
self.pasos.retain(|p| *p != id);
}
elimino
}
/// Redimensiona el marco `id` a `w`×`h` (clamp a [`MIN_MARCO`]), conservando
/// su esquina superior-izquierda. No-op si el id no existe.
pub fn redimensionar_marco(&mut self, id: MarcoId, w: f64, h: f64) {
if let Some(m) = self.marcos.iter_mut().find(|m| m.id == id) {
m.rect.w = w.max(MIN_MARCO);
m.rect.h = h.max(MIN_MARCO);
}
}
/// Suma `delta_rad` al giro propio del marco `id`. No-op si no existe.
pub fn rotar_marco(&mut self, id: MarcoId, delta_rad: f64) {
if let Some(m) = self.marcos.iter_mut().find(|m| m.id == id) {
m.rot_rad += delta_rad;
}
}
/// Reordena el guion: mueve el paso en el índice `desde` a la posición
/// `hasta` (clamp al final). No-op si `desde` está fuera de rango.
pub fn mover_paso(&mut self, desde: usize, hasta: usize) {
if desde >= self.pasos.len() {
return;
}
let id = self.pasos.remove(desde);
self.pasos.insert(hasta.min(self.pasos.len()), id);
}
/// Marco al que apunta el paso `idx` (resolviendo el id contra `marcos`).
pub fn marco_en_paso(&self, idx: usize) -> Option<&Marco> {
self.pasos.get(idx).and_then(|id| self.marco(*id))
}
pub fn n_pasos(&self) -> usize {
self.pasos.len()
}
/// Bounding box de **todos** los marcos (cada uno por su [`Marco::aabb`], así
/// los girados entran enteros). `None` si no hay marcos. Es el encuadre de la
/// *vista general* — el zoom-out narrativo que muestra el mapa completo.
pub fn bbox(&self) -> Option<Rect> {
let mut it = self.marcos.iter().map(Marco::aabb);
let primero = it.next()?;
let (mut min_x, mut min_y) = (primero.x, primero.y);
let (mut max_x, mut max_y) = (primero.x + primero.w, primero.y + primero.h);
for r in it {
min_x = min_x.min(r.x);
min_y = min_y.min(r.y);
max_x = max_x.max(r.x + r.w);
max_y = max_y.max(r.y + r.h);
}
Some(Rect::new(min_x, min_y, max_x - min_x, max_y - min_y))
}
/// Auto-layout: coloca una secuencia de contenidos en una rejilla y arma
/// la ruta en orden de lectura (fila por fila). Es el "dame N piezas →
/// dame un recorrido listo" — el frontend sólo pinta y vuela. Los ids se
/// asignan `1..=n` en orden.
pub fn en_rejilla(contenidos: Vec<ContenidoMarco>, opts: RejillaOpts) -> Recorrido {
let cols = opts.cols.max(1);
let mut rec = Recorrido::new();
for (i, c) in contenidos.into_iter().enumerate() {
let col = (i % cols) as f64;
let row = (i / cols) as f64;
let x = col * (opts.marco_w + opts.gap_x);
let y = row * (opts.marco_h + opts.gap_y);
let id = (i + 1) as MarcoId;
rec.agregar_marco(Marco::new(id, Rect::new(x, y, opts.marco_w, opts.marco_h), c));
rec.pasos.push(id);
}
rec
}
}
#[cfg(feature = "serde")]
impl Recorrido {
/// Serializa el recorrido (marcos + ruta) a su forma binaria `postcard` —
/// el codec nativo del workspace (mismo que `format`/`akasha`/`pluma-cuerpo`).
/// Persiste sólo el modelo de datos; el estado de interacción
/// ([`RecorridoState`]) es efímero y no se guarda.
pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
postcard::to_allocvec(self).map_err(|_| "recorrido :: serializacion fallida")
}
/// Reconstruye un recorrido desde su forma binaria `postcard`.
pub fn deserializar(bytes: &[u8]) -> Result<Recorrido, &'static str> {
postcard::from_bytes::<Recorrido>(bytes).map_err(|_| "recorrido :: deserializacion fallida")
}
}
/// Parámetros del auto-layout en rejilla de [`Recorrido::en_rejilla`].
#[derive(Clone, Copy, Debug)]
pub struct RejillaOpts {
pub cols: usize,
pub marco_w: f64,
pub marco_h: f64,
pub gap_x: f64,
pub gap_y: f64,
}
impl Default for RejillaOpts {
fn default() -> Self {
Self { cols: 3, marco_w: 640.0, marco_h: 400.0, gap_x: 220.0, gap_y: 180.0 }
}
}
/// Animación de cámara en curso entre dos encuadres.
#[derive(Clone, Copy, Debug)]
struct Vuelo {
desde: Camara,
hasta: Camara,
/// Tiempo transcurrido / duración total, en segundos.
t: f64,
dur: f64,
ease: Ease,
}
/// Máquina de interacción del recorrido: cámara viva + paso actual + vuelo en
/// curso + estado de arrastre para el paneo libre.
#[derive(Clone, Debug)]
pub struct RecorridoState {
pub camara: Camara,
/// Índice del paso actual dentro de `Recorrido::pasos`.
pub paso: usize,
vuelo: Option<Vuelo>,
arrastre: Option<(f64, f64)>,
}
impl Default for RecorridoState {
fn default() -> Self {
Self { camara: Camara::default(), paso: 0, vuelo: None, arrastre: None }
}
}
impl RecorridoState {
pub fn new() -> Self {
Self::default()
}
/// `true` si hay un vuelo de cámara animándose.
pub fn animando(&self) -> bool {
self.vuelo.is_some()
}
// ---- Roam libre -------------------------------------------------------
/// Inicio de arrastre para panear. Cancela cualquier vuelo en curso (el
/// usuario toma el control manual).
pub fn pointer_down(&mut self, x: f64, y: f64) {
self.vuelo = None;
self.arrastre = Some((x, y));
}
/// Movimiento de puntero: si hay arrastre activo, panea la cámara por el
/// delta y devuelve `true` (el host debe repintar).
pub fn pointer_move(&mut self, x: f64, y: f64) -> bool {
let Some((px, py)) = self.arrastre else { return false };
self.camara.pan(x - px, y - py);
self.arrastre = Some((x, y));
true
}
pub fn pointer_up(&mut self) {
self.arrastre = None;
}
/// Paneo por delta de pantalla — para hosts que ya entregan el delta del
/// arrastre (p. ej. `llimphi-ui::draggable`, que da `(dx, dy)` por evento).
/// Cancela el vuelo (control manual). Alternativa a `pointer_down/move/up`.
pub fn arrastrar_delta(&mut self, dx: f64, dy: f64) {
self.vuelo = None;
self.camara.pan(dx, dy);
}
/// Wheel: zoom-a-cursor inmediato. Cancela el vuelo (control manual).
pub fn wheel(&mut self, mult: f64, cursor: (f64, f64), panel: Rect) {
self.vuelo = None;
self.camara.zoom_a_cursor(mult, cursor, panel);
}
// ---- Reproducción guiada ---------------------------------------------
/// Arranca un vuelo desde la cámara actual hasta encuadrar el paso `idx`.
/// No hace nada si el índice o su marco no existen. Fija `paso = idx`.
pub fn ir_a_paso(&mut self, rec: &Recorrido, idx: usize, panel: Rect) {
let Some(marco) = rec.marco_en_paso(idx) else { return };
self.paso = idx;
self.iniciar_vuelo(marco.fit(panel), DURACION_PASO_S);
}
/// Avanza al paso siguiente (clamp al final). Devuelve `true` si arrancó
/// un vuelo nuevo.
pub fn siguiente(&mut self, rec: &Recorrido, panel: Rect) -> bool {
if rec.n_pasos() == 0 || self.paso + 1 >= rec.n_pasos() {
return false;
}
self.ir_a_paso(rec, self.paso + 1, panel);
true
}
/// Retrocede al paso anterior (clamp en 0). Devuelve `true` si arrancó un
/// vuelo nuevo.
pub fn anterior(&mut self, rec: &Recorrido, panel: Rect) -> bool {
if self.paso == 0 {
return false;
}
self.ir_a_paso(rec, self.paso - 1, panel);
true
}
/// Vuela a la **vista general**: aleja la cámara hasta encuadrar todos los
/// marcos (recta, sin giro), el gesto-firma de Prezi "alejarse para ver el
/// mapa". No toca `paso` — `siguiente`/`anterior` siguen desde donde iban.
/// Devuelve `true` si arrancó un vuelo (`false` si el lienzo está vacío).
pub fn vista_general(&mut self, rec: &Recorrido, panel: Rect) -> bool {
let Some(bbox) = rec.bbox() else { return false };
self.iniciar_vuelo(Camara::fit(bbox, 0.0, panel), DURACION_PASO_S);
true
}
/// Salto instantáneo (sin vuelo) al encuadre del paso `idx` — útil para
/// reposicionar tras un resize, o para "jump to" sin animación.
pub fn saltar_a_paso(&mut self, rec: &Recorrido, idx: usize, panel: Rect) {
let Some(marco) = rec.marco_en_paso(idx) else { return };
self.paso = idx;
self.vuelo = None;
self.camara = marco.fit(panel);
}
fn iniciar_vuelo(&mut self, hasta: Camara, dur: f64) {
if dur <= 0.0 {
self.camara = hasta;
self.vuelo = None;
return;
}
self.vuelo = Some(Vuelo { desde: self.camara, hasta, t: 0.0, dur, ease: Ease::default() });
}
/// Avanza la animación `dt` segundos. Devuelve `true` mientras siga
/// animando (el host repite el tick); `false` cuando ya no hay vuelo.
/// El host la llama desde un timer (p. ej. `Handle::spawn_periodic`).
pub fn avanzar(&mut self, dt: f64) -> bool {
let Some(mut v) = self.vuelo else { return false };
v.t += dt;
if v.t >= v.dur {
self.camara = v.hasta;
self.vuelo = None;
return false;
}
self.camara = Camara::interpolar(&v.desde, &v.hasta, v.t / v.dur, v.ease);
self.vuelo = Some(v);
true
}
}
/// Reproducción automática ("modo presentador"): tras aterrizar en un paso
/// espera `dwell_s` segundos y avanza solo al siguiente. Al llegar al final,
/// vuelve al inicio si `bucle`, o se detiene. Máquina de tiempo **pura** — el
/// host la tickea junto a [`RecorridoState::avanzar`]; no tiene reloj propio.
#[derive(Clone, Copy, Debug)]
pub struct Autoplay {
/// Segundos de permanencia en cada paso una vez que el vuelo aterrizó.
pub dwell_s: f64,
/// Si al final vuelve al primer paso (`true`) o se detiene (`false`).
pub bucle: bool,
activo: bool,
espera: f64,
}
/// Dwell por defecto del modo presentador, en segundos.
pub const DWELL_S: f64 = 2.5;
impl Default for Autoplay {
fn default() -> Self {
Self { dwell_s: DWELL_S, bucle: true, activo: false, espera: 0.0 }
}
}
impl Autoplay {
pub fn new(dwell_s: f64, bucle: bool) -> Self {
Self { dwell_s, bucle, activo: false, espera: 0.0 }
}
pub fn activo(&self) -> bool {
self.activo
}
/// Arranca la reproducción (resetea el contador de permanencia).
pub fn play(&mut self) {
self.activo = true;
self.espera = 0.0;
}
pub fn pausa(&mut self) {
self.activo = false;
}
/// Alterna play/pausa. Devuelve el nuevo estado.
pub fn toggle(&mut self) -> bool {
if self.activo {
self.pausa();
} else {
self.play();
}
self.activo
}
/// Tick del modo presentador. No hace nada si está pausado. Mientras el
/// vuelo se anima, espera (no acumula dwell). Una vez quieto, acumula `dt`;
/// al superar `dwell_s` avanza un paso (o vuelve al inicio si `bucle`; o se
/// detiene). Devuelve `true` si disparó un avance este tick.
pub fn tick(&mut self, dt: f64, state: &mut RecorridoState, rec: &Recorrido, panel: Rect) -> bool {
if !self.activo || rec.n_pasos() == 0 {
return false;
}
if state.animando() {
self.espera = 0.0;
return false;
}
self.espera += dt;
if self.espera < self.dwell_s {
return false;
}
self.espera = 0.0;
if state.paso + 1 < rec.n_pasos() {
state.siguiente(rec, panel);
} else if self.bucle {
state.ir_a_paso(rec, 0, panel);
} else {
self.activo = false;
return false;
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
const PANEL: Rect = Rect { x: 0.0, y: 0.0, w: 800.0, h: 600.0 };
fn recorrido_demo() -> Recorrido {
let mut r = Recorrido::new();
r.agregar_marco(Marco::new(1, Rect::new(0.0, 0.0, 400.0, 300.0), ContenidoMarco::Etiqueta("a".into())));
r.agregar_marco(Marco::new(2, Rect::new(2000.0, 0.0, 200.0, 150.0), ContenidoMarco::Etiqueta("b".into())));
r.agregar_marco(Marco::new(3, Rect::new(1000.0, 1000.0, 800.0, 600.0), ContenidoMarco::Etiqueta("c".into())));
r.pasos = vec![1, 2, 3];
r
}
#[test]
fn en_rejilla_coloca_y_rutea_en_orden_de_lectura() {
let contenidos = vec![
ContenidoMarco::Etiqueta("a".into()),
ContenidoMarco::Etiqueta("b".into()),
ContenidoMarco::Etiqueta("c".into()),
ContenidoMarco::Etiqueta("d".into()),
];
let opts = RejillaOpts { cols: 2, marco_w: 100.0, marco_h: 50.0, gap_x: 20.0, gap_y: 10.0 };
let rec = Recorrido::en_rejilla(contenidos, opts);
assert_eq!(rec.marcos.len(), 4);
// Ruta secuencial 1..=4.
assert_eq!(rec.pasos, vec![1, 2, 3, 4]);
// Índice 0 en (0,0); índice 1 en la columna siguiente; índice 2 baja de fila.
assert_eq!(rec.marco(1).unwrap().rect, Rect::new(0.0, 0.0, 100.0, 50.0));
assert_eq!(rec.marco(2).unwrap().rect, Rect::new(120.0, 0.0, 100.0, 50.0));
assert_eq!(rec.marco(3).unwrap().rect, Rect::new(0.0, 60.0, 100.0, 50.0));
}
#[test]
fn marco_en_punto_devuelve_el_de_arriba() {
let r = recorrido_demo();
// Punto dentro del marco 1 (0,0,400,300).
assert_eq!(r.marco_en_punto((10.0, 10.0)), Some(1));
// Punto dentro del marco 2 (2000,0,200,150).
assert_eq!(r.marco_en_punto((2050.0, 50.0)), Some(2));
// Punto en el vacío.
assert_eq!(r.marco_en_punto((-500.0, -500.0)), None);
}
#[test]
fn marco_en_punto_respeta_giro() {
let mut r = Recorrido::new();
// Marco cuadrado centrado en (0,0), girado 45°.
r.agregar_marco(
Marco::new(7, Rect::new(-50.0, -50.0, 100.0, 100.0), ContenidoMarco::Vacio)
.con_giro(std::f64::consts::FRAC_PI_4),
);
// El centro siempre está dentro.
assert_eq!(r.marco_en_punto((0.0, 0.0)), Some(7));
// Una esquina del aabb sin girar (49,49) queda FUERA del cuadrado girado
// (su semidiagonal es ~70.7, pero el lado rotado pasa antes por los ejes).
assert_eq!(r.marco_en_punto((49.0, 49.0)), None);
// Sobre el eje X a distancia 60 < semidiagonal: dentro del rombo.
assert_eq!(r.marco_en_punto((60.0, 0.0)), Some(7));
}
#[test]
fn marco_con_imagen_es_agnostico_al_hit_test() {
// El core no decodifica la imagen: guarda bytes + dims y el hit-test
// sigue dependiendo sólo de la geometría del marco.
let mut r = Recorrido::new();
let img = ContenidoMarco::Imagen { bytes: vec![0xDE, 0xAD, 0xBE, 0xEF], ancho: 320, alto: 240 };
r.agregar_marco(Marco::new(5, Rect::new(0.0, 0.0, 400.0, 300.0), img.clone()));
assert_eq!(r.marco_en_punto((100.0, 100.0)), Some(5));
assert_eq!(r.marco_en_punto((9999.0, 0.0)), None);
// La variante conserva bytes y dimensiones tal cual.
assert_eq!(r.marco(5).unwrap().contenido, img);
}
#[test]
fn mover_marco_traslada_el_rect() {
let mut r = recorrido_demo();
r.mover_marco(1, 100.0, -40.0);
assert_eq!(r.marco(1).unwrap().rect, Rect::new(100.0, -40.0, 400.0, 300.0));
// Id inexistente: no-op.
r.mover_marco(999, 1.0, 1.0);
}
#[test]
fn eliminar_marco_purga_la_ruta() {
let mut r = recorrido_demo(); // marcos 1,2,3; pasos [1,2,3]
r.pasos = vec![1, 2, 3, 2]; // el 2 aparece dos veces en el guion
assert!(r.eliminar_marco(2));
assert!(r.marco(2).is_none());
assert_eq!(r.pasos, vec![1, 3], "se purgan TODAS las apariciones del 2");
// Id inexistente: no-op, devuelve false.
assert!(!r.eliminar_marco(999));
assert_eq!(r.marcos.len(), 2);
}
#[test]
fn redimensionar_marco_clampa_al_minimo() {
let mut r = recorrido_demo();
r.redimensionar_marco(1, 500.0, 5.0); // alto por debajo del mínimo
let m = r.marco(1).unwrap();
assert_eq!(m.rect.w, 500.0);
assert_eq!(m.rect.h, MIN_MARCO);
// Conserva la esquina sup-izq (marco 1 estaba en 0,0).
assert_eq!((m.rect.x, m.rect.y), (0.0, 0.0));
}
#[test]
fn rotar_marco_acumula() {
let mut r = recorrido_demo();
r.rotar_marco(1, 0.3);
r.rotar_marco(1, 0.2);
assert!((r.marco(1).unwrap().rot_rad - 0.5).abs() < 1e-12);
r.rotar_marco(404, 1.0); // inexistente: no-op
}
#[test]
fn mover_paso_reordena_el_guion() {
let mut r = recorrido_demo(); // pasos [1,2,3]
r.mover_paso(0, 2); // lleva el primero al final
assert_eq!(r.pasos, vec![2, 3, 1]);
r.mover_paso(2, 0); // y de vuelta al inicio
assert_eq!(r.pasos, vec![1, 2, 3]);
// hasta fuera de rango → clamp al final; desde fuera de rango → no-op.
r.mover_paso(0, 99);
assert_eq!(r.pasos, vec![2, 3, 1]);
r.mover_paso(99, 0);
assert_eq!(r.pasos, vec![2, 3, 1]);
}
#[test]
fn marco_en_paso_resuelve_id() {
let r = recorrido_demo();
assert_eq!(r.marco_en_paso(1).unwrap().id, 2);
assert!(r.marco_en_paso(9).is_none());
}
#[test]
fn pan_libre_mueve_y_cancela_vuelo() {
let r = recorrido_demo();
let mut s = RecorridoState::new();
s.ir_a_paso(&r, 1, PANEL);
assert!(s.animando());
s.pointer_down(100.0, 100.0);
assert!(!s.animando(), "el drag cancela el vuelo");
assert!(s.pointer_move(130.0, 100.0));
s.pointer_up();
assert!(!s.pointer_move(200.0, 200.0), "sin arrastre no panea");
}
#[test]
fn arrastrar_delta_panea_y_cancela_vuelo() {
let r = recorrido_demo();
let mut s = RecorridoState::new();
s.ir_a_paso(&r, 1, PANEL);
s.camara.zoom = 2.0;
let antes = s.camara.world_to_screen((0.0, 0.0), PANEL);
s.arrastrar_delta(40.0, -20.0);
assert!(!s.animando());
let despues = s.camara.world_to_screen((0.0, 0.0), PANEL);
assert!((despues.0 - antes.0 - 40.0).abs() < 1e-9);
assert!((despues.1 - antes.1 + 20.0).abs() < 1e-9);
}
#[test]
fn wheel_hace_zoom_y_cancela_vuelo() {
let r = recorrido_demo();
let mut s = RecorridoState::new();
s.ir_a_paso(&r, 1, PANEL);
let z = s.camara.zoom;
s.wheel(1.1, (400.0, 300.0), PANEL);
assert!(!s.animando());
assert!((s.camara.zoom - z * 1.1).abs() < 1e-9);
}
#[test]
fn siguiente_y_anterior_respetan_bordes() {
let r = recorrido_demo();
let mut s = RecorridoState::new();
assert!(!s.anterior(&r, PANEL), "ya en el primero");
assert!(s.siguiente(&r, PANEL));
assert_eq!(s.paso, 1);
assert!(s.siguiente(&r, PANEL));
assert_eq!(s.paso, 2);
assert!(!s.siguiente(&r, PANEL), "ya en el último");
assert!(s.anterior(&r, PANEL));
assert_eq!(s.paso, 1);
}
#[test]
fn avanzar_completa_el_vuelo_y_aterriza_exacto() {
let r = recorrido_demo();
let mut s = RecorridoState::new();
s.ir_a_paso(&r, 2, PANEL);
let objetivo = r.marco_en_paso(2).unwrap().fit(PANEL);
// Tickea en pasos hasta que el vuelo termina.
let mut iter = 0;
while s.avanzar(0.1) {
iter += 1;
assert!(iter < 1000, "el vuelo no converge");
}
// Al terminar aterriza EXACTAMENTE en el encuadre objetivo.
assert_eq!(s.camara, objetivo);
assert!(!s.animando());
// Un avanzar extra no hace nada.
assert!(!s.avanzar(0.1));
}
#[test]
fn aabb_recto_coincide_con_rect_y_girado_lo_envuelve() {
// Marco recto: el aabb es su propio rect.
let m = Marco::new(1, Rect::new(10.0, 20.0, 100.0, 60.0), ContenidoMarco::Vacio);
assert_eq!(m.aabb(), Rect::new(10.0, 20.0, 100.0, 60.0));
// Cuadrado 100×100 centrado en (0,0) girado 45°: su aabb es el cuadrado
// que lo circunscribe, lado = diagonal = 100·√2 ≈ 141.42.
let g = Marco::new(2, Rect::new(-50.0, -50.0, 100.0, 100.0), ContenidoMarco::Vacio)
.con_giro(std::f64::consts::FRAC_PI_4);
let bb = g.aabb();
let lado = 100.0 * std::f64::consts::SQRT_2;
assert!((bb.w - lado).abs() < 1e-6 && (bb.h - lado).abs() < 1e-6, "{bb:?}");
assert!((bb.centro().0).abs() < 1e-9 && (bb.centro().1).abs() < 1e-9);
}
#[test]
fn bbox_une_todos_los_marcos() {
let r = recorrido_demo();
// Marcos: (0,0,400,300), (2000,0,200,150), (1000,1000,800,600).
let bb = r.bbox().unwrap();
assert_eq!(bb, Rect::new(0.0, 0.0, 2200.0, 1600.0));
assert!(Recorrido::new().bbox().is_none(), "lienzo vacío no tiene bbox");
}
#[test]
fn vista_general_vuela_a_encuadrar_todo_sin_tocar_el_paso() {
let r = recorrido_demo();
let mut s = RecorridoState::new();
s.ir_a_paso(&r, 2, PANEL);
while s.avanzar(0.1) {}
assert_eq!(s.paso, 2);
assert!(s.vista_general(&r, PANEL));
assert!(s.animando());
// No cambió el paso narrativo.
assert_eq!(s.paso, 2);
while s.avanzar(0.1) {}
// Aterriza en el fit del bbox completo, recto.
let objetivo = Camara::fit(r.bbox().unwrap(), 0.0, PANEL);
assert_eq!(s.camara, objetivo);
// Lienzo vacío: no-op.
assert!(!RecorridoState::new().vista_general(&Recorrido::new(), PANEL));
}
/// Tickea estado + autoplay hasta que el autoplay dispare un avance (o se
/// agote el presupuesto de iteraciones). Simula el bucle del host.
fn correr_hasta_avance(ap: &mut Autoplay, s: &mut RecorridoState, r: &Recorrido) -> bool {
for _ in 0..100_000 {
s.avanzar(1.0 / 60.0);
if ap.tick(1.0 / 60.0, s, r, PANEL) {
return true;
}
}
false
}
#[test]
fn autoplay_pausado_no_hace_nada() {
let r = recorrido_demo();
let mut s = RecorridoState::new();
let mut ap = Autoplay::new(0.5, false);
assert!(!ap.activo());
// Sin play, mil ticks no mueven el paso.
for _ in 0..1000 {
assert!(!ap.tick(1.0 / 60.0, &mut s, &r, PANEL));
}
assert_eq!(s.paso, 0);
}
#[test]
fn autoplay_avanza_tras_el_dwell_esperando_a_que_aterrice_el_vuelo() {
let r = recorrido_demo();
let mut s = RecorridoState::new();
let mut ap = Autoplay::new(0.5, false);
ap.play();
assert!(ap.toggle() == false, "toggle desde activo pausa");
ap.play();
// Mientras el contador de dwell no llega, no avanza; el avance ocurre
// recién tras ~0.5s quietos.
assert!(correr_hasta_avance(&mut ap, &mut s, &r));
assert_eq!(s.paso, 1);
// Y respeta que el vuelo aterrice antes de contar el siguiente dwell.
assert!(correr_hasta_avance(&mut ap, &mut s, &r));
assert_eq!(s.paso, 2);
}
#[test]
fn autoplay_al_final_sin_bucle_se_detiene_y_con_bucle_reinicia() {
let r = recorrido_demo(); // 3 pasos
// Sin bucle: tras el último, se desactiva solo.
let mut s = RecorridoState::new();
let mut ap = Autoplay::new(0.2, false);
ap.play();
correr_hasta_avance(&mut ap, &mut s, &r); // → paso 1
correr_hasta_avance(&mut ap, &mut s, &r); // → paso 2 (último)
assert_eq!(s.paso, 2);
// En el último, el dwell vence pero no avanza: se apaga.
let arranco = correr_hasta_avance(&mut ap, &mut s, &r);
assert!(!arranco && !ap.activo(), "sin bucle se detiene en el final");
// Con bucle: del último vuelve al inicio.
let mut s = RecorridoState::new();
s.saltar_a_paso(&r, 2, PANEL);
let mut ap = Autoplay::new(0.2, true);
ap.play();
assert!(correr_hasta_avance(&mut ap, &mut s, &r));
assert_eq!(s.paso, 0, "con bucle reinicia");
}
#[cfg(feature = "serde")]
#[test]
fn roundtrip_postcard_preserva_marcos_ruta_y_contenido() {
let mut r = recorrido_demo();
// Un marco con giro, imagen (bytes crudos) y texto, para cubrir variantes.
r.agregar_marco(
Marco::new(4, Rect::new(10.0, 20.0, 300.0, 200.0), ContenidoMarco::Imagen {
bytes: vec![1, 2, 3, 4, 5],
ancho: 64,
alto: 48,
})
.con_giro(0.37),
);
r.agregar_marco(Marco::new(
5,
Rect::new(0.0, 0.0, 100.0, 100.0),
ContenidoMarco::Texto { titulo: Some("T".into()), parrafos: vec!["p1".into(), "p2".into()] },
));
r.pasos = vec![1, 2, 3, 4, 5];
let bytes = r.serializar().unwrap();
let r2 = Recorrido::deserializar(&bytes).unwrap();
assert_eq!(r2.pasos, r.pasos);
assert_eq!(r2.marcos.len(), r.marcos.len());
// El marco girado con imagen conserva geometría, giro y bytes.
let m4 = r2.marco(4).unwrap();
assert_eq!(m4.rect, Rect::new(10.0, 20.0, 300.0, 200.0));
assert!((m4.rot_rad - 0.37).abs() < 1e-12);
assert_eq!(
m4.contenido,
ContenidoMarco::Imagen { bytes: vec![1, 2, 3, 4, 5], ancho: 64, alto: 48 }
);
// Bytes corruptos no panican: error controlado.
assert!(Recorrido::deserializar(&[0xFF]).is_err());
}
#[test]
fn saltar_a_paso_es_instantaneo() {
let r = recorrido_demo();
let mut s = RecorridoState::new();
s.saltar_a_paso(&r, 2, PANEL);
assert!(!s.animando());
assert_eq!(s.paso, 2);
assert_eq!(s.camara, r.marco_en_paso(2).unwrap().fit(PANEL));
}
}
@@ -0,0 +1,25 @@
[package]
name = "pluma-deck-recorrido-llimphi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Frontend Llimphi del modo Recorrido de pluma-deck: lienzo infinito + cámara que vuela entre marcos (presentación tipo Prezi)."
[dependencies]
pluma-deck-core = { path = "../pluma-deck-core" }
llimphi-ui = { workspace = true }
# Decodifica los bytes de ContenidoMarco::Imagen (PNG/JPEG/WebP) → RGBA8 para
# pintarlos como peniko::Image. El core guarda bytes crudos; aquí se rasterizan.
image = { workspace = true }
[dev-dependencies]
# Sólo para el example recorrido_md_demo: muestra la cadena markdown →
# cuerpo/átomos → Recorrido sin acoplar el lib al modelo de documento. La
# feature `pluma` del core (sólo en dev) trae el adaptador reusable que el
# demo dogfoodea en vez de su antiguo glue inline.
pluma-md = { path = "../pluma-md" }
pluma-core = { path = "../pluma-core" }
pluma-deck-core = { path = "../pluma-deck-core", features = ["pluma", "serde"] }
@@ -0,0 +1,181 @@
//! Demo del modo Recorrido (presentación espacial tipo Prezi).
//!
//! Un lienzo infinito con 5 marcos esparcidos a distintas escalas y giros;
//! la cámara vuela entre ellos siguiendo la ruta. Controles:
//! - **→ / ↓ / Espacio / Enter**: paso siguiente (la cámara vuela al marco).
//! - **← / ↑**: paso anterior.
//! - **Home / Esc**: vista general (aleja para ver todo el lienzo).
//! - **p**: play/pausa del modo presentador (avance automático con bucle).
//! - **rueda**: zoom-a-cursor.
//! - **arrastrar**: paneo libre por el lienzo.
//!
//! Corre con:
//! `cargo run -p pluma-deck-recorrido-llimphi --example recorrido_demo --release`
use std::time::Duration;
use llimphi_ui::{App, DragPhase, Handle, Key, KeyEvent, KeyState, Modifiers, NamedKey, View, WheelDelta};
use pluma_deck_core::{Autoplay, ContenidoMarco, Marco, Recorrido, RecorridoState, Rect, RejillaOpts};
use pluma_deck_recorrido_llimphi::{dentro, panel_actual, recorrido_view, ZOOM_BASE};
/// Panel inicial supuesto antes del primer paint (= `initial_size`), para
/// encuadrar el primer marco de entrada. Tras el primer frame se usa el real.
const PANEL_INICIAL: Rect = Rect { x: 0.0, y: 0.0, w: 1100.0, h: 720.0 };
#[derive(Clone)]
enum Msg {
Zoom { mult: f64, cursor: (f32, f32) },
Pan { dx: f32, dy: f32 },
Siguiente,
Anterior,
VistaGeneral,
ToggleAutoplay,
Tick,
}
struct Model {
rec: Recorrido,
state: RecorridoState,
autoplay: Autoplay,
}
struct Demo;
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"pluma · recorrido (presentación espacial tipo Prezi)"
}
fn initial_size() -> (u32, u32) {
(1100, 720)
}
fn init(handle: &Handle<Self::Msg>) -> Self::Model {
// Seis "slides" con contenido real (título + párrafos), auto-colocados
// en rejilla por en_rejilla; la ruta los recorre en orden de lectura.
let slide = |t: &str, ps: &[&str]| ContenidoMarco::Texto {
titulo: Some(t.into()),
parrafos: ps.iter().map(|s| s.to_string()).collect(),
};
let contenidos = vec![
slide(
"Presentaciones espaciales",
&[
"Tipo Prezi: un lienzo infinito en vez de una pila de diapositivas.",
"La cámara vuela entre marcos haciendo zoom y paneo — el recorrido ES la narrativa.",
],
),
slide(
"Un solo material",
&[
"Cada marco vive en coordenadas de mundo; el orden de los pasos define el guion.",
"El strip lineal de pluma-deck es el caso degenerado de esto.",
],
),
slide(
"Zoom narrativo",
&["Alejarse muestra el mapa completo; acercarse, el detalle.", "El zoom se interpola en espacio logarítmico para un vuelo natural."],
),
slide(
"Contenido nativo",
&["El contenido es agnóstico (título + párrafos).", "Un adaptador mapea un cuerpo o subgrafo de pluma a estos marcos."],
),
slide(
"Controles",
&["Flechas / Espacio / Enter: volar al paso.", "Rueda: zoom-a-cursor. Arrastrar: paneo libre."],
),
slide("Fin", &["Esto es la Fase 3a del §6.sexies en marcha."]),
];
let mut rec = Recorrido::en_rejilla(
contenidos,
RejillaOpts { cols: 3, marco_w: 660.0, marco_h: 420.0, gap_x: 240.0, gap_y: 200.0 },
);
// Un par de marcos sueltos con giro para lucir la libertad espacial.
let id_a = (rec.marcos.len() + 1) as u64;
rec.agregar_marco(
Marco::new(id_a, Rect::new(-520.0, 760.0, 300.0, 200.0), ContenidoMarco::Etiqueta("← lienzo infinito →".into()))
.con_giro(-0.12),
);
let mut state = RecorridoState::new();
state.saltar_a_paso(&rec, 0, PANEL_INICIAL);
// Tick de animación a ~60 Hz; avanzar() es no-op cuando no hay vuelo.
handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
Model { rec, state, autoplay: Autoplay::default() }
}
fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
let panel = panel_actual().unwrap_or(PANEL_INICIAL);
match msg {
Msg::Zoom { mult, cursor } => {
model.state.wheel(mult, (cursor.0 as f64, cursor.1 as f64), panel);
}
Msg::Pan { dx, dy } => {
model.state.arrastrar_delta(dx as f64, dy as f64);
}
Msg::Siguiente => {
model.state.siguiente(&model.rec, panel);
}
Msg::Anterior => {
model.state.anterior(&model.rec, panel);
}
Msg::VistaGeneral => {
model.state.vista_general(&model.rec, panel);
}
Msg::ToggleAutoplay => {
model.autoplay.toggle();
}
Msg::Tick => {
model.state.avanzar(1.0 / 60.0);
model.autoplay.tick(1.0 / 60.0, &mut model.state, &model.rec, panel);
}
}
model
}
fn view(model: &Self::Model) -> View<Self::Msg> {
recorrido_view(&model.rec, &model.state).draggable(|phase, dx, dy| match phase {
DragPhase::Move => Some(Msg::Pan { dx, dy }),
DragPhase::End => None,
})
}
fn on_wheel(
_model: &Self::Model,
delta: WheelDelta,
cursor: (f32, f32),
_modifiers: Modifiers,
) -> Option<Self::Msg> {
let panel = panel_actual()?;
if !dentro(panel, cursor.0, cursor.1) {
return None;
}
// delta.y > 0 ⇒ scroll abajo ⇒ alejar (convención CSS, igual que tullpu).
let mult = ZOOM_BASE.powf(-delta.y as f64);
Some(Msg::Zoom { mult, cursor })
}
fn on_key(_model: &Self::Model, ev: &KeyEvent) -> Option<Self::Msg> {
if ev.state != KeyState::Pressed {
return None;
}
match &ev.key {
Key::Named(NamedKey::ArrowRight | NamedKey::ArrowDown | NamedKey::Enter | NamedKey::Space) => {
Some(Msg::Siguiente)
}
Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowUp) => Some(Msg::Anterior),
Key::Named(NamedKey::Home | NamedKey::Escape) => Some(Msg::VistaGeneral),
Key::Character(c) if c.as_str().eq_ignore_ascii_case("p") => Some(Msg::ToggleAutoplay),
_ => None,
}
}
}
fn main() {
llimphi_ui::run::<Demo>();
}
@@ -0,0 +1,210 @@
//! Demo de **autoría**: colocar/mover/editar marcos en el lienzo y guardar.
//!
//! A diferencia de `recorrido_demo` (presentar), aquí el arrastre edita:
//! - **arrastrar sobre un marco**: lo mueve (y lo selecciona; borde ámbar).
//! - **arrastrar sobre el vacío**: panea el lienzo.
//! - **n**: crea un marco nuevo en el centro de la cámara (lo agrega a la ruta y lo selecciona).
//! - **Supr / Retroceso**: elimina el marco seleccionado (y lo purga del guion).
//! - **[ / ]**: rota el marco seleccionado.
//! - **Ctrl+S / Ctrl+O**: guarda / carga el recorrido en `recorrido.deck` (postcard).
//! - **rueda**: zoom-a-cursor. **flechas / Espacio**: vuela por la ruta.
//!
//! El hit-test, el movimiento y las ops de autoría (`eliminar_marco`,
//! `rotar_marco`, …) y la persistencia (`serializar`/`deserializar`) viven en
//! `pluma-deck-core`; aquí sólo se cablean a eventos.
//!
//! Corre con:
//! `cargo run -p pluma-deck-recorrido-llimphi --example recorrido_editor_demo --release`
use std::time::Duration;
use llimphi_ui::{App, DragPhase, Handle, Key, KeyEvent, KeyState, Modifiers, NamedKey, View, WheelDelta};
use pluma_deck_core::{ContenidoMarco, Marco, Recorrido, RecorridoState, Rect, RejillaOpts};
use pluma_deck_recorrido_llimphi::{dentro, panel_actual, recorrido_view_editor, ZOOM_BASE};
const PANEL_INICIAL: Rect = Rect { x: 0.0, y: 0.0, w: 1100.0, h: 720.0 };
/// Archivo donde guarda/carga el demo (codec nativo postcard).
const ARCHIVO: &str = "recorrido.deck";
#[derive(Clone)]
enum Msg {
Zoom { mult: f64, cursor: (f32, f32) },
/// Move del arrastre: delta `(dx,dy)` + posición inicial del press `(lx,ly)`.
Arrastre { dx: f32, dy: f32, lx: f32, ly: f32 },
FinArrastre,
NuevoMarco,
Eliminar,
Rotar(f64),
Guardar,
Cargar,
Siguiente,
Anterior,
Tick,
}
struct Model {
rec: Recorrido,
state: RecorridoState,
/// `None` = sin arrastre. `Some(None)` = paneando. `Some(Some(id))` = moviendo ese marco.
arrastrando: Option<Option<u64>>,
/// Marco seleccionado (objetivo de eliminar/rotar). Se realza en ámbar.
seleccionado: Option<u64>,
}
struct Demo;
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"pluma · recorrido (autoría: arrastrar mueve marcos, n crea)"
}
fn initial_size() -> (u32, u32) {
(1100, 720)
}
fn init(handle: &Handle<Self::Msg>) -> Self::Model {
let etiqueta = |s: &str| ContenidoMarco::Etiqueta(s.into());
let rec = Recorrido::en_rejilla(
vec![etiqueta("arrastrá un marco"), etiqueta("o el vacío para panear"), etiqueta("tecla n: marco nuevo")],
RejillaOpts { cols: 3, marco_w: 460.0, marco_h: 300.0, gap_x: 200.0, gap_y: 160.0 },
);
let mut state = RecorridoState::new();
state.saltar_a_paso(&rec, 0, PANEL_INICIAL);
handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
Model { rec, state, arrastrando: None, seleccionado: None }
}
fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
let panel = panel_actual().unwrap_or(PANEL_INICIAL);
match msg {
Msg::Zoom { mult, cursor } => {
model.state.wheel(mult, (cursor.0 as f64, cursor.1 as f64), panel);
}
Msg::Arrastre { dx, dy, lx, ly } => {
// En el primer Move decidimos qué se agarró (marco o vacío) y
// lo fijamos hasta soltar — así no se cambia de presa a mitad.
let modo = match model.arrastrando {
Some(m) => m,
None => {
let world = model.state.camara.screen_to_world((lx as f64, ly as f64), panel);
let m = model.rec.marco_en_punto(world);
model.arrastrando = Some(m);
// Agarrar un marco lo selecciona (objetivo de editar).
if m.is_some() {
model.seleccionado = m;
}
m
}
};
match modo {
Some(id) => {
let (wdx, wdy) = model.state.camara.delta_pantalla_a_mundo(dx as f64, dy as f64);
model.rec.mover_marco(id, wdx, wdy);
}
None => model.state.arrastrar_delta(dx as f64, dy as f64),
}
}
Msg::FinArrastre => model.arrastrando = None,
Msg::NuevoMarco => {
let id = model.rec.marcos.iter().map(|m| m.id).max().unwrap_or(0) + 1;
let (cx, cy) = model.state.camara.centro;
let (w, h) = (420.0, 260.0);
model.rec.agregar_marco(Marco::new(
id,
Rect::new(cx - w * 0.5, cy - h * 0.5, w, h),
ContenidoMarco::Etiqueta(format!("marco {id}")),
));
model.rec.pasos.push(id);
model.seleccionado = Some(id);
}
Msg::Eliminar => {
if let Some(id) = model.seleccionado.take() {
model.rec.eliminar_marco(id);
// Reencuadra el paso actual (el guion pudo encogerse).
let idx = model.state.paso.min(model.rec.n_pasos().saturating_sub(1));
model.state.saltar_a_paso(&model.rec, idx, panel);
}
}
Msg::Rotar(d) => {
if let Some(id) = model.seleccionado {
model.rec.rotar_marco(id, d);
}
}
Msg::Guardar => match model.rec.serializar() {
Ok(bytes) => {
let _ = std::fs::write(ARCHIVO, &bytes);
eprintln!("guardado {ARCHIVO} ({} bytes)", bytes.len());
}
Err(e) => eprintln!("error al guardar: {e}"),
},
Msg::Cargar => match std::fs::read(ARCHIVO).map_err(|_| "no se pudo leer").and_then(|b| Recorrido::deserializar(&b)) {
Ok(rec) => {
model.rec = rec;
model.seleccionado = None;
model.state.saltar_a_paso(&model.rec, 0, panel);
eprintln!("cargado {ARCHIVO}");
}
Err(e) => eprintln!("error al cargar: {e}"),
},
Msg::Siguiente => {
model.state.siguiente(&model.rec, panel);
}
Msg::Anterior => {
model.state.anterior(&model.rec, panel);
}
Msg::Tick => {
model.state.avanzar(1.0 / 60.0);
}
}
model
}
fn view(model: &Self::Model) -> View<Self::Msg> {
recorrido_view_editor(&model.rec, &model.state, model.seleccionado).draggable_at(
|phase, dx, dy, lx, ly| match phase {
DragPhase::Move => Some(Msg::Arrastre { dx, dy, lx, ly }),
DragPhase::End => Some(Msg::FinArrastre),
},
)
}
fn on_wheel(_m: &Self::Model, delta: WheelDelta, cursor: (f32, f32), _mods: Modifiers) -> Option<Self::Msg> {
let panel = panel_actual()?;
if !dentro(panel, cursor.0, cursor.1) {
return None;
}
Some(Msg::Zoom { mult: ZOOM_BASE.powf(-delta.y as f64), cursor })
}
fn on_key(_m: &Self::Model, ev: &KeyEvent) -> Option<Self::Msg> {
if ev.state != KeyState::Pressed {
return None;
}
// Atajos con Ctrl: guardar / cargar.
if ev.modifiers.ctrl {
return match &ev.key {
Key::Character(c) if c.eq_ignore_ascii_case("s") => Some(Msg::Guardar),
Key::Character(c) if c.eq_ignore_ascii_case("o") => Some(Msg::Cargar),
_ => None,
};
}
match &ev.key {
Key::Character(c) if c.as_str() == "n" => Some(Msg::NuevoMarco),
Key::Character(c) if c.as_str() == "[" => Some(Msg::Rotar(-0.08)),
Key::Character(c) if c.as_str() == "]" => Some(Msg::Rotar(0.08)),
Key::Named(NamedKey::Delete | NamedKey::Backspace) => Some(Msg::Eliminar),
Key::Named(NamedKey::ArrowRight | NamedKey::ArrowDown | NamedKey::Enter | NamedKey::Space) => {
Some(Msg::Siguiente)
}
Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowUp) => Some(Msg::Anterior),
_ => None,
}
}
}
fn main() {
llimphi_ui::run::<Demo>();
}
@@ -0,0 +1,163 @@
//! Demo de **imagen dentro del marco** (cierra el pendiente menor de la Fase 3
//! del §6.sexies): un marco cuyo contenido es `ContenidoMarco::Imagen`.
//!
//! El core guarda los bytes **codificados** (aquí un PNG generado en memoria,
//! sin tocar disco) + sus dimensiones; el frontend Llimphi los decodifica una
//! vez, los cachea y los pinta encajados en el marco preservando aspect ratio
//! —respetando giro y zoom de la cámara igual que el texto—.
//!
//! Mezcla slides de texto con dos marcos-imagen (uno girado, para lucir que la
//! imagen vuela con el marco). Controles iguales que `recorrido_demo`:
//! - **→ / ↓ / Espacio / Enter**: paso siguiente. **← / ↑**: anterior.
//! - **rueda**: zoom-a-cursor. **arrastrar**: paneo libre.
//!
//! Corre con:
//! `cargo run -p pluma-deck-recorrido-llimphi --example recorrido_imagen_demo --release`
use std::time::Duration;
use llimphi_ui::{App, DragPhase, Handle, Key, KeyEvent, KeyState, Modifiers, NamedKey, View, WheelDelta};
use pluma_deck_core::{ContenidoMarco, Marco, Recorrido, RecorridoState, Rect, RejillaOpts};
use pluma_deck_recorrido_llimphi::{dentro, panel_actual, recorrido_view, ZOOM_BASE};
const PANEL_INICIAL: Rect = Rect { x: 0.0, y: 0.0, w: 1100.0, h: 720.0 };
#[derive(Clone)]
enum Msg {
Zoom { mult: f64, cursor: (f32, f32) },
Pan { dx: f32, dy: f32 },
Siguiente,
Anterior,
Tick,
}
struct Model {
rec: Recorrido,
state: RecorridoState,
}
/// Genera un PNG en memoria: degradado diagonal con una rejilla, para tener una
/// imagen reconocible sin depender de un asset en disco. Devuelve los bytes PNG
/// codificados (lo que viaja en `ContenidoMarco::Imagen`) + sus dimensiones.
fn png_demo(w: u32, h: u32, base: (u8, u8, u8)) -> (Vec<u8>, u32, u32) {
let mut img = image::RgbaImage::new(w, h);
for (x, y, px) in img.enumerate_pixels_mut() {
let fx = x as f32 / w as f32;
let fy = y as f32 / h as f32;
// Degradado diagonal sobre el color base + rejilla cada 40 px.
let t = (fx + fy) * 0.5;
let mezcla = |c: u8| (c as f32 * (0.35 + 0.65 * t)) as u8;
let rejilla = (x % 40 == 0 || y % 40 == 0) as u8 * 40;
*px = image::Rgba([
mezcla(base.0).saturating_add(rejilla),
mezcla(base.1).saturating_add(rejilla),
mezcla(base.2).saturating_add(rejilla),
255,
]);
}
let mut bytes = Vec::new();
image::DynamicImage::ImageRgba8(img)
.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png)
.expect("codificar PNG demo");
(bytes, w, h)
}
struct Demo;
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"pluma · recorrido (imagen dentro del marco)"
}
fn initial_size() -> (u32, u32) {
(1100, 720)
}
fn init(handle: &Handle<Self::Msg>) -> Self::Model {
let slide = |t: &str, ps: &[&str]| ContenidoMarco::Texto {
titulo: Some(t.into()),
parrafos: ps.iter().map(|s| s.to_string()).collect(),
};
let (png_a, wa, ha) = png_demo(480, 320, (90, 150, 230));
let (png_b, wb, hb) = png_demo(360, 360, (230, 140, 90));
let contenidos = vec![
slide(
"Imagen nativa en el marco",
&[
"El marco siguiente lleva una imagen: el core guarda bytes PNG; el frontend los rasteriza y encaja.",
"La imagen vuela con la cámara — zoom y giro la transforman como a cualquier marco.",
],
),
ContenidoMarco::Imagen { bytes: png_a, ancho: wa, alto: ha },
slide("Aspect ratio preservado", &["La imagen se centra y encaja sin deformarse, clipeada al marco."]),
];
let mut rec = Recorrido::en_rejilla(
contenidos,
RejillaOpts { cols: 3, marco_w: 640.0, marco_h: 420.0, gap_x: 240.0, gap_y: 200.0 },
);
// Marco-imagen suelto y girado, para lucir que la imagen sigue el giro.
let id = (rec.marcos.len() + 1) as u64;
rec.agregar_marco(
Marco::new(id, Rect::new(220.0, 760.0, 360.0, 360.0), ContenidoMarco::Imagen { bytes: png_b, ancho: wb, alto: hb })
.con_giro(0.18),
);
rec.pasos.push(id);
let mut state = RecorridoState::new();
state.saltar_a_paso(&rec, 0, PANEL_INICIAL);
handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
Model { rec, state }
}
fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
let panel = panel_actual().unwrap_or(PANEL_INICIAL);
match msg {
Msg::Zoom { mult, cursor } => model.state.wheel(mult, (cursor.0 as f64, cursor.1 as f64), panel),
Msg::Pan { dx, dy } => model.state.arrastrar_delta(dx as f64, dy as f64),
Msg::Siguiente => {
model.state.siguiente(&model.rec, panel);
}
Msg::Anterior => {
model.state.anterior(&model.rec, panel);
}
Msg::Tick => {
model.state.avanzar(1.0 / 60.0);
}
}
model
}
fn view(model: &Self::Model) -> View<Self::Msg> {
recorrido_view(&model.rec, &model.state).draggable(|phase, dx, dy| match phase {
DragPhase::Move => Some(Msg::Pan { dx, dy }),
DragPhase::End => None,
})
}
fn on_wheel(_m: &Self::Model, delta: WheelDelta, cursor: (f32, f32), _mods: Modifiers) -> Option<Self::Msg> {
let panel = panel_actual()?;
if !dentro(panel, cursor.0, cursor.1) {
return None;
}
Some(Msg::Zoom { mult: ZOOM_BASE.powf(-delta.y as f64), cursor })
}
fn on_key(_m: &Self::Model, ev: &KeyEvent) -> Option<Self::Msg> {
if ev.state != KeyState::Pressed {
return None;
}
match &ev.key {
Key::Named(NamedKey::ArrowRight | NamedKey::ArrowDown | NamedKey::Enter | NamedKey::Space) => Some(Msg::Siguiente),
Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowUp) => Some(Msg::Anterior),
_ => None,
}
}
}
fn main() {
llimphi_ui::run::<Demo>();
}
@@ -0,0 +1,146 @@
//! Demo: un documento **markdown real** presentado como recorrido espacial.
//!
//! Cadena completa: `markdown → pluma_md::parse_md → átomos → Recorrido`.
//! Cada encabezado (`#`, `##`, …) abre un "slide" cuyo título es el del
//! encabezado y cuyos párrafos son los bloques siguientes hasta el próximo
//! encabezado. `en_rejilla` los coloca y rutea en orden de lectura.
//!
//! El adaptador (`recorrido_desde_atomos`) ya **no vive aquí**: se promovió a
//! `pluma_deck_core::adaptador` (feature `pluma`) y este demo lo dogfoodea, sin
//! glue inline. El core sigue agnóstico por defecto; la feature sólo se activa
//! en dev para los demos que tocan el modelo de documento de pluma.
//!
//! Corre con un .md propio o el de ejemplo embebido:
//! `cargo run -p pluma-deck-recorrido-llimphi --example recorrido_md_demo --release [archivo.md]`
use std::time::Duration;
use llimphi_ui::{App, DragPhase, Handle, Key, KeyEvent, KeyState, Modifiers, NamedKey, View, WheelDelta};
use pluma_deck_core::adaptador::recorrido_desde_atomos;
use pluma_deck_core::{Recorrido, RecorridoState, Rect, RejillaOpts};
use pluma_deck_recorrido_llimphi::{dentro, panel_actual, recorrido_view, ZOOM_BASE};
const PANEL_INICIAL: Rect = Rect { x: 0.0, y: 0.0, w: 1100.0, h: 720.0 };
const MD_EJEMPLO: &str = "\
# Presentaciones desde markdown
gioser convierte un documento real en un recorrido espacial.
Cada encabezado abre un marco; los párrafos que le siguen son su cuerpo.
## El pipeline
El markdown se parsea con `pluma-md` en átomos (un bloque por átomo).
El adaptador agrupa esos átomos en slides por encabezado.
## El render
`pluma-deck-core` coloca los slides en una rejilla y arma la ruta.
El frontend Llimphi pinta cada marco y la cámara vuela entre ellos.
## Cierre
Mismo material que ya escribís presentado sin diapositivas.
";
#[derive(Clone)]
enum Msg {
Zoom { mult: f64, cursor: (f32, f32) },
Pan { dx: f32, dy: f32 },
Siguiente,
Anterior,
Tick,
}
struct Model {
rec: Recorrido,
state: RecorridoState,
}
struct Demo;
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"pluma · recorrido desde markdown"
}
fn initial_size() -> (u32, u32) {
(1100, 720)
}
fn init(handle: &Handle<Self::Msg>) -> Self::Model {
// Si pasan un .md por arg lo leemos; si no, el embebido.
let md = std::env::args()
.nth(1)
.and_then(|p| std::fs::read_to_string(p).ok())
.unwrap_or_else(|| MD_EJEMPLO.to_string());
let doc = pluma_md::parse_md(&md, "es", "recorrido", 0);
let rec = recorrido_desde_atomos(
&doc.atoms,
RejillaOpts { cols: 3, marco_w: 660.0, marco_h: 420.0, gap_x: 240.0, gap_y: 200.0 },
);
let mut state = RecorridoState::new();
state.saltar_a_paso(&rec, 0, PANEL_INICIAL);
handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
Model { rec, state }
}
fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
let panel = panel_actual().unwrap_or(PANEL_INICIAL);
match msg {
Msg::Zoom { mult, cursor } => {
model.state.wheel(mult, (cursor.0 as f64, cursor.1 as f64), panel);
}
Msg::Pan { dx, dy } => model.state.arrastrar_delta(dx as f64, dy as f64),
Msg::Siguiente => {
model.state.siguiente(&model.rec, panel);
}
Msg::Anterior => {
model.state.anterior(&model.rec, panel);
}
Msg::Tick => {
model.state.avanzar(1.0 / 60.0);
}
}
model
}
fn view(model: &Self::Model) -> View<Self::Msg> {
recorrido_view(&model.rec, &model.state).draggable(|phase, dx, dy| match phase {
DragPhase::Move => Some(Msg::Pan { dx, dy }),
DragPhase::End => None,
})
}
fn on_wheel(_m: &Self::Model, delta: WheelDelta, cursor: (f32, f32), _mods: Modifiers) -> Option<Self::Msg> {
let panel = panel_actual()?;
if !dentro(panel, cursor.0, cursor.1) {
return None;
}
Some(Msg::Zoom { mult: ZOOM_BASE.powf(-delta.y as f64), cursor })
}
fn on_key(_m: &Self::Model, ev: &KeyEvent) -> Option<Self::Msg> {
if ev.state != KeyState::Pressed {
return None;
}
match &ev.key {
Key::Named(NamedKey::ArrowRight | NamedKey::ArrowDown | NamedKey::Enter | NamedKey::Space) => {
Some(Msg::Siguiente)
}
Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowUp) => Some(Msg::Anterior),
_ => None,
}
}
}
fn main() {
llimphi_ui::run::<Demo>();
}
@@ -0,0 +1,463 @@
//! Frontend Llimphi del modo `Recorrido` de `pluma-deck-core` — presentación
//! espacial tipo Prezi: un lienzo 2D infinito con marcos colocados en
//! coordenadas de mundo y una cámara que vuela entre ellos.
//!
//! La lógica vive entera en `pluma-deck-core` (cámara, ruta, máquina de
//! interacción); aquí sólo hay **pintura** (`View::paint_with` aplicando el
//! transform de la cámara) y el cableado de eventos. Sigue la regla #2 del
//! repo: la UI es un frontend intercambiable sobre un `*-core` agnóstico.
//!
//! El host arma su `App` así:
//! - `view`: nodo a pantalla completa con [`recorrido_view`] (registra el rect
//! del panel en un side-channel para que `on_wheel` sepa el tamaño).
//! - `on_wheel`: lee [`panel_actual`] y despacha un zoom-a-cursor.
//! - drag sobre el nodo: `RecorridoState::arrastrar_delta` (pan libre).
//! - flechas: `siguiente`/`anterior` + un tick periódico que llama
//! `RecorridoState::avanzar(dt)` para animar el vuelo.
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle, Rect as KurboRect, RoundedRect, Stroke};
use llimphi_ui::llimphi_raster::peniko::{Blob, Color, Fill, Image as PenikoImage, ImageFormat, Mix};
use llimphi_ui::llimphi_text::{draw_layout_xf, layout_block, measurement, Alignment, TextBlock};
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
use llimphi_ui::{PaintRect, View};
use pluma_deck_core::{Camara, ContenidoMarco, MarcoId, Recorrido, RecorridoState, Rect};
/// Base del zoom por "clic" de rueda (igual criterio que tullpu: `1.1`).
pub const ZOOM_BASE: f64 = 1.1;
type Scene = llimphi_ui::llimphi_raster::vello::Scene;
type Ts = llimphi_ui::llimphi_text::Typesetter;
// ---- Side-channel del rect del panel -------------------------------------
//
// `App::on_wheel` recibe el cursor absoluto pero no el tamaño del viewport.
// El `paint_with` de [`recorrido_view`] escribe el rect del panel cada frame
// y `on_wheel`/handlers lo leen. Mismo patrón que `tullpu` (`LIENZO_RECT`).
static PANEL_RECT: OnceLock<Mutex<Option<Rect>>> = OnceLock::new();
fn panel_set(r: Rect) {
let cell = PANEL_RECT.get_or_init(|| Mutex::new(None));
if let Ok(mut g) = cell.lock() {
*g = Some(r);
}
}
/// Último rect del panel (px de pantalla) registrado por [`recorrido_view`].
/// `None` hasta el primer frame pintado. Lo usan `on_wheel` (zoom-a-cursor),
/// `siguiente`/`anterior` (encuadre) en el `update` del host.
pub fn panel_actual() -> Option<Rect> {
PANEL_RECT.get()?.lock().ok().and_then(|g| *g)
}
/// `true` si `(cx, cy)` (px de pantalla) cae dentro de `panel`.
pub fn dentro(panel: Rect, cx: f32, cy: f32) -> bool {
let (cx, cy) = (cx as f64, cy as f64);
cx >= panel.x && cx <= panel.x + panel.w && cy >= panel.y && cy <= panel.y + panel.h
}
// ---- Caché de imágenes decodificadas -------------------------------------
//
// `ContenidoMarco::Imagen` guarda bytes **codificados** (PNG/JPEG/WebP): el
// core es agnóstico al render. Decodificarlos a RGBA8 en cada frame sería
// carísimo, así que se decodifica una vez y se cachea la `peniko::Image`
// (que es barata de clonar — su `Blob` es `Arc`). La clave `(id, len)` detecta
// el caso de reemplazar la imagen de un marco por otra de distinto tamaño.
static IMG_CACHE: OnceLock<Mutex<HashMap<(MarcoId, usize), PenikoImage>>> = OnceLock::new();
/// Devuelve la `peniko::Image` del marco `id`, decodificando+cacheando la
/// primera vez. `None` si los bytes no son una imagen válida.
fn imagen_cacheada(id: MarcoId, bytes: &[u8]) -> Option<PenikoImage> {
let cell = IMG_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let mut g = cell.lock().ok()?;
if let Some(img) = g.get(&(id, bytes.len())) {
return Some(img.clone());
}
let img = decodificar(bytes)?;
g.insert((id, bytes.len()), img.clone());
Some(img)
}
fn decodificar(bytes: &[u8]) -> Option<PenikoImage> {
let img = image::load_from_memory(bytes).ok()?.to_rgba8();
let (w, h) = (img.width(), img.height());
let blob = Blob::from(img.into_raw());
Some(PenikoImage::new(blob, ImageFormat::Rgba8, w, h))
}
// ---- Pintura por marco ---------------------------------------------------
//
// El `paint_with` corre cada frame con un closure `Send + Sync`. Para no clonar
// los bytes de imagen (ni re-decodificar) por frame, `recorrido_view` precocina
// cada marco a una `Pintura` ligera: el texto se clona (barato) y la imagen se
// resuelve a una `peniko::Image` cacheada (clon barato).
enum Pintura {
Etiqueta(String),
Texto { titulo: Option<String>, parrafos: Vec<String> },
Imagen(PenikoImage),
Nada,
}
struct MarcoPintura {
id: MarcoId,
rect: Rect,
rot_rad: f64,
pintura: Pintura,
}
// ---- Colores del lienzo (no temáticos todavía; placeholder sobrio) -------
const FONDO: Color = Color::from_rgba8(18, 20, 28, 255);
const MARCO_FONDO: Color = Color::from_rgba8(38, 42, 56, 255);
const MARCO_BORDE: Color = Color::from_rgba8(80, 86, 104, 255);
const MARCO_ACENTO: Color = Color::from_rgba8(120, 180, 255, 255);
const TEXTO: Color = Color::from_rgba8(225, 230, 240, 235);
const TEXTO_TENUE: Color = Color::from_rgba8(186, 194, 210, 225);
// Camino narrativo: línea punteada que une los marcos en orden de la ruta,
// pintada por detrás de los marcos. Acento tenue para no robar protagonismo.
const RUTA: Color = Color::from_rgba8(120, 180, 255, 120);
// Realce del marco seleccionado en autoría — ámbar, distinto del acento azul
// del paso actual, para que selección y "paso actual" se distingan de un vistazo.
const SELECCION: Color = Color::from_rgba8(255, 196, 92, 255);
// HUD "paso X / N": píldora sobria abajo-centro, en espacio de pantalla.
const HUD_FONDO: Color = Color::from_rgba8(12, 14, 20, 190);
const HUD_TEXTO: Color = Color::from_rgba8(205, 212, 226, 235);
/// Nodo a pantalla completa que pinta el recorrido y registra el rect del
/// panel. `Msg` es libre: el caller suele colgarle un `.draggable(...)` para
/// el pan — esta función no lo impone para no fijar el tipo de mensaje.
pub fn recorrido_view<Msg: 'static>(rec: &Recorrido, state: &RecorridoState) -> View<Msg> {
vista(rec, state, None)
}
/// Igual que [`recorrido_view`] pero realzando el marco `seleccionado` (autoría):
/// se pinta con un borde de selección distinto del acento del paso actual.
pub fn recorrido_view_editor<Msg: 'static>(
rec: &Recorrido,
state: &RecorridoState,
seleccionado: Option<MarcoId>,
) -> View<Msg> {
vista(rec, state, seleccionado)
}
fn vista<Msg: 'static>(rec: &Recorrido, state: &RecorridoState, seleccionado: Option<MarcoId>) -> View<Msg> {
// Precocinamos cada marco a una `Pintura` ligera (texto clonado, imagen
// resuelta a peniko::Image cacheada) para no clonar bytes ni re-decodificar
// por frame, y para que el closure `Send + Sync` sobreviva sin los bytes.
let pinturas: Vec<MarcoPintura> = rec
.marcos
.iter()
.map(|m| {
let pintura = match &m.contenido {
ContenidoMarco::Etiqueta(t) if !t.is_empty() => Pintura::Etiqueta(t.clone()),
ContenidoMarco::Texto { titulo, parrafos } => {
Pintura::Texto { titulo: titulo.clone(), parrafos: parrafos.clone() }
}
ContenidoMarco::Imagen { bytes, .. } => {
imagen_cacheada(m.id, bytes).map(Pintura::Imagen).unwrap_or(Pintura::Nada)
}
_ => Pintura::Nada,
};
MarcoPintura { id: m.id, rect: m.rect, rot_rad: m.rot_rad, pintura }
})
.collect();
let paso_id = rec.pasos.get(state.paso).copied();
// Centros (coords de mundo) de los marcos en orden de la ruta, para pintar
// el camino narrativo. Los pasos cuyo id no resuelve se omiten.
let ruta: Vec<(f64, f64)> =
rec.pasos.iter().filter_map(|id| rec.marco(*id)).map(|m| m.rect.centro()).collect();
let camara = state.camara;
let paso = state.paso;
let n_pasos = rec.n_pasos();
View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(FONDO)
.paint_with(move |scene, ts, rect: PaintRect| {
panel_set(to_rect(rect));
pintar(scene, ts, rect, &pinturas, &ruta, paso_id, seleccionado, &camara);
pintar_hud(scene, ts, rect, paso, n_pasos);
})
}
fn to_rect(r: PaintRect) -> Rect {
Rect::new(r.x as f64, r.y as f64, r.w as f64, r.h as f64)
}
/// Affine mundo→pantalla de una cámara, dado el rect del panel.
/// `pantalla = centro_panel + escala(zoom) · rot(-rot) · (mundo - centro)`.
fn world_to_screen_affine(cam: &Camara, panel: Rect) -> Affine {
let pcx = panel.x + panel.w * 0.5;
let pcy = panel.y + panel.h * 0.5;
Affine::translate((pcx, pcy))
* Affine::scale(cam.zoom)
* Affine::rotate(-cam.rot_rad)
* Affine::translate((-cam.centro.0, -cam.centro.1))
}
fn pintar(
scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
ts: &mut llimphi_ui::llimphi_text::Typesetter,
rect: PaintRect,
marcos: &[MarcoPintura],
ruta: &[(f64, f64)],
paso_id: Option<MarcoId>,
seleccionado: Option<MarcoId>,
cam: &Camara,
) {
if rect.w <= 0.0 || rect.h <= 0.0 {
return;
}
let panel = to_rect(rect);
let w2s = world_to_screen_affine(cam, panel);
// Clip al panel: un marco con zoom-in no debe derramar fuera del nodo.
let node = 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(Mix::Clip, 1.0, Affine::IDENTITY, &node);
// Camino narrativo por detrás de los marcos.
pintar_ruta(scene, cam, panel, ruta);
for m in marcos {
let (mcx, mcy) = m.rect.centro();
// Giro propio del marco alrededor de su centro, encadenado al mundo→pantalla.
let xf = w2s
* Affine::translate((mcx, mcy))
* Affine::rotate(m.rot_rad)
* Affine::translate((-mcx, -mcy));
let kr = KurboRect::new(
m.rect.x,
m.rect.y,
m.rect.x + m.rect.w,
m.rect.y + m.rect.h,
);
scene.fill(Fill::NonZero, xf, MARCO_FONDO, None, &kr);
// La imagen se pinta encajada en el marco (respeta giro/zoom vía `xf`).
if let Pintura::Imagen(img) = &m.pintura {
pintar_imagen(scene, xf, &m.rect, img);
}
let actual = paso_id == Some(m.id);
let (grosor, color) = if actual { (3.0, MARCO_ACENTO) } else { (1.0, MARCO_BORDE) };
scene.stroke(&Stroke::new(grosor), xf, color, None, &kr);
// Realce de selección (autoría): borde ámbar punteado por encima, así se
// distingue del acento azul del paso actual aunque coincidan.
if seleccionado == Some(m.id) {
let sel = Stroke::new(2.5).with_dashes(0.0, [7.0, 5.0]);
scene.stroke(&sel, xf, SELECCION, None, &kr);
}
// El texto se pinta en el **espacio local del marco** (origen en su
// esquina sup-izq, ejes alineados al marco, 1 unidad = 1 px de pantalla),
// así sigue el giro del marco como lo hace su borde. Los tamaños de
// fuente se clampan por zoom para seguir legibles lejos/cerca.
let local = marco_local_xf(cam, panel, &m.rect, m.rot_rad);
let w_px = (m.rect.w * cam.zoom) as f32;
let h_px = (m.rect.h * cam.zoom) as f32;
match &m.pintura {
Pintura::Etiqueta(t) => pintar_etiqueta(scene, ts, local, w_px, h_px, cam.zoom, t),
Pintura::Texto { titulo, parrafos } => {
pintar_texto(scene, ts, local, w_px, h_px, cam.zoom, titulo.as_deref(), parrafos);
}
Pintura::Imagen(_) | Pintura::Nada => {}
}
}
scene.pop_layer();
}
/// Pinta el **camino narrativo**: una polilínea punteada que une los centros
/// de los marcos en orden de la ruta, con un nodo en cada paso. Se dibuja en
/// espacio de pantalla (grosor constante en px a cualquier zoom) y por detrás
/// de los marcos. No-op con menos de dos pasos.
fn pintar_ruta(scene: &mut Scene, cam: &Camara, panel: Rect, ruta: &[(f64, f64)]) {
if ruta.len() < 2 {
return;
}
let mut path = BezPath::new();
for (i, p) in ruta.iter().enumerate() {
let s = cam.world_to_screen(*p, panel);
if i == 0 {
path.move_to(s);
} else {
path.line_to(s);
}
}
let trazo = Stroke::new(2.0).with_dashes(0.0, [9.0, 7.0]);
scene.stroke(&trazo, Affine::IDENTITY, RUTA, None, &path);
for p in ruta {
let s = cam.world_to_screen(*p, panel);
scene.fill(Fill::NonZero, Affine::IDENTITY, RUTA, None, &Circle::new(s, 3.5));
}
}
/// Pinta `img` encajada en el rect del marco preservando aspect ratio,
/// centrada y clipeada al marco (en su espacio transformado, así respeta el
/// giro propio). `xf` es el mundo→pantalla del marco ya con su rotación.
fn pintar_imagen(scene: &mut Scene, xf: Affine, rect: &Rect, img: &PenikoImage) {
let (iw, ih) = (img.width as f64, img.height as f64);
if iw <= 0.0 || ih <= 0.0 || rect.w <= 0.0 || rect.h <= 0.0 {
return;
}
let s = (rect.w / iw).min(rect.h / ih);
let (dw, dh) = (iw * s, ih * s);
let ox = rect.x + (rect.w - dw) * 0.5;
let oy = rect.y + (rect.h - dh) * 0.5;
let kr = KurboRect::new(rect.x, rect.y, rect.x + rect.w, rect.y + rect.h);
// Clip al rect del marco en su propio espacio (xf incluye giro+zoom).
scene.push_layer(Mix::Clip, 1.0, xf, &kr);
let img_xf = xf * Affine::translate((ox, oy)) * Affine::scale(s);
scene.draw_image(img, img_xf);
scene.pop_layer();
}
/// Afín que lleva coordenadas **locales del marco** a pantalla: el origen (0,0)
/// es su esquina superior-izquierda, los ejes están alineados al marco (rotados
/// según el giro del marco *relativo a la cámara*) y 1 unidad local = 1 px de
/// pantalla. Pintar texto en este espacio hace que siga el giro del marco igual
/// que su borde, sin que el zoom deforme el tamaño de fuente (que se clampa).
fn marco_local_xf(cam: &Camara, panel: Rect, rect: &Rect, rot_rad: f64) -> Affine {
let (scx, scy) = cam.world_to_screen(rect.centro(), panel);
// Giro del marco visto en pantalla: la cámara rota -rot, el marco +rot_rad.
let ang = rot_rad - cam.rot_rad;
let (w_px, h_px) = (rect.w * cam.zoom, rect.h * cam.zoom);
Affine::translate((scx, scy))
* Affine::rotate(ang)
* Affine::translate((-w_px * 0.5, -h_px * 0.5))
}
/// Etiqueta de una línea centrada en el marco, en su espacio local (sigue el
/// giro). El tamaño escala con el zoom (clamp para seguir legible lejos/cerca).
fn pintar_etiqueta(scene: &mut Scene, ts: &mut Ts, local: Affine, w_px: f32, h_px: f32, zoom: f64, t: &str) {
if w_px < 12.0 {
return; // demasiado chico para texto
}
let size_px = ((16.0 * zoom) as f32).clamp(9.0, 40.0);
let block = TextBlock {
text: t,
size_px,
color: TEXTO,
// Centrado vertical aproximado dentro del marco; el `max_width` + center
// resuelven el horizontal.
origin: (0.0, (h_px as f64 - size_px as f64) * 0.5),
max_width: Some(w_px),
alignment: Alignment::Center,
line_height: 1.2,
italic: false,
font_family: None,
};
let layout = layout_block(ts, &block);
draw_layout_xf(scene, &layout, block.color, local * Affine::translate(block.origin));
}
/// Contenido de "slide": título (si hay) + párrafos, fluidos desde la esquina
/// superior-izquierda del marco, clipeados a su rect — todo en el espacio local
/// del marco, así el texto gira con él. El apilado usa la altura medida del
/// título.
fn pintar_texto(
scene: &mut Scene,
ts: &mut Ts,
local: Affine,
w_px: f32,
h_px: f32,
zoom: f64,
titulo: Option<&str>,
parrafos: &[String],
) {
if w_px < 40.0 || h_px < 24.0 {
return; // demasiado chico para texto fluido
}
let pad = ((12.0 * zoom) as f32).clamp(5.0, 22.0);
let inner_w = (w_px - 2.0 * pad).max(8.0);
let left = pad as f64;
let mut y = pad as f64;
// Clip al rect del marco en su espacio local (gira con el marco).
let clip = KurboRect::new(0.0, 0.0, w_px as f64, h_px as f64);
scene.push_layer(Mix::Clip, 1.0, local, &clip);
if let Some(tt) = titulo.filter(|s| !s.is_empty()) {
let size = ((22.0 * zoom) as f32).clamp(12.0, 46.0);
let block = TextBlock {
text: tt,
size_px: size,
color: TEXTO,
origin: (left, y),
max_width: Some(inner_w),
alignment: Alignment::Start,
line_height: 1.15,
italic: false,
font_family: None,
};
let layout = layout_block(ts, &block);
let medida = measurement(&layout);
draw_layout_xf(scene, &layout, TEXTO, local * Affine::translate((left, y)));
y += medida.height as f64 + ((10.0 * zoom) as f32).clamp(4.0, 18.0) as f64;
}
if !parrafos.is_empty() {
let cuerpo = parrafos.join("\n\n");
let size = ((15.0 * zoom) as f32).clamp(9.0, 32.0);
let block = TextBlock {
text: &cuerpo,
size_px: size,
color: TEXTO_TENUE,
origin: (left, y),
max_width: Some(inner_w),
alignment: Alignment::Start,
line_height: 1.35,
italic: false,
font_family: None,
};
let layout = layout_block(ts, &block);
draw_layout_xf(scene, &layout, TEXTO_TENUE, local * Affine::translate((left, y)));
}
scene.pop_layer();
}
/// HUD de progreso "paso actual / total" — píldora sobria abajo-centro, pintada
/// en **espacio de pantalla** (no la afecta la cámara). Orienta al presentador
/// sin robar protagonismo al lienzo. No-op si la ruta está vacía.
fn pintar_hud(scene: &mut Scene, ts: &mut Ts, rect: PaintRect, paso: usize, n_pasos: usize) {
if n_pasos == 0 || rect.w <= 0.0 || rect.h <= 0.0 {
return;
}
let texto = format!("{} / {}", paso + 1, n_pasos);
let block = TextBlock {
text: &texto,
size_px: 14.0,
color: HUD_TEXTO,
origin: (0.0, 0.0),
max_width: None,
alignment: Alignment::Start,
line_height: 1.2,
italic: false,
font_family: None,
};
let layout = layout_block(ts, &block);
let m = measurement(&layout);
let (pad_x, pad_y) = (12.0_f64, 6.0_f64);
let w = m.width as f64 + pad_x * 2.0;
let h = m.height as f64 + pad_y * 2.0;
let cx = rect.x as f64 + rect.w as f64 * 0.5;
let x0 = cx - w * 0.5;
let y0 = rect.y as f64 + rect.h as f64 - h - 16.0;
let pill = RoundedRect::new(x0, y0, x0 + w, y0 + h, h * 0.5);
scene.fill(Fill::NonZero, Affine::IDENTITY, HUD_FONDO, None, &pill);
draw_layout_xf(scene, &layout, HUD_TEXTO, Affine::translate((x0 + pad_x, y0 + pad_y)));
}
@@ -0,0 +1,32 @@
[package]
name = "pluma-deck-web"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
[dependencies]
pluma-deck-core = { path = "../pluma-deck-core" }
wasm-bindgen.workspace = true
js-sys.workspace = true
[dependencies.web-sys]
workspace = true
features = [
"Window",
"Document",
"Element",
"HtmlElement",
"CssStyleDeclaration",
"DomTokenList",
"Event",
"EventTarget",
"PointerEvent",
"MouseEvent",
"WheelEvent",
"KeyboardEvent",
"HtmlCollection",
"DomRect",
"AddEventListenerOptions",
]
+54
View File
@@ -0,0 +1,54 @@
# pluma-deck-web
> Deck en navegador para [pluma](../README.md).
Toma un `Deck` de [`pluma-deck-core`](../pluma-deck-core/README.md) y lo renderiza como SPA: slide actual + nav (←/→ teclas), aspect ratio configurable, full-screen, soporta el mismo theme system que el reader.
## API
```rust
use pluma_deck_web::Presenter;
let p = Presenter::new(container);
p.cargar(&deck);
```
## Modo espacial — Recorrido (tipo Prezi)
Espejo web del frontend Llimphi (`pluma-deck-recorrido-llimphi`): en vez del
strip 1D, un **lienzo infinito** con marcos en coordenadas de mundo y una
cámara que vuela entre ellos. La lógica (cámara/ruta/gesto) vive en
[`pluma-deck-core`](../pluma-deck-core/README.md); el binding (`recorrido`)
sólo aplica la cámara como **un único `transform` CSS** sobre `mundo`
(estilo impress.js) y delega el vuelo entre pasos a una transición CSS.
### Contrato DOM
```html
<div class="recorrido-viewport">
<div class="recorrido-mundo">
<div class="recorrido-marco" data-x="0" data-y="0" data-w="640" data-h="400">…</div>
<div class="recorrido-marco" data-x="900" data-y="0" data-w="640" data-h="400" data-rot="0.1">…</div>
</div>
</div>
```
Cada marco lleva su rect de **mundo** en `data-{x,y,w,h}` (px) y un giro opcional
`data-rot` (radianes). El orden DOM define la ruta. El contenido HTML interno es
libre (texto, `<img>`, etc.).
### API
```rust
use pluma_deck_web::recorrido::RecorridoWeb;
let r = RecorridoWeb::mount(viewport, mundo)?;
r.on_change(|paso| { /* … */ });
// flechas/espacio/enter avanzan; rueda = zoom-a-cursor; arrastrar = paneo.
r.siguiente(); r.anterior(); r.goto(2, true);
```
## Deps
- [`pluma-deck-core`](../pluma-deck-core/README.md), [`pluma-md`](../pluma-md/README.md)
- `wasm-bindgen`, `web-sys`
@@ -0,0 +1,19 @@
# pluma-deck-web
> Browser-side deck for [pluma](../README.md).
Takes a `Deck` from [`pluma-deck-core`](../pluma-deck-core/README.md) and renders it as an SPA: current slide + nav (←/→ keys), configurable aspect ratio, fullscreen, same theme system as the reader.
## API
```rust
use pluma_deck_web::Presenter;
let p = Presenter::new(container);
p.cargar(&deck);
```
## Deps
- [`pluma-deck-core`](../pluma-deck-core/README.md), [`pluma-md`](../pluma-md/README.md)
- `wasm-bindgen`, `web-sys`
+212
View File
@@ -0,0 +1,212 @@
//! Vista-web — binding DOM del deck horizontal. Toda la lógica de
//! decisión de gesto/snap vive en `vista-core`; este crate sólo traduce
//! `PointerEvent`s en eventos de `DeckState` y aplica el offset al DOM.
//!
//! Contrato CSS (el caller provee):
//! ```css
//! .vista-deck { overflow: hidden; touch-action: pan-y; }
//! .vista-strip {
//! display: flex;
//! width: 100%;
//! height: 100%;
//! transform: translate3d(var(--vista-offset, 0px), 0, 0);
//! transition: transform 360ms cubic-bezier(0.22, 0.61, 0.36, 1);
//! }
//! .vista-strip.vista-dragging,
//! .vista-strip.vista-instant { transition: none; }
//! .vista-page { flex: 0 0 100%; height: 100%; overflow-y: auto; }
//! ```
pub mod recorrido;
use std::cell::RefCell;
use std::rc::Rc;
use pluma_deck_core::{DeckState, DragOutcome};
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{Event, HtmlElement, PointerEvent};
#[derive(Clone)]
pub struct Deck {
strip: HtmlElement,
inner: Rc<RefCell<Inner>>,
}
struct Inner {
state: DeckState,
on_change: Option<Box<dyn FnMut(usize)>>,
}
impl Deck {
pub fn mount(strip: HtmlElement) -> Result<Self, JsValue> {
let inner = Rc::new(RefCell::new(Inner {
state: DeckState::new(),
on_change: None,
}));
install_pointerdown(&strip, &inner)?;
install_pointermove(&strip, &inner)?;
install_pointerend(&strip, &inner, "pointerup")?;
install_pointerend(&strip, &inner, "pointercancel")?;
install_pointerend(&strip, &inner, "pointerleave")?;
install_resize(&strip, &inner)?;
Ok(Self { strip, inner })
}
pub fn goto(&self, index: usize, smooth: bool) {
let width = self.strip.client_width() as f64;
let mut i = self.inner.borrow_mut();
let r = i.state.goto(index, width);
drop(i);
if !smooth {
let _ = self.strip.class_list().add_1("vista-instant");
}
set_offset(&self.strip, r.offset_px);
if !smooth {
clear_instant_next_frame(&self.strip);
}
if r.changed {
let mut i = self.inner.borrow_mut();
if let Some(cb) = i.on_change.as_mut() {
cb(r.target_index);
}
}
}
pub fn current_index(&self) -> usize {
self.inner.borrow().state.current_index
}
pub fn page_count(&self) -> u32 {
self.strip.child_element_count()
}
pub fn on_change<F: FnMut(usize) + 'static>(&self, cb: F) {
self.inner.borrow_mut().on_change = Some(Box::new(cb));
}
pub fn strip(&self) -> &HtmlElement {
&self.strip
}
}
fn install_pointerdown(strip: &HtmlElement, inner: &Rc<RefCell<Inner>>) -> Result<(), JsValue> {
let strip2 = strip.clone();
let inner2 = inner.clone();
let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
let width = strip2.client_width() as f64;
inner2.borrow_mut().state.pointer_down(
e.client_x() as f64,
e.client_y() as f64,
e.pointer_id(),
width,
);
});
strip.add_event_listener_with_callback("pointerdown", cb.as_ref().unchecked_ref())?;
cb.forget();
Ok(())
}
fn install_pointermove(strip: &HtmlElement, inner: &Rc<RefCell<Inner>>) -> Result<(), JsValue> {
let strip2 = strip.clone();
let inner2 = inner.clone();
let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
let outcome = inner2
.borrow_mut()
.state
.pointer_move(e.client_x() as f64, e.client_y() as f64);
match outcome {
DragOutcome::StartHorizontal { pointer_id } => {
let _ = strip2.class_list().add_1("vista-dragging");
let _ = strip2.set_pointer_capture(pointer_id);
}
DragOutcome::DragOffset(offset) => {
set_offset(&strip2, offset);
e.prevent_default();
}
DragOutcome::Idle | DragOutcome::CancelVertical => {}
}
});
let opts = web_sys::AddEventListenerOptions::new();
opts.set_passive(false);
strip.add_event_listener_with_callback_and_add_event_listener_options(
"pointermove",
cb.as_ref().unchecked_ref(),
&opts,
)?;
cb.forget();
Ok(())
}
fn install_pointerend(
strip: &HtmlElement,
inner: &Rc<RefCell<Inner>>,
event_name: &str,
) -> Result<(), JsValue> {
let strip2 = strip.clone();
let inner2 = inner.clone();
let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
let width = strip2.client_width() as f64;
let offset = current_offset_px(&strip2);
let n_pages = strip2.child_element_count() as usize;
let res = inner2.borrow_mut().state.pointer_end(offset, width, n_pages);
let _ = strip2.class_list().remove_1("vista-dragging");
let _ = strip2.release_pointer_capture(e.pointer_id());
if let Some(r) = res {
set_offset(&strip2, r.offset_px);
if r.changed {
let mut i = inner2.borrow_mut();
if let Some(cb) = i.on_change.as_mut() {
cb(r.target_index);
}
}
}
});
strip.add_event_listener_with_callback(event_name, cb.as_ref().unchecked_ref())?;
cb.forget();
Ok(())
}
fn install_resize(strip: &HtmlElement, inner: &Rc<RefCell<Inner>>) -> Result<(), JsValue> {
let Some(window) = web_sys::window() else { return Ok(()) };
let strip2 = strip.clone();
let inner2 = inner.clone();
let cb = Closure::<dyn FnMut()>::new(move || {
let width = strip2.client_width() as f64;
let offset = inner2.borrow().state.reposition(width);
let _ = strip2.class_list().add_1("vista-instant");
set_offset(&strip2, offset);
clear_instant_next_frame(&strip2);
});
window.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref())?;
cb.forget();
Ok(())
}
fn set_offset(strip: &HtmlElement, offset_px: f64) {
let _ = strip
.style()
.set_property("--vista-offset", &format!("{}px", offset_px));
}
fn current_offset_px(strip: &HtmlElement) -> f64 {
let s = strip
.style()
.get_property_value("--vista-offset")
.unwrap_or_default();
s.trim().trim_end_matches("px").parse::<f64>().unwrap_or(0.0)
}
fn clear_instant_next_frame(strip: &HtmlElement) {
let strip2 = strip.clone();
let cb = Closure::once(Box::new(move || {
let _ = strip2.class_list().remove_1("vista-instant");
}) as Box<dyn FnOnce()>);
if let Some(w) = web_sys::window() {
let _ = w.request_animation_frame(cb.as_ref().unchecked_ref());
}
cb.forget();
}
#[doc(hidden)]
pub fn __unused_event_marker(_e: &Event) {}
@@ -0,0 +1,603 @@
//! Vista-web **espacial** — binding DOM del modo Recorrido (tipo Prezi).
//!
//! Espejo web del frontend Llimphi (`pluma-deck-recorrido-llimphi`): la lógica
//! de cámara/ruta/gesto vive entera en `pluma-deck-core`; aquí sólo se traduce
//! pointer/wheel/teclado → llamadas al core y se aplica la cámara como **un
//! único `transform` CSS** sobre un contenedor `mundo` (estilo impress.js).
//!
//! A diferencia del strip lineal ([`crate::Deck`], `translate3d` 1D), el modo
//! espacial coloca cada marco en coordenadas de mundo dentro de `mundo` y
//! mueve la cámara sobre todos ellos. El vuelo guiado entre pasos se delega a
//! una **transición CSS** del transform (el core sólo provee la cámara objetivo
//! vía `fit`), igual que el strip delega su deslizamiento a `transition`.
//!
//! Contrato DOM (el caller provee):
//! ```html
//! <div class="recorrido-viewport">
//! <div class="recorrido-mundo">
//! <div class="recorrido-marco" data-x="0" data-y="0" data-w="640" data-h="400">…</div>
//! <div class="recorrido-marco" data-x="900" data-y="0" data-w="640" data-h="400" data-rot="0.1">…</div>
//! </div>
//! </div>
//! ```
//! Cada `.recorrido-marco` lleva su rect de **mundo** en `data-{x,y,w,h}` (px) y
//! un giro opcional `data-rot` (radianes). El orden DOM define la ruta. El
//! binding posiciona los marcos y mueve la cámara; el contenido HTML interno es
//! libre (texto, `<img>`, lo que sea).
use std::cell::RefCell;
use std::rc::Rc;
use pluma_deck_core::{Camara, ContenidoMarco, Marco, Recorrido, RecorridoState, Rect, DURACION_PASO_S};
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{HtmlElement, KeyboardEvent, PointerEvent, WheelEvent};
/// Base de zoom por "clic" de rueda (igual criterio que tullpu y el frontend Llimphi).
pub const ZOOM_BASE: f64 = 1.1;
/// Normalizador del `deltaY` de la rueda a "notches" de zoom: deltaY en modo
/// pixel ronda ±100 por muesca, así que dividir por esto da ~1 notch por muesca
/// y a la vez deja que el pinch de trackpad (deltas chicos) zoomee proporcional.
const WHEEL_NORM: f64 = 100.0;
/// Permanencia por defecto del modo presentador, en ms (espejo de `DWELL_S`).
pub const DWELL_MS: i32 = 2500;
/// Curva del vuelo entre pasos (misma que el strip lineal: salida suave).
const EASE_VUELO: &str = "cubic-bezier(0.22, 0.61, 0.36, 1)";
#[derive(Clone)]
pub struct RecorridoWeb {
viewport: HtmlElement,
mundo: HtmlElement,
inner: Rc<RefCell<Inner>>,
}
struct Inner {
rec: Recorrido,
state: RecorridoState,
/// `Some((x,y))` = paneando desde esa última posición de pointer.
arrastrando: Option<(f64, f64)>,
on_change: Option<Box<dyn FnMut(usize)>>,
/// Handle del `setInterval` del modo presentador (autoplay), si está activo.
autoplay_id: Option<i32>,
/// Closure del intervalo — se guarda para mantenerla viva mientras corre.
autoplay_cb: Option<Closure<dyn FnMut()>>,
}
impl RecorridoWeb {
/// Monta el recorrido sobre `viewport` (clip) y `mundo` (contenedor
/// transformado). Lee los `.recorrido-marco` hijos de `mundo`, los posiciona
/// en coordenadas de mundo y encuadra el primero.
pub fn mount(viewport: HtmlElement, mundo: HtmlElement) -> Result<Self, JsValue> {
estilo(&viewport, "overflow", "hidden");
estilo(&viewport, "position", "relative");
estilo(&viewport, "touch-action", "none");
estilo(&viewport, "user-select", "none");
estilo(&mundo, "position", "absolute");
estilo(&mundo, "left", "0");
estilo(&mundo, "top", "0");
estilo(&mundo, "transform-origin", "0 0");
estilo(&mundo, "will-change", "transform");
let rec = leer_marcos(&mundo);
let mut state = RecorridoState::new();
let panel = panel_de(&viewport);
state.saltar_a_paso(&rec, 0, panel);
let inner = Rc::new(RefCell::new(Inner {
rec,
state,
arrastrando: None,
on_change: None,
autoplay_id: None,
autoplay_cb: None,
}));
aplicar_camara(&mundo, &inner.borrow().state.camara, panel, false);
let this = Self { viewport, mundo, inner };
this.install_pointer()?;
this.install_wheel()?;
this.install_keys()?;
Ok(this)
}
fn panel(&self) -> Rect {
panel_de(&self.viewport)
}
fn aplicar(&self, transicion: bool) {
let panel = self.panel();
aplicar_camara(&self.mundo, &self.inner.borrow().state.camara, panel, transicion);
}
/// Vuela a encuadrar el paso `idx` (con transición si `smooth`). Notifica
/// `on_change` si cambió el paso.
pub fn goto(&self, idx: usize, smooth: bool) {
let panel = self.panel();
let mut i = self.inner.borrow_mut();
let antes = i.state.paso;
let Inner { rec, state, .. } = &mut *i;
state.saltar_a_paso(rec, idx, panel);
let ahora = state.paso;
drop(i);
self.aplicar(smooth);
if ahora != antes {
if let Some(cb) = self.inner.borrow_mut().on_change.as_mut() {
cb(ahora);
}
}
}
/// Paso siguiente (clamp al final). `true` si se movió.
pub fn siguiente(&self) -> bool {
let i = self.inner.borrow();
let n = i.rec.n_pasos();
let p = i.state.paso;
drop(i);
if n == 0 || p + 1 >= n {
return false;
}
self.goto(p + 1, true);
true
}
/// Paso anterior (clamp en 0). `true` si se movió.
pub fn anterior(&self) -> bool {
let p = self.inner.borrow().state.paso;
if p == 0 {
return false;
}
self.goto(p - 1, true);
true
}
/// Vuela a la **vista general** (aleja para encuadrar todo el lienzo). Gesto
/// Prezi de "ver el mapa". No cambia el paso narrativo. Igual que `goto`, el
/// vuelo es una transición CSS: se fija la cámara objetivo del core (instante)
/// y el navegador la anima — el core nunca tickea `avanzar` en web.
pub fn vista_general(&self) {
let panel = self.panel();
{
let mut i = self.inner.borrow_mut();
let Some(bbox) = i.rec.bbox() else { return };
i.state.camara = Camara::fit(bbox, 0.0, panel);
}
self.aplicar(true);
}
pub fn paso_actual(&self) -> usize {
self.inner.borrow().state.paso
}
/// Exporta el recorrido a un documento **HTML autocontenido** (un solo
/// `.html` sin servidor ni `.wasm`): lee los `.recorrido-marco` vivos del
/// DOM (geometría de mundo + su HTML interno) y emite un documento con CSS y
/// un JS vanilla que replica la cámara del core (fit, zoom-a-cursor, pan,
/// pasos, vista general). Para publicar la presentación offline.
pub fn exportar_html(&self, titulo: &str) -> String {
let hijos = self.mundo.children();
let mut marcos = Vec::new();
for idx in 0..hijos.length() {
let Some(el) = hijos.item(idx).and_then(|e| e.dyn_into::<HtmlElement>().ok()) else {
continue;
};
marcos.push(MarcoExport {
x: attr_f64(&el, "data-x", 0.0),
y: attr_f64(&el, "data-y", 0.0),
w: attr_f64(&el, "data-w", 640.0),
h: attr_f64(&el, "data-h", 400.0),
rot: attr_f64(&el, "data-rot", 0.0),
html: el.inner_html(),
});
}
recorrido_a_html(titulo, &marcos)
}
/// `true` si el modo presentador (autoplay) está corriendo.
pub fn autoplay_activo(&self) -> bool {
self.inner.borrow().autoplay_id.is_some()
}
/// Arranca el modo presentador: cada `dur_paso + dwell_ms` avanza un paso
/// solo (vuelve al inicio al llegar al final). Cancela cualquier autoplay
/// previo. Espejo vivo del `setInterval` del HTML exportado.
pub fn iniciar_autoplay(&self, dwell_ms: i32) {
self.detener_autoplay();
let Some(window) = web_sys::window() else { return };
let this = self.clone();
let cb = Closure::<dyn FnMut()>::new(move || {
let (paso, n) = {
let i = this.inner.borrow();
(i.state.paso, i.rec.n_pasos())
};
if n == 0 {
return;
}
this.goto(if paso + 1 < n { paso + 1 } else { 0 }, true);
});
let periodo = (DURACION_PASO_S * 1000.0) as i32 + dwell_ms.max(0);
if let Ok(id) = window.set_interval_with_callback_and_timeout_and_arguments_0(
cb.as_ref().unchecked_ref(),
periodo,
) {
let mut i = self.inner.borrow_mut();
i.autoplay_id = Some(id);
i.autoplay_cb = Some(cb);
}
}
/// Detiene el modo presentador (no-op si no estaba activo).
pub fn detener_autoplay(&self) {
let mut i = self.inner.borrow_mut();
if let Some(id) = i.autoplay_id.take() {
if let Some(w) = web_sys::window() {
w.clear_interval_with_handle(id);
}
}
i.autoplay_cb = None;
}
/// Alterna el modo presentador con el dwell por defecto. Devuelve el nuevo
/// estado (`true` = corriendo).
pub fn toggle_autoplay(&self) -> bool {
if self.autoplay_activo() {
self.detener_autoplay();
false
} else {
self.iniciar_autoplay(DWELL_MS);
true
}
}
pub fn on_change<F: FnMut(usize) + 'static>(&self, cb: F) {
self.inner.borrow_mut().on_change = Some(Box::new(cb));
}
// ---- Cableado de eventos --------------------------------------------
fn install_pointer(&self) -> Result<(), JsValue> {
// down: arranca paneo (cancela cualquier vuelo, control manual).
{
let this = self.clone();
let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
this.inner.borrow_mut().arrastrando = Some((e.client_x() as f64, e.client_y() as f64));
let _ = this.viewport.set_pointer_capture(e.pointer_id());
});
self.viewport
.add_event_listener_with_callback("pointerdown", cb.as_ref().unchecked_ref())?;
cb.forget();
}
// move: si hay arrastre, panea por el delta de pantalla (sin transición).
{
let this = self.clone();
let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
let (x, y) = (e.client_x() as f64, e.client_y() as f64);
let mut i = this.inner.borrow_mut();
let Some((px, py)) = i.arrastrando else { return };
i.state.arrastrar_delta(x - px, y - py);
i.arrastrando = Some((x, y));
drop(i);
this.aplicar(false);
});
self.viewport
.add_event_listener_with_callback("pointermove", cb.as_ref().unchecked_ref())?;
cb.forget();
}
// up/cancel/leave: fin del paneo.
for ev in ["pointerup", "pointercancel", "pointerleave"] {
let this = self.clone();
let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
this.inner.borrow_mut().arrastrando = None;
let _ = this.viewport.release_pointer_capture(e.pointer_id());
});
self.viewport
.add_event_listener_with_callback(ev, cb.as_ref().unchecked_ref())?;
cb.forget();
}
Ok(())
}
fn install_wheel(&self) -> Result<(), JsValue> {
let this = self.clone();
let cb = Closure::<dyn FnMut(WheelEvent)>::new(move |e: WheelEvent| {
e.prevent_default();
let rect = this.viewport.get_bounding_client_rect();
let cursor = (e.client_x() as f64 - rect.left(), e.client_y() as f64 - rect.top());
// deltaY>0 ⇒ scroll abajo ⇒ alejar (convención CSS, igual que tullpu).
// Proporcional al delta (no sólo al signo) para que el pinch de
// trackpad sea suave; normalizado por `WHEEL_NORM` (un "notch" de
// rueda ≈ ±100px en modo pixel) y acotado para que un golpe fuerte no
// teletransporte el zoom.
let pasos = (e.delta_y() / WHEEL_NORM).clamp(-3.0, 3.0);
let mult = ZOOM_BASE.powf(-pasos);
let panel = this.panel();
this.inner.borrow_mut().state.wheel(mult, cursor, panel);
this.aplicar(false);
});
let opts = web_sys::AddEventListenerOptions::new();
opts.set_passive(false);
self.viewport.add_event_listener_with_callback_and_add_event_listener_options(
"wheel",
cb.as_ref().unchecked_ref(),
&opts,
)?;
cb.forget();
Ok(())
}
fn install_keys(&self) -> Result<(), JsValue> {
let Some(window) = web_sys::window() else { return Ok(()) };
let this = self.clone();
let cb = Closure::<dyn FnMut(KeyboardEvent)>::new(move |e: KeyboardEvent| {
let movido = match e.key().as_str() {
"ArrowRight" | "ArrowDown" | " " | "Spacebar" | "Enter" => this.siguiente(),
"ArrowLeft" | "ArrowUp" => this.anterior(),
"Home" | "Escape" => {
this.vista_general();
true
}
"p" | "P" => {
this.toggle_autoplay();
true
}
_ => return,
};
if movido {
e.prevent_default();
}
});
window.add_event_listener_with_callback("keydown", cb.as_ref().unchecked_ref())?;
cb.forget();
Ok(())
}
}
// ---- Lectura del DOM + geometría -----------------------------------------
/// Construye el `Recorrido` del core leyendo los `.recorrido-marco` hijos de
/// `mundo`, y posiciona cada uno en coordenadas de mundo. El orden DOM define
/// la ruta; los ids se asignan `1..=n`.
fn leer_marcos(mundo: &HtmlElement) -> Recorrido {
let mut rec = Recorrido::new();
let hijos = mundo.children();
for idx in 0..hijos.length() {
let Some(el) = hijos.item(idx).and_then(|e| e.dyn_into::<HtmlElement>().ok()) else { continue };
let x = attr_f64(&el, "data-x", 0.0);
let y = attr_f64(&el, "data-y", 0.0);
let w = attr_f64(&el, "data-w", 640.0);
let h = attr_f64(&el, "data-h", 400.0);
let rot = attr_f64(&el, "data-rot", 0.0);
// El marco vive en coordenadas de mundo dentro de `mundo`: left/top/size
// en px-mundo y su giro propio alrededor del centro (transform-origin
// por defecto = center), espejando el render Llimphi.
estilo(&el, "position", "absolute");
estilo(&el, "left", &format!("{x}px"));
estilo(&el, "top", &format!("{y}px"));
estilo(&el, "width", &format!("{w}px"));
estilo(&el, "height", &format!("{h}px"));
estilo(&el, "box-sizing", "border-box");
if rot != 0.0 {
estilo(&el, "transform", &format!("rotate({rot}rad)"));
}
let id = (idx + 1) as u64;
rec.agregar_marco(Marco::new(id, Rect::new(x, y, w, h), ContenidoMarco::Vacio).con_giro(rot));
rec.pasos.push(id);
}
rec
}
fn panel_de(viewport: &HtmlElement) -> Rect {
Rect::new(0.0, 0.0, viewport.client_width() as f64, viewport.client_height() as f64)
}
/// Construye el `transform` CSS de la cámara: réplica exacta de
/// `Camara::world_to_screen` (`centro_panel + zoom·R(-rot)·(mundo - centro)`)
/// como cadena aplicable a `mundo` con `transform-origin: 0 0`.
fn camara_css(cam: &Camara, panel: Rect) -> String {
let (pcx, pcy) = panel.centro();
format!(
"translate({pcx}px,{pcy}px) scale({z}) rotate({r}rad) translate({cx}px,{cy}px)",
z = cam.zoom,
r = -cam.rot_rad,
cx = -cam.centro.0,
cy = -cam.centro.1,
)
}
fn aplicar_camara(mundo: &HtmlElement, cam: &Camara, panel: Rect, transicion: bool) {
let trans = if transicion {
format!("transform {}ms {EASE_VUELO}", (DURACION_PASO_S * 1000.0) as i64)
} else {
"none".to_string()
};
estilo(mundo, "transition", &trans);
estilo(mundo, "transform", &camara_css(cam, panel));
}
fn estilo(el: &HtmlElement, prop: &str, val: &str) {
let _ = el.style().set_property(prop, val);
}
fn attr_f64(el: &HtmlElement, name: &str, def: f64) -> f64 {
el.get_attribute(name).and_then(|s| s.trim().parse::<f64>().ok()).unwrap_or(def)
}
// ---- Export a HTML autocontenido -----------------------------------------
/// Un marco listo para exportar: su rect de mundo + giro + su HTML interno.
#[derive(Clone, Debug, PartialEq)]
pub struct MarcoExport {
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
pub rot: f64,
/// HTML interno del marco (texto, `<img>`, lo que sea).
pub html: String,
}
/// Genera un documento HTML **autocontenido** desde los marcos (función pura,
/// sin DOM — testeable en host). Embebe el CSS y un JS vanilla que replica la
/// cámara de `pluma-deck-core` (`fit`/`zoom_a_cursor`/`pan`/pasos/`vista_general`)
/// para que el `.html` resultante presente offline sin servidor ni `.wasm`.
pub fn recorrido_a_html(titulo: &str, marcos: &[MarcoExport]) -> String {
let t = escapar_html(titulo);
let mut cuerpo = String::new();
for m in marcos {
let rot = if m.rot != 0.0 {
format!(";transform:rotate({}rad)", m.rot)
} else {
String::new()
};
cuerpo.push_str(&format!(
"<div class=\"recorrido-marco\" data-x=\"{x}\" data-y=\"{y}\" data-w=\"{w}\" data-h=\"{h}\" data-rot=\"{r}\" \
style=\"left:{x}px;top:{y}px;width:{w}px;height:{h}px{rot}\">{html}</div>",
x = m.x, y = m.y, w = m.w, h = m.h, r = m.rot, rot = rot, html = m.html,
));
}
format!(
"<!DOCTYPE html>\n<html lang=\"es\"><head><meta charset=\"utf-8\">\
<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
<title>{t}</title><style>{css}</style></head><body>\
<div class=\"recorrido-viewport\"><div class=\"recorrido-mundo\">{cuerpo}</div>\
<div class=\"recorrido-hud\"></div></div><script>{js}</script></body></html>",
t = t, css = EXPORT_CSS, cuerpo = cuerpo, js = EXPORT_JS,
)
}
/// Escape mínimo para insertar texto en HTML (sólo para el `<title>`).
fn escapar_html(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
}
const EXPORT_CSS: &str = "\
html,body{margin:0;height:100%;background:#12141c;font-family:system-ui,sans-serif}\
.recorrido-viewport{position:relative;width:100vw;height:100vh;overflow:hidden;touch-action:none;user-select:none}\
.recorrido-mundo{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform}\
.recorrido-marco{position:absolute;box-sizing:border-box;background:#262a38;border:1px solid #505668;\
border-radius:6px;color:#e1e6f0;padding:20px;overflow:hidden}\
.recorrido-marco h1,.recorrido-marco h2{margin:0 0 .4em;color:#fff}\
.recorrido-marco img{max-width:100%;max-height:100%;object-fit:contain}\
.recorrido-hud{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);\
background:rgba(12,14,20,.78);color:#cdd4e2;padding:6px 12px;border-radius:999px;font-size:14px;pointer-events:none}";
// JS vanilla — espejo de RecorridoWeb/Camara. Se inserta como argumento de
// format!, así sus llaves no necesitan escaparse.
const EXPORT_JS: &str = r#"(function(){
var vp=document.querySelector('.recorrido-viewport');
var mundo=document.querySelector('.recorrido-mundo');
var hud=document.querySelector('.recorrido-hud');
var ZB=1.1,FIT=0.9,ZMIN=0.02,ZMAX=64,DUR=800,WN=100,DWELL=2500;
var autoTimer=null;
var EASE='transform '+DUR+'ms cubic-bezier(0.22,0.61,0.36,1)';
var marcos=[].slice.call(mundo.children).map(function(el){return{
x:+el.dataset.x||0,y:+el.dataset.y||0,w:+el.dataset.w||640,h:+el.dataset.h||400,rot:+el.dataset.rot||0};});
var cam={cx:0,cy:0,zoom:1,rot:0},paso=0;
function P(){return{w:vp.clientWidth,h:vp.clientHeight};}
function clamp(z){return Math.max(ZMIN,Math.min(ZMAX,z));}
function fit(m){var p=P();var zw=m.w>0?p.w/m.w:1,zh=m.h>0?p.h/m.h:1;
return{cx:m.x+m.w/2,cy:m.y+m.h/2,zoom:clamp(Math.min(zw,zh)*FIT),rot:m.rot};}
function fitAll(){if(!marcos.length)return cam;var p=P();
var minx=1/0,miny=1/0,maxx=-1/0,maxy=-1/0;
marcos.forEach(function(m){var hw=m.w/2,hh=m.h/2,c=Math.abs(Math.cos(m.rot)),s=Math.abs(Math.sin(m.rot));
var ex=hw*c+hh*s,ey=hw*s+hh*c,cx=m.x+hw,cy=m.y+hh;
minx=Math.min(minx,cx-ex);miny=Math.min(miny,cy-ey);maxx=Math.max(maxx,cx+ex);maxy=Math.max(maxy,cy+ey);});
var bw=maxx-minx,bh=maxy-miny;var zw=bw>0?p.w/bw:1,zh=bh>0?p.h/bh:1;
return{cx:(minx+maxx)/2,cy:(miny+maxy)/2,zoom:clamp(Math.min(zw,zh)*FIT),rot:0};}
function apply(smooth){var p=P();mundo.style.transition=smooth?EASE:'none';
mundo.style.transform='translate('+(p.w/2)+'px,'+(p.h/2)+'px) scale('+cam.zoom+') rotate('+(-cam.rot)+'rad) translate('+(-cam.cx)+'px,'+(-cam.cy)+'px)';
if(hud)hud.textContent=(paso+1)+' / '+marcos.length;}
function s2w(px,py){var p=P();var sx=(px-p.w/2)/cam.zoom,sy=(py-p.h/2)/cam.zoom;
var c=Math.cos(cam.rot),s=Math.sin(cam.rot);return[cam.cx+sx*c-sy*s,cam.cy+sx*s+sy*c];}
function goto(i,smooth){if(i<0||i>=marcos.length)return;paso=i;cam=fit(marcos[i]);apply(smooth);}
function setAuto(on){if(autoTimer){clearInterval(autoTimer);autoTimer=null;}
if(on)autoTimer=setInterval(function(){goto(paso+1<marcos.length?paso+1:0,true);},DUR+DWELL);}
marcos.forEach(function(m,i){var el=mundo.children[i];el.style.position='absolute';
el.style.left=m.x+'px';el.style.top=m.y+'px';el.style.width=m.w+'px';el.style.height=m.h+'px';
el.style.boxSizing='border-box';if(m.rot)el.style.transform='rotate('+m.rot+'rad)';});
vp.addEventListener('wheel',function(e){e.preventDefault();var r=vp.getBoundingClientRect();
var cx=e.clientX-r.left,cy=e.clientY-r.top;var pasos=Math.max(-3,Math.min(3,e.deltaY/WN));
var mult=Math.pow(ZB,-pasos);var anc=s2w(cx,cy);cam.zoom=clamp(cam.zoom*mult);
var p=P();var sx=(cx-p.w/2)/cam.zoom,sy=(cy-p.h/2)/cam.zoom;var c=Math.cos(cam.rot),s=Math.sin(cam.rot);
cam.cx=anc[0]-(sx*c-sy*s);cam.cy=anc[1]-(sx*s+sy*c);apply(false);},{passive:false});
var drag=null;
vp.addEventListener('pointerdown',function(e){drag=[e.clientX,e.clientY];vp.setPointerCapture(e.pointerId);});
vp.addEventListener('pointermove',function(e){if(!drag)return;var dx=e.clientX-drag[0],dy=e.clientY-drag[1];
drag=[e.clientX,e.clientY];var c=Math.cos(cam.rot),s=Math.sin(cam.rot);
cam.cx-=(dx*c-dy*s)/cam.zoom;cam.cy-=(dx*s+dy*c)/cam.zoom;apply(false);});
['pointerup','pointercancel','pointerleave'].forEach(function(ev){vp.addEventListener(ev,function(){drag=null;});});
window.addEventListener('keydown',function(e){var k=e.key;
if(k==='ArrowRight'||k==='ArrowDown'||k===' '||k==='Spacebar'||k==='Enter'){if(paso+1<marcos.length){goto(paso+1,true);e.preventDefault();}}
else if(k==='ArrowLeft'||k==='ArrowUp'){if(paso>0){goto(paso-1,true);e.preventDefault();}}
else if(k==='Home'||k==='Escape'){cam=fitAll();apply(true);e.preventDefault();}
else if(k==='p'||k==='P'){setAuto(!autoTimer);e.preventDefault();}});
window.addEventListener('resize',function(){apply(false);});
goto(0,false);
})();"#;
#[cfg(test)]
mod tests {
use super::*;
const PANEL: Rect = Rect { x: 0.0, y: 0.0, w: 800.0, h: 600.0 };
#[test]
fn camara_identidad_centra_el_origen() {
// Cámara por defecto (centro 0,0 zoom 1 rot 0): el transform lleva el
// origen de mundo al centro del panel.
let css = camara_css(&Camara::default(), PANEL);
assert_eq!(css, "translate(400px,300px) scale(1) rotate(-0rad) translate(-0px,-0px)");
}
#[test]
fn camara_css_refleja_world_to_screen() {
// Para varios puntos, evaluar el transform CSS a mano debe coincidir con
// Camara::world_to_screen — el CSS es ese mismo afín.
let cam = Camara::new((120.0, -40.0), 1.7, 0.3);
for &(wx, wy) in &[(0.0, 0.0), (200.0, 50.0), (-80.0, 300.0)] {
let esperado = cam.world_to_screen((wx, wy), PANEL);
// Aplicar el transform manualmente: translate(pc)·scale·rotate(-rot)·translate(-c).
let (pcx, pcy) = PANEL.centro();
let (dx, dy) = (wx - cam.centro.0, wy - cam.centro.1);
let (s, c) = (-cam.rot_rad).sin_cos();
let (rx, ry) = (dx * c - dy * s, dx * s + dy * c);
let got = (pcx + rx * cam.zoom, pcy + ry * cam.zoom);
assert!((got.0 - esperado.0).abs() < 1e-9, "x: {got:?} vs {esperado:?}");
assert!((got.1 - esperado.1).abs() < 1e-9, "y: {got:?} vs {esperado:?}");
}
// Y la cadena contiene los componentes esperados.
let css = camara_css(&cam, PANEL);
assert!(css.contains("scale(1.7)"), "{css}");
assert!(css.contains("rotate(-0.3rad)"), "{css}");
}
#[test]
fn export_html_es_documento_autocontenido() {
let marcos = vec![
MarcoExport { x: 0.0, y: 0.0, w: 640.0, h: 400.0, rot: 0.0, html: "<h1>Uno</h1>".into() },
MarcoExport { x: 900.0, y: 0.0, w: 640.0, h: 400.0, rot: 0.12, html: "<p>Dos</p>".into() },
];
let html = recorrido_a_html("Mi <demo>", &marcos);
// Documento completo con CSS + JS embebidos (sin recursos externos).
assert!(html.starts_with("<!DOCTYPE html>"));
assert!(html.contains("<style>") && html.contains("<script>"));
assert!(!html.contains("http://") && !html.contains("https://") && !html.contains(".wasm"));
// El título se escapa.
assert!(html.contains("<title>Mi &lt;demo&gt;</title>"), "{}", &html[..200]);
// Cada marco con su geometría de mundo y su HTML interno.
assert!(html.contains("data-x=\"900\"") && html.contains("data-rot=\"0.12\""));
assert!(html.contains("<h1>Uno</h1>") && html.contains("<p>Dos</p>"));
// El JS replica la cámara del core (funciones clave presentes).
assert!(html.contains("function fit(") && html.contains("function fitAll("));
assert!(html.contains("function s2w(") && html.contains("goto(0,false)"));
// Y el modo presentador (autoplay con setInterval, tecla 'p').
assert!(html.contains("function setAuto(") && html.contains("setInterval("));
}
#[test]
fn export_html_sin_marcos_sigue_siendo_valido() {
let html = recorrido_a_html("vacío", &[]);
assert!(html.starts_with("<!DOCTYPE html>") && html.ends_with("</html>"));
assert!(html.contains("recorrido-mundo"));
}
}
@@ -0,0 +1,14 @@
[package]
name = "pluma-editor-cuerpo"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "pluma — sincronía bidireccional cuerpo ↔ texto plano. Un solo control (el texto), átomos por debajo: edit donde se sienta natural, los cambios bajan a `NarrativeAtom`s con el mínimo diff posible."
[dependencies]
uuid = { workspace = true, features = ["serde"] }
serde = { workspace = true }
pluma-core = { path = "../pluma-core" }
pluma-cuerpo = { path = "../pluma-cuerpo" }
@@ -0,0 +1,20 @@
# pluma-editor-cuerpo
> Editor texto ↔ átomos con diff (greedy) para [pluma](../README.md).
`EditorCuerpo { texto, atom_ids }`. `from_cuerpo(c, atoms)` concatena con `SEPARADOR = "\n\n"`. `parrafos()` recupera el split actual. `diff(atoms_originales) -> Vec<CambioAtom>` con greedy por contenido: párrafos que coinciden se saltan, los que difieren emiten `Mutar` reusando el `Uuid` (hebras vivas), sobrantes emiten `Crear` o `Eliminar`. `aplicar_cambios(cambios, nuevos_ids)` extiende/remueve el `atom_ids` tras persistir.
## API
```rust
use pluma_editor_cuerpo::EditorCuerpo;
let mut e = EditorCuerpo::from_cuerpo(&c, &atoms);
e.set_texto(nuevo);
let cambios = e.diff(&atoms_originales);
```
## Deps
- [`pluma-core`](../pluma-core/README.md), [`pluma-cuerpo`](../pluma-cuerpo/README.md)
- `serde`, `uuid`
@@ -0,0 +1,20 @@
# pluma-editor-cuerpo
> Text ↔ atoms editor with diff (greedy) for [pluma](../README.md).
`EditorCuerpo { texto, atom_ids }`. `from_cuerpo(c, atoms)` concatenates with `SEPARADOR = "\n\n"`. `parrafos()` retrieves the current split. `diff(atoms_originales) -> Vec<CambioAtom>` with greedy content-matching: matching paragraphs are skipped, differing ones emit `Mutar` reusing the `Uuid` (live threads), excess emits `Crear` or `Eliminar`. `aplicar_cambios(cambios, nuevos_ids)` extends/removes `atom_ids` after persisting.
## API
```rust
use pluma_editor_cuerpo::EditorCuerpo;
let mut e = EditorCuerpo::from_cuerpo(&c, &atoms);
e.set_texto(nuevo);
let cambios = e.diff(&atoms_originales);
```
## Deps
- [`pluma-core`](../pluma-core/README.md), [`pluma-cuerpo`](../pluma-cuerpo/README.md)
- `serde`, `uuid`
@@ -0,0 +1,352 @@
//! `pluma-editor-cuerpo` — sincronía entre un cuerpo (lista ordenada de
//! `NarrativeAtom`s) y un único buffer de texto plano editable.
//!
//! La idea del editor multilienzo es **un solo control para la página
//! entera**: el usuario ve todos los párrafos concatenados en un
//! `text-editor` IDE estándar (cursor libre, selección entre párrafos,
//! undo global) — pero por debajo cada párrafo sigue siendo un
//! `NarrativeAtom` con `Uuid` propio. Hebras, alineamientos,
//! transformaciones LLM, persistencia: todo lo que pluma ya hace, sigue
//! funcionando sin saber que la UI muestra un buffer único.
//!
//! Este crate cubre la traducción en los dos sentidos:
//!
//! - **Cuerpo → texto**: concatena los atoms con `\n\n` entre cada uno.
//! Doble salto es el separador natural de párrafo en markdown y un
//! usuario no escribiría doble enter dentro de un mismo párrafo.
//!
//! - **Texto → cuerpo**: dado el texto editado + el orden previo de
//! atoms + un índice de los contenidos viejos, computa el mínimo
//! diff (mutar / crear / eliminar atoms) para que el cuerpo refleje
//! el texto. Greedy con anclas por contenido: si un párrafo del texto
//! coincide byte-a-byte con un atom viejo, lo reusamos (UUID
//! preservado — las hebras siguen vigentes).
//!
//! El crate NO renderiza nada. La UI que reuse el `text-editor` IDE
//! (con sus EditorState/cursor/undo/highlight) vive arriba y consume
//! estas estructuras para sincronizar el `Buffer` de ropey con el grafo.
#![forbid(unsafe_code)]
use std::collections::HashMap;
use uuid::Uuid;
use pluma_core::NarrativeAtom;
use pluma_cuerpo::Cuerpo;
/// Separador canónico entre párrafos en el buffer plano.
/// Doble salto: lo entiende cualquier markdown reader y nadie lo usa
/// dentro de un párrafo normal.
pub const SEPARADOR: &str = "\n\n";
/// Diferencia entre dos estados del cuerpo. `EditorCuerpo::diff`
/// produce esta lista en orden de aplicación; el caller la consume
/// secuencialmente.
#[derive(Debug, Clone, PartialEq)]
pub enum CambioAtom {
/// El atom `id` existe en el cuerpo viejo Y en el nuevo, pero su
/// contenido cambió. Aplicar: `graph.get_mut(id).set_content(...)`
/// + `propagate_mutation(id)`.
Mutar { id: Uuid, texto_nuevo: String },
/// Un párrafo nuevo apareció. Crear un `NarrativeAtom` con el texto
/// y agregarlo al cuerpo en la posición indicada (0-based).
Crear { texto: String, posicion: usize },
/// El atom `id` ya no aparece en el texto. Removerlo del cuerpo
/// (el atom mismo puede quedar en el grafo — el usuario decide).
Eliminar { id: Uuid },
}
/// Vista plana de un cuerpo como texto editable + mapeo a los Uuids
/// originales.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EditorCuerpo {
/// Texto plano del cuerpo entero: párrafos concatenados con
/// `SEPARADOR` entre cada uno. El text-editor IDE edita esto.
pub texto: String,
/// Uuids de los atoms del cuerpo en el orden en que aparecen en
/// `texto`. `atom_ids.len()` siempre coincide con la cantidad de
/// párrafos no-vacíos en `texto` AL CONSTRUIRSE (después de un
/// edit del usuario puede divergir — la función `diff` resuelve).
pub atom_ids: Vec<Uuid>,
}
impl EditorCuerpo {
/// Construye un editor a partir de un cuerpo + el índice de atoms
/// del grafo. Los atoms cuyo Uuid no esté en el índice se omiten
/// (cuerpo huérfano — un caso de datos corruptos).
pub fn from_cuerpo(cuerpo: &Cuerpo, atoms: &HashMap<Uuid, &NarrativeAtom>) -> Self {
let mut chunks: Vec<&str> = Vec::with_capacity(cuerpo.orden.len());
let mut ids: Vec<Uuid> = Vec::with_capacity(cuerpo.orden.len());
for id in &cuerpo.orden {
if let Some(atom) = atoms.get(id) {
chunks.push(atom.content.as_str());
ids.push(*id);
}
}
let texto = chunks.join(SEPARADOR);
Self {
texto,
atom_ids: ids,
}
}
/// Lista los párrafos actuales del texto (split por `SEPARADOR`,
/// trim de espacios alrededor, vacíos descartados). El resultado
/// puede tener tamaño distinto a `atom_ids` después de una edición
/// que insertó/eliminó separadores.
pub fn parrafos(&self) -> Vec<&str> {
self.texto
.split(SEPARADOR)
.map(str::trim)
.filter(|s| !s.is_empty())
.collect()
}
/// Reemplaza el texto entero — equivalente a aplicar la salida del
/// `text-editor` IDE de un golpe. No toca `atom_ids`; el caller
/// debe llamar `diff` después para sincronizar el cuerpo.
pub fn set_texto(&mut self, nuevo: impl Into<String>) {
self.texto = nuevo.into();
}
/// Calcula el diff mínimo para que el cuerpo (referenciado por
/// `atom_ids`) refleje el `texto` actual. Estrategia greedy con
/// anclas por contenido:
///
/// 1. Recorre los párrafos del texto y los atoms originales en
/// paralelo.
/// 2. Si el párrafo i coincide byte-a-byte con el atom i del
/// `atoms_originales`, no hay cambio para esa posición.
/// 3. Si difiere, emite `Mutar` reusando el Uuid del atom i (las
/// hebras siguen apuntando al mismo Uuid).
/// 4. Si el texto tiene MÁS párrafos que los atoms originales, los
/// sobrantes son `Crear` al final.
/// 5. Si el texto tiene MENOS, los atoms sobrantes son `Eliminar`.
///
/// `atoms_originales` debe contener los Uuids de `self.atom_ids`
/// (los del cuerpo cuando se construyó el editor). El caller suele
/// recolectarlos del grafo justo antes de llamar a `diff`.
pub fn diff(
&self,
atoms_originales: &HashMap<Uuid, &NarrativeAtom>,
) -> Vec<CambioAtom> {
let parrafos_nuevos = self.parrafos();
let mut cambios = Vec::new();
let n = parrafos_nuevos.len();
let m = self.atom_ids.len();
let comun = n.min(m);
for i in 0..comun {
let id_viejo = self.atom_ids[i];
let texto_nuevo = parrafos_nuevos[i].to_string();
let texto_viejo = atoms_originales
.get(&id_viejo)
.map(|a| a.content.as_str())
.unwrap_or("");
if texto_viejo != texto_nuevo {
cambios.push(CambioAtom::Mutar {
id: id_viejo,
texto_nuevo,
});
}
}
// Sobrantes del texto: párrafos nuevos.
for (offset, p) in parrafos_nuevos.iter().enumerate().skip(comun) {
cambios.push(CambioAtom::Crear {
texto: p.to_string(),
posicion: offset,
});
}
// Sobrantes del cuerpo: atoms a eliminar.
for &id in self.atom_ids.iter().skip(comun) {
cambios.push(CambioAtom::Eliminar { id });
}
cambios
}
/// Aplica una lista de cambios al `atom_ids` del editor — útil tras
/// que el caller persistió los cambios en el grafo. Devuelve los
/// Uuids de los atoms recién creados, en orden, para que el caller
/// los asigne a los `Crear` reales (el editor no sabe generar
/// `Uuid`s — eso lo hace `NarrativeAtom::new` arriba).
///
/// `nuevos_ids` debe tener al menos tantos elementos como cambios
/// `Crear` haya. Si tiene menos, los `Crear` sobrantes se descartan;
/// si más, se ignoran los extras.
pub fn aplicar_cambios(&mut self, cambios: &[CambioAtom], nuevos_ids: &[Uuid]) {
let mut idx_nuevo = 0;
let mut a_eliminar: Vec<Uuid> = Vec::new();
for c in cambios {
match c {
CambioAtom::Mutar { .. } => {} // el atom_id viejo se reusa
CambioAtom::Crear { .. } => {
if let Some(&id) = nuevos_ids.get(idx_nuevo) {
self.atom_ids.push(id);
idx_nuevo += 1;
}
}
CambioAtom::Eliminar { id } => {
a_eliminar.push(*id);
}
}
}
self.atom_ids.retain(|id| !a_eliminar.contains(id));
}
}
#[cfg(test)]
mod pruebas {
use super::*;
use pluma_cuerpo::Intencion;
fn cuerpo_con_atoms(textos: &[&str]) -> (Cuerpo, Vec<NarrativeAtom>) {
let mut c = Cuerpo::nuevo("es", "es", Intencion::Original, 0);
let atoms: Vec<NarrativeAtom> =
textos.iter().map(|t| NarrativeAtom::new(*t, "es")).collect();
for a in &atoms {
c.agregar(a.id, 0);
}
(c, atoms)
}
fn indice(atoms: &[NarrativeAtom]) -> HashMap<Uuid, &NarrativeAtom> {
atoms.iter().map(|a| (a.id, a)).collect()
}
#[test]
fn from_cuerpo_concatena_con_separador_y_mantiene_orden() {
let (c, atoms) = cuerpo_con_atoms(&["Uno.", "Dos.", "Tres."]);
let idx = indice(&atoms);
let ed = EditorCuerpo::from_cuerpo(&c, &idx);
assert_eq!(ed.texto, "Uno.\n\nDos.\n\nTres.");
assert_eq!(ed.atom_ids, vec![atoms[0].id, atoms[1].id, atoms[2].id]);
}
#[test]
fn parrafos_split_y_trim_de_vacios() {
let (c, atoms) = cuerpo_con_atoms(&["Uno", "Dos"]);
let idx = indice(&atoms);
let mut ed = EditorCuerpo::from_cuerpo(&c, &idx);
// El usuario agrega espacios y un párrafo vacío entre medio:
ed.set_texto(" Uno \n\n\n\n Dos ");
let p = ed.parrafos();
assert_eq!(p, vec!["Uno", "Dos"]);
}
#[test]
fn sin_cambios_diff_vacio() {
let (c, atoms) = cuerpo_con_atoms(&["uno", "dos"]);
let idx = indice(&atoms);
let ed = EditorCuerpo::from_cuerpo(&c, &idx);
assert!(ed.diff(&idx).is_empty());
}
#[test]
fn mutar_un_parrafo_emite_mutar_con_uuid_preservado() {
let (c, atoms) = cuerpo_con_atoms(&["uno", "dos", "tres"]);
let idx = indice(&atoms);
let mut ed = EditorCuerpo::from_cuerpo(&c, &idx);
// El usuario edita el segundo párrafo.
ed.set_texto("uno\n\nDOS!\n\ntres");
let d = ed.diff(&idx);
assert_eq!(d.len(), 1);
assert_eq!(d[0], CambioAtom::Mutar {
id: atoms[1].id,
texto_nuevo: "DOS!".to_string(),
});
}
#[test]
fn agregar_parrafos_emite_crear() {
let (c, atoms) = cuerpo_con_atoms(&["uno", "dos"]);
let idx = indice(&atoms);
let mut ed = EditorCuerpo::from_cuerpo(&c, &idx);
ed.set_texto("uno\n\ndos\n\ntres\n\ncuatro");
let d = ed.diff(&idx);
assert_eq!(d.len(), 2);
assert_eq!(d[0], CambioAtom::Crear {
texto: "tres".to_string(),
posicion: 2,
});
assert_eq!(d[1], CambioAtom::Crear {
texto: "cuatro".to_string(),
posicion: 3,
});
}
#[test]
fn eliminar_parrafos_emite_eliminar_para_los_ids_sobrantes() {
let (c, atoms) = cuerpo_con_atoms(&["uno", "dos", "tres", "cuatro"]);
let idx = indice(&atoms);
let mut ed = EditorCuerpo::from_cuerpo(&c, &idx);
// El usuario borra los dos últimos.
ed.set_texto("uno\n\ndos");
let d = ed.diff(&idx);
assert_eq!(d.len(), 2);
assert_eq!(d[0], CambioAtom::Eliminar { id: atoms[2].id });
assert_eq!(d[1], CambioAtom::Eliminar { id: atoms[3].id });
}
#[test]
fn cambios_mixtos_solo_emiten_lo_que_cambia() {
let (c, atoms) = cuerpo_con_atoms(&["uno", "dos", "tres"]);
let idx = indice(&atoms);
let mut ed = EditorCuerpo::from_cuerpo(&c, &idx);
// Cambia el primero ("uno" → "UNO!"), conserva el segundo,
// cambia el tercero ("tres" → "nuevo"). 3 párrafos, 3 atoms.
ed.set_texto("UNO!\n\ndos\n\nnuevo");
let d = ed.diff(&idx);
// Solo dos Mutar — el segundo párrafo coincide byte-a-byte y
// se omite sin emitir cambio.
assert_eq!(d.len(), 2);
match &d[0] {
CambioAtom::Mutar { id, texto_nuevo } => {
assert_eq!(*id, atoms[0].id);
assert_eq!(texto_nuevo, "UNO!");
}
otro => panic!("esperaba Mutar(0), fue {otro:?}"),
}
match &d[1] {
CambioAtom::Mutar { id, texto_nuevo } => {
assert_eq!(*id, atoms[2].id);
assert_eq!(texto_nuevo, "nuevo");
}
otro => panic!("esperaba Mutar(2), fue {otro:?}"),
}
}
#[test]
fn aplicar_cambios_extiende_atom_ids_para_crear_y_remueve_eliminar() {
let (c, atoms) = cuerpo_con_atoms(&["uno", "dos"]);
let idx = indice(&atoms);
let mut ed = EditorCuerpo::from_cuerpo(&c, &idx);
// Texto nuevo: dos crear + un eliminar.
ed.set_texto("uno\n\ntres\n\ncuatro\n\ncinco");
let cambios = ed.diff(&idx);
// 1 Mutar(dos → tres) + 2 Crear(cuatro, cinco) + 0 Eliminar.
// ATOM ORIGINAL: ["uno", "dos"] (2 items)
// PARRAFOS NUEVOS: ["uno", "tres", "cuatro", "cinco"] (4 items)
// comun = 2 → Mutar(atoms[1].id, "tres") (uno está igual).
// Crear "cuatro" pos 2, Crear "cinco" pos 3.
let nuevos_ids: Vec<Uuid> = vec![Uuid::new_v4(), Uuid::new_v4()];
ed.aplicar_cambios(&cambios, &nuevos_ids);
assert_eq!(ed.atom_ids.len(), 4);
assert_eq!(ed.atom_ids[0], atoms[0].id);
assert_eq!(ed.atom_ids[1], atoms[1].id); // reusado en Mutar
assert_eq!(ed.atom_ids[2], nuevos_ids[0]);
assert_eq!(ed.atom_ids[3], nuevos_ids[1]);
}
#[test]
fn aplicar_cambios_remueve_atom_ids_eliminados() {
let (c, atoms) = cuerpo_con_atoms(&["uno", "dos", "tres"]);
let idx = indice(&atoms);
let mut ed = EditorCuerpo::from_cuerpo(&c, &idx);
ed.set_texto("uno");
let cambios = ed.diff(&idx);
// 0 Mutar + 0 Crear + 2 Eliminar (dos, tres).
ed.aplicar_cambios(&cambios, &[]);
assert_eq!(ed.atom_ids, vec![atoms[0].id]);
}
}
@@ -0,0 +1,37 @@
[package]
name = "pluma-editor-llimphi"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "pluma — backend Llimphi del editor DAG: pinta un RenderPlan como bloques de átomo, conectores de dependencia y osciloscopio de coherencia."
[dependencies]
pluma-render-plan = { path = "../pluma-render-plan" }
pluma-core = { path = "../pluma-core" }
pluma-cuerpo = { path = "../pluma-cuerpo" }
pluma-align = { path = "../pluma-align" }
pluma-editor-cuerpo = { path = "../pluma-editor-cuerpo" }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-text-editor = { workspace = true }
rimay-localize = { workspace = true }
uuid = { workspace = true, features = ["serde"] }
[dev-dependencies]
pluma-graph = { path = "../pluma-graph" }
pluma-graph-transform = { path = "../pluma-graph-transform" }
pluma-transform = { path = "../pluma-transform" }
pluma-transform-tabla = { path = "../pluma-transform-tabla" }
pluma-transform-llm = { path = "../pluma-transform-llm" }
pluma-align-embeddings = { path = "../pluma-align-embeddings" }
pluma-llm = { path = "../pluma-llm" }
pluma-llm-core = { path = "../pluma-llm-core" }
pluma-llm-mock = { path = "../pluma-llm-mock" }
pluma-store = { path = "../pluma-store" }
llimphi-widget-button = { workspace = true }
rimay-verbo-core = { workspace = true }
rimay-verbo-mock = { workspace = true }
rimay-verbo-daemon = { workspace = true }
tokio = { workspace = true }
@@ -0,0 +1,13 @@
# pluma-editor-llimphi
> Editor visual Llimphi de [pluma](../README.md).
UI: panel izquierdo con lista de documentos (de [`pluma-store`](../pluma-store/README.md)), centro con [`text-editor`](../../../02_ruway/llimphi/widgets/text-editor/README.md) sobre el cuerpo concatenado, panel derecho con LLM (cycler de backends via [`pluma-llm`](../pluma-llm/README.md)), historial, diff. Cada save dispara [`pluma-editor-cuerpo::diff`](../pluma-editor-cuerpo/README.md).
Selector de modelo en runtime: botón cíclico Mock → Gemini → Anthropic → DeepSeek → Cohere → Ollama → Mock. Click → `build_client(...)` reconstruye `Arc<dyn ChatClient>` en vivo; si el backend no está configurado, conserva el anterior con mensaje de error.
## Deps
- Todos los crates `pluma-*`
- [`llimphi-ui`](../../../02_ruway/llimphi/) + widgets `text-editor`, `tree`, `tabs`, `splitter`
- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/)
@@ -0,0 +1,13 @@
# pluma-editor-llimphi
> Visual Llimphi editor for [pluma](../README.md).
UI: left panel with document list (from [`pluma-store`](../pluma-store/README.md)), center with [`text-editor`](../../../02_ruway/llimphi/widgets/text-editor/README.md) over the concatenated body, right panel with LLM (backend cycler via [`pluma-llm`](../pluma-llm/README.md)), history, diff. Each save triggers [`pluma-editor-cuerpo::diff`](../pluma-editor-cuerpo/README.md).
Runtime model selector: cyclic button Mock → Gemini → Anthropic → DeepSeek → Cohere → Ollama → Mock. Click → `build_client(...)` rebuilds the `Arc<dyn ChatClient>` live; if a backend isn't configured, keeps the previous one with an error message.
## Deps
- All `pluma-*` crates
- [`llimphi-ui`](../../../02_ruway/llimphi/) + widgets `text-editor`, `tree`, `tabs`, `splitter`
- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/)
@@ -0,0 +1,439 @@
//! Demo del **text-editor IDE de Llimphi sobre `EditorCuerpo`**.
//!
//! Pintamos un cuerpo `es` con 5 párrafos sintéticos. El widget
//! `text-editor` lo ve como UN buffer plano — cada `\n\n` separa un
//! átomo. Editás libremente como en cualquier IDE:
//!
//! - Tipeo / borrado normal, multi-cursor con `Ctrl+Alt+↑/↓`.
//! - `Ctrl+Z` undo / `Ctrl+Shift+Z` redo (del widget).
//! - Click + drag = selección; `Ctrl+C/X/V` con un clipboard en memoria.
//! - **`Ctrl+S`** = "guardar": calcula el diff contra los atoms
//! originales, lo aplica al `HashMap<Uuid, NarrativeAtom>` del
//! modelo (mutando contenido, creando atoms nuevos para los
//! párrafos extra, eliminando los faltantes), sincroniza el
//! `Cuerpo.orden` y resetea el editor sobre el cuerpo nuevo
//! preservando la posición del caret y el scroll.
//! - **`Ctrl+]`** salta al siguiente átomo (`posicion_de_atom` +
//! `set_caret`). Demuestra el lookup átomo → línea.
//!
//! No hay `pluma-graph` ni `pluma-store` acá — el modelo guarda los
//! atoms y el `Cuerpo` directamente. Es el caso pelado del IDE: el
//! caller real reemplaza el `HashMap` por su `NarrativeGraph` y
//! persiste con `pluma-store`.
//!
//! ```bash
//! cargo run -p pluma-editor-llimphi --example cuerpo_ide_demo --release
//! ```
use std::collections::HashMap;
use llimphi_ui::llimphi_layout::taffy::prelude::{
auto, length, percent, FlexDirection, Rect, Size, Style,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, View};
use llimphi_widget_text_editor::{
EditorMetrics, EditorPalette, Language, MemClipboard, PointerEvent,
};
use pluma_core::NarrativeAtom;
use pluma_cuerpo::{Cuerpo, Intencion};
use pluma_editor_cuerpo::CambioAtom;
use pluma_editor_llimphi::cuerpo_ide::{cuerpo_ide_view, CuerpoIde};
use uuid::Uuid;
/// Métricas del editor — fijas durante toda la sesión. Definidas como
/// const para no recalcularlas en cada `update` / `view`.
const METRICS: EditorMetrics = EditorMetrics::for_font_size(13.0);
/// Cap muy amplio para `visible_lines`. El widget lo trunca a 200
/// internamente — pasamos 200 y dejamos que decida.
const VISIBLE_LINES: usize = 200;
#[derive(Clone, Debug)]
enum Msg {
EditorKey(KeyEvent),
EditorPointer(PointerEvent),
/// `Ctrl+S` — el caller pidió persistir los cambios.
Guardar,
/// `Ctrl+]` — saltar al átomo siguiente del cuerpo activo.
SaltarAtomoSiguiente,
/// `Ctrl+J` — togglea la junction inmediatamente anterior al
/// átomo bajo el caret: si era separador (línea-guarda), pasa a
/// fundida (línea editable, parte de la misma zona); si era
/// fundida, vuelve a separador.
ToglearFusion,
/// `Ctrl+Shift+]` — salta el caret a la siguiente zona (grupo de
/// atoms unidos por junctions fundidas). Wrap circular al final.
ZonaSiguiente,
/// `Ctrl+Shift+[` — salta a la zona anterior. Wrap circular al inicio.
ZonaAnterior,
/// `Ctrl+Shift+A` — selecciona la zona donde está el caret.
SeleccionarZona,
}
struct Model {
/// Cuerpo activo — su `orden` es lo que el editor reconstruye.
cuerpo: Cuerpo,
/// Atoms del "grafo" plano. Clave = `id`, valor = atom completo.
atoms: HashMap<Uuid, NarrativeAtom>,
/// El IDE: buffer + cursor + diff vs `editor_cuerpo`.
ide: CuerpoIde,
/// Clipboard local (no toca el del sistema — los demos viven en
/// sandbox). `MemClipboard` cubre Ctrl+C/X/V durante la sesión.
clipboard: MemClipboard,
/// Mensaje del último `Guardar` para mostrar en el footer.
ultimo_save: String,
/// Acumulador de drag para selección por mouse.
drag_accum: (f32, f32),
}
struct Demo;
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"pluma · text-editor IDE sobre EditorCuerpo"
}
fn initial_size() -> (u32, u32) {
(1100, 720)
}
fn init(_: &Handle<Msg>) -> Model {
let textos = [
"El cóndor cruzó el cielo del valle al amanecer.",
"Las llamas pastaban entre los pastizales del altiplano.",
"Una mujer joven tejía un telar bajo el alero.",
"El río Apurímac descendía rugiente por las rocas.",
"Al caer la tarde, las nubes cubrieron el sol.",
];
let atoms_vec: Vec<NarrativeAtom> = textos
.iter()
.map(|t| NarrativeAtom::new(*t, "es"))
.collect();
let mut cuerpo = Cuerpo::nuevo("es", "español (original)", Intencion::Original, 0);
for a in &atoms_vec {
cuerpo.agregar(a.id, 0);
}
let atoms: HashMap<Uuid, NarrativeAtom> =
atoms_vec.into_iter().map(|a| (a.id, a)).collect();
let idx: HashMap<Uuid, &NarrativeAtom> = atoms.iter().map(|(k, v)| (*k, v)).collect();
let ide = CuerpoIde::from_cuerpo(&cuerpo, &idx);
Model {
cuerpo,
atoms,
ide,
clipboard: MemClipboard::default(),
ultimo_save: String::new(),
drag_accum: (0.0, 0.0),
}
}
fn update(model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
match msg {
Msg::EditorKey(ev) => {
let mut model = model;
let _ = model.ide.apply_key_with_clipboard(&ev, &mut model.clipboard);
model
}
Msg::EditorPointer(ev) => {
let mut model = model;
let scroll = model.ide.state.scroll_offset;
match ev {
PointerEvent::Click { x, y } => {
model.drag_accum = (0.0, 0.0);
let (line, col) = METRICS.screen_to_pos(x, y, scroll);
model.ide.set_caret(line, col);
}
PointerEvent::Drag {
initial_x,
initial_y,
dx,
dy,
} => {
model.drag_accum.0 += dx;
model.drag_accum.1 += dy;
let cx = initial_x + model.drag_accum.0;
let cy = initial_y + model.drag_accum.1;
let (line, col) = METRICS.screen_to_pos(cx, cy, scroll);
model.ide.state.extend_selection_to(line, col);
}
}
model
}
Msg::Guardar => guardar(model),
Msg::SaltarAtomoSiguiente => {
let mut model = model;
if let Some(siguiente) = atom_siguiente(&model.ide) {
if let Some((line, col)) = model.ide.posicion_de_atom(siguiente) {
model.ide.set_caret(line, col);
// Asegurar visibilidad: el widget no recalcula
// scroll cuando movemos el caret programáticamente.
model.ide.state.ensure_caret_visible(VISIBLE_LINES);
}
}
model
}
Msg::ToglearFusion => {
let mut model = model;
if let Some(idx) = model.ide.junction_antes_del_caret() {
model.ide.togglear_junction(idx);
}
model
}
Msg::ZonaSiguiente => {
let mut model = model;
model.ide.ir_a_zona_siguiente();
model.ide.state.ensure_caret_visible(VISIBLE_LINES);
model
}
Msg::ZonaAnterior => {
let mut model = model;
model.ide.ir_a_zona_anterior();
model.ide.state.ensure_caret_visible(VISIBLE_LINES);
model
}
Msg::SeleccionarZona => {
let mut model = model;
let zona = model.ide.zona_del_caret();
model.ide.seleccionar_zona(zona);
model.ide.state.ensure_caret_visible(VISIBLE_LINES);
model
}
}
}
fn on_key(_model: &Self::Model, event: &KeyEvent) -> Option<Self::Msg> {
if event.state != KeyState::Pressed {
return None;
}
let ctrl = event.modifiers.ctrl || event.modifiers.meta;
let shift = event.modifiers.shift;
if ctrl {
if let Key::Character(s) = &event.key {
if s.eq_ignore_ascii_case("s") {
return Some(Msg::Guardar);
}
// Shift+] / Shift+[ saltan por zonas; sin shift, por átomos.
// Algunos backends emiten "}" / "{" cuando shift+]/[ está
// activo; aceptamos ambas formas.
if shift && (s == "}" || s == "]") {
return Some(Msg::ZonaSiguiente);
}
if shift && (s == "{" || s == "[") {
return Some(Msg::ZonaAnterior);
}
if !shift && s == "]" {
return Some(Msg::SaltarAtomoSiguiente);
}
if shift && s.eq_ignore_ascii_case("a") {
return Some(Msg::SeleccionarZona);
}
if s.eq_ignore_ascii_case("j") {
return Some(Msg::ToglearFusion);
}
}
}
Some(Msg::EditorKey(event.clone()))
}
fn view(model: &Model) -> View<Msg> {
let palette_editor = EditorPalette::default();
let bg_app = palette_editor.bg;
let fg_text = palette_editor.fg_text;
let fg_muted = palette_editor.fg_line_number;
let fundidas = model.ide.fundido_junctions.iter().filter(|f| **f).count();
let total_junctions = model.ide.fundido_junctions.len();
let header_text = format!(
"cuerpo «{}» · {} átomos · {} zonas · {} párrafos · {}/{} junctions fundidas · {} · Ctrl+S guarda · Ctrl+] siguiente · Ctrl+J fundir · Ctrl+Shift+]/[ zona ↔ · Ctrl+Shift+A seleccionar zona",
model.cuerpo.metadatos.nombre_legible,
model.cuerpo.orden.len(),
model.ide.n_zonas(),
model.ide.n_parrafos_buffer(),
fundidas,
total_junctions,
if model.ide.pendiente_sync() {
"● sin guardar"
} else {
"○ sincronizado"
},
);
let header = chip(header_text, 28.0, 12.0, Color::from_rgba8(40, 44, 52, 255), fg_text);
let footer_text = if model.ultimo_save.is_empty() {
"(sin saves todavía — editá libremente y dale Ctrl+S)".to_string()
} else {
model.ultimo_save.clone()
};
let footer = chip(footer_text, 24.0, 11.0, Color::from_rgba8(33, 36, 42, 255), fg_muted);
let editor = cuerpo_ide_view::<Msg>(
&model.ide,
&palette_editor,
METRICS,
VISIBLE_LINES,
Language::Plain,
|ev| Some(Msg::EditorPointer(ev)),
);
let contenedor_editor = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: auto(),
},
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(8.0_f32),
bottom: length(8.0_f32),
},
..Default::default()
})
.fill(bg_app)
.children(vec![editor]);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(bg_app)
.children(vec![header, contenedor_editor, footer])
}
}
/// Banda de texto uniforme — header/footer comparten estilo.
fn chip(texto: String, alto: f32, font_size: f32, fondo: Color, fg: Color) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(alto),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(4.0_f32),
bottom: length(4.0_f32),
},
..Default::default()
})
.fill(fondo)
.text_aligned(texto, font_size, fg, Alignment::Start)
}
/// Ciclo de "Ctrl+]": devuelve el siguiente atom_id en el orden del
/// cuerpo después de la línea actual del caret. Si el caret está en el
/// último átomo (o no se puede mapear), envuelve al primero.
fn atom_siguiente(ide: &CuerpoIde) -> Option<Uuid> {
if ide.editor_cuerpo.atom_ids.is_empty() {
return None;
}
let (linea, _) = ide.caret();
let actual = ide.atom_id_en_linea(linea);
let pos_actual = actual
.and_then(|id| ide.editor_cuerpo.atom_ids.iter().position(|x| *x == id))
.unwrap_or(usize::MAX);
let n = ide.editor_cuerpo.atom_ids.len();
let siguiente = if pos_actual == usize::MAX {
0
} else {
(pos_actual + 1) % n
};
ide.editor_cuerpo.atom_ids.get(siguiente).copied()
}
/// Aplica el diff al modelo conservando caret + scroll. Reconstruye
/// `cuerpo.orden` y refresca el IDE.
fn guardar(model: Model) -> Model {
let mut model = model;
let caret_antes = model.ide.caret();
let scroll_antes = model.ide.state.scroll_offset;
let idx: HashMap<Uuid, &NarrativeAtom> = model.atoms.iter().map(|(k, v)| (*k, v)).collect();
let cambios = model.ide.diff(&idx);
drop(idx);
let resumen = persistir(&mut model, &cambios);
// Tras persistir, recargá el IDE con el cuerpo + atoms actualizados.
let cuerpo_clon = model.cuerpo.clone();
let idx2: HashMap<Uuid, &NarrativeAtom> = model.atoms.iter().map(|(k, v)| (*k, v)).collect();
model.ide.recargar(&cuerpo_clon, &idx2);
drop(idx2);
// Restaurar caret + scroll para no perder el lugar — clamp al rango
// del cuerpo nuevo (set_caret_at lo hace por nosotros).
model.ide.set_caret(caret_antes.0, caret_antes.1);
model.ide.state.scroll_offset = scroll_antes;
model.ide.state.ensure_caret_visible(VISIBLE_LINES);
model.ultimo_save = resumen;
model
}
/// Aplica los `CambioAtom` al modelo: muta `atoms` y reconstruye
/// `cuerpo.orden`. Devuelve un resumen humano de qué pasó.
fn persistir(model: &mut Model, cambios: &[CambioAtom]) -> String {
if cambios.is_empty() {
return "guardado: sin cambios".to_string();
}
let mut mutados = 0usize;
let mut creados: Vec<Uuid> = Vec::new();
let mut eliminados = 0usize;
for c in cambios {
match c {
CambioAtom::Mutar { id, texto_nuevo } => {
if let Some(a) = model.atoms.get_mut(id) {
a.set_content(texto_nuevo.as_str());
mutados += 1;
}
}
CambioAtom::Crear { texto, posicion: _ } => {
let atom = NarrativeAtom::new(texto.as_str(), &model.cuerpo.branch_id);
let id = atom.id;
model.atoms.insert(id, atom);
creados.push(id);
}
CambioAtom::Eliminar { id } => {
model.atoms.remove(id);
eliminados += 1;
}
}
}
// Reconstruí `cuerpo.orden`. El editor garantiza que `Crear` apunta
// a posiciones consecutivas al final y los `Eliminar` salen del
// final también — así podemos rehacer el orden con
// `EditorCuerpo::aplicar_cambios`, que ya implementa esa semántica.
model.ide.aplicar_cambios(cambios, &creados);
let nuevo_orden: Vec<Uuid> = model.ide.editor_cuerpo.atom_ids.clone();
let ahora = model.cuerpo.metadatos.modificado_en.saturating_add(1);
let viejo: Vec<Uuid> = model.cuerpo.orden.clone();
for id in &viejo {
let _ = model.cuerpo.remover(*id, ahora);
}
for id in &nuevo_orden {
model.cuerpo.agregar(*id, ahora);
}
format!(
"guardado: {mutados} mutar · {} crear · {eliminados} eliminar — orden con {} átomos",
creados.len(),
nuevo_orden.len(),
)
}
fn main() {
llimphi_ui::run::<Demo>();
}
@@ -0,0 +1,582 @@
//! **Editor único** — la UX prometida en PLAN §11.
//!
//! Layout vertical:
//!
//! ┌──────────────────────────────────────────────────────────────┐
//! │ header: cuerpo activo + atajos │
//! ├──────────────────────────────────────────────────────────────┤
//! │ ┌─────────────────┬─────────┬─────────────────┐ │
//! │ │ │ │ │ │
//! │ │ CuerpoIde 0 │ hebras │ CuerpoIde 1 │ │
//! │ │ (text-editor) │ (carril)│ (text-editor) │ │
//! │ │ │ │ │ │
//! │ └─────────────────┴─────────┴─────────────────┘ │
//! ├──────────────────────────────────────────────────────────────┤
//! │ footer: último save │
//! └──────────────────────────────────────────────────────────────┘
//!
//! Tres cuerpos: `qu` (derivado) — `es` (original, **al centro**) —
//! `en` (derivado). Dos cartas: `qu ↔ es` y `es ↔ en`. Cada cuerpo
//! es un text-editor real (no readonly): escribís donde mirás. Las
//! hebras salen en curva S a ambos lados de la madre central — el
//! sentido visual del DAG es directo (madre arriba/centro, hijas
//! abajo/laterales) sin necesidad de etiquetas.
//!
//! **Scroll vertical sincronizado**: al final de cada `update`, el
//! scroll del cuerpo activo se copia a todos los demás (clampeado al
//! fin de buffer de cada uno). PageUp/PageDown, ensure_caret_visible
//! tras typing y set_caret tras click — cualquier cosa que mueva el
//! viewport del activo arrastra al resto. Las hebras nunca se
//! desalinean visualmente.
//!
//! Atajos y gestos:
//! - **Click dentro de cualquier editor** → le da el foco (cuerpo
//! activo) y posiciona el caret en la línea cliqueada.
//! - `Ctrl+1` / `Ctrl+2` / `Ctrl+3` → cambiar cuerpo activo con
//! teclado (qu / es / en respectivamente; preserva buffer, caret,
//! undo — cada cuerpo tiene su propio `CuerpoIde`).
//! - `Ctrl+S` → diff + persiste el cuerpo activo; si era la madre
//! (`es`), marca **ambas** cartas como stale (hebras punteadas).
//! - `Ctrl+]` → siguiente átomo del cuerpo activo.
//! - `Ctrl+C/X/V` → clipboard en memoria.
//!
//! ```bash
//! cargo run -p pluma-editor-llimphi --example editor_unico_demo --release
//! ```
use std::collections::HashMap;
use llimphi_ui::llimphi_layout::taffy::prelude::{
length, percent, FlexDirection, Rect, Size, Style,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, View};
use llimphi_widget_text_editor::{
EditorMetrics, EditorPalette, Language, MemClipboard, PointerEvent,
};
use pluma_align::CartaHebras;
use pluma_core::NarrativeAtom;
use pluma_cuerpo::{Cuerpo, Intencion};
use pluma_editor_cuerpo::CambioAtom;
use pluma_editor_llimphi::cuerpo_ide::CuerpoIde;
use pluma_editor_llimphi::multilienzo::PaletaHebras;
use pluma_editor_llimphi::multilienzo_editor::{
multilienzo_editor_view, sincronizar_scroll_desde_activo, ConfigMultilienzoEditor,
};
use pluma_editor_llimphi::Palette;
use pluma_transform::{Ejecutor, TipoTransformacion, Transformacion};
use pluma_transform_tabla::EjecutorTraducirTabla;
use uuid::Uuid;
const METRICS: EditorMetrics = EditorMetrics::for_font_size(13.0);
const VISIBLE_LINES: usize = 200;
#[derive(Clone, Debug)]
enum Msg {
EditorKey(KeyEvent),
/// Pointer event sobre el editor del cuerpo `cuerpo`. Al cliquear un
/// editor que no es el activo, además del set_caret cambiamos el
/// `activo` — el foco lo da el último click.
EditorPointer { cuerpo: usize, ev: PointerEvent },
Guardar,
CambiarActivo(usize),
SaltarAtomoSiguiente,
}
struct Model {
cuerpos: Vec<Cuerpo>,
atoms: HashMap<Uuid, NarrativeAtom>,
/// `cartas[i]` conecta `cuerpos[i]` con `cuerpos[i+1]`.
cartas: Vec<CartaHebras>,
/// Un IDE por cuerpo — cambiar de cuerpo conserva el buffer de cada
/// uno (caret, undo, ediciones sin guardar). Indexado por cuerpo.
ides: Vec<CuerpoIde>,
/// Índice en `cuerpos` y en `ides` del cuerpo con foco — el que
/// recibe los `Msg::EditorKey` y se pinta con borde accent.
activo: usize,
clipboard: MemClipboard,
ultimo_save: String,
/// Acumulado de drag (x, y) por cuerpo — el `Drag` del widget pasa
/// deltas y el caller acumula.
drag_accum: Vec<(f32, f32)>,
}
struct Demo;
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"pluma · editor único (editores lado-a-lado + hebras)"
}
fn initial_size() -> (u32, u32) {
(1180, 820)
}
fn init(_: &Handle<Msg>) -> Model {
// -- Cuerpo madre `es` -------------------------------------------
let textos_es = [
"El cóndor cruzó el cielo del valle al amanecer.",
"Las llamas pastaban entre los pastizales del altiplano.",
"Una mujer joven tejía un telar bajo el alero.",
"El río Apurímac descendía rugiente por las rocas.",
];
let atoms_es: Vec<NarrativeAtom> = textos_es
.iter()
.map(|t| NarrativeAtom::new(*t, "es"))
.collect();
let mut es = Cuerpo::nuevo("es", "español (original)", Intencion::Original, 100);
for a in &atoms_es {
es.agregar(a.id, 101);
}
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime tokio");
// -- Cuerpo `qu` derivado por tabla -------------------------------
let traducciones_qu = [
"Kuntur wayqu hanaqpachata pacha paqarinpi pasarqa.",
"Llamaqakuna qulla suyup q'achupinpi mikhusharqaku.",
"Sipas warmiq away wasiq hawanpi awayta ruwasharqa.",
"Apurímac mayu rumikuna ukhumanta qhaparispa uraykurqa.",
];
let (qu, atoms_qu, carta_es_qu) = derivar_por_tabla(
&rt, &es, &atoms_es, &traducciones_qu, "qu", 200,
);
// -- Cuerpo `en` derivado por tabla -------------------------------
let traducciones_en = [
"The condor crossed the valley sky at dawn.",
"Llamas grazed among the highland grasslands.",
"A young woman was weaving on a loom beneath the eaves.",
"The Apurímac river descended roaring through the rocks.",
];
let (en, atoms_en, carta_es_en) = derivar_por_tabla(
&rt, &es, &atoms_es, &traducciones_en, "en", 300,
);
// -- Index global de atoms ----------------------------------------
let mut atoms: HashMap<Uuid, NarrativeAtom> = HashMap::new();
for a in atoms_es
.iter()
.chain(atoms_qu.iter())
.chain(atoms_en.iter())
{
atoms.insert(a.id, a.clone());
}
// Orden visual: la madre (es) al centro, derivadas a los lados.
// El multilienzo_editor pinta cartas entre cuerpos consecutivos,
// así que cartas[0] = qu↔es y cartas[1] = es↔en. Las hebras
// salen en S a ambos lados de la columna central — visualiza
// cómo `es` es el ancla de las traducciones.
let cuerpos = vec![qu, es, en];
let cartas = vec![carta_es_qu, carta_es_en];
let idx = ref_idx(&atoms);
let ides: Vec<CuerpoIde> = cuerpos
.iter()
.map(|c| CuerpoIde::from_cuerpo(c, &idx))
.collect();
drop(idx);
let n = cuerpos.len();
Model {
cuerpos,
atoms,
cartas,
ides,
// Arranca con `es` (la madre) activa — está al centro.
activo: 1,
clipboard: MemClipboard::default(),
ultimo_save: String::new(),
drag_accum: vec![(0.0, 0.0); n],
}
}
fn update(model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
// Procesamos el msg sobre el cuerpo activo y, al final, sincronizamos
// el scroll del activo a todos los demás editores. Las hebras
// quedan así alineadas en todo momento sin importar qué cuerpo
// disparó el cambio.
let mut model: Model = match msg {
Msg::EditorKey(ev) => {
let mut model = model;
let i = model.activo;
let _ = model.ides[i].apply_key_with_clipboard(&ev, &mut model.clipboard);
model
}
Msg::EditorPointer { cuerpo, ev } => {
let mut model = model;
if cuerpo >= model.cuerpos.len() {
return model;
}
// Cualquier click en un editor le da el foco. Drag sin
// click previo no debería cambiar el activo (el press
// que originó el drag ya lo cambió antes).
if matches!(ev, PointerEvent::Click { .. }) && cuerpo != model.activo {
model.activo = cuerpo;
}
let scroll = model.ides[cuerpo].state.scroll_offset;
match ev {
PointerEvent::Click { x, y } => {
model.drag_accum[cuerpo] = (0.0, 0.0);
let (line, col) = METRICS.screen_to_pos(x, y, scroll);
model.ides[cuerpo].set_caret(line, col);
}
PointerEvent::Drag {
initial_x,
initial_y,
dx,
dy,
} => {
model.drag_accum[cuerpo].0 += dx;
model.drag_accum[cuerpo].1 += dy;
let cx = initial_x + model.drag_accum[cuerpo].0;
let cy = initial_y + model.drag_accum[cuerpo].1;
let (line, col) = METRICS.screen_to_pos(cx, cy, scroll);
model.ides[cuerpo].state.extend_selection_to(line, col);
}
}
model
}
Msg::Guardar => guardar(model),
Msg::CambiarActivo(i) => {
let mut model = model;
if i < model.cuerpos.len() {
model.activo = i;
if let Some(slot) = model.drag_accum.get_mut(i) {
*slot = (0.0, 0.0);
}
// Al cambiar activo con teclado, el caret del nuevo
// activo puede estar fuera del viewport común. Lo
// traemos a la vista — el scroll resultante se
// propaga al resto en la sincronización del final.
model.ides[i].state.ensure_caret_visible(VISIBLE_LINES);
}
model
}
Msg::SaltarAtomoSiguiente => {
let mut model = model;
let i = model.activo;
if let Some(siguiente) = atom_siguiente(&model.ides[i]) {
if let Some((line, col)) = model.ides[i].posicion_de_atom(siguiente) {
model.ides[i].set_caret(line, col);
model.ides[i].state.ensure_caret_visible(VISIBLE_LINES);
}
}
model
}
};
sincronizar_scroll_desde_activo(&mut model.ides, model.activo);
model
}
fn on_key(_model: &Self::Model, event: &KeyEvent) -> Option<Self::Msg> {
if event.state != KeyState::Pressed {
return None;
}
let ctrl = event.modifiers.ctrl || event.modifiers.meta;
if ctrl {
if let Key::Character(s) = &event.key {
match s.as_str() {
"s" | "S" => return Some(Msg::Guardar),
"]" => return Some(Msg::SaltarAtomoSiguiente),
"1" => return Some(Msg::CambiarActivo(0)),
"2" => return Some(Msg::CambiarActivo(1)),
"3" => return Some(Msg::CambiarActivo(2)),
_ => {}
}
}
}
Some(Msg::EditorKey(event.clone()))
}
fn view(model: &Model) -> View<Msg> {
let palette_editor = EditorPalette::default();
let palette_lienzo = Palette::default();
let paleta_hebras = PaletaHebras::default();
let cfg = ConfigMultilienzoEditor::default();
let bg_app = palette_editor.bg;
let fg_text = palette_editor.fg_text;
let fg_muted = palette_editor.fg_line_number;
let ide_activo = &model.ides[model.activo];
let header_text = format!(
"activo: «{}» · {} átomos · {} párrafos · {} · click = foco · Ctrl+1/2/3 cambiar · Ctrl+S guardar · Ctrl+] siguiente",
model.cuerpos[model.activo].metadatos.nombre_legible,
model.cuerpos[model.activo].orden.len(),
ide_activo.n_parrafos_buffer(),
if ide_activo.pendiente_sync() {
"● cambios sin guardar"
} else {
"○ sincronizado"
},
);
let header = chip(header_text, 28.0, 12.0, Color::from_rgba8(40, 44, 52, 255), fg_text);
// Cuerpo principal: N editores lado-a-lado con hebras entre
// cada par consecutivo. Click en cualquiera → foco; teclas →
// editor activo. Las hebras siguen al scroll de cada editor.
let ides_ref: Vec<&CuerpoIde> = model.ides.iter().collect();
let cuerpos_ref: Vec<&Cuerpo> = model.cuerpos.iter().collect();
let cartas_ref: Vec<Option<&CartaHebras>> = model.cartas.iter().map(Some).collect();
let editores = multilienzo_editor_view::<Msg, _>(
&ides_ref,
&cuerpos_ref,
&cartas_ref,
model.activo,
&palette_editor,
&paleta_hebras,
&palette_lienzo,
&cfg,
METRICS,
VISIBLE_LINES,
Language::Plain,
|cuerpo, ev| Msg::EditorPointer { cuerpo, ev },
);
let area_principal = View::new(Style {
flex_direction: FlexDirection::Row,
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(palette_lienzo.bg_app)
.children(vec![editores]);
// Footer: estado de las hebras (fresh/total por carta) +
// último save. Útil para ver de un vistazo qué pasó al
// Ctrl+S — si editaste `es`, las dos cartas pasan de N/N a
// 0/N y las hebras del multilienzo se pintan punteadas.
let estado_hebras = formatear_estado_hebras(&model);
let footer_text = if model.ultimo_save.is_empty() {
format!(
"{estado_hebras} · editá y Ctrl+S; al tocar la madre las hebras pasan a stale"
)
} else {
format!("{estado_hebras} · {}", model.ultimo_save)
};
let footer = chip(footer_text, 24.0, 11.0, Color::from_rgba8(33, 36, 42, 255), fg_muted);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(bg_app)
.children(vec![header, area_principal, footer])
}
}
fn chip(texto: String, alto: f32, font_size: f32, fondo: Color, fg: Color) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(alto),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(4.0_f32),
bottom: length(4.0_f32),
},
..Default::default()
})
.fill(fondo)
.text_aligned(texto, font_size, fg, Alignment::Start)
}
fn ref_idx(atoms: &HashMap<Uuid, NarrativeAtom>) -> HashMap<Uuid, &NarrativeAtom> {
atoms.iter().map(|(k, v)| (*k, v)).collect()
}
/// Crea un cuerpo derivado de `madre` aplicando `EjecutorTraducirTabla`
/// con la tabla `madre_atom_id → traduccion[i]`. Devuelve la hija +
/// sus átomos nuevos + la carta de hebras derivadas.
///
/// El demo lo usa para generar `qu` y `en` desde la misma `es` sin
/// duplicar el ceremonial del runtime tokio en cada llamada.
/// Resumen textual del estado de cada carta en el formato
/// `cuerpoA↔cuerpoB: fresh/total`. El multilienzo_editor pinta `cartas[i]`
/// entre `cuerpos[i]` y `cuerpos[i+1]`, así que el rótulo refleja ese
/// par exacto. Hebras stale destacan con un `✗`, todas fresh con un `✓`.
fn formatear_estado_hebras(model: &Model) -> String {
if model.cartas.is_empty() {
return "(sin cartas)".to_string();
}
let mut partes: Vec<String> = Vec::with_capacity(model.cartas.len());
for (i, carta) in model.cartas.iter().enumerate() {
let total = carta.hebras.len();
let fresh = carta.hebras.iter().filter(|h| h.fresco).count();
let estado = if total == 0 {
""
} else if fresh == total {
""
} else {
""
};
let a = model
.cuerpos
.get(i)
.map(|c| c.branch_id.as_str())
.unwrap_or("?");
let b = model
.cuerpos
.get(i + 1)
.map(|c| c.branch_id.as_str())
.unwrap_or("?");
partes.push(format!("{a}{b}: {fresh}/{total} {estado}"));
}
partes.join(" · ")
}
fn derivar_por_tabla(
rt: &tokio::runtime::Runtime,
madre: &Cuerpo,
atoms_madre: &[NarrativeAtom],
traducciones: &[&str],
lengua_destino: &str,
timestamp: u64,
) -> (Cuerpo, Vec<NarrativeAtom>, CartaHebras) {
let mut tabla: HashMap<Uuid, String> = HashMap::new();
for (atom, tr) in atoms_madre.iter().zip(traducciones.iter()) {
tabla.insert(atom.id, (*tr).to_string());
}
let ejecutor = EjecutorTraducirTabla::new(tabla, lengua_destino);
let t = Transformacion::nueva(
madre.id,
Uuid::new_v4(),
TipoTransformacion::Traducir {
lengua_destino: lengua_destino.into(),
},
"ana",
timestamp,
);
let prod = rt
.block_on(ejecutor.aplicar(&t, madre, timestamp))
.expect("traducción por tabla");
(prod.hija, prod.atoms_nuevos, prod.carta)
}
fn atom_siguiente(ide: &CuerpoIde) -> Option<Uuid> {
if ide.editor_cuerpo.atom_ids.is_empty() {
return None;
}
let (linea, _) = ide.caret();
let actual = ide.atom_id_en_linea(linea);
let pos_actual = actual
.and_then(|id| ide.editor_cuerpo.atom_ids.iter().position(|x| *x == id))
.unwrap_or(usize::MAX);
let n = ide.editor_cuerpo.atom_ids.len();
let siguiente = if pos_actual == usize::MAX {
0
} else {
(pos_actual + 1) % n
};
ide.editor_cuerpo.atom_ids.get(siguiente).copied()
}
fn guardar(model: Model) -> Model {
let mut model = model;
let i = model.activo;
let caret_antes = model.ides[i].caret();
let scroll_antes = model.ides[i].state.scroll_offset;
let idx = ref_idx(&model.atoms);
let cambios = model.ides[i].diff(&idx);
drop(idx);
let toco_atomos = !cambios.is_empty();
let resumen = persistir(&mut model, i, &cambios);
// Si tocamos la madre (`es`, idx=1 en el orden [qu, es, en]), TODAS
// las cartas quedan stale — ambas la usan como un extremo. Si
// tocamos una hija (qu o en), las hebras se mantienen fresh: la
// hija cambió por edición humana, no porque la madre haya
// derivado. Conservador pero exacto para el demo.
let edito_la_madre = i == 1;
if toco_atomos && edito_la_madre {
for carta in &mut model.cartas {
for h in &mut carta.hebras {
h.fresco = false;
}
}
}
// Refrescá el IDE del cuerpo modificado con el cuerpo + atoms nuevos.
let cuerpo_clon = model.cuerpos[i].clone();
let idx2 = ref_idx(&model.atoms);
model.ides[i].recargar(&cuerpo_clon, &idx2);
drop(idx2);
model.ides[i].set_caret(caret_antes.0, caret_antes.1);
model.ides[i].state.scroll_offset = scroll_antes;
model.ides[i].state.ensure_caret_visible(VISIBLE_LINES);
model.ultimo_save = resumen;
model
}
fn persistir(model: &mut Model, i_cuerpo: usize, cambios: &[CambioAtom]) -> String {
if cambios.is_empty() {
return "guardado: sin cambios".to_string();
}
let mut mutados = 0usize;
let mut creados: Vec<Uuid> = Vec::new();
let mut eliminados = 0usize;
let branch_id = model.cuerpos[i_cuerpo].branch_id.clone();
for c in cambios {
match c {
CambioAtom::Mutar { id, texto_nuevo } => {
if let Some(a) = model.atoms.get_mut(id) {
a.set_content(texto_nuevo.as_str());
mutados += 1;
}
}
CambioAtom::Crear { texto, posicion: _ } => {
let atom = NarrativeAtom::new(texto.as_str(), &branch_id);
let id = atom.id;
model.atoms.insert(id, atom);
creados.push(id);
}
CambioAtom::Eliminar { id } => {
model.atoms.remove(id);
eliminados += 1;
}
}
}
model.ides[i_cuerpo].aplicar_cambios(cambios, &creados);
let nuevo_orden: Vec<Uuid> = model.ides[i_cuerpo].editor_cuerpo.atom_ids.clone();
let ahora = model.cuerpos[i_cuerpo].metadatos.modificado_en.saturating_add(1);
let viejo: Vec<Uuid> = model.cuerpos[i_cuerpo].orden.clone();
for id in &viejo {
let _ = model.cuerpos[i_cuerpo].remover(*id, ahora);
}
for id in &nuevo_orden {
model.cuerpos[i_cuerpo].agregar(*id, ahora);
}
let nombre = &model.cuerpos[i_cuerpo].metadatos.nombre_legible;
format!(
"guardado en «{nombre}»: {mutados} mutar · {} crear · {eliminados} eliminar — {} átomos",
creados.len(),
nuevo_orden.len(),
)
}
fn main() {
llimphi_ui::run::<Demo>();
}
@@ -0,0 +1,995 @@
//! El demo "todo junto": toolbar LLM dinámica + persistencia automática.
//!
//! Cada transformación que disparás con un botón se persiste en
//! `~/.cache/gioser/pluma-multilienzo-completo/` ANTES de que veas la
//! columna nueva. Cerrá el demo, volvé a abrirlo: todo lo que generaste
//! sigue ahí, sin pegarle de nuevo al LLM.
//!
//! Esto es lo más cerca que tenemos a una "app" de pluma multilienzo:
//! abre, deriva cuerpos cuando hace falta, persiste sin avisar, y al
//! cierre garantiza que el sled está flusheado.
//!
//! ```bash
//! GEMINI_API_KEY=... PLUMA_LLM_BACKEND=gemini \
//! cargo run -p pluma-editor-llimphi \
//! --example multilienzo_completo_demo --release
//!
//! # Reset del cache:
//! MULTILIENZO_COMPLETO_RESET=1 cargo run -p pluma-editor-llimphi \
//! --example multilienzo_completo_demo --release
//! ```
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use llimphi_ui::llimphi_layout::taffy::prelude::{
auto, length, percent, FlexDirection, Position, Rect, Size, Style,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::llimphi_hal::winit::keyboard::{Key, NamedKey};
use llimphi_ui::{App, Handle, KeyEvent, KeyState, Modifiers, View, WheelDelta};
use llimphi_widget_button::{button_view, ButtonPalette};
use pluma_align::CartaHebras;
use pluma_core::NarrativeAtom;
use pluma_cuerpo::{Cuerpo, Intencion};
use pluma_editor_llimphi::multilienzo::{
multilienzo_view_resaltado, IndiceAtoms, MultilienzoConfig, PaletaHebras,
};
use pluma_editor_llimphi::Palette;
use pluma_graph::NarrativeGraph;
use pluma_llm::{build_client, from_env as llm_from_env, BackendKind, LlmConfig};
use pluma_llm_core::ChatClient;
use pluma_store::{EstadoUi, PlumaStore};
use pluma_transform::{TipoTransformacion, Transformacion};
use pluma_transform_llm::{
EjecutorResumirLlm, EjecutorTonoLlm, EjecutorTraducirLlm,
};
use uuid::Uuid;
#[derive(Clone, Debug)]
enum Msg {
PedirTraducir(String),
PedirTono(String),
PedirResumir(Option<u32>),
LlmListo {
hija: Cuerpo,
atoms_nuevos: Vec<NarrativeAtom>,
carta: CartaHebras,
transformacion: Transformacion,
},
LlmError(String),
/// Delta de scroll horizontal en píxeles, positivo = derecha.
ScrollHoriz(f32),
/// Alterna entre mostrar solo el cuerpo madre y mostrar todos.
ToggleSoloMadre,
/// Agrega un carácter al final de la búsqueda transversal.
BuscarAgregar(char),
/// Borra el último carácter de la búsqueda.
BuscarBorrar,
/// Limpia la búsqueda completa.
BuscarLimpiar,
/// Actualiza el timestamp de la madre — TODAS las hijas Derivadas
/// quedan stale (es_stale(modificado_madre_en) = true). Útil para
/// demostrar el flujo "regenerar tras editar la madre" sin tener
/// que editar texto a mano todavía.
TocarMadre,
/// Lanza la transformación de la primera hija stale que encuentre.
/// Consulta el store por la `Transformacion` original (madre → hija)
/// y re-aplica con la madre actualizada. Un click = una hija.
RegenerarSiguienteStale,
/// Cicla al siguiente backend LLM en la lista
/// `mock → gemini → anthropic → deepseek → cohere → ollama → mock`.
/// Si el backend no está configurado (env key ausente), conserva el
/// anterior y muestra error en la status bar.
CiclarBackend,
/// Editor inline MVP: muta el primer párrafo de la madre añadiendo un
/// sello con el contador, propaga `PendingEvaluation` por el grafo,
/// avanza `modificado_en` de la madre. Después, las hijas Derivadas
/// quedan stale automáticamente. Sirve para demostrar el ciclo
/// edit → stale → regenerar sin necesitar widget de input.
EditarPrimerParrafoMadre,
}
struct Model {
cuerpos: Vec<Cuerpo>,
graph: NarrativeGraph,
cartas: Vec<CartaHebras>,
chat: Arc<dyn ChatClient>,
/// Backend actualmente activo — para mostrarlo en la toolbar y
/// para ciclar al siguiente con `Msg::CiclarBackend`.
backend: BackendKind,
store: Arc<PlumaStore>,
en_curso: bool,
ultimo_error: Option<String>,
/// Desplazamiento horizontal acumulado del multilienzo, en píxeles.
/// Wheel del mouse + Shift (o eje X de un touchpad) lo modifica.
/// Se limita en `view` al ancho del contenido.
scroll_x: f32,
/// Si `true`, oculta todos los cuerpos excepto el primero (la madre).
/// Toggleable con el botón "solo madre"/"todos".
solo_madre: bool,
/// Query de búsqueda transversal. Cualquier átomo (en cualquier
/// cuerpo visible) cuyo `content` contenga este substring se
/// resalta. Se acumula con `App::on_key` — el demo no usa widget
/// de input, captura las teclas directas (alfanuméricas + espacio
/// + Backspace + Escape).
busqueda: String,
}
struct Demo;
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"pluma · multilienzo completo (LLM + persistencia)"
}
fn initial_size() -> (u32, u32) {
(1400, 720)
}
/// Wheel del mouse → scroll horizontal. Convenciones:
/// - touchpad con eje X (delta.x != 0) → horizontal directo.
/// - Shift + wheel-Y vertical (común en Linux) → horizontal.
/// - Wheel-Y sin Shift → vertical (no implementado todavía, ignorado).
/// Multiplicador 30 px/línea coincide con el visor de texto de nahual.
/// Captura de teclado para la búsqueda transversal sin widget de
/// input. Cualquier `text` no-vacío del KeyEvent (lo que el sistema
/// IME ya resolvió) suma su primer char a la búsqueda. Backspace
/// borra el último; Escape limpia. Ctrl/Alt como modificador deja
/// pasar la tecla (no captura — futuro: combos de la app).
fn on_key(_model: &Self::Model, event: &KeyEvent) -> Option<Self::Msg> {
if event.state != KeyState::Pressed {
return None;
}
if event.modifiers.ctrl || event.modifiers.alt || event.modifiers.meta {
return None;
}
if let Key::Named(NamedKey::Backspace) = event.key {
return Some(Msg::BuscarBorrar);
}
if let Key::Named(NamedKey::Escape) = event.key {
return Some(Msg::BuscarLimpiar);
}
// Texto producido (con IME e ortografía) — el primer char alfanum
// o espacio entra a la búsqueda. Filtramos teclas de control
// (Tab/Enter/etc.) por ser no-imprimibles.
if let Some(text) = &event.text {
if let Some(c) = text.chars().next() {
if !c.is_control() {
return Some(Msg::BuscarAgregar(c));
}
}
}
None
}
fn on_wheel(
_model: &Self::Model,
delta: WheelDelta,
_cursor: (f32, f32),
modifiers: Modifiers,
) -> Option<Self::Msg> {
const PX_POR_LINEA: f32 = 30.0;
let dx_lineas = if delta.x.abs() > 0.0 {
delta.x
} else if modifiers.shift {
// Shift convierte el eje Y de la rueda en horizontal.
delta.y
} else {
return None;
};
Some(Msg::ScrollHoriz(-dx_lineas * PX_POR_LINEA))
}
fn init(_: &Handle<Msg>) -> Model {
let cache_dir = cache_dir();
let reset = std::env::var("MULTILIENZO_COMPLETO_RESET").ok().as_deref()
== Some("1")
|| std::env::args().any(|a| a == "--reset");
if reset {
let _ = std::fs::remove_dir_all(&cache_dir);
eprintln!("multilienzo_completo_demo :: cache reseteado");
}
std::fs::create_dir_all(&cache_dir).expect("crear cache dir");
let sled_path = cache_dir.join("pluma.sled");
let store = Arc::new(PlumaStore::open(&sled_path).expect("abrir PlumaStore"));
let (chat, backend) = construir_chat();
eprintln!(
"multilienzo_completo_demo :: store={} · LLM={} ({})",
sled_path.display(),
chat.model_id(),
etiqueta_backend(backend),
);
// Cargar lo que haya en disco; si nada, sembrar madre es base.
let mut m = if store.cuerpos_len() >= 1 {
eprintln!(
"multilienzo_completo_demo :: cargando {} cuerpos de disco",
store.cuerpos_len()
);
cargar_de_store(store.clone(), chat, backend)
} else {
eprintln!("multilienzo_completo_demo :: sembrando madre es base");
sembrar_madre_base(store.clone(), chat, backend)
};
// Restaurar estado UI persistido — focus, búsqueda, scroll
// sobreviven al cierre del proceso.
if let Ok(Some(ui)) = m.store.get_estado_ui() {
eprintln!(
"multilienzo_completo_demo :: estado UI restaurado: \
solo_madre={} busqueda=\"{}\" scroll_x={:.0}",
ui.solo_madre, ui.busqueda, ui.scroll_x
);
m.solo_madre = ui.solo_madre;
m.busqueda = ui.busqueda;
m.scroll_x = ui.scroll_x;
}
m
}
fn update(model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
let mut m = model;
match msg {
Msg::PedirTraducir(lengua) => {
if m.en_curso || m.cuerpos.is_empty() {
return m;
}
m.en_curso = true;
m.ultimo_error = None;
lanzar_trabajo(&m, handle, TrabajoLlm::Traducir(lengua));
}
Msg::PedirTono(etiqueta) => {
if m.en_curso || m.cuerpos.is_empty() {
return m;
}
m.en_curso = true;
m.ultimo_error = None;
lanzar_trabajo(&m, handle, TrabajoLlm::Tono(etiqueta));
}
Msg::PedirResumir(palabras) => {
if m.en_curso || m.cuerpos.is_empty() {
return m;
}
m.en_curso = true;
m.ultimo_error = None;
lanzar_trabajo(&m, handle, TrabajoLlm::Resumir(palabras));
}
Msg::LlmListo {
hija,
atoms_nuevos,
carta,
transformacion,
} => {
// Persistir TODO antes de actualizar el modelo — si el
// proceso muere entre los dos pasos, lo siguiente que
// abra la store ya ve la transformación completa.
for atom in &atoms_nuevos {
if let Err(e) = m.store.put_atom(atom) {
eprintln!("persistir atom falló: {e}");
}
}
if let Err(e) = m.store.put_cuerpo(&hija) {
eprintln!("persistir cuerpo falló: {e}");
}
if let Err(e) = m.store.put_transformacion(&transformacion) {
eprintln!("persistir transformación falló: {e}");
}
if let Err(e) = m.store.put_carta(&carta) {
eprintln!("persistir carta falló: {e}");
}
if let Err(e) = m.store.flush() {
eprintln!("flush falló: {e}");
}
// Actualizar el modelo de la app.
for atom in atoms_nuevos {
m.graph.insert(atom);
}
m.cuerpos.push(hija);
m.cartas.push(carta);
m.en_curso = false;
}
Msg::LlmError(s) => {
eprintln!("multilienzo_completo_demo :: error LLM: {s}");
m.ultimo_error = Some(s);
m.en_curso = false;
}
Msg::ScrollHoriz(dx) => {
// El clamp duro lo aplica `view` (necesita medir el
// ancho del contenido); aquí solo acumulamos y dejamos
// que no se vaya negativo.
m.scroll_x = (m.scroll_x + dx).max(0.0);
persistir_estado_ui(&m);
}
Msg::ToggleSoloMadre => {
m.solo_madre = !m.solo_madre;
persistir_estado_ui(&m);
}
Msg::BuscarAgregar(c) => {
m.busqueda.push(c);
persistir_estado_ui(&m);
}
Msg::BuscarBorrar => {
m.busqueda.pop();
persistir_estado_ui(&m);
}
Msg::BuscarLimpiar => {
m.busqueda.clear();
persistir_estado_ui(&m);
}
Msg::TocarMadre => {
if let Some(madre) = m.cuerpos.first_mut() {
madre.metadatos.modificado_en = ahora_unix();
let _ = m.store.put_cuerpo(madre);
let _ = m.store.flush();
eprintln!(
"multilienzo_completo_demo :: madre tocada — \
{} hija(s) ahora stale",
contar_stale(&m)
);
}
}
Msg::EditarPrimerParrafoMadre => {
// Tomar el primer átomo de la madre y mutar su contenido.
// Si la madre no tiene átomos, no hay nada que editar.
let (madre_id, primer_atom_id) =
match m.cuerpos.first().and_then(|c| c.orden.first().map(|id| (c.id, *id))) {
Some(t) => t,
None => return m,
};
let nuevo_texto = {
let actual = m
.graph
.get(primer_atom_id)
.map(|a| a.content.as_str().to_string())
.unwrap_or_default();
// Sello incremental para que cada click produzca texto
// distinto (sino el hash no cambiaría).
let sello = ahora_unix() % 10_000;
if let Some(idx) = actual.rfind(" ⟨edit ") {
format!("{} ⟨edit {sello}", &actual[..idx])
} else {
format!("{actual} ⟨edit {sello}")
}
};
if let Some(atom) = m.graph.get_mut(primer_atom_id) {
atom.set_content(nuevo_texto);
// Persistir el atom mutado.
let _ = m.store.put_atom(atom);
}
// Propaga PendingEvaluation a todos los descendientes del
// átomo en el DAG narrativo (caso típico: si el atom era
// dep de otros).
let afectados = m.graph.propagate_mutation(primer_atom_id);
eprintln!(
"multilienzo_completo_demo :: editado primer párrafo madre — \
{} átomos descendientes marcados PendingEvaluation",
afectados.len()
);
// Avanzar modificado_en de la madre — las hijas Derivadas
// pasan a `es_stale(modif)` automáticamente.
if let Some(madre) = m.cuerpos.iter_mut().find(|c| c.id == madre_id) {
madre.metadatos.modificado_en = ahora_unix();
let _ = m.store.put_cuerpo(madre);
}
let _ = m.store.flush();
}
Msg::CiclarBackend => {
let siguiente = siguiente_backend(m.backend);
match build_client(&LlmConfig {
kind: siguiente,
model: if matches!(siguiente, BackendKind::Ollama) {
Some("llama3.1".into())
} else {
None
},
api_key: None,
endpoint: None,
}) {
Ok(c) => {
eprintln!(
"multilienzo_completo_demo :: backend cambiado a {} ({})",
etiqueta_backend(siguiente),
c.model_id()
);
m.chat = c;
m.backend = siguiente;
m.ultimo_error = None;
persistir_estado_ui(&m);
}
Err(e) => {
eprintln!("backend {siguiente:?} no disponible: {e}");
m.ultimo_error = Some(format!(
"{} no disponible — falta env key u Ollama",
etiqueta_backend(siguiente)
));
}
}
}
Msg::RegenerarSiguienteStale => {
if m.en_curso || m.cuerpos.is_empty() {
return m;
}
let madre_modificado = m.cuerpos[0].metadatos.modificado_en;
let madre_id = m.cuerpos[0].id;
let hija_stale_idx = m
.cuerpos
.iter()
.position(|c| c.es_derivado() && c.es_stale(madre_modificado));
let Some(idx) = hija_stale_idx else {
eprintln!(
"multilienzo_completo_demo :: no hay hijas stale — \
click 'tocar madre' antes para forzar staleness"
);
return m;
};
let hija_id = m.cuerpos[idx].id;
let tipo = match m
.store
.transformaciones_de(madre_id)
.ok()
.and_then(|ts| ts.into_iter().find(|t| t.hija == hija_id))
{
Some(t) => t.tipo,
None => {
eprintln!(
"multilienzo_completo_demo :: no se halló \
transformación registrada para la hija {idx}; \
nada que regenerar"
);
return m;
}
};
let Some(trabajo) = trabajo_de_tipo(&tipo) else {
eprintln!(
"multilienzo_completo_demo :: TipoTransformacion \
no regenerable automáticamente: {tipo:?}"
);
return m;
};
m.en_curso = true;
m.ultimo_error = None;
lanzar_trabajo(&m, handle, trabajo);
}
}
m
}
fn view(model: &Model) -> View<Msg> {
let cfg = MultilienzoConfig::default();
let paleta = PaletaHebras::default();
let palette = Palette::default();
let index: IndiceAtoms = model.graph.atoms().map(|a| (a.id, a)).collect();
// Focus mode: si `solo_madre`, recortamos a la primera columna y
// descartamos todas las cartas (no hay vecinos a la derecha).
let cuerpos_ref: Vec<&Cuerpo> = if model.solo_madre {
model.cuerpos.iter().take(1).collect()
} else {
model.cuerpos.iter().collect()
};
let cartas_ref: Vec<Option<&CartaHebras>> = if model.solo_madre {
Vec::new()
} else {
model.cartas.iter().map(Some).collect()
};
let interior = multilienzo_view_resaltado::<Msg>(
&cuerpos_ref,
&index,
&cartas_ref,
&cfg,
&paleta,
&palette,
&model.busqueda,
);
// Envoltorio scrollable: contenedor relative full-width que
// recorta su contenido; el interior va position=Absolute con
// left = -scroll_x. Sin clamp del lado del scroll (el update
// ya impide negativo); el clip resuelve el desbordamiento
// a la derecha visualmente.
let cuerpos_view = View::new(Style {
position: Position::Relative,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.clip(true)
.children(vec![View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(-model.scroll_x),
top: length(0.0_f32),
right: auto(),
bottom: auto(),
},
..Default::default()
})
.children(vec![interior])]);
let n_stale = contar_stale(model);
let toolbar = toolbar_view::<Msg>(
&palette,
model.en_curso,
&model.ultimo_error,
model.cuerpos.len(),
model.cartas.len(),
model.solo_madre,
&model.busqueda,
n_stale,
model.backend,
);
View::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![toolbar, cuerpos_view])
}
}
/// Ciclo fijo de backends para el botón "modelo: X". Empieza por mock
/// porque siempre está disponible — si el siguiente real falla por
/// falta de env, volver a mock recupera control.
const CICLO_BACKENDS: [BackendKind; 6] = [
BackendKind::Mock,
BackendKind::Gemini,
BackendKind::Anthropic,
BackendKind::DeepSeek,
BackendKind::Cohere,
BackendKind::Ollama,
];
fn siguiente_backend(actual: BackendKind) -> BackendKind {
let i = CICLO_BACKENDS
.iter()
.position(|b| *b == actual)
.unwrap_or(0);
CICLO_BACKENDS[(i + 1) % CICLO_BACKENDS.len()]
}
fn etiqueta_backend(b: BackendKind) -> &'static str {
match b {
BackendKind::Mock => "mock",
BackendKind::Gemini => "gemini",
BackendKind::Anthropic => "anthropic",
BackendKind::DeepSeek => "deepseek",
BackendKind::Cohere => "cohere",
BackendKind::Ollama => "ollama",
}
}
/// Cuenta cuántas hijas están stale respecto a la madre actual.
fn contar_stale(m: &Model) -> usize {
if m.cuerpos.is_empty() {
return 0;
}
let modif = m.cuerpos[0].metadatos.modificado_en;
m.cuerpos
.iter()
.filter(|c| c.es_derivado() && c.es_stale(modif))
.count()
}
/// Traduce un `TipoTransformacion` persistido en el trabajo concreto
/// que `lanzar_trabajo` sabe ejecutar. Devuelve `None` para tipos no
/// regenerables automáticamente (`Identidad`, `Reescribir` que
/// requiere prompt humano, `Custom` con Rhai).
fn trabajo_de_tipo(t: &TipoTransformacion) -> Option<TrabajoLlm> {
match t {
TipoTransformacion::Traducir { lengua_destino } => {
Some(TrabajoLlm::Traducir(lengua_destino.clone()))
}
TipoTransformacion::Tono { etiqueta } => {
Some(TrabajoLlm::Tono(etiqueta.clone()))
}
TipoTransformacion::Resumir { palabras_objetivo } => {
Some(TrabajoLlm::Resumir(*palabras_objetivo))
}
_ => None,
}
}
/// Vuelca el estado de UI del modelo al store. Lo llamamos en cada
/// cambio de `solo_madre`/`busqueda`/`scroll_x`. El cuello de botella
/// es despreciable (sled escribe + flush; <1 ms) y vale la pena para
/// no perder el estado de la sesión si el proceso muere.
fn persistir_estado_ui(m: &Model) {
let ui = EstadoUi {
solo_madre: m.solo_madre,
busqueda: m.busqueda.clone(),
scroll_x: m.scroll_x,
backend_llm: m.chat.model_id().to_string(),
};
if let Err(e) = m.store.put_estado_ui(&ui) {
eprintln!("persistir estado UI falló: {e}");
}
let _ = m.store.flush();
}
enum TrabajoLlm {
Traducir(String),
Tono(String),
Resumir(Option<u32>),
}
fn lanzar_trabajo(model: &Model, handle: &Handle<Msg>, trabajo: TrabajoLlm) {
let madre = model.cuerpos[0].clone();
let atoms_owned: Vec<NarrativeAtom> = model.graph.atoms().cloned().collect();
let chat = model.chat.clone();
let ahora = ahora_unix();
handle.spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => return Msg::LlmError(format!("runtime tokio: {e}")),
};
let idx: HashMap<Uuid, &NarrativeAtom> =
atoms_owned.iter().map(|a| (a.id, a)).collect();
let resultado = rt.block_on(async {
match trabajo {
TrabajoLlm::Traducir(lengua) => {
let ej = EjecutorTraducirLlm::from_arc(chat, lengua.clone());
let t = Transformacion::nueva(
madre.id,
Uuid::new_v4(),
TipoTransformacion::Traducir { lengua_destino: lengua },
"ui",
ahora,
);
let producto = ej.aplicar_con_atoms(&t, &madre, &idx, ahora).await?;
Ok::<_, pluma_transform::ErrorEjecutor>((t, producto))
}
TrabajoLlm::Tono(etiqueta) => {
let ej = EjecutorTonoLlm::from_arc(chat, etiqueta.clone());
let t = Transformacion::nueva(
madre.id,
Uuid::new_v4(),
TipoTransformacion::Tono { etiqueta },
"ui",
ahora,
);
let producto = ej.aplicar_con_atoms(&t, &madre, &idx, ahora).await?;
Ok((t, producto))
}
TrabajoLlm::Resumir(palabras) => {
let ej = EjecutorResumirLlm::from_arc(chat, palabras);
let t = Transformacion::nueva(
madre.id,
Uuid::new_v4(),
TipoTransformacion::Resumir { palabras_objetivo: palabras },
"ui",
ahora,
);
let producto = ej.aplicar_con_atoms(&t, &madre, &idx, ahora).await?;
Ok((t, producto))
}
}
});
match resultado {
Ok((transformacion, prod)) => Msg::LlmListo {
hija: prod.hija,
atoms_nuevos: prod.atoms_nuevos,
carta: prod.carta,
transformacion,
},
Err(e) => Msg::LlmError(format!("{e:?}")),
}
});
}
fn toolbar_view<Msg: Clone + 'static>(
palette: &Palette,
en_curso: bool,
ultimo_error: &Option<String>,
n_cuerpos: usize,
n_cartas: usize,
solo_madre: bool,
busqueda: &str,
n_stale: usize,
backend_actual: BackendKind,
) -> View<Msg>
where
Msg: From<MsgUi>,
{
let p_activo = ButtonPalette {
bg: palette.bg_panel,
bg_hover: palette.border_strong,
fg: palette.fg_text,
radius: 5.0,
};
let p_desactivado = ButtonPalette {
bg: Color::from_rgba8(60, 60, 60, 255),
bg_hover: Color::from_rgba8(60, 60, 60, 255),
fg: palette.fg_muted,
radius: 5.0,
};
let pal = if en_curso { &p_desactivado } else { &p_activo };
// Focus mode siempre activo, no afectado por en_curso.
let pal_focus = &p_activo;
let label_focus = if solo_madre { "todos" } else { "solo madre" };
let mut botones: Vec<View<Msg>> = vec![
button_view::<Msg>("→ qu", pal, MsgUi::Traducir("qu".into()).into()),
button_view::<Msg>("→ en", pal, MsgUi::Traducir("en".into()).into()),
button_view::<Msg>("tono formal", pal, MsgUi::Tono("formal".into()).into()),
button_view::<Msg>("resumir 30p", pal, MsgUi::Resumir(Some(30)).into()),
button_view::<Msg>(label_focus, pal_focus, MsgUi::ToggleSoloMadre.into()),
button_view::<Msg>("tocar madre", pal_focus, MsgUi::TocarMadre.into()),
button_view::<Msg>(
"editar madre",
pal_focus,
MsgUi::EditarPrimerParrafoMadre.into(),
),
];
// Botón cíclico de backend: muestra el actual, click pasa al siguiente.
botones.push(button_view::<Msg>(
format!("modelo: {}", etiqueta_backend(backend_actual)),
pal_focus,
MsgUi::CiclarBackend.into(),
));
// Botón de regeneración: activo solo si hay hijas stale.
let label_regen = if n_stale > 0 {
format!("regenerar stale ({n_stale})")
} else {
"regenerar stale (0)".to_string()
};
let pal_regen = if n_stale > 0 && !en_curso {
&p_activo
} else {
&p_desactivado
};
botones.push(button_view::<Msg>(
label_regen,
pal_regen,
MsgUi::RegenerarSiguienteStale.into(),
));
let busqueda_label = if busqueda.is_empty() {
"🔍 (escribe para buscar · Esc limpia)".to_string()
} else {
format!("🔍 \"{busqueda}\"")
};
let status_text = if en_curso {
format!("⏳ en curso… · {n_cuerpos} cuerpos, {n_cartas} cartas · {busqueda_label}")
} else if let Some(e) = ultimo_error {
format!("{}", &e[..e.len().min(80)])
} else {
format!(
"{n_cuerpos} cuerpos · {n_cartas} cartas · {busqueda_label}"
)
};
let status = View::new(Style {
size: Size {
width: length(450.0_f32),
height: length(30.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(6.0_f32),
bottom: length(6.0_f32),
},
..Default::default()
})
.text_aligned(status_text, 12.0, palette.fg_muted, Alignment::Start);
botones.push(status);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(46.0_f32),
},
gap: Size {
width: length(8.0_f32),
height: length(0.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(8.0_f32),
bottom: length(8.0_f32),
},
..Default::default()
})
.fill(palette.bg_panel)
.children(botones)
}
#[derive(Clone, Debug)]
enum MsgUi {
Traducir(String),
Tono(String),
Resumir(Option<u32>),
ToggleSoloMadre,
TocarMadre,
RegenerarSiguienteStale,
CiclarBackend,
EditarPrimerParrafoMadre,
}
impl From<MsgUi> for Msg {
fn from(u: MsgUi) -> Self {
match u {
MsgUi::Traducir(l) => Msg::PedirTraducir(l),
MsgUi::Tono(e) => Msg::PedirTono(e),
MsgUi::Resumir(p) => Msg::PedirResumir(p),
MsgUi::ToggleSoloMadre => Msg::ToggleSoloMadre,
MsgUi::TocarMadre => Msg::TocarMadre,
MsgUi::RegenerarSiguienteStale => Msg::RegenerarSiguienteStale,
MsgUi::CiclarBackend => Msg::CiclarBackend,
MsgUi::EditarPrimerParrafoMadre => Msg::EditarPrimerParrafoMadre,
}
}
}
fn cargar_de_store(
store: Arc<PlumaStore>,
chat: Arc<dyn ChatClient>,
backend: BackendKind,
) -> Model {
let mut graph = NarrativeGraph::new();
for atom in store.iter_atoms() {
graph.insert(atom.expect("leer atom"));
}
let mut cuerpos: Vec<Cuerpo> = store
.iter_cuerpos()
.map(|c| c.expect("leer cuerpo"))
.collect();
// Original al frente; el resto en orden de creación (modificado_en).
cuerpos.sort_by_key(|c| {
let prioridad = if matches!(c.metadatos.intencion, Intencion::Original) {
0
} else {
1
};
(prioridad, c.metadatos.creado_en)
});
let mut cartas: Vec<CartaHebras> = Vec::new();
for w in cuerpos.windows(2) {
if let Some(c) = store
.get_carta_bidir(w[0].id, w[1].id)
.expect("leer carta")
{
cartas.push(c);
}
}
Model {
cuerpos,
graph,
cartas,
chat,
backend,
store,
en_curso: false,
ultimo_error: None,
scroll_x: 0.0,
solo_madre: false,
busqueda: String::new(),
}
}
fn sembrar_madre_base(
store: Arc<PlumaStore>,
chat: Arc<dyn ChatClient>,
backend: BackendKind,
) -> Model {
let mut graph = NarrativeGraph::new();
let mut es = Cuerpo::nuevo("es", "español (original)", Intencion::Original, 100);
for t in [
"El cóndor cruzó el cielo del valle al amanecer.",
"Las llamas pastaban entre los pastizales del altiplano.",
"Una mujer joven tejía un telar bajo el alero.",
] {
let atom = NarrativeAtom::new(t, "es");
es.agregar(atom.id, 101);
graph.insert(atom);
}
// Persistir la madre base.
for atom in graph.atoms() {
store.put_atom(atom).expect("persistir atom");
}
store.put_cuerpo(&es).expect("persistir cuerpo");
store.flush().expect("flush");
Model {
cuerpos: vec![es],
graph,
cartas: Vec::new(),
chat,
backend,
store,
en_curso: false,
ultimo_error: None,
scroll_x: 0.0,
solo_madre: false,
busqueda: String::new(),
}
}
fn cache_dir() -> PathBuf {
let base = std::env::var("XDG_CACHE_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| std::env::var("HOME").ok().map(|h| PathBuf::from(h).join(".cache")))
.unwrap_or_else(|| PathBuf::from("/tmp"));
base.join("gioser").join("pluma-multilienzo-completo")
}
/// Construye el chat inicial y reporta el backend elegido. Si no hay
/// keys, usa un mock pre-poblado con traducciones predecibles.
fn construir_chat() -> (Arc<dyn ChatClient>, BackendKind) {
let usa_mock = std::env::var("ANTHROPIC_API_KEY").is_err()
&& std::env::var("GEMINI_API_KEY").is_err()
&& std::env::var("GOOGLE_API_KEY").is_err()
&& std::env::var("DEEPSEEK_API_KEY").is_err()
&& std::env::var("COHERE_API_KEY").is_err()
&& std::env::var("PLUMA_LLM_BACKEND")
.map(|s| s.to_lowercase() != "ollama")
.unwrap_or(true);
if usa_mock {
let mut mock = pluma_llm_mock::MockChatClient::default()
.con_model_id("mock-completo");
for (k, v) in [
("cóndor cruzó", "Kuntur wayqu hanaqpachata pacha paqarinpi pasarqa."),
("Las llamas pastaban", "Llamaqakuna qulla suyup q'achupinpi mikhusharqaku."),
("mujer joven tejía", "Sipas warmiq away wasiq hawanpi awayta ruwasharqa."),
] {
mock = mock.con_respuesta(k, v);
}
return (Arc::new(mock), BackendKind::Mock);
}
let backend = std::env::var("PLUMA_LLM_BACKEND")
.ok()
.and_then(|s| BackendKind::parse(&s))
.unwrap_or_else(|| {
// mismo orden que from_env interno.
if std::env::var("ANTHROPIC_API_KEY").is_ok() {
BackendKind::Anthropic
} else if std::env::var("GEMINI_API_KEY").is_ok()
|| std::env::var("GOOGLE_API_KEY").is_ok()
{
BackendKind::Gemini
} else if std::env::var("DEEPSEEK_API_KEY").is_ok() {
BackendKind::DeepSeek
} else {
BackendKind::Cohere
}
});
(llm_from_env().expect("from_env"), backend)
}
fn ahora_unix() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn main() {
let _ = Path::new("/"); // silenciar import si no se usa
llimphi_ui::run::<Demo>();
}
@@ -0,0 +1,275 @@
//! Demo del multilienzo — flujo end-to-end real:
//!
//! 1. Cuerpo madre `es` con párrafos sintéticos.
//! 2. Cuerpo `qu` derivado por `EjecutorTraducirTabla` (Derivada 1↔1).
//! 3. Cuerpo `en` (resumen, 2 párrafos manuales).
//! 4. Hebras `es↔qu`: producto natural de la transformación (Derivada).
//! 5. Hebras `qu↔en`: calculadas por `alinear_por_embeddings` con
//! MockProvider determinista (umbral muy bajo para mostrar que el
//! pipeline funciona aun con vectores random — fuerzas variadas
//! generan saturación visible).
//!
//! Una hebra es↔qu se marca stale a mano para mostrar el efecto visual.
//!
//! Corré con:
//! cargo run -p pluma-editor-llimphi --example multilienzo_demo --release
use std::collections::HashMap;
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, FlexDirection, Size, Style};
use llimphi_ui::{App, Handle, View};
use pluma_align::CartaHebras;
use pluma_align_embeddings::{alinear_por_embeddings, ModoAlineacion, ParamsAlineacion};
use pluma_core::NarrativeAtom;
use pluma_cuerpo::{Cuerpo, Intencion};
use pluma_editor_llimphi::multilienzo::{
multilienzo_view, IndiceAtoms, MultilienzoConfig, PaletaHebras,
};
use pluma_editor_llimphi::Palette;
use pluma_transform::{
Ejecutor, TipoTransformacion, Transformacion,
};
use pluma_transform_tabla::EjecutorTraducirTabla;
use rimay_verbo_core::Provider;
use rimay_verbo_daemon::DaemonClient;
use rimay_verbo_mock::MockProvider;
use uuid::Uuid;
#[derive(Clone, Debug)]
enum Msg {}
struct Model {
cuerpos: Vec<Cuerpo>,
atoms: Vec<NarrativeAtom>,
cartas: Vec<CartaHebras>,
}
struct Demo;
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"pluma · multilienzo demo (es → qu derivado · qu ↔ en embeddings)"
}
fn initial_size() -> (u32, u32) {
(1280, 680)
}
fn init(_: &Handle<Msg>) -> Model {
// Runtime tokio compartido para todos los `await` (aplicar de la
// transformación + alineación por embeddings).
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime tokio");
// -- 1. Cuerpo madre `es` ----------------------------------------------
let textos_es = [
"El cóndor cruzó el cielo del valle al amanecer.",
"Las llamas pastaban entre los pastizales del altiplano.",
"Una mujer joven tejía un telar bajo el alero.",
"El río Apurímac descendía rugiente por las rocas.",
"Al caer la tarde, las nubes cubrieron el sol.",
];
let atoms_es: Vec<NarrativeAtom> = textos_es
.iter()
.map(|t| NarrativeAtom::new(*t, "es"))
.collect();
let mut es = Cuerpo::nuevo("es", "español (original)", Intencion::Original, 100);
for a in &atoms_es {
es.agregar(a.id, 101);
}
// -- 2. Cuerpo `qu` derivado por tabla --------------------------------
let traducciones = [
"Kuntur wayqu hanaqpachata pacha paqarinpi pasarqa.",
"Llamaqakuna qulla suyup q'achupinpi mikhusharqaku.",
"Sipas warmiq away wasiq hawanpi awayta ruwasharqa.",
"Apurímac mayu rumikuna ukhumanta qhaparispa uraykurqa.",
"Inti yaykuy pachapi puyukuna intita pakarqaku.",
];
let mut tabla: HashMap<Uuid, String> = HashMap::new();
for (atom, tr) in atoms_es.iter().zip(traducciones.iter()) {
tabla.insert(atom.id, (*tr).to_string());
}
let ejecutor_traducir = EjecutorTraducirTabla::new(tabla, "qu");
let t_qu = Transformacion::nueva(
es.id,
Uuid::new_v4(),
TipoTransformacion::Traducir {
lengua_destino: "qu".into(),
},
"ana",
200,
);
let prod = rt
.block_on(ejecutor_traducir.aplicar(&t_qu, &es, 200))
.expect("traducción por tabla debería tener éxito");
let qu = prod.hija;
let atoms_qu = prod.atoms_nuevos;
let mut carta_es_qu = prod.carta;
// Marcar a mano la primera hebra como stale: la madre se editó después
// de la regeneración (simulación del estado típico tras edición).
if let Some(h) = carta_es_qu.hebras.get_mut(0) {
h.fresco = false;
}
// -- 3. Cuerpo `en` (resumen, 2 párrafos manuales) --------------------
let textos_en = [
"Dawn over the highlands — condor, llamas, weaver.",
"By dusk, the Apurímac roared and the clouds hid the sun.",
];
let atoms_en: Vec<NarrativeAtom> = textos_en
.iter()
.map(|t| NarrativeAtom::new(*t, "en"))
.collect();
let mut en = Cuerpo::nuevo(
"en",
"english (résumé)",
Intencion::Resumen {
palabras_objetivo: Some(40),
},
200,
);
for a in &atoms_en {
en.agregar(a.id, 201);
}
// -- 4. Hebras qu↔en por embeddings (MockProvider determinista) -------
// Indice de atoms para que alinear_por_embeddings resuelva los textos.
let mut atoms_all: Vec<NarrativeAtom> = atoms_es.clone();
atoms_all.extend(atoms_qu.iter().cloned());
atoms_all.extend(atoms_en.iter().cloned());
let idx: HashMap<Uuid, &NarrativeAtom> =
atoms_all.iter().map(|a| (a.id, a)).collect();
// Provider: si hay un `verbo-daemon` corriendo, nos conectamos —
// así las hebras llevan similitudes semánticas reales. Sin daemon,
// fallback al MockProvider determinista (las fuerzas serán
// dispersas; sirve para ver la geometría del pintado).
let socket = socket_verbo_default();
let (provider_label, carta_qu_en) = rt.block_on(async {
match conectar_daemon_si_existe(&socket).await {
Some(daemon) => {
eprintln!(
"multilienzo_demo :: usando verbo-daemon en {} ({})",
socket.display(),
daemon.model_id()
);
// Con modelo real, umbral 0.5 filtra ruido sin perder
// las correspondencias semánticas reales.
let params = ParamsAlineacion {
umbral_minimo: 0.5,
modo: ModoAlineacion::MejorParaCadaA,
};
let carta = alinear_por_embeddings(&qu, &en, &idx, &daemon, &params, 200)
.await
.expect("alineación por embeddings (daemon)");
("verbo-daemon".to_string(), carta)
}
None => {
let mock = MockProvider::default();
eprintln!(
"multilienzo_demo :: sin verbo-daemon — usando MockProvider \
(las fuerzas Embeddings saldrán dispersas y geométricas, no semánticas; \
para hebras reales: `verbo-daemon --provider fastembed &`)"
);
// Umbral negativo: con mock random todas las "mejores"
// pasan — vemos la geometría aunque la fuerza sea ruido.
let params = ParamsAlineacion {
umbral_minimo: -1.0,
modo: ModoAlineacion::MejorParaCadaA,
};
let carta = alinear_por_embeddings(&qu, &en, &idx, &mock, &params, 200)
.await
.expect("alineación por embeddings (mock)");
("mock".to_string(), carta)
}
}
});
eprintln!("multilienzo_demo :: hebras qu↔en con provider={provider_label}");
Model {
cuerpos: vec![es, qu, en],
atoms: atoms_all,
cartas: vec![carta_es_qu, carta_qu_en],
}
}
fn update(model: Model, _msg: Msg, _: &Handle<Msg>) -> Model {
model
}
fn view(model: &Model) -> View<Msg> {
let cfg = MultilienzoConfig::default();
let paleta = PaletaHebras::default();
let palette = Palette::default();
let index: IndiceAtoms = model.atoms.iter().map(|a| (a.id, a)).collect();
let cuerpos_ref: Vec<&Cuerpo> = model.cuerpos.iter().collect();
let cartas_ref: Vec<Option<&CartaHebras>> = model.cartas.iter().map(Some).collect();
let interior = multilienzo_view::<Msg>(
&cuerpos_ref,
&index,
&cartas_ref,
&cfg,
&paleta,
&palette,
);
View::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![interior])
}
}
/// Ruta default del socket del `verbo-daemon`, alineada con la convención
/// que usa el bin (`rimay-verbo-daemon-bin`): `$XDG_RUNTIME_DIR/verbo.sock`
/// con fallback a `/tmp/verbo-{uid}.sock`.
fn socket_verbo_default() -> std::path::PathBuf {
if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
return std::path::PathBuf::from(xdg).join("verbo.sock");
}
let uid = std::fs::read_to_string("/proc/self/loginuid")
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.filter(|&u| u != u32::MAX)
.unwrap_or(1000);
std::path::PathBuf::from(format!("/tmp/verbo-{uid}.sock"))
}
/// Intenta conectar al daemon en `path`. Devuelve `None` si el socket no
/// existe o si la conexión falla — el caller decide el fallback. No
/// imprime; deja la decisión de logging al caller.
async fn conectar_daemon_si_existe(path: &std::path::Path) -> Option<DaemonClient> {
if !path.exists() {
return None;
}
match DaemonClient::connect(path).await {
Ok(c) => Some(c),
Err(e) => {
eprintln!(
"multilienzo_demo :: socket {} existe pero connect falló: {e}",
path.display()
);
None
}
}
}
fn main() {
llimphi_ui::run::<Demo>();
}
@@ -0,0 +1,398 @@
//! Demo del multilienzo con transformaciones disparadas desde la UI.
//!
//! Cuerpo `es` cargado al inicio. Toolbar arriba con cuatro botones:
//! `→ qu`, `→ en`, `tono formal`, `resumir`. Click → spawn thread que
//! corre el ejecutor LLM transparente → al volver, dispatch del
//! resultado al `update` → aparece una columna nueva con hebras
//! Derivadas.
//!
//! Mientras una transformación está en curso, los botones quedan
//! deshabilitados (un solo trabajo a la vez — evita que clicks
//! repetidos disparen N requests en paralelo).
//!
//! ```bash
//! # Con LLM real:
//! GEMINI_API_KEY=... PLUMA_LLM_BACKEND=gemini \
//! cargo run -p pluma-editor-llimphi --example multilienzo_dinamico_demo --release
//!
//! # Sin keys: mock pre-poblado con respuestas predecibles.
//! cargo run -p pluma-editor-llimphi --example multilienzo_dinamico_demo --release
//! ```
use std::collections::HashMap;
use std::sync::Arc;
use llimphi_ui::llimphi_layout::taffy::prelude::{
length, percent, FlexDirection, Rect, Size, Style,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, View};
use llimphi_widget_button::{button_view, ButtonPalette};
use pluma_align::CartaHebras;
use pluma_core::NarrativeAtom;
use pluma_cuerpo::{Cuerpo, Intencion};
use pluma_editor_llimphi::multilienzo::{
multilienzo_view, IndiceAtoms, MultilienzoConfig, PaletaHebras,
};
use pluma_editor_llimphi::Palette;
use pluma_graph::NarrativeGraph;
use pluma_llm::from_env as llm_from_env;
use pluma_llm_core::ChatClient;
use pluma_transform::{TipoTransformacion, Transformacion};
use pluma_transform_llm::{
EjecutorResumirLlm, EjecutorTonoLlm, EjecutorTraducirLlm,
};
use uuid::Uuid;
#[derive(Clone, Debug)]
enum Msg {
PedirTraducir(String),
PedirTono(String),
PedirResumir(Option<u32>),
LlmListo {
hija: Cuerpo,
atoms_nuevos: Vec<NarrativeAtom>,
carta: CartaHebras,
},
LlmError(String),
}
struct Model {
cuerpos: Vec<Cuerpo>,
graph: NarrativeGraph,
cartas: Vec<CartaHebras>,
chat: Arc<dyn ChatClient>,
en_curso: bool,
ultimo_error: Option<String>,
}
struct Demo;
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"pluma · multilienzo dinámico (botones LLM)"
}
fn initial_size() -> (u32, u32) {
(1400, 720)
}
fn init(_: &Handle<Msg>) -> Model {
let mut graph = NarrativeGraph::new();
let mut es = Cuerpo::nuevo("es", "español (original)", Intencion::Original, 100);
for t in [
"El cóndor cruzó el cielo del valle al amanecer.",
"Las llamas pastaban entre los pastizales del altiplano.",
"Una mujer joven tejía un telar bajo el alero.",
] {
let atom = NarrativeAtom::new(t, "es");
es.agregar(atom.id, 101);
graph.insert(atom);
}
Model {
cuerpos: vec![es],
graph,
cartas: Vec::new(),
chat: construir_chat(),
en_curso: false,
ultimo_error: None,
}
}
fn update(model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
let mut m = model;
match msg {
Msg::PedirTraducir(lengua) => {
if m.en_curso || m.cuerpos.is_empty() {
return m;
}
m.en_curso = true;
m.ultimo_error = None;
lanzar_trabajo(&mut m, handle, TrabajoLlm::Traducir(lengua));
}
Msg::PedirTono(etiqueta) => {
if m.en_curso || m.cuerpos.is_empty() {
return m;
}
m.en_curso = true;
m.ultimo_error = None;
lanzar_trabajo(&mut m, handle, TrabajoLlm::Tono(etiqueta));
}
Msg::PedirResumir(palabras) => {
if m.en_curso || m.cuerpos.is_empty() {
return m;
}
m.en_curso = true;
m.ultimo_error = None;
lanzar_trabajo(&mut m, handle, TrabajoLlm::Resumir(palabras));
}
Msg::LlmListo { hija, atoms_nuevos, carta } => {
for atom in atoms_nuevos {
m.graph.insert(atom);
}
m.cuerpos.push(hija);
m.cartas.push(carta);
m.en_curso = false;
}
Msg::LlmError(s) => {
eprintln!("multilienzo_dinamico_demo :: error LLM: {s}");
m.ultimo_error = Some(s);
m.en_curso = false;
}
}
m
}
fn view(model: &Model) -> View<Msg> {
let cfg = MultilienzoConfig::default();
let paleta_hebras = PaletaHebras::default();
let palette = Palette::default();
let index: IndiceAtoms = model.graph.atoms().map(|a| (a.id, a)).collect();
let cuerpos_ref: Vec<&Cuerpo> = model.cuerpos.iter().collect();
let cartas_ref: Vec<Option<&CartaHebras>> = model.cartas.iter().map(Some).collect();
let cuerpos_view = multilienzo_view::<Msg>(
&cuerpos_ref,
&index,
&cartas_ref,
&cfg,
&paleta_hebras,
&palette,
);
let toolbar = toolbar_view::<Msg>(&palette, model.en_curso, &model.ultimo_error);
View::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![toolbar, cuerpos_view])
}
}
enum TrabajoLlm {
Traducir(String),
Tono(String),
Resumir(Option<u32>),
}
/// Encola el trabajo LLM en un thread aparte. Capturamos un snapshot
/// owned de la madre + atoms; el handle dispatcha el resultado al
/// volver al hilo de UI.
fn lanzar_trabajo(model: &mut Model, handle: &Handle<Msg>, trabajo: TrabajoLlm) {
// Madre = primer cuerpo (la "raíz" del haz en este demo).
let madre = model.cuerpos[0].clone();
// Snapshot owned de los atoms para sobrevivir al thread.
let atoms_owned: Vec<NarrativeAtom> = model.graph.atoms().cloned().collect();
let chat = model.chat.clone();
let h = handle.clone();
let ahora = ahora_unix();
handle.spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => return Msg::LlmError(format!("runtime tokio: {e}")),
};
let idx: HashMap<Uuid, &NarrativeAtom> =
atoms_owned.iter().map(|a| (a.id, a)).collect();
let resultado = rt.block_on(async {
match trabajo {
TrabajoLlm::Traducir(lengua) => {
let ej = EjecutorTraducirLlm::from_arc(chat, lengua.clone());
let t = Transformacion::nueva(
madre.id,
Uuid::new_v4(),
TipoTransformacion::Traducir { lengua_destino: lengua },
"ui",
ahora,
);
ej.aplicar_con_atoms(&t, &madre, &idx, ahora).await
}
TrabajoLlm::Tono(etiqueta) => {
let ej = EjecutorTonoLlm::from_arc(chat, etiqueta.clone());
let t = Transformacion::nueva(
madre.id,
Uuid::new_v4(),
TipoTransformacion::Tono { etiqueta },
"ui",
ahora,
);
ej.aplicar_con_atoms(&t, &madre, &idx, ahora).await
}
TrabajoLlm::Resumir(palabras) => {
let ej = EjecutorResumirLlm::from_arc(chat, palabras);
let t = Transformacion::nueva(
madre.id,
Uuid::new_v4(),
TipoTransformacion::Resumir { palabras_objetivo: palabras },
"ui",
ahora,
);
ej.aplicar_con_atoms(&t, &madre, &idx, ahora).await
}
}
});
let _ = h; // handle ya no se usa más; el Msg de retorno lo entrega el runtime.
match resultado {
Ok(prod) => Msg::LlmListo {
hija: prod.hija,
atoms_nuevos: prod.atoms_nuevos,
carta: prod.carta,
},
Err(e) => Msg::LlmError(format!("{e:?}")),
}
});
}
fn toolbar_view<Msg: Clone + 'static>(
palette: &Palette,
en_curso: bool,
ultimo_error: &Option<String>,
) -> View<Msg>
where
Msg: From<MsgUi>,
{
let p_activo = ButtonPalette {
bg: palette.bg_panel,
bg_hover: palette.border_strong,
fg: palette.fg_text,
radius: 5.0,
};
let p_desactivado = ButtonPalette {
bg: Color::from_rgba8(60, 60, 60, 255),
bg_hover: Color::from_rgba8(60, 60, 60, 255),
fg: palette.fg_muted,
radius: 5.0,
};
let pal = if en_curso { &p_desactivado } else { &p_activo };
let mut botones: Vec<View<Msg>> = Vec::new();
let mk = |label: &str, m: MsgUi| {
button_view::<Msg>(label, pal, m.into())
};
botones.push(env(mk("→ qu", MsgUi::Traducir("qu".into()))));
botones.push(env(mk("→ en", MsgUi::Traducir("en".into()))));
botones.push(env(mk("tono formal", MsgUi::Tono("formal".into()))));
botones.push(env(mk("resumir 30p", MsgUi::Resumir(Some(30)))));
let status_text = if en_curso {
"⏳ en curso…".to_string()
} else if let Some(e) = ultimo_error {
format!("{}", &e[..e.len().min(80)])
} else {
"listo — click para derivar un cuerpo nuevo".to_string()
};
let status = View::new(Style {
size: Size {
width: length(360.0_f32),
height: length(30.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(6.0_f32),
bottom: length(6.0_f32),
},
..Default::default()
})
.text_aligned(status_text, 12.0, palette.fg_muted, Alignment::Start);
botones.push(status);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(46.0_f32),
},
gap: Size {
width: length(8.0_f32),
height: length(0.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(8.0_f32),
bottom: length(8.0_f32),
},
..Default::default()
})
.fill(palette.bg_panel)
.children(botones)
}
/// Helper para que el genérico funcione: el toolbar es genérico sobre
/// `Msg: From<MsgUi>`, así reusable si algún día se monta dentro de una
/// app más grande. En este demo, `MsgUi == app::Msg`.
fn env<T>(v: T) -> T {
v
}
#[derive(Clone, Debug)]
enum MsgUi {
Traducir(String),
Tono(String),
Resumir(Option<u32>),
}
impl From<MsgUi> for Msg {
fn from(u: MsgUi) -> Self {
match u {
MsgUi::Traducir(l) => Msg::PedirTraducir(l),
MsgUi::Tono(e) => Msg::PedirTono(e),
MsgUi::Resumir(p) => Msg::PedirResumir(p),
}
}
}
fn construir_chat() -> Arc<dyn ChatClient> {
let usa_mock = std::env::var("ANTHROPIC_API_KEY").is_err()
&& std::env::var("GEMINI_API_KEY").is_err()
&& std::env::var("GOOGLE_API_KEY").is_err()
&& std::env::var("DEEPSEEK_API_KEY").is_err()
&& std::env::var("PLUMA_LLM_BACKEND")
.map(|s| s.to_lowercase() != "ollama")
.unwrap_or(true);
if usa_mock {
let mut mock = pluma_llm_mock::MockChatClient::default()
.con_model_id("mock-demo");
// Mock pre-poblado por substring → respuesta. Suficiente para
// mostrar el flujo aun sin red.
for (k, v) in [
("cóndor cruzó", "Kuntur wayqu hanaqpachata pacha paqarinpi pasarqa."),
("Las llamas pastaban", "Llamaqakuna qulla suyup q'achupinpi mikhusharqaku."),
("mujer joven tejía", "Sipas warmiq away wasiq hawanpi awayta ruwasharqa."),
] {
mock = mock.con_respuesta(k, v);
}
return Arc::new(mock);
}
llm_from_env().expect("from_env")
}
fn ahora_unix() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn main() {
llimphi_ui::run::<Demo>();
}
@@ -0,0 +1,328 @@
//! Demo end-to-end del multilienzo con LLM real + persistencia en grafo.
//!
//! Cuatro piezas en línea:
//!
//! 1. **`pluma_llm::from_env`** elige backend transparente —
//! Anthropic/Gemini/DeepSeek/Ollama/Mock según `PLUMA_LLM_BACKEND` o
//! la primera env key disponible; fallback a Mock para que el demo
//! arranque sin nada configurado.
//! 2. **`EjecutorTraducirLlm::from_arc`** + Anthropic system-cached
//! genera la traducción es→qu párrafo por párrafo.
//! 3. **`pluma_graph_transform::persistir_producto`** mete los atoms
//! nuevos en el `NarrativeGraph`. Sin esto, la hija sería un cuerpo
//! con Uuids huérfanos.
//! 4. **`pluma_align_embeddings::alinear_por_embeddings`** + `verbo-daemon`
//! si está corriendo, sino MockProvider — calcula hebras qu↔en.
//!
//! Cómo correrlo:
//!
//! ```bash
//! # Mock (sin red, sin keys, eco predecible — siempre funciona):
//! cargo run -p pluma-editor-llimphi --example multilienzo_llm_demo --release
//!
//! # Anthropic real (necesita ANTHROPIC_API_KEY):
//! ANTHROPIC_API_KEY=sk-ant-... \
//! cargo run -p pluma-editor-llimphi --example multilienzo_llm_demo --release
//!
//! # Gemini:
//! GEMINI_API_KEY=... PLUMA_LLM_BACKEND=gemini \
//! cargo run -p pluma-editor-llimphi --example multilienzo_llm_demo --release
//!
//! # DeepSeek:
//! DEEPSEEK_API_KEY=... PLUMA_LLM_BACKEND=deepseek \
//! cargo run -p pluma-editor-llimphi --example multilienzo_llm_demo --release
//!
//! # Ollama 100% local (necesita `ollama serve` y `ollama pull llama3.1`):
//! PLUMA_LLM_BACKEND=ollama PLUMA_LLM_MODEL=llama3.1 \
//! cargo run -p pluma-editor-llimphi --example multilienzo_llm_demo --release
//!
//! # Embeddings reales en lugar de mock:
//! verbo-daemon --provider fastembed &
//! # ...y volver a lanzar el demo
//! ```
use std::path::{Path, PathBuf};
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, FlexDirection, Size, Style};
use llimphi_ui::{App, Handle, View};
use pluma_align::CartaHebras;
use pluma_align_embeddings::{alinear_por_embeddings, ModoAlineacion, ParamsAlineacion};
use pluma_core::NarrativeAtom;
use pluma_cuerpo::{Cuerpo, Intencion};
use pluma_editor_llimphi::multilienzo::{
multilienzo_view, IndiceAtoms, MultilienzoConfig, PaletaHebras,
};
use pluma_editor_llimphi::Palette;
use pluma_graph::NarrativeGraph;
use pluma_graph_transform::{indice_atoms, persistir_producto};
use pluma_llm::{from_env as llm_from_env, BackendKind, LlmConfig};
use pluma_llm_core::ChatClient;
use pluma_transform::{TipoTransformacion, Transformacion};
use pluma_transform_llm::EjecutorTraducirLlm;
use rimay_verbo_core::Provider;
use rimay_verbo_daemon::DaemonClient;
use rimay_verbo_mock::MockProvider;
use uuid::Uuid;
#[derive(Clone, Debug)]
enum Msg {}
struct Model {
cuerpos: Vec<Cuerpo>,
graph: NarrativeGraph,
cartas: Vec<CartaHebras>,
label_llm: String,
label_provider: String,
}
struct Demo;
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"pluma · multilienzo llm demo (es → qu via LLM real)"
}
fn initial_size() -> (u32, u32) {
(1280, 680)
}
fn init(_: &Handle<Msg>) -> Model {
// -- 0. Runtime async compartido ---------------------------------------
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime tokio");
// -- 1. Madre es + grafo -----------------------------------------------
let textos_es = [
"El cóndor cruzó el cielo del valle al amanecer.",
"Las llamas pastaban entre los pastizales del altiplano.",
"Una mujer joven tejía un telar bajo el alero.",
"El río Apurímac descendía rugiente por las rocas.",
"Al caer la tarde, las nubes cubrieron el sol.",
];
let mut graph = NarrativeGraph::new();
let mut es = Cuerpo::nuevo("es", "español (original)", Intencion::Original, 100);
for t in textos_es {
let atom = NarrativeAtom::new(t, "es");
es.agregar(atom.id, 101);
graph.insert(atom);
}
// -- 2. Chat client transparente — quien sea el backend de turno ------
// `from_env` cae a Mock si nada está configurado. Para mock con
// respuestas razonables, sustituyo por un mock pre-poblado con
// traducciones predecibles. Eso permite ver el demo "como si"
// hubiera traducido sin red.
let (chat, label_llm) = construir_chat_para_demo(&textos_es);
eprintln!("multilienzo_llm_demo :: LLM = {label_llm}");
// -- 3. Traducir es → qu vía LLM, persistir en grafo -----------------
let ejecutor = EjecutorTraducirLlm::from_arc(chat, "qu");
let t_qu = Transformacion::nueva(
es.id,
Uuid::new_v4(),
TipoTransformacion::Traducir { lengua_destino: "qu".into() },
"demo",
200,
);
let (qu, mut carta_es_qu) = rt.block_on(async {
let idx = indice_atoms(&graph);
let producto = ejecutor
.aplicar_con_atoms(&t_qu, &es, &idx, 200)
.await
.expect("traducción LLM");
drop(idx);
persistir_producto(&mut graph, producto)
});
// Una hebra marcada stale para mostrar el efecto visual.
if let Some(h) = carta_es_qu.hebras.get_mut(0) {
h.fresco = false;
}
// -- 4. Cuerpo en (resumen, 2 párrafos manuales) ---------------------
let textos_en = [
"Dawn over the highlands — condor, llamas, weaver.",
"By dusk, the Apurímac roared and the clouds hid the sun.",
];
let mut en = Cuerpo::nuevo(
"en",
"english (résumé)",
Intencion::Resumen { palabras_objetivo: Some(40) },
200,
);
for t in textos_en {
let atom = NarrativeAtom::new(t, "en");
en.agregar(atom.id, 201);
graph.insert(atom);
}
// -- 5. Hebras qu↔en por embeddings — daemon si existe, mock si no ---
let socket = socket_verbo_default();
let (carta_qu_en, label_provider) = rt.block_on(async {
let idx = indice_atoms(&graph);
match conectar_daemon_si_existe(&socket).await {
Some(daemon) => {
let label = format!(
"verbo-daemon @ {} ({})",
socket.display(),
daemon.model_id()
);
let params = ParamsAlineacion {
umbral_minimo: 0.5,
modo: ModoAlineacion::MejorParaCadaA,
};
let carta = alinear_por_embeddings(&qu, &en, &idx, &daemon, &params, 200)
.await
.expect("embeddings (daemon)");
(carta, label)
}
None => {
let mock = MockProvider::default();
let params = ParamsAlineacion {
umbral_minimo: -1.0,
modo: ModoAlineacion::MejorParaCadaA,
};
let carta = alinear_por_embeddings(&qu, &en, &idx, &mock, &params, 200)
.await
.expect("embeddings (mock)");
(carta, "MockProvider".to_string())
}
}
});
eprintln!("multilienzo_llm_demo :: embeddings = {label_provider}");
Model {
cuerpos: vec![es, qu, en],
graph,
cartas: vec![carta_es_qu, carta_qu_en],
label_llm,
label_provider,
}
}
fn update(model: Model, _msg: Msg, _: &Handle<Msg>) -> Model {
model
}
fn view(model: &Model) -> View<Msg> {
let cfg = MultilienzoConfig::default();
let paleta = PaletaHebras::default();
let palette = Palette::default();
let index: IndiceAtoms = model.graph.atoms().map(|a| (a.id, a)).collect();
let cuerpos_ref: Vec<&Cuerpo> = model.cuerpos.iter().collect();
let cartas_ref: Vec<Option<&CartaHebras>> = model.cartas.iter().map(Some).collect();
let interior = multilienzo_view::<Msg>(
&cuerpos_ref,
&index,
&cartas_ref,
&cfg,
&paleta,
&palette,
);
let _ = &model.label_llm;
let _ = &model.label_provider;
View::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![interior])
}
}
/// Construye el chat client para el demo. Si `pluma_llm::from_env` cae a
/// Mock (porque no hay credenciales), lo sustituimos por uno pre-poblado
/// con traducciones predecibles — así el demo se ve "como si tradujera"
/// aun sin red. Cuando hay credenciales reales, devuelve el cliente que
/// el factory produjo y la IA traduce de verdad.
fn construir_chat_para_demo(
textos: &[&'static str],
) -> (std::sync::Arc<dyn ChatClient>, String) {
// Detectamos si vamos a caer a Mock antes de construir, mirando las
// mismas env vars que el factory.
let usa_mock = std::env::var("PLUMA_LLM_BACKEND")
.ok()
.and_then(|s| BackendKind::parse(&s))
.map(|k| k == BackendKind::Mock)
.unwrap_or_else(|| {
std::env::var("ANTHROPIC_API_KEY").is_err()
&& std::env::var("GEMINI_API_KEY").is_err()
&& std::env::var("GOOGLE_API_KEY").is_err()
&& std::env::var("DEEPSEEK_API_KEY").is_err()
&& std::env::var("PLUMA_LLM_BACKEND")
.map(|s| BackendKind::parse(&s) != Some(BackendKind::Ollama))
.unwrap_or(true)
});
if usa_mock {
// Mock pre-poblado: cada texto en español tiene su traducción al qu
// hardcoded en una tabla. Demuestra el flujo aún sin LLM real.
let traducciones = [
("cóndor cruzó", "Kuntur wayqu hanaqpachata pacha paqarinpi pasarqa."),
("Las llamas pastaban", "Llamaqakuna qulla suyup q'achupinpi mikhusharqaku."),
("mujer joven tejía", "Sipas warmiq away wasiq hawanpi awayta ruwasharqa."),
("Apurímac", "Apurímac mayu rumikuna ukhumanta qhaparispa uraykurqa."),
("nubes cubrieron", "Inti yaykuy pachapi puyukuna intita pakarqaku."),
];
let mut mock = pluma_llm_mock::MockChatClient::default()
.con_model_id("mock-demo");
for (k, v) in traducciones {
mock = mock.con_respuesta(k, v);
}
let _ = textos;
return (std::sync::Arc::new(mock), "mock-demo (sin red)".to_string());
}
match llm_from_env() {
Ok(cli) => {
let label = format!("backend real: {}", cli.model_id());
(cli, label)
}
Err(e) => {
eprintln!(
"multilienzo_llm_demo :: fallo el factory ({e}); caigo a mock-demo"
);
let mock = pluma_llm_mock::MockChatClient::default()
.con_model_id("mock-demo");
(std::sync::Arc::new(mock), "mock-demo (fallback)".to_string())
}
}
}
fn socket_verbo_default() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
return PathBuf::from(xdg).join("verbo.sock");
}
let uid = std::fs::read_to_string("/proc/self/loginuid")
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.filter(|&u| u != u32::MAX)
.unwrap_or(1000);
PathBuf::from(format!("/tmp/verbo-{uid}.sock"))
}
async fn conectar_daemon_si_existe(path: &Path) -> Option<DaemonClient> {
if !path.exists() {
return None;
}
DaemonClient::connect(path).await.ok()
}
fn main() {
// Silenciar wrns de campos no leídos en `Model` para esta demo —
// labels existen para debugging, no para pintar (todavía).
let _ = LlmConfig::default();
llimphi_ui::run::<Demo>();
}
@@ -0,0 +1,353 @@
//! Demo del multilienzo con persistencia: lo que generes una vez
//! sobrevive a cerrar el proceso.
//!
//! Comportamiento:
//! - Primer arranque: cuerpo `es` sintético + traducción a `qu` vía
//! LLM (transparente: el backend lo decide `pluma_llm::from_env`)
//! + cuerpo `en` (resumen manual). Persiste todo en
//! `~/.cache/gioser/pluma-multilienzo/`.
//! - Arranques siguientes: lee la store, salta el LLM por completo.
//! Lo que ves en pantalla son los mismos cuerpos y hebras de la
//! primera vez.
//! - `--reset` (o env `MULTILIENZO_RESET=1`) limpia el cache y
//! fuerza una regeneración.
//!
//! ```bash
//! # Primera corrida: pega al LLM y guarda.
//! GEMINI_API_KEY=... PLUMA_LLM_BACKEND=gemini \
//! cargo run -p pluma-editor-llimphi --example multilienzo_store_demo --release
//!
//! # Siguientes corridas: instantáneo, sin red.
//! cargo run -p pluma-editor-llimphi --example multilienzo_store_demo --release
//!
//! # Resetear cache:
//! MULTILIENZO_RESET=1 cargo run -p pluma-editor-llimphi \
//! --example multilienzo_store_demo --release
//! ```
use std::path::{Path, PathBuf};
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, FlexDirection, Size, Style};
use llimphi_ui::{App, Handle, View};
use pluma_align::CartaHebras;
use pluma_align_embeddings::{alinear_por_embeddings, ModoAlineacion, ParamsAlineacion};
use pluma_core::NarrativeAtom;
use pluma_cuerpo::{Cuerpo, Intencion};
use pluma_editor_llimphi::multilienzo::{
multilienzo_view, IndiceAtoms, MultilienzoConfig, PaletaHebras,
};
use pluma_editor_llimphi::Palette;
use pluma_graph::NarrativeGraph;
use pluma_graph_transform::{indice_atoms, persistir_producto};
use pluma_llm::from_env as llm_from_env;
use pluma_llm_core::ChatClient;
use pluma_store::PlumaStore;
use pluma_transform::{TipoTransformacion, Transformacion};
use pluma_transform_llm::EjecutorTraducirLlm;
use rimay_verbo_daemon::DaemonClient;
use rimay_verbo_mock::MockProvider;
use uuid::Uuid;
#[derive(Clone, Debug)]
enum Msg {}
struct Model {
cuerpos: Vec<Cuerpo>,
graph: NarrativeGraph,
cartas: Vec<CartaHebras>,
}
struct Demo;
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"pluma · multilienzo store demo (persiste entre corridas)"
}
fn initial_size() -> (u32, u32) {
(1280, 680)
}
fn init(_: &Handle<Msg>) -> Model {
let cache_dir = cache_dir();
let reset = std::env::var("MULTILIENZO_RESET").ok().as_deref() == Some("1")
|| std::env::args().any(|a| a == "--reset");
if reset {
let _ = std::fs::remove_dir_all(&cache_dir);
eprintln!("multilienzo_store_demo :: cache reseteado");
}
std::fs::create_dir_all(&cache_dir).expect("crear cache dir");
let sled_path = cache_dir.join("pluma.sled");
let store = PlumaStore::open(&sled_path).expect("abrir PlumaStore");
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime tokio");
// ¿La store tiene contenido? Si sí, cargamos. Si no, generamos.
let tiene_contenido = store.cuerpos_len() >= 1;
if tiene_contenido {
eprintln!(
"multilienzo_store_demo :: cargando de {} ({} cuerpos)",
sled_path.display(),
store.cuerpos_len()
);
return cargar_de_store(&store);
}
eprintln!(
"multilienzo_store_demo :: cache vacía → generando vía LLM y persistiendo en {}",
sled_path.display()
);
// -- Generar desde cero --
let mut graph = NarrativeGraph::new();
let mut es = Cuerpo::nuevo("es", "español (original)", Intencion::Original, 100);
let textos_es = [
"El cóndor cruzó el cielo del valle al amanecer.",
"Las llamas pastaban entre los pastizales del altiplano.",
"Una mujer joven tejía un telar bajo el alero.",
];
let mut atoms_es: Vec<Uuid> = Vec::new();
for t in textos_es {
let atom = NarrativeAtom::new(t, "es");
atoms_es.push(atom.id);
es.agregar(atom.id, 101);
graph.insert(atom);
}
// LLM transparente — si no hay key, MockChatClient pre-poblado.
let chat = construir_chat();
eprintln!("multilienzo_store_demo :: LLM = {}", chat.model_id());
let ejecutor = EjecutorTraducirLlm::from_arc(chat, "qu");
let t_qu = Transformacion::nueva(
es.id,
Uuid::new_v4(),
TipoTransformacion::Traducir { lengua_destino: "qu".into() },
"demo",
200,
);
let (qu, carta_es_qu) = rt.block_on(async {
let idx = indice_atoms(&graph);
let producto = ejecutor
.aplicar_con_atoms(&t_qu, &es, &idx, 200)
.await
.expect("traducción");
drop(idx);
persistir_producto(&mut graph, producto)
});
// Cuerpo en (resumen manual, 2 párrafos hardcoded).
let mut en = Cuerpo::nuevo(
"en",
"english (résumé)",
Intencion::Resumen { palabras_objetivo: Some(40) },
200,
);
for t in ["Dawn over the highlands.", "By dusk, clouds hid the sun."] {
let atom = NarrativeAtom::new(t, "en");
en.agregar(atom.id, 201);
graph.insert(atom);
}
// Hebras qu↔en por embeddings.
let socket = socket_verbo_default();
let carta_qu_en = rt.block_on(async {
let idx = indice_atoms(&graph);
match conectar_daemon_si_existe(&socket).await {
Some(daemon) => {
let params = ParamsAlineacion {
umbral_minimo: 0.5,
modo: ModoAlineacion::MejorParaCadaA,
};
alinear_por_embeddings(&qu, &en, &idx, &daemon, &params, 200)
.await
.expect("embeddings daemon")
}
None => {
let mock = MockProvider::default();
let params = ParamsAlineacion {
umbral_minimo: -1.0,
modo: ModoAlineacion::MejorParaCadaA,
};
alinear_por_embeddings(&qu, &en, &idx, &mock, &params, 200)
.await
.expect("embeddings mock")
}
}
});
// Persistir TODO.
for atom in graph.atoms() {
store.put_atom(atom).expect("persistir atom");
}
for c in [&es, &qu, &en] {
store.put_cuerpo(c).expect("persistir cuerpo");
}
store.put_transformacion(&t_qu).expect("persistir transformación");
store.put_carta(&carta_es_qu).expect("persistir carta es↔qu");
store.put_carta(&carta_qu_en).expect("persistir carta qu↔en");
store.flush().expect("flush");
eprintln!(
"multilienzo_store_demo :: persistido — atoms={} cuerpos={} cartas={} transformaciones={}",
store.atoms_len(),
store.cuerpos_len(),
store.cartas_len(),
store.iter_transformaciones().count(),
);
Model {
cuerpos: vec![es, qu, en],
graph,
cartas: vec![carta_es_qu, carta_qu_en],
}
}
fn update(model: Model, _msg: Msg, _: &Handle<Msg>) -> Model {
model
}
fn view(model: &Model) -> View<Msg> {
let cfg = MultilienzoConfig::default();
let paleta = PaletaHebras::default();
let palette = Palette::default();
let index: IndiceAtoms = model.graph.atoms().map(|a| (a.id, a)).collect();
let cuerpos_ref: Vec<&Cuerpo> = model.cuerpos.iter().collect();
let cartas_ref: Vec<Option<&CartaHebras>> = model.cartas.iter().map(Some).collect();
let interior = multilienzo_view::<Msg>(
&cuerpos_ref,
&index,
&cartas_ref,
&cfg,
&paleta,
&palette,
);
View::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![interior])
}
}
/// Carga cuerpos + grafo + cartas del store. Si la store está
/// inconsistente, el demo falla con mensaje claro — el caller resetea
/// con `--reset`.
fn cargar_de_store(store: &PlumaStore) -> Model {
let mut graph = NarrativeGraph::new();
for atom in store.iter_atoms() {
graph.insert(atom.expect("leer atom"));
}
let mut cuerpos: Vec<Cuerpo> = store
.iter_cuerpos()
.map(|c| c.expect("leer cuerpo"))
.collect();
// Orden estable: Original primero, luego Traduccion, luego Resumen.
// Más fino requeriría un campo de orden en MetaCuerpo; por ahora
// este sort de tres categorías es suficiente para el demo.
cuerpos.sort_by_key(|c| match c.metadatos.intencion {
Intencion::Original => 0,
Intencion::Traduccion => 1,
Intencion::Resumen { .. } => 2,
Intencion::Tono { .. } => 3,
Intencion::Reescritura { .. } => 4,
Intencion::Anotacion => 5,
Intencion::Custom { .. } => 6,
});
// Cargar cartas en el orden de las columnas: cuerpos[i] ↔ cuerpos[i+1].
let mut cartas: Vec<CartaHebras> = Vec::with_capacity(cuerpos.len().saturating_sub(1));
for w in cuerpos.windows(2) {
if let Some(carta) = store
.get_carta_bidir(w[0].id, w[1].id)
.expect("leer carta")
{
cartas.push(carta);
}
}
Model {
cuerpos,
graph,
cartas,
}
}
/// Cache dir: `$XDG_CACHE_HOME/gioser/pluma-multilienzo` o
/// `$HOME/.cache/gioser/pluma-multilienzo`.
fn cache_dir() -> PathBuf {
let base = std::env::var("XDG_CACHE_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| std::env::var("HOME").ok().map(|h| PathBuf::from(h).join(".cache")))
.unwrap_or_else(|| PathBuf::from("/tmp"));
base.join("gioser").join("pluma-multilienzo")
}
fn construir_chat() -> std::sync::Arc<dyn ChatClient> {
// Si no hay key real, sustituimos por mock pre-poblado con
// traducciones predecibles — el demo se ve "como si tradujera".
let usa_mock = std::env::var("ANTHROPIC_API_KEY").is_err()
&& std::env::var("GEMINI_API_KEY").is_err()
&& std::env::var("GOOGLE_API_KEY").is_err()
&& std::env::var("DEEPSEEK_API_KEY").is_err()
&& std::env::var("PLUMA_LLM_BACKEND")
.map(|s| s.to_lowercase() != "ollama")
.unwrap_or(true);
if usa_mock {
let mut mock = pluma_llm_mock::MockChatClient::default()
.con_model_id("mock-demo");
let pares = [
("cóndor cruzó", "Kuntur wayqu hanaqpachata pacha paqarinpi pasarqa."),
("Las llamas pastaban", "Llamaqakuna qulla suyup q'achupinpi mikhusharqaku."),
("mujer joven tejía", "Sipas warmiq away wasiq hawanpi awayta ruwasharqa."),
];
for (k, v) in pares {
mock = mock.con_respuesta(k, v);
}
return std::sync::Arc::new(mock);
}
llm_from_env().expect("from_env LLM")
}
fn socket_verbo_default() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
return PathBuf::from(xdg).join("verbo.sock");
}
let uid = std::fs::read_to_string("/proc/self/loginuid")
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.filter(|&u| u != u32::MAX)
.unwrap_or(1000);
PathBuf::from(format!("/tmp/verbo-{uid}.sock"))
}
async fn conectar_daemon_si_existe(path: &Path) -> Option<DaemonClient> {
if !path.exists() {
return None;
}
DaemonClient::connect(path).await.ok()
}
fn main() {
llimphi_ui::run::<Demo>();
}
@@ -0,0 +1,757 @@
//! Demo: transformaciones LLM sobre la **zona del caret**, no sobre el
//! cuerpo entero. Es la unión del `cuerpo_ide_demo` (edición con
//! junctions togglables) y `multilienzo_dinamico_demo` (botones LLM),
//! pero el ejecutor recibe un sub-`Cuerpo` con SOLO los atoms de la zona
//! activa.
//!
//! Flujo:
//!
//! 1. cuerpo_ide a la izquierda con 6 párrafos sintéticos.
//! 2. Edita con normalidad (multi-cursor, undo, clipboard). `Ctrl+J`
//! togglea la junction anterior al caret → fusiona/desfusiona zonas.
//! `Ctrl+Shift+]/[` saltan entre zonas.
//! 3. Click en cualquiera de los cuatro botones (`→ qu`, `→ en`,
//! `tono formal`, `resumir 30p`): toma la zona del caret, hace un
//! `guardar()` implícito (sync cuerpo_ide → atoms), arma un
//! sub-`Cuerpo` con `orden = atom_ids_de_zona(zona)`, lanza el
//! ejecutor LLM correspondiente, y al volver agrega una card en el
//! panel derecho con la hija producida.
//!
//! Sin keys: usa `pluma_llm_mock` con respuestas pre-pobladas. Con keys
//! (`GEMINI_API_KEY`, `ANTHROPIC_API_KEY`…) usa `pluma_llm::from_env`.
//!
//! ```bash
//! cargo run -p pluma-editor-llimphi --example zona_transform_demo --release
//!
//! GEMINI_API_KEY=... PLUMA_LLM_BACKEND=gemini \
//! cargo run -p pluma-editor-llimphi --example zona_transform_demo --release
//! ```
use std::collections::HashMap;
use std::sync::Arc;
use llimphi_ui::llimphi_layout::taffy::prelude::{
auto, length, percent, FlexDirection, Rect, Size, Style,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, View};
use llimphi_widget_button::{button_view, ButtonPalette};
use llimphi_widget_text_editor::{
EditorMetrics, EditorPalette, Language, MemClipboard, PointerEvent,
};
use pluma_core::NarrativeAtom;
use pluma_cuerpo::{Cuerpo, Intencion};
use pluma_editor_cuerpo::CambioAtom;
use pluma_editor_llimphi::cuerpo_ide::{cuerpo_ide_view, CuerpoIde};
use pluma_llm::from_env as llm_from_env;
use pluma_llm_core::ChatClient;
use pluma_transform::{TipoTransformacion, Transformacion};
use pluma_transform_llm::{
EjecutorResumirLlm, EjecutorTonoLlm, EjecutorTraducirLlm,
};
use uuid::Uuid;
const METRICS: EditorMetrics = EditorMetrics::for_font_size(13.0);
const VISIBLE_LINES: usize = 200;
#[derive(Clone, Debug)]
enum Msg {
EditorKey(KeyEvent),
EditorPointer(PointerEvent),
ToglearFusion,
ZonaSiguiente,
ZonaAnterior,
PedirTraducir(String),
PedirTono(String),
PedirResumir(Option<u32>),
LlmListo {
zona: usize,
etiqueta: String,
branch: String,
atoms_nuevos: Vec<NarrativeAtom>,
orden: Vec<Uuid>,
},
LlmError(String),
}
/// Una derivación por zona ya materializada. El panel derecho pinta una
/// `card` por entrada — vivimos con un `Vec<HijaZona>` plano para que no
/// haya magia: cada click suma una entrada al final.
struct HijaZona {
zona: usize,
etiqueta: String,
branch: String,
atoms: HashMap<Uuid, NarrativeAtom>,
orden: Vec<Uuid>,
}
struct Model {
cuerpo: Cuerpo,
atoms: HashMap<Uuid, NarrativeAtom>,
ide: CuerpoIde,
clipboard: MemClipboard,
drag_accum: (f32, f32),
chat: Arc<dyn ChatClient>,
en_curso: bool,
ultimo_error: Option<String>,
hijas: Vec<HijaZona>,
}
struct Demo;
impl App for Demo {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"pluma · transform sobre zona (haz que crece por zona)"
}
fn initial_size() -> (u32, u32) {
(1500, 760)
}
fn init(_: &Handle<Msg>) -> Model {
let textos = [
"El cóndor cruzó el cielo del valle al amanecer.",
"Las llamas pastaban entre los pastizales del altiplano.",
"Una mujer joven tejía un telar bajo el alero.",
"El río Apurímac descendía rugiente por las rocas.",
"Al caer la tarde, las nubes cubrieron el sol.",
"Los kuntures alzaron vuelo hacia los nevados.",
];
let atoms_vec: Vec<NarrativeAtom> = textos
.iter()
.map(|t| NarrativeAtom::new(*t, "es"))
.collect();
let mut cuerpo = Cuerpo::nuevo("es", "español (original)", Intencion::Original, 0);
for a in &atoms_vec {
cuerpo.agregar(a.id, 0);
}
let atoms: HashMap<Uuid, NarrativeAtom> =
atoms_vec.into_iter().map(|a| (a.id, a)).collect();
let idx: HashMap<Uuid, &NarrativeAtom> = atoms.iter().map(|(k, v)| (*k, v)).collect();
let ide = CuerpoIde::from_cuerpo(&cuerpo, &idx);
Model {
cuerpo,
atoms,
ide,
clipboard: MemClipboard::default(),
drag_accum: (0.0, 0.0),
chat: construir_chat(),
en_curso: false,
ultimo_error: None,
hijas: Vec::new(),
}
}
fn update(model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
match msg {
Msg::EditorKey(ev) => {
let mut model = model;
let _ = model.ide.apply_key_with_clipboard(&ev, &mut model.clipboard);
model
}
Msg::EditorPointer(ev) => {
let mut model = model;
let scroll = model.ide.state.scroll_offset;
match ev {
PointerEvent::Click { x, y } => {
model.drag_accum = (0.0, 0.0);
let (line, col) = METRICS.screen_to_pos(x, y, scroll);
model.ide.set_caret(line, col);
}
PointerEvent::Drag {
initial_x,
initial_y,
dx,
dy,
} => {
model.drag_accum.0 += dx;
model.drag_accum.1 += dy;
let cx = initial_x + model.drag_accum.0;
let cy = initial_y + model.drag_accum.1;
let (line, col) = METRICS.screen_to_pos(cx, cy, scroll);
model.ide.state.extend_selection_to(line, col);
}
}
model
}
Msg::ToglearFusion => {
let mut model = model;
if let Some(idx) = model.ide.junction_antes_del_caret() {
model.ide.togglear_junction(idx);
}
model
}
Msg::ZonaSiguiente => {
let mut model = model;
model.ide.ir_a_zona_siguiente();
model.ide.state.ensure_caret_visible(VISIBLE_LINES);
model
}
Msg::ZonaAnterior => {
let mut model = model;
model.ide.ir_a_zona_anterior();
model.ide.state.ensure_caret_visible(VISIBLE_LINES);
model
}
Msg::PedirTraducir(lengua) => lanzar(model, handle, TrabajoLlm::Traducir(lengua)),
Msg::PedirTono(etiqueta) => lanzar(model, handle, TrabajoLlm::Tono(etiqueta)),
Msg::PedirResumir(palabras) => lanzar(model, handle, TrabajoLlm::Resumir(palabras)),
Msg::LlmListo {
zona,
etiqueta,
branch,
atoms_nuevos,
orden,
} => {
let mut model = model;
let atoms_hash: HashMap<Uuid, NarrativeAtom> =
atoms_nuevos.into_iter().map(|a| (a.id, a)).collect();
model.hijas.push(HijaZona {
zona,
etiqueta,
branch,
atoms: atoms_hash,
orden,
});
model.en_curso = false;
model
}
Msg::LlmError(s) => {
let mut model = model;
eprintln!("zona_transform_demo :: error LLM: {s}");
model.ultimo_error = Some(s);
model.en_curso = false;
model
}
}
}
fn on_key(_model: &Self::Model, event: &KeyEvent) -> Option<Self::Msg> {
if event.state != KeyState::Pressed {
return None;
}
let ctrl = event.modifiers.ctrl || event.modifiers.meta;
let shift = event.modifiers.shift;
if ctrl {
if let Key::Character(s) = &event.key {
if shift && (s == "}" || s == "]") {
return Some(Msg::ZonaSiguiente);
}
if shift && (s == "{" || s == "[") {
return Some(Msg::ZonaAnterior);
}
if s.eq_ignore_ascii_case("j") {
return Some(Msg::ToglearFusion);
}
}
}
Some(Msg::EditorKey(event.clone()))
}
fn view(model: &Model) -> View<Msg> {
let palette_editor = EditorPalette::default();
let bg_app = palette_editor.bg;
let fg_text = palette_editor.fg_text;
let fg_muted = palette_editor.fg_line_number;
let zona_caret = model.ide.zona_del_caret();
let n_zonas = model.ide.n_zonas();
let header_text = format!(
"haz por zona · {} átomos · {} zonas · caret en zona {} · {} · Ctrl+J fundir · Ctrl+Shift+]/[ navegar zonas",
model.cuerpo.orden.len(),
n_zonas,
zona_caret,
if model.en_curso {
"⏳ LLM en curso…"
} else if model.ultimo_error.is_some() {
"⚠ error (ver footer)"
} else {
"listo"
},
);
let header = chip(
header_text,
28.0,
12.0,
Color::from_rgba8(40, 44, 52, 255),
fg_text,
);
let toolbar = toolbar_view(model.en_curso, zona_caret);
let editor = cuerpo_ide_view::<Msg>(
&model.ide,
&palette_editor,
METRICS,
VISIBLE_LINES,
Language::Plain,
|ev| Some(Msg::EditorPointer(ev)),
);
let columna_izq = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(0.58_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(8.0_f32),
bottom: length(8.0_f32),
},
..Default::default()
})
.fill(bg_app)
.children(vec![editor]);
let panel_der = panel_hijas_view(&model.hijas, fg_text, fg_muted);
let centro = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: auto(),
},
flex_grow: 1.0,
..Default::default()
})
.fill(bg_app)
.children(vec![columna_izq, panel_der]);
let footer_text = model
.ultimo_error
.clone()
.unwrap_or_else(|| {
if model.hijas.is_empty() {
"(sin derivaciones todavía — click en un botón para transformar la zona del caret)"
.to_string()
} else {
format!("{} hijas derivadas", model.hijas.len())
}
});
let footer = chip(
footer_text,
24.0,
11.0,
Color::from_rgba8(33, 36, 42, 255),
fg_muted,
);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(bg_app)
.children(vec![header, toolbar, centro, footer])
}
}
fn chip(texto: String, alto: f32, font_size: f32, fondo: Color, fg: Color) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(alto),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(4.0_f32),
bottom: length(4.0_f32),
},
..Default::default()
})
.fill(fondo)
.text_aligned(texto, font_size, fg, Alignment::Start)
}
fn toolbar_view(en_curso: bool, zona_caret: usize) -> View<Msg> {
let p_activo = ButtonPalette {
bg: Color::from_rgba8(60, 70, 88, 255),
bg_hover: Color::from_rgba8(85, 100, 130, 255),
fg: Color::from_rgba8(235, 235, 245, 255),
radius: 5.0,
};
let p_off = ButtonPalette {
bg: Color::from_rgba8(60, 60, 60, 255),
bg_hover: Color::from_rgba8(60, 60, 60, 255),
fg: Color::from_rgba8(140, 140, 140, 255),
radius: 5.0,
};
let pal = if en_curso { &p_off } else { &p_activo };
let mk = |label: &str, m: Msg| button_view::<Msg>(label, pal, m);
let label_zona = format!("derivar zona {}:", zona_caret);
let etiqueta = View::new(Style {
size: Size {
width: length(150.0_f32),
height: length(30.0_f32),
},
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(6.0_f32),
bottom: length(6.0_f32),
},
..Default::default()
})
.text_aligned(
label_zona,
12.0,
Color::from_rgba8(220, 220, 220, 255),
Alignment::Start,
);
let botones: Vec<View<Msg>> = vec![
etiqueta,
mk("→ qu", Msg::PedirTraducir("qu".into())),
mk("→ en", Msg::PedirTraducir("en".into())),
mk("tono formal", Msg::PedirTono("formal".into())),
mk("resumir 30p", Msg::PedirResumir(Some(30))),
];
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(48.0_f32),
},
gap: Size {
width: length(8.0_f32),
height: length(0.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(8.0_f32),
bottom: length(8.0_f32),
},
..Default::default()
})
.fill(Color::from_rgba8(28, 32, 40, 255))
.children(botones)
}
fn panel_hijas_view(hijas: &[HijaZona], fg_text: Color, fg_muted: Color) -> View<Msg> {
let mut cards: Vec<View<Msg>> = Vec::new();
if hijas.is_empty() {
cards.push(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(60.0_f32),
},
padding: Rect {
left: length(12.0_f32),
right: length(12.0_f32),
top: length(20.0_f32),
bottom: length(8.0_f32),
},
..Default::default()
})
.text_aligned(
"panel hijas vacío".to_string(),
12.0,
fg_muted,
Alignment::Start,
),
);
} else {
// Las cards más recientes arriba — usuario las ve primero.
for h in hijas.iter().rev() {
cards.push(card_hija(h, fg_text, fg_muted));
}
}
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(0.42_f32),
height: percent(1.0_f32),
},
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(8.0_f32),
bottom: length(8.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(8.0_f32),
},
..Default::default()
})
.fill(Color::from_rgba8(22, 26, 32, 255))
.clip(true)
.children(cards)
}
fn card_hija(h: &HijaZona, fg_text: Color, fg_muted: Color) -> View<Msg> {
let head = format!("zona {} · {} · branch {}", h.zona, h.etiqueta, h.branch);
let cuerpo: String = h
.orden
.iter()
.filter_map(|id| h.atoms.get(id).map(|a| a.content.as_str()))
.collect::<Vec<_>>()
.join("\n\n");
let head_view = View::new(Style {
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(4.0_f32),
bottom: length(2.0_f32),
},
..Default::default()
})
.text_aligned(head, 11.0, fg_muted, Alignment::Start);
// Estimar alto del cuerpo en función de líneas. 16px por línea es
// generoso pero evita scrollbars internos.
let n_lineas = (cuerpo.matches('\n').count() + 1).max(1) as f32;
let alto_cuerpo = (n_lineas * 16.0 + 12.0).max(40.0);
let body_view = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(alto_cuerpo),
},
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(2.0_f32),
bottom: length(6.0_f32),
},
..Default::default()
})
.text_aligned(cuerpo, 12.0, fg_text, Alignment::Start);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: length(alto_cuerpo + 26.0),
},
..Default::default()
})
.fill(Color::from_rgba8(40, 46, 56, 255))
.children(vec![head_view, body_view])
}
enum TrabajoLlm {
Traducir(String),
Tono(String),
Resumir(Option<u32>),
}
impl TrabajoLlm {
fn etiqueta(&self) -> String {
match self {
TrabajoLlm::Traducir(l) => format!("traducir → {l}"),
TrabajoLlm::Tono(t) => format!("tono → {t}"),
TrabajoLlm::Resumir(Some(n)) => format!("resumir ≈{n}p"),
TrabajoLlm::Resumir(None) => "resumir".to_string(),
}
}
}
/// `guardar` lite — sincroniza el buffer del IDE contra los atoms para
/// que la transformación use el texto que el usuario VE (no el original
/// de init). Igual que el `guardar` de `cuerpo_ide_demo` pero sin
/// rearmar el IDE: ese refresh confunde si lo hacemos antes de lanzar el
/// trabajo. Reflejamos cambios en `atoms` y `cuerpo.orden`, y dejamos el
/// IDE coherente vía `aplicar_cambios`.
fn sincronizar(model: &mut Model) {
let idx: HashMap<Uuid, &NarrativeAtom> =
model.atoms.iter().map(|(k, v)| (*k, v)).collect();
let cambios = model.ide.diff(&idx);
drop(idx);
if cambios.is_empty() {
return;
}
let mut creados: Vec<Uuid> = Vec::new();
for c in &cambios {
match c {
CambioAtom::Mutar { id, texto_nuevo } => {
if let Some(a) = model.atoms.get_mut(id) {
a.set_content(texto_nuevo.as_str());
}
}
CambioAtom::Crear { texto, posicion: _ } => {
let atom = NarrativeAtom::new(texto.as_str(), &model.cuerpo.branch_id);
let id = atom.id;
model.atoms.insert(id, atom);
creados.push(id);
}
CambioAtom::Eliminar { id } => {
model.atoms.remove(id);
}
}
}
model.ide.aplicar_cambios(&cambios, &creados);
let nuevo_orden: Vec<Uuid> = model.ide.editor_cuerpo.atom_ids.clone();
let ahora = model.cuerpo.metadatos.modificado_en.saturating_add(1);
let viejo: Vec<Uuid> = model.cuerpo.orden.clone();
for id in &viejo {
let _ = model.cuerpo.remover(*id, ahora);
}
for id in &nuevo_orden {
model.cuerpo.agregar(*id, ahora);
}
}
fn lanzar(mut model: Model, handle: &Handle<Msg>, trabajo: TrabajoLlm) -> Model {
if model.en_curso {
return model;
}
sincronizar(&mut model);
let zona = model.ide.zona_del_caret();
let atom_ids = match model.ide.atom_ids_de_zona(zona) {
Some(v) if !v.is_empty() => v,
_ => {
model.ultimo_error = Some(format!("zona {zona} sin atoms — nada que transformar"));
return model;
}
};
// Sub-cuerpo: clonamos la madre y le reemplazamos el `orden` con los
// atoms de la zona. Los atoms reales viven en `model.atoms` —
// intactos.
let mut subcuerpo = model.cuerpo.clone();
subcuerpo.orden = atom_ids;
let etiqueta = trabajo.etiqueta();
let atoms_owned: Vec<NarrativeAtom> = model.atoms.values().cloned().collect();
let chat = model.chat.clone();
let h = handle.clone();
let ahora = ahora_unix();
model.en_curso = true;
model.ultimo_error = None;
handle.spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => return Msg::LlmError(format!("runtime tokio: {e}")),
};
let idx: HashMap<Uuid, &NarrativeAtom> =
atoms_owned.iter().map(|a| (a.id, a)).collect();
let resultado = rt.block_on(async {
match trabajo {
TrabajoLlm::Traducir(lengua) => {
let ej = EjecutorTraducirLlm::from_arc(chat, lengua.clone());
let t = Transformacion::nueva(
subcuerpo.id,
Uuid::new_v4(),
TipoTransformacion::Traducir {
lengua_destino: lengua,
},
"ui",
ahora,
);
ej.aplicar_con_atoms(&t, &subcuerpo, &idx, ahora).await
}
TrabajoLlm::Tono(etiq) => {
let ej = EjecutorTonoLlm::from_arc(chat, etiq.clone());
let t = Transformacion::nueva(
subcuerpo.id,
Uuid::new_v4(),
TipoTransformacion::Tono { etiqueta: etiq },
"ui",
ahora,
);
ej.aplicar_con_atoms(&t, &subcuerpo, &idx, ahora).await
}
TrabajoLlm::Resumir(palabras) => {
let ej = EjecutorResumirLlm::from_arc(chat, palabras);
let t = Transformacion::nueva(
subcuerpo.id,
Uuid::new_v4(),
TipoTransformacion::Resumir {
palabras_objetivo: palabras,
},
"ui",
ahora,
);
ej.aplicar_con_atoms(&t, &subcuerpo, &idx, ahora).await
}
}
});
let _ = h;
match resultado {
Ok(prod) => Msg::LlmListo {
zona,
etiqueta,
branch: prod.hija.branch_id.clone(),
atoms_nuevos: prod.atoms_nuevos,
orden: prod.hija.orden,
},
Err(e) => Msg::LlmError(format!("{e:?}")),
}
});
model
}
fn construir_chat() -> Arc<dyn ChatClient> {
let usa_mock = std::env::var("ANTHROPIC_API_KEY").is_err()
&& std::env::var("GEMINI_API_KEY").is_err()
&& std::env::var("GOOGLE_API_KEY").is_err()
&& std::env::var("DEEPSEEK_API_KEY").is_err()
&& std::env::var("COHERE_API_KEY").is_err()
&& std::env::var("PLUMA_LLM_BACKEND")
.map(|s| s.to_lowercase() != "ollama")
.unwrap_or(true);
if usa_mock {
let mut mock = pluma_llm_mock::MockChatClient::default().con_model_id("mock-zona");
// Respuestas pre-pobladas — substring → respuesta. Si la zona no
// contiene ninguna de estas, el mock responde con un placeholder
// genérico para que el demo igual muestre algo.
for (k, v) in [
("cóndor cruzó", "Kuntur wayqu hanaqpachata pacha paqarinpi pasarqa."),
("Las llamas pastaban", "Llamakuna qulla suyup q'achunpi mikhusharqaku."),
("mujer joven tejía", "Sipas warmi away wasiq hawanpi awayta ruwasharqa."),
("río Apurímac", "Apurímac mayu rumikunaq ukhunpita uraykachisharqa."),
("Al caer la tarde", "Inti waykuyninpi phuyukuna intita pakarqa."),
("kuntures alzaron", "Kunturkunaqa riti urqukunaman phawarqaku."),
] {
mock = mock.con_respuesta(k, v);
}
return Arc::new(mock);
}
llm_from_env().expect("from_env")
}
fn ahora_unix() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn main() {
llimphi_ui::run::<Demo>();
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,322 @@
//! `pluma-editor-llimphi` — el backend Llimphi del editor DAG.
//!
//! Consume un [`RenderPlan`] de `pluma-render-plan` y lo vuelca a un árbol
//! `llimphi-ui::View`: los bloques de átomo y las marcas del osciloscopio
//! son nodos absolutamente posicionados (taffy `Position::Absolute`); los
//! conectores de dependencia van como triplas de rectángulos delgados que
//! dibujan el codo en S.
//!
//! Es el único crate de pluma visual que toca `llimphi-ui` — el resto de
//! la cadena (`core`, `graph`, `render-plan`) es agnóstico.
#![forbid(unsafe_code)]
pub mod cuerpo_ide;
pub mod multilienzo;
pub mod multilienzo_editor;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{auto, length, percent, Position, Rect, Size, Style},
FlexDirection,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use pluma_render_plan::{AtomBlock, CoherenceTone, Edge, RenderPlan, SidepaneMark};
/// Paleta del editor — los colores que cambia el tema, separados del
/// `Color` semántico de las tonalidades (rojo conflicto, ámbar pendiente).
#[derive(Debug, Clone, Copy)]
pub struct Palette {
pub bg_app: Color,
pub bg_panel: Color,
pub fg_text: Color,
pub fg_muted: Color,
pub border_strong: Color,
}
/// Tema oscuro por defecto — análogo al `nahual-theme` dark default.
impl Default for Palette {
fn default() -> Self {
Self::from_theme(&llimphi_theme::Theme::dark())
}
}
impl Palette {
/// Construye la paleta desde un `Theme` semántico.
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg_app: t.bg_app,
bg_panel: t.bg_panel,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
border_strong: t.accent,
}
}
}
/// Color semántico de un estado de coherencia. Fijo, no temático: el rojo
/// de "conflicto" y el ámbar de "pendiente" son señales, no estilo.
pub fn tone_color(tone: CoherenceTone) -> Color {
match tone {
// hsl(145°, 42%, 55%) ≈ rgb(94, 184, 124) — verde coherencia
CoherenceTone::Valid => Color::from_rgba8(94, 184, 124, 255),
// hsl(42°, 82%, 58%) ≈ rgb(238, 178, 53) — ámbar pendiente
CoherenceTone::Pending => Color::from_rgba8(238, 178, 53, 255),
// hsl(2°, 70%, 58%) ≈ rgb(225, 84, 75) — rojo conflicto
CoherenceTone::Conflict => Color::from_rgba8(225, 84, 75, 255),
}
}
/// ID estable (en el catálogo `rimay-localize`) para la etiqueta corta
/// de un tono. Existe como función para que callers puedan acceder al
/// ID raw (p.ej. tests) sin pasar por i18n.
pub fn tone_label_id(tone: CoherenceTone) -> &'static str {
match tone {
CoherenceTone::Valid => "pluma-tone-valid",
CoherenceTone::Pending => "pluma-tone-pending",
CoherenceTone::Conflict => "pluma-tone-conflict",
}
}
/// Etiqueta corta de un tono ya traducida al locale activo. La firma
/// vuelve `String` (no `&'static str` como antes) porque la traducción
/// es dinámica. Auto-inicializa el fallback es-PE si nadie llamó a
/// `rimay_localize::init` aún.
pub fn tone_label(tone: CoherenceTone) -> String {
rimay_localize::t(tone_label_id(tone))
}
/// Compone el plan completo en un árbol `View`: capa de conectores al
/// fondo, bloques y marcas encima. El nodo raíz mide exactamente el
/// contenido — envolverlo en un contenedor con clipping para documentos
/// largos (Llimphi todavía no implementa scroll; los bloques fuera del
/// viewport quedan recortados por la superficie).
pub fn editor_view<Msg: Clone + 'static>(plan: &RenderPlan, palette: &Palette) -> View<Msg> {
let cfg = plan.config;
let content_w = plan
.blocks
.iter()
.map(|b| b.x + b.w)
.fold(0.0f32, f32::max)
+ cfg.margin;
let content_h = plan.content_height.max(cfg.margin * 2.0);
let mut children: Vec<View<Msg>> = Vec::new();
// Aristas al fondo: las pinta primero, los bloques las tapan al cruzarlas.
for e in &plan.edges {
children.extend(edge_segments::<Msg>(e, palette.border_strong));
}
for b in &plan.blocks {
children.push(block_view::<Msg>(b, palette));
}
for m in &plan.sidepane {
children.push(mark_view::<Msg>(m, &cfg));
}
View::new(Style {
position: Position::Relative,
size: Size {
width: length(content_w.max(cfg.margin * 2.0)),
height: length(content_h),
},
..Default::default()
})
.children(children)
}
// ---------------------------------------------------------------------
// Bloques y marcas
// ---------------------------------------------------------------------
/// Caja absoluta de un átomo: borde tonal + interior con meta + preview.
fn block_view<Msg: Clone + 'static>(b: &AtomBlock, palette: &Palette) -> View<Msg> {
let meta = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(14.0_f32),
},
..Default::default()
})
.text_aligned(
format!(
"{} · profundidad {} · {}",
b.branch,
b.depth,
tone_label(b.tone)
),
10.0,
palette.fg_muted,
Alignment::Start,
);
let preview = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(18.0_f32),
},
..Default::default()
})
.text_aligned(b.preview.clone(), 13.0, palette.fg_text, Alignment::Start);
// Interior: bg del panel, padding, dos filas de texto.
let inner = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(3.0_f32),
},
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(8.0_f32),
bottom: length(8.0_f32),
},
..Default::default()
})
.fill(palette.bg_panel)
.radius(3.0)
.children(vec![meta, preview]);
// Exterior: borde tonal (2 px) absolutamente posicionado.
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(b.x),
top: length(b.y),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(b.w),
height: length(b.h),
},
padding: Rect {
left: length(2.0_f32),
right: length(2.0_f32),
top: length(2.0_f32),
bottom: length(2.0_f32),
},
..Default::default()
})
.fill(tone_color(b.tone))
.radius(5.0)
.children(vec![inner])
}
/// Marca del osciloscopio de coherencia en el sidepane.
fn mark_view<Msg: Clone + 'static>(
m: &SidepaneMark,
cfg: &pluma_render_plan::LayoutConfig,
) -> View<Msg> {
let usable = (cfg.sidepane_width - 8.0).max(4.0);
let w = (m.intensity * usable).max(3.0);
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(cfg.margin),
top: length(m.y),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(w),
height: length(m.h),
},
..Default::default()
})
.fill(tone_color(m.tone))
.radius(3.0)
}
// ---------------------------------------------------------------------
// Conectores
// ---------------------------------------------------------------------
/// Devuelve los tres rectángulos que dibujan el codo en S de una arista
/// del prerrequisito al dependiente: vertical baja, horizontal cruza,
/// vertical baja. Si origen y destino están alineados verticalmente, el
/// tramo horizontal degenera a un punto — se omite y queda un único
/// segmento vertical.
fn edge_segments<Msg: Clone + 'static>(e: &Edge, color: Color) -> Vec<View<Msg>> {
let stroke = 1.6f32;
let half = stroke * 0.5;
let mid_y = (e.y1 + e.y2) * 0.5;
let mut out = Vec::with_capacity(3);
// Tramo 1: vertical desde (x1, y1) hasta (x1, mid_y).
out.push(line_view::<Msg>(
e.x1 - half,
e.y1,
stroke,
(mid_y - e.y1).abs().max(stroke),
color,
));
// Tramo 2: horizontal a la altura `mid_y` cruzando entre x1 y x2.
if (e.x2 - e.x1).abs() > stroke {
let (x_l, x_r) = if e.x1 < e.x2 {
(e.x1, e.x2)
} else {
(e.x2, e.x1)
};
out.push(line_view::<Msg>(
x_l - half,
mid_y - half,
(x_r - x_l) + stroke,
stroke,
color,
));
}
// Tramo 3: vertical desde (x2, mid_y) hasta (x2, y2).
out.push(line_view::<Msg>(
e.x2 - half,
mid_y,
stroke,
(e.y2 - mid_y).abs().max(stroke),
color,
));
out
}
/// Rectángulo delgado absolutamente posicionado — el "pincel" de un tramo.
fn line_view<Msg: Clone + 'static>(x: f32, y: f32, w: f32, h: f32, color: Color) -> View<Msg> {
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(x),
top: length(y),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(w),
height: length(h),
},
..Default::default()
})
.fill(color)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tones_have_distinct_colors() {
let v = tone_color(CoherenceTone::Valid);
let p = tone_color(CoherenceTone::Pending);
let c = tone_color(CoherenceTone::Conflict);
assert!(v.components != p.components);
assert!(p.components != c.components);
assert!(v.components != c.components);
}
#[test]
fn tone_labels_are_set() {
assert_eq!(tone_label_id(CoherenceTone::Conflict), "pluma-tone-conflict");
}
}
@@ -0,0 +1,713 @@
//! `multilienzo` — vista de cuerpos paralelos del mismo documento.
//!
//! Pinta N columnas (cuerpos) intercaladas con N1 *carriles* angostos donde
//! se trazan las *hebras*: diagonales que conectan párrafos correspondientes
//! entre cuerpos consecutivos. Color y trazo codifican origen y frescura.
//!
//! Contrato con el caller:
//! - `cuerpos`: la lista en orden de presentación (de izquierda a derecha).
//! - `atoms`: índice por `Uuid` con los `NarrativeAtom`s referenciados.
//! El multilienzo no resuelve por su cuenta — lo recibe ya armado.
//! - `cartas`: `cartas[i]` es la carta entre `cuerpos[i]` y `cuerpos[i+1]`.
//! `None` significa "no hay carta calculada todavía para ese par":
//! no se pintan hebras en ese carril.
//!
//! La vista no maneja scroll explícito: si el contenido excede el rect que
//! le asigna taffy, se recorta. La integración con scroll horizontal vendrá
//! cuando llimphi-ui exponga primitivas de scroll dedicadas — por ahora el
//! ancho total del HStack se calcula y se devuelve al caller, que puede
//! envolverlo en su propio contenedor con `clip(true)` y desplazarlo.
use std::collections::HashMap;
use llimphi_ui::llimphi_layout::taffy::prelude::{
auto, length, percent, FlexDirection, Position, Rect, Size, Style,
};
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Stroke};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use uuid::Uuid;
use pluma_align::{CartaHebras, OrigenAlineamiento};
use pluma_core::NarrativeAtom;
use pluma_cuerpo::Cuerpo;
use crate::Palette;
/// Configuración geométrica del multilienzo.
#[derive(Debug, Clone, Copy)]
pub struct MultilienzoConfig {
/// Altura uniforme de cada bloque de párrafo, en px.
pub altura_atom: f32,
/// Separación vertical entre bloques dentro de una columna.
pub gap_atom: f32,
/// Ancho de cada columna de cuerpo.
pub ancho_cuerpo: f32,
/// Ancho del carril intermedio donde se pintan las hebras.
pub ancho_carril: f32,
/// Padding interno superior — desde donde empiezan los primeros átomos.
pub padding_top: f32,
/// Altura de la cabecera de cada columna (rótulo del cuerpo).
pub alto_header: f32,
/// Grosor del trazo de las hebras, en px.
pub grosor_hebra: f32,
}
impl Default for MultilienzoConfig {
fn default() -> Self {
Self {
altura_atom: 64.0,
gap_atom: 10.0,
ancho_cuerpo: 280.0,
ancho_carril: 72.0,
padding_top: 12.0,
alto_header: 28.0,
grosor_hebra: 2.0,
}
}
}
/// Paleta semántica de las hebras. Distinta del [`Palette`] del editor
/// porque codifica una dimensión propia: el origen del alineamiento.
#[derive(Debug, Clone, Copy)]
pub struct PaletaHebras {
/// Origen [`OrigenAlineamiento::Derivado`] — la hebra más confiable: la
/// emitió una transformación.
pub derivada: Color,
/// Origen [`OrigenAlineamiento::Embeddings`] — confianza calculada por
/// un modelo. Su saturación se modula por la `fuerza` del alineamiento.
pub embeddings: Color,
/// Origen [`OrigenAlineamiento::Manual`] — la trazó un humano.
pub manual: Color,
/// Hebra stale (la madre cambió tras la última regeneración).
/// Desaturada, mate.
pub stale: Color,
}
impl Default for PaletaHebras {
fn default() -> Self {
Self {
// verde — consistente con `tone_color(Valid)`
derivada: Color::from_rgba8(94, 184, 124, 230),
// azul de embeddings
embeddings: Color::from_rgba8(96, 150, 220, 230),
// ámbar — consistente con `tone_color(Pending)` (autoría humana = atención)
manual: Color::from_rgba8(238, 178, 53, 230),
// gris frío semitransparente
stale: Color::from_rgba8(150, 150, 150, 140),
}
}
}
/// Índice rápido para resolver `Uuid → &NarrativeAtom`. El editor lo
/// construye desde su `NarrativeGraph`; el multilienzo lo consume sin
/// asumir su origen.
pub type IndiceAtoms<'a> = HashMap<Uuid, &'a NarrativeAtom>;
/// Datos pre-calculados de una hebra, listos para que la closure de
/// `paint_with` solo dibuje. Se calcula en CPU una vez por frame.
#[derive(Debug, Clone, Copy)]
struct HebraPintada {
/// Posición vertical del punto izquierdo dentro del carril, en px
/// relativos al rect del carril.
y_izq: f32,
/// Posición vertical del punto derecho.
y_der: f32,
/// Color final con alpha modulado por fuerza/stale.
color: Color,
/// Si la hebra va punteada (stale o baja confianza). Una sola variable
/// porque el patrón es uniforme: 6 px on, 4 px off.
punteada: bool,
}
/// Construye la vista multilienzo completa. El nodo raíz es un HStack con
/// el ancho exacto del contenido — el caller lo envuelve si necesita clip
/// o scroll.
///
/// Si `cuerpos` está vacío, devuelve un nodo vacío. Si `cartas` tiene
/// menos de `cuerpos.len()-1` entradas, los carriles faltantes quedan sin
/// hebras (no es un error: el caller puede ir agregando cartas).
pub fn multilienzo_view<Msg: Clone + 'static>(
cuerpos: &[&Cuerpo],
atoms: &IndiceAtoms<'_>,
cartas: &[Option<&CartaHebras>],
cfg: &MultilienzoConfig,
paleta_hebras: &PaletaHebras,
palette: &Palette,
) -> View<Msg> {
multilienzo_view_resaltado::<Msg>(
cuerpos, atoms, cartas, cfg, paleta_hebras, palette, "",
)
}
/// Variante con resaltado de búsqueda transversal: cualquier átomo cuyo
/// `content` contenga `resaltar` (case-insensitive) se pinta con un
/// fondo distinto. Pasar `""` desactiva el resaltado (idéntico a
/// [`multilienzo_view`]).
pub fn multilienzo_view_resaltado<Msg: Clone + 'static>(
cuerpos: &[&Cuerpo],
atoms: &IndiceAtoms<'_>,
cartas: &[Option<&CartaHebras>],
cfg: &MultilienzoConfig,
paleta_hebras: &PaletaHebras,
palette: &Palette,
resaltar: &str,
) -> View<Msg> {
armar_multilienzo::<Msg>(
cuerpos,
atoms,
cartas,
cfg,
paleta_hebras,
palette,
resaltar,
&|_, _| None,
)
}
/// Variante interactiva: además del resaltado, recibe un callback que
/// el runtime invoca al hacer click en cualquier bloque de átomo de
/// cualquier columna. El callback recibe `(i_cuerpo, atom_id)` — el
/// índice del cuerpo dentro del slice `cuerpos` (no su `branch_id`) y
/// el `Uuid` del átomo cliqueado — y produce el `Msg` que el caller
/// quiera disparar (típicamente: cambiar cuerpo activo + saltar caret
/// del IDE a ese átomo).
///
/// La cabecera de la columna (rótulo) **no** es clickeable; solo los
/// bloques de párrafo.
pub fn multilienzo_view_interactivo<Msg, F>(
cuerpos: &[&Cuerpo],
atoms: &IndiceAtoms<'_>,
cartas: &[Option<&CartaHebras>],
cfg: &MultilienzoConfig,
paleta_hebras: &PaletaHebras,
palette: &Palette,
resaltar: &str,
on_atom_click: F,
) -> View<Msg>
where
Msg: Clone + 'static,
F: Fn(usize, Uuid) -> Msg,
{
armar_multilienzo::<Msg>(
cuerpos,
atoms,
cartas,
cfg,
paleta_hebras,
palette,
resaltar,
&|i, id| Some(on_atom_click(i, id)),
)
}
/// Núcleo común: las variantes públicas se diferencian solo en si
/// pasan o no un handler de click por átomo. El handler se modela como
/// `&dyn Fn(usize, Uuid) -> Option<Msg>` — `None` significa "no
/// cablear `on_click` en ese bloque" (caso no interactivo).
fn armar_multilienzo<Msg: Clone + 'static>(
cuerpos: &[&Cuerpo],
atoms: &IndiceAtoms<'_>,
cartas: &[Option<&CartaHebras>],
cfg: &MultilienzoConfig,
paleta_hebras: &PaletaHebras,
palette: &Palette,
resaltar: &str,
on_atom_click: &dyn Fn(usize, Uuid) -> Option<Msg>,
) -> View<Msg> {
if cuerpos.is_empty() {
return View::new(Style::default());
}
let alto_max = cuerpos
.iter()
.map(|c| c.orden.len())
.max()
.unwrap_or(0);
let alto_contenido = cfg.padding_top
+ cfg.alto_header
+ alto_max as f32 * (cfg.altura_atom + cfg.gap_atom);
let mut hijos: Vec<View<Msg>> = Vec::with_capacity(cuerpos.len() * 2 - 1);
for (i, c) in cuerpos.iter().enumerate() {
hijos.push(columna_cuerpo::<Msg>(
c,
i,
atoms,
cfg,
palette,
alto_contenido,
resaltar,
on_atom_click,
));
if i + 1 < cuerpos.len() {
let carta = cartas.get(i).copied().flatten();
let derecha = cuerpos[i + 1];
hijos.push(carril_hebras::<Msg>(
c,
derecha,
carta,
cfg,
paleta_hebras,
palette,
alto_contenido,
));
}
}
let ancho_total = cuerpos.len() as f32 * cfg.ancho_cuerpo
+ (cuerpos.len().saturating_sub(1)) as f32 * cfg.ancho_carril;
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: length(ancho_total),
height: length(alto_contenido),
},
..Default::default()
})
.fill(palette.bg_app)
.children(hijos)
}
/// Columna de un cuerpo: cabecera + lista vertical de bloques de párrafo.
///
/// `i_cuerpo` es el índice de esta columna dentro del slice del caller;
/// se lo pasamos a `on_atom_click` para que el caller sepa **qué**
/// cuerpo recibió el click sin tener que re-buscar por `branch_id`.
fn columna_cuerpo<Msg: Clone + 'static>(
cuerpo: &Cuerpo,
i_cuerpo: usize,
atoms: &IndiceAtoms<'_>,
cfg: &MultilienzoConfig,
palette: &Palette,
alto_total: f32,
resaltar: &str,
on_atom_click: &dyn Fn(usize, Uuid) -> Option<Msg>,
) -> View<Msg> {
let header_text = format!(
"{} · {}",
cuerpo.metadatos.nombre_legible,
intencion_label(&cuerpo.metadatos.intencion)
);
let header = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(cfg.alto_header),
},
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(6.0_f32),
bottom: length(6.0_f32),
},
..Default::default()
})
.fill(palette.bg_panel)
.text_aligned(header_text, 11.0, palette.fg_muted, Alignment::Start);
let mut bloques: Vec<View<Msg>> = Vec::with_capacity(cuerpo.orden.len());
let resaltar_lc = if resaltar.is_empty() {
String::new()
} else {
resaltar.to_lowercase()
};
for (i, atom_id) in cuerpo.orden.iter().enumerate() {
let (preview, hit) = atoms
.get(atom_id)
.map(|a| {
let p = preview_text(a);
let hit = !resaltar_lc.is_empty()
&& a.content.to_lowercase().contains(&resaltar_lc);
(p, hit)
})
.unwrap_or_else(|| ("(átomo ausente)".to_string(), false));
let y = cfg.padding_top + cfg.alto_header + i as f32 * (cfg.altura_atom + cfg.gap_atom);
let click_msg = on_atom_click(i_cuerpo, *atom_id);
bloques.push(bloque_atom::<Msg>(&preview, y, cfg, palette, hit, click_msg));
}
View::new(Style {
position: Position::Relative,
size: Size {
width: length(cfg.ancho_cuerpo),
height: length(alto_total),
},
..Default::default()
})
.children({
let mut v = vec![header];
v.extend(bloques);
v
})
}
/// Bloque de un párrafo dentro de una columna: caja con preview de texto,
/// absolutamente posicionada para que las posiciones Y coincidan con las
/// que el carril usa al pintar hebras.
fn bloque_atom<Msg: Clone + 'static>(
preview: &str,
y: f32,
cfg: &MultilienzoConfig,
palette: &Palette,
hit_busqueda: bool,
click_msg: Option<Msg>,
) -> View<Msg> {
// Fondo destacado cuando el átomo matchea la búsqueda transversal.
// Mezcla 30% del color accent con el bg_panel base — visible sin
// ser estridente.
let fondo = if hit_busqueda {
mezclar(palette.bg_panel, palette.border_strong, 0.35)
} else {
palette.bg_panel
};
let mut v = View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(8.0_f32),
top: length(y),
right: length(8.0_f32),
bottom: auto(),
},
size: Size {
width: auto(),
height: length(cfg.altura_atom),
},
padding: Rect {
left: length(10.0_f32),
right: length(10.0_f32),
top: length(8.0_f32),
bottom: length(8.0_f32),
},
..Default::default()
})
.fill(fondo)
.radius(4.0)
.text_aligned(preview.to_string(), 13.0, palette.fg_text, Alignment::Start);
if let Some(msg) = click_msg {
v = v.on_click(msg);
}
v
}
/// Interpolación lineal de dos colores por componente RGBA. `t = 0`
/// devuelve `a`, `t = 1` devuelve `b`, intermedio el blend.
fn mezclar(a: Color, b: Color, t: f32) -> Color {
let t = t.clamp(0.0, 1.0);
let ca = a.components;
let cb = b.components;
Color::new([
ca[0] + (cb[0] - ca[0]) * t,
ca[1] + (cb[1] - ca[1]) * t,
ca[2] + (cb[2] - ca[2]) * t,
ca[3] + (cb[3] - ca[3]) * t,
])
}
/// Carril entre dos columnas: nodo que pinta diagonales (hebras) con
/// `paint_with`. Pre-calcula posiciones en CPU; la closure solo dibuja.
fn carril_hebras<Msg: Clone + 'static>(
izq: &Cuerpo,
der: &Cuerpo,
carta: Option<&CartaHebras>,
cfg: &MultilienzoConfig,
paleta: &PaletaHebras,
_palette: &Palette,
alto_total: f32,
) -> View<Msg> {
let hebras = match carta {
Some(c) => precomputar_hebras(izq, der, c, cfg, paleta),
None => Vec::new(),
};
let grosor = cfg.grosor_hebra;
let nodo = View::new(Style {
size: Size {
width: length(cfg.ancho_carril),
height: length(alto_total),
},
..Default::default()
});
if hebras.is_empty() {
return nodo;
}
nodo.paint_with(move |scene, _ts, rect| {
let stroke_solido = Stroke::new(grosor as f64);
let stroke_punteado = Stroke::new(grosor as f64).with_dashes(0.0, [6.0, 4.0]);
for h in &hebras {
let mut path = BezPath::new();
path.move_to((rect.x as f64, (rect.y + h.y_izq) as f64));
path.line_to(((rect.x + rect.w) as f64, (rect.y + h.y_der) as f64));
let s = if h.punteada { &stroke_punteado } else { &stroke_solido };
scene.stroke(s, Affine::IDENTITY, h.color, None, &path);
}
})
}
/// Pre-calcula `HebraPintada`s para un par de cuerpos. Resuelve la
/// ambigüedad de orden de `Alineamiento` (atom_a/atom_b vs izq/der)
/// consultando en qué cuerpo vive cada átomo.
fn precomputar_hebras(
izq: &Cuerpo,
der: &Cuerpo,
carta: &CartaHebras,
cfg: &MultilienzoConfig,
paleta: &PaletaHebras,
) -> Vec<HebraPintada> {
let pos_izq: HashMap<Uuid, usize> = izq
.orden
.iter()
.enumerate()
.map(|(i, &id)| (id, i))
.collect();
let pos_der: HashMap<Uuid, usize> = der
.orden
.iter()
.enumerate()
.map(|(i, &id)| (id, i))
.collect();
let centro = |i: usize| -> f32 {
cfg.padding_top
+ cfg.alto_header
+ i as f32 * (cfg.altura_atom + cfg.gap_atom)
+ cfg.altura_atom * 0.5
};
let mut out = Vec::with_capacity(carta.hebras.len());
for h in &carta.hebras {
// Resolver cuál atom va a la izquierda y cuál a la derecha.
let (i_izq, i_der) = if let (Some(&a), Some(&b)) =
(pos_izq.get(&h.atom_a), pos_der.get(&h.atom_b))
{
(a, b)
} else if let (Some(&a), Some(&b)) =
(pos_izq.get(&h.atom_b), pos_der.get(&h.atom_a))
{
(a, b)
} else {
// La hebra apunta a átomos ajenos a este par — ignorar.
continue;
};
let (color_base, atenuar_por_fuerza) = if !h.fresco {
(paleta.stale, false)
} else {
match &h.origen {
OrigenAlineamiento::Derivado { .. } => (paleta.derivada, false),
OrigenAlineamiento::Manual { .. } => (paleta.manual, false),
OrigenAlineamiento::Embeddings { .. } => (paleta.embeddings, true),
}
};
let color = if atenuar_por_fuerza {
atenuar_alpha(color_base, h.fuerza)
} else {
color_base
};
out.push(HebraPintada {
y_izq: centro(i_izq),
y_der: centro(i_der),
color,
punteada: !h.fresco,
});
}
out
}
/// Reduce el alpha de un color por un factor `[0, 1]`. Conserva los
/// componentes de color tal cual; solo modula transparencia. Útil para
/// modular la saturación visual de hebras según su `fuerza`.
fn atenuar_alpha(c: Color, factor: f32) -> Color {
let f = factor.clamp(0.0, 1.0);
let [r, g, b, a] = c.components;
Color::new([r, g, b, a * f])
}
/// Rótulo corto y legible para cada variante de `Intencion`. La UI lo
/// muestra junto al `nombre_legible` del cuerpo en la cabecera de columna.
fn intencion_label(intencion: &pluma_cuerpo::Intencion) -> String {
use pluma_cuerpo::Intencion;
match intencion {
Intencion::Original => "original".to_string(),
Intencion::Traduccion => "traducción".to_string(),
Intencion::Tono { etiqueta } => format!("tono: {etiqueta}"),
Intencion::Resumen { palabras_objetivo: Some(n) } => format!("resumen ≈{n}p"),
Intencion::Resumen { palabras_objetivo: None } => "resumen".to_string(),
Intencion::Reescritura { .. } => "reescritura".to_string(),
Intencion::Anotacion => "anotación".to_string(),
Intencion::Custom { kind } => kind.clone(),
}
}
/// Recorta el `content` del átomo a un preview de UNA línea aproximado.
/// Sin parley aquí — solo trunca por bytes (cuidando frontera UTF-8) y
/// sustituye saltos de línea por espacios.
fn preview_text(atom: &NarrativeAtom) -> String {
const LIMITE: usize = 140;
let mut s = atom.content.replace('\n', " ");
if s.len() > LIMITE {
// Recortar respetando UTF-8.
let mut corte = LIMITE;
while !s.is_char_boundary(corte) && corte > 0 {
corte -= 1;
}
s.truncate(corte);
s.push('…');
}
s
}
#[cfg(test)]
mod pruebas {
use super::*;
use pluma_align::{alinear_uno_a_uno, OrigenAlineamiento};
use pluma_cuerpo::Intencion;
/// Helper: cuerpo + atoms vivos (los retiene el caller).
fn cuerpo_con_atomos(branch: &str, intencion: Intencion, textos: &[&str]) -> (Cuerpo, Vec<NarrativeAtom>) {
let mut c = Cuerpo::nuevo(branch, branch, intencion, 100);
let atoms: Vec<NarrativeAtom> = textos
.iter()
.map(|t| NarrativeAtom::new(*t, branch))
.collect();
for a in &atoms {
c.agregar(a.id, 101);
}
(c, atoms)
}
#[test]
fn vacio_devuelve_vista_sin_panico() {
let cfg = MultilienzoConfig::default();
let paleta = PaletaHebras::default();
let palette = Palette::default();
let _v: View<()> = multilienzo_view(&[], &IndiceAtoms::new(), &[], &cfg, &paleta, &palette);
}
#[test]
fn precomputar_hebras_resuelve_orden_atom_a_atom_b() {
let (a, atoms_a) = cuerpo_con_atomos("es", Intencion::Original, &["uno", "dos"]);
let (b, atoms_b) = cuerpo_con_atomos("qu", Intencion::Traduccion, &["huk", "iskay"]);
// Carta con atom_a=es_id, atom_b=qu_id (orden natural).
let carta_natural = alinear_uno_a_uno(
&a, &b,
OrigenAlineamiento::Derivado { transformacion: Uuid::new_v4(), timestamp: 1 },
);
let cfg = MultilienzoConfig::default();
let paleta = PaletaHebras::default();
let hebras_n = precomputar_hebras(&a, &b, &carta_natural, &cfg, &paleta);
assert_eq!(hebras_n.len(), 2);
// Misma carta pero invertida (atom_a=qu, atom_b=es). Debe seguir resolviendo
// las posiciones correctamente al cuerpo izq/der.
let mut carta_invertida = CartaHebras::nueva().con_par(b.id, a.id);
for h in &carta_natural.hebras {
let invertida = pluma_align::Alineamiento {
id: h.id,
atom_a: h.atom_b,
atom_b: h.atom_a,
fuerza: h.fuerza,
origen: h.origen.clone(),
fresco: h.fresco,
};
carta_invertida.agregar(invertida);
}
let hebras_i = precomputar_hebras(&a, &b, &carta_invertida, &cfg, &paleta);
// Las posiciones y_izq/y_der deben ser las mismas, sin importar el orden
// declarado en la carta. (Es robusto a la convención del caller.)
assert_eq!(hebras_n.len(), hebras_i.len());
for (n, i) in hebras_n.iter().zip(hebras_i.iter()) {
assert!((n.y_izq - i.y_izq).abs() < 1e-3);
assert!((n.y_der - i.y_der).abs() < 1e-3);
}
let _ = (atoms_a, atoms_b);
}
#[test]
fn stale_pinta_punteada_y_color_stale() {
let (a, atoms_a) = cuerpo_con_atomos("es", Intencion::Original, &["x"]);
let (b, atoms_b) = cuerpo_con_atomos("qu", Intencion::Traduccion, &["y"]);
let mut carta = alinear_uno_a_uno(
&a, &b,
OrigenAlineamiento::Embeddings { modelo: "iniy-1".into(), timestamp: 100 },
);
carta.hebras[0].fresco = false;
let paleta = PaletaHebras::default();
let hebras = precomputar_hebras(&a, &b, &carta, &MultilienzoConfig::default(), &paleta);
assert_eq!(hebras.len(), 1);
assert!(hebras[0].punteada);
// Color stale (alpha bajo).
assert!(hebras[0].color.components[3] < 0.6);
let _ = (atoms_a, atoms_b);
}
#[test]
fn embeddings_modulan_alpha_por_fuerza() {
let (a, _atoms_a) = cuerpo_con_atomos("es", Intencion::Original, &["x"]);
let (b, _atoms_b) = cuerpo_con_atomos("qu", Intencion::Traduccion, &["y"]);
let mut carta = alinear_uno_a_uno(
&a, &b,
OrigenAlineamiento::Embeddings { modelo: "iniy-1".into(), timestamp: 100 },
);
carta.hebras[0].fuerza = 0.4;
let paleta = PaletaHebras::default();
let hebras = precomputar_hebras(&a, &b, &carta, &MultilienzoConfig::default(), &paleta);
// El alpha debe ser ~0.4 del alpha base de paleta.embeddings.
let a_base = paleta.embeddings.components[3];
assert!((hebras[0].color.components[3] - a_base * 0.4).abs() < 1e-3);
}
#[test]
fn variante_interactiva_invoca_callback_por_cada_atomo() {
use std::cell::RefCell;
let (a, _atoms_a) = cuerpo_con_atomos("es", Intencion::Original, &["uno", "dos", "tres"]);
let (b, _atoms_b) = cuerpo_con_atomos("qu", Intencion::Traduccion, &["huk", "iskay"]);
let idx: IndiceAtoms = IndiceAtoms::new();
let cuerpos: Vec<&Cuerpo> = vec![&a, &b];
let cartas: Vec<Option<&CartaHebras>> = vec![None];
let cfg = MultilienzoConfig::default();
let paleta = PaletaHebras::default();
let palette = Palette::default();
let visitas: RefCell<Vec<(usize, Uuid)>> = RefCell::new(Vec::new());
let _v: View<()> = multilienzo_view_interactivo(
&cuerpos,
&idx,
&cartas,
&cfg,
&paleta,
&palette,
"",
|i, id| {
visitas.borrow_mut().push((i, id));
},
);
// Cada átomo de cada columna debe haber producido una visita —
// así sabemos que el cableado de `on_click` está pasando por la
// ruta del callback (3 átomos de `a` + 2 de `b` = 5).
let v = visitas.borrow();
assert_eq!(v.len(), 5);
let cuerpo_ids: Vec<usize> = v.iter().map(|(i, _)| *i).collect();
assert_eq!(cuerpo_ids, vec![0, 0, 0, 1, 1]);
// Los Uuid emitidos deben coincidir con el orden de los cuerpos.
assert_eq!(v[0].1, a.orden[0]);
assert_eq!(v[2].1, a.orden[2]);
assert_eq!(v[3].1, b.orden[0]);
}
#[test]
fn preview_text_trunca_respetando_utf8() {
let txt = "á".repeat(200); // cada `á` ocupa 2 bytes
let atom = NarrativeAtom::new(&txt, "main");
let p = preview_text(&atom);
// No debe panicar y debe terminar en `…`.
assert!(p.ends_with('…'));
assert!(p.len() <= 144);
}
}
@@ -0,0 +1,744 @@
//! `multilienzo_editor` — N editores reales de cuerpo lado-a-lado.
//!
//! Reemplazo del par "vista panorámica readonly arriba + IDE único
//! abajo" por **un solo plano**: cada cuerpo es un editor multi-párrafo
//! real en su propia columna, las hebras cruzan los carriles intermedios
//! entre columnas. Click en cualquier editor le da el foco (cambia el
//! cuerpo activo) y posiciona el caret en la línea cliqueada.
//!
//! Diseño:
//!
//! ┌────────────────┬─────┬────────────────┬─────┬────────────────┐
//! │ header cuerpo0 │ │ header cuerpo1 │ │ header cuerpo2 │
//! ├────────────────┤ c ├────────────────┤ c ├────────────────┤
//! │ │ a │ │ a │ │
//! │ CuerpoIde 0 │ r │ CuerpoIde 1 │ r │ CuerpoIde 2 │
//! │ (text-editor) │ r │ (text-editor) │ r │ (text-editor) │
//! │ │ i │ │ i │ │
//! │ │ l │ │ l │ │
//! └────────────────┴─────┴────────────────┴─────┴────────────────┘
//! │ │
//! │ hebras (paint_with) │
//!
//! Las hebras se pintan en coordenadas vivas: la `y` de cada extremo
//! se calcula como `(line - scroll_offset) * line_height + line_height/2`
//! del editor correspondiente, así siguen al scroll real. Si un extremo
//! queda fuera del viewport vertical del carril, se clampea al borde
//! (efecto "asoma por arriba/abajo" hasta que el usuario scrollea ese
//! cuerpo a la vista). Cada cuerpo scrollea independientemente; sin
//! scroll sincronizado en este MVP — las hebras se desalinean cuando
//! los viewports divergen, que es exactamente el feedback visual que
//! le decimos al usuario.
use llimphi_ui::llimphi_layout::taffy::prelude::{
auto, length, percent, FlexDirection, Rect, Size, Style,
};
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Stroke};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use llimphi_widget_text_editor::{
EditorMetrics, EditorPalette, Language, PointerEvent,
};
use pluma_align::{CartaHebras, OrigenAlineamiento};
use pluma_cuerpo::Cuerpo;
use uuid::Uuid;
use crate::cuerpo_ide::{cuerpo_ide_view, CuerpoIde};
use crate::multilienzo::PaletaHebras;
use crate::Palette;
/// Configuración geométrica de la vista de editores lado-a-lado.
#[derive(Debug, Clone, Copy)]
pub struct ConfigMultilienzoEditor {
/// Ancho del carril intermedio donde se pintan las hebras, en px.
pub ancho_carril: f32,
/// Altura del header (rótulo del cuerpo) sobre cada editor, en px.
pub alto_header: f32,
/// Grosor del trazo de las hebras, en px.
pub grosor_hebra: f32,
/// Padding (en px) que rodea cada editor cuando es el cuerpo activo
/// — pintado con `palette.border_strong` para destacar el foco.
pub grosor_foco: f32,
}
impl Default for ConfigMultilienzoEditor {
fn default() -> Self {
Self {
ancho_carril: 56.0,
alto_header: 28.0,
grosor_hebra: 2.0,
grosor_foco: 2.0,
}
}
}
/// Datos pre-calculados de una hebra entre dos editores vivos.
#[derive(Debug, Clone, Copy)]
struct HebraEditor {
/// Y en píxeles dentro del rect del carril (ya considera el
/// `scroll_offset` y `alto_header` de cada editor).
y_izq: f32,
y_der: f32,
color: Color,
punteada: bool,
}
/// Render principal: N editores en HStack con carriles de hebras entre
/// cada par consecutivo.
///
/// Contrato:
/// - `ides[i]` corresponde a `cuerpos[i]`. El caller mantiene la
/// correspondencia 1↔1.
/// - `cartas[i]` es la carta entre `cuerpos[i]` y `cuerpos[i+1]`. `None`
/// deja el carril vacío.
/// - `activo` es el índice del cuerpo con foco — recibe un borde accent
/// visible. Si está fuera de rango, ningún editor se destaca.
/// - `on_pointer(i, ev)` se invoca para clicks/drag dentro del editor
/// `i`. El caller convierte `(x, y)` a `(line, col)` con
/// `metrics.screen_to_pos(x, y, scroll_offset)` y aplica al ide
/// correspondiente.
///
/// El nodo raíz mide ancho fijo (suma de columnas + carriles) y `height
/// = percent(1.0)` — el caller lo envuelve si quiere darle un tamaño
/// concreto.
pub fn multilienzo_editor_view<Msg, FPtr>(
ides: &[&CuerpoIde],
cuerpos: &[&Cuerpo],
cartas: &[Option<&CartaHebras>],
activo: usize,
palette_editor: &EditorPalette,
paleta_hebras: &PaletaHebras,
palette_lienzo: &Palette,
cfg: &ConfigMultilienzoEditor,
metrics: EditorMetrics,
visible_lines: usize,
language: Language,
on_pointer: FPtr,
) -> View<Msg>
where
Msg: Clone + 'static,
FPtr: Fn(usize, PointerEvent) -> Msg + Send + Sync + Clone + 'static,
{
assert_eq!(
ides.len(),
cuerpos.len(),
"multilienzo_editor: ides y cuerpos deben tener el mismo largo"
);
if ides.is_empty() {
return View::new(Style::default());
}
let mut hijos: Vec<View<Msg>> = Vec::with_capacity(ides.len() * 2 - 1);
for i in 0..ides.len() {
let on_pointer_i = {
let cb = on_pointer.clone();
move |ev: PointerEvent| Some(cb(i, ev))
};
hijos.push(columna_editor(
ides[i],
cuerpos[i],
i == activo,
palette_editor,
palette_lienzo,
cfg,
metrics,
visible_lines,
language,
on_pointer_i,
));
if i + 1 < ides.len() {
let carta = cartas.get(i).copied().flatten();
hijos.push(carril_editor(
ides[i],
ides[i + 1],
carta,
cfg,
paleta_hebras,
metrics,
));
}
}
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(palette_lienzo.bg_app)
.children(hijos)
}
/// Una columna: wrapper que pinta el borde de foco cuando el cuerpo
/// está activo, header con el nombre del cuerpo arriba, editor real
/// abajo expandido a flex-grow.
#[allow(clippy::too_many_arguments)]
fn columna_editor<Msg, FPtr>(
ide: &CuerpoIde,
cuerpo: &Cuerpo,
activo: bool,
palette_editor: &EditorPalette,
palette_lienzo: &Palette,
cfg: &ConfigMultilienzoEditor,
metrics: EditorMetrics,
visible_lines: usize,
language: Language,
on_pointer: FPtr,
) -> View<Msg>
where
Msg: Clone + 'static,
FPtr: Fn(PointerEvent) -> Option<Msg> + Send + Sync + Clone + 'static,
{
let header_text = format!(
"{} · {}",
cuerpo.metadatos.nombre_legible,
intencion_label(&cuerpo.metadatos.intencion),
);
let header_color = if activo {
palette_lienzo.border_strong
} else {
palette_lienzo.fg_muted
};
let header = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(cfg.alto_header),
},
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(6.0_f32),
bottom: length(6.0_f32),
},
..Default::default()
})
.fill(palette_lienzo.bg_panel)
.text_aligned(header_text, 11.0, header_color, Alignment::Start);
let editor = cuerpo_ide_view::<Msg>(
ide,
palette_editor,
metrics,
visible_lines,
language,
on_pointer,
);
// Overlay con divisores entre átomos: una línea horizontal sutil en
// la "línea vacía" del separador (la línea blanca del `\n\n`) entre
// cada par de átomos consecutivos. Saca el ojo del muro de texto y
// marca dónde termina cada párrafo lógico. El overlay no tiene
// handler de click, así que es transparente al hit-test —
// `paint_with` solo dibuja, no captura.
let overlay_separadores =
overlay_separadores_atomos::<Msg>(ide, metrics, palette_lienzo);
let contenedor_editor = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: auto(),
},
flex_grow: 1.0,
..Default::default()
})
.fill(palette_editor.bg)
.children(vec![editor, overlay_separadores]);
// Wrapper con padding accent cuando es el activo — el padding actúa
// como borde grueso visible (Llimphi todavía no expone `border()`
// en View, así que usamos fill + padding para simularlo).
let pad = if activo { cfg.grosor_foco } else { 0.0 };
let fondo_wrapper = if activo {
palette_lienzo.border_strong
} else {
palette_lienzo.bg_app
};
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(pad),
right: length(pad),
top: length(pad),
bottom: length(pad),
},
..Default::default()
})
.fill(fondo_wrapper)
.children(vec![View::new(Style {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.children(vec![header, contenedor_editor])])
}
/// Carril entre dos editores: pinta las hebras de la carta correspondiente
/// con `paint_with`. Las posiciones Y se resuelven contra los ides vivos
/// (línea inicial del átomo × `line_height`, menos `scroll_offset`).
fn carril_editor<Msg: Clone + 'static>(
izq: &CuerpoIde,
der: &CuerpoIde,
carta: Option<&CartaHebras>,
cfg: &ConfigMultilienzoEditor,
paleta: &PaletaHebras,
metrics: EditorMetrics,
) -> View<Msg> {
let hebras = match carta {
Some(c) => precomputar_hebras_editor(izq, der, c, cfg, paleta, metrics),
None => Vec::new(),
};
let grosor = cfg.grosor_hebra;
let nodo = View::new(Style {
size: Size {
width: length(cfg.ancho_carril),
height: percent(1.0_f32),
},
..Default::default()
});
if hebras.is_empty() {
return nodo;
}
nodo.paint_with(move |scene, _ts, rect| {
let solido = Stroke::new(grosor as f64);
let punteado = Stroke::new(grosor as f64).with_dashes(0.0, [6.0, 4.0]);
let alto_carril = rect.h;
// Bezier cúbica con tangentes horizontales en ambos extremos —
// mismo look que un grafo Sankey o las hebras de `git log
// --graph`. El control point arranca a `t * ancho` del extremo
// en X y queda a la altura del extremo en Y. `t = 0.5` deja la
// curva con su panza justo en el centro del carril.
let t = 0.5_f32;
let dx = (rect.w * t) as f64;
for h in &hebras {
// Clamp suave al alto del carril — cuando un átomo está fuera
// del viewport, la hebra se "asoma" pegada al borde.
let y_izq = h.y_izq.clamp(0.0, alto_carril);
let y_der = h.y_der.clamp(0.0, alto_carril);
let x1 = rect.x as f64;
let x2 = (rect.x + rect.w) as f64;
let y1 = (rect.y + y_izq) as f64;
let y2 = (rect.y + y_der) as f64;
let mut path = BezPath::new();
path.move_to((x1, y1));
path.curve_to((x1 + dx, y1), (x2 - dx, y2), (x2, y2));
let stroke = if h.punteada { &punteado } else { &solido };
scene.stroke(stroke, Affine::IDENTITY, h.color, None, &path);
}
})
}
/// Arma el overlay que pinta los divisores entre átomos. Va sobre el
/// editor — superpuesto al contenido del text-editor pero detrás de
/// nada (es el último hijo del contenedor). Sin `on_click`, así que el
/// hit-test del runtime lo ignora y los clicks llegan al editor abajo.
fn overlay_separadores_atomos<Msg: Clone + 'static>(
ide: &CuerpoIde,
metrics: EditorMetrics,
palette_lienzo: &Palette,
) -> View<Msg> {
let ys = precomputar_y_separadores(ide, metrics);
let line_h = metrics.line_height;
let gutter = metrics.gutter_width as f64;
// Color sutil: fg_muted con alpha reducido — visible pero sin
// competir con el texto.
let base = palette_lienzo.fg_muted.components;
let color = Color::new([base[0], base[1], base[2], base[3] * 0.35]);
let nodo = View::new(Style {
position: llimphi_ui::llimphi_layout::taffy::Position::Absolute,
inset: Rect {
left: length(0.0_f32),
top: length(0.0_f32),
right: length(0.0_f32),
bottom: length(0.0_f32),
},
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
});
if ys.is_empty() {
return nodo;
}
nodo.paint_with(move |scene, _ts, rect| {
let stroke = Stroke::new(1.0);
for &y_local in &ys {
// El editor no tiene padding vertical interno; rangos válidos
// del overlay son [0, rect.h]. Las líneas fuera de viewport
// se omiten (no se pintan recortadas — confundiría).
if y_local < 0.0 || y_local > rect.h {
continue;
}
// Salta el gutter (no separamos sobre los números de línea).
let x1 = rect.x as f64 + gutter;
let x2 = (rect.x + rect.w) as f64;
let y = (rect.y + y_local) as f64;
let mut path = BezPath::new();
path.move_to((x1, y));
path.line_to((x2, y));
scene.stroke(&stroke, Affine::IDENTITY, color, None, &path);
}
// suppress unused warning if compiler complains about line_h
let _ = line_h;
})
}
/// Devuelve los Y locales (en el rect del editor, sin contar el header
/// que vive en otro nodo) donde cae el separador entre átomos
/// consecutivos del ide, ajustados al scroll actual.
fn precomputar_y_separadores(ide: &CuerpoIde, metrics: EditorMetrics) -> Vec<f32> {
let mut out = Vec::new();
if ide.editor_cuerpo.atom_ids.len() < 2 {
return out;
}
let scroll = ide.state.scroll_offset as f32;
for i in 1..ide.editor_cuerpo.atom_ids.len() {
let id = ide.editor_cuerpo.atom_ids[i];
let Some((line, _)) = ide.posicion_de_atom(id) else {
continue;
};
// El SEPARADOR es `\n\n`, que aporta una línea vacía entre dos
// párrafos. El átomo `i` arranca en `line`; la línea vacía es
// `line - 1`. Si el átomo arranca en 0 (no debería pasar para
// i >= 1), saltamos.
if line == 0 {
continue;
}
let linea_sep = (line - 1) as f32;
let y = (linea_sep - scroll + 0.5) * metrics.line_height;
out.push(y);
}
out
}
/// Resuelve para cada hebra de la carta su posición Y en cada editor.
/// Acepta que la carta tenga `atom_a/atom_b` en cualquier orden respecto
/// a `izq/der` — ya lo hacía el multilienzo readonly, replicamos la
/// misma robustez acá.
fn precomputar_hebras_editor(
izq: &CuerpoIde,
der: &CuerpoIde,
carta: &CartaHebras,
cfg: &ConfigMultilienzoEditor,
paleta: &PaletaHebras,
metrics: EditorMetrics,
) -> Vec<HebraEditor> {
let header = cfg.alto_header;
let y_de_atom = |ide: &CuerpoIde, id: Uuid| -> Option<f32> {
let (line, _) = ide.posicion_de_atom(id)?;
let scroll = ide.state.scroll_offset as f32;
// Centro vertical de la línea, en coordenadas locales al carril.
Some(header + (line as f32 - scroll + 0.5) * metrics.line_height)
};
let mut out = Vec::with_capacity(carta.hebras.len());
for h in &carta.hebras {
let (y_izq, y_der) = if let (Some(a), Some(b)) =
(y_de_atom(izq, h.atom_a), y_de_atom(der, h.atom_b))
{
(a, b)
} else if let (Some(a), Some(b)) =
(y_de_atom(izq, h.atom_b), y_de_atom(der, h.atom_a))
{
(a, b)
} else {
continue;
};
let (color_base, modular_fuerza) = if !h.fresco {
(paleta.stale, false)
} else {
match &h.origen {
OrigenAlineamiento::Derivado { .. } => (paleta.derivada, false),
OrigenAlineamiento::Manual { .. } => (paleta.manual, false),
OrigenAlineamiento::Embeddings { .. } => (paleta.embeddings, true),
}
};
let color = if modular_fuerza {
modular_alpha(color_base, h.fuerza)
} else {
color_base
};
out.push(HebraEditor {
y_izq,
y_der,
color,
punteada: !h.fresco,
});
}
out
}
/// Copia el `scroll_offset` del cuerpo activo al resto de los editores —
/// el patrón estándar para mantener las hebras alineadas cuando el
/// usuario scrollea uno solo. Cada destino clampea al fin de su buffer
/// (si el cuerpo destino es más corto, su scroll queda topado en su
/// última línea — el viewport muestra menos contenido, pero nunca
/// líneas espurias arriba).
///
/// El caller suele llamar esto al final de cada `update` que pueda
/// haber tocado el scroll del activo (typing con `ensure_caret_visible`,
/// PageUp/PageDown, click+set_caret).
pub fn sincronizar_scroll_desde_activo(ides: &mut [CuerpoIde], activo: usize) {
if activo >= ides.len() {
return;
}
let scroll = ides[activo].state.scroll_offset;
sincronizar_scroll(ides, scroll, activo);
}
/// Versión explícita: aplica `scroll` a todos los `ides` salvo el índice
/// `excepto`. Útil cuando el caller ya tiene el valor de scroll (p.ej.
/// porque viene de un wheel event futuro) y no quiere depender del
/// estado del activo.
pub fn sincronizar_scroll(ides: &mut [CuerpoIde], scroll: usize, excepto: usize) {
for (i, ide) in ides.iter_mut().enumerate() {
if i == excepto {
continue;
}
let max = ide.state.line_count().saturating_sub(1);
ide.state.scroll_offset = scroll.min(max);
}
}
fn modular_alpha(c: Color, factor: f32) -> Color {
let f = factor.clamp(0.0, 1.0);
let [r, g, b, a] = c.components;
Color::new([r, g, b, a * f])
}
/// Rótulo corto y legible para cada variante de `Intencion`. Copiado
/// (no factorizado) de `multilienzo.rs`: son dos vistas distintas con
/// dos paletas distintas, conviene que cada una controle su rótulo.
fn intencion_label(intencion: &pluma_cuerpo::Intencion) -> String {
use pluma_cuerpo::Intencion;
match intencion {
Intencion::Original => "original".to_string(),
Intencion::Traduccion => "traducción".to_string(),
Intencion::Tono { etiqueta } => format!("tono: {etiqueta}"),
Intencion::Resumen {
palabras_objetivo: Some(n),
} => format!("resumen ≈{n}p"),
Intencion::Resumen {
palabras_objetivo: None,
} => "resumen".to_string(),
Intencion::Reescritura { .. } => "reescritura".to_string(),
Intencion::Anotacion => "anotación".to_string(),
Intencion::Custom { kind } => kind.clone(),
}
}
#[cfg(test)]
mod pruebas {
use super::*;
use pluma_align::{alinear_uno_a_uno, OrigenAlineamiento};
use pluma_core::NarrativeAtom;
use pluma_cuerpo::{Cuerpo, Intencion};
use std::collections::HashMap;
fn ide_con_textos(branch: &str, intencion: Intencion, textos: &[&str]) -> (Cuerpo, Vec<NarrativeAtom>, CuerpoIde) {
let mut c = Cuerpo::nuevo(branch, branch, intencion, 100);
let atoms: Vec<NarrativeAtom> = textos
.iter()
.map(|t| NarrativeAtom::new(*t, branch))
.collect();
for a in &atoms {
c.agregar(a.id, 101);
}
let idx: HashMap<Uuid, &NarrativeAtom> = atoms.iter().map(|a| (a.id, a)).collect();
let ide = CuerpoIde::from_cuerpo(&c, &idx);
(c, atoms, ide)
}
#[test]
fn separadores_se_computan_uno_por_juntura_entre_atomos() {
// 3 átomos → 2 separadores. El primer átomo arranca en línea 0,
// los siguientes en 2 y 4 (con SEPARADOR `\n\n` = 1 línea vacía
// entre cada par).
let (_, _, ide) = ide_con_textos("es", Intencion::Original, &["uno", "dos", "tres"]);
let metrics = EditorMetrics::for_font_size(13.0);
let ys = precomputar_y_separadores(&ide, metrics);
assert_eq!(ys.len(), 2);
// Separador entre átomo 0 y 1 → línea 1 (atomo[1] arranca en 2).
let y_sep_01 = (1.0 + 0.5) * metrics.line_height;
assert!((ys[0] - y_sep_01).abs() < 1e-3);
// Separador entre átomo 1 y 2 → línea 3 (atomo[2] arranca en 4).
let y_sep_12 = (3.0 + 0.5) * metrics.line_height;
assert!((ys[1] - y_sep_12).abs() < 1e-3);
}
#[test]
fn separadores_siguen_al_scroll() {
let (_, _, mut ide) = ide_con_textos("es", Intencion::Original, &["uno", "dos"]);
let metrics = EditorMetrics::for_font_size(13.0);
let antes = precomputar_y_separadores(&ide, metrics);
ide.state.scroll_offset = 2;
let despues = precomputar_y_separadores(&ide, metrics);
// El separador queda más arriba cuando se scrollea hacia abajo:
// la diferencia debe ser exactamente 2 × line_height.
assert_eq!(antes.len(), 1);
assert_eq!(despues.len(), 1);
let delta = antes[0] - despues[0];
assert!((delta - 2.0 * metrics.line_height).abs() < 1e-3);
}
#[test]
fn separadores_vacio_para_un_solo_atomo() {
let (_, _, ide) = ide_con_textos("es", Intencion::Original, &["uno"]);
let metrics = EditorMetrics::for_font_size(13.0);
let ys = precomputar_y_separadores(&ide, metrics);
assert!(ys.is_empty());
}
#[test]
fn vacio_devuelve_vista_sin_panico() {
let v: View<()> = multilienzo_editor_view(
&[],
&[],
&[],
0,
&EditorPalette::default(),
&PaletaHebras::default(),
&Palette::default(),
&ConfigMultilienzoEditor::default(),
EditorMetrics::for_font_size(13.0),
100,
Language::Plain,
|_, _| (),
);
let _ = v;
}
#[test]
fn precomputar_hebras_alinea_centros_de_linea_con_scroll() {
let (a, _atoms_a, ide_a) = ide_con_textos("es", Intencion::Original, &["uno", "dos"]);
let (b, _atoms_b, ide_b) = ide_con_textos("qu", Intencion::Traduccion, &["huk", "iskay"]);
let carta = alinear_uno_a_uno(
&a,
&b,
OrigenAlineamiento::Derivado {
transformacion: Uuid::new_v4(),
timestamp: 1,
},
);
let cfg = ConfigMultilienzoEditor::default();
let paleta = PaletaHebras::default();
let metrics = EditorMetrics::for_font_size(13.0);
let hebras = precomputar_hebras_editor(&ide_a, &ide_b, &carta, &cfg, &paleta, metrics);
assert_eq!(hebras.len(), 2);
// El primer átomo arranca en línea 0 — centro vertical = header + 0.5 * line_height.
let y_esperada_atom_0 = cfg.alto_header + 0.5 * metrics.line_height;
assert!((hebras[0].y_izq - y_esperada_atom_0).abs() < 1e-3);
assert!((hebras[0].y_der - y_esperada_atom_0).abs() < 1e-3);
// El segundo átomo arranca después del primer párrafo (1 línea de
// contenido + 1 línea vacía del separador) = línea 2.
let y_esperada_atom_1 = cfg.alto_header + (2.0 + 0.5) * metrics.line_height;
assert!((hebras[1].y_izq - y_esperada_atom_1).abs() < 1e-3);
}
#[test]
fn stale_pinta_punteada() {
let (a, _atoms_a, ide_a) = ide_con_textos("es", Intencion::Original, &["x"]);
let (b, _atoms_b, ide_b) = ide_con_textos("qu", Intencion::Traduccion, &["y"]);
let mut carta = alinear_uno_a_uno(
&a,
&b,
OrigenAlineamiento::Embeddings {
modelo: "iniy-1".into(),
timestamp: 100,
},
);
carta.hebras[0].fresco = false;
let hebras = precomputar_hebras_editor(
&ide_a,
&ide_b,
&carta,
&ConfigMultilienzoEditor::default(),
&PaletaHebras::default(),
EditorMetrics::for_font_size(13.0),
);
assert_eq!(hebras.len(), 1);
assert!(hebras[0].punteada);
}
#[test]
fn sincronizar_scroll_copia_al_resto_y_clampea() {
// Cuerpo activo largo: 10 átomos. Cuerpos destino: uno largo, uno corto.
let textos_largos: Vec<String> = (0..10).map(|i| format!("p{i}")).collect();
let textos_largos_ref: Vec<&str> = textos_largos.iter().map(|s| s.as_str()).collect();
let (_, _, ide_largo_a) = ide_con_textos("es", Intencion::Original, &textos_largos_ref);
let (_, _, ide_largo_b) = ide_con_textos("qu", Intencion::Traduccion, &textos_largos_ref);
let (_, _, ide_corto) = ide_con_textos("en", Intencion::Traduccion, &["solo uno"]);
let mut ides = vec![ide_largo_a, ide_largo_b, ide_corto];
ides[0].state.scroll_offset = 12; // activo scrollea hacia abajo
sincronizar_scroll_desde_activo(&mut ides, 0);
// El otro cuerpo largo recibe el scroll tal cual (su line_count
// permite scrollear más allá de 12).
assert!(ides[1].state.scroll_offset >= 12 - 1);
// El cuerpo corto se clampea a su última línea (solo tiene 1
// párrafo ⇒ line_count == 1 ⇒ max_scroll == 0).
assert_eq!(ides[2].state.scroll_offset, 0);
// El activo no se toca.
assert_eq!(ides[0].state.scroll_offset, 12);
}
#[test]
fn sincronizar_scroll_es_idempotente_sin_cambios() {
let (_, _, mut ide_a) = ide_con_textos("es", Intencion::Original, &["uno", "dos", "tres"]);
let (_, _, mut ide_b) = ide_con_textos("qu", Intencion::Traduccion, &["huk", "iskay", "kimsa"]);
ide_a.state.scroll_offset = 2;
ide_b.state.scroll_offset = 2;
let mut ides = vec![ide_a, ide_b];
sincronizar_scroll_desde_activo(&mut ides, 0);
sincronizar_scroll_desde_activo(&mut ides, 0);
assert_eq!(ides[0].state.scroll_offset, 2);
assert_eq!(ides[1].state.scroll_offset, 2);
}
#[test]
fn scroll_offset_desplaza_y_de_la_hebra() {
let (a, _atoms_a, mut ide_a) = ide_con_textos("es", Intencion::Original, &["uno"]);
let (b, _atoms_b, ide_b) = ide_con_textos("qu", Intencion::Traduccion, &["huk"]);
let carta = alinear_uno_a_uno(
&a,
&b,
OrigenAlineamiento::Derivado {
transformacion: Uuid::new_v4(),
timestamp: 1,
},
);
let cfg = ConfigMultilienzoEditor::default();
let paleta = PaletaHebras::default();
let metrics = EditorMetrics::for_font_size(13.0);
let antes = precomputar_hebras_editor(&ide_a, &ide_b, &carta, &cfg, &paleta, metrics);
ide_a.state.scroll_offset = 3;
let despues = precomputar_hebras_editor(&ide_a, &ide_b, &carta, &cfg, &paleta, metrics);
// El lado izquierdo se desplaza 3 líneas hacia arriba; el lado
// derecho queda igual.
let delta = antes[0].y_izq - despues[0].y_izq;
assert!((delta - 3.0 * metrics.line_height).abs() < 1e-3);
assert!((antes[0].y_der - despues[0].y_der).abs() < 1e-3);
}
}
@@ -0,0 +1,22 @@
[package]
name = "pluma-graph-transform"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "pluma — pegamento entre pluma-graph y pluma-transform: helpers para persistir un ProductoTransformacion en el NarrativeGraph y construir el índice Uuid→&NarrativeAtom que los ejecutores LLM esperan."
[dependencies]
uuid = { workspace = true, features = ["serde"] }
pluma-core = { path = "../pluma-core" }
pluma-cuerpo = { path = "../pluma-cuerpo" }
pluma-align = { path = "../pluma-align" }
pluma-graph = { path = "../pluma-graph" }
pluma-transform = { path = "../pluma-transform" }
[dev-dependencies]
tokio = { workspace = true }
pluma-llm-mock = { path = "../pluma-llm-mock" }
pluma-transform-llm = { path = "../pluma-transform-llm" }
pluma-transform-tabla = { path = "../pluma-transform-tabla" }
@@ -0,0 +1,19 @@
# pluma-graph-transform
> Mutaciones del DAG de [pluma](../README.md). Insert / mutar / eliminar atómico.
Todas las modificaciones del grafo pasan por este crate. Cada operación devuelve un `CambioGrafo` reversible. Apilable como historial de undo.
## API
```rust
use pluma_graph_transform::{aplicar, CambioGrafo};
let cambio = CambioGrafo::Crear { id, contenido };
aplicar(&mut grafo, cambio.clone());
let _undo = cambio.invertir();
```
## Deps
- [`pluma-core`](../pluma-core/README.md), [`pluma-graph`](../pluma-graph/README.md)
@@ -0,0 +1,19 @@
# pluma-graph-transform
> DAG mutations of [pluma](../README.md). Atomic insert / mutate / delete.
All graph modifications pass through this crate. Every operation returns a reversible `CambioGrafo`. Stackable as undo history.
## API
```rust
use pluma_graph_transform::{aplicar, CambioGrafo};
let cambio = CambioGrafo::Crear { id, contenido };
aplicar(&mut grafo, cambio.clone());
let _undo = cambio.invertir();
```
## Deps
- [`pluma-core`](../pluma-core/README.md), [`pluma-graph`](../pluma-graph/README.md)
@@ -0,0 +1,188 @@
//! `pluma-graph-transform` — pegamento entre `pluma-graph` y `pluma-transform`.
//!
//! Dos helpers que cierran el flujo end-to-end:
//!
//! 1. [`indice_atoms`]: construye el `HashMap<Uuid, &NarrativeAtom>` que los
//! ejecutores LLM esperan en su `aplicar_con_atoms`. Lo arma a partir
//! del `NarrativeGraph` actual con cero copias (referencias).
//!
//! 2. [`persistir_producto`]: dado un [`ProductoTransformacion`] devuelto
//! por un ejecutor, mete los `atoms_nuevos` en el grafo y devuelve el
//! par `(hija, carta)` listo para mostrar/persistir aparte. La hija
//! es solo metadatos + orden de Uuids; la carta es la `CartaHebras`
//! para que la UI la pinte.
//!
//! Por qué un crate separado y no un método en `NarrativeGraph`:
//!
//! - `pluma-graph` debe quedar agnóstico de la idea de transformación —
//! si mañana hay otra forma de producir átomos, no queremos contaminar
//! el grafo con ese acoplamiento.
//! - `pluma-transform` no depende de `pluma-graph` por la misma razón en
//! reverso: el modelo de transformación funciona con cualquier
//! resolver de átomos (un grafo en disco, una colección en memoria,
//! un mock). Mantenerlo limpio facilita reusarlo desde wawa o desde
//! un test.
//!
//! Este crate vive en el intersticio. Lo trae solo quien quiera el
//! pegamento; quien no, sigue con los dos crates por separado.
#![forbid(unsafe_code)]
use std::collections::HashMap;
use uuid::Uuid;
use pluma_align::CartaHebras;
use pluma_core::NarrativeAtom;
use pluma_cuerpo::Cuerpo;
use pluma_graph::NarrativeGraph;
use pluma_transform::ProductoTransformacion;
/// Construye un índice `Uuid → &NarrativeAtom` desde el grafo. Es lo
/// que los ejecutores LLM esperan en `aplicar_con_atoms`. Vive el tiempo
/// del préstamo `&graph`, sin copiar átomos.
pub fn indice_atoms(graph: &NarrativeGraph) -> HashMap<Uuid, &NarrativeAtom> {
graph.atoms().map(|a| (a.id, a)).collect()
}
/// Persiste el resultado de un ejecutor: mete los `atoms_nuevos` en el
/// grafo (los toma por valor, sin clonar) y devuelve `(hija, carta)`
/// listos para que el caller los inserte en su catálogo de cuerpos +
/// los pase a la vista multilienzo.
///
/// La hija NO se inserta en `NarrativeGraph` — el grafo es de átomos,
/// no de cuerpos. La gestión de cuerpos vive en otro nivel (ver
/// `pluma-store` cuando se cierre).
pub fn persistir_producto(
graph: &mut NarrativeGraph,
producto: ProductoTransformacion,
) -> (Cuerpo, CartaHebras) {
let ProductoTransformacion {
hija,
atoms_nuevos,
carta,
} = producto;
for atom in atoms_nuevos {
graph.insert(atom);
}
(hija, carta)
}
#[cfg(test)]
mod pruebas {
use super::*;
use pluma_cuerpo::Intencion;
use pluma_llm_mock::MockChatClient;
use pluma_transform::{TipoTransformacion, Transformacion};
use pluma_transform_llm::EjecutorTraducirLlm;
use pluma_transform_tabla::EjecutorTraducirTabla;
use pluma_transform::Ejecutor;
#[test]
fn indice_atoms_resuelve_los_atomos_del_grafo() {
let mut g = NarrativeGraph::new();
let a = NarrativeAtom::new("uno", "es");
let b = NarrativeAtom::new("dos", "es");
let (ia, ib) = (a.id, b.id);
g.insert(a);
g.insert(b);
let idx = indice_atoms(&g);
assert_eq!(idx.len(), 2);
assert_eq!(idx[&ia].content.as_str(), "uno");
assert_eq!(idx[&ib].content.as_str(), "dos");
}
#[tokio::test]
async fn persistir_producto_de_tabla_pone_atoms_nuevos_en_grafo() {
// Sembrar la madre.
let atom_a = NarrativeAtom::new("uno", "es");
let atom_b = NarrativeAtom::new("dos", "es");
let (id_a, id_b) = (atom_a.id, atom_b.id);
let mut madre = Cuerpo::nuevo("es", "es", Intencion::Original, 0);
madre.agregar(id_a, 1);
madre.agregar(id_b, 1);
let mut g = NarrativeGraph::new();
g.insert(atom_a);
g.insert(atom_b);
assert_eq!(g.len(), 2);
// Tabla traduce las dos.
let mut tabla = HashMap::new();
tabla.insert(id_a, "huk".to_string());
tabla.insert(id_b, "iskay".to_string());
let ej = EjecutorTraducirTabla::new(tabla, "qu");
let t = Transformacion::nueva(
madre.id,
Uuid::new_v4(),
TipoTransformacion::Traducir { lengua_destino: "qu".into() },
"tester",
1,
);
let producto = ej.aplicar(&t, &madre, 1).await.unwrap();
assert_eq!(producto.atoms_nuevos.len(), 2);
let (hija, carta) = persistir_producto(&mut g, producto);
// El grafo ahora tiene 4 átomos: 2 madre + 2 hija.
assert_eq!(g.len(), 4);
// La hija tiene Uuids nuevos (distintos de los de la madre).
for &id in &hija.orden {
assert_ne!(id, id_a);
assert_ne!(id, id_b);
assert!(g.contains(id));
}
// La carta enlaza madre con hija.
assert_eq!(carta.hebras.len(), 2);
}
#[tokio::test]
async fn ciclo_completo_con_ejecutor_llm_persiste_atoms_nuevos() {
// Sembrar la madre.
let atom_a = NarrativeAtom::new("uno", "es");
let atom_b = NarrativeAtom::new("dos", "es");
let (id_a, id_b) = (atom_a.id, atom_b.id);
let mut madre = Cuerpo::nuevo("es", "es", Intencion::Original, 0);
madre.agregar(id_a, 1);
madre.agregar(id_b, 1);
let mut g = NarrativeGraph::new();
g.insert(atom_a);
g.insert(atom_b);
// Mock chat con respuestas indexadas.
let chat = MockChatClient::default()
.con_respuesta("uno", "huk")
.con_respuesta("dos", "iskay");
let ej = EjecutorTraducirLlm::new(chat, "qu");
let t = Transformacion::nueva(
madre.id,
Uuid::new_v4(),
TipoTransformacion::Traducir { lengua_destino: "qu".into() },
"tester",
1,
);
// Flujo que la app real escribe:
// 1. Construir índice del grafo.
// 2. ejecutor.aplicar_con_atoms.
// 3. persistir_producto.
let idx = indice_atoms(&g);
let producto = ej.aplicar_con_atoms(&t, &madre, &idx, 1).await.unwrap();
// `producto` referencia atoms en `idx` que prestan de `g`; tras
// este `await`, ya tenemos producto OWNED — drop idx y persistir.
drop(idx);
let (hija, carta) = persistir_producto(&mut g, producto);
assert_eq!(g.len(), 4);
assert_eq!(hija.orden.len(), 2);
assert_eq!(carta.hebras.len(), 2);
// Los textos de la hija están en el grafo.
let textos: Vec<String> = hija
.orden
.iter()
.map(|id| g.get(*id).unwrap().content.as_str().to_string())
.collect();
assert_eq!(textos, vec!["huk".to_string(), "iskay".to_string()]);
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "pluma-graph"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "pluma-app — grafo narrativo (DAG de NarrativeAtoms): inserción, orden topológico y propagación de mutaciones en cascada."
[dependencies]
pluma-core = { path = "../pluma-core" }
uuid = { workspace = true }
+19
View File
@@ -0,0 +1,19 @@
# pluma-graph
> DAG de átomos con identidad estable para [pluma](../README.md).
Modelo de grafo direccional sobre los átomos: nodos = átomos, aristas = relaciones (referencia, derivación, traducción, ...). Cycles detectados pero no prohibidos (las traducciones pueden citar al original que las cita). Persistencia delegada a [`pluma-store`](../pluma-store/README.md).
## API
```rust
use pluma_graph::{Grafo, Arista};
let mut g = Grafo::new();
g.conectar(a, b, Arista::Referencia);
```
## Deps
- [`pluma-core`](../pluma-core/README.md)
- `petgraph`, `uuid`, `serde`
+19
View File
@@ -0,0 +1,19 @@
# pluma-graph
> Stable-identity atom DAG for [pluma](../README.md).
Directed graph model over atoms: nodes = atoms, edges = relationships (reference, derivation, translation, ...). Cycles detected but not forbidden (translations may cite the original that cites them). Persistence delegated to [`pluma-store`](../pluma-store/README.md).
## API
```rust
use pluma_graph::{Grafo, Arista};
let mut g = Grafo::new();
g.conectar(a, b, Arista::Referencia);
```
## Deps
- [`pluma-core`](../pluma-core/README.md)
- `petgraph`, `uuid`, `serde`
+211
View File
@@ -0,0 +1,211 @@
//! `pluma_app-graph` — el grafo narrativo (DAG de `NarrativeAtom`s).
//!
//! Mantiene los átomos + una adjacency list `dependencia → dependientes`.
//! Cuando un átomo muta, [`NarrativeGraph::propagate_mutation`] marca en
//! cascada a todo descendiente como `PendingEvaluation` — la "onda de
//! choque lógica" de la spec. Agnóstico de UI: devuelve los ids
//! afectados; el front-end decide cuándo re-renderizar.
#![forbid(unsafe_code)]
use pluma_core::{CoherenceState, NarrativeAtom};
use std::collections::{HashMap, HashSet, VecDeque};
use uuid::Uuid;
/// El documento como grafo dirigido acíclico de átomos narrativos.
#[derive(Debug, Default)]
pub struct NarrativeGraph {
nodes: HashMap<Uuid, NarrativeAtom>,
/// `dependencia → [átomos que dependen de ella]`.
adjacency: HashMap<Uuid, Vec<Uuid>>,
}
impl NarrativeGraph {
pub fn new() -> Self {
Self::default()
}
pub fn len(&self) -> usize {
self.nodes.len()
}
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
pub fn contains(&self, id: Uuid) -> bool {
self.nodes.contains_key(&id)
}
pub fn get(&self, id: Uuid) -> Option<&NarrativeAtom> {
self.nodes.get(&id)
}
pub fn get_mut(&mut self, id: Uuid) -> Option<&mut NarrativeAtom> {
self.nodes.get_mut(&id)
}
/// Itera todos los átomos del grafo (orden no determinista).
pub fn atoms(&self) -> impl Iterator<Item = &NarrativeAtom> {
self.nodes.values()
}
/// Construye un grafo desde una colección de átomos.
pub fn from_atoms(atoms: impl IntoIterator<Item = NarrativeAtom>) -> Self {
let mut g = Self::new();
for a in atoms {
g.insert(a);
}
g
}
/// Inserta un átomo y conecta las aristas desde sus dependencias.
pub fn insert(&mut self, atom: NarrativeAtom) {
let id = atom.id;
for &dep in &atom.dependencies {
let children = self.adjacency.entry(dep).or_default();
if !children.contains(&id) {
children.push(id);
}
}
self.nodes.insert(id, atom);
}
/// Dependientes directos de `id`.
pub fn dependents(&self, id: Uuid) -> &[Uuid] {
self.adjacency.get(&id).map(|v| v.as_slice()).unwrap_or(&[])
}
/// Propaga una mutación: marca `PendingEvaluation` en TODO descendiente
/// transitivo de `origin` (BFS sobre la adjacency). Devuelve los ids
/// afectados — el caller (front-end) decide cuándo re-renderizar.
///
/// `origin` mismo no se marca (es la fuente; ya se sabe que cambió).
pub fn propagate_mutation(&mut self, origin: Uuid) -> Vec<Uuid> {
let mut affected = Vec::new();
let mut seen: HashSet<Uuid> = HashSet::new();
let mut queue: VecDeque<Uuid> = VecDeque::new();
queue.push_back(origin);
seen.insert(origin);
while let Some(current) = queue.pop_front() {
let children: Vec<Uuid> = self
.adjacency
.get(&current)
.cloned()
.unwrap_or_default();
for child in children {
if seen.insert(child) {
if let Some(node) = self.nodes.get_mut(&child) {
node.coherence = CoherenceState::PendingEvaluation;
}
affected.push(child);
queue.push_back(child);
}
}
}
affected
}
/// Orden topológico de los átomos (dependencias antes que dependientes).
/// `None` si el grafo tiene un ciclo (no es un DAG válido).
pub fn topological_order(&self) -> Option<Vec<Uuid>> {
let mut indeg: HashMap<Uuid, usize> = self.nodes.keys().map(|&k| (k, 0)).collect();
for atom in self.nodes.values() {
for &dep in &atom.dependencies {
if self.nodes.contains_key(&dep) {
*indeg.entry(atom.id).or_insert(0) += 1;
}
}
}
let mut queue: VecDeque<Uuid> =
indeg.iter().filter(|(_, &d)| d == 0).map(|(&k, _)| k).collect();
let mut order = Vec::with_capacity(self.nodes.len());
while let Some(u) = queue.pop_front() {
order.push(u);
for &child in self.dependents(u) {
if let Some(d) = indeg.get_mut(&child) {
*d -= 1;
if *d == 0 {
queue.push_back(child);
}
}
}
}
if order.len() == self.nodes.len() {
Some(order)
} else {
None // quedó un ciclo
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Construye una cadena a → b → c y devuelve sus ids.
fn chain() -> (NarrativeGraph, Uuid, Uuid, Uuid) {
let mut g = NarrativeGraph::new();
let a = NarrativeAtom::new("a", "main");
let ( a_id,) = (a.id,);
let b = NarrativeAtom::new("b", "main").depends_on(a_id);
let b_id = b.id;
let c = NarrativeAtom::new("c", "main").depends_on(b_id);
let c_id = c.id;
g.insert(a);
g.insert(b);
g.insert(c);
(g, a_id, b_id, c_id)
}
#[test]
fn insert_wires_adjacency() {
let (g, a, b, c) = chain();
assert_eq!(g.len(), 3);
assert_eq!(g.dependents(a), &[b]);
assert_eq!(g.dependents(b), &[c]);
assert!(g.dependents(c).is_empty());
}
#[test]
fn propagate_marks_all_descendants_pending() {
let (mut g, a, b, c) = chain();
let affected = g.propagate_mutation(a);
assert_eq!(affected.len(), 2);
assert!(affected.contains(&b) && affected.contains(&c));
assert_eq!(g.get(b).unwrap().coherence, CoherenceState::PendingEvaluation);
assert_eq!(g.get(c).unwrap().coherence, CoherenceState::PendingEvaluation);
// El origen NO se marca.
assert_eq!(g.get(a).unwrap().coherence, CoherenceState::Valid);
}
#[test]
fn propagate_from_leaf_affects_nothing() {
let (mut g, _a, _b, c) = chain();
assert!(g.propagate_mutation(c).is_empty());
}
#[test]
fn topological_order_respects_dependencies() {
let (g, a, b, c) = chain();
let order = g.topological_order().expect("es un DAG");
let pos = |id: Uuid| order.iter().position(|&x| x == id).unwrap();
assert!(pos(a) < pos(b));
assert!(pos(b) < pos(c));
}
#[test]
fn cycle_has_no_topological_order() {
// a depende de b, b depende de a.
let mut g = NarrativeGraph::new();
let a = NarrativeAtom::new("a", "main");
let b = NarrativeAtom::new("b", "main");
let (a_id, b_id) = (a.id, b.id);
let a = a.depends_on(b_id);
let b = b.depends_on(a_id);
g.insert(a);
g.insert(b);
assert!(g.topological_order().is_none());
}
}
@@ -0,0 +1,16 @@
[package]
name = "pluma-llm-anthropic"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "pluma — backend del trait pluma-llm-core contra api.anthropic.com (Messages API). Incluye prompt caching del system prompt: requests sucesivas con el mismo system pagan input cacheado, no full price."
[dependencies]
async-trait = { workspace = true }
pluma-llm-core = { path = "../pluma-llm-core" }
reqwest = { workspace = true, features = ["json", "rustls-tls"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true }
@@ -0,0 +1,19 @@
# pluma-llm-anthropic
> Backend Anthropic (Claude) para [pluma](../README.md).
Implementa `ChatClient` contra la API de Anthropic. Soporta el catálogo de modelos Claude actual + streaming SSE + tool use + prompt caching cuando el mensaje lo justifica. Lee `ANTHROPIC_API_KEY` del entorno.
## API
```rust
use pluma_llm_anthropic::AnthropicClient;
use pluma_llm_core::ChatClient;
let chat = AnthropicClient::new("claude-opus-4-7", api_key);
```
## Deps
- [`pluma-llm-core`](../pluma-llm-core/README.md)
- `reqwest`, `eventsource-stream`, `serde_json`
@@ -0,0 +1,19 @@
# pluma-llm-anthropic
> Anthropic (Claude) backend for [pluma](../README.md).
`ChatClient` impl against the Anthropic API. Supports the current Claude model catalog + SSE streaming + tool use + prompt caching when the message justifies it. Reads `ANTHROPIC_API_KEY` from env.
## API
```rust
use pluma_llm_anthropic::AnthropicClient;
use pluma_llm_core::ChatClient;
let chat = AnthropicClient::new("claude-opus-4-7", api_key);
```
## Deps
- [`pluma-llm-core`](../pluma-llm-core/README.md)
- `reqwest`, `eventsource-stream`, `serde_json`
@@ -0,0 +1,389 @@
//! `pluma-llm-anthropic` — backend del trait `ChatClient` contra
//! `api.anthropic.com` (Messages API).
//!
//! Trae prompt caching ENCENDIDO por defecto sobre el `system` prompt:
//! cuando el caller emite N requests con el mismo system (caso típico al
//! traducir muchos párrafos con la misma instrucción de "traductor"),
//! la primera paga full input, las siguientes pagan el system como
//! cache read — ~10× más barato. La contabilidad expone los token counts
//! de cache hit / miss para que la app pueda mostrar el ahorro real.
//!
//! Sin caching del `messages`: en pluma el `system` es lo que se repite;
//! los `messages` cambian por párrafo. Quien quiera cachear segmentos
//! largos de `messages` puede hacerlo en una iteración futura.
//!
//! ## Configuración mínima
//!
//! ```no_run
//! # use pluma_llm_anthropic::AnthropicClient;
//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
//! // API key: env `ANTHROPIC_API_KEY` (o pasarla explícita en `with_api_key`).
//! // Modelo: por defecto `claude-sonnet-4-6` — balance calidad/costo.
//! let cli = AnthropicClient::from_env()?;
//! # Ok(()) }
//! ```
#![forbid(unsafe_code)]
use async_trait::async_trait;
use pluma_llm_core::{
ChatClient, ChatError, ChatRequest, ChatResponse, ChatUsage, Role, StopReason,
};
use reqwest::header::{HeaderMap, HeaderValue};
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// Endpoint default del Messages API.
const ENDPOINT_DEFAULT: &str = "https://api.anthropic.com/v1/messages";
/// Versión del API documentada por Anthropic — se manda como header
/// `anthropic-version`. Si Anthropic publica una nueva, se bumpea aquí.
const API_VERSION: &str = "2023-06-01";
/// Modelo por defecto: Sonnet 4.6 es el sweet spot calidad/costo para
/// transformaciones de pluma (traducir, tono, resumir). Quien quiera
/// más cabeza usa Opus 4.7 vía `with_model("claude-opus-4-7")`;
/// quien quiera más barato usa Haiku 4.5
/// (`claude-haiku-4-5-20251001`).
const MODEL_DEFAULT: &str = "claude-sonnet-4-6";
/// Timeout por defecto de la request HTTP. Un párrafo se traduce en
/// pocos segundos; 60 s deja holgura para colas/red sin colgar la UI.
const TIMEOUT_DEFAULT_SECS: u64 = 60;
/// Cliente Anthropic Messages API que implementa [`ChatClient`].
pub struct AnthropicClient {
http: reqwest::Client,
endpoint: String,
api_key: String,
model: String,
cache_system: bool,
}
impl AnthropicClient {
/// Construye un cliente leyendo la API key de `ANTHROPIC_API_KEY`.
/// Si la variable no está, devuelve `ChatError::AuthMissing`.
pub fn from_env() -> Result<Self, ChatError> {
let api_key = std::env::var("ANTHROPIC_API_KEY")
.map_err(|_| ChatError::AuthMissing("ANTHROPIC_API_KEY".to_string()))?;
Self::with_api_key(api_key)
}
/// Construye un cliente con una API key explícita. Útil cuando la
/// key vive en un keyring del SO o en un archivo (no env var).
pub fn with_api_key(api_key: impl Into<String>) -> Result<Self, ChatError> {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(TIMEOUT_DEFAULT_SECS))
.build()
.map_err(|e| ChatError::Network(format!("construir reqwest client: {e}")))?;
Ok(Self {
http,
endpoint: ENDPOINT_DEFAULT.to_string(),
api_key: api_key.into(),
model: MODEL_DEFAULT.to_string(),
cache_system: true,
})
}
/// Encadenable: cambia el modelo. Anthropic ids válidos hoy:
/// `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-haiku-4-5-20251001`.
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self
}
/// Encadenable: desactiva el prompt caching del system. Por defecto
/// está encendido — apagarlo solo tiene sentido si el system cambia
/// en cada request (cosa rara, pero el caller decide).
pub fn sin_cache_system(mut self) -> Self {
self.cache_system = false;
self
}
/// Encadenable: cambia el endpoint. Útil para proxies internos o
/// servicios compatible-Anthropic.
pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = endpoint.into();
self
}
/// Headers que requiere la Messages API.
fn headers(&self) -> Result<HeaderMap, ChatError> {
let mut h = HeaderMap::new();
h.insert(
"x-api-key",
HeaderValue::from_str(&self.api_key)
.map_err(|_| ChatError::Backend("api key con bytes inválidos".to_string()))?,
);
h.insert("anthropic-version", HeaderValue::from_static(API_VERSION));
h.insert("content-type", HeaderValue::from_static("application/json"));
Ok(h)
}
}
#[async_trait]
impl ChatClient for AnthropicClient {
fn model_id(&self) -> &str {
&self.model
}
async fn complete(&self, req: &ChatRequest) -> Result<ChatResponse, ChatError> {
let payload = construir_payload(req, &self.model, self.cache_system);
let resp = self
.http
.post(&self.endpoint)
.headers(self.headers()?)
.json(&payload)
.send()
.await
.map_err(|e| ChatError::Network(format!("POST messages: {e}")))?;
let status = resp.status();
let body_bytes = resp
.bytes()
.await
.map_err(|e| ChatError::Network(format!("leer body: {e}")))?;
// Distinguir errores comunes ANTES de intentar deserializar como
// respuesta exitosa — el body de error tiene shape distinto.
if status == 401 || status == 403 {
return Err(ChatError::AuthInvalid);
}
if status == 429 {
return Err(ChatError::RateLimited);
}
if !status.is_success() {
// El body trae JSON `{ "type": "error", "error": { "message": ... } }`.
let mensaje = match serde_json::from_slice::<ErrorEnvelope>(&body_bytes) {
Ok(env) => env.error.message,
Err(_) => String::from_utf8_lossy(&body_bytes).into_owned(),
};
return Err(ChatError::Backend(format!("HTTP {status}: {mensaje}")));
}
let parsed: AnthropicMessagesResponse = serde_json::from_slice(&body_bytes)
.map_err(|e| ChatError::Backend(format!("parseo response: {e}")))?;
let content = parsed
.content
.into_iter()
.filter_map(|b| match b {
AnthropicContentBlock::Text { text } => Some(text),
})
.collect::<Vec<_>>()
.join("");
let usage = parsed.usage.map(|u| ChatUsage {
input_tokens: u.input_tokens,
output_tokens: u.output_tokens,
cache_read_input_tokens: u.cache_read_input_tokens.unwrap_or(0),
cache_creation_input_tokens: u.cache_creation_input_tokens.unwrap_or(0),
});
Ok(ChatResponse {
content,
stop_reason: parsed.stop_reason.map(StopReason),
usage,
})
}
}
/// Traduce un [`ChatRequest`] al payload JSON que Anthropic espera. El
/// `system` se envía como bloque cacheable cuando `cache_system` está
/// activo — un bloque `{type:"text", text:..., cache_control:{type:"ephemeral"}}`
/// dentro de un array — para que la siguiente request con system idéntico
/// caiga en cache.
fn construir_payload(
req: &ChatRequest,
modelo: &str,
cache_system: bool,
) -> serde_json::Value {
let mensajes: Vec<serde_json::Value> = req
.messages
.iter()
.map(|m| {
let role = match m.role {
Role::User => "user",
Role::Assistant => "assistant",
};
if m.images.is_empty() {
// Solo-texto: `content` como string plano (idéntico al
// payload previo a visión — no rompe caching ni tests).
serde_json::json!({
"role": role,
"content": m.content,
})
} else {
// Multimodal: array de bloques. Anthropic recomienda las
// imágenes ANTES del texto. Cada imagen es un bloque
// `image` con `source` base64.
let mut blocks: Vec<serde_json::Value> = m
.images
.iter()
.map(|img| {
serde_json::json!({
"type": "image",
"source": {
"type": "base64",
"media_type": img.media_type,
"data": img.data_base64,
}
})
})
.collect();
blocks.push(serde_json::json!({"type": "text", "text": m.content}));
serde_json::json!({ "role": role, "content": blocks })
}
})
.collect();
let mut payload = serde_json::json!({
"model": modelo,
"max_tokens": req.max_tokens,
"temperature": req.temperature,
"messages": mensajes,
});
if let Some(sys) = req.system.as_deref() {
let system_value = if cache_system {
// Array de bloques con `cache_control: ephemeral` — la API
// reusa el cache para hasta 5 minutos si el contenido del
// bloque no cambia. Es la forma documentada de prompt caching.
serde_json::json!([{
"type": "text",
"text": sys,
"cache_control": {"type": "ephemeral"}
}])
} else {
// String plano — sin caching.
serde_json::json!(sys)
};
payload
.as_object_mut()
.expect("payload es object")
.insert("system".to_string(), system_value);
}
payload
}
// -------- Tipos del wire de Anthropic (parseo de la respuesta) --------
#[derive(Debug, Deserialize)]
struct AnthropicMessagesResponse {
content: Vec<AnthropicContentBlock>,
stop_reason: Option<String>,
usage: Option<AnthropicUsage>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum AnthropicContentBlock {
Text { text: String },
}
#[derive(Debug, Deserialize)]
struct AnthropicUsage {
input_tokens: u32,
output_tokens: u32,
cache_read_input_tokens: Option<u32>,
cache_creation_input_tokens: Option<u32>,
}
#[derive(Debug, Deserialize, Serialize)]
struct ErrorEnvelope {
error: ErrorBody,
}
#[derive(Debug, Deserialize, Serialize)]
struct ErrorBody {
#[serde(default)]
message: String,
#[serde(default, rename = "type")]
_kind: String,
}
#[cfg(test)]
mod pruebas {
use super::*;
use pluma_llm_core::ChatMessage;
#[test]
fn payload_sin_system_no_lleva_el_campo() {
let req = ChatRequest::una_vuelta("hola", 50);
let p = construir_payload(&req, "claude-sonnet-4-6", true);
assert!(p.get("system").is_none());
assert_eq!(p["model"], "claude-sonnet-4-6");
assert_eq!(p["max_tokens"], 50);
assert_eq!(p["messages"][0]["role"], "user");
assert_eq!(p["messages"][0]["content"], "hola");
}
#[test]
fn payload_con_system_cacheado_emite_bloque_ephemeral() {
let req = ChatRequest::una_vuelta("texto", 50)
.con_sistema("Eres un traductor.");
let p = construir_payload(&req, "claude-sonnet-4-6", true);
let system = p.get("system").expect("system presente");
assert!(system.is_array());
let bloque = &system[0];
assert_eq!(bloque["type"], "text");
assert_eq!(bloque["text"], "Eres un traductor.");
assert_eq!(bloque["cache_control"]["type"], "ephemeral");
}
#[test]
fn payload_con_cache_desactivado_emite_system_plano() {
let req = ChatRequest::una_vuelta("x", 50)
.con_sistema("Eres un traductor.");
let p = construir_payload(&req, "claude-sonnet-4-6", false);
let system = p.get("system").expect("system presente");
assert_eq!(system, "Eres un traductor.");
}
// (Test de `from_env` sin variable omitido: Rust 2024 marca
// `std::env::remove_var` y `set_var` como unsafe — tocar el entorno
// del proceso desde tests es race-prone y `forbid(unsafe_code)` lo
// bloquea. La lógica que devuelve `AuthMissing` queda cubierta por
// inspección del código y por uso end-to-end del cliente.)
#[test]
fn roles_se_mapean_correctamente_en_el_payload() {
let req = ChatRequest {
system: None,
max_tokens: 10,
temperature: 0.0,
messages: vec![
ChatMessage::user("U"),
ChatMessage::assistant("A"),
ChatMessage::user("U2"),
],
};
let p = construir_payload(&req, "m", true);
assert_eq!(p["messages"][0]["role"], "user");
assert_eq!(p["messages"][1]["role"], "assistant");
assert_eq!(p["messages"][2]["role"], "user");
assert_eq!(p["messages"][2]["content"], "U2");
}
#[test]
fn mensaje_con_imagen_emite_bloques_image_y_text() {
use pluma_llm_core::ChatImage;
let req = ChatRequest {
system: None,
max_tokens: 100,
temperature: 0.0,
messages: vec![ChatMessage::user_con_imagenes(
"¿qué hay en la foto?",
vec![ChatImage::new("image/png", "AAEC")],
)],
};
let p = construir_payload(&req, "claude-sonnet-4-6", false);
let content = &p["messages"][0]["content"];
assert!(content.is_array(), "content debe ser array con imagen");
// Imagen primero, texto después.
assert_eq!(content[0]["type"], "image");
assert_eq!(content[0]["source"]["type"], "base64");
assert_eq!(content[0]["source"]["media_type"], "image/png");
assert_eq!(content[0]["source"]["data"], "AAEC");
assert_eq!(content[1]["type"], "text");
assert_eq!(content[1]["text"], "¿qué hay en la foto?");
}
}
@@ -0,0 +1,16 @@
[package]
name = "pluma-llm-cohere"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "pluma — backend del trait pluma-llm-core contra Cohere v2 chat API. Shape de response distinta de OpenAI (un `message` con content: [{type:text, text}]); request shape compatible con messages."
[dependencies]
async-trait = { workspace = true }
pluma-llm-core = { path = "../pluma-llm-core" }
reqwest = { workspace = true, features = ["json", "rustls-tls"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true }
@@ -0,0 +1,18 @@
# pluma-llm-cohere
> Backend Cohere para [pluma](../README.md).
Implementa `ChatClient` contra la API Chat de Cohere (Command R, Command R+). Streaming + RAG citations cuando se incluyen documents en la request. Lee `COHERE_API_KEY`.
## API
```rust
use pluma_llm_cohere::CohereClient;
let chat = CohereClient::new("command-r-plus", api_key);
```
## Deps
- [`pluma-llm-core`](../pluma-llm-core/README.md)
- `reqwest`, `serde_json`
@@ -0,0 +1,18 @@
# pluma-llm-cohere
> Cohere backend for [pluma](../README.md).
`ChatClient` impl against Cohere's Chat API (Command R, Command R+). Streaming + RAG citations when documents are included in the request. Reads `COHERE_API_KEY`.
## API
```rust
use pluma_llm_cohere::CohereClient;
let chat = CohereClient::new("command-r-plus", api_key);
```
## Deps
- [`pluma-llm-core`](../pluma-llm-core/README.md)
- `reqwest`, `serde_json`
@@ -0,0 +1,298 @@
//! `pluma-llm-cohere` — backend del trait `ChatClient` contra
//! `api.cohere.com/v2/chat`.
//!
//! Request shape parecida a OpenAI (`messages` con `role` + `content`),
//! pero la response es distinta: un solo `message` con
//! `content: [{type:"text", text:"..."}]` (estilo Anthropic). Por eso
//! va en un crate aparte en lugar de reusar
//! `pluma-llm-openai-compatible`.
//!
//! ## Configuración
//!
//! ```no_run
//! # use pluma_llm_cohere::CohereClient;
//! # fn run() -> Result<(), Box<dyn std::error::Error>> {
//! // Lee COHERE_API_KEY del env.
//! let cli = CohereClient::from_env()?;
//! // Modelo default: `command-a-03-2025` (Command A, top-of-line de
//! // Cohere). Para Command-R: `.with_model("command-r-08-2024")`.
//! # Ok(()) }
//! ```
#![forbid(unsafe_code)]
use async_trait::async_trait;
use pluma_llm_core::{
ChatClient, ChatError, ChatRequest, ChatResponse, ChatUsage, Role, StopReason,
};
use reqwest::header::{HeaderMap, HeaderValue};
use serde::Deserialize;
use std::time::Duration;
const TIMEOUT_DEFAULT_SECS: u64 = 60;
const ENDPOINT_DEFAULT: &str = "https://api.cohere.com/v2/chat";
const MODEL_DEFAULT: &str = "command-a-03-2025";
const ENV_KEY: &str = "COHERE_API_KEY";
/// Cliente Cohere v2 implementando [`ChatClient`].
pub struct CohereClient {
http: reqwest::Client,
endpoint: String,
api_key: String,
model: String,
}
impl CohereClient {
/// Lee la API key de `COHERE_API_KEY`.
pub fn from_env() -> Result<Self, ChatError> {
let api_key =
std::env::var(ENV_KEY).map_err(|_| ChatError::AuthMissing(ENV_KEY.to_string()))?;
Self::with_api_key(api_key)
}
/// Construye con una API key explícita.
pub fn with_api_key(api_key: impl Into<String>) -> Result<Self, ChatError> {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(TIMEOUT_DEFAULT_SECS))
.build()
.map_err(|e| ChatError::Network(format!("construir reqwest client: {e}")))?;
Ok(Self {
http,
endpoint: ENDPOINT_DEFAULT.to_string(),
api_key: api_key.into(),
model: MODEL_DEFAULT.to_string(),
})
}
/// Cambia el modelo. Válidos hoy: `command-a-03-2025` (default),
/// `command-r-plus-08-2024`, `command-r-08-2024`.
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self
}
/// Cambia el endpoint — útil para proxies internos.
pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = endpoint.into();
self
}
fn headers(&self) -> Result<HeaderMap, ChatError> {
let mut h = HeaderMap::new();
h.insert("content-type", HeaderValue::from_static("application/json"));
let val = HeaderValue::from_str(&format!("Bearer {}", self.api_key))
.map_err(|_| ChatError::Backend("api key con bytes inválidos".to_string()))?;
h.insert("authorization", val);
Ok(h)
}
}
#[async_trait]
impl ChatClient for CohereClient {
fn model_id(&self) -> &str {
&self.model
}
async fn complete(&self, req: &ChatRequest) -> Result<ChatResponse, ChatError> {
let payload = construir_payload(req, &self.model);
let resp = self
.http
.post(&self.endpoint)
.headers(self.headers()?)
.json(&payload)
.send()
.await
.map_err(|e| ChatError::Network(format!("POST v2/chat: {e}")))?;
let status = resp.status();
let body_bytes = resp
.bytes()
.await
.map_err(|e| ChatError::Network(format!("leer body: {e}")))?;
if status == 401 || status == 403 {
return Err(ChatError::AuthInvalid);
}
if status == 429 {
return Err(ChatError::RateLimited);
}
if !status.is_success() {
let mensaje = match serde_json::from_slice::<CohereError>(&body_bytes) {
Ok(env) => env.message,
Err(_) => String::from_utf8_lossy(&body_bytes).into_owned(),
};
return Err(ChatError::Backend(format!("HTTP {status}: {mensaje}")));
}
let parsed: CohereResponse = serde_json::from_slice(&body_bytes)
.map_err(|e| ChatError::Backend(format!("parseo response: {e}")))?;
// El `message.content` es un array de bloques `{type,text}`. Solo
// recogemos los de tipo "text".
let content = parsed
.message
.content
.into_iter()
.filter_map(|b| match b {
CohereContentBlock::Text { text } => Some(text),
})
.collect::<Vec<_>>()
.join("");
// Cohere reporta `usage.tokens` (total) y `usage.billed_units`
// (lo que cobra). Preferimos `tokens` para mostrar la actividad
// real del modelo; el caller que quiera contabilidad de costo
// mira `billed_units` por separado en una iteración futura.
let usage = parsed.usage.and_then(|u| u.tokens).map(|t| ChatUsage {
input_tokens: t.input_tokens.unwrap_or(0),
output_tokens: t.output_tokens.unwrap_or(0),
// Cohere no expone caching de prompts hoy.
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
});
Ok(ChatResponse {
content,
stop_reason: parsed.finish_reason.map(StopReason),
usage,
})
}
}
/// Compone el payload v2/chat: messages con role/content (string plano),
/// model, max_tokens, temperature. El `system` de pluma se mete como
/// primer mensaje con role=system (igual que OpenAI; distinto de
/// Anthropic donde el system es top-level).
fn construir_payload(req: &ChatRequest, modelo: &str) -> serde_json::Value {
let mut mensajes: Vec<serde_json::Value> = Vec::with_capacity(req.messages.len() + 1);
if let Some(sys) = &req.system {
mensajes.push(serde_json::json!({"role": "system", "content": sys}));
}
for m in &req.messages {
let role = match m.role {
Role::User => "user",
Role::Assistant => "assistant",
};
mensajes.push(serde_json::json!({"role": role, "content": m.content}));
}
serde_json::json!({
"model": modelo,
"messages": mensajes,
"max_tokens": req.max_tokens,
"temperature": req.temperature,
})
}
// -------- Tipos del wire Cohere v2 --------
#[derive(Debug, Deserialize)]
struct CohereResponse {
message: CohereMessage,
#[serde(default)]
finish_reason: Option<String>,
#[serde(default)]
usage: Option<CohereUsage>,
}
#[derive(Debug, Deserialize)]
struct CohereMessage {
#[serde(default)]
content: Vec<CohereContentBlock>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum CohereContentBlock {
Text { text: String },
}
#[derive(Debug, Deserialize)]
struct CohereUsage {
#[serde(default)]
tokens: Option<CohereTokens>,
}
#[derive(Debug, Deserialize)]
struct CohereTokens {
#[serde(default)]
input_tokens: Option<u32>,
#[serde(default)]
output_tokens: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct CohereError {
#[serde(default)]
message: String,
}
#[cfg(test)]
mod pruebas {
use super::*;
use pluma_llm_core::ChatMessage;
#[test]
fn payload_sin_system_solo_user() {
let req = ChatRequest::una_vuelta("hola", 50);
let p = construir_payload(&req, "command-a-03-2025");
assert_eq!(p["model"], "command-a-03-2025");
assert_eq!(p["messages"].as_array().unwrap().len(), 1);
assert_eq!(p["messages"][0]["role"], "user");
}
#[test]
fn payload_con_system_inserta_primer_mensaje_system() {
let req = ChatRequest::una_vuelta("x", 50).con_sistema("Eres traductor.");
let p = construir_payload(&req, "m");
let msgs = p["messages"].as_array().unwrap();
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0]["role"], "system");
assert_eq!(msgs[0]["content"], "Eres traductor.");
}
#[test]
fn parsea_response_completa_con_content_y_usage() {
let body = serde_json::json!({
"id": "abc",
"message": {
"role": "assistant",
"content": [
{"type": "text", "text": "hola"},
{"type": "text", "text": " mundo"}
]
},
"finish_reason": "COMPLETE",
"usage": {
"tokens": {"input_tokens": 12, "output_tokens": 3},
"billed_units": {"input_tokens": 12, "output_tokens": 3}
}
});
let parsed: CohereResponse = serde_json::from_value(body).unwrap();
let texto: String = parsed
.message
.content
.into_iter()
.filter_map(|b| match b {
CohereContentBlock::Text { text } => Some(text),
})
.collect();
assert_eq!(texto, "hola mundo");
assert_eq!(parsed.finish_reason.as_deref(), Some("COMPLETE"));
let u = parsed.usage.unwrap().tokens.unwrap();
assert_eq!(u.input_tokens, Some(12));
assert_eq!(u.output_tokens, Some(3));
}
#[test]
fn roles_assistant_pasa_como_assistant() {
let req = ChatRequest {
system: None,
max_tokens: 1,
temperature: 0.0,
messages: vec![ChatMessage::assistant("hola"), ChatMessage::user("¿qué?")],
};
let p = construir_payload(&req, "m");
assert_eq!(p["messages"][0]["role"], "assistant");
assert_eq!(p["messages"][1]["role"], "user");
}
}
@@ -0,0 +1,17 @@
[package]
name = "pluma-llm-core"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "pluma — contrato del cliente LLM (chat completion): trait ChatClient + tipos comunes. Backends (Anthropic, OpenAI-compatible, mock) implementan el rasgo en crates aparte."
[dependencies]
async-trait = { workspace = true }
base64 = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+19
View File
@@ -0,0 +1,19 @@
# pluma-llm-core
> Trait `ChatClient` + tipos compartidos de LLM para [pluma](../README.md).
Define la abstracción que cualquier backend implementa. Tipos: `Message`, `Role`, `Tool`, `ChatRequest`, `ChatResponse`, `ChatStream`. Pensado para que el switch de backend sea cambiar UNA variante del enum de config.
## API
```rust
pub trait ChatClient: Send + Sync {
async fn send(&self, req: ChatRequest) -> Result<ChatResponse>;
async fn stream(&self, req: ChatRequest) -> Result<ChatStream>;
}
```
## Deps
- `serde`, `async-trait`, `futures-core`
- Cero deps de HTTP o providers específicos
@@ -0,0 +1,19 @@
# pluma-llm-core
> `ChatClient` trait + shared LLM types for [pluma](../README.md).
Defines the abstraction every backend implements. Types: `Message`, `Role`, `Tool`, `ChatRequest`, `ChatResponse`, `ChatStream`. Designed so switching backend is changing ONE config enum variant.
## API
```rust
pub trait ChatClient: Send + Sync {
async fn send(&self, req: ChatRequest) -> Result<ChatResponse>;
async fn stream(&self, req: ChatRequest) -> Result<ChatStream>;
}
```
## Deps
- `serde`, `async-trait`, `futures-core`
- Zero HTTP / provider-specific deps
+290
View File
@@ -0,0 +1,290 @@
//! `pluma-llm-core` — el contrato del cliente LLM agnóstico de proveedor.
//!
//! Define el rasgo [`ChatClient`] y los tipos comunes (mensajes, request,
//! response, errores) que los backends implementan. Igual idea que
//! `rimay-verbo-core` con embeddings: una sola verdad del contrato, N
//! impls intercambiables (`pluma-llm-anthropic`, `pluma-llm-mock`, …).
//!
//! El contrato se mantiene MÍNIMO: solo lo que la suite necesita hoy.
//! - System prompt opcional (lo cachean los proveedores que soporten
//! prompt caching — Anthropic lo hace si se marca explícitamente).
//! - Lista de mensajes alternados user/assistant.
//! - `max_tokens` + `temperature` configurables.
//! - Respuesta con `content` + `stop_reason` + `usage` opcional.
//!
//! Streaming queda fuera por ahora — el caso típico de pluma (traducir
//! una tabla de párrafos) es batch: pedir N veces y materializar. Cuando
//! aparezca un caso real de UX que requiera tokens en vivo, se añadirá
//! `stream(&self, req)` al rasgo sin romper la API actual.
#![forbid(unsafe_code)]
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Rol del mensaje en una conversación de chat. Los proveedores
/// mainstream usan exactamente estos dos — system se trata aparte
/// porque API distintas lo modelan distinto (Anthropic: campo top-level;
/// OpenAI: mensaje con role=system).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
/// Lo escribió quien está usando la app.
User,
/// Lo escribió el modelo en una vuelta previa.
Assistant,
}
/// Una imagen adjunta a un mensaje (visión multimodal). Los proveedores
/// que soportan visión (Anthropic, Gemini) la reciben como bloque de
/// contenido junto al texto; los que no, la ignoran y usan solo `content`.
///
/// Los bytes viajan en base64 (estándar, sin saltos de línea) para no
/// acoplar el contrato a `&[u8]` crudos al serializar la request.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatImage {
/// MIME type del contenido, p.ej. `"image/png"`, `"image/jpeg"`,
/// `"image/webp"`, `"image/gif"`.
pub media_type: String,
/// Bytes de la imagen codificados en base64.
pub data_base64: String,
}
impl ChatImage {
/// Construye desde base64 ya codificado.
pub fn new(media_type: impl Into<String>, data_base64: impl Into<String>) -> Self {
Self {
media_type: media_type.into(),
data_base64: data_base64.into(),
}
}
/// Construye desde bytes crudos, codificando a base64.
pub fn from_bytes(media_type: impl Into<String>, bytes: &[u8]) -> Self {
use base64::Engine as _;
Self {
media_type: media_type.into(),
data_base64: base64::engine::general_purpose::STANDARD.encode(bytes),
}
}
}
/// Un mensaje dentro de la conversación. Además del texto en `content`
/// puede llevar imágenes (`images`) para los backends con visión.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: Role,
pub content: String,
/// Imágenes adjuntas (visión). Vacío = mensaje solo-texto. Tiene
/// `serde(default)` para retrocompat con payloads previos sin el campo.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub images: Vec<ChatImage>,
}
impl ChatMessage {
pub fn user(content: impl Into<String>) -> Self {
Self {
role: Role::User,
content: content.into(),
images: Vec::new(),
}
}
pub fn assistant(content: impl Into<String>) -> Self {
Self {
role: Role::Assistant,
content: content.into(),
images: Vec::new(),
}
}
/// Mensaje de usuario con texto + imágenes (visión).
pub fn user_con_imagenes(content: impl Into<String>, images: Vec<ChatImage>) -> Self {
Self {
role: Role::User,
content: content.into(),
images,
}
}
/// `true` si el mensaje lleva al menos una imagen.
pub fn tiene_imagenes(&self) -> bool {
!self.images.is_empty()
}
}
/// Petición de completion al modelo.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatRequest {
/// Instrucción del sistema (ej. "Eres un traductor profesional al
/// quechua del Cuzco. Conserva nombres propios y números."). Se le
/// dice al modelo que **caché esto si puedes** — los proveedores que
/// soporten prompt caching lo aprovechan al re-emitir el sistema
/// idéntico en muchas requests cortas (caso típico: traducir N
/// párrafos con el mismo system).
pub system: Option<String>,
/// Conversación. Para una sola request user→assistant, basta
/// `vec![ChatMessage::user(prompt)]`.
pub messages: Vec<ChatMessage>,
/// Cota de tokens de salida.
pub max_tokens: u32,
/// Determinismo: 0.0 = casi determinista, 1.0 = creativo. Para
/// traducción/extracción suele ir bajo (0.10.3); para reescritura
/// creativa, más alto.
pub temperature: f32,
}
impl ChatRequest {
/// Constructor mínimo: un solo mensaje user, sin system.
pub fn una_vuelta(prompt: impl Into<String>, max_tokens: u32) -> Self {
Self {
system: None,
messages: vec![ChatMessage::user(prompt)],
max_tokens,
temperature: 0.2,
}
}
/// Encadenable: agrega un system prompt.
pub fn con_sistema(mut self, system: impl Into<String>) -> Self {
self.system = Some(system.into());
self
}
/// Encadenable: ajusta la temperatura.
pub fn con_temperatura(mut self, t: f32) -> Self {
self.temperature = t.clamp(0.0, 1.0);
self
}
}
/// Razón por la que el modelo dejó de generar tokens. Los strings son
/// libres porque cada proveedor usa los suyos (Anthropic:
/// `"end_turn" | "max_tokens" | "stop_sequence"`; OpenAI:
/// `"stop" | "length"`). Los consumidores que quieran lógica condicional
/// deben tratar el string como opaco.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StopReason(pub String);
/// Contabilidad de tokens reportada por el proveedor — útil para tracking
/// de costo y diagnóstico. `None` cuando el proveedor no la expone.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatUsage {
pub input_tokens: u32,
pub output_tokens: u32,
/// Tokens leídos de un cache de prompt-caching (Anthropic). 0 si el
/// backend no soporta o si no hubo hit.
pub cache_read_input_tokens: u32,
/// Tokens escritos al cache (primer write tras un miss).
pub cache_creation_input_tokens: u32,
}
/// Respuesta del modelo.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatResponse {
/// Texto generado.
pub content: String,
/// Razón de parada, si el proveedor la reporta.
pub stop_reason: Option<StopReason>,
/// Contabilidad de tokens, si el proveedor la reporta.
pub usage: Option<ChatUsage>,
}
/// Errores típicos. Los específicos del backend (HTTP status raros,
/// payload inesperado) van en `Backend(String)` con mensaje propio del
/// adapter.
#[derive(Debug, Error)]
pub enum ChatError {
/// El backend no encontró credenciales (ej. `ANTHROPIC_API_KEY` sin
/// definir). Los consumidores deciden si caer a un mock o pedirle al
/// usuario que configure.
#[error("falta credencial del backend: {0}")]
AuthMissing(String),
/// El backend rechazó la credencial (401/403). Distinto de
/// `AuthMissing`: aquí SÍ hay clave pero no sirve.
#[error("credencial inválida")]
AuthInvalid,
/// El servicio devolvió 429 / cuota superada. El caller puede
/// retornar al usuario o esperar y reintentar con backoff.
#[error("rate limited por el backend")]
RateLimited,
/// Error de red/transporte (DNS, TLS, timeout, etc.).
#[error("error de red: {0}")]
Network(String),
/// Cualquier otra cosa que el backend reporte como inesperada.
#[error("error del backend: {0}")]
Backend(String),
/// El caller canceló la operación (señal, drop, ctrl-c).
#[error("cancelado")]
Cancelled,
}
/// El cliente LLM. Cada backend (Anthropic, OpenAI-compatible, mock,
/// ollama local cuando se sume) implementa este rasgo.
#[async_trait]
pub trait ChatClient: Send + Sync {
/// Nombre del modelo que este cliente atiende — para logging y para
/// que el caller pueda anotar en metadatos qué modelo produjo qué
/// salida (auditoría de derivaciones en pluma).
fn model_id(&self) -> &str;
/// Ejecuta una request de chat y devuelve la respuesta.
async fn complete(&self, req: &ChatRequest) -> Result<ChatResponse, ChatError>;
}
#[cfg(test)]
mod pruebas {
use super::*;
#[test]
fn una_vuelta_construye_request_mínimo() {
let r = ChatRequest::una_vuelta("hola", 100);
assert_eq!(r.messages.len(), 1);
assert_eq!(r.messages[0].role, Role::User);
assert_eq!(r.messages[0].content, "hola");
assert!(r.system.is_none());
assert_eq!(r.max_tokens, 100);
}
#[test]
fn con_temperatura_clampea() {
let r = ChatRequest::una_vuelta("x", 10).con_temperatura(2.5);
assert_eq!(r.temperature, 1.0);
let r = ChatRequest::una_vuelta("x", 10).con_temperatura(-0.5);
assert_eq!(r.temperature, 0.0);
}
#[test]
fn chat_image_from_bytes_codifica_base64() {
let img = ChatImage::from_bytes("image/png", &[0, 1, 2, 3]);
assert_eq!(img.media_type, "image/png");
assert_eq!(img.data_base64, "AAECAw==");
}
#[test]
fn mensaje_con_imagenes_y_retrocompat_serde() {
let m = ChatMessage::user_con_imagenes(
"¿qué hay acá?",
vec![ChatImage::new("image/jpeg", "Zm9v")],
);
assert!(m.tiene_imagenes());
// Un mensaje solo-texto NO serializa el campo `images` (sigue
// produciendo el mismo JSON que antes de agregar visión).
let plano = ChatMessage::user("hola");
let json = serde_json::to_string(&plano).unwrap();
assert_eq!(json, r#"{"role":"user","content":"hola"}"#);
// Y un JSON viejo sin `images` deserializa igual.
let back: ChatMessage =
serde_json::from_str(r#"{"role":"user","content":"x"}"#).unwrap();
assert!(!back.tiene_imagenes());
}
#[test]
fn encadenable_combina_sistema_y_temperatura() {
let r = ChatRequest::una_vuelta("traducir", 200)
.con_sistema("Eres un traductor.")
.con_temperatura(0.1);
assert_eq!(r.system.as_deref(), Some("Eres un traductor."));
assert!((r.temperature - 0.1).abs() < 1e-6);
}
}
@@ -0,0 +1,16 @@
[package]
name = "pluma-llm-gemini"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "pluma — backend de pluma-llm-core contra Google Gemini API (Generative Language). Shape distinta de OpenAI: `contents` con `parts`, `systemInstruction` aparte, roles user/model."
[dependencies]
async-trait = { workspace = true }
pluma-llm-core = { path = "../pluma-llm-core" }
reqwest = { workspace = true, features = ["json", "rustls-tls"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true }
@@ -0,0 +1,18 @@
# pluma-llm-gemini
> Backend Google Gemini para [pluma](../README.md).
Implementa `ChatClient` contra la API de Gemini. Modelos Pro / Flash + streaming + system instruction nativa. Lee `GEMINI_API_KEY` o `GOOGLE_API_KEY`.
## API
```rust
use pluma_llm_gemini::GeminiClient;
let chat = GeminiClient::new("gemini-2.0-pro", api_key);
```
## Deps
- [`pluma-llm-core`](../pluma-llm-core/README.md)
- `reqwest`, `serde_json`
@@ -0,0 +1,18 @@
# pluma-llm-gemini
> Google Gemini backend for [pluma](../README.md).
`ChatClient` impl against the Gemini API. Pro / Flash models + streaming + native system instructions. Reads `GEMINI_API_KEY` or `GOOGLE_API_KEY`.
## API
```rust
use pluma_llm_gemini::GeminiClient;
let chat = GeminiClient::new("gemini-2.0-pro", api_key);
```
## Deps
- [`pluma-llm-core`](../pluma-llm-core/README.md)
- `reqwest`, `serde_json`
@@ -0,0 +1,37 @@
//! Smoke test contra Gemini real. UNA request, prompt corto,
//! max_tokens muy bajo — para validar que el adapter habla con
//! `api.anthropic.com`... perdón, con `generativelanguage.googleapis.com`
//! sin gastar tokens. Lee `GEMINI_API_KEY` del env.
//!
//! Corré con:
//! GEMINI_API_KEY=... cargo run -p pluma-llm-gemini --example smoke --release
use pluma_llm_core::{ChatClient, ChatRequest};
use pluma_llm_gemini::GeminiClient;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = GeminiClient::from_env()?;
eprintln!("smoke :: usando modelo {}", cli.model_id());
// Mínimo absoluto: una palabra a traducir, system corto.
let req = ChatRequest::una_vuelta("Translate the single word \"hello\" to Quechua. Respond with just the word.", 30)
.con_sistema("You output a single word with no punctuation.")
.con_temperatura(0.1);
let resp = cli.complete(&req).await?;
println!("respuesta: {}", resp.content.trim());
if let Some(u) = resp.usage {
println!(
"tokens: input={} output={} cache_read={} cache_creation={}",
u.input_tokens,
u.output_tokens,
u.cache_read_input_tokens,
u.cache_creation_input_tokens
);
}
if let Some(s) = resp.stop_reason {
println!("stop_reason: {}", s.0);
}
Ok(())
}
@@ -0,0 +1,381 @@
//! `pluma-llm-gemini` — adapter contra Google Gemini API
//! (Generative Language).
//!
//! Gemini no habla la shape OpenAI: usa `contents` con `parts`,
//! `systemInstruction` aparte, y roles `user` / `model` (no `assistant`).
//! Este crate traduce los tipos genéricos de `pluma-llm-core` a esa shape
//! y de vuelta. La API key va en el query string (`?key=...`) según la
//! documentación oficial de AI Studio.
//!
//! ## Configuración
//!
//! ```no_run
//! # use pluma_llm_gemini::GeminiClient;
//! # fn run() -> Result<(), Box<dyn std::error::Error>> {
//! // Lee GEMINI_API_KEY (o GOOGLE_API_KEY como fallback — ambas son
//! // convenciones comunes en el ecosistema).
//! let cli = GeminiClient::from_env()?;
//!
//! // O explícito; modelo por defecto: `gemini-2.5-flash` (rápido, barato).
//! // Para Pro: `.with_model("gemini-2.5-pro")`.
//! # Ok(()) }
//! ```
#![forbid(unsafe_code)]
use async_trait::async_trait;
use pluma_llm_core::{
ChatClient, ChatError, ChatRequest, ChatResponse, ChatUsage, Role, StopReason,
};
use reqwest::header::{HeaderMap, HeaderValue};
use serde::Deserialize;
use std::time::Duration;
const TIMEOUT_DEFAULT_SECS: u64 = 60;
const ENDPOINT_BASE: &str = "https://generativelanguage.googleapis.com/v1beta/models";
/// `gemini-2.5-flash` — balance velocidad/calidad para transformaciones
/// de pluma (traducir, tono, resumir). Para más cabeza: `gemini-2.5-pro`.
const MODEL_DEFAULT: &str = "gemini-2.5-flash";
/// Convenciones de env: el ecosistema usa `GEMINI_API_KEY` (AI Studio) y
/// `GOOGLE_API_KEY` (Cloud). Aceptamos ambos en orden.
const ENV_KEY_PRIMARY: &str = "GEMINI_API_KEY";
const ENV_KEY_FALLBACK: &str = "GOOGLE_API_KEY";
/// Cliente Gemini implementando [`ChatClient`].
pub struct GeminiClient {
http: reqwest::Client,
endpoint_base: String,
api_key: String,
model: String,
}
impl GeminiClient {
/// Lee la API key de `GEMINI_API_KEY` (preferida) o `GOOGLE_API_KEY`.
pub fn from_env() -> Result<Self, ChatError> {
let api_key = std::env::var(ENV_KEY_PRIMARY)
.or_else(|_| std::env::var(ENV_KEY_FALLBACK))
.map_err(|_| {
ChatError::AuthMissing(format!("{ENV_KEY_PRIMARY} (o {ENV_KEY_FALLBACK})"))
})?;
Self::with_api_key(api_key)
}
/// Construye con una API key explícita.
pub fn with_api_key(api_key: impl Into<String>) -> Result<Self, ChatError> {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(TIMEOUT_DEFAULT_SECS))
.build()
.map_err(|e| ChatError::Network(format!("construir reqwest client: {e}")))?;
Ok(Self {
http,
endpoint_base: ENDPOINT_BASE.to_string(),
api_key: api_key.into(),
model: MODEL_DEFAULT.to_string(),
})
}
/// Cambia el modelo. Válidos hoy: `gemini-2.5-pro`, `gemini-2.5-flash`,
/// `gemini-2.5-flash-lite`, `gemini-2.0-flash`.
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self
}
/// Cambia la base del endpoint — útil para proxies internos que
/// reescriben hacia Gemini.
pub fn with_endpoint_base(mut self, base: impl Into<String>) -> Self {
self.endpoint_base = base.into();
self
}
fn url_generate(&self) -> String {
format!("{}/{}:generateContent", self.endpoint_base, self.model)
}
/// Header `x-goog-api-key` — alternativa documentada al query param.
/// La preferimos para no mostrar la key en logs de access HTTP.
fn headers(&self) -> Result<HeaderMap, ChatError> {
let mut h = HeaderMap::new();
h.insert("content-type", HeaderValue::from_static("application/json"));
let val = HeaderValue::from_str(&self.api_key)
.map_err(|_| ChatError::Backend("api key con bytes inválidos".to_string()))?;
h.insert("x-goog-api-key", val);
Ok(h)
}
}
#[async_trait]
impl ChatClient for GeminiClient {
fn model_id(&self) -> &str {
&self.model
}
async fn complete(&self, req: &ChatRequest) -> Result<ChatResponse, ChatError> {
let payload = construir_payload(req);
let resp = self
.http
.post(self.url_generate())
.headers(self.headers()?)
.json(&payload)
.send()
.await
.map_err(|e| ChatError::Network(format!("POST generateContent: {e}")))?;
let status = resp.status();
let body_bytes = resp
.bytes()
.await
.map_err(|e| ChatError::Network(format!("leer body: {e}")))?;
if status == 401 || status == 403 {
return Err(ChatError::AuthInvalid);
}
if status == 429 {
return Err(ChatError::RateLimited);
}
if !status.is_success() {
// Gemini devuelve `{"error":{"message":...,"status":"..."}}`.
let mensaje = match serde_json::from_slice::<GeminiErrorEnvelope>(&body_bytes) {
Ok(env) => env.error.message,
Err(_) => String::from_utf8_lossy(&body_bytes).into_owned(),
};
return Err(ChatError::Backend(format!("HTTP {status}: {mensaje}")));
}
let parsed: GeminiResponse = serde_json::from_slice(&body_bytes)
.map_err(|e| ChatError::Backend(format!("parseo response: {e}")))?;
let primer = parsed.candidates.into_iter().next().ok_or_else(|| {
ChatError::Backend("response sin candidates".to_string())
})?;
// Concatenar las parts.text del content.
let content = primer
.content
.map(|c| {
c.parts
.into_iter()
.filter_map(|p| p.text)
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default();
let stop_reason = primer.finish_reason.map(StopReason);
// Gemini reporta `cachedContentTokenCount` cuando se usó cache de
// sistema (endpoint /cachedContents, no implementado aquí).
let usage = parsed.usage_metadata.map(|u| ChatUsage {
input_tokens: u.prompt_token_count.unwrap_or(0),
output_tokens: u.candidates_token_count.unwrap_or(0),
cache_read_input_tokens: u.cached_content_token_count.unwrap_or(0),
cache_creation_input_tokens: 0,
});
Ok(ChatResponse {
content,
stop_reason,
usage,
})
}
}
/// Traduce un `ChatRequest` a la shape Gemini:
/// - `system` → top-level `systemInstruction: {parts: [{text:...}]}`.
/// - `messages` → array `contents`, role mapeado (`user` → `user`,
/// `assistant` → `model`), cada uno con `parts: [{text:...}]`.
/// - `max_tokens` + `temperature` → `generationConfig`.
fn construir_payload(req: &ChatRequest) -> serde_json::Value {
let contents: Vec<serde_json::Value> = req
.messages
.iter()
.map(|m| {
let role = match m.role {
Role::User => "user",
Role::Assistant => "model",
};
// Texto primero, luego las imágenes como `inlineData`
// (camelCase, igual que el resto del payload Gemini).
let mut parts: Vec<serde_json::Value> =
vec![serde_json::json!({"text": m.content})];
for img in &m.images {
parts.push(serde_json::json!({
"inlineData": {
"mimeType": img.media_type,
"data": img.data_base64,
}
}));
}
serde_json::json!({
"role": role,
"parts": parts,
})
})
.collect();
let mut payload = serde_json::json!({
"contents": contents,
"generationConfig": {
"maxOutputTokens": req.max_tokens,
"temperature": req.temperature,
},
});
if let Some(sys) = &req.system {
payload.as_object_mut().unwrap().insert(
"systemInstruction".to_string(),
serde_json::json!({"parts": [{"text": sys}]}),
);
}
payload
}
// -------- Tipos del wire Gemini --------
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GeminiResponse {
#[serde(default)]
candidates: Vec<GeminiCandidate>,
#[serde(default)]
usage_metadata: Option<GeminiUsage>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GeminiCandidate {
#[serde(default)]
content: Option<GeminiContent>,
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct GeminiContent {
#[serde(default)]
parts: Vec<GeminiPart>,
}
#[derive(Debug, Deserialize)]
struct GeminiPart {
#[serde(default)]
text: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GeminiUsage {
#[serde(default)]
prompt_token_count: Option<u32>,
#[serde(default)]
candidates_token_count: Option<u32>,
#[serde(default)]
cached_content_token_count: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct GeminiErrorEnvelope {
error: GeminiErrorBody,
}
#[derive(Debug, Deserialize)]
struct GeminiErrorBody {
#[serde(default)]
message: String,
}
#[cfg(test)]
mod pruebas {
use super::*;
use pluma_llm_core::ChatMessage;
#[test]
fn payload_sin_system_omite_systemInstruction() {
let req = ChatRequest::una_vuelta("hola", 50);
let p = construir_payload(&req);
assert!(p.get("systemInstruction").is_none());
let contents = p["contents"].as_array().unwrap();
assert_eq!(contents.len(), 1);
assert_eq!(contents[0]["role"], "user");
assert_eq!(contents[0]["parts"][0]["text"], "hola");
assert_eq!(p["generationConfig"]["maxOutputTokens"], 50);
}
#[test]
fn payload_con_system_lleva_systemInstruction_top_level() {
let req = ChatRequest::una_vuelta("traduce esto", 100)
.con_sistema("Eres traductor.");
let p = construir_payload(&req);
let si = p.get("systemInstruction").expect("system presente");
assert_eq!(si["parts"][0]["text"], "Eres traductor.");
}
#[test]
fn assistant_se_mapea_a_role_model() {
let req = ChatRequest {
system: None,
max_tokens: 1,
temperature: 0.0,
messages: vec![
ChatMessage::user("u"),
ChatMessage::assistant("a"),
],
};
let p = construir_payload(&req);
assert_eq!(p["contents"][0]["role"], "user");
assert_eq!(p["contents"][1]["role"], "model");
}
#[test]
fn mensaje_con_imagen_agrega_part_inline_data() {
use pluma_llm_core::ChatImage;
let req = ChatRequest {
system: None,
max_tokens: 100,
temperature: 0.0,
messages: vec![ChatMessage::user_con_imagenes(
"describe",
vec![ChatImage::new("image/jpeg", "Zm9v")],
)],
};
let p = construir_payload(&req);
let parts = &p["contents"][0]["parts"];
assert_eq!(parts[0]["text"], "describe");
assert_eq!(parts[1]["inlineData"]["mimeType"], "image/jpeg");
assert_eq!(parts[1]["inlineData"]["data"], "Zm9v");
}
#[test]
fn url_generate_incluye_modelo_y_endpoint() {
let cli = GeminiClient::with_api_key("k").unwrap();
assert!(cli.url_generate().contains("gemini-2.5-flash:generateContent"));
let cli = cli.with_model("gemini-2.5-pro");
assert!(cli.url_generate().ends_with("gemini-2.5-pro:generateContent"));
}
#[test]
fn parsea_response_con_candidates_y_usage() {
let body = serde_json::json!({
"candidates": [{
"content": {"parts": [{"text": "huk"}, {"text": " iskay"}], "role": "model"},
"finishReason": "STOP"
}],
"usageMetadata": {
"promptTokenCount": 100,
"candidatesTokenCount": 5,
"cachedContentTokenCount": 80
}
});
let parsed: GeminiResponse = serde_json::from_value(body).unwrap();
let cand = &parsed.candidates[0];
let content = cand.content.as_ref().unwrap();
let texto: String = content
.parts
.iter()
.filter_map(|p| p.text.clone())
.collect();
assert_eq!(texto, "huk iskay");
let u = parsed.usage_metadata.unwrap();
assert_eq!(u.prompt_token_count, Some(100));
assert_eq!(u.cached_content_token_count, Some(80));
}
}
@@ -0,0 +1,15 @@
[package]
name = "pluma-llm-mock"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "pluma — backend LLM determinista para tests. No habla con ningún servicio; devuelve respuestas predeclaradas indexadas por hash del prompt, o un eco modificable."
[dependencies]
async-trait = { workspace = true }
pluma-llm-core = { path = "../pluma-llm-core" }
[dev-dependencies]
tokio = { workspace = true }
+23
View File
@@ -0,0 +1,23 @@
# pluma-llm-mock
> Backend mock determinista para [pluma](../README.md). Tests + fallback.
Implementa `ChatClient` sin red. Devuelve respuestas según un script preconfigurado o ecos del input. Útil para:
- **Tests** verdes sin credenciales.
- **Fallback** automático cuando no hay env keys (la fachada de [`pluma-llm`](../pluma-llm/README.md) cae acá).
- **CI** offline.
## API
```rust
use pluma_llm_mock::MockClient;
let chat = MockClient::echo();
let chat = MockClient::scripted(vec!["resp1", "resp2"]);
```
## Deps
- [`pluma-llm-core`](../pluma-llm-core/README.md)
- Sin deps de red
@@ -0,0 +1,23 @@
# pluma-llm-mock
> Deterministic mock backend for [pluma](../README.md). Tests + fallback.
`ChatClient` impl without network. Returns answers based on a preconfigured script or echoes the input. Useful for:
- **Tests** green without credentials.
- **Fallback** when no env keys are present (the [`pluma-llm`](../pluma-llm/README.md) facade falls back to it).
- **CI** offline.
## API
```rust
use pluma_llm_mock::MockClient;
let chat = MockClient::echo();
let chat = MockClient::scripted(vec!["resp1", "resp2"]);
```
## Deps
- [`pluma-llm-core`](../pluma-llm-core/README.md)
- No network deps

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