From 30467600bcd9effa15e8338fb32d357c1edab660 Mon Sep 17 00:00:00 2001 From: Sergio Date: Thu, 4 Jun 2026 12:18:01 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20pluma=20standalone=20=E2=80=94=20autor?= =?UTF-8?q?=C3=ADa=20multilienzo=20(haz=20de=20cuerpos)=20+=20notebook=20r?= =?UTF-8?q?eactivo=20(front-door,=20git-dep=20al=20monorepo)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 3 + 00_unanchay/pluma/LEEME.md | 91 + 00_unanchay/pluma/PRUEBAS.md | 390 + 00_unanchay/pluma/README.md | 46 + 00_unanchay/pluma/README.qu.md | 43 + 00_unanchay/pluma/foreign-docx/Cargo.toml | 16 + 00_unanchay/pluma/foreign-docx/src/lib.rs | 460 + .../pluma/pluma-align-embeddings/Cargo.toml | 21 + .../pluma/pluma-align-embeddings/LEEME.md | 19 + .../pluma/pluma-align-embeddings/README.md | 19 + .../pluma/pluma-align-embeddings/src/lib.rs | 372 + 00_unanchay/pluma/pluma-align/Cargo.toml | 14 + 00_unanchay/pluma/pluma-align/LEEME.md | 18 + 00_unanchay/pluma/pluma-align/README.md | 18 + 00_unanchay/pluma/pluma-align/src/lib.rs | 385 + 00_unanchay/pluma/pluma-app/Cargo.toml | 46 + 00_unanchay/pluma/pluma-app/LEEME.md | 16 + 00_unanchay/pluma/pluma-app/README.md | 16 + 00_unanchay/pluma/pluma-app/src/clipboard.rs | 30 + 00_unanchay/pluma/pluma-app/src/init.rs | 149 + 00_unanchay/pluma/pluma-app/src/main.rs | 200 + 00_unanchay/pluma/pluma-app/src/model.rs | 168 + 00_unanchay/pluma/pluma-app/src/update.rs | 1004 +++ 00_unanchay/pluma/pluma-app/src/util.rs | 101 + 00_unanchay/pluma/pluma-app/src/view.rs | 821 ++ 00_unanchay/pluma/pluma-core/Cargo.toml | 13 + 00_unanchay/pluma/pluma-core/LEEME.md | 19 + 00_unanchay/pluma/pluma-core/README.md | 19 + 00_unanchay/pluma/pluma-core/src/lib.rs | 138 + 00_unanchay/pluma/pluma-cuerpo/Cargo.toml | 14 + 00_unanchay/pluma/pluma-cuerpo/LEEME.md | 19 + 00_unanchay/pluma/pluma-cuerpo/README.md | 19 + 00_unanchay/pluma/pluma-cuerpo/src/lib.rs | 379 + 00_unanchay/pluma/pluma-deck-app/Cargo.toml | 28 + 00_unanchay/pluma/pluma-deck-app/src/main.rs | 706 ++ 00_unanchay/pluma/pluma-deck-core/Cargo.toml | 25 + 00_unanchay/pluma/pluma-deck-core/LEEME.md | 18 + 00_unanchay/pluma/pluma-deck-core/README.md | 18 + .../pluma/pluma-deck-core/src/adaptador.rs | 141 + .../pluma/pluma-deck-core/src/camara.rs | 257 + 00_unanchay/pluma/pluma-deck-core/src/lib.rs | 195 + .../pluma/pluma-deck-core/src/recorrido.rs | 850 ++ .../pluma-deck-recorrido-llimphi/Cargo.toml | 25 + .../examples/recorrido_demo.rs | 181 + .../examples/recorrido_editor_demo.rs | 210 + .../examples/recorrido_imagen_demo.rs | 163 + .../examples/recorrido_md_demo.rs | 146 + .../pluma-deck-recorrido-llimphi/src/lib.rs | 463 + 00_unanchay/pluma/pluma-deck-web/Cargo.toml | 32 + 00_unanchay/pluma/pluma-deck-web/LEEME.md | 54 + 00_unanchay/pluma/pluma-deck-web/README.md | 19 + 00_unanchay/pluma/pluma-deck-web/src/lib.rs | 212 + .../pluma/pluma-deck-web/src/recorrido.rs | 603 ++ .../pluma/pluma-editor-cuerpo/Cargo.toml | 14 + .../pluma/pluma-editor-cuerpo/LEEME.md | 20 + .../pluma/pluma-editor-cuerpo/README.md | 20 + .../pluma/pluma-editor-cuerpo/src/lib.rs | 352 + .../pluma/pluma-editor-llimphi/Cargo.toml | 37 + .../pluma/pluma-editor-llimphi/LEEME.md | 13 + .../pluma/pluma-editor-llimphi/README.md | 13 + .../examples/cuerpo_ide_demo.rs | 439 + .../examples/editor_unico_demo.rs | 582 ++ .../examples/multilienzo_completo_demo.rs | 995 +++ .../examples/multilienzo_demo.rs | 275 + .../examples/multilienzo_dinamico_demo.rs | 398 + .../examples/multilienzo_llm_demo.rs | 328 + .../examples/multilienzo_store_demo.rs | 353 + .../examples/zona_transform_demo.rs | 757 ++ .../pluma-editor-llimphi/src/cuerpo_ide.rs | 1334 +++ .../pluma/pluma-editor-llimphi/src/lib.rs | 322 + .../pluma-editor-llimphi/src/multilienzo.rs | 713 ++ .../src/multilienzo_editor.rs | 744 ++ .../pluma/pluma-graph-transform/Cargo.toml | 22 + .../pluma/pluma-graph-transform/LEEME.md | 19 + .../pluma/pluma-graph-transform/README.md | 19 + .../pluma/pluma-graph-transform/src/lib.rs | 188 + 00_unanchay/pluma/pluma-graph/Cargo.toml | 12 + 00_unanchay/pluma/pluma-graph/LEEME.md | 19 + 00_unanchay/pluma/pluma-graph/README.md | 19 + 00_unanchay/pluma/pluma-graph/src/lib.rs | 211 + .../pluma/pluma-llm-anthropic/Cargo.toml | 16 + .../pluma/pluma-llm-anthropic/LEEME.md | 19 + .../pluma/pluma-llm-anthropic/README.md | 19 + .../pluma/pluma-llm-anthropic/src/lib.rs | 389 + 00_unanchay/pluma/pluma-llm-cohere/Cargo.toml | 16 + 00_unanchay/pluma/pluma-llm-cohere/LEEME.md | 18 + 00_unanchay/pluma/pluma-llm-cohere/README.md | 18 + 00_unanchay/pluma/pluma-llm-cohere/src/lib.rs | 298 + 00_unanchay/pluma/pluma-llm-core/Cargo.toml | 17 + 00_unanchay/pluma/pluma-llm-core/LEEME.md | 19 + 00_unanchay/pluma/pluma-llm-core/README.md | 19 + 00_unanchay/pluma/pluma-llm-core/src/lib.rs | 290 + 00_unanchay/pluma/pluma-llm-gemini/Cargo.toml | 16 + 00_unanchay/pluma/pluma-llm-gemini/LEEME.md | 18 + 00_unanchay/pluma/pluma-llm-gemini/README.md | 18 + .../pluma/pluma-llm-gemini/examples/smoke.rs | 37 + 00_unanchay/pluma/pluma-llm-gemini/src/lib.rs | 381 + 00_unanchay/pluma/pluma-llm-mock/Cargo.toml | 15 + 00_unanchay/pluma/pluma-llm-mock/LEEME.md | 23 + 00_unanchay/pluma/pluma-llm-mock/README.md | 23 + 00_unanchay/pluma/pluma-llm-mock/src/lib.rs | 238 + .../pluma-llm-openai-compatible/Cargo.toml | 16 + .../pluma-llm-openai-compatible/LEEME.md | 18 + .../pluma-llm-openai-compatible/README.md | 18 + .../pluma-llm-openai-compatible/src/lib.rs | 352 + 00_unanchay/pluma/pluma-llm/Cargo.toml | 22 + 00_unanchay/pluma/pluma-llm/LEEME.md | 20 + 00_unanchay/pluma/pluma-llm/README.md | 20 + 00_unanchay/pluma/pluma-llm/src/lib.rs | 329 + .../pluma/pluma-md-reader-web/Cargo.toml | 25 + .../pluma/pluma-md-reader-web/LEEME.md | 21 + .../pluma/pluma-md-reader-web/README.md | 21 + .../pluma/pluma-md-reader-web/src/lib.rs | 88 + 00_unanchay/pluma/pluma-md/Cargo.toml | 13 + 00_unanchay/pluma/pluma-md/LEEME.md | 17 + 00_unanchay/pluma/pluma-md/README.md | 17 + 00_unanchay/pluma/pluma-md/src/export.rs | 116 + 00_unanchay/pluma/pluma-md/src/import.rs | 232 + 00_unanchay/pluma/pluma-md/src/lib.rs | 95 + .../pluma/pluma-notebook-app/Cargo.toml | 16 + 00_unanchay/pluma/pluma-notebook-app/LEEME.md | 17 + .../pluma/pluma-notebook-app/README.md | 17 + .../pluma/pluma-notebook-app/src/main.rs | 91 + .../pluma/pluma-notebook-core/Cargo.toml | 19 + .../pluma/pluma-notebook-core/LEEME.md | 19 + .../pluma/pluma-notebook-core/README.md | 19 + .../pluma/pluma-notebook-core/src/cell.rs | 222 + .../pluma/pluma-notebook-core/src/lib.rs | 49 + .../pluma/pluma-notebook-core/src/notebook.rs | 444 + .../pluma/pluma-notebook-exec/Cargo.toml | 18 + .../pluma/pluma-notebook-exec/LEEME.md | 19 + .../pluma/pluma-notebook-exec/README.md | 19 + .../pluma/pluma-notebook-exec/src/lib.rs | 316 + .../pluma-notebook-graph-llimphi/Cargo.toml | 29 + .../pluma-notebook-graph-llimphi/LEEME.md | 10 + .../pluma-notebook-graph-llimphi/README.md | 10 + .../examples/notebook_graph_demo.rs | 132 + .../examples/notebook_graph_dominium_demo.rs | 429 + .../pluma-notebook-graph-llimphi/src/lib.rs | 634 ++ .../pluma-notebook-kernel-llm/Cargo.toml | 19 + .../pluma/pluma-notebook-kernel-llm/LEEME.md | 18 + .../pluma/pluma-notebook-kernel-llm/README.md | 18 + .../examples/notebook_llm_demo.rs | 130 + .../pluma-notebook-kernel-llm/src/lib.rs | 330 + .../pluma-notebook-kernel-media/Cargo.toml | 16 + .../pluma-notebook-kernel-media/src/lib.rs | 684 ++ .../pluma-notebook-kernel-multi/Cargo.toml | 15 + .../pluma-notebook-kernel-multi/src/lib.rs | 54 + .../pluma-notebook-kernel-python/Cargo.toml | 22 + .../pluma-notebook-kernel-python/LEEME.md | 30 + .../pluma-notebook-kernel-python/README.md | 30 + .../pluma-notebook-kernel-python/src/lib.rs | 322 + .../pluma-notebook-kernel-tinkuy/Cargo.toml | 19 + .../pluma-notebook-kernel-tinkuy/src/lib.rs | 650 ++ .../pluma-notebook-kernel-wasm/Cargo.toml | 20 + .../pluma/pluma-notebook-kernel-wasm/LEEME.md | 30 + .../pluma-notebook-kernel-wasm/README.md | 30 + .../pluma-notebook-kernel-wasm/src/lib.rs | 347 + .../pluma/pluma-notebook-llimphi/Cargo.toml | 31 + .../pluma/pluma-notebook-llimphi/LEEME.md | 10 + .../pluma/pluma-notebook-llimphi/README.md | 10 + .../pluma/pluma-notebook-llimphi/src/main.rs | 1350 +++ .../pluma/pluma-notebook-store/Cargo.toml | 18 + .../pluma/pluma-notebook-store/LEEME.md | 20 + .../pluma/pluma-notebook-store/README.md | 20 + .../pluma/pluma-notebook-store/src/lib.rs | 115 + .../pluma/pluma-render-plan/Cargo.toml | 14 + 00_unanchay/pluma/pluma-render-plan/LEEME.md | 21 + 00_unanchay/pluma/pluma-render-plan/README.md | 21 + .../pluma/pluma-render-plan/src/lib.rs | 391 + 00_unanchay/pluma/pluma-semantic/Cargo.toml | 16 + 00_unanchay/pluma/pluma-semantic/LEEME.md | 18 + 00_unanchay/pluma/pluma-semantic/README.md | 18 + 00_unanchay/pluma/pluma-semantic/src/lib.rs | 137 + 00_unanchay/pluma/pluma-store/Cargo.toml | 23 + 00_unanchay/pluma/pluma-store/LEEME.md | 20 + 00_unanchay/pluma/pluma-store/README.md | 20 + 00_unanchay/pluma/pluma-store/src/lib.rs | 128 + .../pluma/pluma-store/src/multilienzo.rs | 496 ++ .../pluma/pluma-transform-llm/Cargo.toml | 22 + .../pluma/pluma-transform-llm/LEEME.md | 19 + .../pluma/pluma-transform-llm/README.md | 19 + .../pluma/pluma-transform-llm/src/lib.rs | 691 ++ .../pluma/pluma-transform-tabla/Cargo.toml | 19 + .../pluma/pluma-transform-tabla/LEEME.md | 19 + .../pluma/pluma-transform-tabla/README.md | 19 + .../pluma/pluma-transform-tabla/src/lib.rs | 304 + 00_unanchay/pluma/pluma-transform/Cargo.toml | 20 + 00_unanchay/pluma/pluma-transform/LEEME.md | 18 + 00_unanchay/pluma/pluma-transform/README.md | 18 + 00_unanchay/pluma/pluma-transform/src/lib.rs | 361 + Cargo.lock | 7595 +++++++++++++++++ Cargo.toml | 484 ++ LICENSE | 21 + README.md | 16 + 195 files changed, 38852 insertions(+) create mode 100644 .gitignore create mode 100644 00_unanchay/pluma/LEEME.md create mode 100644 00_unanchay/pluma/PRUEBAS.md create mode 100644 00_unanchay/pluma/README.md create mode 100644 00_unanchay/pluma/README.qu.md create mode 100644 00_unanchay/pluma/foreign-docx/Cargo.toml create mode 100644 00_unanchay/pluma/foreign-docx/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-align-embeddings/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-align-embeddings/LEEME.md create mode 100644 00_unanchay/pluma/pluma-align-embeddings/README.md create mode 100644 00_unanchay/pluma/pluma-align-embeddings/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-align/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-align/LEEME.md create mode 100644 00_unanchay/pluma/pluma-align/README.md create mode 100644 00_unanchay/pluma/pluma-align/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-app/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-app/LEEME.md create mode 100644 00_unanchay/pluma/pluma-app/README.md create mode 100644 00_unanchay/pluma/pluma-app/src/clipboard.rs create mode 100644 00_unanchay/pluma/pluma-app/src/init.rs create mode 100644 00_unanchay/pluma/pluma-app/src/main.rs create mode 100644 00_unanchay/pluma/pluma-app/src/model.rs create mode 100644 00_unanchay/pluma/pluma-app/src/update.rs create mode 100644 00_unanchay/pluma/pluma-app/src/util.rs create mode 100644 00_unanchay/pluma/pluma-app/src/view.rs create mode 100644 00_unanchay/pluma/pluma-core/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-core/LEEME.md create mode 100644 00_unanchay/pluma/pluma-core/README.md create mode 100644 00_unanchay/pluma/pluma-core/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-cuerpo/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-cuerpo/LEEME.md create mode 100644 00_unanchay/pluma/pluma-cuerpo/README.md create mode 100644 00_unanchay/pluma/pluma-cuerpo/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-deck-app/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-deck-app/src/main.rs create mode 100644 00_unanchay/pluma/pluma-deck-core/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-deck-core/LEEME.md create mode 100644 00_unanchay/pluma/pluma-deck-core/README.md create mode 100644 00_unanchay/pluma/pluma-deck-core/src/adaptador.rs create mode 100644 00_unanchay/pluma/pluma-deck-core/src/camara.rs create mode 100644 00_unanchay/pluma/pluma-deck-core/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-deck-core/src/recorrido.rs create mode 100644 00_unanchay/pluma/pluma-deck-recorrido-llimphi/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_demo.rs create mode 100644 00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_editor_demo.rs create mode 100644 00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_imagen_demo.rs create mode 100644 00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_md_demo.rs create mode 100644 00_unanchay/pluma/pluma-deck-recorrido-llimphi/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-deck-web/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-deck-web/LEEME.md create mode 100644 00_unanchay/pluma/pluma-deck-web/README.md create mode 100644 00_unanchay/pluma/pluma-deck-web/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-deck-web/src/recorrido.rs create mode 100644 00_unanchay/pluma/pluma-editor-cuerpo/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-editor-cuerpo/LEEME.md create mode 100644 00_unanchay/pluma/pluma-editor-cuerpo/README.md create mode 100644 00_unanchay/pluma/pluma-editor-cuerpo/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-editor-llimphi/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-editor-llimphi/LEEME.md create mode 100644 00_unanchay/pluma/pluma-editor-llimphi/README.md create mode 100644 00_unanchay/pluma/pluma-editor-llimphi/examples/cuerpo_ide_demo.rs create mode 100644 00_unanchay/pluma/pluma-editor-llimphi/examples/editor_unico_demo.rs create mode 100644 00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_completo_demo.rs create mode 100644 00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_demo.rs create mode 100644 00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_dinamico_demo.rs create mode 100644 00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_llm_demo.rs create mode 100644 00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_store_demo.rs create mode 100644 00_unanchay/pluma/pluma-editor-llimphi/examples/zona_transform_demo.rs create mode 100644 00_unanchay/pluma/pluma-editor-llimphi/src/cuerpo_ide.rs create mode 100644 00_unanchay/pluma/pluma-editor-llimphi/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-editor-llimphi/src/multilienzo.rs create mode 100644 00_unanchay/pluma/pluma-editor-llimphi/src/multilienzo_editor.rs create mode 100644 00_unanchay/pluma/pluma-graph-transform/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-graph-transform/LEEME.md create mode 100644 00_unanchay/pluma/pluma-graph-transform/README.md create mode 100644 00_unanchay/pluma/pluma-graph-transform/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-graph/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-graph/LEEME.md create mode 100644 00_unanchay/pluma/pluma-graph/README.md create mode 100644 00_unanchay/pluma/pluma-graph/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-llm-anthropic/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-llm-anthropic/LEEME.md create mode 100644 00_unanchay/pluma/pluma-llm-anthropic/README.md create mode 100644 00_unanchay/pluma/pluma-llm-anthropic/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-llm-cohere/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-llm-cohere/LEEME.md create mode 100644 00_unanchay/pluma/pluma-llm-cohere/README.md create mode 100644 00_unanchay/pluma/pluma-llm-cohere/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-llm-core/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-llm-core/LEEME.md create mode 100644 00_unanchay/pluma/pluma-llm-core/README.md create mode 100644 00_unanchay/pluma/pluma-llm-core/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-llm-gemini/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-llm-gemini/LEEME.md create mode 100644 00_unanchay/pluma/pluma-llm-gemini/README.md create mode 100644 00_unanchay/pluma/pluma-llm-gemini/examples/smoke.rs create mode 100644 00_unanchay/pluma/pluma-llm-gemini/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-llm-mock/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-llm-mock/LEEME.md create mode 100644 00_unanchay/pluma/pluma-llm-mock/README.md create mode 100644 00_unanchay/pluma/pluma-llm-mock/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-llm-openai-compatible/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-llm-openai-compatible/LEEME.md create mode 100644 00_unanchay/pluma/pluma-llm-openai-compatible/README.md create mode 100644 00_unanchay/pluma/pluma-llm-openai-compatible/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-llm/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-llm/LEEME.md create mode 100644 00_unanchay/pluma/pluma-llm/README.md create mode 100644 00_unanchay/pluma/pluma-llm/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-md-reader-web/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-md-reader-web/LEEME.md create mode 100644 00_unanchay/pluma/pluma-md-reader-web/README.md create mode 100644 00_unanchay/pluma/pluma-md-reader-web/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-md/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-md/LEEME.md create mode 100644 00_unanchay/pluma/pluma-md/README.md create mode 100644 00_unanchay/pluma/pluma-md/src/export.rs create mode 100644 00_unanchay/pluma/pluma-md/src/import.rs create mode 100644 00_unanchay/pluma/pluma-md/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-notebook-app/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-notebook-app/LEEME.md create mode 100644 00_unanchay/pluma/pluma-notebook-app/README.md create mode 100644 00_unanchay/pluma/pluma-notebook-app/src/main.rs create mode 100644 00_unanchay/pluma/pluma-notebook-core/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-notebook-core/LEEME.md create mode 100644 00_unanchay/pluma/pluma-notebook-core/README.md create mode 100644 00_unanchay/pluma/pluma-notebook-core/src/cell.rs create mode 100644 00_unanchay/pluma/pluma-notebook-core/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-notebook-core/src/notebook.rs create mode 100644 00_unanchay/pluma/pluma-notebook-exec/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-notebook-exec/LEEME.md create mode 100644 00_unanchay/pluma/pluma-notebook-exec/README.md create mode 100644 00_unanchay/pluma/pluma-notebook-exec/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-notebook-graph-llimphi/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-notebook-graph-llimphi/LEEME.md create mode 100644 00_unanchay/pluma/pluma-notebook-graph-llimphi/README.md create mode 100644 00_unanchay/pluma/pluma-notebook-graph-llimphi/examples/notebook_graph_demo.rs create mode 100644 00_unanchay/pluma/pluma-notebook-graph-llimphi/examples/notebook_graph_dominium_demo.rs create mode 100644 00_unanchay/pluma/pluma-notebook-graph-llimphi/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-notebook-kernel-llm/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-notebook-kernel-llm/LEEME.md create mode 100644 00_unanchay/pluma/pluma-notebook-kernel-llm/README.md create mode 100644 00_unanchay/pluma/pluma-notebook-kernel-llm/examples/notebook_llm_demo.rs create mode 100644 00_unanchay/pluma/pluma-notebook-kernel-llm/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-notebook-kernel-media/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-notebook-kernel-media/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-notebook-kernel-multi/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-notebook-kernel-multi/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-notebook-kernel-python/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-notebook-kernel-python/LEEME.md create mode 100644 00_unanchay/pluma/pluma-notebook-kernel-python/README.md create mode 100644 00_unanchay/pluma/pluma-notebook-kernel-python/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-notebook-kernel-tinkuy/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-notebook-kernel-tinkuy/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-notebook-kernel-wasm/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-notebook-kernel-wasm/LEEME.md create mode 100644 00_unanchay/pluma/pluma-notebook-kernel-wasm/README.md create mode 100644 00_unanchay/pluma/pluma-notebook-kernel-wasm/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-notebook-llimphi/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-notebook-llimphi/LEEME.md create mode 100644 00_unanchay/pluma/pluma-notebook-llimphi/README.md create mode 100644 00_unanchay/pluma/pluma-notebook-llimphi/src/main.rs create mode 100644 00_unanchay/pluma/pluma-notebook-store/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-notebook-store/LEEME.md create mode 100644 00_unanchay/pluma/pluma-notebook-store/README.md create mode 100644 00_unanchay/pluma/pluma-notebook-store/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-render-plan/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-render-plan/LEEME.md create mode 100644 00_unanchay/pluma/pluma-render-plan/README.md create mode 100644 00_unanchay/pluma/pluma-render-plan/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-semantic/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-semantic/LEEME.md create mode 100644 00_unanchay/pluma/pluma-semantic/README.md create mode 100644 00_unanchay/pluma/pluma-semantic/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-store/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-store/LEEME.md create mode 100644 00_unanchay/pluma/pluma-store/README.md create mode 100644 00_unanchay/pluma/pluma-store/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-store/src/multilienzo.rs create mode 100644 00_unanchay/pluma/pluma-transform-llm/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-transform-llm/LEEME.md create mode 100644 00_unanchay/pluma/pluma-transform-llm/README.md create mode 100644 00_unanchay/pluma/pluma-transform-llm/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-transform-tabla/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-transform-tabla/LEEME.md create mode 100644 00_unanchay/pluma/pluma-transform-tabla/README.md create mode 100644 00_unanchay/pluma/pluma-transform-tabla/src/lib.rs create mode 100644 00_unanchay/pluma/pluma-transform/Cargo.toml create mode 100644 00_unanchay/pluma/pluma-transform/LEEME.md create mode 100644 00_unanchay/pluma/pluma-transform/README.md create mode 100644 00_unanchay/pluma/pluma-transform/src/lib.rs create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7141ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +**/*.rs.bk +*.pdb diff --git a/00_unanchay/pluma/LEEME.md b/00_unanchay/pluma/LEEME.md new file mode 100644 index 0000000..bb46fda --- /dev/null +++ b/00_unanchay/pluma/LEEME.md @@ -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` 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). diff --git a/00_unanchay/pluma/PRUEBAS.md b/00_unanchay/pluma/PRUEBAS.md new file mode 100644 index 0000000..78382ac --- /dev/null +++ b/00_unanchay/pluma/PRUEBAS.md @@ -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 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 | diff --git a/00_unanchay/pluma/README.md b/00_unanchay/pluma/README.md new file mode 100644 index 0000000..a7464c3 --- /dev/null +++ b/00_unanchay/pluma/README.md @@ -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). diff --git a/00_unanchay/pluma/README.qu.md b/00_unanchay/pluma/README.qu.md new file mode 100644 index 0000000..51cfacc --- /dev/null +++ b/00_unanchay/pluma/README.qu.md @@ -0,0 +1,43 @@ + + +# 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). diff --git a/00_unanchay/pluma/foreign-docx/Cargo.toml b/00_unanchay/pluma/foreign-docx/Cargo.toml new file mode 100644 index 0000000..9f8df63 --- /dev/null +++ b/00_unanchay/pluma/foreign-docx/Cargo.toml @@ -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 `` del XML → un NarrativeAtom; runs `` 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 } diff --git a/00_unanchay/pluma/foreign-docx/src/lib.rs b/00_unanchay/pluma/foreign-docx/src/lib.rs new file mode 100644 index 0000000..9fb4d96 --- /dev/null +++ b/00_unanchay/pluma/foreign-docx/src/lib.rs @@ -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 +//! ``, junta los runs `` 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, +} + +#[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, + nombre: impl Into, + ahora: u64, +) -> Result { + 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 `` abre un buffer; cada `` añade +/// su texto; `` 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 `` se ignoran — quedan como espacio implícito entre +/// los runs adyacentes. +fn extraer_parrafos(xml: &str) -> Result, 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 `` 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 `` con un solo +/// ``. 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, +) -> Result, 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(""); + xml.push_str(&escapar_xml(&atom.content)); + xml.push_str(""); + } + // Si el cuerpo está vacío, igual escribimos un párrafo vacío — + // un .docx sin a veces lo rechazan parsers estrictos. + if cuerpo.orden.is_empty() || cuerpo.orden.iter().all(|id| !atoms.contains_key(id)) { + xml.push_str(""); + } + 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, +) -> Result, 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(""); + xml.push_str(&escapar_xml(&atom.content)); + xml.push_str(""); + } + if cuerpo.orden.is_empty() || cuerpo.orden.iter().all(|id| !atoms.contains_key(id)) { + xml.push_str(""); + } + 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#" + + + + +"#; + +const PACKAGE_RELS_XML: &str = r#" + + +"#; + +const DOCUMENT_RELS_XML: &str = r#" + +"#; + +const DOCUMENT_HEAD: &str = r#" + + "#; + +const DOCUMENT_TAIL: &str = r#" +"#; + +/// 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 { + 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#" + + + +"#, + ) + .unwrap(); + // El documento. + let mut xml = String::from( + r#" + + "#, + ); + for p in parrafos { + xml.push_str(&format!( + "{}", + p + )); + } + xml.push_str(""); + 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) { + 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 = 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(&[" & \"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(), " & \"comillas\" 'simples'"); + } +} diff --git a/00_unanchay/pluma/pluma-align-embeddings/Cargo.toml b/00_unanchay/pluma/pluma-align-embeddings/Cargo.toml new file mode 100644 index 0000000..c47e6a3 --- /dev/null +++ b/00_unanchay/pluma/pluma-align-embeddings/Cargo.toml @@ -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 } diff --git a/00_unanchay/pluma/pluma-align-embeddings/LEEME.md b/00_unanchay/pluma/pluma-align-embeddings/LEEME.md new file mode 100644 index 0000000..dee9c34 --- /dev/null +++ b/00_unanchay/pluma/pluma-align-embeddings/LEEME.md @@ -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` diff --git a/00_unanchay/pluma/pluma-align-embeddings/README.md b/00_unanchay/pluma/pluma-align-embeddings/README.md new file mode 100644 index 0000000..da12c01 --- /dev/null +++ b/00_unanchay/pluma/pluma-align-embeddings/README.md @@ -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` diff --git a/00_unanchay/pluma/pluma-align-embeddings/src/lib.rs b/00_unanchay/pluma/pluma-align-embeddings/src/lib.rs new file mode 100644 index 0000000..c84d867 --- /dev/null +++ b/00_unanchay/pluma/pluma-align-embeddings/src/lib.rs @@ -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, + provider: &dyn Provider, + params: &ParamsAlineacion, + ahora: u64, +) -> Result { + 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, +) -> (Vec, Vec) { + 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) { + let mut c = Cuerpo::nuevo(branch, branch, intencion, 100); + let atoms: Vec = + 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 { + 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); + } +} diff --git a/00_unanchay/pluma/pluma-align/Cargo.toml b/00_unanchay/pluma/pluma-align/Cargo.toml new file mode 100644 index 0000000..a206b08 --- /dev/null +++ b/00_unanchay/pluma/pluma-align/Cargo.toml @@ -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" } diff --git a/00_unanchay/pluma/pluma-align/LEEME.md b/00_unanchay/pluma/pluma-align/LEEME.md new file mode 100644 index 0000000..6265dad --- /dev/null +++ b/00_unanchay/pluma/pluma-align/LEEME.md @@ -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` 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` diff --git a/00_unanchay/pluma/pluma-align/README.md b/00_unanchay/pluma/pluma-align/README.md new file mode 100644 index 0000000..7943f4a --- /dev/null +++ b/00_unanchay/pluma/pluma-align/README.md @@ -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` 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` diff --git a/00_unanchay/pluma/pluma-align/src/lib.rs b/00_unanchay/pluma/pluma-align/src/lib.rs new file mode 100644 index 0000000..a8d4ed8 --- /dev/null +++ b/00_unanchay/pluma/pluma-align/src/lib.rs @@ -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 { + 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, + pub cuerpo_b: Option, + pub hebras: Vec, +} + +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 { + 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, &'static str> { + postcard::to_allocvec(self).map_err(|_| "carta :: serializacion fallida") + } + + /// Reconstruye la carta desde postcard. + pub fn deserializar(bytes: &[u8]) -> Result { + postcard::from_bytes::(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 = cuerpo_a.orden.iter().map(|&id| (id, ())).collect(); + let en_b: HashMap = 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, Vec) { + 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 = (0..n).map(|_| Uuid::new_v4()).collect(); + let ids_b: Vec = (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); + } +} diff --git a/00_unanchay/pluma/pluma-app/Cargo.toml b/00_unanchay/pluma/pluma-app/Cargo.toml new file mode 100644 index 0000000..db6e838 --- /dev/null +++ b/00_unanchay/pluma/pluma-app/Cargo.toml @@ -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 } diff --git a/00_unanchay/pluma/pluma-app/LEEME.md b/00_unanchay/pluma/pluma-app/LEEME.md new file mode 100644 index 0000000..9a37324 --- /dev/null +++ b/00_unanchay/pluma/pluma-app/LEEME.md @@ -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/) diff --git a/00_unanchay/pluma/pluma-app/README.md b/00_unanchay/pluma/pluma-app/README.md new file mode 100644 index 0000000..34496ff --- /dev/null +++ b/00_unanchay/pluma/pluma-app/README.md @@ -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/) diff --git a/00_unanchay/pluma/pluma-app/src/clipboard.rs b/00_unanchay/pluma/pluma-app/src/clipboard.rs new file mode 100644 index 0000000..d5700d3 --- /dev/null +++ b/00_unanchay/pluma/pluma-app/src/clipboard.rs @@ -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, +} + +impl ArboardClipboard { + pub(crate) fn new() -> Self { + Self { + inner: arboard::Clipboard::new().ok(), + } + } +} + +impl Clipboard for ArboardClipboard { + fn get(&mut self) -> Option { + 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()); + } + } +} diff --git a/00_unanchay/pluma/pluma-app/src/init.rs b/00_unanchay/pluma/pluma-app/src/init.rs new file mode 100644 index 0000000..b5a6416 --- /dev/null +++ b/00_unanchay/pluma/pluma-app/src/init.rs @@ -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 = store + .iter_atoms() + .filter_map(|r| r.ok()) + .map(|a| (a.id, a)) + .collect(); + let mut cuerpos: Vec = store.iter_cuerpos().filter_map(|r| r.ok()).collect(); + let transformaciones: Vec = store + .iter_transformaciones() + .filter_map(|r| r.ok()) + .collect(); + let cartas: Vec = 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 = + 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, 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) +} diff --git a/00_unanchay/pluma/pluma-app/src/main.rs b/00_unanchay/pluma/pluma-app/src/main.rs new file mode 100644 index 0000000..337ae91 --- /dev/null +++ b/00_unanchay/pluma/pluma-app/src/main.rs @@ -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::(); +} + +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) -> 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) -> Model { + actualizar(model, msg, handle) + } + + fn on_key(model: &Self::Model, event: &KeyEvent) -> Option { + 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 { + vista(model) + } + + fn view_overlay(model: &Model) -> Option> { + vista_overlay(model) + } +} diff --git a/00_unanchay/pluma/pluma-app/src/model.rs b/00_unanchay/pluma/pluma-app/src/model.rs new file mode 100644 index 0000000..3f49c30 --- /dev/null +++ b/00_unanchay/pluma/pluma-app/src/model.rs @@ -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), + LlmListo { + hija: Cuerpo, + atoms_nuevos: Vec, + 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), + /// 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, + pub(crate) cuerpos: Vec, + pub(crate) atoms: HashMap, + pub(crate) cartas: Vec, + pub(crate) transformaciones: Vec, + /// `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, + pub(crate) ide: CuerpoIde, + pub(crate) clipboard: ArboardClipboard, + pub(crate) drag_accum: (f32, f32), + + pub(crate) chat: Arc, + pub(crate) backend_idx: usize, + pub(crate) en_curso: bool, + pub(crate) ultimo_error: Option, + 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, + /// 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, + /// 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, + + // --- 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, +} diff --git a/00_unanchay/pluma/pluma-app/src/update.rs b/00_unanchay/pluma/pluma-app/src/update.rs new file mode 100644 index 0000000..4a0bed4 --- /dev/null +++ b/00_unanchay/pluma/pluma-app/src/update.rs @@ -0,0 +1,1004 @@ +//! Lógica de actualización del bucle Elm: el `match` central, las +//! mutaciones del modelo (abrir/crear/guardar/mover/regenerar), el +//! find-in-page, y el trabajo LLM lanzado en un thread aparte. + +use std::collections::HashMap; + +use llimphi_motion::{animate, motion, Tween}; +use llimphi_ui::Handle; +use llimphi_widget_edit_menu::{self as editmenu, EditAction, EditFlags}; +use llimphi_widget_text_editor::PointerEvent; +use pluma_align::CartaHebras; +use pluma_core::NarrativeAtom; +use pluma_cuerpo::{Cuerpo, Intencion}; +use pluma_editor_cuerpo::CambioAtom; +use pluma_llm::{build_client, LlmConfig}; +use pluma_transform::{TipoTransformacion, Transformacion}; +use pluma_transform_llm::{EjecutorResumirLlm, EjecutorTonoLlm, EjecutorTraducirLlm}; +use uuid::Uuid; + +use crate::model::{Model, Msg, BACKENDS, METRICS, VISIBLE_LINES}; +use crate::util::{ahora_unix, etiqueta_backend, expandir_ruta, extension_lower}; + +pub(crate) fn actualizar(mut model: Model, msg: Msg, handle: &Handle) -> Model { + match msg { + Msg::EditorKey(ev) => { + let _ = model.ide.apply_key_with_clipboard(&ev, &mut model.clipboard); + } + Msg::EditorPointer(ev) => { + 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); + } + } + } + Msg::AbrirDoc(id) => { + cambiar_activo(&mut model, id); + } + Msg::NuevoDoc => { + crear_doc_nuevo(&mut model); + } + Msg::Guardar => { + guardar_activo(&mut model); + } + Msg::PathInputKey(ev) => { + model.path_input.apply_key(&ev); + } + Msg::FocusPath => { + model.path_focused = true; + } + Msg::DefocusPath => { + model.path_focused = false; + } + Msg::AbrirArchivo => { + model.path_focused = false; + abrir_archivo(&mut model); + } + Msg::ExportarMd => { + model.path_focused = false; + exportar_md(&mut model); + } + Msg::FindToggle => { + model.find_visible = !model.find_visible; + if model.find_visible { + recomputar_matches(&mut model); + if !model.find_matches.is_empty() { + saltar_a_match(&mut model); + } + } + } + Msg::FindKey(ev) => { + model.find_input.apply_key(&ev); + recomputar_matches(&mut model); + if !model.find_matches.is_empty() { + saltar_a_match(&mut model); + } + } + Msg::FindSiguiente => { + if model.find_matches.is_empty() { + return model; + } + model.find_idx = (model.find_idx + 1) % model.find_matches.len(); + saltar_a_match(&mut model); + } + Msg::FindAnterior => { + if model.find_matches.is_empty() { + return model; + } + let n = model.find_matches.len(); + model.find_idx = (model.find_idx + n - 1) % n; + saltar_a_match(&mut model); + } + Msg::FindClose => { + model.find_visible = false; + } + Msg::DiffToggle => { + model.diff_visible = !model.diff_visible; + } + // Rail hospedado: pata reenvió un diente. 0/1 colapsan las columnas + // (editor a pantalla completa), 2/3 togglean Buscar/Diff (su misma lógica). + Msg::HostActivate(id) => match id { + 0 => model.side_izq_visible = !model.side_izq_visible, + 1 => model.side_der_visible = !model.side_der_visible, + 2 => { + model.find_visible = !model.find_visible; + if model.find_visible { + recomputar_matches(&mut model); + if !model.find_matches.is_empty() { + saltar_a_match(&mut model); + } + } + } + 3 => model.diff_visible = !model.diff_visible, + _ => {} + }, + Msg::MoverAtomArriba => { + mover_atom_caret(&mut model, -1); + } + Msg::MoverAtomAbajo => { + mover_atom_caret(&mut model, 1); + } + Msg::TocarMadre => { + tocar_madre(&mut model); + } + Msg::RegenerarStale => { + regenerar_siguiente_stale(&mut model, handle); + } + Msg::ToglearFusion => { + if let Some(idx) = model.ide.junction_antes_del_caret() { + model.ide.togglear_junction(idx); + } + } + Msg::ZonaSiguiente => { + model.ide.ir_a_zona_siguiente(); + model.ide.state.ensure_caret_visible(VISIBLE_LINES); + } + Msg::ZonaAnterior => { + model.ide.ir_a_zona_anterior(); + model.ide.state.ensure_caret_visible(VISIBLE_LINES); + } + Msg::CicloBackend => { + cycle_backend(&mut model); + } + Msg::PedirTraducir(lengua) => { + lanzar(&mut model, handle, TrabajoLlm::Traducir(lengua)); + } + Msg::PedirTono(etiqueta) => { + lanzar(&mut model, handle, TrabajoLlm::Tono(etiqueta)); + } + Msg::PedirResumir(palabras) => { + lanzar(&mut model, handle, TrabajoLlm::Resumir(palabras)); + } + Msg::LlmListo { + hija, + atoms_nuevos, + carta, + transformacion, + } => { + recibir_hija(&mut model, hija, atoms_nuevos, carta, transformacion); + } + Msg::LlmError(s) => { + eprintln!("pluma-app :: error LLM: {s}"); + model.ultimo_error = Some(s); + model.en_curso = false; + } + Msg::ResizeIzq(dx) => { + model.side_izq_w = (model.side_izq_w + dx).clamp(160.0, 420.0); + } + Msg::ResizeDer(dx) => { + // El divisor está del lado izquierdo de la columna derecha: + // dx>0 = divisor a la derecha = panel der encoge. + model.side_der_w = (model.side_der_w - dx).clamp(220.0, 520.0); + } + + // --- Menú principal + menú de edición contextual --- + Msg::MenuOpen(idx) => { + model.menu_open = idx; + model.menu_active = usize::MAX; + model.edit_menu = None; + if idx.is_some() { + model.menu_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic); + animate(handle, motion::FAST, || Msg::MenuTick); + } + } + Msg::CloseMenus => { + model.menu_open = None; + model.menu_active = usize::MAX; + model.edit_menu = None; + model.edit_active = usize::MAX; + } + Msg::EditMenuOpen(x, y) => { + model.edit_menu = Some((x, y)); + model.edit_active = usize::MAX; + model.menu_open = None; + model.edit_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic); + animate(handle, motion::FAST, || Msg::MenuTick); + } + Msg::EditMenuAction(action) => { + return aplicar_edit_menu(model, action); + } + Msg::MenuCommand(cmd) => { + return ejecutar_menu_command(model, cmd, handle); + } + Msg::MenuNav(dir) => { + if let Some(mi) = model.menu_open { + let menu = menu_principal(&model); + model.menu_active = + llimphi_widget_menubar::menubar_nav(&menu, mi, model.menu_active, dir); + } + } + Msg::MenuActivate => { + if let Some(mi) = model.menu_open { + let menu = menu_principal(&model); + if let Some(cmd) = + llimphi_widget_menubar::menubar_command_at(&menu, mi, model.menu_active) + { + return ejecutar_menu_command(model, cmd, handle); + } + } + } + Msg::MenuTick => {} + Msg::EditNav(dir) => { + let flags = EditFlags::from_editor(&model.ide.state, false); + model.edit_active = editmenu::edit_menu_step(flags, model.edit_active, dir); + } + Msg::EditActivate => { + let flags = EditFlags::from_editor(&model.ide.state, false); + if let Some(action) = editmenu::edit_menu_action_at(flags, model.edit_active) { + return aplicar_edit_menu(model, action); + } + } + } + model +} + +/// Construye el menú principal de pluma reflejando el estado actual: los +/// ítems de Editar quedan grises cuando no hay selección (Cortar/Copiar) o +/// historial (Deshacer/Rehacer). El editor focuseado es el `cuerpo_ide` +/// central (único editor de texto rico de la app). +pub(crate) fn menu_principal(model: &Model) -> app_bus::AppMenu { + use app_bus::{AppMenu, Menu, MenuItem}; + + let ed = &model.ide.state; + let has_sel = ed.has_selection(); + let can_undo = ed.can_undo(); + let can_redo = ed.can_redo(); + + let mut undo = MenuItem::new("Deshacer", "edit.undo").shortcut("Ctrl+Z"); + if !can_undo { + undo = undo.disabled(); + } + let mut redo = MenuItem::new("Rehacer", "edit.redo").shortcut("Ctrl+Y"); + if !can_redo { + redo = redo.disabled(); + } + let mut cut = MenuItem::new("Cortar", "edit.cut").shortcut("Ctrl+X").separated(); + let mut copy = MenuItem::new("Copiar", "edit.copy").shortcut("Ctrl+C"); + if !has_sel { + cut = cut.disabled(); + copy = copy.disabled(); + } + let paste = MenuItem::new("Pegar", "edit.paste").shortcut("Ctrl+V"); + let sel_all = MenuItem::new("Seleccionar todo", "edit.selectall") + .shortcut("Ctrl+A") + .separated(); + + // El botón de regenerar stale sólo tiene sentido si hay alguna hija + // stale del activo — lo grisamos cuando no. + let mut regen = MenuItem::new("Regenerar stale", "llm.regen"); + if contar_stale_del_activo(model) == 0 { + regen = regen.disabled(); + } + + AppMenu::new() + .menu( + Menu::new("Archivo") + .item(MenuItem::new("Nuevo documento", "file.nuevo").shortcut("Ctrl+N")) + .item(MenuItem::new("Guardar", "file.guardar").shortcut("Ctrl+S")) + .item(MenuItem::new("Abrir archivo (ruta)", "file.abrir").separated()) + .item(MenuItem::new("Exportar (md/docx)", "file.exportar")), + ) + .menu( + Menu::new("Editar") + .item(undo) + .item(redo) + .item(cut) + .item(copy) + .item(paste) + .item(sel_all), + ) + .menu( + Menu::new("Buscar") + .item(MenuItem::new("Buscar en documento", "search.find").shortcut("Ctrl+F")), + ) + .menu( + Menu::new("Multilienzo") + .item(MenuItem::new("Diff madre ↔ hija", "mult.diff").shortcut("Ctrl+D")) + .item(MenuItem::new("Togglear fusión (zona)", "mult.fusion").shortcut("Ctrl+J")) + .item(MenuItem::new("Zona siguiente", "mult.zona_sig").separated()) + .item(MenuItem::new("Zona anterior", "mult.zona_ant")), + ) + .menu( + Menu::new("LLM") + .item(MenuItem::new("Ciclar backend", "llm.backend")) + .item(MenuItem::new("Traducir → qu", "llm.trad_qu")) + .item(MenuItem::new("Traducir → en", "llm.trad_en")) + .item(MenuItem::new("Tono formal", "llm.tono")) + .item(MenuItem::new("Resumir 30p", "llm.resumir")) + .item(MenuItem::new("Tocar madre", "llm.tocar").separated()) + .item(regen), + ) + .menu( + Menu::new("Ayuda") + .item(MenuItem::new("pluma · editor multilienzo", "help.about").disabled()), + ) +} + +/// Traduce el `command` string del menú principal al `Msg` real de la app +/// y lo aplica. Cierra el dropdown antes de actuar. Los comandos `edit.*` +/// se enrutan al menú de edición sobre el `cuerpo_ide`. +fn ejecutar_menu_command(mut model: Model, command: String, handle: &Handle) -> Model { + model.menu_open = None; + let target = match command.as_str() { + "file.nuevo" => Some(Msg::NuevoDoc), + "file.guardar" => Some(Msg::Guardar), + "file.abrir" => Some(Msg::AbrirArchivo), + "file.exportar" => Some(Msg::ExportarMd), + "edit.undo" => Some(Msg::EditMenuAction(EditAction::Undo)), + "edit.redo" => Some(Msg::EditMenuAction(EditAction::Redo)), + "edit.cut" => Some(Msg::EditMenuAction(EditAction::Cut)), + "edit.copy" => Some(Msg::EditMenuAction(EditAction::Copy)), + "edit.paste" => Some(Msg::EditMenuAction(EditAction::Paste)), + "edit.selectall" => Some(Msg::EditMenuAction(EditAction::SelectAll)), + "search.find" => Some(Msg::FindToggle), + "mult.diff" => Some(Msg::DiffToggle), + "mult.fusion" => Some(Msg::ToglearFusion), + "mult.zona_sig" => Some(Msg::ZonaSiguiente), + "mult.zona_ant" => Some(Msg::ZonaAnterior), + "llm.backend" => Some(Msg::CicloBackend), + "llm.trad_qu" => Some(Msg::PedirTraducir("qu".into())), + "llm.trad_en" => Some(Msg::PedirTraducir("en".into())), + "llm.tono" => Some(Msg::PedirTono("formal".into())), + "llm.resumir" => Some(Msg::PedirResumir(Some(30))), + "llm.tocar" => Some(Msg::TocarMadre), + "llm.regen" => Some(Msg::RegenerarStale), + _ => None, + }; + match target { + Some(msg) => actualizar(model, msg, handle), + None => model, + } +} + +/// Aplica una acción del menú de edición al `EditorState` del cuerpo_ide, +/// reusando `editmenu::apply` (mismo camino que las teclas de edición). +/// Cierra el menú. Como `apply_key_with_clipboard`, no necesita marcar +/// dirty manual: el `CuerpoIde` deriva el pendiente_sync de su `edit_seq`. +fn aplicar_edit_menu(mut model: Model, action: EditAction) -> Model { + model.edit_menu = None; + let _ = llimphi_widget_edit_menu::apply(&mut model.ide.state, action, &mut model.clipboard); + model.ide.state.ensure_caret_visible(VISIBLE_LINES); + model +} + +fn cambiar_activo(model: &mut Model, id: Uuid) { + if model.activo == Some(id) { + return; + } + let cuerpo = match model.cuerpos.iter().find(|c| c.id == id) { + Some(c) => c.clone(), + None => return, + }; + model.activo = Some(id); + let idx: HashMap = + model.atoms.iter().map(|(k, v)| (*k, v)).collect(); + model.ide.recargar(&cuerpo, &idx); + model.ultimo_status = format!("doc: {}", cuerpo.metadatos.nombre_legible); +} + +fn crear_doc_nuevo(model: &mut Model) { + let ahora = ahora_unix(); + let n = model + .cuerpos + .iter() + .filter(|c| !c.metadatos.intencion.es_derivada()) + .count() + + 1; + let atom = NarrativeAtom::new("Empieza a escribir aquí…", "es"); + let mut cuerpo = Cuerpo::nuevo( + format!("es-{n}"), + format!("doc #{n} sin título"), + Intencion::Original, + ahora, + ); + cuerpo.agregar(atom.id, ahora); + let _ = model.store.put_atom(&atom); + let _ = model.store.put_cuerpo(&cuerpo); + let _ = model.store.flush(); + let id = cuerpo.id; + model.atoms.insert(atom.id, atom); + model.cuerpos.push(cuerpo); + cambiar_activo(model, id); + model.ultimo_status = format!("doc #{n} creado"); +} + +fn guardar_activo(model: &mut Model) { + let Some(activo_id) = model.activo else { + model.ultimo_status = "sin doc activo".into(); + return; + }; + let idx: HashMap = + model.atoms.iter().map(|(k, v)| (*k, v)).collect(); + let cambios = model.ide.diff(&idx); + drop(idx); + + if cambios.is_empty() { + model.ultimo_status = "sin cambios".into(); + return; + } + + let mut creados: Vec = Vec::new(); + let branch_id = model + .cuerpos + .iter() + .find(|c| c.id == activo_id) + .map(|c| c.branch_id.clone()) + .unwrap_or_else(|| "es".to_string()); + + 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()); + let _ = model.store.put_atom(a); + } + } + CambioAtom::Crear { texto, posicion: _ } => { + let atom = NarrativeAtom::new(texto.as_str(), &branch_id); + let id = atom.id; + let _ = model.store.put_atom(&atom); + model.atoms.insert(id, atom); + creados.push(id); + } + CambioAtom::Eliminar { id } => { + model.atoms.remove(id); + // El sled mantiene el atom histórico — no lo borramos + // del backend porque hijas/cartas pueden seguir apuntando + // a él. La memoria local sí lo descarta. + } + } + } + + model.ide.aplicar_cambios(&cambios, &creados); + + // Reconstruir `cuerpo.orden` con el orden nuevo del IDE. + let nuevo_orden: Vec = model.ide.editor_cuerpo.atom_ids.clone(); + if let Some(c) = model.cuerpos.iter_mut().find(|c| c.id == activo_id) { + let ahora = c.metadatos.modificado_en.saturating_add(1); + let viejo: Vec = c.orden.clone(); + for id in &viejo { + let _ = c.remover(*id, ahora); + } + for id in &nuevo_orden { + c.agregar(*id, ahora); + } + let _ = model.store.put_cuerpo(c); + } + let _ = model.store.flush(); + + let n_mut = cambios + .iter() + .filter(|c| matches!(c, CambioAtom::Mutar { .. })) + .count(); + let n_new = creados.len(); + let n_del = cambios + .iter() + .filter(|c| matches!(c, CambioAtom::Eliminar { .. })) + .count(); + model.ultimo_status = format!("guardado: {n_mut} mut · {n_new} crear · {n_del} del"); +} + +/// Recalcula las posiciones (línea, col) donde aparece el query en el +/// buffer actual. Búsqueda case-insensitive, substring. Llamarlo cada +/// vez que el query o el texto cambian. Reset de `find_idx` al primer +/// match cuando hay alguno; lo deja en 0 si no hay (consistente con +/// "0 de 0"), pero la UI no salta si está vacío. +fn recomputar_matches(model: &mut Model) { + let query = model.find_input.text(); + if query.is_empty() { + model.find_matches.clear(); + model.find_idx = 0; + return; + } + let q_lower = query.to_lowercase(); + let mut matches: Vec<(usize, usize)> = Vec::new(); + let texto = model.ide.texto_buffer(); + for (line_idx, linea) in texto.lines().enumerate() { + let l_lower = linea.to_lowercase(); + let mut start = 0; + while let Some(pos) = l_lower[start..].find(&q_lower) { + let col = start + pos; + matches.push((line_idx, col)); + start = col + q_lower.len().max(1); + if start >= l_lower.len() { + break; + } + } + } + model.find_matches = matches; + if model.find_idx >= model.find_matches.len() { + model.find_idx = 0; + } +} + +fn saltar_a_match(model: &mut Model) { + let Some(&(line, col)) = model.find_matches.get(model.find_idx) else { + return; + }; + model.ide.set_caret(line, col); + model.ide.state.ensure_caret_visible(VISIBLE_LINES); +} + +/// Avanza `modificado_en` del cuerpo activo a la hora actual. Cualquier +/// hija derivada cuyo `regenerada_en` sea anterior se vuelve `es_stale` +/// y aparece en el botón «regenerar stale (N)». Caso de uso típico: +/// editaste la madre sin querer recordar todos los detalles y querés +/// invalidar las derivadas para que vuelvan a salir del LLM. +fn tocar_madre(model: &mut Model) { + let Some(activo_id) = model.activo else { + model.ultimo_status = "sin doc activo".into(); + return; + }; + let ahora = ahora_unix(); + if let Some(c) = model.cuerpos.iter_mut().find(|c| c.id == activo_id) { + c.metadatos.modificado_en = ahora; + let _ = model.store.put_cuerpo(c); + } + let _ = model.store.flush(); + let n = contar_stale_del_activo(model); + model.ultimo_status = format!("madre tocada — {n} hija(s) ahora stale"); + model.ultimo_error = None; +} + +pub(crate) fn contar_stale_del_activo(model: &Model) -> usize { + let Some(activo_id) = model.activo else { + return 0; + }; + let Some(madre) = model.cuerpos.iter().find(|c| c.id == activo_id) else { + return 0; + }; + let modif = madre.metadatos.modificado_en; + model + .cuerpos + .iter() + .filter(|c| { + c.metadatos.derivado_de == Some(activo_id) && c.es_derivado() && c.es_stale(modif) + }) + .count() +} + +/// Encuentra la primera hija del activo que sea stale, busca la +/// `Transformacion` original registrada (madre==activo, hija==hija_id), +/// traduce su `TipoTransformacion` a un `TrabajoLlm`, y lo lanza con la +/// madre actualizada — el ejecutor produce una hija nueva fresca; la +/// vieja queda en el modelo (sigue visible por si querés diff). Lo +/// hacemos hija-por-hija (no batch) para que el progreso sea visible +/// y un error no aborte todas. +fn regenerar_siguiente_stale(model: &mut Model, handle: &Handle) { + if model.en_curso { + model.ultimo_status = "LLM ocupado — esperá".into(); + return; + } + let Some(activo_id) = model.activo else { + model.ultimo_status = "sin doc activo".into(); + return; + }; + let madre_modif = match model.cuerpos.iter().find(|c| c.id == activo_id) { + Some(c) => c.metadatos.modificado_en, + None => return, + }; + let hija_id_opt = model + .cuerpos + .iter() + .find(|c| { + c.metadatos.derivado_de == Some(activo_id) + && c.es_derivado() + && c.es_stale(madre_modif) + }) + .map(|c| c.id); + let Some(hija_id) = hija_id_opt else { + model.ultimo_status = "no hay hijas stale — tocar madre primero".into(); + return; + }; + // Buscar la Transformacion original. Prioridad: la del store (en + // memoria está cargada al iniciar; el sled es la fuente de verdad). + let tipo = model + .transformaciones + .iter() + .find(|t| t.madre == activo_id && t.hija == hija_id) + .map(|t| t.tipo.clone()); + let Some(tipo) = tipo else { + model.ultimo_status = format!( + "no se halló transformación para regenerar {hija_id} — falta historial" + ); + return; + }; + let Some(trabajo) = trabajo_de_tipo(&tipo) else { + model.ultimo_status = format!("tipo {tipo:?} no es regenerable automáticamente"); + return; + }; + lanzar(model, handle, trabajo); +} + +/// Traduce un `TipoTransformacion` persistido al `TrabajoLlm` que +/// `lanzar` sabe correr. `Identidad`/`Reescribir`/`Custom` no son +/// auto-regenerables — Reescribir necesita prompt humano, Custom Rhai, +/// Identidad no aporta nada nuevo. +fn trabajo_de_tipo(t: &TipoTransformacion) -> Option { + 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, + } +} + +/// Mueve el átomo donde está el caret una posición arriba (`delta=-1`) +/// o abajo (`delta=1`). Sincroniza el buffer al modelo antes de +/// reordenar (para no perder ediciones pendientes), muta `cuerpo.orden`, +/// persiste, y recarga el IDE — junctions resetean a separadores (es +/// el costo del reorder; el usuario las re-fusiona si las quería). +/// El caret queda en la primera línea del átomo movido. +fn mover_atom_caret(model: &mut Model, delta: i32) { + let Some(activo_id) = model.activo else { + return; + }; + // Sincroniza pendientes para no perderlos al recargar. + guardar_activo(model); + + let (caret_line, _) = model.ide.caret(); + let Some(atom_id) = model.ide.atom_id_en_linea(caret_line) else { + return; + }; + let cuerpo = match model.cuerpos.iter_mut().find(|c| c.id == activo_id) { + Some(c) => c, + None => return, + }; + let n = cuerpo.orden.len(); + if n < 2 { + return; + } + let i = match cuerpo.orden.iter().position(|x| *x == atom_id) { + Some(i) => i, + None => return, + }; + let j = if delta < 0 { + if i == 0 { + return; + } + i - 1 + } else { + if i + 1 >= n { + return; + } + i + 1 + }; + cuerpo.orden.swap(i, j); + cuerpo.metadatos.modificado_en = cuerpo.metadatos.modificado_en.saturating_add(1); + let _ = model.store.put_cuerpo(cuerpo); + let _ = model.store.flush(); + + // Recargar el IDE con el orden nuevo. Snapshot la cuerpo data + // primero para evitar el borrow simultáneo del index. + let cuerpo_clon = cuerpo.clone(); + // Liberamos el préstamo mutable de `model.cuerpos` antes de + // tomar uno inmutable de `model.atoms` para construir el índice. + let _ = cuerpo; + let idx: HashMap = + model.atoms.iter().map(|(k, v)| (*k, v)).collect(); + model.ide.recargar(&cuerpo_clon, &idx); + drop(idx); + + // Posicionar el caret al inicio del átomo movido. Su nuevo idx es + // `j`; sumamos lineas anteriores (cada atom = 1 + atoms_extra_lineas + // + separador). Más simple: usar posicion_de_atom. + if let Some((line, col)) = model.ide.posicion_de_atom(atom_id) { + model.ide.set_caret(line, col); + model.ide.state.ensure_caret_visible(VISIBLE_LINES); + } + + model.ultimo_status = format!( + "atom movido {}", + if delta < 0 { "↑" } else { "↓" } + ); + model.ultimo_error = None; +} + +fn abrir_archivo(model: &mut Model) { + let path_raw = model.path_input.text().trim().to_string(); + if path_raw.is_empty() { + model.ultimo_error = Some("ruta vacía".into()); + return; + } + let path = expandir_ruta(&path_raw); + let bytes = match std::fs::read(&path) { + Ok(b) => b, + Err(e) => { + model.ultimo_error = Some(format!("leyendo {path:?}: {e}")); + return; + } + }; + let nombre = path + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "archivo".to_string()); + let ahora = ahora_unix(); + + let importado = if extension_lower(&path) == Some("docx".to_string()) { + match foreign_docx::parse_docx(&bytes, "es", nombre.clone(), ahora) { + Ok(imp) => (imp.cuerpo, imp.atoms), + Err(e) => { + model.ultimo_error = Some(format!("parse_docx {nombre}: {e:?}")); + return; + } + } + } else if extension_lower(&path) == Some("md".to_string()) + || extension_lower(&path) == Some("markdown".to_string()) + || extension_lower(&path) == Some("txt".to_string()) + { + let texto = match std::str::from_utf8(&bytes) { + Ok(s) => s.to_string(), + Err(e) => { + model.ultimo_error = Some(format!("{nombre} no es UTF-8: {e}")); + return; + } + }; + let imp = pluma_md::parse_md(&texto, "es", nombre.clone(), ahora); + (imp.cuerpo, imp.atoms) + } else { + model.ultimo_error = Some(format!( + "extensión no soportada en {nombre} — usá .md o .docx" + )); + return; + }; + + let (cuerpo, atoms_nuevos) = importado; + if atoms_nuevos.is_empty() { + model.ultimo_error = Some(format!("{nombre} no produjo átomos")); + return; + } + for a in &atoms_nuevos { + let _ = model.store.put_atom(a); + model.atoms.insert(a.id, a.clone()); + } + let _ = model.store.put_cuerpo(&cuerpo); + let _ = model.store.flush(); + let id = cuerpo.id; + let n = atoms_nuevos.len(); + model.cuerpos.push(cuerpo); + model.ultimo_status = format!("abierto «{nombre}»: {n} átomos"); + model.ultimo_error = None; + cambiar_activo(model, id); +} + +fn exportar_md(model: &mut Model) { + let Some(activo_id) = model.activo else { + model.ultimo_error = Some("sin doc activo".into()); + return; + }; + let path_raw = model.path_input.text().trim().to_string(); + if path_raw.is_empty() { + model.ultimo_error = Some("ruta vacía".into()); + return; + } + let path = expandir_ruta(&path_raw); + let Some(cuerpo) = model.cuerpos.iter().find(|c| c.id == activo_id) else { + model.ultimo_error = Some("doc activo desapareció".into()); + return; + }; + + let ext = extension_lower(&path).unwrap_or_default(); + let bytes: Vec = if ext == "docx" { + match foreign_docx::write_docx(cuerpo, &model.atoms) { + Ok(b) => b, + Err(e) => { + model.ultimo_error = Some(format!("write_docx: {e}")); + return; + } + } + } else if ext.is_empty() || ext == "md" || ext == "markdown" || ext == "txt" { + let md = pluma_md::to_md(cuerpo, &model.atoms); + if md.is_empty() { + model.ultimo_error = Some("doc vacío — nada que exportar".into()); + return; + } + md.into_bytes() + } else { + model.ultimo_error = Some(format!( + "extensión .{ext} no soportada — usá .md o .docx" + )); + return; + }; + + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + match std::fs::write(&path, &bytes) { + Ok(()) => { + model.ultimo_status = format!( + "exportado «{}» a {} ({} bytes)", + cuerpo.metadatos.nombre_legible, + path.display(), + bytes.len(), + ); + model.ultimo_error = None; + } + Err(e) => { + model.ultimo_error = Some(format!("escribiendo {path:?}: {e}")); + } + } +} + +fn cycle_backend(model: &mut Model) { + let total = BACKENDS.len(); + for step in 1..=total { + let try_idx = (model.backend_idx + step) % total; + let kind = BACKENDS[try_idx]; + match build_client(&LlmConfig { + kind, + ..Default::default() + }) { + Ok(c) => { + model.chat = c; + model.backend_idx = try_idx; + model.ultimo_status = format!("backend → {}", etiqueta_backend(kind)); + model.ultimo_error = None; + return; + } + Err(e) => { + model.ultimo_error = Some(format!("backend {kind:?}: {e}")); + } + } + } + // Si todos fallaron (no debería: Mock siempre funciona), no-op. +} + +fn recibir_hija( + model: &mut Model, + hija: Cuerpo, + atoms_nuevos: Vec, + carta: CartaHebras, + transformacion: Transformacion, +) { + for a in &atoms_nuevos { + let _ = model.store.put_atom(a); + model.atoms.insert(a.id, a.clone()); + } + let _ = model.store.put_cuerpo(&hija); + let _ = model.store.put_carta(&carta); + let _ = model.store.put_transformacion(&transformacion); + let _ = model.store.flush(); + let hija_id = hija.id; + let nombre = hija.metadatos.nombre_legible.clone(); + model.cuerpos.push(hija); + model.cartas.push(carta); + model.transformaciones.push(transformacion); + model.en_curso = false; + model.ultimo_status = format!("hija «{nombre}» derivada"); + cambiar_activo(model, hija_id); +} + +// --------------------------------------------------------------------- +// Trabajo LLM +// --------------------------------------------------------------------- + +pub(crate) enum TrabajoLlm { + Traducir(String), + Tono(String), + Resumir(Option), +} + +fn lanzar(model: &mut Model, handle: &Handle, trabajo: TrabajoLlm) { + if model.en_curso { + return; + } + let Some(activo_id) = model.activo else { + model.ultimo_status = "sin doc activo".into(); + return; + }; + // Sincronizar antes de transformar — si el usuario tipeó sin Ctrl+S, + // queremos que el LLM vea el texto editado. + guardar_activo(model); + + let madre = match model.cuerpos.iter().find(|c| c.id == activo_id) { + Some(c) => c.clone(), + None => { + model.ultimo_error = Some("doc activo desapareció".into()); + return; + } + }; + if madre.orden.is_empty() { + model.ultimo_status = "madre vacía — nada que transformar".into(); + return; + } + + let atoms_owned: Vec = 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; + model.ultimo_status = format!("LLM en curso ({} backend)", etiqueta_backend(BACKENDS[model.backend_idx])); + + 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 = + 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 + .map(|p| (p, t)) + } + TrabajoLlm::Tono(etiq) => { + let ej = EjecutorTonoLlm::from_arc(chat, etiq.clone()); + let t = Transformacion::nueva( + madre.id, + Uuid::new_v4(), + TipoTransformacion::Tono { etiqueta: etiq }, + "ui", + ahora, + ); + ej.aplicar_con_atoms(&t, &madre, &idx, ahora) + .await + .map(|p| (p, t)) + } + 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 + .map(|p| (p, t)) + } + } + }); + + let _ = h; + match resultado { + Ok((prod, transformacion)) => Msg::LlmListo { + hija: prod.hija, + atoms_nuevos: prod.atoms_nuevos, + carta: prod.carta, + transformacion, + }, + Err(e) => Msg::LlmError(format!("{e:?}")), + } + }); +} diff --git a/00_unanchay/pluma/pluma-app/src/util.rs b/00_unanchay/pluma/pluma-app/src/util.rs new file mode 100644 index 0000000..6967472 --- /dev/null +++ b/00_unanchay/pluma/pluma-app/src/util.rs @@ -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 { + 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) +} diff --git a/00_unanchay/pluma/pluma-app/src/view.rs b/00_unanchay/pluma/pluma-app/src/view.rs new file mode 100644 index 0000000..0267f1c --- /dev/null +++ b/00_unanchay/pluma/pluma-app/src/view.rs @@ -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 { + 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> { + 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 { + 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 { + 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> = 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::("+ nuevo doc (Ctrl+N)", &palette_btn, Msg::NuevoDoc); + let boton_guardar = button_view::("💾 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 { + 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::( + &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::("📂 abrir", &palette_btn, Msg::AbrirArchivo), + button_view::("⬆ 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 { + let cuerpo_central: View = if model.diff_visible { + vista_diff(model, palette_editor) + } else { + cuerpo_ide_view::( + &model.ide, + palette_editor, + METRICS, + VISIBLE_LINES, + Language::Plain, + |ev| Some(Msg::EditorPointer(ev)), + ) + }; + + let mut hijos: Vec> = 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 { + // 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::( + &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> = 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::( + &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 { + 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 { + let theme = Theme::dark(); + let palette_input = TextInputPalette::from_theme(&theme); + let palette_btn = ButtonPalette::from_theme(&theme); + + let input = text_input_view::( + &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::("◀", &palette_btn, Msg::FindAnterior); + let next = button_view::("▶", &palette_btn, Msg::FindSiguiente); + let cerrar = button_view::("✕", &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 { + 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::(&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::(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::(label, pal, m); + let botones: Vec> = 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::("⏰ tocar madre", pal, Msg::TocarMadre); + let regen_btn = button_view::(&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> = 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 { + 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> = 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 { + 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> = 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 { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(1.0_f32), + }, + ..Default::default() + }) + .fill(theme.border) +} diff --git a/00_unanchay/pluma/pluma-core/Cargo.toml b/00_unanchay/pluma/pluma-core/Cargo.toml new file mode 100644 index 0000000..9ace970 --- /dev/null +++ b/00_unanchay/pluma/pluma-core/Cargo.toml @@ -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"] } diff --git a/00_unanchay/pluma/pluma-core/LEEME.md b/00_unanchay/pluma/pluma-core/LEEME.md new file mode 100644 index 0000000..f24acb9 --- /dev/null +++ b/00_unanchay/pluma/pluma-core/LEEME.md @@ -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 diff --git a/00_unanchay/pluma/pluma-core/README.md b/00_unanchay/pluma/pluma-core/README.md new file mode 100644 index 0000000..c053dde --- /dev/null +++ b/00_unanchay/pluma/pluma-core/README.md @@ -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 diff --git a/00_unanchay/pluma/pluma-core/src/lib.rs b/00_unanchay/pluma/pluma-core/src/lib.rs new file mode 100644 index 0000000..b6d0ad9 --- /dev/null +++ b/00_unanchay/pluma/pluma-core/src/lib.rs @@ -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` 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, + /// Concepto → intensidad. Lo puebla `pluma_app-semantic`. + pub semantic_vectors: HashMap, + /// Átomos prerrequisito (sus "padres" lógicos). + pub dependencies: Vec, + /// 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, branch_id: impl Into) -> 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) { + 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()); + } +} diff --git a/00_unanchay/pluma/pluma-cuerpo/Cargo.toml b/00_unanchay/pluma/pluma-cuerpo/Cargo.toml new file mode 100644 index 0000000..5565fb9 --- /dev/null +++ b/00_unanchay/pluma/pluma-cuerpo/Cargo.toml @@ -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" } diff --git a/00_unanchay/pluma/pluma-cuerpo/LEEME.md b/00_unanchay/pluma/pluma-cuerpo/LEEME.md new file mode 100644 index 0000000..22888b3 --- /dev/null +++ b/00_unanchay/pluma/pluma-cuerpo/LEEME.md @@ -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` diff --git a/00_unanchay/pluma/pluma-cuerpo/README.md b/00_unanchay/pluma/pluma-cuerpo/README.md new file mode 100644 index 0000000..45ee620 --- /dev/null +++ b/00_unanchay/pluma/pluma-cuerpo/README.md @@ -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` diff --git a/00_unanchay/pluma/pluma-cuerpo/src/lib.rs b/00_unanchay/pluma/pluma-cuerpo/src/lib.rs new file mode 100644 index 0000000..ce96b8b --- /dev/null +++ b/00_unanchay/pluma/pluma-cuerpo/src/lib.rs @@ -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 }, + /// 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, + /// 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, + /// 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, + /// 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, +} + +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, + nombre_legible: impl Into, + 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) -> 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 { + 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, &'static str> { + postcard::to_allocvec(self).map_err(|_| "cuerpo :: serializacion fallida") + } + + /// Reconstruye un cuerpo desde su forma binaria. + pub fn deserializar(bytes: &[u8]) -> Result { + postcard::from_bytes::(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()); + } +} diff --git a/00_unanchay/pluma/pluma-deck-app/Cargo.toml b/00_unanchay/pluma/pluma-deck-app/Cargo.toml new file mode 100644 index 0000000..e212a09 --- /dev/null +++ b/00_unanchay/pluma/pluma-deck-app/Cargo.toml @@ -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 } diff --git a/00_unanchay/pluma/pluma-deck-app/src/main.rs b/00_unanchay/pluma/pluma-deck-app/src/main.rs new file mode 100644 index 0000000..e632cb0 --- /dev/null +++ b/00_unanchay/pluma/pluma-deck-app/src/main.rs @@ -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), + /// 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, + /// `None` = sin arrastre. `Some(None)` = paneando. `Some(Some(id))` = moviendo ese marco. + arrastrando: Option>, + /// Destino de Ctrl+S (postcard). + guardar_en: PathBuf, + /// Undo/redo de autoría (snapshots del recorrido). + historial: Historial, + /// 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, + /// 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, + /// 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 { + 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::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::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 { + 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> { + // 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, Arc 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 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 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 { + 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 { + 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 { + pasado: Vec, + futuro: Vec, + max: usize, +} + +impl Historial { + 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 { + 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 { + 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 { + 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::(); +} + +#[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()); + } +} diff --git a/00_unanchay/pluma/pluma-deck-core/Cargo.toml b/00_unanchay/pluma/pluma-deck-core/Cargo.toml new file mode 100644 index 0000000..d745206 --- /dev/null +++ b/00_unanchay/pluma/pluma-deck-core/Cargo.toml @@ -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 } diff --git a/00_unanchay/pluma/pluma-deck-core/LEEME.md b/00_unanchay/pluma/pluma-deck-core/LEEME.md new file mode 100644 index 0000000..dad3c38 --- /dev/null +++ b/00_unanchay/pluma/pluma-deck-core/LEEME.md @@ -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` diff --git a/00_unanchay/pluma/pluma-deck-core/README.md b/00_unanchay/pluma/pluma-deck-core/README.md new file mode 100644 index 0000000..e96de85 --- /dev/null +++ b/00_unanchay/pluma/pluma-deck-core/README.md @@ -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` diff --git a/00_unanchay/pluma/pluma-deck-core/src/adaptador.rs b/00_unanchay/pluma/pluma-deck-core/src/adaptador.rs new file mode 100644 index 0000000..69e4bff --- /dev/null +++ b/00_unanchay/pluma/pluma-deck-core/src/adaptador.rs @@ -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, titulo: &mut Option, parrafos: &mut Vec) { + 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) -> Vec { + let mut slides = Vec::new(); + let mut titulo: Option = None; + let mut parrafos: Vec = 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()]); + } +} diff --git a/00_unanchay/pluma/pluma-deck-core/src/camara.rs b/00_unanchay/pluma/pluma-deck-core/src/camara.rs new file mode 100644 index 0000000..6e32c5c --- /dev/null +++ b/00_unanchay/pluma/pluma-deck-core/src/camara.rs @@ -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); + } +} diff --git a/00_unanchay/pluma/pluma-deck-core/src/lib.rs b/00_unanchay/pluma/pluma-deck-core/src/lib.rs new file mode 100644 index 0000000..acb8c0c --- /dev/null +++ b/00_unanchay/pluma/pluma-deck-core/src/lib.rs @@ -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 { + 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); + } +} diff --git a/00_unanchay/pluma/pluma-deck-core/src/recorrido.rs b/00_unanchay/pluma/pluma-deck-core/src/recorrido.rs new file mode 100644 index 0000000..b21d6fa --- /dev/null +++ b/00_unanchay/pluma/pluma-deck-core/src/recorrido.rs @@ -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, parrafos: Vec }, + /// 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, 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, + pub pasos: Vec, +} + +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 { + 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 { + 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, 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, &'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 { + postcard::from_bytes::(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, + 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)); + } +} diff --git a/00_unanchay/pluma/pluma-deck-recorrido-llimphi/Cargo.toml b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/Cargo.toml new file mode 100644 index 0000000..ad1959f --- /dev/null +++ b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/Cargo.toml @@ -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"] } diff --git a/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_demo.rs b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_demo.rs new file mode 100644 index 0000000..17e42e6 --- /dev/null +++ b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_demo.rs @@ -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::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::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 { + 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 { + 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 { + 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::(); +} diff --git a/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_editor_demo.rs b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_editor_demo.rs new file mode 100644 index 0000000..06f5859 --- /dev/null +++ b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_editor_demo.rs @@ -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>, + /// Marco seleccionado (objetivo de eliminar/rotar). Se realza en ámbar. + seleccionado: Option, +} + +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::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::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 { + 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 { + 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 { + 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::(); +} diff --git a/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_imagen_demo.rs b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_imagen_demo.rs new file mode 100644 index 0000000..8a2a6ca --- /dev/null +++ b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_imagen_demo.rs @@ -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, 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::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::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 { + 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 { + 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 { + 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::(); +} diff --git a/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_md_demo.rs b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_md_demo.rs new file mode 100644 index 0000000..4e6b4a0 --- /dev/null +++ b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_md_demo.rs @@ -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::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::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 { + 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 { + 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 { + 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::(); +} diff --git a/00_unanchay/pluma/pluma-deck-recorrido-llimphi/src/lib.rs b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/src/lib.rs new file mode 100644 index 0000000..26fdeb6 --- /dev/null +++ b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/src/lib.rs @@ -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>> = 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 { + 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>> = 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 { + 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 { + 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, parrafos: Vec }, + 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(rec: &Recorrido, state: &RecorridoState) -> View { + 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( + rec: &Recorrido, + state: &RecorridoState, + seleccionado: Option, +) -> View { + vista(rec, state, seleccionado) +} + +fn vista(rec: &Recorrido, state: &RecorridoState, seleccionado: Option) -> View { + // 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 = 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, + seleccionado: Option, + 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))); +} diff --git a/00_unanchay/pluma/pluma-deck-web/Cargo.toml b/00_unanchay/pluma/pluma-deck-web/Cargo.toml new file mode 100644 index 0000000..8e9de25 --- /dev/null +++ b/00_unanchay/pluma/pluma-deck-web/Cargo.toml @@ -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", +] diff --git a/00_unanchay/pluma/pluma-deck-web/LEEME.md b/00_unanchay/pluma/pluma-deck-web/LEEME.md new file mode 100644 index 0000000..fe0b400 --- /dev/null +++ b/00_unanchay/pluma/pluma-deck-web/LEEME.md @@ -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 +
+
+
+
+
+
+``` + +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, ``, 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` diff --git a/00_unanchay/pluma/pluma-deck-web/README.md b/00_unanchay/pluma/pluma-deck-web/README.md new file mode 100644 index 0000000..3386600 --- /dev/null +++ b/00_unanchay/pluma/pluma-deck-web/README.md @@ -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` diff --git a/00_unanchay/pluma/pluma-deck-web/src/lib.rs b/00_unanchay/pluma/pluma-deck-web/src/lib.rs new file mode 100644 index 0000000..071c83b --- /dev/null +++ b/00_unanchay/pluma/pluma-deck-web/src/lib.rs @@ -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>, +} + +struct Inner { + state: DeckState, + on_change: Option>, +} + +impl Deck { + pub fn mount(strip: HtmlElement) -> Result { + 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(&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>) -> Result<(), JsValue> { + let strip2 = strip.clone(); + let inner2 = inner.clone(); + let cb = Closure::::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>) -> Result<(), JsValue> { + let strip2 = strip.clone(); + let inner2 = inner.clone(); + let cb = Closure::::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>, + event_name: &str, +) -> Result<(), JsValue> { + let strip2 = strip.clone(); + let inner2 = inner.clone(); + let cb = Closure::::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>) -> Result<(), JsValue> { + let Some(window) = web_sys::window() else { return Ok(()) }; + let strip2 = strip.clone(); + let inner2 = inner.clone(); + let cb = Closure::::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::().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); + 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) {} diff --git a/00_unanchay/pluma/pluma-deck-web/src/recorrido.rs b/00_unanchay/pluma/pluma-deck-web/src/recorrido.rs new file mode 100644 index 0000000..a4958fb --- /dev/null +++ b/00_unanchay/pluma/pluma-deck-web/src/recorrido.rs @@ -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 +//!
+//!
+//!
+//!
+//!
+//!
+//! ``` +//! 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, ``, 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>, +} + +struct Inner { + rec: Recorrido, + state: RecorridoState, + /// `Some((x,y))` = paneando desde esa última posición de pointer. + arrastrando: Option<(f64, f64)>, + on_change: Option>, + /// Handle del `setInterval` del modo presentador (autoplay), si está activo. + autoplay_id: Option, + /// Closure del intervalo — se guarda para mantenerla viva mientras corre. + autoplay_cb: Option>, +} + +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 { + 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::().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::::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(&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::::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::::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::::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::::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::::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::().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::().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, ``, 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!( + "
{html}
", + x = m.x, y = m.y, w = m.w, h = m.h, r = m.rot, rot = rot, html = m.html, + )); + } + format!( + "\n\ + \ + {t}\ +
{cuerpo}
\ +
", + t = t, css = EXPORT_CSS, cuerpo = cuerpo, js = EXPORT_JS, + ) +} + +/// Escape mínimo para insertar texto en HTML (sólo para el ``). +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>"), "{}", &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("

Uno

") && html.contains("

Dos

")); + // 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("") && html.ends_with("")); + assert!(html.contains("recorrido-mundo")); + } +} diff --git a/00_unanchay/pluma/pluma-editor-cuerpo/Cargo.toml b/00_unanchay/pluma/pluma-editor-cuerpo/Cargo.toml new file mode 100644 index 0000000..53b771a --- /dev/null +++ b/00_unanchay/pluma/pluma-editor-cuerpo/Cargo.toml @@ -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" } diff --git a/00_unanchay/pluma/pluma-editor-cuerpo/LEEME.md b/00_unanchay/pluma/pluma-editor-cuerpo/LEEME.md new file mode 100644 index 0000000..29ccec6 --- /dev/null +++ b/00_unanchay/pluma/pluma-editor-cuerpo/LEEME.md @@ -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` 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` diff --git a/00_unanchay/pluma/pluma-editor-cuerpo/README.md b/00_unanchay/pluma/pluma-editor-cuerpo/README.md new file mode 100644 index 0000000..dd9544e --- /dev/null +++ b/00_unanchay/pluma/pluma-editor-cuerpo/README.md @@ -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` 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` diff --git a/00_unanchay/pluma/pluma-editor-cuerpo/src/lib.rs b/00_unanchay/pluma/pluma-editor-cuerpo/src/lib.rs new file mode 100644 index 0000000..24a94cc --- /dev/null +++ b/00_unanchay/pluma/pluma-editor-cuerpo/src/lib.rs @@ -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, +} + +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) -> Self { + let mut chunks: Vec<&str> = Vec::with_capacity(cuerpo.orden.len()); + let mut ids: Vec = 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) { + 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, + ) -> Vec { + 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 = 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) { + let mut c = Cuerpo::nuevo("es", "es", Intencion::Original, 0); + let atoms: Vec = + 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 { + 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 = 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]); + } +} diff --git a/00_unanchay/pluma/pluma-editor-llimphi/Cargo.toml b/00_unanchay/pluma/pluma-editor-llimphi/Cargo.toml new file mode 100644 index 0000000..28ed5dc --- /dev/null +++ b/00_unanchay/pluma/pluma-editor-llimphi/Cargo.toml @@ -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 } diff --git a/00_unanchay/pluma/pluma-editor-llimphi/LEEME.md b/00_unanchay/pluma/pluma-editor-llimphi/LEEME.md new file mode 100644 index 0000000..e1a492e --- /dev/null +++ b/00_unanchay/pluma/pluma-editor-llimphi/LEEME.md @@ -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` 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/) diff --git a/00_unanchay/pluma/pluma-editor-llimphi/README.md b/00_unanchay/pluma/pluma-editor-llimphi/README.md new file mode 100644 index 0000000..086fa96 --- /dev/null +++ b/00_unanchay/pluma/pluma-editor-llimphi/README.md @@ -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` 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/) diff --git a/00_unanchay/pluma/pluma-editor-llimphi/examples/cuerpo_ide_demo.rs b/00_unanchay/pluma/pluma-editor-llimphi/examples/cuerpo_ide_demo.rs new file mode 100644 index 0000000..a01449f --- /dev/null +++ b/00_unanchay/pluma/pluma-editor-llimphi/examples/cuerpo_ide_demo.rs @@ -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` 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, + /// 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) -> 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 = 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 = + atoms_vec.into_iter().map(|a| (a.id, a)).collect(); + + let idx: HashMap = 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) -> 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 { + 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 { + 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::( + &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 { + 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 { + 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 = 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 = 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 = 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 = model.ide.editor_cuerpo.atom_ids.clone(); + + let ahora = model.cuerpo.metadatos.modificado_en.saturating_add(1); + let viejo: Vec = 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::(); +} diff --git a/00_unanchay/pluma/pluma-editor-llimphi/examples/editor_unico_demo.rs b/00_unanchay/pluma/pluma-editor-llimphi/examples/editor_unico_demo.rs new file mode 100644 index 0000000..7d8b614 --- /dev/null +++ b/00_unanchay/pluma/pluma-editor-llimphi/examples/editor_unico_demo.rs @@ -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, + atoms: HashMap, + /// `cartas[i]` conecta `cuerpos[i]` con `cuerpos[i+1]`. + cartas: Vec, + /// Un IDE por cuerpo — cambiar de cuerpo conserva el buffer de cada + /// uno (caret, undo, ediciones sin guardar). Indexado por cuerpo. + ides: Vec, + /// Í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) -> 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 = 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 = 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 = 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) -> 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 { + 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 { + 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> = model.cartas.iter().map(Some).collect(); + let editores = multilienzo_editor_view::( + &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 { + 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) -> HashMap { + 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 = 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, CartaHebras) { + let mut tabla: HashMap = 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 { + 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 = 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 = model.ides[i_cuerpo].editor_cuerpo.atom_ids.clone(); + + let ahora = model.cuerpos[i_cuerpo].metadatos.modificado_en.saturating_add(1); + let viejo: Vec = 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::(); +} diff --git a/00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_completo_demo.rs b/00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_completo_demo.rs new file mode 100644 index 0000000..9428aae --- /dev/null +++ b/00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_completo_demo.rs @@ -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), + LlmListo { + hija: Cuerpo, + atoms_nuevos: Vec, + 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, + graph: NarrativeGraph, + cartas: Vec, + chat: Arc, + /// Backend actualmente activo — para mostrarlo en la toolbar y + /// para ciclar al siguiente con `Msg::CiclarBackend`. + backend: BackendKind, + store: Arc, + en_curso: bool, + ultimo_error: Option, + /// 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 { + 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 { + 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) -> 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) -> 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 { + 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> = if model.solo_madre { + Vec::new() + } else { + model.cartas.iter().map(Some).collect() + }; + + let interior = multilienzo_view_resaltado::( + &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::( + &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 { + 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), +} + +fn lanzar_trabajo(model: &Model, handle: &Handle, trabajo: TrabajoLlm) { + let madre = model.cuerpos[0].clone(); + let atoms_owned: Vec = 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 = + 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( + palette: &Palette, + en_curso: bool, + ultimo_error: &Option, + n_cuerpos: usize, + n_cartas: usize, + solo_madre: bool, + busqueda: &str, + n_stale: usize, + backend_actual: BackendKind, +) -> View +where + Msg: From, +{ + 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> = vec![ + button_view::("→ qu", pal, MsgUi::Traducir("qu".into()).into()), + button_view::("→ en", pal, MsgUi::Traducir("en".into()).into()), + button_view::("tono formal", pal, MsgUi::Tono("formal".into()).into()), + button_view::("resumir 30p", pal, MsgUi::Resumir(Some(30)).into()), + button_view::(label_focus, pal_focus, MsgUi::ToggleSoloMadre.into()), + button_view::("tocar madre", pal_focus, MsgUi::TocarMadre.into()), + button_view::( + "editar madre", + pal_focus, + MsgUi::EditarPrimerParrafoMadre.into(), + ), + ]; + // Botón cíclico de backend: muestra el actual, click pasa al siguiente. + botones.push(button_view::( + 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::( + 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), + ToggleSoloMadre, + TocarMadre, + RegenerarSiguienteStale, + CiclarBackend, + EditarPrimerParrafoMadre, +} + +impl From 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, + chat: Arc, + backend: BackendKind, +) -> Model { + let mut graph = NarrativeGraph::new(); + for atom in store.iter_atoms() { + graph.insert(atom.expect("leer atom")); + } + let mut cuerpos: Vec = 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 = 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, + chat: Arc, + 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, 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::(); +} diff --git a/00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_demo.rs b/00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_demo.rs new file mode 100644 index 0000000..cae7e52 --- /dev/null +++ b/00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_demo.rs @@ -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, + atoms: Vec, + cartas: Vec, +} + +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) -> 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 = 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 = 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 = 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 = atoms_es.clone(); + atoms_all.extend(atoms_qu.iter().cloned()); + atoms_all.extend(atoms_en.iter().cloned()); + let idx: HashMap = + 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) -> Model { + model + } + + fn view(model: &Model) -> View { + 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> = model.cartas.iter().map(Some).collect(); + + let interior = multilienzo_view::( + &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::().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 { + 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::(); +} diff --git a/00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_dinamico_demo.rs b/00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_dinamico_demo.rs new file mode 100644 index 0000000..c9b821a --- /dev/null +++ b/00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_dinamico_demo.rs @@ -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), + LlmListo { + hija: Cuerpo, + atoms_nuevos: Vec, + carta: CartaHebras, + }, + LlmError(String), +} + +struct Model { + cuerpos: Vec, + graph: NarrativeGraph, + cartas: Vec, + chat: Arc, + en_curso: bool, + ultimo_error: Option, +} + +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) -> 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) -> 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 { + 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> = model.cartas.iter().map(Some).collect(); + + let cuerpos_view = multilienzo_view::( + &cuerpos_ref, + &index, + &cartas_ref, + &cfg, + &paleta_hebras, + &palette, + ); + + let toolbar = toolbar_view::(&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), +} + +/// 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, 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 = 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 = + 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( + palette: &Palette, + en_curso: bool, + ultimo_error: &Option, +) -> View +where + Msg: From, +{ + 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> = Vec::new(); + let mk = |label: &str, m: MsgUi| { + button_view::(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`, así reusable si algún día se monta dentro de una +/// app más grande. En este demo, `MsgUi == app::Msg`. +fn env(v: T) -> T { + v +} + +#[derive(Clone, Debug)] +enum MsgUi { + Traducir(String), + Tono(String), + Resumir(Option), +} + +impl From 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 { + 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::(); +} diff --git a/00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_llm_demo.rs b/00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_llm_demo.rs new file mode 100644 index 0000000..7d7a7a0 --- /dev/null +++ b/00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_llm_demo.rs @@ -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, + graph: NarrativeGraph, + cartas: Vec, + 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) -> 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) -> Model { + model + } + + fn view(model: &Model) -> View { + 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> = model.cartas.iter().map(Some).collect(); + + let interior = multilienzo_view::( + &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, 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::().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 { + 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::(); +} diff --git a/00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_store_demo.rs b/00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_store_demo.rs new file mode 100644 index 0000000..86b0b44 --- /dev/null +++ b/00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_store_demo.rs @@ -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, + graph: NarrativeGraph, + cartas: Vec, +} + +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) -> 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 = 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) -> Model { + model + } + + fn view(model: &Model) -> View { + 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> = model.cartas.iter().map(Some).collect(); + + let interior = multilienzo_view::( + &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 = 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 = 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 { + // 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::().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 { + if !path.exists() { + return None; + } + DaemonClient::connect(path).await.ok() +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/00_unanchay/pluma/pluma-editor-llimphi/examples/zona_transform_demo.rs b/00_unanchay/pluma/pluma-editor-llimphi/examples/zona_transform_demo.rs new file mode 100644 index 0000000..782c079 --- /dev/null +++ b/00_unanchay/pluma/pluma-editor-llimphi/examples/zona_transform_demo.rs @@ -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), + LlmListo { + zona: usize, + etiqueta: String, + branch: String, + atoms_nuevos: Vec, + orden: Vec, + }, + LlmError(String), +} + +/// Una derivación por zona ya materializada. El panel derecho pinta una +/// `card` por entrada — vivimos con un `Vec` plano para que no +/// haya magia: cada click suma una entrada al final. +struct HijaZona { + zona: usize, + etiqueta: String, + branch: String, + atoms: HashMap, + orden: Vec, +} + +struct Model { + cuerpo: Cuerpo, + atoms: HashMap, + ide: CuerpoIde, + clipboard: MemClipboard, + drag_accum: (f32, f32), + chat: Arc, + en_curso: bool, + ultimo_error: Option, + hijas: Vec, +} + +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) -> 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 = 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 = + atoms_vec.into_iter().map(|a| (a.id, a)).collect(); + + let idx: HashMap = 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) -> 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 = + 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 { + 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 { + 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::( + &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 { + 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 { + 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::(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> = 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 { + let mut cards: Vec> = 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 { + 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::>() + .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), +} + +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 = + 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 = 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 = model.ide.editor_cuerpo.atom_ids.clone(); + let ahora = model.cuerpo.metadatos.modificado_en.saturating_add(1); + let viejo: Vec = 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, 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 = 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 = + 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 { + 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::(); +} diff --git a/00_unanchay/pluma/pluma-editor-llimphi/src/cuerpo_ide.rs b/00_unanchay/pluma/pluma-editor-llimphi/src/cuerpo_ide.rs new file mode 100644 index 0000000..d3f7c91 --- /dev/null +++ b/00_unanchay/pluma/pluma-editor-llimphi/src/cuerpo_ide.rs @@ -0,0 +1,1334 @@ +//! `cuerpo_ide` — el text-editor IDE de Llimphi montado sobre un +//! [`pluma_editor_cuerpo::EditorCuerpo`]. +//! +//! La página del editor multilienzo es **un solo control**: el usuario ve +//! todos los párrafos del cuerpo concatenados en un buffer plano editado +//! con el `text-editor` widget de Llimphi (cursor libre, multi-cursor, +//! undo/redo, find/replace, clipboard, highlight si lo activa el caller, +//! viewport scroll). Por debajo seguimos teniendo un grafo de +//! `NarrativeAtom`s con hebras vivas. +//! +//! Esta capa cose las dos cosas. Flujo típico: +//! +//! 1. [`CuerpoIde::from_cuerpo`] toma un `Cuerpo` + el índice de atoms +//! y arma un [`EditorCuerpo`] (texto plano + Uuids en orden) y un +//! [`EditorState`] del widget cargado con ese texto. +//! 2. El caller mete eventos de teclado vía [`CuerpoIde::apply_key`]. +//! El buffer queda desincronizado del `EditorCuerpo` (que sigue +//! mostrando el texto original) hasta el próximo `diff`. La +//! desincronía se detecta exactamente vía +//! [`EditorState::edit_seq`] — ningún flag manual que se pueda +//! perder si el caller toca el `state` por su cuenta. +//! 3. [`CuerpoIde::diff`] mete `state.text()` en +//! `editor_cuerpo.texto` y devuelve la lista mínima de +//! [`CambioAtom`] que el caller debe aplicar al grafo +//! (mutar contenido / crear atom nuevo / eliminar uno que ya no +//! aparece). Si el buffer no se tocó desde el último diff, retorna +//! `vec![]` sin escanear nada. +//! 4. Tras persistir en el grafo (creando `NarrativeAtom`s reales para +//! los `Crear`), el caller pasa los Uuids resultantes a +//! [`CuerpoIde::aplicar_cambios`] para que el `atom_ids` del editor +//! refleje el nuevo orden y la sincronía quede sellada. +//! +//! El widget no sabe ni necesita saber que el texto está particionado +//! en átomos: lo trata como un buffer único, con `\n\n` separando +//! párrafos. Esta capa SÍ lo sabe y expone helpers +//! [`CuerpoIde::posicion_de_atom`] / [`CuerpoIde::atom_id_en_linea`] que +//! traducen entre coordenadas del buffer y Uuids. + +use std::collections::HashMap; + +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::View; +use llimphi_widget_text_editor::{ + text_editor_view_highlighted, ApplyResult, Clipboard, EditorMetrics, EditorOptions, + EditorPalette, EditorState, Language, PointerEvent, +}; +use pluma_core::NarrativeAtom; +use pluma_cuerpo::Cuerpo; +use pluma_editor_cuerpo::{CambioAtom, EditorCuerpo, SEPARADOR}; +use uuid::Uuid; + +// Re-exports — el caller importa todo desde `cuerpo_ide` sin tener que +// conocer la geografía interna de los dos crates que ensamblamos. +pub use llimphi_widget_text_editor::{ + ApplyResult as EditorApplyResult, EditorMetrics as IdeMetrics, + EditorPalette as IdePalette, GutterStyle as IdeGutterStyle, Language as IdeLanguage, + PointerEvent as IdePointerEvent, +}; +pub use pluma_editor_cuerpo::{CambioAtom as IdeCambio, SEPARADOR as SEPARADOR_PARRAFO}; + +/// Una página de edición: cuerpo plano + estado del text-editor. +/// +/// Es `Clone` porque `EditorState` lo es; útil para snapshots a nivel +/// de aplicación (p.ej. "guardar como" sobre una copia, o un undo de +/// alto nivel que cubre operaciones sobre el grafo, no solo el buffer). +#[derive(Debug, Clone)] +pub struct CuerpoIde { + /// Vista plana del cuerpo. `texto` se actualiza cuando el caller + /// llama a [`Self::diff`]; mientras tanto la fuente de verdad + /// editable es `state.buffer`. + pub editor_cuerpo: EditorCuerpo, + /// Buffer + cursor + undo + viewport del widget. + pub state: EditorState, + /// `state.edit_seq` cuando el `editor_cuerpo.texto` fue sincronizado + /// por última vez con el buffer. El widget bumpea `edit_seq` con + /// **toda** mutación del buffer (set_text, apply_key, etc.) — usar + /// ese contador como marca evita el bug clásico de "flag bool que + /// se olvida de bajarse" cuando el caller mete cambios por fuera de + /// `apply_key`. + seq_sincronizado: u64, + /// `state.edit_seq` cuando se computaron las guardas por última + /// vez. Si difiere de `state.edit_seq`, la lista está stale y + /// [`Self::recomputar_guard_lines_si_stale`] la reconstruye. + seq_guardas: u64, + /// Flag por **junction** entre átomos consecutivos. Longitud = + /// `max(0, atom_ids.len() - 1)`. Índice *i* representa la + /// separación entre `atom_ids[i]` y `atom_ids[i+1]`. `false` = + /// separador (la línea vacía es guarda); `true` = fundida (la + /// línea vacía pertenece a la zona, es contenido editable). + /// + /// Por convención TODA junction arranca como separador (`false`). + /// La fusión es deliberada — un atajo del caller la togglea. + pub fundido_junctions: Vec, +} + +impl CuerpoIde { + /// Construye una página vacía. Útil para callers que quieren cargar + /// el cuerpo después con [`Self::recargar`] (p.ej. UI con `Option<…>` + /// que arranca sin documento abierto). + pub fn nuevo_vacio() -> Self { + let state = EditorState::new(); + let seq = state.edit_seq; + Self { + editor_cuerpo: EditorCuerpo { + texto: String::new(), + atom_ids: Vec::new(), + }, + state, + seq_sincronizado: seq, + seq_guardas: seq, + fundido_junctions: Vec::new(), + } + } + + /// Construye una página del IDE a partir de un `Cuerpo` + atoms del + /// grafo. El `EditorState` queda cargado con el texto plano del + /// cuerpo y el caret al final (convención de `EditorState::set_text`). + pub fn from_cuerpo(cuerpo: &Cuerpo, atoms: &HashMap) -> Self { + Self::con_opciones(cuerpo, atoms, EditorOptions::default()) + } + + /// Como [`Self::from_cuerpo`] pero permite pasar opciones del editor + /// (tab → spaces, indent size, page size, single-line). + /// + /// Tras cargar el texto, todas las junctions arrancan como + /// separador (`fundido_junctions[i] = false`) y las guardas se + /// computan en consecuencia. + pub fn con_opciones( + cuerpo: &Cuerpo, + atoms: &HashMap, + options: EditorOptions, + ) -> Self { + let editor_cuerpo = EditorCuerpo::from_cuerpo(cuerpo, atoms); + let n_junctions = editor_cuerpo.atom_ids.len().saturating_sub(1); + let mut state = EditorState::with_options(options); + state.set_text(&editor_cuerpo.texto); + let seq = state.edit_seq; + let mut ide = Self { + editor_cuerpo, + state, + seq_sincronizado: seq, + seq_guardas: seq.wrapping_sub(1), // forzar recompute + fundido_junctions: vec![false; n_junctions], + }; + ide.recomputar_guard_lines(); + ide.state.snap_off_guards(-1); + ide + } + + /// Resetea el IDE a un nuevo cuerpo (útil cuando el caller cambia de + /// pestaña / cuerpo activo). Limpia el undo del widget — semántica + /// del `EditorState::set_text`. Conserva las opciones del editor. + /// Todas las junctions vuelven a `false` (separador). + pub fn recargar(&mut self, cuerpo: &Cuerpo, atoms: &HashMap) { + self.editor_cuerpo = EditorCuerpo::from_cuerpo(cuerpo, atoms); + self.state.set_text(&self.editor_cuerpo.texto); + self.seq_sincronizado = self.state.edit_seq; + let n_junctions = self.editor_cuerpo.atom_ids.len().saturating_sub(1); + self.fundido_junctions = vec![false; n_junctions]; + self.recomputar_guard_lines(); + self.state.snap_off_guards(-1); + } + + /// `true` si el buffer del widget difiere del `editor_cuerpo.texto` + /// memorizado — al menos una mutación tocó el contenido desde la + /// última llamada a [`Self::diff`] o [`Self::recargar`]. + /// + /// Derivado de `state.edit_seq`, así que es resistente a mutaciones + /// del state por fuera de `apply_key` (p.ej. el caller llamó + /// `state.set_text` por su cuenta). + pub fn pendiente_sync(&self) -> bool { + self.state.edit_seq != self.seq_sincronizado + } + + /// Reenvía el evento a [`EditorState::apply_key`]. El tracking de + /// `pendiente_sync` se mantiene automáticamente vía `edit_seq`. Si + /// el buffer cambió, las guardas se recomputan tras el evento (y + /// el caret vuelve a snapearse — el primer snap se hizo con la + /// lista vieja). + pub fn apply_key(&mut self, event: &llimphi_ui::KeyEvent) -> ApplyResult { + let r = self.state.apply_key(event); + self.refrescar_guardas_si_cambio(r); + r + } + + /// Como [`Self::apply_key`] con backend de clipboard — habilita + /// `Ctrl+C/X/V`. + pub fn apply_key_with_clipboard( + &mut self, + event: &llimphi_ui::KeyEvent, + clipboard: &mut dyn Clipboard, + ) -> ApplyResult { + let r = self.state.apply_key_with_clipboard(event, clipboard); + self.refrescar_guardas_si_cambio(r); + r + } + + /// Si la edición cambió el buffer, recomputa la lista de guardas y + /// re-snappea el caret (el primer snap dentro del widget usó la + /// lista anterior). Si sólo movió el cursor, no hace nada — las + /// guardas no cambiaron. + fn refrescar_guardas_si_cambio(&mut self, r: ApplyResult) { + if r.changed() { + self.recomputar_guard_lines(); + self.state.snap_off_guards(1); + } + } + + /// Vuelca el texto del buffer en `editor_cuerpo.texto` (si hubo + /// cambios) y devuelve el diff mínimo contra los atoms originales + /// pasados por el caller. Si el buffer no se tocó desde el último + /// `diff` / `recargar`, retorna `vec![]` sin escanear nada — es el + /// path caliente de un `Ctrl+S` sobre un documento sin cambios. + /// + /// El caller suele recolectar `atoms_originales` del grafo justo + /// antes — el editor no consulta el grafo por sí mismo. + pub fn diff(&mut self, atoms_originales: &HashMap) -> Vec { + if !self.pendiente_sync() { + return Vec::new(); + } + self.editor_cuerpo.set_texto(self.state.text()); + self.seq_sincronizado = self.state.edit_seq; + self.editor_cuerpo.diff(atoms_originales) + } + + /// Tras persistir los cambios en el grafo (creando `NarrativeAtom`s + /// nuevos para los `Crear` y removiendo los `Eliminar`), pasá acá + /// los Uuids generados para los `Crear`, **en orden**, y el + /// `atom_ids` del editor queda alineado con el cuerpo nuevo. La + /// lista de `fundido_junctions` se ajusta en consecuencia + /// (junctions nuevas arrancan como separador `false`; junctions + /// eliminadas se descartan preservando el flag de las que sobreviven). + pub fn aplicar_cambios(&mut self, cambios: &[CambioAtom], nuevos_ids: &[Uuid]) { + let n_antes = self.editor_cuerpo.atom_ids.len(); + self.editor_cuerpo.aplicar_cambios(cambios, nuevos_ids); + let n_despues = self.editor_cuerpo.atom_ids.len(); + let target = n_despues.saturating_sub(1); + // Preservamos el flag de las junctions que sobreviven (las + // primeras `min(target, len_actual)`) y extendemos con `false` + // (separador) para junctions nuevas. Si hay borrados al + // final, simplemente truncamos. + self.fundido_junctions.resize(target, false); + // Reset de seq_guardas para forzar recompute en el próximo render. + self.seq_guardas = self.state.edit_seq.wrapping_sub(1); + let _ = n_antes; + self.recomputar_guard_lines(); + } + + /// Togglea la junction *idx* (entre `atom_ids[idx]` y + /// `atom_ids[idx+1]`) — si era separador, pasa a fundida; si era + /// fundida, pasa a separador. Tras el toggle, las guardas y el + /// caret se refrescan. `idx` fuera de rango → no-op silencioso. + pub fn togglear_junction(&mut self, idx: usize) { + if idx >= self.fundido_junctions.len() { + return; + } + self.fundido_junctions[idx] = !self.fundido_junctions[idx]; + self.seq_guardas = self.state.edit_seq.wrapping_sub(1); + self.recomputar_guard_lines(); + // El caret puede haber quedado sobre una nueva guarda o + // liberado de una vieja — re-snap por las dudas. + self.state.snap_off_guards(0); + } + + /// Marca la junction *idx* como fundida (no es guarda). No-op si + /// ya estaba fundida o si `idx` está fuera de rango. + pub fn fundir_junction(&mut self, idx: usize) { + if idx < self.fundido_junctions.len() && !self.fundido_junctions[idx] { + self.togglear_junction(idx); + } + } + + /// Marca la junction *idx* como separador (es guarda). No-op si + /// ya era separador o si `idx` está fuera de rango. + pub fn separar_junction(&mut self, idx: usize) { + if idx < self.fundido_junctions.len() && self.fundido_junctions[idx] { + self.togglear_junction(idx); + } + } + + /// Recomputa `state.guard_lines` Y `state.line_tints` desde cero. + /// 1. Enumera los índices de línea vacía del buffer (cada una es + /// candidata a guarda — aparece por un `\n\n` o trailing). + /// 2. Las matchea por ordinal con `fundido_junctions`: la *i*-ésima + /// línea vacía corresponde a la *i*-ésima junction. Junctions + /// extra (más blanks que junctions registradas) se tratan como + /// separador (guarda) — eso pasa típicamente cuando el usuario + /// acaba de tipear `\n\n` y aún no llamó a `diff` para + /// materializar el atom nuevo. + /// 3. Junctions `false` (separador) van a `guard_lines`; junctions + /// `true` (fundida) NO se agregan — la línea vacía pertenece a + /// la zona, es contenido editable. + pub fn recomputar_guard_lines(&mut self) { + let texto = self.state.text(); + let total_lineas = self.state.line_count(); + let mut guards: Vec = Vec::new(); + // Cada línea del buffer pertenece a un grupo (zona). Empezamos + // en grupo 0; cada vez que cruzamos un separador (junction + // que NO está fundida), incrementamos. Líneas guarda no + // pertenecen a ningún grupo (color `None`). + let mut tints: Vec> = vec![None; total_lineas]; + let mut grupo_actual = 0usize; + let mut junction_idx = 0usize; + for (linea, contenido) in texto.lines().enumerate() { + if contenido.is_empty() { + // Línea candidata a junction. + let fundida = self + .fundido_junctions + .get(junction_idx) + .copied() + .unwrap_or(false); + if !fundida { + // Separador: guarda + corte de grupo. + guards.push(linea); + if linea < tints.len() { + tints[linea] = None; + } + grupo_actual += 1; + } else { + // Fundida: la línea es contenido de la zona, hereda el tinte. + if linea < tints.len() { + tints[linea] = Some(color_de_grupo(grupo_actual)); + } + } + junction_idx += 1; + } else { + // Línea de contenido — tinte del grupo actual. + if linea < tints.len() { + tints[linea] = Some(color_de_grupo(grupo_actual)); + } + } + } + self.state.set_guard_lines(guards); + self.state.line_tints = tints; + self.seq_guardas = self.state.edit_seq; + } + + /// Devuelve la línea de la junction *idx* en el buffer actual, + /// para permitir scroll/highlight dirigidos. `None` si el índice + /// no corresponde a ninguna junction visible. + pub fn linea_de_junction(&self, idx: usize) -> Option { + let texto = self.state.text(); + let mut count = 0usize; + for (linea, contenido) in texto.lines().enumerate() { + if !contenido.is_empty() { + continue; + } + if count == idx { + return Some(linea); + } + count += 1; + } + None + } + + /// Devuelve el índice de la junction que **precede** al atom en la + /// línea actual del caret. Útil para "fundir el párrafo del caret + /// con el anterior". Devuelve `None` si el caret está en el primer + /// atom (no tiene junction anterior) o no se puede mapear. + pub fn junction_antes_del_caret(&self) -> Option { + let (linea, _) = self.caret(); + let texto = self.state.text(); + // Cuántas líneas vacías hay ANTES de `linea` — esa es la + // cantidad de junctions que precede al atom actual; el índice + // de la junction inmediatamente anterior es ese count - 1. + let mut count = 0usize; + for (i, contenido) in texto.lines().enumerate() { + if i >= linea { + break; + } + if contenido.is_empty() { + count += 1; + } + } + if count == 0 { + None + } else { + Some(count - 1) + } + } + + /// Atajo retrocompatible — alias histórico de [`Self::aplicar_cambios`]. + #[inline] + pub fn aplicar_cambios_locales(&mut self, cambios: &[CambioAtom], nuevos_ids: &[Uuid]) { + self.aplicar_cambios(cambios, nuevos_ids); + } + + /// Cuántas **zonas** distintas hay en el cuerpo actual. Una zona es + /// un grupo de atoms consecutivos unidos por junctions fundidas; cada + /// junction `false` (separador) marca el inicio de una zona nueva. + /// + /// Reglas: + /// - `n_atoms == 0` → `0` zonas. + /// - `n_atoms == 1` → `1` zona. + /// - En general: `n_zonas = 1 + (cantidad de junctions separadoras)`. + pub fn n_zonas(&self) -> usize { + let n = self.editor_cuerpo.atom_ids.len(); + if n == 0 { + return 0; + } + let separadoras = self.fundido_junctions.iter().filter(|f| !**f).count(); + // Cada separadora abre una zona nueva; la primera zona ya existe. + // Si hay más junctions registradas que atoms-1 (estado transitorio + // entre tipeo y diff), las extras se ignoran — el cap es real: + // como mucho `n` zonas, una por atom. + (1 + separadoras).min(n) + } + + /// Devuelve la zona que contiene al atom `atom_idx` (0-based en + /// `editor_cuerpo.atom_ids`). `None` si `atom_idx` está fuera de rango. + pub fn zona_de_atom_idx(&self, atom_idx: usize) -> Option { + if atom_idx >= self.editor_cuerpo.atom_ids.len() { + return None; + } + // La zona del atom *i* es la cantidad de junctions separadoras en + // `fundido_junctions[..i]`: cada separadora cierra una zona y abre + // la siguiente. + let zona = self + .fundido_junctions + .iter() + .take(atom_idx) + .filter(|f| !**f) + .count(); + Some(zona) + } + + /// Devuelve la zona a la que pertenece la línea `linea` del buffer. + /// Líneas guarda (separadores no fundidos) no pertenecen a ninguna + /// zona — devuelven `None`. Líneas vacías fundidas SÍ pertenecen a + /// la zona del atom que las flanquea. + pub fn zona_de_linea(&self, linea: usize) -> Option { + if self.editor_cuerpo.atom_ids.is_empty() { + return None; + } + if self.state.is_guard_line(linea) { + return None; + } + let id = self.atom_id_en_linea(linea)?; + let idx = self.editor_cuerpo.atom_ids.iter().position(|x| *x == id)?; + self.zona_de_atom_idx(idx) + } + + /// Zona actual del caret. Si el caret está sobre una guarda, busca la + /// zona del atom de la línea inmediatamente anterior (consistente con + /// [`Self::atom_id_en_linea`], que atribuye separadores al átomo + /// anterior). Devuelve `0` si el cuerpo está vacío — los callers que + /// quieran distinguir el caso usan [`Self::n_zonas`] previamente. + pub fn zona_del_caret(&self) -> usize { + if self.editor_cuerpo.atom_ids.is_empty() { + return 0; + } + let (linea, _) = self.caret(); + if let Some(z) = self.zona_de_linea(linea) { + return z; + } + // Caret sobre guarda: el snap del widget normalmente lo saca de + // ahí, pero por las dudas tomamos la zona del atom anterior. + let anterior = linea.saturating_sub(1); + self.zona_de_linea(anterior).unwrap_or(0) + } + + /// Rango inclusive de índices de atom (en `editor_cuerpo.atom_ids`) + /// que forman la zona `zona`. `None` si `zona` está fuera de rango. + pub fn atoms_de_zona(&self, zona: usize) -> Option<(usize, usize)> { + if zona >= self.n_zonas() { + return None; + } + // Primer atom de la zona: el primero cuyo zona_de_atom_idx == zona. + // Caminamos las junctions contando separadoras: cuando la cuenta + // alcanza `zona`, el atom siguiente es el inicio. + let mut zona_actual = 0usize; + let mut start: Option = None; + let mut end: usize = 0; + for atom_idx in 0..self.editor_cuerpo.atom_ids.len() { + if atom_idx > 0 { + // Junction inmediatamente anterior a este atom. + let fundida = self + .fundido_junctions + .get(atom_idx - 1) + .copied() + .unwrap_or(false); + if !fundida { + zona_actual += 1; + } + } + if zona_actual == zona { + if start.is_none() { + start = Some(atom_idx); + } + end = atom_idx; + } else if zona_actual > zona { + break; + } + } + start.map(|s| (s, end)) + } + + /// Rango inclusive de líneas del buffer que cubren la zona `zona`. + /// La línea de inicio es la del primer atom de la zona; la línea de + /// fin es la última línea del último atom de la zona (cuenta los + /// `\n` internos del atom). Las junctions fundidas internas a la zona + /// quedan incluidas naturalmente porque no son guarda. + pub fn lineas_de_zona(&self, zona: usize) -> Option<(usize, usize)> { + let (start_atom, end_atom) = self.atoms_de_zona(zona)?; + let start_id = *self.editor_cuerpo.atom_ids.get(start_atom)?; + let end_id = *self.editor_cuerpo.atom_ids.get(end_atom)?; + let (start_line, _) = self.posicion_de_atom(start_id)?; + let (end_line_start, _) = self.posicion_de_atom(end_id)?; + let end_parrafo = self.editor_cuerpo.texto.split(SEPARADOR).nth(end_atom)?; + let end_line = end_line_start + end_parrafo.matches('\n').count(); + Some((start_line, end_line)) + } + + /// Devuelve los `Uuid` de los atoms que forman la zona `zona`, en + /// orden. `None` si la zona está fuera de rango. Útil para construir + /// un sub-`Cuerpo` que se pase a un ejecutor LLM y derivar SOLO esa + /// zona, sin tocar el resto del documento. + pub fn atom_ids_de_zona(&self, zona: usize) -> Option> { + let (start, end) = self.atoms_de_zona(zona)?; + Some(self.editor_cuerpo.atom_ids[start..=end].to_vec()) + } + + /// Mueve el caret al inicio de la zona `zona` (línea de la primera + /// atom, columna 0) y se asegura de que sea visible. Si la zona está + /// fuera de rango, no-op. + pub fn ir_a_zona(&mut self, zona: usize) { + let Some((start_line, _)) = self.lineas_de_zona(zona) else { + return; + }; + self.set_caret(start_line, 0); + } + + /// Selecciona la zona `zona` entera: caret pasa al final de la última + /// línea de la zona y el anchor queda en el inicio (línea de la + /// primera atom, col 0). Si la zona está fuera de rango, no-op. + pub fn seleccionar_zona(&mut self, zona: usize) { + let Some((start_line, end_line)) = self.lineas_de_zona(zona) else { + return; + }; + self.set_caret(start_line, 0); + let end_col = self.state.buffer.line_len_chars(end_line); + self.state.extend_selection_to(end_line, end_col); + } + + /// Zona siguiente (con wrap al inicio si estamos en la última). Si + /// no hay zonas, no-op silencioso. Si hay solo una, recae sobre sí + /// misma — mueve el caret al inicio. + pub fn ir_a_zona_siguiente(&mut self) { + let total = self.n_zonas(); + if total == 0 { + return; + } + let actual = self.zona_del_caret(); + let siguiente = (actual + 1) % total; + self.ir_a_zona(siguiente); + } + + /// Zona anterior (con wrap al final si estamos en la primera). + pub fn ir_a_zona_anterior(&mut self) { + let total = self.n_zonas(); + if total == 0 { + return; + } + let actual = self.zona_del_caret(); + let anterior = if actual == 0 { total - 1 } else { actual - 1 }; + self.ir_a_zona(anterior); + } + + /// Cuántos átomos cubre el cuerpo plano que el editor está mostrando + /// (estado del último sync — puede diferir de los párrafos del + /// buffer hasta el próximo `diff`). + #[inline] + pub fn n_atoms(&self) -> usize { + self.editor_cuerpo.atom_ids.len() + } + + /// Cantidad de párrafos en el buffer **ahora mismo** (puede diferir + /// de [`Self::n_atoms`] si el usuario insertó/eliminó separadores + /// desde el último sync). Útil para feedback en vivo del header. + pub fn n_parrafos_buffer(&self) -> usize { + // No clonamos el string — iteramos directo sobre el rope. + let texto = self.state.text(); + texto + .split(SEPARADOR) + .map(str::trim) + .filter(|s| !s.is_empty()) + .count() + } + + /// Texto crudo del buffer del widget. Atajo de `state.text()` para + /// callers que solo leen. + #[inline] + pub fn texto_buffer(&self) -> String { + self.state.text() + } + + /// Línea inicial (0-based) del átomo `id` en `editor_cuerpo.texto`. + /// Camina los párrafos sumando `\n`s reales — robusto a átomos + /// multi-línea (si el cuerpo guarda contenido con `\n` interno) y al + /// número de newlines del [`SEPARADOR`]. + /// + /// Devuelve `None` si el id no pertenece al cuerpo. La posición es + /// exacta para el texto memorizado del último sync; si el caller + /// editó el buffer y no llamó [`Self::diff`] aún, la posición es la + /// del *cuerpo sincronizado*, no la del buffer vivo. + pub fn posicion_de_atom(&self, id: Uuid) -> Option<(usize, usize)> { + let idx = self.editor_cuerpo.atom_ids.iter().position(|x| *x == id)?; + if idx == 0 { + return Some((0, 0)); + } + // Líneas vacías que aporta el separador entre dos párrafos: para + // `\n\n` (2 newlines) hay 1 línea vacía entre los párrafos. + let lineas_vacias_sep = SEPARADOR.matches('\n').count().saturating_sub(1); + let mut linea = 0usize; + for (i, parrafo) in self.editor_cuerpo.texto.split(SEPARADOR).enumerate() { + if i == idx { + return Some((linea, 0)); + } + // Líneas que ocupa este párrafo (N `\n`s ⇒ N+1 líneas). + linea += parrafo.matches('\n').count() + 1; + linea += lineas_vacias_sep; + } + // No debería pasar — `idx` está en rango por `position`. + None + } + + /// Inversa de [`Self::posicion_de_atom`]: dado una línea del buffer + /// (0-based), devuelve el Uuid del átomo al que pertenece esa línea. + /// Si la línea cae sobre el separador (la línea en blanco entre dos + /// párrafos), la atribuye al átomo **anterior** — así un click justo + /// debajo del último renglón sigue seleccionando el párrafo que + /// estabas leyendo. + /// + /// Camina los párrafos reales del texto sincronizado, igual que + /// [`Self::posicion_de_atom`]. + pub fn atom_id_en_linea(&self, linea: usize) -> Option { + if self.editor_cuerpo.atom_ids.is_empty() { + return None; + } + let lineas_vacias_sep = SEPARADOR.matches('\n').count().saturating_sub(1); + let mut cursor_linea = 0usize; + for (i, parrafo) in self.editor_cuerpo.texto.split(SEPARADOR).enumerate() { + let content_lines = parrafo.matches('\n').count() + 1; + let fin_parrafo = cursor_linea + content_lines; + // Dentro del contenido del párrafo i. + if linea < fin_parrafo { + return self.editor_cuerpo.atom_ids.get(i).copied(); + } + // En el separador posterior al párrafo i. + if linea < fin_parrafo + lineas_vacias_sep { + return self.editor_cuerpo.atom_ids.get(i).copied(); + } + cursor_linea = fin_parrafo + lineas_vacias_sep; + } + None + } + + /// Caret actual `(line, col)` del cursor primario. Atajo de + /// `state.cursor.caret`. + #[inline] + pub fn caret(&self) -> (usize, usize) { + let p = self.state.cursor.caret; + (p.line, p.col) + } + + /// Posiciona el caret `(line, col)`, clampeando al rango válido. + /// Atajo de `state.set_caret_at` para callers que no quieren tocar + /// el state directamente. + #[inline] + pub fn set_caret(&mut self, line: usize, col: usize) { + self.state.set_caret_at(line, col); + } +} + +impl Default for CuerpoIde { + fn default() -> Self { + Self::nuevo_vacio() + } +} + +/// Render del IDE: arma el `text-editor` widget con el texto del cuerpo. +/// +/// `language` es típicamente [`Language::Plain`] para prosa narrativa +/// (sin syntax highlight); el caller puede pasar otro si su contenido +/// es código embebido. `visible_lines` cumple el rol habitual del +/// widget — cuántas líneas dibujamos como máximo por frame (el widget +/// cappea internamente a 200; pasar un número alto cuando se desconoce +/// el viewport real es seguro). +/// +/// `on_pointer` propaga el `PointerEvent` del widget (Click / Drag +/// dentro del área de texto) al `Msg` del caller; el caller convierte +/// (x, y) en (line, col) con [`EditorMetrics::screen_to_pos`] y aplica +/// [`CuerpoIde::set_caret`] o `state.extend_selection_to`. +pub fn cuerpo_ide_view( + ide: &CuerpoIde, + palette: &EditorPalette, + metrics: EditorMetrics, + visible_lines: usize, + language: Language, + on_pointer: impl Fn(PointerEvent) -> Option + Send + Sync + Clone + 'static, +) -> View { + // El IDE narrativo siempre quiere ver pista visual en las líneas + // separadoras: encendemos `phantom_guard_lines` para que cada + // guarda reciba el divisor fantasma. El estilo de gutter + // (Numbers/Phantom) y el ancho los decide el caller — el omitido + // del número en las líneas guarda ocurre automáticamente porque + // `state.guard_lines` lo lleva (lo pobló `recomputar_guard_lines`). + let mut metrics = metrics; + metrics.phantom_guard_lines = true; + text_editor_view_highlighted( + &ide.state, + palette, + metrics, + visible_lines, + language, + on_pointer, + ) +} + +/// Constructor para tests / herramientas: arma un `CuerpoIde` sin pasar +/// por un `Cuerpo` — recibe el texto plano y la lista de `atom_ids` en +/// orden. Útil cuando el caller quiere instrumentar un estado intermedio. +pub fn cuerpo_ide_desde_texto(texto: impl Into, atom_ids: Vec) -> CuerpoIde { + let texto = texto.into(); + let n_junctions = atom_ids.len().saturating_sub(1); + let mut state = EditorState::new(); + state.set_text(&texto); + let seq = state.edit_seq; + let mut ide = CuerpoIde { + editor_cuerpo: EditorCuerpo { texto, atom_ids }, + state, + seq_sincronizado: seq, + seq_guardas: seq.wrapping_sub(1), + fundido_junctions: vec![false; n_junctions], + }; + ide.recomputar_guard_lines(); + ide.state.snap_off_guards(-1); + ide +} + +/// Paleta circular de 8 tonalidades para colorear las zonas del IDE +/// narrativo. Cada índice de grupo `i` recibe `PALETA_ZONAS[i % +/// PALETA_ZONAS.len()]` — el alpha está calculado para sumar como +/// tinte sutil sobre el fondo del editor (≤16/255), sin afectar la +/// lectura del texto. Los matices están repartidos en el círculo +/// cromático para que dos grupos adyacentes se distingan al ojo aun +/// con baja saturación. +const PALETA_ZONAS: [Color; 8] = [ + // ámbar tibio + Color::from_rgba8(238, 178, 53, 16), + // verde salvia + Color::from_rgba8(94, 184, 124, 16), + // azul lavanda + Color::from_rgba8(120, 150, 220, 16), + // rosa palo + Color::from_rgba8(220, 130, 160, 16), + // turquesa + Color::from_rgba8(80, 190, 200, 16), + // violeta suave + Color::from_rgba8(170, 130, 220, 16), + // arena + Color::from_rgba8(210, 190, 130, 16), + // coral + Color::from_rgba8(230, 140, 120, 16), +]; + +/// Devuelve el color asignado al grupo `idx` siguiendo la paleta +/// circular [`PALETA_ZONAS`]. +pub fn color_de_grupo(idx: usize) -> Color { + PALETA_ZONAS[idx % PALETA_ZONAS.len()] +} + +/// Cuántos grupos distintos pueden colorearse antes de que se repita +/// la tonalidad. Útil para que la UI muestre "N grupos · ciclo cada +/// `paleta_zonas_len()`". +pub const fn paleta_zonas_len() -> usize { + PALETA_ZONAS.len() +} + +#[cfg(test)] +mod pruebas { + use super::*; + use pluma_cuerpo::Intencion; + + fn cuerpo_con_atoms(textos: &[&str]) -> (Cuerpo, Vec) { + let mut c = Cuerpo::nuevo("es", "es", Intencion::Original, 0); + let atoms: Vec = + 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 { + atoms.iter().map(|a| (a.id, a)).collect() + } + + #[test] + fn nuevo_vacio_arranca_sincronizado() { + let ide = CuerpoIde::nuevo_vacio(); + assert!(!ide.pendiente_sync()); + assert_eq!(ide.n_atoms(), 0); + assert_eq!(ide.n_parrafos_buffer(), 0); + assert_eq!(ide.texto_buffer(), ""); + } + + #[test] + fn default_es_equivalente_a_nuevo_vacio() { + let a = CuerpoIde::default(); + let b = CuerpoIde::nuevo_vacio(); + assert_eq!(a.editor_cuerpo, b.editor_cuerpo); + assert_eq!(a.texto_buffer(), b.texto_buffer()); + } + + #[test] + fn from_cuerpo_carga_texto_concatenado_y_arranca_sincronizado() { + let (c, atoms) = cuerpo_con_atoms(&["Uno.", "Dos.", "Tres."]); + let idx = indice(&atoms); + let ide = CuerpoIde::from_cuerpo(&c, &idx); + assert_eq!( + ide.state.text(), + format!("Uno.{s}Dos.{s}Tres.", s = SEPARADOR) + ); + assert_eq!(ide.editor_cuerpo.atom_ids.len(), 3); + assert!(!ide.pendiente_sync()); + } + + #[test] + fn diff_sin_cambios_corta_temprano_y_no_toca_texto() { + let (c, atoms) = cuerpo_con_atoms(&["uno", "dos"]); + let idx = indice(&atoms); + let mut ide = CuerpoIde::from_cuerpo(&c, &idx); + let seq_antes = ide.state.edit_seq; + let texto_antes = ide.editor_cuerpo.texto.clone(); + + let d = ide.diff(&idx); + assert!(d.is_empty()); + // El edit_seq no debe avanzar — diff no toca el state. + assert_eq!(ide.state.edit_seq, seq_antes); + // Y el texto memorizado tampoco. + assert_eq!(ide.editor_cuerpo.texto, texto_antes); + assert!(!ide.pendiente_sync()); + } + + #[test] + fn editar_buffer_y_diff_emite_mutar_con_uuid_preservado() { + let (c, atoms) = cuerpo_con_atoms(&["uno", "dos", "tres"]); + let idx = indice(&atoms); + let mut ide = CuerpoIde::from_cuerpo(&c, &idx); + ide.state + .set_text(&format!("uno{s}DOS!{s}tres", s = SEPARADOR)); + assert!( + ide.pendiente_sync(), + "set_text debe disparar pendiente_sync vía edit_seq" + ); + let d = ide.diff(&idx); + assert_eq!(d.len(), 1); + match &d[0] { + CambioAtom::Mutar { id, texto_nuevo } => { + assert_eq!(*id, atoms[1].id); + assert_eq!(texto_nuevo, "DOS!"); + } + otro => panic!("esperaba Mutar, fue {otro:?}"), + } + assert!(!ide.pendiente_sync(), "tras diff el editor queda sincronizado"); + } + + #[test] + fn aplicar_cambios_alinea_atom_ids_con_los_nuevos_uuids() { + let (c, atoms) = cuerpo_con_atoms(&["uno", "dos"]); + let idx = indice(&atoms); + let mut ide = CuerpoIde::from_cuerpo(&c, &idx); + ide.state + .set_text(&format!("uno{s}tres{s}cuatro", s = SEPARADOR)); + let cambios = ide.diff(&idx); + let nuevo_id = Uuid::new_v4(); + ide.aplicar_cambios(&cambios, &[nuevo_id]); + assert_eq!(ide.editor_cuerpo.atom_ids.len(), 3); + assert_eq!(ide.editor_cuerpo.atom_ids[2], nuevo_id); + } + + #[test] + fn alias_legacy_aplicar_cambios_locales_sigue_funcionando() { + let (c, atoms) = cuerpo_con_atoms(&["uno"]); + let idx = indice(&atoms); + let mut ide = CuerpoIde::from_cuerpo(&c, &idx); + ide.state.set_text(&format!("uno{s}dos", s = SEPARADOR)); + let cambios = ide.diff(&idx); + let nuevo = Uuid::new_v4(); + ide.aplicar_cambios_locales(&cambios, &[nuevo]); + assert_eq!(ide.editor_cuerpo.atom_ids, vec![atoms[0].id, nuevo]); + } + + #[test] + fn recargar_resetea_estado_a_cuerpo_nuevo() { + let (c1, atoms1) = cuerpo_con_atoms(&["uno", "dos"]); + let idx1 = indice(&atoms1); + let mut ide = CuerpoIde::from_cuerpo(&c1, &idx1); + ide.state.set_text("editado a mano"); + assert!(ide.pendiente_sync()); + + let (c2, atoms2) = cuerpo_con_atoms(&["A", "B", "C"]); + let idx2 = indice(&atoms2); + ide.recargar(&c2, &idx2); + assert_eq!(ide.state.text(), format!("A{s}B{s}C", s = SEPARADOR)); + assert_eq!(ide.editor_cuerpo.atom_ids.len(), 3); + assert!(!ide.pendiente_sync()); + } + + #[test] + fn cuerpo_ide_desde_texto_construye_sin_grafo_y_sincronizado() { + let id_a = Uuid::new_v4(); + let id_b = Uuid::new_v4(); + let ide = cuerpo_ide_desde_texto(format!("A{s}B", s = SEPARADOR), vec![id_a, id_b]); + assert_eq!(ide.editor_cuerpo.atom_ids, vec![id_a, id_b]); + assert_eq!(ide.state.text(), format!("A{s}B", s = SEPARADOR)); + assert!(!ide.pendiente_sync()); + } + + #[test] + fn n_parrafos_buffer_cuenta_split_actual_no_atom_ids_memorizados() { + let (c, atoms) = cuerpo_con_atoms(&["uno", "dos"]); + let idx = indice(&atoms); + let mut ide = CuerpoIde::from_cuerpo(&c, &idx); + // n_atoms refleja lo memorizado; n_parrafos_buffer el buffer vivo. + assert_eq!(ide.n_atoms(), 2); + assert_eq!(ide.n_parrafos_buffer(), 2); + ide.state + .set_text(&format!("uno{s}dos{s}tres{s}cuatro", s = SEPARADOR)); + assert_eq!(ide.n_atoms(), 2, "atom_ids viejos hasta el próximo diff"); + assert_eq!(ide.n_parrafos_buffer(), 4, "el buffer ya tiene 4"); + } + + #[test] + fn posicion_de_atom_devuelve_linea_inicial_correcta() { + let (c, atoms) = cuerpo_con_atoms(&["primero", "segundo", "tercero"]); + let idx = indice(&atoms); + let ide = CuerpoIde::from_cuerpo(&c, &idx); + // Texto: "primero\n\nsegundo\n\ntercero" + // Líneas: 0 1 2 3 4 + assert_eq!(ide.posicion_de_atom(atoms[0].id), Some((0, 0))); + assert_eq!(ide.posicion_de_atom(atoms[1].id), Some((2, 0))); + assert_eq!(ide.posicion_de_atom(atoms[2].id), Some((4, 0))); + assert_eq!(ide.posicion_de_atom(Uuid::new_v4()), None); + } + + #[test] + fn atom_id_en_linea_atribuye_separador_al_atom_previo() { + let (c, atoms) = cuerpo_con_atoms(&["primero", "segundo", "tercero"]); + let idx = indice(&atoms); + let ide = CuerpoIde::from_cuerpo(&c, &idx); + // Texto: "primero\n\nsegundo\n\ntercero" + // Líneas: 0 1 2 3 4 + assert_eq!(ide.atom_id_en_linea(0), Some(atoms[0].id)); + // Línea 1 = "" (separador): se atribuye al atom previo. + assert_eq!(ide.atom_id_en_linea(1), Some(atoms[0].id)); + assert_eq!(ide.atom_id_en_linea(2), Some(atoms[1].id)); + assert_eq!(ide.atom_id_en_linea(3), Some(atoms[1].id)); + assert_eq!(ide.atom_id_en_linea(4), Some(atoms[2].id)); + // Fuera de rango → None. + assert_eq!(ide.atom_id_en_linea(99), None); + // IDE vacío → None siempre. + let vacio = CuerpoIde::nuevo_vacio(); + assert_eq!(vacio.atom_id_en_linea(0), None); + } + + #[test] + fn posicion_y_atom_id_son_inversas_para_atomos_single_line() { + let (c, atoms) = cuerpo_con_atoms(&["a", "b", "c", "d"]); + let idx = indice(&atoms); + let ide = CuerpoIde::from_cuerpo(&c, &idx); + for a in &atoms { + let (line, _) = ide.posicion_de_atom(a.id).expect("atom existe"); + assert_eq!(ide.atom_id_en_linea(line), Some(a.id)); + } + } + + #[test] + fn caret_helpers_son_passthrough_consistente() { + let (c, atoms) = cuerpo_con_atoms(&["abc", "def"]); + let idx = indice(&atoms); + let mut ide = CuerpoIde::from_cuerpo(&c, &idx); + // set_caret usa la API segura — con guardas, el caret no + // puede caer en (0, 2) sólo si esa línea es guarda; "abc" no + // lo es, así que el assert pasa. + ide.set_caret(0, 2); + assert_eq!(ide.caret(), (0, 2)); + // Set caret no marca pendiente_sync — sólo cambios del buffer + // bumpean edit_seq. + assert!(!ide.pendiente_sync()); + } + + #[test] + fn from_cuerpo_arranca_con_todas_las_junctions_como_separador() { + let (c, atoms) = cuerpo_con_atoms(&["A", "B", "C"]); + let idx = indice(&atoms); + let ide = CuerpoIde::from_cuerpo(&c, &idx); + // 3 átomos → 2 junctions, ambas separador. + assert_eq!(ide.fundido_junctions, vec![false, false]); + // Y las dos líneas vacías (1 y 3) deberían ser guardas. + assert_eq!(ide.state.guard_lines, vec![1, 3]); + } + + #[test] + fn fundir_junction_quita_la_guarda_de_esa_linea() { + let (c, atoms) = cuerpo_con_atoms(&["A", "B", "C"]); + let idx = indice(&atoms); + let mut ide = CuerpoIde::from_cuerpo(&c, &idx); + // Líneas: 0="A", 1="", 2="B", 3="", 4="C". + // Fusionar la junction 0 (entre A y B): la línea 1 deja de ser guarda. + ide.fundir_junction(0); + assert_eq!(ide.fundido_junctions, vec![true, false]); + assert_eq!(ide.state.guard_lines, vec![3]); + } + + #[test] + fn separar_junction_revierte_la_fusion() { + let (c, atoms) = cuerpo_con_atoms(&["A", "B"]); + let idx = indice(&atoms); + let mut ide = CuerpoIde::from_cuerpo(&c, &idx); + ide.fundir_junction(0); + assert!(ide.state.guard_lines.is_empty()); + ide.separar_junction(0); + assert_eq!(ide.state.guard_lines, vec![1]); + } + + #[test] + fn togglear_junction_es_idempotente_doble_aplica() { + let (c, atoms) = cuerpo_con_atoms(&["A", "B"]); + let idx = indice(&atoms); + let mut ide = CuerpoIde::from_cuerpo(&c, &idx); + ide.togglear_junction(0); + ide.togglear_junction(0); + assert_eq!(ide.fundido_junctions, vec![false]); + assert_eq!(ide.state.guard_lines, vec![1]); + } + + #[test] + fn togglear_junction_fuera_de_rango_es_noop() { + let (c, atoms) = cuerpo_con_atoms(&["A", "B"]); + let idx = indice(&atoms); + let mut ide = CuerpoIde::from_cuerpo(&c, &idx); + ide.togglear_junction(99); + // Sin cambios: 1 junction separador, 1 guarda. + assert_eq!(ide.fundido_junctions, vec![false]); + assert_eq!(ide.state.guard_lines, vec![1]); + } + + #[test] + fn caret_atraviesa_separador_pero_se_queda_en_linea_fundida() { + let (c, atoms) = cuerpo_con_atoms(&["A", "B", "C"]); + let idx = indice(&atoms); + let mut ide = CuerpoIde::from_cuerpo(&c, &idx); + // Fundir junction 0 → línea 1 deja de ser guarda. + ide.fundir_junction(0); + // Click en línea 1: el caret puede quedarse ahí porque es contenido. + ide.set_caret(1, 0); + assert_eq!(ide.caret(), (1, 0)); + // Click en línea 3 (sigue siendo guarda): salta. + ide.set_caret(3, 0); + assert!(ide.caret().0 != 3); + } + + #[test] + fn junction_antes_del_caret_apunta_a_la_correcta() { + let (c, atoms) = cuerpo_con_atoms(&["A", "B", "C"]); + let idx = indice(&atoms); + let mut ide = CuerpoIde::from_cuerpo(&c, &idx); + // En "A" (línea 0): no hay junction previa. + ide.set_caret(0, 0); + assert_eq!(ide.junction_antes_del_caret(), None); + // En "B" (línea 2): la junction previa es la 0. + ide.set_caret(2, 0); + assert_eq!(ide.junction_antes_del_caret(), Some(0)); + // En "C" (línea 4): la junction previa es la 1. + ide.set_caret(4, 0); + assert_eq!(ide.junction_antes_del_caret(), Some(1)); + } + + #[test] + fn linea_de_junction_devuelve_la_linea_vacia_correcta() { + let (c, atoms) = cuerpo_con_atoms(&["A", "B", "C"]); + let idx = indice(&atoms); + let ide = CuerpoIde::from_cuerpo(&c, &idx); + assert_eq!(ide.linea_de_junction(0), Some(1)); + assert_eq!(ide.linea_de_junction(1), Some(3)); + assert_eq!(ide.linea_de_junction(2), None); + } + + #[test] + fn line_tints_asigna_un_color_por_grupo() { + let (c, atoms) = cuerpo_con_atoms(&["A", "B", "C"]); + let idx = indice(&atoms); + let ide = CuerpoIde::from_cuerpo(&c, &idx); + // Líneas: 0="A", 1="", 2="B", 3="", 4="C". Tres grupos. + let t = &ide.state.line_tints; + assert_eq!(t.len(), 5); + // Cada atom-line tiene tinte del grupo correspondiente. + assert_eq!(t[0], Some(color_de_grupo(0))); + assert_eq!(t[2], Some(color_de_grupo(1))); + assert_eq!(t[4], Some(color_de_grupo(2))); + // Guardas (líneas 1 y 3): sin tinte. + assert_eq!(t[1], None); + assert_eq!(t[3], None); + } + + #[test] + fn fundir_junction_unifica_el_color_del_grupo() { + let (c, atoms) = cuerpo_con_atoms(&["A", "B", "C"]); + let idx = indice(&atoms); + let mut ide = CuerpoIde::from_cuerpo(&c, &idx); + // Fundir junction 0 → atoms A+B forman un solo grupo (0). + // Atom C sigue siendo grupo 1. + ide.fundir_junction(0); + let t = &ide.state.line_tints; + assert_eq!(t[0], Some(color_de_grupo(0))); + // Línea 1 deja de ser guarda y hereda el tinte del grupo 0. + assert_eq!(t[1], Some(color_de_grupo(0))); + assert_eq!(t[2], Some(color_de_grupo(0))); + // Junction 1 sigue siendo separador → línea 3 es guarda sin tinte. + assert_eq!(t[3], None); + // Atom C es grupo 1 (no 2, porque se fusionó el primero). + assert_eq!(t[4], Some(color_de_grupo(1))); + } + + #[test] + fn separar_revierte_color_a_grupos_originales() { + let (c, atoms) = cuerpo_con_atoms(&["A", "B"]); + let idx = indice(&atoms); + let mut ide = CuerpoIde::from_cuerpo(&c, &idx); + ide.fundir_junction(0); + // Tras fundir, ambos atoms son grupo 0 (mismo color). + assert_eq!(ide.state.line_tints[0], ide.state.line_tints[2]); + ide.separar_junction(0); + // Tras separar, colores distintos. + assert_ne!(ide.state.line_tints[0], ide.state.line_tints[2]); + } + + #[test] + fn paleta_ciclica_repite_color_pasados_8_grupos() { + // 9 atoms → grupo 0..8 → el último debe compartir color con el primero. + let textos: Vec<&str> = vec!["a", "b", "c", "d", "e", "f", "g", "h", "i"]; + let (c, atoms) = cuerpo_con_atoms(&textos); + let idx = indice(&atoms); + let ide = CuerpoIde::from_cuerpo(&c, &idx); + assert_eq!(ide.state.line_tints[0], ide.state.line_tints[16]); + } + + #[test] + fn n_zonas_arranca_igual_a_n_atoms() { + let (c, atoms) = cuerpo_con_atoms(&["A", "B", "C", "D"]); + let idx = indice(&atoms); + let ide = CuerpoIde::from_cuerpo(&c, &idx); + // Sin junctions fundidas: una zona por atom. + assert_eq!(ide.n_zonas(), 4); + } + + #[test] + fn n_zonas_cae_cuando_fundimos_junctions() { + let (c, atoms) = cuerpo_con_atoms(&["A", "B", "C", "D"]); + let idx = indice(&atoms); + let mut ide = CuerpoIde::from_cuerpo(&c, &idx); + // Fundir junction 0 → A+B forman una zona, C y D solos: total 3. + ide.fundir_junction(0); + assert_eq!(ide.n_zonas(), 3); + // Fundir también junction 2 → C+D se unen: total 2. + ide.fundir_junction(2); + assert_eq!(ide.n_zonas(), 2); + // Fundir la del medio → todo una sola zona. + ide.fundir_junction(1); + assert_eq!(ide.n_zonas(), 1); + } + + #[test] + fn n_zonas_cuerpo_vacio() { + let ide = CuerpoIde::nuevo_vacio(); + assert_eq!(ide.n_zonas(), 0); + } + + #[test] + fn zona_de_atom_idx_mapea_grupos() { + let (c, atoms) = cuerpo_con_atoms(&["A", "B", "C", "D"]); + let idx = indice(&atoms); + let mut ide = CuerpoIde::from_cuerpo(&c, &idx); + ide.fundir_junction(0); // A+B juntos + assert_eq!(ide.zona_de_atom_idx(0), Some(0)); + assert_eq!(ide.zona_de_atom_idx(1), Some(0)); + assert_eq!(ide.zona_de_atom_idx(2), Some(1)); + assert_eq!(ide.zona_de_atom_idx(3), Some(2)); + assert_eq!(ide.zona_de_atom_idx(99), None); + } + + #[test] + fn lineas_de_zona_simple() { + let (c, atoms) = cuerpo_con_atoms(&["A", "B", "C"]); + let idx = indice(&atoms); + let ide = CuerpoIde::from_cuerpo(&c, &idx); + // Líneas: 0="A", 1="", 2="B", 3="", 4="C". + assert_eq!(ide.lineas_de_zona(0), Some((0, 0))); + assert_eq!(ide.lineas_de_zona(1), Some((2, 2))); + assert_eq!(ide.lineas_de_zona(2), Some((4, 4))); + assert_eq!(ide.lineas_de_zona(3), None); + } + + #[test] + fn lineas_de_zona_con_fusion_cubre_atoms_y_junctions() { + let (c, atoms) = cuerpo_con_atoms(&["A", "B", "C"]); + let idx = indice(&atoms); + let mut ide = CuerpoIde::from_cuerpo(&c, &idx); + // Fundir junction 0 → zona 0 = atoms A+B → líneas 0..=2 (incluye + // la línea vacía fundida). + ide.fundir_junction(0); + assert_eq!(ide.lineas_de_zona(0), Some((0, 2))); + // Zona 1 = solo C → línea 4. + assert_eq!(ide.lineas_de_zona(1), Some((4, 4))); + } + + #[test] + fn zona_de_linea_devuelve_none_sobre_guarda() { + let (c, atoms) = cuerpo_con_atoms(&["A", "B"]); + let idx = indice(&atoms); + let ide = CuerpoIde::from_cuerpo(&c, &idx); + // Línea 1 es separador → sin zona. + assert_eq!(ide.zona_de_linea(0), Some(0)); + assert_eq!(ide.zona_de_linea(1), None); + assert_eq!(ide.zona_de_linea(2), Some(1)); + } + + #[test] + fn ir_a_zona_mueve_el_caret() { + let (c, atoms) = cuerpo_con_atoms(&["A", "B", "C"]); + let idx = indice(&atoms); + let mut ide = CuerpoIde::from_cuerpo(&c, &idx); + ide.ir_a_zona(2); + assert_eq!(ide.caret(), (4, 0)); + ide.ir_a_zona(0); + assert_eq!(ide.caret(), (0, 0)); + // Fuera de rango: no-op (caret se queda donde estaba). + ide.ir_a_zona(99); + assert_eq!(ide.caret(), (0, 0)); + } + + #[test] + fn ir_a_zona_siguiente_y_anterior_con_wrap() { + let (c, atoms) = cuerpo_con_atoms(&["A", "B", "C"]); + let idx = indice(&atoms); + let mut ide = CuerpoIde::from_cuerpo(&c, &idx); + // `from_cuerpo` deja el caret al final del texto (convención de + // set_text); plantamos explícitamente en zona 0 para arrancar. + ide.set_caret(0, 0); + ide.ir_a_zona_siguiente(); + assert_eq!(ide.caret(), (2, 0)); + ide.ir_a_zona_siguiente(); + assert_eq!(ide.caret(), (4, 0)); + // Wrap desde zona 2 → zona 0. + ide.ir_a_zona_siguiente(); + assert_eq!(ide.caret(), (0, 0)); + // Anterior desde zona 0 → wrap a la última (línea 4). + ide.ir_a_zona_anterior(); + assert_eq!(ide.caret(), (4, 0)); + } + + #[test] + fn seleccionar_zona_planta_anchor_y_extiende_al_final() { + let (c, atoms) = cuerpo_con_atoms(&["Uno", "Dos", "Tres"]); + let idx = indice(&atoms); + let mut ide = CuerpoIde::from_cuerpo(&c, &idx); + ide.seleccionar_zona(1); + // Anchor en (2, 0), caret en (2, 3) — "Dos" tiene 3 chars. + assert_eq!(ide.caret(), (2, 3)); + assert_eq!(ide.state.selected_text().as_deref(), Some("Dos")); + } + + #[test] + fn seleccionar_zona_fundida_abarca_lineas_intermedias() { + let (c, atoms) = cuerpo_con_atoms(&["Uno", "Dos", "Tres"]); + let idx = indice(&atoms); + let mut ide = CuerpoIde::from_cuerpo(&c, &idx); + // Fundimos 0 → zona 0 = "Uno\n\nDos" (línea vacía es contenido). + ide.fundir_junction(0); + ide.seleccionar_zona(0); + let sel = ide.state.selected_text().unwrap_or_default(); + assert!(sel.starts_with("Uno"), "selección debería empezar con 'Uno': {sel:?}"); + assert!(sel.ends_with("Dos"), "selección debería terminar con 'Dos': {sel:?}"); + } + + #[test] + fn atom_ids_de_zona_devuelve_orden_correcto() { + let (c, atoms) = cuerpo_con_atoms(&["A", "B", "C", "D"]); + let idx = indice(&atoms); + let mut ide = CuerpoIde::from_cuerpo(&c, &idx); + // Sin fusiones: zona 1 = solo atom B (idx 1). + assert_eq!(ide.atom_ids_de_zona(1), Some(vec![atoms[1].id])); + // Tras fundir 0: zona 0 = A+B; zona 1 = C; zona 2 = D. + ide.fundir_junction(0); + assert_eq!( + ide.atom_ids_de_zona(0), + Some(vec![atoms[0].id, atoms[1].id]) + ); + assert_eq!(ide.atom_ids_de_zona(1), Some(vec![atoms[2].id])); + assert_eq!(ide.atom_ids_de_zona(2), Some(vec![atoms[3].id])); + assert_eq!(ide.atom_ids_de_zona(99), None); + } + + #[test] + fn zona_del_caret_sigue_al_caret() { + let (c, atoms) = cuerpo_con_atoms(&["A", "B", "C"]); + let idx = indice(&atoms); + let mut ide = CuerpoIde::from_cuerpo(&c, &idx); + ide.set_caret(0, 0); + assert_eq!(ide.zona_del_caret(), 0); + ide.set_caret(2, 0); + assert_eq!(ide.zona_del_caret(), 1); + ide.set_caret(4, 0); + assert_eq!(ide.zona_del_caret(), 2); + } + + #[test] + fn recargar_resetea_junctions_a_separador() { + let (c1, atoms1) = cuerpo_con_atoms(&["A", "B"]); + let idx1 = indice(&atoms1); + let mut ide = CuerpoIde::from_cuerpo(&c1, &idx1); + ide.fundir_junction(0); + assert_eq!(ide.fundido_junctions, vec![true]); + + let (c2, atoms2) = cuerpo_con_atoms(&["X", "Y", "Z"]); + let idx2 = indice(&atoms2); + ide.recargar(&c2, &idx2); + // El cuerpo nuevo arranca todo como separador. + assert_eq!(ide.fundido_junctions, vec![false, false]); + assert_eq!(ide.state.guard_lines, vec![1, 3]); + } +} diff --git a/00_unanchay/pluma/pluma-editor-llimphi/src/lib.rs b/00_unanchay/pluma/pluma-editor-llimphi/src/lib.rs new file mode 100644 index 0000000..2af186a --- /dev/null +++ b/00_unanchay/pluma/pluma-editor-llimphi/src/lib.rs @@ -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(plan: &RenderPlan, palette: &Palette) -> View { + 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> = Vec::new(); + // Aristas al fondo: las pinta primero, los bloques las tapan al cruzarlas. + for e in &plan.edges { + children.extend(edge_segments::(e, palette.border_strong)); + } + for b in &plan.blocks { + children.push(block_view::(b, palette)); + } + for m in &plan.sidepane { + children.push(mark_view::(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(b: &AtomBlock, palette: &Palette) -> View { + 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( + m: &SidepaneMark, + cfg: &pluma_render_plan::LayoutConfig, +) -> View { + 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(e: &Edge, color: Color) -> Vec> { + 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::( + 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::( + 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::( + 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(x: f32, y: f32, w: f32, h: f32, color: Color) -> View { + 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"); + } +} diff --git a/00_unanchay/pluma/pluma-editor-llimphi/src/multilienzo.rs b/00_unanchay/pluma/pluma-editor-llimphi/src/multilienzo.rs new file mode 100644 index 0000000..8d4e6b4 --- /dev/null +++ b/00_unanchay/pluma/pluma-editor-llimphi/src/multilienzo.rs @@ -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; + +/// 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( + cuerpos: &[&Cuerpo], + atoms: &IndiceAtoms<'_>, + cartas: &[Option<&CartaHebras>], + cfg: &MultilienzoConfig, + paleta_hebras: &PaletaHebras, + palette: &Palette, +) -> View { + multilienzo_view_resaltado::( + 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( + cuerpos: &[&Cuerpo], + atoms: &IndiceAtoms<'_>, + cartas: &[Option<&CartaHebras>], + cfg: &MultilienzoConfig, + paleta_hebras: &PaletaHebras, + palette: &Palette, + resaltar: &str, +) -> View { + armar_multilienzo::( + 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( + cuerpos: &[&Cuerpo], + atoms: &IndiceAtoms<'_>, + cartas: &[Option<&CartaHebras>], + cfg: &MultilienzoConfig, + paleta_hebras: &PaletaHebras, + palette: &Palette, + resaltar: &str, + on_atom_click: F, +) -> View +where + Msg: Clone + 'static, + F: Fn(usize, Uuid) -> Msg, +{ + armar_multilienzo::( + 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` — `None` significa "no +/// cablear `on_click` en ese bloque" (caso no interactivo). +fn armar_multilienzo( + cuerpos: &[&Cuerpo], + atoms: &IndiceAtoms<'_>, + cartas: &[Option<&CartaHebras>], + cfg: &MultilienzoConfig, + paleta_hebras: &PaletaHebras, + palette: &Palette, + resaltar: &str, + on_atom_click: &dyn Fn(usize, Uuid) -> Option, +) -> View { + 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> = Vec::with_capacity(cuerpos.len() * 2 - 1); + for (i, c) in cuerpos.iter().enumerate() { + hijos.push(columna_cuerpo::( + 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::( + 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( + cuerpo: &Cuerpo, + i_cuerpo: usize, + atoms: &IndiceAtoms<'_>, + cfg: &MultilienzoConfig, + palette: &Palette, + alto_total: f32, + resaltar: &str, + on_atom_click: &dyn Fn(usize, Uuid) -> Option, +) -> View { + 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> = 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::(&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( + preview: &str, + y: f32, + cfg: &MultilienzoConfig, + palette: &Palette, + hit_busqueda: bool, + click_msg: Option, +) -> View { + // 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( + izq: &Cuerpo, + der: &Cuerpo, + carta: Option<&CartaHebras>, + cfg: &MultilienzoConfig, + paleta: &PaletaHebras, + _palette: &Palette, + alto_total: f32, +) -> View { + 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 { + let pos_izq: HashMap = izq + .orden + .iter() + .enumerate() + .map(|(i, &id)| (id, i)) + .collect(); + let pos_der: HashMap = 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) { + let mut c = Cuerpo::nuevo(branch, branch, intencion, 100); + let atoms: Vec = 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> = vec![None]; + let cfg = MultilienzoConfig::default(); + let paleta = PaletaHebras::default(); + let palette = Palette::default(); + + let visitas: RefCell> = 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 = 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); + } +} diff --git a/00_unanchay/pluma/pluma-editor-llimphi/src/multilienzo_editor.rs b/00_unanchay/pluma/pluma-editor-llimphi/src/multilienzo_editor.rs new file mode 100644 index 0000000..9dbb443 --- /dev/null +++ b/00_unanchay/pluma/pluma-editor-llimphi/src/multilienzo_editor.rs @@ -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( + 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 +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> = 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( + 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 +where + Msg: Clone + 'static, + FPtr: Fn(PointerEvent) -> Option + 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::( + 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::(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( + izq: &CuerpoIde, + der: &CuerpoIde, + carta: Option<&CartaHebras>, + cfg: &ConfigMultilienzoEditor, + paleta: &PaletaHebras, + metrics: EditorMetrics, +) -> View { + 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( + ide: &CuerpoIde, + metrics: EditorMetrics, + palette_lienzo: &Palette, +) -> View { + 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 { + 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 { + let header = cfg.alto_header; + let y_de_atom = |ide: &CuerpoIde, id: Uuid| -> Option { + 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, CuerpoIde) { + let mut c = Cuerpo::nuevo(branch, branch, intencion, 100); + let atoms: Vec = textos + .iter() + .map(|t| NarrativeAtom::new(*t, branch)) + .collect(); + for a in &atoms { + c.agregar(a.id, 101); + } + let idx: HashMap = 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 = (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); + } +} diff --git a/00_unanchay/pluma/pluma-graph-transform/Cargo.toml b/00_unanchay/pluma/pluma-graph-transform/Cargo.toml new file mode 100644 index 0000000..ae0c4d8 --- /dev/null +++ b/00_unanchay/pluma/pluma-graph-transform/Cargo.toml @@ -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" } diff --git a/00_unanchay/pluma/pluma-graph-transform/LEEME.md b/00_unanchay/pluma/pluma-graph-transform/LEEME.md new file mode 100644 index 0000000..e79f1c4 --- /dev/null +++ b/00_unanchay/pluma/pluma-graph-transform/LEEME.md @@ -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) diff --git a/00_unanchay/pluma/pluma-graph-transform/README.md b/00_unanchay/pluma/pluma-graph-transform/README.md new file mode 100644 index 0000000..91a217e --- /dev/null +++ b/00_unanchay/pluma/pluma-graph-transform/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) diff --git a/00_unanchay/pluma/pluma-graph-transform/src/lib.rs b/00_unanchay/pluma/pluma-graph-transform/src/lib.rs new file mode 100644 index 0000000..082b59e --- /dev/null +++ b/00_unanchay/pluma/pluma-graph-transform/src/lib.rs @@ -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` 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 { + 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 = 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()]); + } +} diff --git a/00_unanchay/pluma/pluma-graph/Cargo.toml b/00_unanchay/pluma/pluma-graph/Cargo.toml new file mode 100644 index 0000000..3753a64 --- /dev/null +++ b/00_unanchay/pluma/pluma-graph/Cargo.toml @@ -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 } diff --git a/00_unanchay/pluma/pluma-graph/LEEME.md b/00_unanchay/pluma/pluma-graph/LEEME.md new file mode 100644 index 0000000..268b5b9 --- /dev/null +++ b/00_unanchay/pluma/pluma-graph/LEEME.md @@ -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` diff --git a/00_unanchay/pluma/pluma-graph/README.md b/00_unanchay/pluma/pluma-graph/README.md new file mode 100644 index 0000000..eb25203 --- /dev/null +++ b/00_unanchay/pluma/pluma-graph/README.md @@ -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` diff --git a/00_unanchay/pluma/pluma-graph/src/lib.rs b/00_unanchay/pluma/pluma-graph/src/lib.rs new file mode 100644 index 0000000..9108b07 --- /dev/null +++ b/00_unanchay/pluma/pluma-graph/src/lib.rs @@ -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, + /// `dependencia → [átomos que dependen de ella]`. + adjacency: HashMap>, +} + +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 { + self.nodes.values() + } + + /// Construye un grafo desde una colección de átomos. + pub fn from_atoms(atoms: impl IntoIterator) -> 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 { + let mut affected = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let mut queue: VecDeque = VecDeque::new(); + queue.push_back(origin); + seen.insert(origin); + + while let Some(current) = queue.pop_front() { + let children: Vec = 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> { + let mut indeg: HashMap = 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 = + 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()); + } +} diff --git a/00_unanchay/pluma/pluma-llm-anthropic/Cargo.toml b/00_unanchay/pluma/pluma-llm-anthropic/Cargo.toml new file mode 100644 index 0000000..50397c9 --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-anthropic/Cargo.toml @@ -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 } diff --git a/00_unanchay/pluma/pluma-llm-anthropic/LEEME.md b/00_unanchay/pluma/pluma-llm-anthropic/LEEME.md new file mode 100644 index 0000000..8bd6f9f --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-anthropic/LEEME.md @@ -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` diff --git a/00_unanchay/pluma/pluma-llm-anthropic/README.md b/00_unanchay/pluma/pluma-llm-anthropic/README.md new file mode 100644 index 0000000..e637615 --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-anthropic/README.md @@ -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` diff --git a/00_unanchay/pluma/pluma-llm-anthropic/src/lib.rs b/00_unanchay/pluma/pluma-llm-anthropic/src/lib.rs new file mode 100644 index 0000000..05b7f63 --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-anthropic/src/lib.rs @@ -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> { +//! // 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 { + 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) -> Result { + 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) -> 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) -> Self { + self.endpoint = endpoint.into(); + self + } + + /// Headers que requiere la Messages API. + fn headers(&self) -> Result { + 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 { + 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::(&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::>() + .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 = 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 = 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, + stop_reason: Option, + usage: Option, +} + +#[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, + cache_creation_input_tokens: Option, +} + +#[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?"); + } +} diff --git a/00_unanchay/pluma/pluma-llm-cohere/Cargo.toml b/00_unanchay/pluma/pluma-llm-cohere/Cargo.toml new file mode 100644 index 0000000..573820f --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-cohere/Cargo.toml @@ -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 } diff --git a/00_unanchay/pluma/pluma-llm-cohere/LEEME.md b/00_unanchay/pluma/pluma-llm-cohere/LEEME.md new file mode 100644 index 0000000..1064210 --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-cohere/LEEME.md @@ -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` diff --git a/00_unanchay/pluma/pluma-llm-cohere/README.md b/00_unanchay/pluma/pluma-llm-cohere/README.md new file mode 100644 index 0000000..02ee589 --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-cohere/README.md @@ -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` diff --git a/00_unanchay/pluma/pluma-llm-cohere/src/lib.rs b/00_unanchay/pluma/pluma-llm-cohere/src/lib.rs new file mode 100644 index 0000000..119de83 --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-cohere/src/lib.rs @@ -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> { +//! // 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 { + 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) -> Result { + 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) -> Self { + self.model = model.into(); + self + } + + /// Cambia el endpoint — útil para proxies internos. + pub fn with_endpoint(mut self, endpoint: impl Into) -> Self { + self.endpoint = endpoint.into(); + self + } + + fn headers(&self) -> Result { + 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 { + 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::(&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::>() + .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 = 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, + #[serde(default)] + usage: Option, +} + +#[derive(Debug, Deserialize)] +struct CohereMessage { + #[serde(default)] + content: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum CohereContentBlock { + Text { text: String }, +} + +#[derive(Debug, Deserialize)] +struct CohereUsage { + #[serde(default)] + tokens: Option, +} + +#[derive(Debug, Deserialize)] +struct CohereTokens { + #[serde(default)] + input_tokens: Option, + #[serde(default)] + output_tokens: Option, +} + +#[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"); + } +} diff --git a/00_unanchay/pluma/pluma-llm-core/Cargo.toml b/00_unanchay/pluma/pluma-llm-core/Cargo.toml new file mode 100644 index 0000000..94e4ab2 --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-core/Cargo.toml @@ -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 } diff --git a/00_unanchay/pluma/pluma-llm-core/LEEME.md b/00_unanchay/pluma/pluma-llm-core/LEEME.md new file mode 100644 index 0000000..eb3f6dd --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-core/LEEME.md @@ -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; + async fn stream(&self, req: ChatRequest) -> Result; +} +``` + +## Deps + +- `serde`, `async-trait`, `futures-core` +- Cero deps de HTTP o providers específicos diff --git a/00_unanchay/pluma/pluma-llm-core/README.md b/00_unanchay/pluma/pluma-llm-core/README.md new file mode 100644 index 0000000..a194114 --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-core/README.md @@ -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; + async fn stream(&self, req: ChatRequest) -> Result; +} +``` + +## Deps + +- `serde`, `async-trait`, `futures-core` +- Zero HTTP / provider-specific deps diff --git a/00_unanchay/pluma/pluma-llm-core/src/lib.rs b/00_unanchay/pluma/pluma-llm-core/src/lib.rs new file mode 100644 index 0000000..85ccf9a --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-core/src/lib.rs @@ -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, data_base64: impl Into) -> 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, 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, +} + +impl ChatMessage { + pub fn user(content: impl Into) -> Self { + Self { + role: Role::User, + content: content.into(), + images: Vec::new(), + } + } + pub fn assistant(content: impl Into) -> 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, images: Vec) -> 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, + /// Conversación. Para una sola request user→assistant, basta + /// `vec![ChatMessage::user(prompt)]`. + pub messages: Vec, + /// 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, 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) -> 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, + /// Contabilidad de tokens, si el proveedor la reporta. + pub usage: Option, +} + +/// 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; +} + +#[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); + } +} diff --git a/00_unanchay/pluma/pluma-llm-gemini/Cargo.toml b/00_unanchay/pluma/pluma-llm-gemini/Cargo.toml new file mode 100644 index 0000000..7f5458a --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-gemini/Cargo.toml @@ -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 } diff --git a/00_unanchay/pluma/pluma-llm-gemini/LEEME.md b/00_unanchay/pluma/pluma-llm-gemini/LEEME.md new file mode 100644 index 0000000..5908e6f --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-gemini/LEEME.md @@ -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` diff --git a/00_unanchay/pluma/pluma-llm-gemini/README.md b/00_unanchay/pluma/pluma-llm-gemini/README.md new file mode 100644 index 0000000..0d571ab --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-gemini/README.md @@ -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` diff --git a/00_unanchay/pluma/pluma-llm-gemini/examples/smoke.rs b/00_unanchay/pluma/pluma-llm-gemini/examples/smoke.rs new file mode 100644 index 0000000..91e609c --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-gemini/examples/smoke.rs @@ -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> { + 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(()) +} diff --git a/00_unanchay/pluma/pluma-llm-gemini/src/lib.rs b/00_unanchay/pluma/pluma-llm-gemini/src/lib.rs new file mode 100644 index 0000000..fffae4a --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-gemini/src/lib.rs @@ -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> { +//! // 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 { + 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) -> Result { + 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) -> 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) -> 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 { + 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 { + 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::(&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::>() + .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 = 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 = + 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, + #[serde(default)] + usage_metadata: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GeminiCandidate { + #[serde(default)] + content: Option, + #[serde(default)] + finish_reason: Option, +} + +#[derive(Debug, Deserialize)] +struct GeminiContent { + #[serde(default)] + parts: Vec, +} + +#[derive(Debug, Deserialize)] +struct GeminiPart { + #[serde(default)] + text: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GeminiUsage { + #[serde(default)] + prompt_token_count: Option, + #[serde(default)] + candidates_token_count: Option, + #[serde(default)] + cached_content_token_count: Option, +} + +#[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)); + } +} diff --git a/00_unanchay/pluma/pluma-llm-mock/Cargo.toml b/00_unanchay/pluma/pluma-llm-mock/Cargo.toml new file mode 100644 index 0000000..c28c4c6 --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-mock/Cargo.toml @@ -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 } diff --git a/00_unanchay/pluma/pluma-llm-mock/LEEME.md b/00_unanchay/pluma/pluma-llm-mock/LEEME.md new file mode 100644 index 0000000..9a2ad03 --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-mock/LEEME.md @@ -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 diff --git a/00_unanchay/pluma/pluma-llm-mock/README.md b/00_unanchay/pluma/pluma-llm-mock/README.md new file mode 100644 index 0000000..9158c60 --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-mock/README.md @@ -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 diff --git a/00_unanchay/pluma/pluma-llm-mock/src/lib.rs b/00_unanchay/pluma/pluma-llm-mock/src/lib.rs new file mode 100644 index 0000000..937c4c2 --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-mock/src/lib.rs @@ -0,0 +1,238 @@ +//! `pluma-llm-mock` — backend LLM determinista para tests. +//! +//! No habla con ningún servicio. Tiene dos modos: +//! +//! 1. **Tabla**: el caller registra `(prompt_substr, respuesta)` y el +//! mock devuelve la respuesta cuyo `prompt_substr` aparece primero en +//! el último `ChatMessage::user` de la request. Útil cuando se sabe +//! qué prompts esperar (tests de integración de `pluma-transform-llm`). +//! +//! 2. **Eco**: si nada coincide, devuelve la última request del usuario +//! prefijada con un string configurable (default `"mock:: "`). Eso +//! permite que un test que olvidó preparar la tabla aún produzca +//! salida razonable y deterministica. +//! +//! No simula latencia, no falla aleatoriamente, no consume tokens. La +//! contabilidad reportada en `ChatUsage` es siempre 0. + +#![forbid(unsafe_code)] + +use async_trait::async_trait; +use pluma_llm_core::{ + ChatClient, ChatError, ChatRequest, ChatResponse, ChatUsage, Role, StopReason, +}; + +/// Cliente LLM mock. Cero red, cero latencia, salida deterministica. +pub struct MockChatClient { + /// Reglas `(donde, substring, respuesta)`. La primera que matchee + /// gana. Orden importa. + tabla: Vec<(Donde, String, String)>, + /// Prefijo del eco para prompts no cubiertos por la tabla. + eco_prefix: String, + /// `model_id` reportado — útil para distinguir varios mocks en una + /// suite de tests. + model_id: String, +} + +/// Dónde buscar el `substring` de una regla del mock. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Donde { + /// En el último mensaje `user` del request (comportamiento típico). + User, + /// En el `system` prompt — útil para que dos celdas con el mismo + /// user pero distinto system reciban respuestas distintas (el caso + /// canónico de `pluma-notebook-kernel-llm`). + System, +} + +impl Default for MockChatClient { + fn default() -> Self { + Self { + tabla: Vec::new(), + eco_prefix: "mock:: ".to_string(), + model_id: "pluma-llm-mock".to_string(), + } + } +} + +impl MockChatClient { + /// Constructor encadenable: registra un par. `substring` se busca en + /// el último `ChatMessage::user` del request — el primero que matchee + /// gana, en orden de registro. + pub fn con_respuesta( + mut self, + substring: impl Into, + respuesta: impl Into, + ) -> Self { + self.tabla + .push((Donde::User, substring.into(), respuesta.into())); + self + } + + /// Como [`Self::con_respuesta`] pero matchea contra el `system` + /// prompt en lugar del último user. Útil cuando distintas celdas/ + /// transformaciones mandan el MISMO user pero distinto system + /// (caso típico del `LlmKernel` del notebook: `llm-traducir-qu` y + /// `llm-tono-formal` aplicadas al mismo texto comparten user pero + /// difieren en el system "Eres traductor…" vs "Reescribes en tono…"). + pub fn con_respuesta_si_system( + mut self, + substring_system: impl Into, + respuesta: impl Into, + ) -> Self { + self.tabla + .push((Donde::System, substring_system.into(), respuesta.into())); + self + } + + /// Cambia el prefijo de eco — útil para que un test vea de qué + /// mock viene la salida cuando hay varios. + pub fn con_eco_prefix(mut self, prefix: impl Into) -> Self { + self.eco_prefix = prefix.into(); + self + } + + /// Anota un `model_id` distinto del default. + pub fn con_model_id(mut self, id: impl Into) -> Self { + self.model_id = id.into(); + self + } + + /// Devuelve el último mensaje user del request, o `""` si no hay. + /// Helper para tests que quieran inspeccionar qué se le pidió. + fn ultimo_user(req: &ChatRequest) -> &str { + req.messages + .iter() + .rev() + .find(|m| m.role == Role::User) + .map(|m| m.content.as_str()) + .unwrap_or("") + } +} + +#[async_trait] +impl ChatClient for MockChatClient { + fn model_id(&self) -> &str { + &self.model_id + } + + async fn complete(&self, req: &ChatRequest) -> Result { + let prompt = Self::ultimo_user(req); + let system = req.system.as_deref().unwrap_or(""); + // Buscar coincidencia en la tabla. El primero que matchee gana, + // independientemente de si la regla es User o System. + let content = self + .tabla + .iter() + .find(|(donde, needle, _)| match donde { + Donde::User => prompt.contains(needle.as_str()), + Donde::System => system.contains(needle.as_str()), + }) + .map(|(_, _, resp)| resp.clone()) + .unwrap_or_else(|| format!("{}{prompt}", self.eco_prefix)); + + Ok(ChatResponse { + content, + stop_reason: Some(StopReason("mock_end".to_string())), + usage: Some(ChatUsage { + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }), + }) + } +} + +#[cfg(test)] +mod pruebas { + use super::*; + use pluma_llm_core::ChatMessage; + + fn rt() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + } + + #[test] + fn tabla_gana_sobre_eco() { + let cli = MockChatClient::default() + .con_respuesta("traducir", "TRADUCCIÓN_DUMMY"); + let req = ChatRequest::una_vuelta("por favor traducir esto al qu", 50); + let resp = rt().block_on(cli.complete(&req)).unwrap(); + assert_eq!(resp.content, "TRADUCCIÓN_DUMMY"); + } + + #[test] + fn eco_cae_cuando_no_hay_coincidencia() { + let cli = MockChatClient::default(); + let req = ChatRequest::una_vuelta("hola mundo", 50); + let resp = rt().block_on(cli.complete(&req)).unwrap(); + assert_eq!(resp.content, "mock:: hola mundo"); + } + + #[test] + fn primer_match_de_la_tabla_gana() { + let cli = MockChatClient::default() + .con_respuesta("alfa", "PRIMERO") + .con_respuesta("beta", "SEGUNDO"); + let req = ChatRequest::una_vuelta("alfa beta", 50); + let resp = rt().block_on(cli.complete(&req)).unwrap(); + assert_eq!(resp.content, "PRIMERO"); + } + + #[test] + fn model_id_y_eco_prefix_configurables() { + let cli = MockChatClient::default() + .con_model_id("test-2") + .con_eco_prefix("[ECO] "); + assert_eq!(cli.model_id(), "test-2"); + let req = ChatRequest::una_vuelta("xyz", 1); + let resp = rt().block_on(cli.complete(&req)).unwrap(); + assert_eq!(resp.content, "[ECO] xyz"); + } + + #[test] + fn con_respuesta_si_system_diferencia_acciones_con_mismo_user() { + // Caso típico LlmKernel: dos celdas mandan el mismo texto user + // pero distinto system. La regla por system los distingue. + let cli = MockChatClient::default() + .con_respuesta_si_system("traductor", "TRAD") + .con_respuesta_si_system("tono", "TONO"); + let user = "el mismo texto"; + + let req_trad = ChatRequest::una_vuelta(user, 50) + .con_sistema("Eres un traductor profesional."); + let r1 = rt().block_on(cli.complete(&req_trad)).unwrap(); + assert_eq!(r1.content, "TRAD"); + + let req_tono = ChatRequest::una_vuelta(user, 50) + .con_sistema("Reescribes con tono formal."); + let r2 = rt().block_on(cli.complete(&req_tono)).unwrap(); + assert_eq!(r2.content, "TONO"); + + // Sin system que matchee → eco. + let req_libre = ChatRequest::una_vuelta(user, 50); + let r3 = rt().block_on(cli.complete(&req_libre)).unwrap(); + assert_eq!(r3.content, "mock:: el mismo texto"); + } + + #[test] + fn usa_el_ultimo_mensaje_user_aunque_haya_assistant_intercalado() { + let cli = MockChatClient::default().con_respuesta("DOS", "B"); + let req = ChatRequest { + system: None, + max_tokens: 10, + temperature: 0.0, + messages: vec![ + ChatMessage::user("UNO"), + ChatMessage::assistant("ASS"), + ChatMessage::user("DOS"), + ], + }; + let resp = rt().block_on(cli.complete(&req)).unwrap(); + assert_eq!(resp.content, "B"); + } +} diff --git a/00_unanchay/pluma/pluma-llm-openai-compatible/Cargo.toml b/00_unanchay/pluma/pluma-llm-openai-compatible/Cargo.toml new file mode 100644 index 0000000..9fcd3f9 --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-openai-compatible/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "pluma-llm-openai-compatible" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "pluma — backend de pluma-llm-core contra cualquier servicio con la shape OpenAI Chat Completions (`/chat/completions`). Cubre DeepSeek, Ollama en modo OpenAI-compat, Groq, Together, vLLM, y cualquier proxy compatible. Presets explícitos para los dos casos típicos en gioser: DeepSeek remoto y Ollama local." + +[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 } diff --git a/00_unanchay/pluma/pluma-llm-openai-compatible/LEEME.md b/00_unanchay/pluma/pluma-llm-openai-compatible/LEEME.md new file mode 100644 index 0000000..43c5cb5 --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-openai-compatible/LEEME.md @@ -0,0 +1,18 @@ +# pluma-llm-openai-compatible + +> Backend genérico OpenAI-compatible para [pluma](../README.md). + +Cualquier endpoint con la API de OpenAI funciona: **OpenAI**, **DeepSeek**, **Ollama**, **vLLM**, **Together**, proxies y self-hosted. Config explícita: `endpoint` + `api_key` (opcional, según el proveedor) + `model` (default depende del backend). + +## API + +```rust +use pluma_llm_openai_compatible::OpenAiClient; + +let chat = OpenAiClient::new("https://api.deepseek.com", api_key, "deepseek-chat"); +``` + +## Deps + +- [`pluma-llm-core`](../pluma-llm-core/README.md) +- `reqwest`, `serde_json`, `eventsource-stream` diff --git a/00_unanchay/pluma/pluma-llm-openai-compatible/README.md b/00_unanchay/pluma/pluma-llm-openai-compatible/README.md new file mode 100644 index 0000000..de252a4 --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-openai-compatible/README.md @@ -0,0 +1,18 @@ +# pluma-llm-openai-compatible + +> Generic OpenAI-compatible backend for [pluma](../README.md). + +Any OpenAI-API endpoint works: **OpenAI**, **DeepSeek**, **Ollama**, **vLLM**, **Together**, proxies and self-hosted. Explicit config: `endpoint` + `api_key` (optional, depending on provider) + `model` (default depends on backend). + +## API + +```rust +use pluma_llm_openai_compatible::OpenAiClient; + +let chat = OpenAiClient::new("https://api.deepseek.com", api_key, "deepseek-chat"); +``` + +## Deps + +- [`pluma-llm-core`](../pluma-llm-core/README.md) +- `reqwest`, `serde_json`, `eventsource-stream` diff --git a/00_unanchay/pluma/pluma-llm-openai-compatible/src/lib.rs b/00_unanchay/pluma/pluma-llm-openai-compatible/src/lib.rs new file mode 100644 index 0000000..d592550 --- /dev/null +++ b/00_unanchay/pluma/pluma-llm-openai-compatible/src/lib.rs @@ -0,0 +1,352 @@ +//! `pluma-llm-openai-compatible` — adapter contra cualquier servicio que +//! hable la shape OpenAI Chat Completions. +//! +//! La mayoría de los proveedores que NO son Anthropic ni Gemini hablan +//! esta forma: DeepSeek, Groq, Together, vLLM self-hosted, Ollama en +//! modo `/v1/chat/completions`. Una sola implementación los cubre todos +//! por configuración de endpoint + api_key + modelo. +//! +//! ## Presets +//! +//! ```no_run +//! # use pluma_llm_openai_compatible::OpenAiCompatibleClient; +//! # fn run() -> Result<(), Box> { +//! // DeepSeek remoto (lee DEEPSEEK_API_KEY). +//! let cli = OpenAiCompatibleClient::deepseek_from_env()?; +//! +//! // Ollama corriendo en localhost — sin auth. +//! let cli = OpenAiCompatibleClient::ollama_local("llama3.1"); +//! +//! // Custom: cualquier endpoint + opcionalmente sin auth (local) o con bearer. +//! let cli = OpenAiCompatibleClient::custom("http://10.0.0.5:8000/v1/chat/completions", None, "qwen2.5"); +//! # Ok(()) } +//! ``` +//! +//! ## Shape del wire +//! +//! Request: +//! ```json +//! { "model": "...", "messages": [{"role":"system","content":"..."}, +//! {"role":"user","content":"..."}], "max_tokens": 1024, "temperature": 0.2 } +//! ``` +//! +//! Response: +//! ```json +//! { "choices": [{"message":{"role":"assistant","content":"..."}, +//! "finish_reason":"stop"}], "usage":{"prompt_tokens":N,"completion_tokens":M} } +//! ``` +//! +//! El system prompt va como primer `ChatMessage` con role=`system` — +//! distinto de Anthropic donde es un campo top-level. La conversión la +//! hace `construir_payload`. + +#![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; + +// Endpoints + variables de entorno conocidos. +const DEEPSEEK_ENDPOINT: &str = "https://api.deepseek.com/chat/completions"; +const DEEPSEEK_ENV: &str = "DEEPSEEK_API_KEY"; +const DEEPSEEK_MODEL_DEFAULT: &str = "deepseek-chat"; +const OLLAMA_ENDPOINT_DEFAULT: &str = "http://localhost:11434/v1/chat/completions"; + +/// Cliente para cualquier endpoint OpenAI-compatible. `api_key` es +/// `Option` porque algunos servicios locales (Ollama) no la +/// piden — un `None` simplemente omite el header `Authorization`. +pub struct OpenAiCompatibleClient { + http: reqwest::Client, + endpoint: String, + api_key: Option, + model: String, +} + +impl OpenAiCompatibleClient { + /// Constructor general: endpoint + api_key opcional + modelo. + pub fn custom( + endpoint: impl Into, + api_key: Option, + model: impl Into, + ) -> Self { + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(TIMEOUT_DEFAULT_SECS)) + .build() + .expect("reqwest client"); + Self { + http, + endpoint: endpoint.into(), + api_key, + model: model.into(), + } + } + + /// Preset: DeepSeek con `DEEPSEEK_API_KEY` y modelo `deepseek-chat`. + /// Para `deepseek-reasoner` u otro, encadenar `.with_model(...)`. + pub fn deepseek_from_env() -> Result { + let api_key = std::env::var(DEEPSEEK_ENV) + .map_err(|_| ChatError::AuthMissing(DEEPSEEK_ENV.to_string()))?; + Ok(Self::custom(DEEPSEEK_ENDPOINT, Some(api_key), DEEPSEEK_MODEL_DEFAULT)) + } + + /// Preset: Ollama en localhost, modo OpenAI-compatible. No requiere + /// API key. El `model` debe estar pulled previamente + /// (`ollama pull llama3.1`). Para Ollama en otra máquina o puerto, + /// usar `custom` con la URL completa. + pub fn ollama_local(model: impl Into) -> Self { + Self::custom(OLLAMA_ENDPOINT_DEFAULT, None, model) + } + + /// Encadenable: cambia el modelo. + pub fn with_model(mut self, model: impl Into) -> Self { + self.model = model.into(); + self + } + + /// Encadenable: ajusta el timeout HTTP. Útil para modelos locales + /// grandes (Ollama con llama3.1 70B puede tardar varios minutos por + /// request en CPU). + pub fn with_timeout(mut self, t: Duration) -> Self { + self.http = reqwest::Client::builder() + .timeout(t) + .build() + .expect("reqwest client"); + self + } + + fn headers(&self) -> Result { + let mut h = HeaderMap::new(); + h.insert("content-type", HeaderValue::from_static("application/json")); + if let Some(key) = &self.api_key { + let val = HeaderValue::from_str(&format!("Bearer {key}")) + .map_err(|_| ChatError::Backend("api key con bytes inválidos".to_string()))?; + h.insert("authorization", val); + } + Ok(h) + } +} + +#[async_trait] +impl ChatClient for OpenAiCompatibleClient { + fn model_id(&self) -> &str { + &self.model + } + + async fn complete(&self, req: &ChatRequest) -> Result { + 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 chat/completions: {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() { + // Algunos servicios devuelven `{"error":{"message":...}}`; + // otros texto plano. Probamos JSON primero, caemos a string. + let mensaje = match serde_json::from_slice::(&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: OpenAiChatResponse = serde_json::from_slice(&body_bytes) + .map_err(|e| ChatError::Backend(format!("parseo response: {e}")))?; + + // Concatenar el contenido del primer choice. Servicios bien + // comportados devuelven 1 choice; si vinieran más, los ignoramos + // (el contrato de `ChatResponse` es UNA respuesta). + let primer = parsed.choices.into_iter().next().ok_or_else(|| { + ChatError::Backend("response sin choices".to_string()) + })?; + let content = primer.message.content.unwrap_or_default(); + let stop_reason = primer.finish_reason.map(StopReason); + + // Algunos servicios (DeepSeek) reportan tokens cacheados en + // `prompt_tokens_details.cached_tokens` o `prompt_cache_hit_tokens`. + // Aceptamos ambos campos por compat. + let usage = parsed.usage.map(|u| { + let cached = u + .prompt_cache_hit_tokens + .or(u.prompt_tokens_details.and_then(|d| d.cached_tokens)) + .unwrap_or(0); + ChatUsage { + input_tokens: u.prompt_tokens.unwrap_or(0), + output_tokens: u.completion_tokens.unwrap_or(0), + cache_read_input_tokens: cached, + // OpenAI-shape no expone cache_creation separado. + cache_creation_input_tokens: 0, + } + }); + + Ok(ChatResponse { + content, + stop_reason, + usage, + }) + } +} + +/// Traduce un `ChatRequest` a la shape OpenAI. El `system` se pone como +/// primer mensaje con role=system; el orden user/assistant se conserva +/// tal cual. +fn construir_payload(req: &ChatRequest, modelo: &str) -> serde_json::Value { + let mut mensajes: Vec = 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 OpenAI-compatible -------- + +#[derive(Debug, Deserialize)] +struct OpenAiChatResponse { + choices: Vec, + #[serde(default)] + usage: Option, +} + +#[derive(Debug, Deserialize)] +struct OpenAiChoice { + message: OpenAiMessageOut, + #[serde(default)] + finish_reason: Option, +} + +#[derive(Debug, Deserialize)] +struct OpenAiMessageOut { + #[serde(default)] + content: Option, +} + +#[derive(Debug, Deserialize)] +struct OpenAiUsage { + #[serde(default)] + prompt_tokens: Option, + #[serde(default)] + completion_tokens: Option, + /// Campo de DeepSeek y otros que reportan caching del input directo. + #[serde(default)] + prompt_cache_hit_tokens: Option, + /// Forma alternativa (OpenAI moderna, vLLM): `prompt_tokens_details.cached_tokens`. + #[serde(default)] + prompt_tokens_details: Option, +} + +#[derive(Debug, Deserialize)] +struct PromptTokensDetails { + #[serde(default)] + cached_tokens: Option, +} + +#[derive(Debug, Deserialize)] +struct OpenAiErrorEnvelope { + error: OpenAiErrorBody, +} + +#[derive(Debug, Deserialize)] +struct OpenAiErrorBody { + #[serde(default)] + message: String, +} + +#[cfg(test)] +mod pruebas { + use super::*; + use pluma_llm_core::ChatMessage; + + #[test] + fn payload_sin_system_solo_pone_user() { + let req = ChatRequest::una_vuelta("hola", 50); + let p = construir_payload(&req, "deepseek-chat"); + assert_eq!(p["model"], "deepseek-chat"); + assert_eq!(p["messages"].as_array().unwrap().len(), 1); + assert_eq!(p["messages"][0]["role"], "user"); + assert_eq!(p["messages"][0]["content"], "hola"); + } + + #[test] + fn payload_con_system_inserta_primer_mensaje_system() { + let req = ChatRequest::una_vuelta("hola", 50) + .con_sistema("Eres un asistente."); + let p = construir_payload(&req, "deepseek-chat"); + let msgs = p["messages"].as_array().unwrap(); + assert_eq!(msgs.len(), 2); + assert_eq!(msgs[0]["role"], "system"); + assert_eq!(msgs[0]["content"], "Eres un asistente."); + assert_eq!(msgs[1]["role"], "user"); + } + + #[test] + fn presets_construyen_endpoints_y_modelos_esperados() { + let ollama = OpenAiCompatibleClient::ollama_local("llama3.1"); + assert_eq!(ollama.endpoint, OLLAMA_ENDPOINT_DEFAULT); + assert_eq!(ollama.model, "llama3.1"); + assert!(ollama.api_key.is_none()); + + let custom = OpenAiCompatibleClient::custom( + "http://x/v1/chat/completions", + Some("k".into()), + "qwen2.5", + ); + assert_eq!(custom.model, "qwen2.5"); + assert_eq!(custom.api_key.as_deref(), Some("k")); + } + + #[test] + fn with_model_encadena() { + let cli = OpenAiCompatibleClient::ollama_local("a") + .with_model("b"); + assert_eq!(cli.model_id(), "b"); + } + + #[test] + fn roles_se_mapean_user_assistant() { + let req = ChatRequest { + system: None, + max_tokens: 10, + temperature: 0.0, + messages: vec![ + ChatMessage::user("U"), + ChatMessage::assistant("A"), + ], + }; + let p = construir_payload(&req, "m"); + assert_eq!(p["messages"][0]["role"], "user"); + assert_eq!(p["messages"][1]["role"], "assistant"); + } +} diff --git a/00_unanchay/pluma/pluma-llm/Cargo.toml b/00_unanchay/pluma/pluma-llm/Cargo.toml new file mode 100644 index 0000000..c8e4528 --- /dev/null +++ b/00_unanchay/pluma/pluma-llm/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "pluma-llm" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "pluma — fachada transparente del stack LLM: una sola config (LlmConfig) construye un Arc contra Anthropic, Gemini, DeepSeek, Ollama o Mock. Los consumidores reciben un trait, no un backend concreto." + +[dependencies] +serde = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } +pluma-llm-core = { path = "../pluma-llm-core" } +pluma-llm-mock = { path = "../pluma-llm-mock" } +pluma-llm-anthropic = { path = "../pluma-llm-anthropic" } +pluma-llm-cohere = { path = "../pluma-llm-cohere" } +pluma-llm-gemini = { path = "../pluma-llm-gemini" } +pluma-llm-openai-compatible = { path = "../pluma-llm-openai-compatible" } + +[dev-dependencies] +tokio = { workspace = true } +serde_json = { workspace = true } diff --git a/00_unanchay/pluma/pluma-llm/LEEME.md b/00_unanchay/pluma/pluma-llm/LEEME.md new file mode 100644 index 0000000..f378dea --- /dev/null +++ b/00_unanchay/pluma/pluma-llm/LEEME.md @@ -0,0 +1,20 @@ +# pluma-llm + +> Fachada `Arc` con autodetect para [pluma](../README.md). + +`LlmConfig { kind, model?, api_key?, endpoint? }` + `build_client(&cfg) -> Arc`. Cinco backends: Anthropic, Gemini, Cohere, OpenAI-compatible (DeepSeek/Ollama/proxies), Mock. `from_env()` autodetecta por `PLUMA_LLM_BACKEND` o por la primera env key presente; fallback final `Mock` para que el proceso jamás falle por credenciales ausentes. + +`LlmConfig` (de)serializable JSON/TOML — apto para config files de apps. + +## API + +```rust +use pluma_llm::{build_client, LlmConfig}; + +let chat = build_client(&LlmConfig::from_env()?); +let resp = chat.send(messages).await?; +``` + +## Deps + +- [`pluma-llm-core`](../pluma-llm-core/README.md) + backends opcionales diff --git a/00_unanchay/pluma/pluma-llm/README.md b/00_unanchay/pluma/pluma-llm/README.md new file mode 100644 index 0000000..392bcf9 --- /dev/null +++ b/00_unanchay/pluma/pluma-llm/README.md @@ -0,0 +1,20 @@ +# pluma-llm + +> `Arc` facade with autodetect for [pluma](../README.md). + +`LlmConfig { kind, model?, api_key?, endpoint? }` + `build_client(&cfg) -> Arc`. Five backends: Anthropic, Gemini, Cohere, OpenAI-compatible (DeepSeek/Ollama/proxies), Mock. `from_env()` autodetects via `PLUMA_LLM_BACKEND` or the first present env key; final `Mock` fallback so the process never fails over missing credentials. + +`LlmConfig` is (de)serializable as JSON/TOML — apt for app config files. + +## API + +```rust +use pluma_llm::{build_client, LlmConfig}; + +let chat = build_client(&LlmConfig::from_env()?); +let resp = chat.send(messages).await?; +``` + +## Deps + +- [`pluma-llm-core`](../pluma-llm-core/README.md) + optional backends diff --git a/00_unanchay/pluma/pluma-llm/src/lib.rs b/00_unanchay/pluma/pluma-llm/src/lib.rs new file mode 100644 index 0000000..637414c --- /dev/null +++ b/00_unanchay/pluma/pluma-llm/src/lib.rs @@ -0,0 +1,329 @@ +//! `pluma-llm` — fachada transparente sobre el stack LLM. +//! +//! Un solo punto de entrada: el caller declara qué backend quiere +//! (Anthropic, Gemini, DeepSeek, Ollama, Mock), opcionalmente el modelo +//! y la API key (si no, se lee de env), y recibe un `Arc`. +//! Desde ese momento el caller habla solo con el trait — no importa cuál +//! IA esté detrás. +//! +//! ## Ejemplo end-to-end +//! +//! ```no_run +//! # use pluma_llm::{build_client, BackendKind, LlmConfig}; +//! # use pluma_llm_core::{ChatClient, ChatRequest}; +//! # async fn run() -> Result<(), Box> { +//! let cli = build_client(&LlmConfig { +//! kind: BackendKind::Gemini, +//! model: None, // default por backend +//! api_key: None, // lee env +//! endpoint: None, // default +//! })?; +//! let resp = cli.complete(&ChatRequest::una_vuelta("hola", 50)).await?; +//! println!("{}", resp.content); +//! # Ok(()) } +//! ``` +//! +//! Cambiar de IA es UNA línea: `kind: BackendKind::Anthropic`. Cero +//! cambios en el resto del código del consumidor. +//! +//! ## Variables de entorno reconocidas +//! +//! - **Anthropic**: `ANTHROPIC_API_KEY` · default `claude-sonnet-4-6`. +//! - **Gemini**: `GEMINI_API_KEY` o `GOOGLE_API_KEY` · default `gemini-2.5-flash`. +//! - **DeepSeek**: `DEEPSEEK_API_KEY` · default `deepseek-chat`. +//! - **Ollama**: sin key · endpoint default `http://localhost:11434/v1/chat/completions` +//! · `model` REQUERIDO (el caller dice qué tag pulled usar). +//! - **Mock**: sin key, sin red, eco determinista para tests. +//! +//! ## Selección por env +//! +//! [`from_env`] elige backend automático según `PLUMA_LLM_BACKEND`: +//! `"anthropic" | "gemini" | "deepseek" | "ollama" | "mock"`. Default si +//! la variable no está: el primer backend cuyo env de API esté presente, +//! en orden Anthropic → Gemini → DeepSeek, con fallback final a Mock. +//! Quien quiera más control, llama directo a [`build_client`]. + +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use pluma_llm_core::{ChatClient, ChatError}; + +pub use pluma_llm_core; // re-export para que el caller solo dependa de este crate + +/// Identidad del backend LLM concreto que se va a instanciar. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum BackendKind { + Anthropic, + Gemini, + DeepSeek, + Cohere, + Ollama, + Mock, +} + +impl BackendKind { + /// Parsea una etiqueta string (case-insensitive). Útil para CLIs y + /// config en archivos. + pub fn parse(s: &str) -> Option { + match s.to_lowercase().as_str() { + "anthropic" => Some(BackendKind::Anthropic), + "gemini" | "google" => Some(BackendKind::Gemini), + "deepseek" => Some(BackendKind::DeepSeek), + "cohere" => Some(BackendKind::Cohere), + "ollama" => Some(BackendKind::Ollama), + "mock" => Some(BackendKind::Mock), + _ => None, + } + } +} + +/// Configuración de un backend. Campos opcionales caen a defaults +/// razonables por backend. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LlmConfig { + pub kind: BackendKind, + /// Modelo concreto. `None` = default del backend (ver doc del crate). + /// Para Ollama, NO hay default — se exige modelo explícito. + #[serde(default)] + pub model: Option, + /// API key. `None` = lee del env. Anthropic y los demás backends + /// remotos exigen una. Ollama y Mock la ignoran. + #[serde(default)] + pub api_key: Option, + /// Endpoint custom. `None` = default del backend. Útil para proxies + /// internos o servicios self-hosted. + #[serde(default)] + pub endpoint: Option, +} + +impl Default for BackendKind { + fn default() -> Self { + BackendKind::Mock + } +} + +/// Error de configuración o de instanciación del backend. +#[derive(Debug, Error)] +pub enum BuildError { + /// Algo del [`ChatClient`] concreto falló al construir (typically + /// `AuthMissing` cuando la env var no está). + #[error("inicialización del backend falló: {0}")] + Chat(#[from] ChatError), + /// Configuración incompleta — p.ej. Ollama sin `model`. + #[error("config incompleta: {0}")] + Config(String), +} + +/// Construye un cliente concreto según `cfg`. Devuelve un +/// `Arc` — el caller habla solo con el trait y puede +/// cambiar de backend cambiando UNA variante del enum. +pub fn build_client(cfg: &LlmConfig) -> Result, BuildError> { + match cfg.kind { + BackendKind::Anthropic => { + let mut cli = match &cfg.api_key { + Some(k) => pluma_llm_anthropic::AnthropicClient::with_api_key(k.clone())?, + None => pluma_llm_anthropic::AnthropicClient::from_env()?, + }; + if let Some(m) = &cfg.model { + cli = cli.with_model(m.clone()); + } + if let Some(ep) = &cfg.endpoint { + cli = cli.with_endpoint(ep.clone()); + } + Ok(Arc::new(cli)) + } + BackendKind::Gemini => { + let mut cli = match &cfg.api_key { + Some(k) => pluma_llm_gemini::GeminiClient::with_api_key(k.clone())?, + None => pluma_llm_gemini::GeminiClient::from_env()?, + }; + if let Some(m) = &cfg.model { + cli = cli.with_model(m.clone()); + } + if let Some(ep) = &cfg.endpoint { + cli = cli.with_endpoint_base(ep.clone()); + } + Ok(Arc::new(cli)) + } + BackendKind::DeepSeek => { + let cli = match &cfg.api_key { + Some(k) => { + let endpoint = cfg + .endpoint + .clone() + .unwrap_or_else(|| "https://api.deepseek.com/chat/completions".into()); + let model = cfg.model.clone().unwrap_or_else(|| "deepseek-chat".into()); + pluma_llm_openai_compatible::OpenAiCompatibleClient::custom( + endpoint, + Some(k.clone()), + model, + ) + } + None => { + let mut cli = + pluma_llm_openai_compatible::OpenAiCompatibleClient::deepseek_from_env()?; + if let Some(m) = &cfg.model { + cli = cli.with_model(m.clone()); + } + cli + } + }; + Ok(Arc::new(cli)) + } + BackendKind::Cohere => { + let mut cli = match &cfg.api_key { + Some(k) => pluma_llm_cohere::CohereClient::with_api_key(k.clone())?, + None => pluma_llm_cohere::CohereClient::from_env()?, + }; + if let Some(m) = &cfg.model { + cli = cli.with_model(m.clone()); + } + if let Some(ep) = &cfg.endpoint { + cli = cli.with_endpoint(ep.clone()); + } + Ok(Arc::new(cli)) + } + BackendKind::Ollama => { + let model = cfg.model.clone().ok_or_else(|| { + BuildError::Config( + "Ollama exige `model` explícito (p.ej. \"llama3.1\", \"qwen2.5\") — sin default seguro".into(), + ) + })?; + let cli = if let Some(ep) = &cfg.endpoint { + pluma_llm_openai_compatible::OpenAiCompatibleClient::custom( + ep.clone(), + None, + model, + ) + } else { + pluma_llm_openai_compatible::OpenAiCompatibleClient::ollama_local(model) + }; + Ok(Arc::new(cli)) + } + BackendKind::Mock => Ok(Arc::new(pluma_llm_mock::MockChatClient::default())), + } +} + +/// Elige backend según `PLUMA_LLM_BACKEND`. Si la variable no está, +/// detecta automáticamente: usa el primer backend cuyo env de API esté +/// definido, en orden Anthropic → Gemini → DeepSeek. Fallback final: +/// Mock (deterministic, sin red). +/// +/// El modelo (`PLUMA_LLM_MODEL`) y endpoint (`PLUMA_LLM_ENDPOINT`) +/// también pueden venir por env — útil para CI/sandbox sin tocar código. +pub fn from_env() -> Result, BuildError> { + let kind = std::env::var("PLUMA_LLM_BACKEND") + .ok() + .and_then(|s| BackendKind::parse(&s)) + .unwrap_or_else(detectar_backend_por_env); + let model = std::env::var("PLUMA_LLM_MODEL").ok(); + let endpoint = std::env::var("PLUMA_LLM_ENDPOINT").ok(); + build_client(&LlmConfig { + kind, + model, + api_key: None, // siempre via env del backend específico + endpoint, + }) +} + +/// Detecta qué backend usar por presencia de la API key correspondiente. +/// Heurística honesta — si nadie expuso credenciales, cae a Mock para no +/// fallar el arranque del proceso. +fn detectar_backend_por_env() -> BackendKind { + if std::env::var("ANTHROPIC_API_KEY").is_ok() { + return BackendKind::Anthropic; + } + if std::env::var("GEMINI_API_KEY").is_ok() || std::env::var("GOOGLE_API_KEY").is_ok() { + return BackendKind::Gemini; + } + if std::env::var("DEEPSEEK_API_KEY").is_ok() { + return BackendKind::DeepSeek; + } + if std::env::var("COHERE_API_KEY").is_ok() { + return BackendKind::Cohere; + } + BackendKind::Mock +} + +#[cfg(test)] +mod pruebas { + use super::*; + + #[test] + fn parse_acepta_aliases() { + assert_eq!(BackendKind::parse("anthropic"), Some(BackendKind::Anthropic)); + assert_eq!(BackendKind::parse("ANTHROPIC"), Some(BackendKind::Anthropic)); + assert_eq!(BackendKind::parse("google"), Some(BackendKind::Gemini)); + assert_eq!(BackendKind::parse("gemini"), Some(BackendKind::Gemini)); + assert_eq!(BackendKind::parse("deepseek"), Some(BackendKind::DeepSeek)); + assert_eq!(BackendKind::parse("ollama"), Some(BackendKind::Ollama)); + assert_eq!(BackendKind::parse("mock"), Some(BackendKind::Mock)); + assert_eq!(BackendKind::parse("openai"), None); + assert_eq!(BackendKind::parse(""), None); + } + + #[test] + fn default_kind_es_mock() { + let cfg = LlmConfig::default(); + assert_eq!(cfg.kind, BackendKind::Mock); + } + + #[tokio::test] + async fn build_mock_y_devuelve_chat_client_funcional() { + let cli = build_client(&LlmConfig { + kind: BackendKind::Mock, + ..Default::default() + }) + .unwrap(); + assert_eq!(cli.model_id(), "pluma-llm-mock"); + // No probamos el eco aquí (ya lo cubre pluma-llm-mock); solo + // que el cliente está vivo. + } + + #[test] + fn build_ollama_sin_model_es_config_error() { + let cfg = LlmConfig { + kind: BackendKind::Ollama, + model: None, + ..Default::default() + }; + // No usamos `panic!("{otro:?}")` porque `Arc` no + // implementa Debug; matcheamos por discriminante. + match build_client(&cfg) { + Err(BuildError::Config(msg)) => assert!(msg.contains("Ollama")), + Err(otro) => panic!("esperaba Config, fue otro error: {otro}"), + Ok(_) => panic!("esperaba Config error, hubo Ok"), + } + } + + #[test] + fn build_ollama_con_model_y_endpoint_custom() { + let cfg = LlmConfig { + kind: BackendKind::Ollama, + model: Some("llama3.1".into()), + endpoint: Some("http://10.0.0.5:11434/v1/chat/completions".into()), + ..Default::default() + }; + let cli = build_client(&cfg).unwrap(); + assert_eq!(cli.model_id(), "llama3.1"); + } + + #[test] + fn serde_roundtrip_de_llmconfig() { + let cfg = LlmConfig { + kind: BackendKind::Gemini, + model: Some("gemini-2.5-pro".into()), + api_key: None, + endpoint: None, + }; + let json = serde_json::to_string(&cfg).unwrap(); + let r: LlmConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(r.kind, BackendKind::Gemini); + assert_eq!(r.model.as_deref(), Some("gemini-2.5-pro")); + } +} diff --git a/00_unanchay/pluma/pluma-md-reader-web/Cargo.toml b/00_unanchay/pluma/pluma-md-reader-web/Cargo.toml new file mode 100644 index 0000000..218c544 --- /dev/null +++ b/00_unanchay/pluma/pluma-md-reader-web/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "pluma-md-reader-web" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true + +[dependencies] +pluma-md = { path = "../pluma-md" } +wasm-bindgen.workspace = true +wasm-bindgen-futures.workspace = true +js-sys.workspace = true + +[dependencies.web-sys] +workspace = true +features = [ + "Window", + "Document", + "Element", + "HtmlElement", + "CssStyleDeclaration", + "Response", + "console", +] diff --git a/00_unanchay/pluma/pluma-md-reader-web/LEEME.md b/00_unanchay/pluma/pluma-md-reader-web/LEEME.md new file mode 100644 index 0000000..56d0e8c --- /dev/null +++ b/00_unanchay/pluma/pluma-md-reader-web/LEEME.md @@ -0,0 +1,21 @@ +# pluma-md-reader-web + +> Reader Markdown para WASM (browser). Usa [`pluma-md`](../pluma-md/README.md). + +Toma un `
` contenedor (`HtmlElement`) e inyecta el HTML producido por `pluma-md`. NO inyecta estilos — el host provee el CSS y reacciona al `data-pluma-theme` que el reader pone en el wrapper. + +Es el reader que usa este sitio (`gioser-web`). + +## API + +```rust +use pluma_md_reader_web::Reader; + +let reader = Reader::new(container); +reader.open_url("./README.md", "gioser").await?; +``` + +## Deps + +- [`pluma-md`](../pluma-md/README.md) +- `wasm-bindgen`, `wasm-bindgen-futures`, `web-sys` diff --git a/00_unanchay/pluma/pluma-md-reader-web/README.md b/00_unanchay/pluma/pluma-md-reader-web/README.md new file mode 100644 index 0000000..b0f017b --- /dev/null +++ b/00_unanchay/pluma/pluma-md-reader-web/README.md @@ -0,0 +1,21 @@ +# pluma-md-reader-web + +> Markdown reader for WASM (browser). Uses [`pluma-md`](../pluma-md/README.md). + +Takes a `
` container (`HtmlElement`) and injects the HTML produced by `pluma-md`. Does NOT inject styles — the host provides CSS and reacts to the `data-pluma-theme` the reader puts on the wrapper. + +This is the reader this site (`gioser-web`) uses. + +## API + +```rust +use pluma_md_reader_web::Reader; + +let reader = Reader::new(container); +reader.open_url("./README.md", "gioser").await?; +``` + +## Deps + +- [`pluma-md`](../pluma-md/README.md) +- `wasm-bindgen`, `wasm-bindgen-futures`, `web-sys` diff --git a/00_unanchay/pluma/pluma-md-reader-web/src/lib.rs b/00_unanchay/pluma/pluma-md-reader-web/src/lib.rs new file mode 100644 index 0000000..4b242ef --- /dev/null +++ b/00_unanchay/pluma/pluma-md-reader-web/src/lib.rs @@ -0,0 +1,88 @@ +//! Pluma reader — visor de markdown elegante para WASM/web. +//! +//! Toma un `
` que actúa como contenedor y le inyecta el HTML +//! producido por `pluma-md`. El styling (fonts, colores, animaciones) +//! lo provee el CSS del host: este crate no inyecta estilos, sólo +//! marcado y `data-pluma-theme="…"` para que el CSS reaccione. +//! +//! Patrón de uso: +//! +//! ```ignore +//! let container = document.get_element_by_id("drawer-aire-content")? +//! .dyn_into::()?; +//! let reader = Reader::new(container); +//! reader.show_loading(); +//! wasm_bindgen_futures::spawn_local(async move { +//! let _ = reader.open_url("./md/aire.md", "aire").await; +//! }); +//! ``` + +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::JsFuture; +use web_sys::{HtmlElement, Response}; + +pub struct Reader { + container: HtmlElement, +} + +impl Reader { + pub fn new(container: HtmlElement) -> Self { + Self { container } + } + + pub fn container(&self) -> &HtmlElement { + &self.container + } + + /// Inyecta un mensaje de carga mientras se resuelve `open_url`. + pub fn show_loading(&self) { + self.container.set_inner_html( + r#"
"#, + ); + } + + /// Inyecta un mensaje de error visible. + pub fn show_error(&self, msg: &str) { + let safe: String = msg.replace('<', "<").replace('>', ">"); + self.container.set_inner_html(&format!( + r#"
{}
"#, + safe + )); + } + + /// Renderea un string markdown directamente, sin fetch. + pub fn render_md(&self, md: &str, theme: &str) { + let html = pluma_md::to_themed_html(md, theme); + self.container.set_inner_html(&html); + } + + /// Inyecta HTML pre-renderizado (sin parsear). Útil si el caller ya + /// hizo el parse en otro lado. + pub fn render_html(&self, html: &str) { + self.container.set_inner_html(html); + } + + /// Limpia el contenedor. + pub fn clear(&self) { + self.container.set_inner_html(""); + } + + /// Fetcha la URL, parsea el markdown y lo renderea con el tema dado. + /// El loader muestra un placeholder mientras la promesa está pendiente. + pub async fn open_url(&self, url: &str, theme: &str) -> Result<(), JsValue> { + let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; + self.show_loading(); + let resp_value = JsFuture::from(window.fetch_with_str(url)).await?; + let resp: Response = resp_value.dyn_into()?; + if !resp.ok() { + let err = format!("HTTP {} para {}", resp.status(), url); + self.show_error(&err); + return Err(JsValue::from_str(&err)); + } + let text_value = JsFuture::from(resp.text()?).await?; + let md = text_value.as_string().unwrap_or_default(); + self.render_md(&md, theme); + Ok(()) + } +} diff --git a/00_unanchay/pluma/pluma-md/Cargo.toml b/00_unanchay/pluma/pluma-md/Cargo.toml new file mode 100644 index 0000000..0c4a6ed --- /dev/null +++ b/00_unanchay/pluma/pluma-md/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "pluma-md" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true + +[dependencies] +pulldown-cmark = { workspace = true } +pluma-core = { path = "../pluma-core" } +pluma-cuerpo = { path = "../pluma-cuerpo" } +uuid = { workspace = true } diff --git a/00_unanchay/pluma/pluma-md/LEEME.md b/00_unanchay/pluma/pluma-md/LEEME.md new file mode 100644 index 0000000..8415c0f --- /dev/null +++ b/00_unanchay/pluma/pluma-md/LEEME.md @@ -0,0 +1,17 @@ +# pluma-md + +> Parser Markdown → HTML temable para [pluma](../README.md). + +Wrapper delgado sobre `pulldown-cmark` con extensiones GFM (tables, footnotes, tasklists, strikethrough, smart punctuation, heading attrs). Salida envuelta en `
` para que el CSS del host customice por theme. + +## API + +```rust +use pluma_md::{to_html, to_themed_html}; + +let html = to_themed_html(md, "aire"); +``` + +## Deps + +- `pulldown-cmark` diff --git a/00_unanchay/pluma/pluma-md/README.md b/00_unanchay/pluma/pluma-md/README.md new file mode 100644 index 0000000..ca327cd --- /dev/null +++ b/00_unanchay/pluma/pluma-md/README.md @@ -0,0 +1,17 @@ +# pluma-md + +> Markdown → themed HTML parser for [pluma](../README.md). + +Thin wrapper around `pulldown-cmark` with GFM extensions (tables, footnotes, tasklists, strikethrough, smart punctuation, heading attrs). Output wrapped in `
` so the host CSS can customize per theme. + +## API + +```rust +use pluma_md::{to_html, to_themed_html}; + +let html = to_themed_html(md, "aire"); +``` + +## Deps + +- `pulldown-cmark` diff --git a/00_unanchay/pluma/pluma-md/src/export.rs b/00_unanchay/pluma/pluma-md/src/export.rs new file mode 100644 index 0000000..3223713 --- /dev/null +++ b/00_unanchay/pluma/pluma-md/src/export.rs @@ -0,0 +1,116 @@ +//! Exportador: `Cuerpo` + atoms → markdown. +//! +//! El contraparte de [`crate::parse_md`]. Concatena el contenido de cada +//! atom del cuerpo en el orden de `cuerpo.orden` separándolos con `\n\n` +//! (blank line, el separador GFM de bloque). +//! +//! Lossy en formato: si los atoms se importaron con `parse_md`, sus +//! prefijos de heading (`# `, `## `) ya están en el contenido — así que +//! `to_md` los preserva. Listas y otros bloques que pulldown aplanó NO +//! se reconstruyen: salen como párrafos. Para preservar formato fino, +//! exportá a otro formato o guardá el .md original junto al cuerpo. +//! +//! Pensado para ser igual de delgado que `parse_md`: un solo helper +//! reutilizable por la UI y los tests, sin depender de `pluma-store` ni +//! del runtime. + +use std::collections::HashMap; + +use pluma_core::NarrativeAtom; +use pluma_cuerpo::Cuerpo; +use uuid::Uuid; + +/// Concatena `cuerpo.orden` → atom.content con `\n\n`. Atoms ausentes +/// del índice se saltan silenciosamente (puede pasar si el caller pasa +/// un subconjunto del grafo). Devuelve `""` cuando el cuerpo está +/// vacío o ninguno de sus atoms resolvió. +pub fn to_md(cuerpo: &Cuerpo, atoms: &HashMap) -> String { + let mut out = String::new(); + for atom_id in &cuerpo.orden { + let Some(atom) = atoms.get(atom_id) else { + continue; + }; + if !out.is_empty() { + out.push_str("\n\n"); + } + out.push_str(&atom.content); + } + out +} + +/// Variante que acepta el índice por `&NarrativeAtom` — útil cuando el +/// caller tiene los atoms prestados (típico del flujo de transform que +/// arma índices `HashMap`). +pub fn to_md_borrow(cuerpo: &Cuerpo, atoms: &HashMap) -> String { + let mut out = String::new(); + for atom_id in &cuerpo.orden { + let Some(atom) = atoms.get(atom_id) else { + continue; + }; + if !out.is_empty() { + out.push_str("\n\n"); + } + out.push_str(&atom.content); + } + out +} + +#[cfg(test)] +mod pruebas { + use super::*; + use pluma_cuerpo::Intencion; + + fn cuerpo_y_atoms(textos: &[&str]) -> (Cuerpo, HashMap) { + 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 to_md_concatena_en_orden_con_doble_newline() { + let (c, atoms) = cuerpo_y_atoms(&["Uno.", "Dos.", "Tres."]); + assert_eq!(to_md(&c, &atoms), "Uno.\n\nDos.\n\nTres."); + } + + #[test] + fn to_md_devuelve_vacio_si_cuerpo_vacio() { + let c = Cuerpo::nuevo("es", "es", Intencion::Original, 0); + let atoms: HashMap = HashMap::new(); + assert_eq!(to_md(&c, &atoms), ""); + } + + #[test] + fn to_md_salta_atoms_ausentes_del_indice() { + let (mut c, mut atoms) = cuerpo_y_atoms(&["A", "B", "C"]); + // Sacar el del medio del índice (pero dejarlo en el orden). + let id_b = c.orden[1]; + atoms.remove(&id_b); + assert_eq!(to_md(&c, &atoms), "A\n\nC"); + } + + #[test] + fn to_md_preserva_prefijos_de_heading() { + let (mut c, atoms) = cuerpo_y_atoms(&[]); + let h1 = NarrativeAtom::new("# Título", "es"); + let p = NarrativeAtom::new("Párrafo bajo el título.", "es"); + c.agregar(h1.id, 1); + c.agregar(p.id, 1); + let mut map = atoms; + map.insert(h1.id, h1); + map.insert(p.id, p); + assert_eq!(to_md(&c, &map), "# Título\n\nPárrafo bajo el título."); + } + + #[test] + fn to_md_borrow_y_to_md_dan_el_mismo_resultado() { + let (c, atoms) = cuerpo_y_atoms(&["uno", "dos"]); + let borrow: HashMap = + atoms.iter().map(|(k, v)| (*k, v)).collect(); + assert_eq!(to_md(&c, &atoms), to_md_borrow(&c, &borrow)); + } +} diff --git a/00_unanchay/pluma/pluma-md/src/import.rs b/00_unanchay/pluma/pluma-md/src/import.rs new file mode 100644 index 0000000..fbd768b --- /dev/null +++ b/00_unanchay/pluma/pluma-md/src/import.rs @@ -0,0 +1,232 @@ +//! Importador: markdown → `(Cuerpo, Vec)`. +//! +//! Convierte un documento markdown en un cuerpo madre apto para el +//! multilienzo, con un `NarrativeAtom` por *bloque*. Bloque = párrafo, +//! ítem de lista, encabezado, code block, blockquote, tabla. Cada uno +//! aporta un átomo independiente que puede traducirse o transformarse +//! con `pluma-transform-llm` y alinearse con `pluma-align-embeddings`. +//! +//! El texto de cada bloque se aplana: el átomo lleva el plain-text del +//! bloque (sin sintaxis markdown). Eso permite que un párrafo `**hola +//! mundo**` se traduzca como "hola mundo" sin que el modelo lidie con +//! asteriscos. Si el caller necesita el markdown original, lo guarda +//! aparte; la idea de "cuerpo" no es preservar formato, es preservar +//! contenido legible alineable. +//! +//! Encabezados (`#`, `##`, …) se inyectan como átomos con prefijo +//! `"# "` / `"## "` / etc. al texto del título. Ese pequeño marcador +//! le dice al modelo (o al lector) que es un heading sin necesidad de +//! un campo extra en `NarrativeAtom`. + +use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; + +use pluma_core::NarrativeAtom; +use pluma_cuerpo::{Cuerpo, Intencion}; + +use crate::default_options; + +/// Resultado del import: el cuerpo madre + sus átomos. +pub struct DocumentoImportado { + pub cuerpo: Cuerpo, + pub atoms: Vec, +} + +/// Importa markdown como cuerpo Original. `branch_id` y `nombre` se +/// anotan en los metadatos del cuerpo; `ahora` es el timestamp de +/// creación. Devuelve el cuerpo + los `NarrativeAtom`s, listos para +/// `graph.insert(atom)` en el caller. +pub fn parse_md( + md: &str, + branch_id: impl Into, + nombre: impl Into, + ahora: u64, +) -> DocumentoImportado { + let branch = branch_id.into(); + let mut cuerpo = Cuerpo::nuevo(branch.clone(), nombre, Intencion::Original, ahora); + let bloques = bloques_planos(md, default_options()); + let mut atoms = Vec::with_capacity(bloques.len()); + for texto in bloques { + 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); + } + DocumentoImportado { cuerpo, atoms } +} + +/// Recorre el stream de eventos pulldown y produce un string por +/// bloque. Mantiene un cursor de texto + un prefijo de heading que se +/// resetea al cerrar el bloque. +fn bloques_planos(md: &str, opts: Options) -> Vec { + let mut out = Vec::new(); + let mut buf = String::new(); + let mut en_code_block = false; + let mut prefijo_pendiente: Option = None; + + for ev in Parser::new_ext(md, opts) { + match ev { + Event::Start(tag) => match tag { + Tag::Heading { level, .. } => { + let hashes = "#".repeat(level as usize); + prefijo_pendiente = Some(format!("{hashes} ")); + } + Tag::CodeBlock(_) => { + en_code_block = true; + } + Tag::List(_) | Tag::Item | Tag::BlockQuote(_) | Tag::Paragraph => { + // Estos tags abren un bloque nuevo — el "end" lo cierra. + } + Tag::Table(_) | Tag::TableHead | Tag::TableRow | Tag::TableCell => { + // Tablas: las aplanamos por celda separada por `\t` por fila. + } + _ => {} + }, + Event::End(end) => match end { + TagEnd::Paragraph + | TagEnd::Heading(_) + | TagEnd::Item + | TagEnd::BlockQuote(_) + | TagEnd::CodeBlock + | TagEnd::TableRow + | TagEnd::TableHead => { + let mut bloque = buf.trim().to_string(); + if let Some(p) = prefijo_pendiente.take() { + bloque = format!("{p}{bloque}"); + } + if !bloque.is_empty() { + out.push(bloque); + } + buf.clear(); + en_code_block = false; + } + _ => {} + }, + Event::Text(t) => { + buf.push_str(&t); + } + Event::Code(t) => { + // Código inline: lo metemos al buffer del bloque actual. + buf.push_str(&t); + } + Event::SoftBreak | Event::HardBreak => { + // Un salto blando dentro de un párrafo: lo aplanamos a + // espacio. Los saltos duros pierden contexto, pero como + // el átomo es un bloque, una sola línea visible alcanza. + if en_code_block { + buf.push('\n'); + } else { + buf.push(' '); + } + } + Event::TaskListMarker(_) | Event::Html(_) | Event::InlineHtml(_) => { + // Ignorados — el texto que los rodea ya se captura. + } + Event::Rule => { + // Separador horizontal: cerrar bloque actual. + let bloque = buf.trim().to_string(); + if !bloque.is_empty() { + out.push(bloque); + } + buf.clear(); + } + _ => {} + } + } + if !buf.trim().is_empty() { + out.push(buf.trim().to_string()); + } + out +} + +#[cfg(test)] +mod pruebas { + use super::*; + + fn textos(d: &DocumentoImportado) -> Vec { + d.atoms.iter().map(|a| a.content.to_string()).collect() + } + + #[test] + fn parrafos_basicos_se_separan() { + let md = "Primer párrafo.\n\nSegundo párrafo.\n\nTercer párrafo."; + let d = parse_md(md, "es", "doc.md", 1); + assert_eq!(textos(&d), vec![ + "Primer párrafo.".to_string(), + "Segundo párrafo.".to_string(), + "Tercer párrafo.".to_string(), + ]); + assert_eq!(d.cuerpo.orden.len(), 3); + assert_eq!(d.cuerpo.metadatos.intencion, Intencion::Original); + assert_eq!(d.cuerpo.branch_id, "es"); + } + + #[test] + fn encabezado_lleva_prefijo_hash_segun_nivel() { + let d = parse_md("# Título\n\n## Subtítulo\n\nTexto.", "es", "x", 0); + let ts = textos(&d); + assert_eq!(ts[0], "# Título"); + assert_eq!(ts[1], "## Subtítulo"); + assert_eq!(ts[2], "Texto."); + } + + #[test] + fn lista_genera_un_atom_por_item() { + let d = parse_md("- uno\n- dos\n- tres", "es", "x", 0); + let ts = textos(&d); + assert_eq!(ts, vec!["uno", "dos", "tres"]); + } + + #[test] + fn formato_inline_se_aplana_a_texto() { + let d = parse_md("Un texto con **negrita** y *cursiva* y `código`.", "es", "x", 0); + let ts = textos(&d); + assert_eq!(ts.len(), 1); + assert_eq!(ts[0], "Un texto con negrita y cursiva y código."); + } + + #[test] + fn code_block_se_preserva_como_bloque() { + let md = "Texto.\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```\n\nFin."; + let d = parse_md(md, "es", "x", 0); + let ts = textos(&d); + assert_eq!(ts.len(), 3); + assert_eq!(ts[0], "Texto."); + assert!(ts[1].contains("fn main")); + assert!(ts[1].contains("println!")); + assert_eq!(ts[2], "Fin."); + } + + #[test] + fn lineas_vacias_no_producen_atoms_huerfanos() { + let d = parse_md("\n\n\nPárrafo.\n\n\n", "es", "x", 0); + assert_eq!(d.atoms.len(), 1); + } + + #[test] + fn separador_horizontal_corta_el_bloque() { + let md = "Uno.\n\n---\n\nDos."; + let d = parse_md(md, "es", "x", 0); + let ts = textos(&d); + assert!(ts.iter().any(|t| t == "Uno.")); + assert!(ts.iter().any(|t| t == "Dos.")); + } + + #[test] + fn blockquote_se_emite_como_bloque() { + let d = parse_md("> Citado.\n\nDespués.", "es", "x", 0); + let ts = textos(&d); + assert!(ts.iter().any(|t| t == "Citado.")); + assert!(ts.iter().any(|t| t == "Después.")); + } + + #[test] + fn cuerpo_y_atoms_referencian_los_mismos_uuids() { + let d = parse_md("a\n\nb\n\nc", "es", "x", 0); + for (atom, uuid_en_orden) in d.atoms.iter().zip(d.cuerpo.orden.iter()) { + assert_eq!(&atom.id, uuid_en_orden); + } + } +} diff --git a/00_unanchay/pluma/pluma-md/src/lib.rs b/00_unanchay/pluma/pluma-md/src/lib.rs new file mode 100644 index 0000000..18607b2 --- /dev/null +++ b/00_unanchay/pluma/pluma-md/src/lib.rs @@ -0,0 +1,95 @@ +//! Pluma — parser markdown agnóstico, listo para envolver en cualquier viewer. +//! +//! Es deliberadamente delgado: wrappea `pulldown-cmark` (todas las +//! extensiones GFM habilitadas) y emite HTML envuelto en `
` +//! con un `data-pluma-theme="…"` para que el CSS del viewer aplique colores +//! por tema sin necesidad de re-renderear. +//! +//! No tiene deps de web/DOM/wasm: corre igual en server, terminal, WASM o +//! tests. Si necesitás emitir Markdown-AST en lugar de HTML, usá la API +//! `events()` y construí tu propio renderer. + +pub mod export; +pub mod import; +pub use export::{to_md, to_md_borrow}; +pub use import::{parse_md, DocumentoImportado}; + +use pulldown_cmark::{html, Event, Options, Parser}; + +/// Opciones por default — GFM completo: tables, footnotes, tasklists, strikethrough, +/// smart punctuation, heading anchors. +pub fn default_options() -> Options { + Options::ENABLE_TABLES + | Options::ENABLE_FOOTNOTES + | Options::ENABLE_STRIKETHROUGH + | Options::ENABLE_TASKLISTS + | Options::ENABLE_SMART_PUNCTUATION + | Options::ENABLE_HEADING_ATTRIBUTES +} + +/// Markdown → HTML "crudo" (sin wrapper de tema). +pub fn to_html(md: &str) -> String { + let mut out = String::with_capacity(md.len() * 2); + let parser = Parser::new_ext(md, default_options()); + html::push_html(&mut out, parser); + out +} + +/// Markdown → HTML envuelto en `
`. +/// El `theme` es un string opaco (ej. "aire", "fuego") que el CSS del viewer +/// matchea via `[data-pluma-theme="aire"]`. +pub fn to_themed_html(md: &str, theme: &str) -> String { + let body = to_html(md); + let safe_theme: String = theme + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') + .collect(); + format!( + r#"
{body}
"#, + theme = safe_theme, + body = body + ) +} + +/// Devuelve un iterador de eventos pulldown-cmark (AST stream). +/// Útil si querés renderear a algo distinto que HTML. +pub fn events(md: &str) -> impl Iterator> { + Parser::new_ext(md, default_options()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_h1() { + let html = to_html("# Hola"); + assert!(html.contains("

Hola

"), "got {}", html); + } + + #[test] + fn renders_list() { + let html = to_html("- a\n- b\n"); + assert!(html.contains("
  • a
  • ")); + assert!(html.contains("
  • b
  • ")); + } + + #[test] + fn themed_wrapper_sanitizes_theme_name() { + let html = to_themed_html("# x", "aire