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:
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
*.pdb
|
||||
@@ -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 texto–texto. |
|
||||
| [`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).
|
||||
@@ -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, ¶ms, 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 |
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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 }
|
||||
@@ -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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
_ => 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.45–0.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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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);
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -0,0 +1,18 @@
|
||||
# pluma-align
|
||||
|
||||
> Alineamiento texto–texto 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`
|
||||
@@ -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`
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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/)
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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`
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">")
|
||||
}
|
||||
|
||||
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 <demo></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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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 N−1 *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()]);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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(¤t)
|
||||
.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 }
|
||||
@@ -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
|
||||
@@ -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.1–0.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 }
|
||||
@@ -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
Reference in New Issue
Block a user