From e65e9cc623a539e158050a10fdfafc4692efe928 Mon Sep 17 00:00:00 2001 From: Sergio Date: Thu, 4 Jun 2026 04:23:42 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20llimphi=20standalone=20=E2=80=94=20fram?= =?UTF-8?q?ework=20UI=20soberano=20extra=C3=ADdo=20del=20monorepo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motor gráfico Llimphi como workspace independiente: bucle Elm (input→update→view→layout→raster→present) sobre wgpu+vello+taffy+parley. Núcleo (hal/raster/layout/text/ui/theme/surface/motion/icons) + ~40 widgets + módulos, sin dependencias al resto del monorepo. cargo check --workspace pasa (64 crates). Puerta de entrada: cargo run -p llimphi-ui --example counter. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + COMPUTO-FUERA-DEL-HILO-UI.md | 122 + Cargo.lock | 3848 +++++++++++++++++ Cargo.toml | 441 ++ LEEME.md | 90 + LICENSE | 21 + MANUAL.md | 1041 +++++ README.md | 43 + README.qu.md | 35 + SDD.md | 366 ++ android/LEEME.md | 108 + android/clear-screen-android/Cargo.toml | 49 + android/clear-screen-android/LEEME.md | 11 + android/clear-screen-android/README.md | 11 + android/clear-screen-android/src/lib.rs | 291 ++ android/scripts/build-android.sh | 89 + android/vello-hello-android/Cargo.toml | 43 + android/vello-hello-android/LEEME.md | 11 + android/vello-hello-android/README.md | 11 + android/vello-hello-android/src/lib.rs | 376 ++ android/vello-text-android/Cargo.toml | 44 + android/vello-text-android/LEEME.md | 11 + android/vello-text-android/README.md | 11 + android/vello-text-android/src/lib.rs | 406 ++ llimphi-compositor/Cargo.toml | 16 + llimphi-compositor/src/lib.rs | 348 ++ llimphi-compositor/src/render.rs | 705 +++ llimphi-compositor/src/view.rs | 408 ++ llimphi-compositor/tests/text_measure.rs | 87 + llimphi-gallery/Cargo.toml | 40 + llimphi-gallery/src/main.rs | 966 +++++ llimphi-gpu-bench/Cargo.toml | 15 + llimphi-gpu-bench/src/main.rs | 941 ++++ llimphi-hal/Cargo.toml | 17 + llimphi-hal/LEEME.md | 10 + llimphi-hal/README.md | 10 + llimphi-hal/examples/clear_screen.rs | 135 + llimphi-hal/src/lib.rs | 823 ++++ llimphi-icons/Cargo.toml | 11 + llimphi-icons/examples/app_icons_gallery.rs | 136 + llimphi-icons/src/app_icons.rs | 824 ++++ llimphi-icons/src/lib.rs | 1005 +++++ llimphi-layout/Cargo.toml | 19 + llimphi-layout/LEEME.md | 10 + llimphi-layout/README.md | 10 + llimphi-layout/examples/layout_panels.rs | 250 ++ llimphi-layout/src/lib.rs | 184 + llimphi-motion/Cargo.toml | 12 + llimphi-motion/src/lib.rs | 259 ++ llimphi-raster/Cargo.toml | 24 + llimphi-raster/LEEME.md | 10 + llimphi-raster/README.md | 10 + llimphi-raster/examples/gpu_million_points.rs | 111 + llimphi-raster/examples/render_node.rs | 143 + llimphi-raster/examples/spike_gpu_directo.rs | 390 ++ llimphi-raster/src/gpu.rs | 553 +++ llimphi-raster/src/lib.rs | 120 + llimphi-raster/tests/gpu_batch_smoke.rs | 128 + llimphi-surface/Cargo.toml | 12 + llimphi-surface/src/lib.rs | 404 ++ llimphi-text/Cargo.toml | 24 + llimphi-text/LEEME.md | 9 + llimphi-text/README.md | 9 + llimphi-text/assets/DejaVuSans.ttf | Bin 0 -> 756072 bytes llimphi-text/examples/hello_text.rs | 167 + llimphi-text/src/lib.rs | 359 ++ llimphi-theme/Cargo.toml | 12 + llimphi-theme/LEEME.md | 9 + llimphi-theme/README.md | 9 + llimphi-theme/src/lib.rs | 361 ++ llimphi-ui/Cargo.toml | 28 + llimphi-ui/LEEME.md | 9 + llimphi-ui/README.md | 9 + llimphi-ui/examples/counter.rs | 124 + llimphi-ui/examples/editor.rs | 132 + llimphi-ui/examples/gpu_paint_demo.rs | 393 ++ llimphi-ui/src/eventloop.rs | 1301 ++++++ llimphi-ui/src/lib.rs | 604 +++ llimphi-workspace/Cargo.toml | 13 + llimphi-workspace/examples/workspace_demo.rs | 212 + llimphi-workspace/src/lib.rs | 378 ++ modules/bookmarks/Cargo.toml | 16 + modules/bookmarks/LEEME.md | 5 + modules/bookmarks/README.md | 5 + modules/bookmarks/src/lib.rs | 424 ++ modules/bookmarks/tests/smoke.rs | 94 + modules/command-palette/Cargo.toml | 14 + modules/command-palette/LEEME.md | 5 + modules/command-palette/README.md | 5 + modules/command-palette/src/lib.rs | 352 ++ modules/command-palette/tests/smoke.rs | 125 + modules/diff-viewer/Cargo.toml | 13 + modules/diff-viewer/LEEME.md | 5 + modules/diff-viewer/README.md | 5 + modules/diff-viewer/src/lib.rs | 398 ++ modules/diff-viewer/tests/smoke.rs | 155 + modules/fif/Cargo.toml | 13 + modules/fif/LEEME.md | 5 + modules/fif/README.md | 5 + modules/fif/src/lib.rs | 815 ++++ modules/file-picker/Cargo.toml | 13 + modules/file-picker/LEEME.md | 5 + modules/file-picker/README.md | 5 + modules/file-picker/src/lib.rs | 382 ++ modules/mini-map/Cargo.toml | 14 + modules/mini-map/LEEME.md | 5 + modules/mini-map/README.md | 5 + modules/mini-map/src/lib.rs | 274 ++ modules/mini-map/tests/smoke.rs | 63 + modules/plugin-host/Cargo.toml | 21 + modules/plugin-host/LEEME.md | 5 + modules/plugin-host/README.md | 5 + modules/plugin-host/src/lib.rs | 334 ++ .../tests/fixtures/hello-status/.gitignore | 1 + .../tests/fixtures/hello-status/manifest.toml | 11 + .../tests/fixtures/hello-status/plugin.wat | 44 + modules/plugin-host/tests/smoke.rs | 109 + modules/selector/Cargo.toml | 12 + modules/selector/src/lib.rs | 245 ++ modules/shuma-term/Cargo.toml | 14 + modules/shuma-term/LEEME.md | 5 + modules/shuma-term/README.md | 5 + modules/shuma-term/src/lib.rs | 511 +++ modules/shuma-term/tests/smoke.rs | 157 + modules/symbol-outline/Cargo.toml | 14 + modules/symbol-outline/LEEME.md | 5 + modules/symbol-outline/README.md | 5 + modules/symbol-outline/src/lib.rs | 352 ++ modules/symbol-outline/tests/smoke.rs | 130 + widgets/app-header/Cargo.toml | 13 + widgets/app-header/LEEME.md | 5 + widgets/app-header/README.md | 5 + widgets/app-header/src/lib.rs | 145 + widgets/avatar/Cargo.toml | 12 + widgets/avatar/src/lib.rs | 116 + widgets/badge/Cargo.toml | 12 + widgets/badge/src/lib.rs | 136 + widgets/banner/Cargo.toml | 11 + widgets/banner/LEEME.md | 5 + widgets/banner/README.md | 5 + widgets/banner/src/lib.rs | 109 + widgets/breadcrumb/Cargo.toml | 13 + widgets/breadcrumb/src/lib.rs | 128 + widgets/button/Cargo.toml | 12 + widgets/button/LEEME.md | 5 + widgets/button/README.md | 5 + widgets/button/examples/button_demo.rs | 136 + widgets/button/src/lib.rs | 124 + widgets/card/Cargo.toml | 13 + widgets/card/LEEME.md | 5 + widgets/card/README.md | 5 + widgets/card/src/lib.rs | 146 + widgets/clipboard/Cargo.toml | 12 + widgets/clipboard/src/lib.rs | 55 + widgets/context-menu/Cargo.toml | 13 + widgets/context-menu/LEEME.md | 5 + widgets/context-menu/README.md | 5 + widgets/context-menu/src/lib.rs | 761 ++++ widgets/dock-rail/Cargo.toml | 12 + widgets/dock-rail/src/lib.rs | 184 + widgets/edit-menu/Cargo.toml | 14 + widgets/edit-menu/src/lib.rs | 361 ++ widgets/empty/Cargo.toml | 13 + widgets/empty/src/lib.rs | 112 + widgets/field/Cargo.toml | 12 + widgets/field/src/lib.rs | 127 + widgets/gallery/Cargo.toml | 31 + widgets/gallery/LEEME.md | 5 + widgets/gallery/README.md | 5 + widgets/gallery/src/main.rs | 569 +++ widgets/grid/Cargo.toml | 12 + widgets/grid/src/lib.rs | 467 ++ widgets/list/Cargo.toml | 12 + widgets/list/LEEME.md | 5 + widgets/list/README.md | 5 + widgets/list/src/lib.rs | 201 + widgets/menubar/Cargo.toml | 15 + widgets/menubar/src/lib.rs | 334 ++ widgets/modal/Cargo.toml | 13 + widgets/modal/src/lib.rs | 319 ++ widgets/navigator/Cargo.toml | 17 + widgets/navigator/examples/navigator_demo.rs | 222 + widgets/navigator/src/lib.rs | 626 +++ widgets/nodegraph/Cargo.toml | 16 + widgets/nodegraph/LEEME.md | 5 + widgets/nodegraph/README.md | 5 + widgets/nodegraph/examples/nodegraph_demo.rs | 197 + widgets/nodegraph/src/lib.rs | 718 +++ widgets/panel/Cargo.toml | 12 + widgets/panel/src/lib.rs | 239 + widgets/panes/Cargo.toml | 12 + widgets/panes/examples/panes_demo.rs | 319 ++ widgets/panes/src/lib.rs | 505 +++ widgets/progress/Cargo.toml | 12 + widgets/progress/src/lib.rs | 126 + widgets/scroll/Cargo.toml | 12 + widgets/scroll/src/lib.rs | 326 ++ widgets/segmented/Cargo.toml | 12 + widgets/segmented/src/lib.rs | 143 + widgets/shortcuts-help/Cargo.toml | 13 + widgets/shortcuts-help/src/lib.rs | 282 ++ widgets/skeleton/Cargo.toml | 12 + widgets/skeleton/src/lib.rs | 145 + widgets/slider/Cargo.toml | 16 + widgets/slider/LEEME.md | 5 + widgets/slider/README.md | 5 + widgets/slider/examples/slider_demo.rs | 130 + widgets/slider/src/lib.rs | 254 ++ widgets/spinner/Cargo.toml | 12 + widgets/spinner/src/lib.rs | 72 + widgets/splash/Cargo.toml | 13 + widgets/splash/src/lib.rs | 283 ++ widgets/splitter/Cargo.toml | 12 + widgets/splitter/LEEME.md | 5 + widgets/splitter/README.md | 5 + widgets/splitter/examples/splitter_demo.rs | 126 + widgets/splitter/src/lib.rs | 174 + widgets/stat-card/Cargo.toml | 13 + widgets/stat-card/LEEME.md | 5 + widgets/stat-card/README.md | 5 + widgets/stat-card/src/lib.rs | 144 + widgets/status-bar/Cargo.toml | 14 + widgets/status-bar/src/lib.rs | 242 ++ widgets/switch/Cargo.toml | 12 + widgets/switch/src/lib.rs | 152 + widgets/tabs/Cargo.toml | 13 + widgets/tabs/LEEME.md | 5 + widgets/tabs/README.md | 5 + widgets/tabs/examples/tabs_demo.rs | 136 + widgets/tabs/src/lib.rs | 271 ++ widgets/text-area/Cargo.toml | 12 + widgets/text-area/LEEME.md | 5 + widgets/text-area/README.md | 5 + widgets/text-area/src/lib.rs | 261 ++ widgets/text-editor-core/Cargo.toml | 18 + widgets/text-editor-core/src/bracket.rs | 165 + widgets/text-editor-core/src/buffer.rs | 255 ++ widgets/text-editor-core/src/clipboard.rs | 50 + widgets/text-editor-core/src/cursor.rs | 325 ++ widgets/text-editor-core/src/diagnostics.rs | 78 + widgets/text-editor-core/src/find.rs | 168 + widgets/text-editor-core/src/highlight.rs | 590 +++ widgets/text-editor-core/src/lib.rs | 40 + widgets/text-editor-core/src/ops.rs | 384 ++ widgets/text-editor-core/src/undo.rs | 133 + widgets/text-editor-lsp/Cargo.toml | 18 + widgets/text-editor-lsp/LEEME.md | 5 + widgets/text-editor-lsp/README.md | 5 + widgets/text-editor-lsp/src/client.rs | 501 +++ widgets/text-editor-lsp/src/lib.rs | 275 ++ widgets/text-editor-lsp/src/protocol.rs | 515 +++ widgets/text-editor-lsp/src/tests.rs | 380 ++ widgets/text-editor/Cargo.toml | 18 + widgets/text-editor/LEEME.md | 5 + widgets/text-editor/README.md | 5 + widgets/text-editor/src/lib.rs | 68 + widgets/text-editor/src/state.rs | 1258 ++++++ widgets/text-editor/src/view.rs | 855 ++++ widgets/text-input/Cargo.toml | 13 + widgets/text-input/LEEME.md | 5 + widgets/text-input/README.md | 5 + widgets/text-input/src/lib.rs | 270 ++ widgets/theme-switcher/Cargo.toml | 16 + widgets/theme-switcher/LEEME.md | 5 + widgets/theme-switcher/README.md | 5 + .../examples/theme_switcher_demo.rs | 153 + widgets/theme-switcher/src/lib.rs | 179 + widgets/tiled/Cargo.toml | 16 + widgets/tiled/LEEME.md | 5 + widgets/tiled/README.md | 5 + widgets/tiled/examples/tiled_demo.rs | 218 + widgets/tiled/src/lib.rs | 322 ++ widgets/timeline/Cargo.toml | 12 + widgets/timeline/src/lib.rs | 154 + widgets/toast/Cargo.toml | 13 + widgets/toast/src/lib.rs | 238 + widgets/tooltip/Cargo.toml | 12 + widgets/tooltip/src/lib.rs | 166 + widgets/tree/Cargo.toml | 12 + widgets/tree/LEEME.md | 5 + widgets/tree/README.md | 5 + widgets/tree/examples/tree_demo.rs | 207 + widgets/tree/src/lib.rs | 345 ++ widgets/wawa-mark/Cargo.toml | 12 + widgets/wawa-mark/examples/wawa_mark_demo.rs | 85 + widgets/wawa-mark/src/lib.rs | 306 ++ 286 files changed, 46136 insertions(+) create mode 100644 .gitignore create mode 100644 COMPUTO-FUERA-DEL-HILO-UI.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LEEME.md create mode 100644 LICENSE create mode 100644 MANUAL.md create mode 100644 README.md create mode 100644 README.qu.md create mode 100644 SDD.md create mode 100644 android/LEEME.md create mode 100644 android/clear-screen-android/Cargo.toml create mode 100644 android/clear-screen-android/LEEME.md create mode 100644 android/clear-screen-android/README.md create mode 100644 android/clear-screen-android/src/lib.rs create mode 100755 android/scripts/build-android.sh create mode 100644 android/vello-hello-android/Cargo.toml create mode 100644 android/vello-hello-android/LEEME.md create mode 100644 android/vello-hello-android/README.md create mode 100644 android/vello-hello-android/src/lib.rs create mode 100644 android/vello-text-android/Cargo.toml create mode 100644 android/vello-text-android/LEEME.md create mode 100644 android/vello-text-android/README.md create mode 100644 android/vello-text-android/src/lib.rs create mode 100644 llimphi-compositor/Cargo.toml create mode 100644 llimphi-compositor/src/lib.rs create mode 100644 llimphi-compositor/src/render.rs create mode 100644 llimphi-compositor/src/view.rs create mode 100644 llimphi-compositor/tests/text_measure.rs create mode 100644 llimphi-gallery/Cargo.toml create mode 100644 llimphi-gallery/src/main.rs create mode 100644 llimphi-gpu-bench/Cargo.toml create mode 100644 llimphi-gpu-bench/src/main.rs create mode 100644 llimphi-hal/Cargo.toml create mode 100644 llimphi-hal/LEEME.md create mode 100644 llimphi-hal/README.md create mode 100644 llimphi-hal/examples/clear_screen.rs create mode 100644 llimphi-hal/src/lib.rs create mode 100644 llimphi-icons/Cargo.toml create mode 100644 llimphi-icons/examples/app_icons_gallery.rs create mode 100644 llimphi-icons/src/app_icons.rs create mode 100644 llimphi-icons/src/lib.rs create mode 100644 llimphi-layout/Cargo.toml create mode 100644 llimphi-layout/LEEME.md create mode 100644 llimphi-layout/README.md create mode 100644 llimphi-layout/examples/layout_panels.rs create mode 100644 llimphi-layout/src/lib.rs create mode 100644 llimphi-motion/Cargo.toml create mode 100644 llimphi-motion/src/lib.rs create mode 100644 llimphi-raster/Cargo.toml create mode 100644 llimphi-raster/LEEME.md create mode 100644 llimphi-raster/README.md create mode 100644 llimphi-raster/examples/gpu_million_points.rs create mode 100644 llimphi-raster/examples/render_node.rs create mode 100644 llimphi-raster/examples/spike_gpu_directo.rs create mode 100644 llimphi-raster/src/gpu.rs create mode 100644 llimphi-raster/src/lib.rs create mode 100644 llimphi-raster/tests/gpu_batch_smoke.rs create mode 100644 llimphi-surface/Cargo.toml create mode 100644 llimphi-surface/src/lib.rs create mode 100644 llimphi-text/Cargo.toml create mode 100644 llimphi-text/LEEME.md create mode 100644 llimphi-text/README.md create mode 100644 llimphi-text/assets/DejaVuSans.ttf create mode 100644 llimphi-text/examples/hello_text.rs create mode 100644 llimphi-text/src/lib.rs create mode 100644 llimphi-theme/Cargo.toml create mode 100644 llimphi-theme/LEEME.md create mode 100644 llimphi-theme/README.md create mode 100644 llimphi-theme/src/lib.rs create mode 100644 llimphi-ui/Cargo.toml create mode 100644 llimphi-ui/LEEME.md create mode 100644 llimphi-ui/README.md create mode 100644 llimphi-ui/examples/counter.rs create mode 100644 llimphi-ui/examples/editor.rs create mode 100644 llimphi-ui/examples/gpu_paint_demo.rs create mode 100644 llimphi-ui/src/eventloop.rs create mode 100644 llimphi-ui/src/lib.rs create mode 100644 llimphi-workspace/Cargo.toml create mode 100644 llimphi-workspace/examples/workspace_demo.rs create mode 100644 llimphi-workspace/src/lib.rs create mode 100644 modules/bookmarks/Cargo.toml create mode 100644 modules/bookmarks/LEEME.md create mode 100644 modules/bookmarks/README.md create mode 100644 modules/bookmarks/src/lib.rs create mode 100644 modules/bookmarks/tests/smoke.rs create mode 100644 modules/command-palette/Cargo.toml create mode 100644 modules/command-palette/LEEME.md create mode 100644 modules/command-palette/README.md create mode 100644 modules/command-palette/src/lib.rs create mode 100644 modules/command-palette/tests/smoke.rs create mode 100644 modules/diff-viewer/Cargo.toml create mode 100644 modules/diff-viewer/LEEME.md create mode 100644 modules/diff-viewer/README.md create mode 100644 modules/diff-viewer/src/lib.rs create mode 100644 modules/diff-viewer/tests/smoke.rs create mode 100644 modules/fif/Cargo.toml create mode 100644 modules/fif/LEEME.md create mode 100644 modules/fif/README.md create mode 100644 modules/fif/src/lib.rs create mode 100644 modules/file-picker/Cargo.toml create mode 100644 modules/file-picker/LEEME.md create mode 100644 modules/file-picker/README.md create mode 100644 modules/file-picker/src/lib.rs create mode 100644 modules/mini-map/Cargo.toml create mode 100644 modules/mini-map/LEEME.md create mode 100644 modules/mini-map/README.md create mode 100644 modules/mini-map/src/lib.rs create mode 100644 modules/mini-map/tests/smoke.rs create mode 100644 modules/plugin-host/Cargo.toml create mode 100644 modules/plugin-host/LEEME.md create mode 100644 modules/plugin-host/README.md create mode 100644 modules/plugin-host/src/lib.rs create mode 100644 modules/plugin-host/tests/fixtures/hello-status/.gitignore create mode 100644 modules/plugin-host/tests/fixtures/hello-status/manifest.toml create mode 100644 modules/plugin-host/tests/fixtures/hello-status/plugin.wat create mode 100644 modules/plugin-host/tests/smoke.rs create mode 100644 modules/selector/Cargo.toml create mode 100644 modules/selector/src/lib.rs create mode 100644 modules/shuma-term/Cargo.toml create mode 100644 modules/shuma-term/LEEME.md create mode 100644 modules/shuma-term/README.md create mode 100644 modules/shuma-term/src/lib.rs create mode 100644 modules/shuma-term/tests/smoke.rs create mode 100644 modules/symbol-outline/Cargo.toml create mode 100644 modules/symbol-outline/LEEME.md create mode 100644 modules/symbol-outline/README.md create mode 100644 modules/symbol-outline/src/lib.rs create mode 100644 modules/symbol-outline/tests/smoke.rs create mode 100644 widgets/app-header/Cargo.toml create mode 100644 widgets/app-header/LEEME.md create mode 100644 widgets/app-header/README.md create mode 100644 widgets/app-header/src/lib.rs create mode 100644 widgets/avatar/Cargo.toml create mode 100644 widgets/avatar/src/lib.rs create mode 100644 widgets/badge/Cargo.toml create mode 100644 widgets/badge/src/lib.rs create mode 100644 widgets/banner/Cargo.toml create mode 100644 widgets/banner/LEEME.md create mode 100644 widgets/banner/README.md create mode 100644 widgets/banner/src/lib.rs create mode 100644 widgets/breadcrumb/Cargo.toml create mode 100644 widgets/breadcrumb/src/lib.rs create mode 100644 widgets/button/Cargo.toml create mode 100644 widgets/button/LEEME.md create mode 100644 widgets/button/README.md create mode 100644 widgets/button/examples/button_demo.rs create mode 100644 widgets/button/src/lib.rs create mode 100644 widgets/card/Cargo.toml create mode 100644 widgets/card/LEEME.md create mode 100644 widgets/card/README.md create mode 100644 widgets/card/src/lib.rs create mode 100644 widgets/clipboard/Cargo.toml create mode 100644 widgets/clipboard/src/lib.rs create mode 100644 widgets/context-menu/Cargo.toml create mode 100644 widgets/context-menu/LEEME.md create mode 100644 widgets/context-menu/README.md create mode 100644 widgets/context-menu/src/lib.rs create mode 100644 widgets/dock-rail/Cargo.toml create mode 100644 widgets/dock-rail/src/lib.rs create mode 100644 widgets/edit-menu/Cargo.toml create mode 100644 widgets/edit-menu/src/lib.rs create mode 100644 widgets/empty/Cargo.toml create mode 100644 widgets/empty/src/lib.rs create mode 100644 widgets/field/Cargo.toml create mode 100644 widgets/field/src/lib.rs create mode 100644 widgets/gallery/Cargo.toml create mode 100644 widgets/gallery/LEEME.md create mode 100644 widgets/gallery/README.md create mode 100644 widgets/gallery/src/main.rs create mode 100644 widgets/grid/Cargo.toml create mode 100644 widgets/grid/src/lib.rs create mode 100644 widgets/list/Cargo.toml create mode 100644 widgets/list/LEEME.md create mode 100644 widgets/list/README.md create mode 100644 widgets/list/src/lib.rs create mode 100644 widgets/menubar/Cargo.toml create mode 100644 widgets/menubar/src/lib.rs create mode 100644 widgets/modal/Cargo.toml create mode 100644 widgets/modal/src/lib.rs create mode 100644 widgets/navigator/Cargo.toml create mode 100644 widgets/navigator/examples/navigator_demo.rs create mode 100644 widgets/navigator/src/lib.rs create mode 100644 widgets/nodegraph/Cargo.toml create mode 100644 widgets/nodegraph/LEEME.md create mode 100644 widgets/nodegraph/README.md create mode 100644 widgets/nodegraph/examples/nodegraph_demo.rs create mode 100644 widgets/nodegraph/src/lib.rs create mode 100644 widgets/panel/Cargo.toml create mode 100644 widgets/panel/src/lib.rs create mode 100644 widgets/panes/Cargo.toml create mode 100644 widgets/panes/examples/panes_demo.rs create mode 100644 widgets/panes/src/lib.rs create mode 100644 widgets/progress/Cargo.toml create mode 100644 widgets/progress/src/lib.rs create mode 100644 widgets/scroll/Cargo.toml create mode 100644 widgets/scroll/src/lib.rs create mode 100644 widgets/segmented/Cargo.toml create mode 100644 widgets/segmented/src/lib.rs create mode 100644 widgets/shortcuts-help/Cargo.toml create mode 100644 widgets/shortcuts-help/src/lib.rs create mode 100644 widgets/skeleton/Cargo.toml create mode 100644 widgets/skeleton/src/lib.rs create mode 100644 widgets/slider/Cargo.toml create mode 100644 widgets/slider/LEEME.md create mode 100644 widgets/slider/README.md create mode 100644 widgets/slider/examples/slider_demo.rs create mode 100644 widgets/slider/src/lib.rs create mode 100644 widgets/spinner/Cargo.toml create mode 100644 widgets/spinner/src/lib.rs create mode 100644 widgets/splash/Cargo.toml create mode 100644 widgets/splash/src/lib.rs create mode 100644 widgets/splitter/Cargo.toml create mode 100644 widgets/splitter/LEEME.md create mode 100644 widgets/splitter/README.md create mode 100644 widgets/splitter/examples/splitter_demo.rs create mode 100644 widgets/splitter/src/lib.rs create mode 100644 widgets/stat-card/Cargo.toml create mode 100644 widgets/stat-card/LEEME.md create mode 100644 widgets/stat-card/README.md create mode 100644 widgets/stat-card/src/lib.rs create mode 100644 widgets/status-bar/Cargo.toml create mode 100644 widgets/status-bar/src/lib.rs create mode 100644 widgets/switch/Cargo.toml create mode 100644 widgets/switch/src/lib.rs create mode 100644 widgets/tabs/Cargo.toml create mode 100644 widgets/tabs/LEEME.md create mode 100644 widgets/tabs/README.md create mode 100644 widgets/tabs/examples/tabs_demo.rs create mode 100644 widgets/tabs/src/lib.rs create mode 100644 widgets/text-area/Cargo.toml create mode 100644 widgets/text-area/LEEME.md create mode 100644 widgets/text-area/README.md create mode 100644 widgets/text-area/src/lib.rs create mode 100644 widgets/text-editor-core/Cargo.toml create mode 100644 widgets/text-editor-core/src/bracket.rs create mode 100644 widgets/text-editor-core/src/buffer.rs create mode 100644 widgets/text-editor-core/src/clipboard.rs create mode 100644 widgets/text-editor-core/src/cursor.rs create mode 100644 widgets/text-editor-core/src/diagnostics.rs create mode 100644 widgets/text-editor-core/src/find.rs create mode 100644 widgets/text-editor-core/src/highlight.rs create mode 100644 widgets/text-editor-core/src/lib.rs create mode 100644 widgets/text-editor-core/src/ops.rs create mode 100644 widgets/text-editor-core/src/undo.rs create mode 100644 widgets/text-editor-lsp/Cargo.toml create mode 100644 widgets/text-editor-lsp/LEEME.md create mode 100644 widgets/text-editor-lsp/README.md create mode 100644 widgets/text-editor-lsp/src/client.rs create mode 100644 widgets/text-editor-lsp/src/lib.rs create mode 100644 widgets/text-editor-lsp/src/protocol.rs create mode 100644 widgets/text-editor-lsp/src/tests.rs create mode 100644 widgets/text-editor/Cargo.toml create mode 100644 widgets/text-editor/LEEME.md create mode 100644 widgets/text-editor/README.md create mode 100644 widgets/text-editor/src/lib.rs create mode 100644 widgets/text-editor/src/state.rs create mode 100644 widgets/text-editor/src/view.rs create mode 100644 widgets/text-input/Cargo.toml create mode 100644 widgets/text-input/LEEME.md create mode 100644 widgets/text-input/README.md create mode 100644 widgets/text-input/src/lib.rs create mode 100644 widgets/theme-switcher/Cargo.toml create mode 100644 widgets/theme-switcher/LEEME.md create mode 100644 widgets/theme-switcher/README.md create mode 100644 widgets/theme-switcher/examples/theme_switcher_demo.rs create mode 100644 widgets/theme-switcher/src/lib.rs create mode 100644 widgets/tiled/Cargo.toml create mode 100644 widgets/tiled/LEEME.md create mode 100644 widgets/tiled/README.md create mode 100644 widgets/tiled/examples/tiled_demo.rs create mode 100644 widgets/tiled/src/lib.rs create mode 100644 widgets/timeline/Cargo.toml create mode 100644 widgets/timeline/src/lib.rs create mode 100644 widgets/toast/Cargo.toml create mode 100644 widgets/toast/src/lib.rs create mode 100644 widgets/tooltip/Cargo.toml create mode 100644 widgets/tooltip/src/lib.rs create mode 100644 widgets/tree/Cargo.toml create mode 100644 widgets/tree/LEEME.md create mode 100644 widgets/tree/README.md create mode 100644 widgets/tree/examples/tree_demo.rs create mode 100644 widgets/tree/src/lib.rs create mode 100644 widgets/wawa-mark/Cargo.toml create mode 100644 widgets/wawa-mark/examples/wawa_mark_demo.rs create mode 100644 widgets/wawa-mark/src/lib.rs 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/COMPUTO-FUERA-DEL-HILO-UI.md b/COMPUTO-FUERA-DEL-HILO-UI.md new file mode 100644 index 0000000..6ad2f47 --- /dev/null +++ b/COMPUTO-FUERA-DEL-HILO-UI.md @@ -0,0 +1,122 @@ +# Cómputo pesado fuera del hilo de UI — regla dura de Llimphi + +> **PRIORIDAD URGENTE.** Patrón a aplicar a **todas** las apps Llimphi. +> Origen: el "Not Responding" de cosmos (2026-05-31). Implementación de +> referencia: `01_yachay/cosmos/cosmos-app-llimphi` (commits `added8b3`, +> `9f221983`). + +## La regla + +Ningún `App::update`, `App::init` ni handler (`on_key`/`on_wheel`/…) debe +ejecutar trabajo pesado **síncrono**. Bloquea el hilo de UI → la ventana no +repinta, no responde, no cierra → "Not Responding". Es el antipatrón win32 de +trabajo pesado en el message loop. + +Crítico: en winit, **`App::init()` corre dentro de `resumed`, DESPUÉS de crear +la ventana**. Un cómputo pesado en init congela la ventana ya visible. + +Se nota brutal en **debug** (sin optimizar, 10–50× más lento; además debug +*panica* en overflow donde release *wrappea*). Pero la mala arquitectura está +igual en release: una carta pesada, una máquina lenta o un dataset grande la +exponen. + +"Pesado" = efemérides/simulación, layout de árboles grandes, IO de disco/red, +parse, embeddings, compresión… cualquier cosa que pueda pasar de ~unos ms. + +## El patrón (mover a un worker) + +```rust +// 1) Mensaje de resultado: u64 = generación; Arc porque Msg: Clone. +enum Msg { /* … */ XComputed(u64, std::sync::Arc) } + +// 2) En el Model: el resultado es Option (None = "calculando…"), +// más un flag dirty y un contador de generación. +struct Model { x: Option, x_dirty: bool, x_gen: u64, /* … */ } + +// 3) recompute_x sólo marca dirty (los helpers no tienen el Handle). +fn recompute_x(m: &mut Model) { m.x_dirty = true; } + +// 4) Al FINAL de update() (que SÍ tiene el Handle): si está sucio, bumpear +// generación, clonar los inputs y despachar a un worker. +if m.x_dirty { + m.x_dirty = false; + m.x_gen = m.x_gen.wrapping_add(1); + let gen = m.x_gen; + let input = m.input.clone(); // sólo lo que el worker necesita + handle.spawn(move || Msg::XComputed(gen, std::sync::Arc::new(compute(&input)))); +} + +// 5) Arm del resultado: aplicar SÓLO si la generación sigue vigente +// (un recálculo posterior ya dejó viejo a este). try_unwrap evita copiar +// (el Arc llega con refcount 1 porque el Msg no se clona en el camino). +Msg::XComputed(gen, x) => { + if gen == m.x_gen { + m.x = Some(std::sync::Arc::try_unwrap(x).unwrap_or_else(|a| (*a).clone())); + } +} + +// 6) En init: arrancar con None y despachar el primer cómputo a un worker +// (init tiene el Handle). La vista pinta "calculando…" mientras tanto. + +// 7) En la vista: match &model.x { Some(v) => panel(v), None => calculando() } +``` + +Notas: +- El campo `Option` exige `T: Clone` (para el fallback de `try_unwrap`). +- La **generación** evita que un resultado tardío pise a uno más nuevo + (drags, toggles rápidos). Imprescindible si el recálculo puede dispararse + seguido. +- Inputs al worker deben ser `Send` (clonar `Chart`, `Vec`, etc.). +- No hace falta async-ear lo barato: en cosmos el render de la carta quedó + síncrono (con el solver acotado son ms); sólo el astro (144 muestras × 10 + cuerpos) fue a worker. + +## Soluciones colaterales de la misma cacería (ya aplicadas, no revertir) + +- **Preferir Vulkan en `llimphi-hal`** (`Hal::new`, commit `9f221983`): pedir + adapter con `Backends::PRIMARY` y caer a `all()` (incluye GL) sólo si no hay + PRIMARY. El backend **GL de Mesa sobre Wayland segfaultea en el teardown** + (`eglTerminate → wl_proxy_marshal` sobre conexión muerta, exit 139 sin + panic). Es infra compartida → ya beneficia a todas las apps. No volver a + `InstanceDescriptor::default()`. +- **Acotar solvers iterativos** (`cosmos-ephemeris`, Kepler, commit `added8b3`): + un `loop {}` con corte `dl.abs() < 1e-15` (pegado al epsilon de f64) entra en + ciclo límite y NO converge para ciertos inputs → loop infinito. Release + fusiona flops (FMA) y converge; debug no. **Todo solver Newton/bisección + lleva cota dura** (`for _ in 0..N`), no `loop {}`. + +## Cómo diagnosticar (sin ptrace; `ptrace_scope=1` bloquea gdb a no-hijos) + +- `/proc/$PID/wchan` del hilo principal: `do_epoll_wait` = ocioso sano; + `__futex_wait` = deadlock de lock; estado `R` sostenido = spin o cómputo en + el hilo de UI; `dma_fence`/`drm` = GPU; `poll` sobre fd `wayland-0` = frame + callback. +- gdb **como PADRE** sí puede (lanzar la app *bajo* gdb): backtrace del spin/ + segfault. La pila de wgpu revela el backend (`wgpu_hal::gles` vs vulkan). +- Trazar con un `eprintln` ENTER/DONE para distinguir "una llamada que no + termina" (loop infinito) de "se llama repetidas veces" (storm de dispatch). +- En debug arranca como `cargo run` (binario `target/debug`); el release puede + ocultar el bug (float/overflow distintos). + +## Checklist — auditar y aplicar a cada app + +Buscar trabajo pesado en `init`/`update`/handlers y moverlo a worker: + +- [x] `01_yachay/cosmos/cosmos-app-llimphi` (referencia) +- [ ] `00_unanchay/pluma/pluma-app` +- [ ] `00_unanchay/pluma/pluma-editor-llimphi` +- [ ] `00_unanchay/pluma/pluma-notebook-llimphi` +- [ ] `00_unanchay/puriy/puriy-llimphi` (motor JS/render — alto riesgo) +- [ ] `00_unanchay/khipu/khipu-app` +- [ ] `00_unanchay/chaka/chaka-app-llimphi` +- [ ] `01_yachay/dominium/dominium-app-llimphi` +- [ ] `01_yachay/nakui/nakui-ui-llimphi`, `nakui-sheet-llimphi`, `nakui-explorer-llimphi` +- [ ] `01_yachay/iniy/iniy-explorer-llimphi` +- [ ] `01_yachay/tinkuy/tinkuy-llimphi` (simulación — alto riesgo) +- [ ] `02_ruway/ayni/ayni-llimphi` +- [ ] `02_ruway/chasqui/chasqui-explorer-llimphi`, `chasqui-broker-explorer-llimphi` +- [ ] `02_ruway/nada`, `02_ruway/mirada/*-llimphi` +- [ ] `pineal-*` (charting — revisar si el cómputo de series corre en update) + +(Lista de partida: `grep -rl 'llimphi-ui' --include=Cargo.toml`. Los widgets/ +modules/demos rara vez hacen cómputo pesado; foco en las apps de dominio.) diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f58b6a5 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3848 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.12.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.12.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.12.1", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "font-types" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02a596f5713680923a2080d86de50fe472fb290693cf0f701187a1c8b36996b7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-cache-parser" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f8afb20c8069fd676d27b214559a337cc619a605d25a87baa90b49a06f3b18" +dependencies = [ + "bytemuck", + "thiserror 1.0.69", +] + +[[package]] +name = "fontique" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64763d1f274c8383333851435b6cdf071c31cfcdb39fd5860d20943205a007a7" +dependencies = [ + "bytemuck", + "fontconfig-cache-parser", + "hashbrown 0.15.5", + "icu_locid", + "memmap2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", + "peniko", + "read-fonts 0.29.3", + "roxmltree", + "smallvec", + "windows", + "windows-core", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.12.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.12.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags 2.12.1", + "libc", + "plain", + "redox_syscall 0.8.1", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "llimphi-clipboard" +version = "0.1.0" +dependencies = [ + "arboard", + "llimphi-widget-text-editor", +] + +[[package]] +name = "llimphi-compositor" +version = "0.1.0" +dependencies = [ + "llimphi-layout", + "llimphi-text", + "vello", + "wgpu", +] + +[[package]] +name = "llimphi-hal" +version = "0.1.0" +dependencies = [ + "pollster", + "raw-window-handle", + "wgpu", + "winit", +] + +[[package]] +name = "llimphi-icons" +version = "0.1.0" +dependencies = [ + "llimphi-ui", +] + +[[package]] +name = "llimphi-layout" +version = "0.1.0" +dependencies = [ + "llimphi-hal", + "llimphi-raster", + "pollster", + "taffy", +] + +[[package]] +name = "llimphi-module-bookmarks" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-text-input", + "nucleo-matcher", +] + +[[package]] +name = "llimphi-module-command-palette" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-text-input", + "nucleo-matcher", +] + +[[package]] +name = "llimphi-module-diff-viewer" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "similar", +] + +[[package]] +name = "llimphi-module-fif" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-text-input", +] + +[[package]] +name = "llimphi-module-file-picker" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-text-input", +] + +[[package]] +name = "llimphi-module-mini-map" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-module-selector" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-module-symbol-outline" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-text-input", + "nucleo-matcher", +] + +[[package]] +name = "llimphi-motion" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-raster" +version = "0.1.0" +dependencies = [ + "llimphi-hal", + "pollster", + "vello", +] + +[[package]] +name = "llimphi-surface" +version = "0.1.0" +dependencies = [ + "llimphi-hal", + "llimphi-ui", + "parking_lot", +] + +[[package]] +name = "llimphi-text" +version = "0.1.0" +dependencies = [ + "llimphi-hal", + "llimphi-raster", + "parley", + "pollster", + "vello", +] + +[[package]] +name = "llimphi-theme" +version = "0.1.0" +dependencies = [ + "llimphi-raster", +] + +[[package]] +name = "llimphi-ui" +version = "0.1.0" +dependencies = [ + "llimphi-compositor", + "llimphi-hal", + "llimphi-layout", + "llimphi-raster", + "llimphi-text", + "pollster", +] + +[[package]] +name = "llimphi-widget-app-header" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-avatar" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-badge" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-banner" +version = "0.1.0" +dependencies = [ + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-breadcrumb" +version = "0.1.0" +dependencies = [ + "llimphi-icons", + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-button" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-card" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-context-menu" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-dock-rail" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-edit-menu" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-context-menu", + "llimphi-widget-text-editor", +] + +[[package]] +name = "llimphi-widget-empty" +version = "0.1.0" +dependencies = [ + "llimphi-icons", + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-field" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-grid" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-list" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-modal" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-navigator" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-nodegraph", + "llimphi-widget-segmented", + "llimphi-widget-tree", +] + +[[package]] +name = "llimphi-widget-nodegraph" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-panel" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-panes" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-progress" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-scroll" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-segmented" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-shortcuts-help" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-skeleton" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-slider" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-spinner" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-splash" +version = "0.1.0" +dependencies = [ + "llimphi-motion", + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-splitter" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-stat-card" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-card", +] + +[[package]] +name = "llimphi-widget-status-bar" +version = "0.1.0" +dependencies = [ + "llimphi-icons", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-switch" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-tabs" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-text-area" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-text-editor" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-text-editor-core", + "tree-sitter", +] + +[[package]] +name = "llimphi-widget-text-editor-core" +version = "0.1.0" +dependencies = [ + "peniko", + "ropey", + "tree-sitter", + "tree-sitter-python", + "tree-sitter-rust", +] + +[[package]] +name = "llimphi-widget-text-editor-lsp" +version = "0.1.0" +dependencies = [ + "llimphi-widget-text-editor", + "lsp-types", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "llimphi-widget-text-input" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-text-editor", +] + +[[package]] +name = "llimphi-widget-theme-switcher" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-tiled" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-timeline" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-toast" +version = "0.1.0" +dependencies = [ + "llimphi-icons", + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-tooltip" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-tree" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-wawa-mark" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-workspace" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panes", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" + +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri", + "serde", + "serde_json", + "serde_repr", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "metal" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" +dependencies = [ + "bitflags 2.12.1", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e380993072e52eef724eddfcde0ed013b0c023c3f0417336ed041aa9f076994e" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.12.1", + "cfg_aliases", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "rustc-hash", + "spirv", + "strum", + "termcolor", + "thiserror 2.0.18", + "unicode-xid", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.12.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.12.1", + "block2", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.12.1", + "objc2 0.6.4", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.12.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.12.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.12.1", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.12.1", + "block2", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.12.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.12.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "parley" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28dadbe655332fd7d996794ec8d0c376695f6ca47bc75aa01e0967c7f28e42a" +dependencies = [ + "fontique", + "hashbrown 0.15.5", + "peniko", + "skrifa 0.31.3", + "swash", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "peniko" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b44f9ddd2f480176b34278eb653ec1c8062f3b143a4e16eeff5ffac3334e288" +dependencies = [ + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.12.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "read-fonts" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04ca636dac446b5664bd16c069c00a9621806895b8bb02c2dc68542b23b8f25d" +dependencies = [ + "bytemuck", + "font-types 0.9.0", +] + +[[package]] +name = "read-fonts" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ea612a55c08586a1d15134be8a776186c440c312ebda3b9e8efbfe4255b7f4" +dependencies = [ + "bytemuck", + "font-types 0.9.0", +] + +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "redox_syscall" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "ropey" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5" +dependencies = [ + "smallvec", + "str_indices", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.12.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.12.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "skrifa" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbeb4ca4399663735553a09dd17ce7e49a0a0203f03b706b39628c4d913a8607" +dependencies = [ + "bytemuck", + "read-fonts 0.29.3", +] + +[[package]] +name = "skrifa" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "576e60c7de4bb6a803a0312f9bef17e78cf1e8d25a80e1ade76770d7a0237955" +dependencies = [ + "bytemuck", + "read-fonts 0.33.1", +] + +[[package]] +name = "skrifa" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +dependencies = [ + "bytemuck", + "read-fonts 0.37.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.12.1", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "swash" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "842f3cd369c2ba38966204f983eaa5e54a8e84a7d7159ed36ade2b6c335aae64" +dependencies = [ + "skrifa 0.40.0", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "taffy" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" + +[[package]] +name = "tree-sitter" +version = "0.24.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5387dffa7ffc7d2dae12b50c6f7aab8ff79d6210147c6613561fc3d474c6f75" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-python" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "vello" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa3f8a53870a2ee699ce05b738a3f9974c92c35ed4874de86052ac68d214811c" +dependencies = [ + "bytemuck", + "futures-intrusive", + "log", + "peniko", + "png 0.17.16", + "skrifa 0.35.0", + "static_assertions", + "thiserror 2.0.18", + "vello_encoding", + "vello_shaders", + "wgpu", +] + +[[package]] +name = "vello_encoding" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c69b0fe94b0ac7e47619c504ee2c377355174f5c46353c46d03fa5f7e435922b" +dependencies = [ + "bytemuck", + "guillotiere", + "peniko", + "skrifa 0.35.0", + "smallvec", +] + +[[package]] +name = "vello_shaders" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ebea426bb2f95b7610bca09178b03d809ede1d3c500a9acf6eca43e8f200be" +dependencies = [ + "bytemuck", + "naga", + "thiserror 2.0.18", + "vello_encoding", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.12.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.12.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.12.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.12.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.12.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "24.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0b3436f0729f6cdf2e6e9201f3d39dc95813fad61d826c1ed07918b4539353" +dependencies = [ + "arrayvec", + "bitflags 2.12.1", + "cfg_aliases", + "document-features", + "js-sys", + "log", + "naga", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "24.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f0aa306497a238d169b9dc70659105b4a096859a34894544ca81719242e1499" +dependencies = [ + "arrayvec", + "bit-vec", + "bitflags 2.12.1", + "cfg_aliases", + "document-features", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "rustc-hash", + "smallvec", + "thiserror 2.0.18", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "24.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112f464674ca69f3533248508ee30cb84c67cf06c25ff6800685f5e0294e259" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.12.1", + "block", + "bytemuck", + "cfg_aliases", + "core-graphics-types", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "ordered-float", + "parking_lot", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash", + "smallvec", + "thiserror 2.0.18", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows", + "windows-core", +] + +[[package]] +name = "wgpu-types" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ac044c0e76c03a0378e7786ac505d010a873665e2d51383dcff8dd227dc69c" +dependencies = [ + "bitflags 2.12.1", + "js-sys", + "log", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.12.1", + "block2", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.12.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a40cbb4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,441 @@ +# Cargo.toml raíz STANDALONE de Llimphi — dry-run de extracción. +# Generado desde la raíz de gioser quitando el prefijo 02_ruway/llimphi/ a los +# path-deps internos. Excluye los 3 crates acoplados al resto del workspace +# (menubar→app-bus, shuma-term→shuma-exec, plugin-host→card-core) y los demos +# gallery que los agregan, más android (target propio). +[workspace] +resolver = "2" +members = [ + "llimphi-hal", "llimphi-raster", "llimphi-layout", "llimphi-text", + "llimphi-ui", "llimphi-theme", "llimphi-surface", "llimphi-motion", + "llimphi-icons", "llimphi-compositor", "llimphi-workspace", + "widgets/*", "modules/*", +] +exclude = [ + "android", + "llimphi-gallery", "llimphi-gpu-bench", + "widgets/gallery", "widgets/menubar", + "modules/shuma-term", "modules/plugin-host", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +rust-version = "1.80" +license = "MIT" +authors = ["Sergio "] +publish = false +repository = "https://gitea.gioser.net/sergio/llimphi" + +[workspace.dependencies] +# === Registro de apps / menú global === +app-bus = { path = "shared/app-bus" } +# === Serialización === +serde = { version = "1", features = ["derive"] } +serde_json = "1" +lsp-types = "0.97" +serde-big-array = "0.5" +postcard = { version = "1", features = ["use-std"] } +toml = "0.8" +ron = "0.8" +bincode = "1" +base64 = "0.22" + +# === Errores === +thiserror = "2" # bump uniforme; arje (era 1) puede requerir ajustes menores +anyhow = "1" + +# === Async === +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["compat"] } +async-trait = "0.1" +futures = "0.3" + +# === Observabilidad === +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } + +# === Linux primitives (arje) === +nix = { version = "0.29", features = ["signal", "process", "sched", "mount", "fs", "socket", "net", "user"] } +libc = "0.2" + +# === IDs / Hash / Crypto === +ulid = { version = "1", features = ["serde"] } +uuid = { version = "1", features = ["v4", "rng-getrandom"] } +sha2 = "0.10" +blake3 = "1.5" +ed25519-dalek = "2" +aes-gcm = "0.10" +chacha20poly1305 = "0.10" +argon2 = "0.5" +rand = "0.8" + +# === WASM (arje) === +# wasmi 1.0: unifica la versión con renaser (su kernel ya corre 1.0), para +# que el ABI WASM del host sea idéntico en Linux y en bare-metal. +wasmi = "1.0" +wat = "1" + +# === Storage / DB === +sled = "0.34" +rusqlite = { version = "0.31", features = ["bundled", "blob"] } + +# === Ingesta de documentos (iniy-ingest: PDF / EPUB) === +pdf-extract = "0.7" +epub = "2.1" + +# === Bulk import Wikipedia (iniy-wiki dump) === +bzip2 = "0.4" + +# === Compresión (minga multi-bundle) === +zstd = "0.13" + +# === HTTP server (iniy-server) === +axum = "0.7" +tower = "0.5" + +# === ANN sobre embeddings (iniy nli --ann) === +instant-distance = "0.6" + +# === P2P (minga) === +libp2p = { version = "0.56", features = ["tokio", "tcp", "noise", "yamux", "macros", "kad", "identify", "relay", "dcutr", "autonat", "mdns"] } +libp2p-stream = "=0.4.0-alpha" +libp2p-allow-block-list = "0.6" + +# === SSH (ssh, sandokan RemoteEngine, matilda) === +russh = "0.54" + +# === Math determinista cross-platform (dominium) === +libm = "0.2" + +# === SMF (takiy-midi) === +# midly: parser/emitter SMF tipo 0/1, no_std-friendly, sin allocs en hot path. +midly = "0.5" + +# === Code parsing (minga) === +arboard = "3" +ropey = "1.6" +tree-sitter = "0.24" +tree-sitter-rust = "0.23" +tree-sitter-python = "0.23" +tree-sitter-typescript = "0.23" +tree-sitter-javascript = "0.23" +tree-sitter-go = "0.23" + +# === FS notify === +notify = "6.1" + +# === Grafos (iniy, nakui-core ya lo usa directo en 0.6) === +petgraph = "0.6" + +# === Image decoding (nahual-image-viewer-llimphi) === +# default-features = false: nos quedamos con PNG + JPEG + WebP (lossless). +# tullpu-render exporta a las tres; AVIF/TIFF/… los habilitamos si una app +# los pide específicamente. +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } + +# === FUSE (minga-vfs) === +# default-features = false: prescinde de pkg-config/libfuse-dev en build. +# El montaje pasa a ser Rust puro (vía el helper `fusermount3` en runtime). +fuser = { version = "0.15", default-features = false } + +# === CLI / auth (minga) === +clap = { version = "4", features = ["derive"] } +rpassword = "7" + +# === PAM (auth-core) === +pam = "0.8" + +# === D-Bus (arje compat) === +zbus = { version = "4", default-features = false, features = ["tokio"] } + +# === Tests === +tempfile = "3" + +# === Llimphi (motor gráfico soberano) === +# wgpu sobre Vulkan/Metal/DX12, winit para ventana en dev Linux. +# raw-window-handle 0.6 alinea winit 0.30 con wgpu 24. +# vello 0.5 = rasterizador vectorial sobre wgpu 24. +# taffy 0.9 = motor Flexbox/Grid puro Rust (ya pulled por transitivos, lo alineamos). +# parley 0.2 = shaping/layout de texto compatible con peniko 0.4 (que vello 0.5 expone). +wgpu = "24" +winit = "0.30" +raw-window-handle = "0.6" +pollster = "0.4" +vello = "0.5" +taffy = "0.9" +# parley = shaping completo (bidi, ligatures, fallback CJK/emoji vía fontique, line break). +parley = "0.4" +# Bucle Elm (input→update→view→layout→raster→present). Lo consumen las apps. +llimphi-ui = { path = "llimphi-ui" } +# Paleta semántica compartida por las apps y los widgets. +llimphi-theme = { path = "llimphi-theme" } +# Tweens y helpers de animación sobre el bucle Elm. +llimphi-motion = { path = "llimphi-motion" } +# Iconos vectoriales (BezPath en grid 24×24) compartidos por todas las apps. +llimphi-icons = { path = "llimphi-icons" } +# Widgets reusables sobre llimphi-ui — uno por crate. +llimphi-widget-app-header = { path = "widgets/app-header" } +llimphi-widget-banner = { path = "widgets/banner" } +llimphi-widget-button = { path = "widgets/button" } +llimphi-widget-card = { path = "widgets/card" } +llimphi-clipboard = { path = "widgets/clipboard" } +llimphi-widget-context-menu = { path = "widgets/context-menu" } +llimphi-widget-edit-menu = { path = "widgets/edit-menu" } +llimphi-widget-menubar = { path = "widgets/menubar" } +llimphi-widget-list = { path = "widgets/list" } +llimphi-widget-grid = { path = "widgets/grid" } +llimphi-widget-slider = { path = "widgets/slider" } +llimphi-widget-scroll = { path = "widgets/scroll" } +llimphi-widget-splitter = { path = "widgets/splitter" } +llimphi-widget-stat-card = { path = "widgets/stat-card" } +llimphi-widget-tabs = { path = "widgets/tabs" } +llimphi-module-command-palette = { path = "modules/command-palette" } +llimphi-module-diff-viewer = { path = "modules/diff-viewer" } +llimphi-module-fif = { path = "modules/fif" } +llimphi-module-file-picker = { path = "modules/file-picker" } +llimphi-module-bookmarks = { path = "modules/bookmarks" } +llimphi-module-mini-map = { path = "modules/mini-map" } +llimphi-module-shuma-term = { path = "modules/shuma-term" } +llimphi-module-symbol-outline = { path = "modules/symbol-outline" } +llimphi-plugin-host = { path = "modules/plugin-host" } +llimphi-widget-theme-switcher = { path = "widgets/theme-switcher" } +llimphi-widget-text-area = { path = "widgets/text-area" } +llimphi-widget-text-editor-core = { path = "widgets/text-editor-core" } +llimphi-widget-text-editor = { path = "widgets/text-editor" } +llimphi-widget-text-editor-lsp = { path = "widgets/text-editor-lsp" } +llimphi-widget-text-input = { path = "widgets/text-input" } +llimphi-widget-tiled = { path = "widgets/tiled" } +llimphi-widget-nodegraph = { path = "widgets/nodegraph" } +llimphi-widget-tree = { path = "widgets/tree" } +llimphi-widget-navigator = { path = "widgets/navigator" } +# Sello vectorial wawa (rombo + W implícita + Merkle Core). +llimphi-widget-wawa-mark = { path = "widgets/wawa-mark" } +# Widgets de elegancia transversal (tooltip, spinner, progress, toast, +# modal, empty, status-bar, shortcuts-help, splash). +llimphi-widget-tooltip = { path = "widgets/tooltip" } +llimphi-widget-spinner = { path = "widgets/spinner" } +llimphi-widget-progress = { path = "widgets/progress" } +llimphi-widget-toast = { path = "widgets/toast" } +llimphi-widget-modal = { path = "widgets/modal" } +llimphi-widget-empty = { path = "widgets/empty" } +llimphi-widget-status-bar = { path = "widgets/status-bar" } +llimphi-widget-shortcuts-help = { path = "widgets/shortcuts-help" } +llimphi-widget-timeline = { path = "widgets/timeline" } +llimphi-widget-splash = { path = "widgets/splash" } +# Controles de formulario y signaling (switch, segmented, breadcrumb, +# badge, avatar, skeleton, field). +llimphi-widget-switch = { path = "widgets/switch" } +llimphi-widget-segmented = { path = "widgets/segmented" } +llimphi-widget-dock-rail = { path = "widgets/dock-rail" } +llimphi-widget-breadcrumb = { path = "widgets/breadcrumb" } +llimphi-widget-badge = { path = "widgets/badge" } +llimphi-widget-avatar = { path = "widgets/avatar" } +llimphi-widget-skeleton = { path = "widgets/skeleton" } +llimphi-widget-field = { path = "widgets/field" } +# Firma visual transversal (gradient sutil + hairline accent). +llimphi-widget-panel = { path = "widgets/panel" } +llimphi-widget-panes = { path = "widgets/panes" } +llimphi-workspace = { path = "llimphi-workspace" } +# Abstracción Selector — host (paths) + wawa (khipus). +llimphi-module-selector = { path = "modules/selector" } + +# === Filesystem helpers === +directories = "5" + +# === Diff line-based (llimphi-module-diff-viewer) === +# `similar` es la crate de facto: implementa Myers + Patience + LCS, +# expone `TextDiff` con ChangeTag por línea (Equal/Insert/Delete), +# zero deps fuera de std. La 2.x es estable hace años. +similar = "2" + +# === Fuzzy matching (shuma-history) === +# nucleo-matcher = mismo matcher que helix-editor: rápido, Unicode-correct, +# bonus por prefijos, ranking estable. La versión 0.3 expone el API simple +# que necesitamos (Matcher + Pattern + score). +nucleo-matcher = "0.3" + +# === Transporte autenticado (shuma-link) === +# snow = framework Noise pure-rust. Lo usamos en modo Noise_XK (cliente +# conoce la pubkey del servidor, server descubre la del cliente y la +# valida contra una allowlist). ChaCha20-Poly1305 + X25519 + BLAKE2s. +# La versión 0.9 viene pinneada por libp2p, así nos alineamos. +snow = "0.9" +hex = "0.4" + +# === PTY + emulador de terminal (shuma-exec, módulos REPL) === +# portable-pty aloja un PTY cross-platform; lo usamos para los +# comandos TUI tipo vim/htop/less que necesitan un terminal de verdad. +# vt100 parsea la secuencia de bytes que el PTY emite (ANSI + cursor +# movement + erase + screen state) y mantiene un buffer de pantalla +# renderizable como grid. +portable-pty = "0.9" +vt100 = "0.16" + +# === WASM web (gioser) === +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +web-sys = "0.3" +glam = "0.30" + +# === Markdown (pluma) === +pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] } + +# === Archivos comprimidos (nahual archive viewer) === +# Sólo listamos el directorio central (nombres/tamaños); no descomprimimos, +# por eso default-features=false alcanza para ZIP. Para tar.gz sí +# descomprimimos en streaming con flate2 (ya declarado arriba), saltando +# los datos de cada entrada — sólo leemos headers. +zip = { version = "2.4", default-features = false } +tar = { version = "0.4", default-features = false } + +# === Fuentes (nahual font viewer) === +# Parseo de TTF/OTF/TTC y extracción de contornos de glifo a paths. +ttf-parser = "0.25" + +# ============================================================ +# Intra-workspace deps de nahual (referenciadas por workspace = true) +# ============================================================ +nahual-text-viewer-llimphi = { path = "02_ruway/nahual/nahual-text-viewer-llimphi" } +nahual-image-viewer-llimphi = { path = "02_ruway/nahual/nahual-image-viewer-llimphi" } +nahual-thumb-core = { path = "02_ruway/nahual/nahual-thumb-core" } +nahual-gallery-llimphi = { path = "02_ruway/nahual/nahual-gallery-llimphi" } +nahual-video-viewer-llimphi = { path = "02_ruway/nahual/nahual-video-viewer-llimphi" } +nahual-card-viewer-llimphi = { path = "02_ruway/nahual/nahual-card-viewer-llimphi" } +nahual-audio-viewer-llimphi = { path = "02_ruway/nahual/nahual-audio-viewer-llimphi" } +nahual-tree-viewer-llimphi = { path = "02_ruway/nahual/nahual-tree-viewer-llimphi" } +nahual-hex-viewer-llimphi = { path = "02_ruway/nahual/nahual-hex-viewer-llimphi" } +nahual-table-viewer-llimphi = { path = "02_ruway/nahual/nahual-table-viewer-llimphi" } +nahual-markdown-viewer-llimphi = { path = "02_ruway/nahual/nahual-markdown-viewer-llimphi" } +nahual-archive-viewer-llimphi = { path = "02_ruway/nahual/nahual-archive-viewer-llimphi" } +nahual-font-viewer-llimphi = { path = "02_ruway/nahual/nahual-font-viewer-llimphi" } +nahual-map-viewer-llimphi = { path = "02_ruway/nahual/nahual-map-viewer-llimphi" } +nahual-geo-core = { path = "02_ruway/nahual/nahual-geo-core" } +nahual-viewer-core = { path = "02_ruway/nahual/nahual-viewer-core" } +nahual-file-explorer-llimphi = { path = "02_ruway/nahual/nahual-file-explorer-llimphi" } + +# ============================================================ +# Intra-workspace deps de pineal (módulo de gráficos) +# ============================================================ +pineal-core = { path = "00_unanchay/pineal/pineal-core" } +pineal-render = { path = "00_unanchay/pineal/pineal-render" } +pineal-cartesian = { path = "00_unanchay/pineal/pineal-cartesian" } +pineal-stream = { path = "00_unanchay/pineal/pineal-stream" } +pineal-mesh = { path = "00_unanchay/pineal/pineal-mesh" } +pineal-financial = { path = "00_unanchay/pineal/pineal-financial" } +pineal-polar = { path = "00_unanchay/pineal/pineal-polar" } +pineal-heatmap = { path = "00_unanchay/pineal/pineal-heatmap" } +pineal-treemap = { path = "00_unanchay/pineal/pineal-treemap" } +pineal-flow = { path = "00_unanchay/pineal/pineal-flow" } +pineal-phosphor = { path = "00_unanchay/pineal/pineal-phosphor" } +pineal-export = { path = "00_unanchay/pineal/pineal-export" } +pineal-hexbin = { path = "00_unanchay/pineal/pineal-hexbin" } +pineal-contour = { path = "00_unanchay/pineal/pineal-contour" } +pineal-bars = { path = "00_unanchay/pineal/pineal-bars" } +pineal = { path = "00_unanchay/pineal/pineal-umbrella" } + +# ============================================================ +# Intra-workspace deps de iniy (laboratorio semántico de creencias) +# ============================================================ +iniy-core = { path = "01_yachay/iniy/iniy-core" } +iniy-ingest = { path = "01_yachay/iniy/iniy-ingest" } +iniy-extract = { path = "01_yachay/iniy/iniy-extract" } +iniy-nli = { path = "01_yachay/iniy/iniy-nli" } +iniy-nli-llm = { path = "01_yachay/iniy/iniy-nli-llm" } +iniy-graph = { path = "01_yachay/iniy/iniy-graph" } +iniy-store = { path = "01_yachay/iniy/iniy-store" } + +# === auto: declarados por crates internos faltantes === +cosmos-coords = { path = "01_yachay/cosmos/cosmos-coords" } +cosmos-core = { path = "01_yachay/cosmos/cosmos-core" } +cosmos-ephemeris = { path = "01_yachay/cosmos/cosmos-ephemeris" } +cosmos-time = { path = "01_yachay/cosmos/cosmos-time" } +cosmos-wcs = { path = "01_yachay/cosmos/cosmos-wcs" } + +# === auto: externas de eternal === +celestial-eop-data = { version = "0.1"} +approx = "0.5" +byteorder = "1.5" +cc = "1.0" +chrono = "0.4" +crc32fast = "1.4" +criterion = "0.5" +csv = "1.4" +flate2 = "1.0" +glob = "0.3" +indicatif = "0.18" +lz4_flex = "0.11" +memmap2 = "0.9" +mockito = "1.0" +ndarray = "0.15" +num-traits = "0.2" +once_cell = "1.19" +parking_lot = "0.12" +png = "0.18" +proptest = "1.4" +quick-xml = "0.31" +rayon = "1.8" +regex = "1.11" +reqwest = "0.12" +tiff = "0.11" +wide = "0.7" +wiremock = "0.6" + +# === i18n (rimay-localize) === +fluent-bundle = "0.15" +unic-langid = { version = "0.9", features = ["macros"] } +sys-locale = "0.3" + +# === Servo (puriy-engine) === +# Crates publicados de Servo embebibles individualmente. html5ever/markup5ever +# ya entran via ammonia→surrealdb→nakui, así que alineamos versión para no +# duplicar el árbol. markup5ever_rcdom es el DOM Rc-based simple (suficiente +# para Fase 2: parsear y renderizar, sin scripting). cssparser es el tokenizer +# CSS de Stylo, sirve para inline styles. ureq = HTTP síncrono minimalista, +# evita pull de tokio en el engine. +html5ever = "0.39" +markup5ever = "0.39" +markup5ever_rcdom = "0.39" +cssparser = "0.35" +url = "2" +ureq = { version = "2", default-features = false, features = ["tls"] } + +# === takiy-synth (SoundFont MIDI) === +# rustysynth = sintetizador SF2 puro Rust, MIT. Reemplaza el oscilador +# feo de takiy-synth por muestras reales (FluidR3, GeneralUser GS, etc). +rustysynth = "1.3" + +# === takiy-playback (audio device output) === +# cpal = backend de audio cross-platform (ALSA/PulseAudio/Pipewire en +# Linux, WASAPI en Windows, CoreAudio en macOS). Lo usamos sólo para +# abrir el device default y empujar muestras f32 — nada de mezclado +# ni efectos en el callback. +cpal = "0.15" + +# === media-source-wav (decoder PCM en disco) === +# hound = lector/escritor WAV puro-Rust, sin deps nativas. Soporta PCM +# entero (8/16/24/32) y float (32). Suficiente para abrir samples y +# stems de prueba sin meter ffmpeg/symphonia. +hound = "3.5" + +# === media-source-{mp3,flac,vorbis} (decoders vía symphonia) === +# symphonia es una colección de decoders puro-Rust mantenida. `mp3` cubre +# media-source-mp3; `flac` (decoder + demuxer FLAC nativo) cubre +# media-source-flac (lossless); `vorbis` + `ogg` (codec + demuxer Ogg) +# cubren media-source-vorbis (lossy clásico, libre de patentes). Sin aac: +# ese tier patentado entra por shared/foreign-av. +symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg"] } + +# === media-source-opus (decoder Opus NATIVO puro-Rust) === +# Opus es el formato de audio nativo de gioser (par del video AV1). ogg +# demuxea las páginas Ogg; opus-wave es un port puro-Rust de libopus +# (SILK+CELT, sin C ni FFI) — par del rav1d del lado video. +ogg = "0.9" +opus-wave = "3" + +# === media-source-webm (demux nativo Matroska/WebM) === +# matroska-demuxer es un demuxer puro-Rust de MKV/WebM (EBML). Saca los +# paquetes de los tracks V_AV1 y A_OPUS para alimentar a media-source-av1 +# y media-source-opus — un .webm AV1+Opus se reproduce 100% nativo. +matroska-demuxer = "0.7" diff --git a/LEEME.md b/LEEME.md new file mode 100644 index 0000000..9949174 --- /dev/null +++ b/LEEME.md @@ -0,0 +1,90 @@ +# llimphi + +> Framework de UI nativa: HAL · raster · layout · text · theme · ui — más widgets y módulos. + +`llimphi` es el motor gráfico que comparten todas las apps del monorepo. Pipeline retained-mode declarativa sobre `vello` + `wgpu` + `taffy`, con shaping `fontdue`/`harfbuzz`, theme `Dark/Light/Aurora/Sunset`, HAL multiplataforma (Wayland · X11 · Win32 · Android · Wawa). + +**Manual de uso:** [MANUAL.md](MANUAL.md) — referencia completa (bucle Elm, DSL `View`, los ~44 widgets y 10 módulos, GPU directo, gotchas) para humanos e IA. Diseño y roadmap: [SDD.md](SDD.md). + +Filosofía: **un widget no se diseña pensando en mockups; se diseña con lo que `vello` y `taffy` pueden hacer.** + +## Instalación + +```sh +# usar como dep en otro crate: +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-... = { workspace = true } +``` + +## Compatibilidad + +- **Linux/Wayland** — backend principal. +- **Linux/X11** — via XWayland (mediante `winit`). +- **macOS / Windows** — `winit` + `wgpu`. +- **Android** — `clear-screen-android`, `vello-hello-android`, `vello-text-android` para validar el HAL móvil. +- **Wawa bare-metal** — HAL alterno sobre framebuffer. + +## Crates: framework + +| Crate | Rol | +|---|---| +| [`llimphi-hal`](llimphi-hal/README.md) | Abstracción de superficie (winit / framebuffer / android). | +| [`llimphi-raster`](llimphi-raster/README.md) | Rasterizer vello + cache de scenes. | +| [`llimphi-layout`](llimphi-layout/README.md) | Layout taffy + extensiones. | +| [`llimphi-text`](llimphi-text/README.md) | Shaping + fonts (Fontdue/HarfBuzz). | +| [`llimphi-theme`](llimphi-theme/README.md) | Themes Dark/Light/Aurora/Sunset + paleta. | +| [`llimphi-ui`](llimphi-ui/README.md) | `View` retained-mode + Elm-arch. | + +## Crates: widgets (visuales reactivos) + +| Widget | Función | +|---|---| +| [`button`](widgets/button/README.md) | Botón con variantes. | +| [`text-input`](widgets/text-input/README.md) | Input single-line. | +| [`text-area`](widgets/text-area/README.md) | Textarea multi-line. | +| [`text-editor`](widgets/text-editor/README.md) | Editor (rope · cursor · undo · highlight · clipboard · find). | +| [`text-editor-lsp`](widgets/text-editor-lsp/README.md) | Editor + LSP. | +| [`tree`](widgets/tree/README.md) | Árbol jerárquico. | +| [`list`](widgets/list/README.md) | Lista virtualizada. | +| [`tabs`](widgets/tabs/README.md) | Tabs con cierre. | +| [`splitter`](widgets/splitter/README.md) | Splitter horizontal/vertical. | +| [`tiled`](widgets/tiled/README.md) | Tiled window manager dentro de la app. | +| [`slider`](widgets/slider/README.md) | Slider con tick marks. | +| [`gallery`](widgets/gallery/README.md) | Grid de cards. | +| [`card`](widgets/card/README.md) | Card base. | +| [`stat-card`](widgets/stat-card/README.md) | Card para métricas. | +| [`banner`](widgets/banner/README.md) | Banner / alerts. | +| [`app-header`](widgets/app-header/README.md) | Header común de app. | +| [`context-menu`](widgets/context-menu/README.md) | Menú contextual (look distintivo). | +| [`theme-switcher`](widgets/theme-switcher/README.md) | Selector de tema. | +| [`nodegraph`](widgets/nodegraph/README.md) | Lienzo de nodos + cables Bezier. | + +## Crates: modules (feature funcional con estado) + +| Module | Función | +|---|---| +| [`command-palette`](modules/command-palette/README.md) | Paleta de comandos. | +| [`diff-viewer`](modules/diff-viewer/README.md) | Diff side-by-side. | +| [`fif`](modules/fif/README.md) | Find-in-files. | +| [`file-picker`](modules/file-picker/README.md) | Picker de archivos. | +| [`mini-map`](modules/mini-map/README.md) | Mini-mapa del editor. | +| [`bookmarks`](modules/bookmarks/README.md) | Bookmarks por archivo. | +| [`symbol-outline`](modules/symbol-outline/README.md) | Outline de símbolos LSP. | +| [`plugin-host`](modules/plugin-host/README.md) | Host para plugins WASM. | +| [`shuma-term`](modules/shuma-term/README.md) | Terminal embebida (shell shuma). | + +## Crates: android + +| Crate | Rol | +|---|---| +| [`clear-screen-android`](android/clear-screen-android/README.md) | Smoke test HAL Android. | +| [`vello-hello-android`](android/vello-hello-android/README.md) | Vello hello-world Android. | +| [`vello-text-android`](android/vello-text-android/README.md) | Text shaping Android. | + +## Consideraciones + +- **Una sola API: `View` declarativa**. Sin imperativo, sin DOM virtual ajeno. +- **El mismo árbol corre en Wayland y Wawa**: HAL abstrae la superficie, el resto es idéntico. +- Los widgets son **puramente visuales**; los módulos encapsulan estado + comportamiento. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ede9631 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sergio + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANUAL.md b/MANUAL.md new file mode 100644 index 0000000..8f1e19a --- /dev/null +++ b/MANUAL.md @@ -0,0 +1,1041 @@ +# Manual de Llimphi + +> Motor gráfico soberano de gioser. `wgpu` + `vello` + `taffy` + `parley`, +> bucle Elm `input → update → view → layout → raster → present`. +> Reemplazo total de GPUI (extinto 2026-05-26): toda app gráfica de la suite +> corre sobre Llimphi. + +Este documento es la **referencia de uso** orientada a humanos y a IA. +Está organizado para salto directo: cada capa, widget y módulo trae su API +real (firmas copiadas del código). Para el **porqué** arquitectónico ver +[`SDD.md`](SDD.md); para la regla de concurrencia ver +[`COMPUTO-FUERA-DEL-HILO-UI.md`](COMPUTO-FUERA-DEL-HILO-UI.md). + +--- + +## Índice + +1. [Modelo mental en 60 segundos](#1-modelo-mental-en-60-segundos) +2. [Arquitectura — las capas](#2-arquitectura--las-capas) +3. [Quickstart — la app mínima](#3-quickstart--la-app-mínima) +4. [El trait `App` (bucle Elm)](#4-el-trait-app-bucle-elm) +5. [`Handle` — efectos y concurrencia](#5-handle--efectos-y-concurrencia) +6. [`View` — el DSL declarativo](#6-viewmsg--el-dsl-declarativo) +7. [Layout (`taffy` / `Style`)](#7-layout-taffy--style) +8. [Eventos e interacción](#8-eventos-e-interacción) +9. [Texto](#9-texto) +10. [Canvas custom y GPU directo](#10-canvas-custom-y-gpu-directo) +11. [Theme y paletas](#11-theme-y-paletas) +12. [Capas base (hal · raster · text · motion · icons · surface)](#12-capas-base) +13. [Catálogo de widgets](#13-catálogo-de-widgets) +14. [Catálogo de módulos](#14-catálogo-de-módulos) +15. [`llimphi-workspace` — chasis tipo tmux](#15-llimphi-workspace--chasis-tipo-tmux) +16. [Reglas duras y gotchas](#16-reglas-duras-y-gotchas) +17. [Comandos y demos](#17-comandos-y-demos) +18. [Cheat-sheet](#18-cheat-sheet) +19. [Índice de crates](#19-índice-de-crates) + +--- + +## 1. Modelo mental en 60 segundos + +Llimphi es **Elm sobre la GPU**. Una app es un tipo que implementa el trait +`App` con cuatro piezas: + +- `Model` — estado **inmutable** de la app. +- `Msg` — todo lo que puede pasar (`Clone + Send`). +- `update(model, msg, handle) -> model` — transición **pura** que devuelve un + modelo nuevo. +- `view(&model) -> View` — función **pura** que describe la pantalla como + un árbol de `View`. + +El runtime hace el bucle: un evento (click/tecla/rueda) produce un `Msg`, +`update` deriva el nuevo `Model`, `view` reconstruye el árbol, `taffy` calcula +las cajas, `vello` rasteriza, y se hace swap del frame. **No hay mutabilidad +compartida, no hay vDOM ajeno, no hay callbacks imperativos**: declarás qué se +ve y qué `Msg` emite cada nodo. + +``` + evento ─▶ Msg ─▶ update(model,msg) ─▶ model' ─▶ view(model') ─▶ View + │ + present ◀─ raster(vello) ◀─ layout(taffy) ◀──────────────────────┘ +``` + +Tres reglas de oro: +1. **`view` es pura** — no muta nada, sólo lee el modelo y arma el árbol. +2. **Cómputo pesado va a un worker** vía `Handle::spawn`, nunca síncrono en + `update`/`init`/handlers (congela la ventana → "Not Responding"). +3. **Widgets son visuales y stateless**; el estado vive en tu `Model`. + **Módulos** sí encapsulan estado + comportamiento. + +--- + +## 2. Arquitectura — las capas + +``` +4. llimphi-ui ........... runtime winit del bucle Elm (App, Handle, run, KeyEvent) + └ llimphi-compositor . árbol View, mount sobre taffy, paint, hit-test (winit-free) +3. llimphi-layout ....... motor de layout (taffy: flexbox + grid) +2. llimphi-raster ....... rasterizador vectorial (vello) + backend GPU directo +1. llimphi-text ......... shaping + fuentes (parley): bidi, ligaduras, CJK/emoji +0. llimphi-hal .......... abstracción de superficie (wgpu + winit / framebuffer) +``` + +El **split compositor/runtime** (2026-05-31) es importante: `llimphi-compositor` +es *winit-free* (sólo `View`, `mount`, `paint`, hit-test). `llimphi-ui` lo corre +sobre winit y **re-exporta todo el compositor**, así escribís `llimphi_ui::View` +sin enterarte del split. Esto habilita un futuro runtime sobre el framebuffer +del kernel `wawa` reusando el mismo compositor. + +Auxiliares: `llimphi-theme` (paletas), `llimphi-motion` (tweens), +`llimphi-icons` (iconos vectoriales), `llimphi-surface` (texturas externas), +`llimphi-workspace` (chasis tmux), `llimphi-gallery` (showcase). + +Catálogo: **~45 widgets** (visuales) + **10 módulos** (features con estado). + +--- + +## 3. Quickstart — la app mínima + +```rust +use llimphi_ui::llimphi_layout::taffy::prelude::*; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::{App, Handle, View}; + +#[derive(Clone)] +enum Msg { Increment, Reset } + +struct Counter; + +impl App for Counter { + type Model = u32; + type Msg = Msg; + + fn title() -> &'static str { "llimphi · counter" } + + fn init(_: &Handle) -> Self::Model { 0 } + + fn update(model: Self::Model, msg: Self::Msg, _: &Handle) -> Self::Model { + match msg { + Msg::Increment => model.saturating_add(1), + Msg::Reset => 0, + } + } + + fn view(model: &Self::Model) -> View { + let boton = View::new(Style { + size: Size { width: length(160.0), height: length(56.0) }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(Color::from_rgba8(60, 200, 130, 255)) + .radius(12.0) + .text("+1", 28.0, Color::from_rgba8(10, 30, 20, 255)) + .on_click(Msg::Increment); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0), height: percent(1.0) }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + gap: Size { width: length(0.0), height: length(24.0) }, + ..Default::default() + }) + .fill(Color::from_rgba8(20, 24, 32, 255)) + .children(vec![ + View::new(Style::default()).text(model.to_string(), 160.0, Color::WHITE), + boton, + ]) + } +} + +fn main() { llimphi_ui::run::(); } +``` + +`Cargo.toml`: +```toml +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +# + los widgets/modules que uses: +# llimphi-widget-button = { workspace = true } +``` + +Corre con `cargo run -p --release`. El ejemplo vivo está en +`llimphi-ui/examples/counter.rs`. + +--- + +## 4. El trait `App` (bucle Elm) + +Definido en `llimphi-ui/src/lib.rs`. El estado es inmutable; cada evento +produce un `Model` nuevo. + +```rust +pub trait App: 'static { + type Model: 'static; + type Msg: Clone + Send + 'static; + + fn init(handle: &Handle) -> Self::Model; + fn update(model: Self::Model, msg: Self::Msg, handle: &Handle) -> Self::Model; + fn view(model: &Self::Model) -> View; + + // --- Todo lo de abajo tiene default; sobreescribí lo que necesites --- + + fn on_key(_model: &Self::Model, _event: &KeyEvent) -> Option { None } + + fn on_wheel(_model: &Self::Model, _delta: WheelDelta, + _cursor: (f32, f32), _modifiers: Modifiers) -> Option { None } + + /// Capa de overlay (menús, modales, popovers). Si devuelve `Some`, se pinta + /// encima y clicks/hover van EXCLUSIVAMENTE a ella (el fondo queda "bajo + /// vidrio"). La transición la maneja tu Model. + fn view_overlay(_model: &Self::Model) -> Option> { None } + + /// Drag&drop de archivos desde el file manager. Un evento por archivo. + fn on_file_drop(_model: &Self::Model, _path: std::path::PathBuf) -> Option { None } + + /// El foco cambió (Tab/Shift+Tab o click sobre un nodo `focusable`). El + /// runtime administra el foco; guardás `id` en tu Model para pintar el ring + /// y rutear el teclado. Ver §8 (Foco y teclado). + fn on_focus(_model: &Self::Model, _id: Option) -> Option { None } + + /// IME (composición de texto: CJK, acentos muertos, emoji). Opt-in vía + /// `ime_allowed()` para no robarle el texto a las apps que sólo leen + /// `on_key`. Flujo: Enabled → Preedit* → Commit/Disabled. Ver §8 (IME). + fn ime_allowed() -> bool { false } + fn on_ime(_model: &Self::Model, _event: &ImeEvent) -> Option { None } + /// Área del caret en px físicos para ubicar la ventana de candidatos. + fn ime_cursor_area(_model: &Self::Model) -> Option<(f32, f32, f32, f32)> { None } + + fn title() -> &'static str { "llimphi" } + fn app_id() -> Option<&'static str> { None } // app_id del xdg-toplevel en Wayland + fn initial_size() -> (u32, u32) { (960, 540) } +} +``` + +Punto de entrada: `pub fn run()` — corre hasta que el usuario cierre la +ventana o la app llame `Handle::quit`. + +**Eventos de teclado** (`KeyEvent`): +```rust +pub struct KeyEvent { + pub key: Key, // re-export de winit; usar NamedKey para teclas especiales + pub state: KeyState, // Pressed | Released + pub text: Option, // texto resultante con IME/modifiers; None para flechas etc. + pub modifiers: Modifiers, // { shift, ctrl, alt, meta } + pub repeat: bool, +} +``` +`Key` y `NamedKey` se re-exportan desde `llimphi_ui`. + +**Rueda** (`WheelDelta { x, y }`): normalizado a "líneas". Convención CSS: +`y` positivo = scroll hacia abajo. + +--- + +## 5. `Handle` — efectos y concurrencia + +`Handle` es `Send + Clone`. Llega a `init` y `update`. Es el único modo +legítimo de producir efectos sin romper la pureza de la transición. + +```rust +impl Handle { + pub fn quit(&self); // cierra la ventana / termina el bucle + pub fn dispatch(&self, msg: Msg); // encola un Msg para el próximo turno + pub fn spawn Msg + Send + 'static>(&self, f: F); // worker; su Msg reentra al update + pub fn spawn_periodic Msg + Send + 'static>(&self, period: Duration, f: F); // tick periódico + pub fn for_test() -> Self; // handle "muerto" para tests sin event loop +} +``` + +- **`spawn`** — trabajo bloqueante (IO, PAM, parse, efemérides). El `Msg` que + devuelve la closure se entrega al `update` en el hilo de UI. **Este es el + patrón obligatorio para todo cómputo pesado** (§16). +- **`spawn_periodic`** — feeds a intervalos: ticks de simulación (~11 Hz en + dominium), polling, animaciones por reloj. El thread muere cuando se cierra + el event loop. + +--- + +## 6. `View` — el DSL declarativo + +Un `View` = `Style` de taffy + relleno + texto/imagen/painter + handlers + +hijos. Todo se arma con builders encadenables (`self -> Self`). Definido en +`llimphi-compositor/src/view.rs`. + +```rust +View::new(style: Style) -> View +``` + +### Apariencia +| Método | Efecto | +|---|---| +| `.fill(Color)` | color de fondo | +| `.hover_fill(Color)` | color al pasar el cursor (habilita hit-test de hover) | +| `.radius(f64)` | esquinas redondeadas | +| `.alpha(f32)` | opacidad de todo el subtree `[0,1]` (capa intermedia — no gratis) | +| `.transform(Affine)` | afín 2D alrededor del centro del rect (estilo CSS `transform-origin:50% 50%`) | +| `.clip(bool)` | recorta hijos al rect (paint + hit-test) | +| `.image(Image)` | pinta `peniko::Image` centrada, preservando aspect ratio | +| `.children(Vec>)` | hijos | + +### Texto (ver §9) +```rust +.text(content, size_px, color) // centrado +.text_aligned(content, size_px, color, Alignment) +.text_aligned_italic(content, size_px, color, Alignment, italic) +.text_aligned_full(content, size_px, color, Alignment, italic, font_family: Option) +.text_runs(content, size_px, default_color, runs: Vec<(usize,usize,Color)>, Alignment) // multicolor 1-pasada +.line_height(mult) // override interlínea (default 1.2) +``` + +### Interacción (ver §8) +```rust +.on_click(Msg) +.on_click_at(|lx, ly, w, h| -> Option) // posición local + tamaño del rect +.on_right_click(Msg) / .on_right_click_at(...) +.on_middle_click(Msg) +.on_pointer_enter(Msg) / .on_pointer_leave(Msg) +.draggable(|phase: DragPhase, dx, dy| -> Option) +.draggable_at(|phase, dx, dy, lx0, ly0| -> Option) // + posición inicial del press +.drag_payload(u64) // payload que viaja con el drag +.on_drop(|payload: u64| -> Option) // este nodo es drop target +.drop_hover_fill(Color) // resaltado mientras un drag lo sobrevuela +.on_scroll(|dx, dy| -> Option) // rueda local (antes del on_wheel global) +.focusable(u64) // nodo enfocable por Tab/click (id opaco) +``` + +### Pintura custom (ver §10) +```rust +.paint_with(|scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect| { ... }) +.gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| { ... }) +``` + +Notas clave: +- **Un nodo es draggable *o* clickable**, no ambos: `draggable` sobreescribe + `on_click`. +- Las variantes `*_at` ganan sobre las simples si ambas están. +- `PaintRect { x, y, w, h }` es el rect **absoluto** del nodo en píxeles físicos. +- `DragPhase` = `Move` (un evento por `CursorMoved`, `dx/dy` = delta **desde el + evento anterior**, no acumulado) | `End` (al soltar). + +--- + +## 7. Layout (`taffy` / `Style`) + +`Style` es el `taffy::Style` directo, re-exportado vía +`llimphi_ui::llimphi_layout::taffy::prelude::*`. Es Flexbox + CSS Grid puro. + +Campos más usados: +```rust +Style { + flex_direction: FlexDirection::Row | Column, + size: Size { width, height }, // length(px) | percent(0..1) | Dimension::auto() + min_size, max_size, + flex_grow: f32, flex_shrink: f32, + align_items: Some(AlignItems::{Start,Center,End,Stretch}), + justify_content: Some(JustifyContent::{Start,Center,End,SpaceBetween,...}), + gap: Size { width, height }, + padding: Rect { left, right, top, bottom }, // con length(px) + margin: Rect { ... }, + ..Default::default() +} +``` + +Helpers de `prelude`: `length(px)`, `percent(frac)`, `auto()`, `Dimension`, +`Size`, `Rect`, `FlexDirection`, `AlignItems`, `JustifyContent`. + +`llimphi-layout` además expone: +- `LayoutTree::new()` / `.clear()` (reuso entre frames), `.leaf(style)`, + `.node(style, &children)`, `.compute(...)`, `.compute_with_measure(F)`. +- `Rect { x, y, w, h }` y `ComputedLayout { rects: HashMap }`. + +En el 99% de los casos no tocás `LayoutTree` a mano: lo maneja el runtime al +montar tu `View`. Sólo armás `Style`s. + +--- + +## 8. Eventos e interacción + +| Quiero… | Cómo | +|---|---| +| Botón / fila clickable | `.on_click(Msg)` (+ `.hover_fill` para feedback) | +| Saber dónde se clickeó (canvas) | `.on_click_at(\|lx,ly,w,h\| ...)` → convertir a coords de mundo | +| Menú contextual | `.on_right_click(Msg::OpenMenu{..})`, guardar pos en Model, abrir en `view_overlay` | +| Abrir en pestaña nueva | `.on_middle_click(Msg)` | +| Preview al pasar el mouse | `.on_pointer_enter(Msg)` / `.on_pointer_leave(Msg)` | +| Resize de panel | `.draggable(\|phase,dx,dy\| ...)` acumulando delta en el Model | +| Arrastrar entidad de un canvas | `.draggable_at(\|phase,dx,dy,lx0,ly0\| ...)` | +| Drag&drop entre zonas | origen: `.drag_payload(id)`; destino: `.on_drop(\|id\| ...)` + `.drop_hover_fill` | +| Scroll global | `App::on_wheel(model, delta, cursor, mods)` | +| Área de scroll | widget `scroll_y(...)` (autocontenido) o `.on_scroll(\|dx,dy\| ...)` por nodo | +| Teclado | `App::on_key(model, &KeyEvent) -> Option` | +| Foco / Tab | `.focusable(id)` en los nodos + `App::on_focus(model, id)` (ver abajo) | +| IME (CJK, acentos) | `App::ime_allowed() -> true` + `App::on_ime(model, &ImeEvent)` (ver abajo) | +| Drop de archivos del SO | `App::on_file_drop(model, path)` | + +**Patrón overlay** (menús/modales): el modelo guarda "menú abierto sí/no". +Mientras esté abierto, `view_overlay` devuelve `Some(view)`; clicks fuera se +cierran envolviendo los items en un scrim a pantalla completa con +`on_click = DismissOverlay`. Cuando el modelo dice cerrado, `view_overlay` +devuelve `None`. + +**Scroll** (widget `llimphi-widget-scroll`). `scroll_y(offset, content_len, +viewport_len, content, on_scroll, &palette)` arma un viewport clipeado + +contenido desplazado `-offset` + barra arrastrable. Es **stateless**: el offset +vive en tu Model. `on_scroll(delta_px)` (rueda y arrastre) emite un delta a +sumar; clampealo con `scroll::clamp_offset` en tu `update`. Helpers: +`ensure_visible(offset, vp, item_top, item_h)` para llevar la selección a la +vista (teclado); `approach(cur, target, factor)` para scroll suave/inercia +(driveado por `Handle::spawn_periodic`). + +**Foco y teclado.** Marcá los nodos navegables con `.focusable(id)` (id `u64` +que vos elegís). El runtime es la **única fuente de verdad** del foco: lo mueve +con Tab/Shift+Tab en orden de árbol (envolviendo) y al clickear un nodo +enfocable, y te avisa con `App::on_focus(model, Option)`. Guardás el id en +tu Model para (a) pintar el ring (`if model.focus == Some(id) { .fill(accent) }` +en `view`) y (b) rutear el teclado al campo activo desde `on_key`. No setees el +foco por tu cuenta vía Msg: quedaría desincronizado del runtime. + +**IME** (composición de texto). Opt-in: `ime_allowed() -> true`. Con IME activo +el texto compuesto **no** llega por `KeyEvent.text` sino por `on_ime`: +`ImeEvent::Enabled` → uno o más `Preedit{text, cursor}` (texto en composición, a +pintar subrayado en el caret) → `Commit(text)` (insertá como tecleado) o +`Disabled`. Reportá el área del caret con `ime_cursor_area(model)` para ubicar +la ventana de candidatos (CJK) junto al cursor. + +--- + +## 9. Texto + +`TextSpec` (en compositor) describe el texto de un nodo: +```rust +pub struct TextSpec { + pub content: String, + pub size_px: f32, + pub color: Color, + pub alignment: Alignment, // Start | Center | End | Justify + pub italic: bool, + pub font_family: Option, // string CSS con fallbacks + pub line_height: f32, // múltiplo; default 1.2 + pub runs: Option>, // color por rango de BYTES +} +``` + +- `Center` es el default (apto para labels). Para editores/párrafos usar + `.text_aligned(..., Alignment::Start)`. +- **Multicolor en una sola pasada de shaping**: `.text_runs(...)` colorea + rangos de bytes — es la base del syntax highlighting (un nodo por línea, no + por token). Anclado arriba-izquierda; el caller dimensiona el rect. +- El runtime mide el texto con parley durante el layout (`compute_with_measure`) + para que taffy reserve el alto real del texto envuelto a varias líneas + (evita "textos aplastados"). +- Shaping completo: bidi, ligaduras, kerning, fallback CJK/emoji vía fontique. + +--- + +## 10. Canvas custom y GPU directo + +Dos hooks para pintar primitivas no expresables como composición de `View`s. +Conviven en el mismo árbol; el runtime pinta **toda la pasada vello primero**, +luego los `gpu_painter` en orden DFS. + +### `paint_with` — vía vello (el default) +```rust +.paint_with(|scene: &mut vello::Scene, ts: &mut Typesetter, rect: PaintRect| { + // dibujar BezPath, kurbo, texto con `ts`, etc. dentro de `rect`. + // NO dejar push_layer sin pop_layer; NO resetear la scene. +}) +``` +Para: dominium-canvas, osciloscopios de pluma, charts de cosmos, pineal. +Bueno hasta ~500 K primitivos por frame (rebuild) o ~2 M (Scene reusada). + +### `gpu_paint_with` — sube vertex buffers directo a wgpu, salta vello +```rust +.gpu_paint_with(|device, queue, encoder, view, rect: PaintRect, (vp_w, vp_h)| { + // abrir begin_render_pass con LoadOp::Load (NO clear) para preservar vello. + // (vp_w, vp_h) = tamaño en px de la TextureView destino, para calcular NDC. +}) +``` +Para volumen masivo: starfield Gaia de cosmos, particles de tinkuy, viewport de +nakui, pineal denso. Rango 100 K – 10 M+ primitivos. **No** soporta texto ni AA +fino ni múltiples grosores de stroke por flush. Para texto encima de un render +GPU, usar `view_overlay` (segunda Scene vello). + +### ¿Cuándo cada uno? +| Pregunta | vello (`paint_with`) | GPU directo (`gpu_paint_with`) | +|---|---|---| +| Primitivos/frame | < ~500 K rebuild / < ~2 M Scene reusada | 100 K – 10 M+ | +| ¿Cambian cada frame? | sí, rebuild barato | mejor estático (buffer persistente) | +| Curvas Bezier | nativas | hay que teselar | +| Texto | sí | no | +| AA fino | sí (analítico) | no (sin MSAA) | + +**Default: `paint_with`** salvo que ya midas que el volumen lo justifica +(factores ~11× a 1M en GPU mid sólo en el régimen persistente). El backend GPU +expone `GpuPipelines`/`GpuBatch` en `llimphi-raster` (§12). + +--- + +## 11. Theme y paletas + +`llimphi-theme::Theme` es un struct de slots semánticos de color. Cuatro presets +`const`: `Theme::dark()` (default), `light()`, `aurora()`, `sunset()`. + +```rust +pub struct Theme { + pub name: &'static str, + // fondos + pub bg_app, bg_panel, bg_panel_alt, bg_input, bg_input_focus, + pub bg_button, bg_button_hover, bg_selected, bg_row_hover: Color, + // texto + pub fg_text, fg_muted, fg_placeholder, fg_destructive: Color, + // bordes y acento + pub border, border_focus, accent: Color, +} + +Theme::all() -> Vec // orden de rotación canónico +Theme::by_name(name) -> Option +Theme::next_after(current_name) -> Theme // para el theme-switcher +``` + +Tokens auxiliares en el mismo crate: +- `motion::{FAST=80ms, NORMAL=160ms, SLOW=320ms}` + `ease_out_cubic`, + `ease_in_out_cubic`, `linear`. +- `alpha::{SCRIM, GLASS_PANEL, DISABLED, HINT}` (constantes `u8`). +- `radius::{XS=2, SM=4, MD=8, LG=12, XL=20}` (`f64`). + +**Patrón de widgets**: cada widget define su `XxxPalette` con +`Palette::from_theme(&theme)`. Tu app guarda un `Theme` en el Model, deriva las +paletas que necesita en `view`, y se las pasa a los widgets. Para cambiar de +tema, el `theme-switcher` emite `Msg(next_theme)` y reconstruís todo. + +--- + +## 12. Capas base + +### `llimphi-hal` — superficie +```rust +Hal::new(compatible_surface: Option<&wgpu::Surface>) -> Result // async +trait Surface { fn size(); fn resize(w,h); fn acquire() -> Result; fn present(frame, hal); } +WinitSurface::new(hal, window: Arc) -> Result +Frame::view() -> &wgpu::TextureView; Frame::size() -> (u32,u32) +``` +`Hal::new` pide adapter `Backends::PRIMARY` (Vulkan) y cae a `all()` sólo si no +hay — **no volver a `InstanceDescriptor::default()`**: el backend GL de Mesa +sobre Wayland segfaultea en el teardown. El runtime de `llimphi-ui` ya maneja +todo esto; sólo tocás HAL si escribís un runtime nuevo. + +### `llimphi-raster` — rasterización +```rust +Renderer::new(hal) -> Result +Renderer::render(&mut self, hal, scene: &vello::Scene, frame: &Frame, base_color: Color) +// GPU directo: +GpuPipelines::new(device, color_format) -> Self // campos: lines, tris, rects, bind_layout +GpuBatch::new(&pipelines) + .line_width(w) .add_line(p0,p1,color) .add_polyline(&pts,color) + .add_tri(a,b,c, ca,cb,cc) .add_tri_list(&verts,color) .add_rect(x,y,w,h,color) + .primitive_count() -> u32 + .flush(device, queue, encoder, view, viewport, load_op) +``` +Re-exporta `vello` y `peniko` (`Color`, `Image`, `Fill`, etc.). + +### `llimphi-text` — shaping +```rust +Typesetter::new() // una por proceso (FontContext es caro) + .layout(text, size_px, max_width, alignment, line_height, italic, font_family) -> Layout<()> + .layout_runs(text, size_px, default_color, &runs, alignment, line_height) -> Layout +TextBlock::simple(text, size_px, color, origin) +layout_block(ts, &block) / measure(ts, &block) -> Measurement +draw_layout(scene, &layout, color, origin) / draw_layout_runs(scene, &layout, origin) +Alignment::{Start, Center, End, Justify} +``` + +### `llimphi-motion` — tweens +```rust +trait Lerp { fn lerp(self, other, t: f32) -> Self; } // impl para f32,f64,(f32,f32),(f64,f64),Color +Tween::new(from, to, duration, easing: fn(f32)->f32) // o Tween::idle(value) +tween.value() / .progress() / .done() +animate(handle, duration, make_msg) // arranca los ticks del tween +``` +Patrón: guardás `Tween` en el Model, `animate(...)` en el update, la `view` +lee `tween.value()` cada repaint. El tween se auto-termina. + +### `llimphi-icons` — iconos vectoriales (~23, grid 24×24) +```rust +Icon::{File, Folder, Save, Plus, Minus, X, Check, Edit, Trash, ChevronUp/Down/Left/Right, + Home, Search, Info, Warning, Error, Bell, Settings, More, ...} +icon_view(Icon, color, stroke_width) -> View +paint_icon(scene, rect, icon, color, stroke_width) // dentro de un paint_with +``` +`stroke_width` en unidades del grid 24×24 (1.6 es armónico). + +### `llimphi-surface` — texturas externas +```rust +ExternalSurface::new(device, queue) // barato de clonar (Arc interno) + .upload(&rgba, w, h) // desde otro hilo/decoder/cámara + .view(style) -> View // blittea a su rect en el árbol Elm + .blit(queue, encoder, dst_view, rect, viewport) // o manual desde gpu_paint_with +``` + +--- + +## 13. Catálogo de widgets + +Los widgets son **funciones puras** que devuelven `View` (o specs que se +convierten a `View`). Son **stateless**: el estado vive en tu Model. Convención: +cada uno trae `XxxPalette::from_theme(&Theme)`. Crates en +`widgets//`, dep `llimphi-widget-`. + +### Controles + +**button** — `button_view(label, &ButtonPalette, on_click: Msg) -> View`; +`button_styled(label, style, alignment, &palette, on_click)`. + +**field** — wrapper de formulario (label + helper/error + requerido). +`field_view(FieldSpec { label, control: View, required, helper, error, palette })`. + +**text-input** — input single-line **con estado** `TextInputState` +(`new()`/`masked()`, `text()`, `set_text()`, `apply_key(&KeyEvent) -> bool`, +soporta undo/redo + selección con Shift). Render: +`text_input_view(&state, placeholder, focused, &palette, on_focus: Msg)`. + +**text-area** — multilínea con estado `TextAreaState` (Enter = newline, sin +auto-submit). `text_area_view(&state, placeholder, focused, body_height, &palette, on_focus)`. + +**slider** — sin estado. `slider_view(label, value, min, max, &palette, +on_change: Fn(DragPhase, delta_value) -> Option)`. El delta viene en +unidades, no píxeles. + +**switch** — `switch_view(progress: f32 [0..1], on_toggle: Msg, &palette)`. La +app guarda el `bool` y opcionalmente anima `progress` con un `Tween`. + +**segmented** — N opciones exclusivas. `segmented_view(&[&str], selected: usize, +make_msg: Fn(usize)->Msg, &palette)`. + +**progress** — `linear_progress_view(progress, track, fill, height)` y +`radial_progress_view(progress, track, fill, stroke_ratio)`. Sin eventos. + +**spinner** — `spinner_view(color, stroke_ratio, speed_rev_per_sec)`. Animado por +reloj absoluto; requiere redraws periódicos (`spawn_periodic`). + +**badge** — `count_badge_view(count, BadgeKind)` ("99+" si ≥100) y +`dot_badge_view(BadgeKind)`. `BadgeKind::{Info,Success,Warning,Error,Neutral}`. + +**avatar** — `avatar_view(name, size_px)`: círculo determinista (color por hash +del nombre + inicial). + +**tooltip** — render puro. `tooltip_view(TooltipSpec { anchor, viewport, side: +Side, text, palette })`. Se monta en `view_overlay`; la app controla visibilidad +con `on_pointer_enter/leave`. + +**empty** — empty-state. `empty_view(Icon, title, description: Option<&str>, &palette)`. + +**skeleton** — placeholder con shimmer. `skeleton_view`, `skeleton_box_view(w,h,..)`, +`skeleton_line_view(w,..)`. Requiere redraws periódicos. + +**banner** — tira de status. `banner_view(BannerKind::{Info,Success,Warning,Error}, message)`. + +### Contenedores y layout + +**panel** — chrome (gradiente + hairline accent). `panel_view(children, PanelStyle)`; +`PanelStyle::{from_theme, from_theme_large, neutral}`. `panel_signature_painter(style)` +para reusar el look en un `paint_with`. + +**card** — `card_view(children, CardOptions { accent, padding, gap, radius, signature }, &CardPalette)`. + +**stat-card** — métrica de dashboard. `stat_card_view(label, value, description, +accent, &recent_items, &palette)`. + +**tabs** — `tabs_view(TabsSpec { labels, active: usize, on_select: Fn(usize)->Msg, +content: View, tab_height, palette, tab_width })`. Selección la maneja la app. + +**splitter** — divisor draggable de 2 panes. `splitter_two(Direction::{Row,Column}, +a, a_size, b, b_size, on_resize: Fn(DragPhase, delta)->Option, &palette)`. +`PaneSize::{Fixed(px), Flex}`. La app acumula el delta en su Model. + +**scroll** — área de scroll vertical con barra arrastrable. `scroll_y(offset, +content_len, viewport_len, content, on_scroll: Fn(delta_px)->Msg, &palette)`. +Stateless (offset en el Model); rueda autocontenida. Helpers: `clamp_offset`, +`ensure_visible` (selección a la vista), `approach` (scroll suave). Ver §8. + +**tiled** — grilla auto cols×rows de tiles con title bar. `tiled_view(tiles, &palette)`, +`tiled_view_cols(tiles, cols, &palette)`, y variantes `*_reorderable*` con +`on_reorder: Fn(from, to)->Option` (drag-to-swap por la title bar). `TileSpec { label, content }`. + +**panes** — árbol binario BSP tipo tmux. La app guarda un `Layout`: +```rust +Layout::single(id) / Layout::Split { axis: Axis, ratio, first, second } +layout.split(target, new, axis) / .without(target) / .resize(&path, delta) / .leaves() +panes_view(&layout, focused: PaneId, leaf: FnMut(PaneId)->View, on_resize: Fn(Vec,DragPhase,delta)->Option, + on_focus: Fn(PaneId)->Msg, &palette) +``` + +**grid** — grilla 2D virtualizada. `ventana_visible(total, vp_w, vp_h, scroll_fila, +&metrics) -> VisibleWindow` para virtualizar, luego `grid_view(GridSpec { cells: +Vec, cols, metrics, caption, ... })`. + +**list** — lista vertical virtualizada. `list_view(ListSpec { rows: Vec, total, caption, truncated_hint, row_height, palette })`. +La app prefiltra las filas visibles. + +**tree** — árbol expand/collapse. `tree_view(TreeSpec { rows: Vec, row_height, +indent_px, palette })`. La app aplana el árbol según nodos expandidos. + +**navigator** — navegador data-agnóstico de nodos en dos modos conmutables +(**árbol** ↔ **grafo**, reusa tree + nodegraph). Render-only: la app guarda +`expanded`/`selected`/`mode`. Pasa un bosque de `NavNode { id: u64, label, +kind: NavKind (Monad|Group|Dir|File|Other), children }` y callbacks por id. +```rust +navigator_view(NavSpec { roots, mode: NavMode::{Tree,Graph}, selected, palette, guides }, + is_expanded: Fn(u64)->bool, on_toggle: Fn(u64)->Msg, + on_select: Fn(u64)->Msg, on_context: OptionMsg>) +// árbol: click selecciona, chevron expande, icono por kind. grafo: cables de +// contención padre→hijo, arrastrar selecciona, right-click abre. Pensado para +// el sidebar de Mónadas/archivos de pata, pero no sabe de nouser. +``` + +**app-header** — `app_header(label, actions: Vec>, &palette)`. + +**status-bar** — `status_bar_view(left, center, right, &palette)` con +`StatusSegment::text(..).with_icon(Icon).clickable(Msg).emphasized()`. + +**breadcrumb** — `breadcrumb_view(&[&str], make_msg: Fn(usize)->Msg, &palette)` +(el último segmento no es clickable). + +**modal** — diálogo centrado con scrim. `modal_view(ModalSpec { title, body: +View, buttons: Vec, size, viewport, on_dismiss, palette })`. +`ModalButton::{primary, cancel, destructive}(label, msg)`. Se monta en `view_overlay`. + +**toast** — notificaciones efímeras bottom-right. La app guarda `Vec` +(`Toast::{info,success,warning,error}(id, text, duration)`), filtra +`is_alive(now)`, y `toast_stack_view(&toasts, viewport, make_dismiss: Fn(u64)->Msg)`. + +**splash** — splash de arranque (cuatro cuadrantes andinos). `splash_view(started_at: +Instant, bg, fg_text)`; basado en tiempo, requiere redraws. + +### Ricos / interactivos + +**nodegraph** — lienzo de nodos + cables Bezier. Sin estado (la app guarda +posiciones y `Wire`s). +```rust +NodeSpec { id: NodeId(u32), label, x, y, inputs: Vec, outputs: Vec } +Wire { from_node, from_output: PinIdx(u16), to_node, to_input } +nodegraph_view(&nodes, &wires, &palette, &metrics, + on_drag_node: Fn(NodeId, DragPhase, dx, dy)->Option, + on_connect: Fn(NodeId, PinIdx, NodeId, PinIdx)->Option) +// + nodegraph_view_ex (right-click) y nodegraph_view_styled (tints por nodo/cable) +``` + +**timeline** — scrub clickeable. `timeline_view(progress: f32, &palette, +on_seek: Fn(f32 [0..1])->Option)`. + +**text-editor** — editor IDE (capa visual sobre el core agnóstico). La app guarda +`EditorState`: +```rust +EditorState::new(); .text(); .set_text(s); .has_selection(); .can_undo()/.can_redo(); +.add_cursor_at(line,col); .apply_key_with_clipboard(&KeyEvent, &mut dyn Clipboard) -> ApplyResult; +.ensure_caret_visible(visible_lines) +// nota: `metrics` se pasa POR VALOR; el callback es on_pointer: Fn(PointerEvent)->Option +text_editor_view(&state, &EditorPalette, metrics: EditorMetrics, visible_lines: usize, on_pointer) +text_editor_view_highlighted(&state, &palette, metrics, visible_lines, language: Language, on_pointer) +text_editor_view_full(&state, &palette, metrics, visible_lines, language, match_ranges: &[(usize,usize)], on_pointer) +syntax_palette_dark(&theme) -> SyntaxPalette // en lib.rs del widget +``` + +**text-editor-core** — núcleo **agnóstico** (sin GPU, sin Llimphi; sólo +`peniko::Color`). Reutilizable en TUI/web/headless. Tipos clave: +- `Buffer` (sobre `ropey`): `from_str`, `text`, `insert(offset,s)`, `delete(s,e)`, + `offset_to_pos`, `pos_to_offset`, `slice`, `line(n)`. +- `Pos { line, col }`, `Selection { anchor, caret }`, `Cursor { caret, anchor: + Option, desired_col }` con `move_left/right/up/down/word_left/...`, + `selection_range(&buf)`, `collapse`. +- Ops: `replace_selection`, `delete_backward/forward`, `indent_or_insert_tab`, + `insert_newline_auto_indent` → devuelven `EditDelta { start, removed, inserted, + cursor_before, cursor_after }` con `.apply()/.undo()`. +- `UndoStack`: `push(delta)`, `undo/redo(&mut buf, &mut cursor) -> bool`, `can_undo/redo`. +- `FindState { query, case_sensitive }`: `all_matches`, `find_next`, `find_prev`. +- Matching de brackets: `find_bracket_pair(&buf, &cursor) -> Option<(Pos, Pos)>`, `Direction`. +- `Clipboard` (trait `get/set`), `MemClipboard`, `NullClipboard`. +- `Diagnostic { range: DiagnosticRange { start: Pos, end: Pos }, severity: Severity, + message: String, source: Option }` (+ ctors `error(..)`, `warning(..)`); + `Severity::{Error, Warning, Information, Hint}`. +- Highlight tree-sitter: `Language::{Plain, Rust, Python, Wat}` + (+ `Language::from_cell_language(s)`); `Highlighter::new(lang)` con + `.highlight(&mut self, source: &str) -> Vec>` (un `Vec` **por + línea**), `.set_language(lang)`, `.language()`; helpers de módulo + `invalidate_tree_cache(lang)` y `apply_pending_edits(lang, &edits)` para el + caché incremental. `TokenKind`, `Span`, `SyntaxPalette::color(kind)`. + +**text-editor-lsp** — cliente LSP por stdin/stdout. `trait LspClient` (fire-and-forget +`request_*` + lecturas de caché `latest_*`/`clear_*`): completions, hover, +definition, references, rename, formatting, signature help, document symbols. +`RustAnalyzerClient::start(workspace_root)`; `NoopLspClient` para tests. + +**clipboard** — portapapeles del sistema vía `arboard`. `SystemClipboard::new()`, +`is_available()`, impl `Clipboard`. No-op silencioso si no hay display (CI headless). + +**menubar** — barra de menú mac-style. `menubar_view(&MenuBarSpec { menu: &AppMenu, +open: Option, theme, viewport, height, on_open: Fn(Option)->Msg, +on_command: Fn(&str)->Msg })`; dropdown en `view_overlay` con `menubar_overlay(spec)` +o `menubar_overlay_animated(spec, active, appear)`. Navegación por teclado: +`menubar_nav`, `menubar_command_at`. + +**edit-menu** — menú estándar de edición sobre un editor. +`EditFlags::from_editor(&state, masked)`, `edit_context_menu(anchor, viewport, +&theme, flags, on_action: Fn(EditAction)->Msg, on_dismiss)` → +`ContextMenuSpec`. `apply(&mut state, EditAction, &mut clipboard) -> ApplyResult`. +`EditAction::{Undo,Redo,Cut,Copy,Paste,Delete,SelectAll}`. + +**context-menu** — menú contextual genérico (look "webpage"). `ContextMenuItem:: +action(label).with_shortcut(..).icon(..).disabled().destructive().submenu(children)` +o `::separator()`. `context_menu_view(ContextMenuSpec { anchor, viewport, header, +items, active, on_pick: Fn(usize)->Msg, on_dismiss, palette })`; `context_menu_view_ex` +con submenús/animación. Se monta en `view_overlay` con scrim. + +**theme-switcher** — `theme_switcher_view(¤t: &Theme, on_change: Fn(Theme)->Msg)` +(+ `_styled`/`_flex`). Cicla `Theme::next_after`. + +**shortcuts-help** — overlay "?" con atajos agrupados. `shortcuts_help_view( +ShortcutsHelpSpec { title, groups: Vec }>, viewport, on_dismiss, palette })`. + +**wawa-mark** — sello vectorial del SO wawa. `wawa_mark_view(&WawaMarkPalette)`; +`paint_mark(scene, rect, &palette)` para canvas custom. Usar en contenedor cuadrado. + +--- + +## 14. Catálogo de módulos + +Los módulos encapsulan **estado + comportamiento** (a diferencia de los widgets). +Todos siguen el mismo contrato: + +``` +State + Msg + Action + apply(state, msg, ...) -> Action + + on_key(state, &KeyEvent) -> Option + + open_shortcut(&KeyEvent) -> bool + + view(state, ..., to_host: F) -> View + + Palette +``` + +La app guarda `Option` (o el state directo, p. ej. bookmarks), +rutea el atajo de apertura con `open_shortcut`, rutea teclas con `on_key`, aplica +`Msg`s con `apply`, y monta el `view` pasando un mapeo `to_host: Fn(ModuleMsg) -> +HostMsg`. Cuando `apply` devuelve una `Action` (p. ej. `Invoke(id)`, `OpenAt{..}`, +`GoTo{..}`), la app ejecuta el efecto. Crates en `modules//`. + +| Módulo | Atajo | Acción que devuelve | Propósito | +|---|---|---|---| +| **command-palette** | `Ctrl+Shift+P` | `Invoke(String)` | paleta de comandos fuzzy. El host declara `&[Command]` | +| **file-picker** | `Ctrl+P` | `Open(PathBuf)` | fuzzy file picker; host pasa `&[PathBuf]` + `root` | +| **fif** (find-in-files) | `Ctrl+Shift+F` | `OpenAt{path,line,col}`, `Searched{..}`, `Replaced{..}` | buscar/reemplazar; dual-view (dialog + barra). `search()` / `replace_all()` hacen el I/O | +| **diff-viewer** | `Ctrl+Shift+D` | — | diff side-by-side. `DiffState::new(before_label, after_label, before, after)` computa con `similar` | +| **mini-map** | `Ctrl+Shift+M` | `JumpTo(line)` | minimapa del buffer; agnóstico del editor (recibe `Snapshot`) | +| **bookmarks** | `Ctrl+Alt+B` toggle, `Ctrl+Shift+B` lista, `Ctrl+Alt+N/P` nav | `JumpTo{path,line}` | marcadores per-file persistentes (state directo, no Option) | +| **symbol-outline** | `Ctrl+Shift+O` | `GoTo{line,col}` | outline de símbolos; host arma `Vec` (LSP/tree-sitter/custom) | +| **selector** | — | — | abstracción portátil abrir/guardar: `trait Selector` (`HostSelector` con PathBuf, `WawaSelector` placeholder content-addressed) | +| **plugin-host** | — | `OpenAt{..}`, `SetStatus(..)` | runtime WASM (wasmi) con permisos por bitfield; `PluginHost::load_from_dir`/`invoke(id, cap, args)` | +| **shuma-term** | `` Ctrl+` `` | `SetStatus(..)` | terminal integrada. `spawn(cwd)` lanza PTY (`shuma_exec`), `vt100::Parser` renderiza; `Tick` drena el PTY | + +Patrón típico de integración (command-palette): +```rust +struct Model { palette: Option, commands: Vec, /* … */ } +enum Msg { Palette(PaletteMsg), /* … */ } + +// on_key: +if command_palette::open_shortcut(ev) { return Some(Msg::Palette(PaletteMsg::Open)); } +if let Some(_) = &model.palette { return command_palette::on_key(p, ev).map(Msg::Palette); } + +// update: +Msg::Palette(m) => { + if let Some(state) = model.palette.as_mut() { + match command_palette::apply(state, m, &model.commands) { + PaletteAction::Invoke(id) => { /* ejecutar comando id */ model.palette = None; } + PaletteAction::Close => model.palette = None, + PaletteAction::None => {} + } + } +} + +// view_overlay: +model.palette.as_ref().map(|s| + command_palette::view(s, &model.commands, &palette, Msg::Palette)) +``` + +--- + +## 15. `llimphi-workspace` — chasis tipo tmux + +Monta cualquier componente en un layout intercambiable con splits resizables +(máquina de estados de foco/split/cierre + chrome estándar). Construido sobre +`llimphi-widget-panes`. + +```rust +Workspace::new() + .focused() -> PaneId .count() .leaves() -> Vec .layout() -> &Layout + .focus(id) .split(Axis) -> PaneId .close() -> Option .resize(&path, delta) + .apply(WsMsg) -> WsEffect + +enum WsMsg { Focus(PaneId), Split(Axis), Close, Resize(Vec, f32) } +enum WsEffect { None, Created(PaneId), Closed(PaneId) } + +workspace_view(&ws, &WorkspacePalette, + leaf: FnMut(PaneId)->View, // materializa el contenido de cada panel + lift: Fn(WsMsg)->Host) // sube los Msg del chasis a tu Msg +``` + +Patrón: `enum Msg { Ws(WsMsg), Panel(PaneId, PanelMsg) }`. En `update`, +`ws.apply(msg)` te avisa con `WsEffect::{Created,Closed}(id)` para que crees o +destruyas el estado del panel correspondiente. + +--- + +## 16. Reglas duras y gotchas + +### 🔴 Cómputo pesado fuera del hilo de UI (PRIORIDAD URGENTE) +Ningún `update`/`init`/handler puede ejecutar trabajo **síncrono** pesado +(efemérides, simulación, IO, parse, embeddings, layout de árboles grandes). +Bloquea el hilo → "Not Responding". **`init` corre dentro de `resumed`, después +de crear la ventana**, así que un cómputo pesado ahí ya congela una ventana +visible. + +Patrón (referencia: `cosmos-app-llimphi`): +```rust +// Model: Option (None = "calculando…") + flag dirty + contador de generación. +struct Model { x: Option, x_dirty: bool, x_gen: u64 } +enum Msg { XComputed(u64, Arc) } + +// al FINAL de update() (que tiene el Handle): +if m.x_dirty { + m.x_dirty = false; + m.x_gen = m.x_gen.wrapping_add(1); + let (gen, input) = (m.x_gen, m.input.clone()); // sólo lo que el worker necesita (Send) + handle.spawn(move || Msg::XComputed(gen, Arc::new(compute(&input)))); +} +// al recibir: aplicar SÓLO si la generación sigue vigente (evita que un +// resultado tardío pise a uno más nuevo en drags/toggles rápidos). +Msg::XComputed(gen, x) => if gen == m.x_gen { + m.x = Some(Arc::try_unwrap(x).unwrap_or_else(|a| (*a).clone())); +} +``` +La **generación** es imprescindible si el recálculo se dispara seguido. Ver +[`COMPUTO-FUERA-DEL-HILO-UI.md`](COMPUTO-FUERA-DEL-HILO-UI.md) y su checklist por app. + +### Otras +- **Solvers iterativos** (Newton/bisección): cota dura `for _ in 0..N`, nunca + `loop {}` con corte pegado al epsilon de f64 — en debug no converge → loop + infinito. +- **Backend GPU**: preferir Vulkan (`Backends::PRIMARY`); el GL de Mesa sobre + Wayland segfaultea en el teardown. Ya está hecho en `Hal::new`, no revertir. +- **Un nodo es draggable o clickable**, no ambos. +- **`alpha` y `clip`** crean capas intermedias: tienen costo, usar sólo cuando + hace falta. +- **`paint_with`** no debe dejar `push_layer` sin `pop_layer` ni resetear la + Scene. +- **Hit-test respeta `.transform()`**: un nodo rotado/escalado/trasladado recibe + los clicks donde se ve pintado (el runtime invierte el afín acumulado). Lo que + **no** se ajusta todavía: la posición local que reciben los handlers `*_at` se + reporta en coords de pantalla, no en el espacio local del nodo transformado. +- **GPUI está extinto**: no agregar dependencias ni código GPUI (regla §3 de + `CLAUDE.md`). +- **Texto en regla pesada**: crear un `Typesetter` por frame es caro + (`FontContext::new` enumera fuentes del sistema). El runtime ya cachea uno y lo + pasa a `paint_with`. + +--- + +## 17. Comandos y demos + +```bash +cargo check --workspace # smoke test mínimo (debe pasar siempre) +cargo run -p --release # correr una app +cargo run -p --example --release # correr un demo + +# demos del propio framework: +cargo run -p llimphi-ui --example counter --release # bucle Elm completo +cargo run -p llimphi-ui --example editor --release # text field + teclado +cargo run -p llimphi-ui --example gpu_paint_demo --release +cargo run -p llimphi-gallery --release # showcase de TODO el kit +cargo run -p nada --release # editor real para ejercitar widgets + +# benchmark GPU directo vs vello: +cargo run -p llimphi-gpu-bench --release +``` + +`llimphi-gallery` (`src/main.rs`, ~967 líneas) es la **referencia viva** del +patrón completo: `Model`/`Msg`/`init`/`update`/`view`/`view_overlay` con overlays +mutuamente excluyentes (modal > atajos > toasts > context-menu > dropdown). +Controles: click en switches/segments; "Mostrar toast"/"Abrir modal"; `?` abre +atajos; `Esc` cierra el overlay activo. + +--- + +## 18. Cheat-sheet + +```rust +// ── App mínima ────────────────────────────────────────────── +impl App for X { type Model; type Msg; init; update; view; } +llimphi_ui::run::(); + +// ── Nodo ──────────────────────────────────────────────────── +View::new(Style{ flex_direction, size, gap, padding, align_items, justify_content, ..default() }) + .fill(c).hover_fill(c).radius(r).clip(b).alpha(a).transform(xf) + .text(s, px, c) | .text_aligned(s,px,c,al) | .text_runs(s,px,c,runs,al) + .image(img) | .paint_with(|scene,ts,rect|{}) | .gpu_paint_with(|d,q,enc,view,rect,vp|{}) + .on_click(m) | .on_click_at(|lx,ly,w,h|) | .on_right_click(m) | .on_middle_click(m) + .on_pointer_enter(m) | .on_pointer_leave(m) + .draggable(|ph,dx,dy|) | .draggable_at(|ph,dx,dy,lx0,ly0|) + .drag_payload(id) | .on_drop(|id|) | .drop_hover_fill(c) + .children(vec![..]) + +// ── Efectos ───────────────────────────────────────────────── +handle.spawn(|| Msg::Done(compute())); // worker → reentra al update +handle.spawn_periodic(dur, || Msg::Tick); // feed periódico +handle.dispatch(Msg::X); handle.quit(); + +// ── Estilo de layout (taffy prelude) ──────────────────────── +length(px) percent(0..1) Dimension::auto() +FlexDirection::{Row,Column} AlignItems::{Start,Center,End,Stretch} +JustifyContent::{Start,Center,End,SpaceBetween} + +// ── Theme ─────────────────────────────────────────────────── +Theme::dark()/light()/aurora()/sunset(); Theme::next_after(name); XxxPalette::from_theme(&t) + +// ── Overlay (menús/modales) ───────────────────────────────── +fn view_overlay(m) -> Option> { if m.open { Some(menu) } else { None } } +``` + +--- + +## 19. Índice de crates + +**Framework** (`02_ruway/llimphi/`): +`llimphi-hal` · `llimphi-raster` · `llimphi-text` · `llimphi-layout` · +`llimphi-compositor` · `llimphi-ui` · `llimphi-theme` · `llimphi-motion` · +`llimphi-icons` · `llimphi-surface` · `llimphi-workspace` · `llimphi-gallery` · +`llimphi-gpu-bench`. + +**Widgets** (`widgets/`, dep `llimphi-widget-`): app-header · avatar · badge · +banner · breadcrumb · button · card · clipboard · context-menu · edit-menu · +empty · field · gallery · grid · list · menubar · modal · navigator · nodegraph · +panel · panes · progress · segmented · shortcuts-help · skeleton · slider · splash · +splitter · stat-card · status-bar · switch · tabs · text-area · text-editor · +text-editor-core · text-editor-lsp · text-input · theme-switcher · tiled · +timeline · toast · tooltip · tree · wawa-mark. + +**Módulos** (`modules/`): bookmarks · command-palette · diff-viewer · fif · +file-picker · mini-map · plugin-host · selector · shuma-term · symbol-outline. + +**Android** (`android/`): clear-screen-android · vello-hello-android · +vello-text-android. + +--- + +> Documentos hermanos: [`SDD.md`](SDD.md) (diseño y roadmap), +> [`COMPUTO-FUERA-DEL-HILO-UI.md`](COMPUTO-FUERA-DEL-HILO-UI.md) (regla de +> concurrencia), [`README.md`](README.md) / [`LEEME.md`](LEEME.md) (overview). +> Las firmas de este manual reflejan el código al 2026-06-01; ante divergencia, +> la fuente autoritativa es el `lib.rs` de cada crate. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b038ede --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# llimphi + +> Native UI framework: HAL · raster · layout · text · theme · ui — plus widgets and modules. + +`llimphi` is a sovereign, retained-mode UI framework with an Elm-style loop (`input → update → view → layout → raster → present`). Declarative pipeline over `vello` + `wgpu` + `taffy` + `parley`, with `Dark/Light/Aurora/Sunset` themes and a multi-platform HAL (Wayland · X11 · Win32 · Android · Wawa bare-metal). It powers a full Rust application suite; this repository is the framework extracted to stand on its own. + +**Usage manual:** [MANUAL.md](MANUAL.md) — full reference (Elm loop, `View` DSL, the ~44 widgets and 10 modules, GPU path, gotchas) for humans and AI. Design rationale and roadmap: [SDD.md](SDD.md). + +Philosophy: **widgets aren't designed against mockups; they're designed with what `vello` and `taffy` can do.** + +## Quick start + +```sh +git clone https://gitea.gioser.net/sergio/llimphi.git +cd llimphi +cargo run -p llimphi-ui --example counter # ~124 LOC: the full Elm loop on screen +``` + +## Install + +```toml +[dependencies] +llimphi-ui = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +llimphi-theme = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +# widgets are one crate each — pull only what you use: +llimphi-widget-button = { git = "https://gitea.gioser.net/sergio/llimphi.git" } +``` + +## Compatibility + +- **Linux/Wayland** — primary backend. +- **Linux/X11** — via XWayland. +- **macOS / Windows** — `winit` + `wgpu`. +- **Android** — HAL via `android` crates. +- **Wawa bare-metal** — alternative framebuffer HAL. + +Crates listed in [README.md](README.md) (framework, widgets, modules, android). + +## Considerations + +- **Single API: declarative `View`.** No imperative, no foreign vDOM. +- **Same scene tree on Wayland and Wawa**: HAL abstracts the surface. +- Widgets are **purely visual**; modules encapsulate state + behavior. diff --git a/README.qu.md b/README.qu.md new file mode 100644 index 0000000..5470a43 --- /dev/null +++ b/README.qu.md @@ -0,0 +1,35 @@ + + +# llimphi + +> Natural UI framework: HAL · raster · layout · text · theme · ui — widgetkuna + modules. + +`llimphi` monorepupa llapan apps tukuyniqlla grafico motor. Retained-mode declarativo pipeline (`vello` + `wgpu` + `taffy`), `fontdue`/`harfbuzz` shaping, `Dark/Light/Aurora/Sunset` themes, multi-superficie HAL (Wayland · X11 · Win32 · Android · Wawa). Detalle [SDD.md](SDD.md)-pi. + +**Imayna llamk'ana qillqa (manual):** [MANUAL.md](MANUAL.md) — hunt'asqa referencia (Elm muyuy, `View` DSL, ~44 widgetkuna, 10 modulekuna, GPU ñan). Runakunapaq IA-paqpas. + +Yuyaynin: **widget mana mockuppi munakun; vello + taffy atisqankuwan ruwasqa.** + +## Churay + +```sh +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +``` + +## Tinkuy + +- **Linux/Wayland** — ñawpaq backend. +- **Linux/X11** — XWayland-rayku. +- **macOS / Windows** — `winit` + `wgpu`. +- **Android** — `android` cratekuna HAL. +- **Wawa bare-metal** — sapan framebuffer HAL. + +Crateskunaq listako [README.md](README.md)-pi. + +## Yuyaykunaq + +- **Sapan API: declarativo `View`.** Mana imperativo, mana hawanka vDOM. +- **Kikin escena Wayland Wawapipas**: HAL superficie huñun. +- Widgets **ch'uya rikuq**; módulos estado + ruway huñun. diff --git a/SDD.md b/SDD.md new file mode 100644 index 0000000..2f7a55d --- /dev/null +++ b/SDD.md @@ -0,0 +1,366 @@ +# Llimphi — motor gráfico soberano + +> Llimphi (quechua: *color / brillo / pigmento*, en el sentido de "pintar la pantalla"). Tipo: **NATIVE GPU rendering suite**. + +> **Regla dura para apps:** nada de cómputo pesado síncrono en `App::update`/`init`/handlers — congela la UI ("Not Responding"). Ver [COMPUTO-FUERA-DEL-HILO-UI.md](COMPUTO-FUERA-DEL-HILO-UI.md) (patrón worker + checklist por app, prioridad urgente). + +> **¿Buscás cómo *usar* Llimphi?** Este SDD es el *porqué* (diseño, fases, roadmap). La referencia de *uso* — bucle Elm, DSL `View`, catálogo de widgets/módulos, GPU directo — está en [MANUAL.md](MANUAL.md), verificada contra el código. + +## Tesis + +Soberanía total sobre el píxel. Renderizar las geometrías exactas del simulador cósmico (`cosmos`), el compositor (`mirada`), las apps de escritorio (`nahual`) y el visor (`pluma`) sin cajas negras de Apple/Google/navegadores. Reemplazo total de **GPUI** en la pila gioser. + +## Anatomía — 4 capas estrictas (S₀ → S₂) + +Cada capa hace **una sola cosa** con precisión matemática. + +``` +[ CUADRANTE III · 0x02 RUWAY ] + +4. llimphi-ui — Lógica de Interfaz (Árbol Monádico / DAG UI) + │ (manejo de estado, eventos de teclado/ratón) + ▼ +3. llimphi-layout — Motor de Layout (Cálculo Espacial) + │ (cajas, dimensiones, restricciones flex/grid) + ▼ +2. llimphi-raster — Rasterizador Vectorial (La Brocha Fina) + │ (primitivas matemáticas → píxeles via Compute Shaders) + ▼ +1. llimphi-hal — Abstracción de Hardware (Puente al Silicio) + │ (GPU o Framebuffer, sin importar el OS) + ▼ +[ HARDWARE · GPU / Pantalla ] +``` + +## Fases de forja + +### Fase 1 — Puente al Silicio (`llimphi-hal`) + +Aislar el motor del sistema operativo. Llimphi debe pintar tanto en una ventana Wayland controlada por `mirada` como en el framebuffer directo al arrancar `wawa`. + +- **Abstractor:** `wgpu` (impl Rust de WebGPU sobre Vulkan nativo). Control de memoria seguro, bajísima sobrecarga. +- **Ventana:** `winit` para desarrollo en Linux. La arquitectura define un **trait `Surface`** abstracto: el día de mañana se desenchufa `winit` y se le pasa el puntero de memoria bruto del kernel `wawa`. +- **Hito:** Compilar, iniciar Vulkan por debajo, limpiar la pantalla pintándola de un solo color gris plomo a 144 Hz. + +### Fase 2 — Brocha Matemática (`llimphi-raster`) + +Pintar curvas y grafos orbitales con precisión Δ < 10⁻⁹ rad sin destrozar la CPU. En lugar de rasterizar píxel por píxel, **delegar todo el cálculo vectorial a los Compute Shaders de la GPU**. + +- **Motor:** `vello`. +- **Integración:** Conectar la textura de salida de `wgpu` como lienzo destino de `vello`. +- **Ejecución:** Construir una `Scene` en `vello`. Pasarle primitivas geométricas puras (líneas, curvas de Bézier, texto). +- **Hito:** Renderizar en pantalla el grafo de un nodo estático con anti-aliasing perfecto calculado íntegramente por la GPU. + +### Fase 3 — Física del Espacio (`llimphi-layout`) + +Posicionar dinámicamente paneles, texto y ventanas requiere resolver ecuaciones de restricciones espaciales. No escribir un sistema propio de márgenes/padding: es un sumidero infinito. + +- **Motor:** `taffy` (de la gente de Dioxus). Algoritmos Flexbox + CSS Grid en Rust puro. +- **Flujo:** Antes de decirle a `llimphi-raster` dónde pintar, pasar el árbol de nodos a `taffy` para calcular las coordenadas `(x, y, width, height)` absolutas de toda la interfaz. +- **Hito:** Paneles laterales y cajas que se redimensionan automáticamente, calculados en < 1 ms por frame. + +### Fase 4 — Árbol de Estado Monádico (`llimphi-ui`) + +El mayor problema de las interfaces (y por qué falló el paradigma OOP en esto) es el manejo del estado. Aquí se inyecta la cosmovisión estructural. + +- **Arquitectura:** Nada de mutabilidad compartida (`Rc>` disperso). Unidireccional estilo Elm o **DAG (Grafo Acíclico Dirigido)**: el estado de la aplicación es **inmutable** y cada evento (click, tecla) genera una **nueva versión** del estado. +- **Bucle:** + 1. El usuario hace click (Input). + 2. El evento actualiza el Estado Global. + 3. El Estado Global reconstruye el Árbol UI. + 4. El Árbol pasa por `llimphi-layout` (Layout). + 5. Las coordenadas resultantes generan primitivas para `llimphi-raster` (Scene). + 6. `llimphi-hal` renderiza y hace el swap de la pantalla. + +## Veredicto arquitectónico + +No es una biblioteca genérica. Es un **motor de combate**. `wgpu + vello + taffy + DAG monádico` da un frontend capaz de competir en rendimiento con los mejores editores del mundo, diseñado como **traje a medida** para las topologías de gioser. Sin abstracciones de navegadores, sin cajas negras de Apple/Google. + +## Pila exacta (sin negociación) + +| Capa | Crate raíz | Deps externas | +|---|---|---| +| HAL | `llimphi-hal` | `wgpu`, `winit`, `raw-window-handle` | +| Raster | `llimphi-raster` | `vello`, `vello_encoding`, `peniko` | +| Text | `llimphi-text` | `parley` (shaping + fontique + swash, hereda vello via raster) | +| Layout | `llimphi-layout` | `taffy` | +| UI | `llimphi-ui` | `llimphi-{hal,raster,layout,text}` | + +## Migración GPUI → Llimphi + +Apps actualmente en GPUI que deben portarse: + +- `02_ruway/nahual/*` (todas las apps GPUI: shell, file-explorer, database-explorer, image-viewer, text-viewer + 8 libs + 12 widgets) +- `02_ruway/mirada/mirada-launcher`, `mirada-portal`, `mirada-greeter` +- `00_unanchay/pluma/pluma-editor-gpui` +- `01_yachay/dominium/dominium-canvas-gpui` +- `01_yachay/cosmos/cosmos-app` (canvas + panels GPUI) + +**Estrategia:** Las apps mantienen su lógica de dominio en sus `*-core` agnósticos. Solo se reemplaza la capa de presentación: en lugar de `use gpui::*`, pasan a usar `use llimphi_ui::*`. + +## Estado (2026-05-31) + +### Hecho +- Las 5 capas del framework en producción: `llimphi-hal` (wgpu+winit), `llimphi-raster` (vello), `llimphi-text` (parley, ahora con vello directo y texto multicolor en una pasada), `llimphi-layout` (taffy, con `LayoutTree::clear()` para reuso entre frames), `llimphi-ui` (bucle Elm + runtime winit). +- Split compositor/runtime: `llimphi-compositor` (winit-free: View tree, mount, paint/paint_gpu, hit-test) separado de `llimphi-ui` (runtime winit) → habilita un futuro runtime sobre el framebuffer de `wawa` sin winit. +- GPUI extinto (2026-05-26): toda app gráfica de la suite corre sobre Llimphi. +- Backend GPU directo (sin vello) completo y validado en hardware real (Iris Xe): `GpuPipelines` + `GpuBatch` + `View::gpu_paint_with`; ~11× vs vello a 1M puntos persistente, >140 fps. +- Catálogo de ~44 widgets: incluye text-editor (split en `-core` agnóstico + `-lsp`), nodegraph, tiled/panes/splitter, tree, list, grid (virtualizada 2D), gallery, timeline (scrub clickeable), menubar/edit-menu/context-menu, clipboard del sistema, tabs, modal, toast, y la familia de controles (button/field/slider/switch/segmented/...). +- 10 módulos compuestos: command-palette, diff-viewer, fif (find-in-files), file-picker, bookmarks, mini-map, shuma-term, symbol-outline, selector, plugin-host. +- `llimphi-workspace` (chasis tipo tmux) + `llimphi-gallery` (showcase) + `llimphi-motion`/`llimphi-icons`/`llimphi-surface` auxiliares. + +### Pendiente +- Runtime sobre framebuffer de `wawa` (`WawaFramebufferSurface`) reusando el compositor winit-free — habilitado por el split pero aún no escrito. +- Backend GPU directo: sin MSAA/AA fino, sin texto, una sola `line_width` por flush; falta primer caller real denso (cosmos starfield) que mida una falla concreta antes de extender shaders. +- Widgets `llimphi-widget-{transport, waveform}` aún por extraer (la nota de media los deja como futuro no bloqueante). +- Investigación abierta: cuelgue/deadlock de apps Llimphi tras click/scroll (hipótesis `get_current_texture` Wayland FIFO) — pendiente reproducir+backtrace. + +## Estado — bitácora histórica + +- **2026-05-25:** SDD escrito. Esqueletos de los 4 crates creados. +- **2026-05-25 (tarde):** Las 4 fases en código y compilando. Examples: + - `cargo run -p llimphi-hal --example clear_screen --release` — ventana gris plomo a refresh del display ✅ (verificado en hardware). + - `cargo run -p llimphi-raster --example render_node --release` — nodo con AA perfecto vía vello/wgpu. + - `cargo run -p llimphi-layout --example layout_panels --release` — sidebar + header/body/footer flex que se reorganiza al resize. + - `cargo run -p llimphi-ui --example counter --release` — bucle Elm completo: click hit-test → update → view → layout → raster → present. +- **2026-05-25 (noche):** quinto crate `llimphi-text` (skrifa + vello). Bug de `max_storage_buffers_per_shader_stage` corregido (`Limits::default()` en vez de `downlevel`). `View::text()` permite poner texto centrado en cualquier nodo. Examples: + - `cargo run -p llimphi-text --example hello_text --release` — "Llimphi" + tagline sobre fondo negro. + - `counter` ahora muestra el número real (no barras) y los botones llevan label. +- **2026-05-25 (cierre):** dos fixes de hardware + parley. + - **Storage write fix:** swapchain de muchos adapters Linux/Vulkan no acepta storage writes en Rgba8Unorm. Patrón nuevo: textura intermedia con `STORAGE_BINDING | TEXTURE_BINDING` donde pinta vello + `TextureBlitter` que la copia al swapchain en `Surface::present(frame, &hal)`. Cambio de API: `frame.present()` → `surface.present(frame, &hal)`. + - **Paint-order fix:** `mount_recursive` registraba en post-orden y el background del root tapaba a los hijos. Ahora pre-orden depth-first. + - **Parley:** llimphi-text reescrito sobre parley. API nueva: `Typesetter` (cachea FontContext + LayoutContext), `TextBlock { text, size_px, color, origin, max_width, alignment, line_height }`, `Alignment { Start, Center, End, Justify }`, `measure(&mut ts, &block)`. Bidi + ligatures + fallback CJK/emoji vía fontique. `hello_text` muestra título + párrafo justificado con script mixto Latin/Arabic/CJK. +- **2026-05-25 (cierre+1):** teclado en `llimphi-ui`. `App` gana `fn on_key(model, &KeyEvent) -> Option` con default `None`. Re-export `Key` y `NamedKey` de winit. Runtime mantiene `Modifiers` state vía `ModifiersChanged`. `TextSpec` gana `alignment` (default `Center`, los labels de botón siguen igual) + `View::text_aligned(...)`. Example nuevo `editor`: text field con char insertion, backspace, enter, tab→4-spaces, ctrl+L limpia. +- **2026-05-26:** migración GPUI → Llimphi **completada**. GPUI queda extinto: toda app gráfica de la suite (pluma, mirada, cosmos, dominium, nahual, iniy, khipu, chasqui…) corre sobre Llimphi. No se agrega código nuevo sobre GPUI (ver regla dura §3 de `CLAUDE.md`). +- **2026-05-31:** split de `llimphi-widget-text-editor` (4328 LOC) → núcleo agnóstico `llimphi-widget-text-editor-core` (buffer/cursor/ops/undo/bracket/find/diagnostics/clipboard/highlight, sin render: sólo `peniko::Color`) + widget Llimphi (state + view) que lo re-exporta. Núcleo reutilizable en TUI/web/headless. `LayoutTree::clear()` para reusar el árbol taffy entre frames (`llimphi-layout`). +- **2026-05-31 (texto multicolor):** syntax highlighting en una sola pasada de shaping. `llimphi-text` gana `RunBrush` + `Typesetter::layout_runs` (color por rango de bytes vía `parley::RangedBuilder`/`StyleProperty::Brush`) + `draw_layout_runs`; `View::text_runs` lo expone. El editor pasó de un nodo (+ layout parley) por token a uno por línea. +- **2026-05-31 (split compositor/runtime):** `llimphi-ui` (1943 LOC) partido para separar la composición declarativa del runtime winit: + - **`llimphi-compositor`** (nuevo, **winit-free**): el árbol `View`, `mount` sobre taffy, `paint`/`paint_gpu` a `vello::Scene` y el hit-test. Depende sólo de `llimphi-layout` + `llimphi-text` + `vello` + `wgpu` (este último sólo por la firma de `GpuPaintFn`; `wgpu` no es windowing). **No depende de `llimphi-hal`.** + - **`llimphi-ui`**: queda como el runtime winit (`App`/`Handle`/`run`/event loop/`KeyEvent`) y re-exporta el compositor entero → los consumidores siguen usando `llimphi_ui::View` etc. sin cambios. + - Prerrequisito habilitado: `llimphi-text` ahora depende de `vello` directo (no de `llimphi-raster`), así que la pila de render (`compositor`→`text`/`vello`) es winit-free. Eso abre la puerta a un runtime sobre el framebuffer del kernel `wawa` (`WawaFramebufferSurface`) que reuse el mismo compositor sin arrastrar winit. `Renderer` (lo único que necesita `llimphi-hal`) se queda en `llimphi-raster`, consumido por `llimphi-ui`. + +## Roadmap — GPU directo wgpu (sin vello) + +### Por qué + +`llimphi-raster` traduce hoy todo a `vello::Scene` (BezPath / kurbo / +peniko) y vello rasteriza vía compute shaders. Para 99 % de la suite +sobra: pluma editor, shuma shell, mirada compositor, nahual, iniy, khipu, +chasqui explorer, etc. pintan decenas a centenas de primitivos por frame. + +El techo aparece cuando una app necesita rendir **>1 M primitivos por +frame**. En ese régimen el overhead de construir `BezPath`, ensamblar +buffers para los shaders internos de vello y hacer una pasada compute +por cada batch domina sobre el tiempo de raster real. Casos concretos +en gioser: + +| App | Carga potencial | Trigger probable | +|---|---|---| +| **cosmos** | Catálogo Gaia DR3, mapas de cielo enteros | Starfield denso o sky-survey overlay | +| **tinkuy** | Particle engine N→∞ por diseño | Sim con > 10⁵ partículas | +| **nakui** | 100 K filas × 26 cols = 2.6 M celdas potencialmente visibles | Viewport con dataset grande | +| **dominium** | Mean-field con N agentes | Cuando se pase de 10³ a 10⁵ | +| **pineal** | Sus painters ya producen `Vec` interleaved (principio P1) — son los primeros listos para consumir el backend | Cualquiera de los anteriores que use pineal-* | + +El techo es **horizontal**. Resolverlo en cualquier app individual sería +duplicación; el lugar es el motor. + +### Qué es + +Un backend alternativo en `llimphi-raster` que **salta vello** y sube +los slices de coordenadas directamente a vertex buffers `wgpu`, dispara +shaders WGSL chiquitos y emite una draw call por batch. + +``` +hoy: painter → vello::Scene → BezPath → vello → wgpu → GPU +con esto: painter → GpuBatch → vertex buffer → wgpu → GPU +``` + +El trait que ven las apps (`Canvas` para pineal, `View::paint_with` para +llimphi-ui) **no cambia**. Cambia el implementador por debajo cuando se +elige "modo GPU directo". + +### Trade-offs vs vello + +| | Vello (hoy) | GPU directo | +|---|---|---| +| AA | Analítico, perfecto | MSAA hardware o supersample en shader | +| Curvas suaves | Bezier nativo | Hay que teselar primero | +| Texto | Sí, vello + parley | No — usar vello para text aunque coexista | +| Throughput primitivos | Bueno hasta ~100 K | Apto para 1–10 M | +| Costo de mantener | Cero (vello lo mantiene Linebender) | Shaders WGSL + pipelines propias | + +Decisión: los dos backends **coexisten**. La app elige por hint +(`View::gpu_paint_with` para denso, `paint_with` para todo lo demás). + +### Plan de tareas + +**Fase 0 — Spike de medición (½ día). ✓ HECHO (2026-05-28).** +Benchmark sintético: pintar 100 K, 500 K y 1 M puntos con `SceneCanvas` +actual vs un mock GPU-directo (vertex buffer + shader trivial). Si el +factor no es ≥ 5× en el rango de 500 K, abortar — vello ya es +suficiente y no vale el costo de mantenimiento. Métrica de éxito: 60 fps +con 1 M puntos en GPU mid (Radeon 5500M, Intel Iris Xe). + +Implementado en `llimphi-raster/examples/spike_gpu_directo.rs`. Cubre +ambos backends contra una textura `Rgba8Unorm` 1024×1024 headless, +warmup 5 + 15 frames medidos, bloquea hasta GPU idle (`Maintain::Wait`) +para que los `ms` reportados sean tiempo real CPU+GPU. + +El binario `llimphi-gpu-bench` (en su propio crate) reporta info del +adapter wgpu + corre dos escenarios distintos: **rebuild por frame** +(LCG + `write_buffer` de 12-160 MB por frame, peor caso) y +**persistente** (buffer/Scene preparados UNA vez, bucle medido sólo +emite la draw call — caso real de cosmos/tinkuy/nakui). + +**Resultados — Intel Iris Xe (TGL GT2), Mesa 26.1.1, Vulkan, 2026-05-28:** + +Rebuild por frame: + +| N | vello ms | directo ms | factor | +|---:|---:|---:|---:| +| 25K | 7.3 | 1.2 | **6.05×** | +| 50K | 12.9 | 1.4 | **8.94×** | +| 100K | 21.7 | 3.2 | **6.67×** | +| 200K | 26.1 | 6.1 | 4.30× | +| 500K | 94.4 | 18.0 | **5.25×** | +| 1M | 202.4 | 49.0 | 4.13× | + +Persistente (datos fijos, sólo redraw): + +| N | vello ms | directo ms | factor | fps directo | +|---:|---:|---:|---:|---:| +| 100K | 18.6 | 0.8 | **22.55×** | 1210 | +| 500K | 34.1 | 3.4 | **9.97×** | 293 | +| 1M | 83.1 | 7.1 | **11.76×** | 141 | +| 2M | 101.7 | 16.0 | **6.37×** | 63 | +| 5M | crash | 41.8 | — | 24 | +| 10M | crash | 79.7 | — | 13 | + +Veredictos contra el criterio del SDD: + +- **Factor ≥5× a 500K**: ✓ PASA. Rebuild 5.25×, persistente 9.97×. +- **≥60 fps @ 1M**: ✓ PASA en persistente (141 fps); falla en rebuild + (22 fps) — pero rebuild no es el use case real. +- **Techo de vello**: ~2 M paths en GPU mid. Más alto que mi hipótesis + inicial (que era 200–300 K, contaminada por llvmpipe), pero existe. + El path directo escala lineal a >10 M sin crashes. + +Conclusión: el GPU directo cumple su propósito. La diferencia entre +rebuild y persistente (5–20×) confirma que el patrón correcto es +"datos cambian → vello, datos estáticos → GPU directo persistente". + +**Fase 1 — Hook en `llimphi-ui` (1–2 días).** +Hoy `View::paint_with(F)` da +`F: Fn(&mut vello::Scene, &mut Typesetter, PaintRect)`. Agregar: + +```rust +View::gpu_paint_with(F) + where F: Fn(&wgpu::Device, &wgpu::Queue, + &mut wgpu::CommandEncoder, + &wgpu::TextureView, PaintRect) +``` + +El runtime de llimphi-ui ya tiene `Device`/`Queue` para vello; sólo hay +que exponer el `CommandEncoder` y `TextureView` del frame durante el +mount/paint. Compatibilidad: ambos hooks coexisten en el mismo View +tree; el orden de pintura sigue siendo pre-orden DFS. + +**Fase 2 — Pipelines y shaders en `llimphi-raster` (3–5 días).** +Tres pipelines WGSL precompiladas y cacheadas: + +- `lines_pipeline` — line list, anchura uniforme (expandida a tris en + vertex shader como hace pineal-export::png). +- `tris_pipeline` — triangle list con per-vertex color. +- `rects_pipeline` — instanced quad con per-instance `[x, y, w, h, color]`. + +Vertex format común: `[x: f32, y: f32, rgba: u32]`. Sin texturas; eso +queda para una fase posterior si aparece demanda. + +**Fase 3 — `GpuBatch` accumulator (2–3 días).** +Estructura que las apps usan dentro del callback: + +```rust +let mut batch = GpuBatch::new(device); +batch.add_lines(&coords, color); +batch.add_tris(&coords, &colors); +batch.add_rect(rect, color); +batch.flush(encoder, view); // 1 draw call por pipeline usada +``` + +Grow strategy: vertex buffer dobla capacidad cada vez que se queda +chico. Sin copy back — vive del frame, se reusa el siguiente. + +**Fase 4 — `GpuSceneCanvas` en pineal-render (1 día).** +Wrapper que implementa el trait `Canvas` de pineal usando `GpuBatch` +por debajo. Cero cambios en los painters. Permite usar el catálogo +entero de pineal en modo denso simplemente eligiendo el otro +constructor de Canvas dentro del `gpu_paint_with`. + +**Fase 5 — Primer caller real (cosmos starfield, 2–3 días).** +Adaptar `cosmos-canvas-llimphi` para subir todas las estrellas del +viewport en una draw call usando `gpu_paint_with`. Métrica: dataset +HYG (~120 K estrellas brillantes) renderizadas a 144 fps en GPU mid. + +**Fase 6 — Tests + demo + SDD (1 día). ✓ HECHO (2026-05-28).** +- `llimphi-raster/examples/gpu_million_points.rs`: usa `GpuPipelines` + + `GpuBatch` puros (sin app, sin runtime Elm) para pintar N rects + sintéticos. Validación headless del HAL + bench de referencia + post-implementación. Smoke en `tests/gpu_batch_smoke.rs`. +- Tabla "cuándo elegir" → abajo. +- Pineal SDD §4 actualizado con `GpuSceneCanvas` en producción. + +### ¿Cuándo elegir vello vs GPU directo? + +| Pregunta | Vello (`paint_with`) | GPU directo (`gpu_paint_with`) | +|---|---|---| +| ¿Cuántos primitivos por frame? | < ~500 K (rebuild) o < ~2 M (Scene reusada) | 100 K – 10 M+ | +| ¿Los datos cambian cada frame? | Sí — vello rebuild es barato hasta 500 K | Posible pero con coste de `write_buffer`; ideal estático | +| ¿Curvas Bezier nativas? | Sí | No (teselar antes) | +| ¿Texto? | Sí | No — usar vello hermano u overlay | +| ¿AA fino requerido? | Sí (analítico) | No (sin MSAA todavía) | +| ¿Múltiples grosores de stroke? | Sí | Una sola `line_width` por flush | +| ¿Anti-fluctuación de pixel? | Sí | Subpixel jitter visible | +| Ejemplos de uso | pluma editor, shuma shell, mirada, nahual, iniy, khipu, chasqui explorer, dominium UI | cosmos starfield denso, tinkuy particles, nakui viewport, pineal denso | + +Default razonable: **`paint_with`** salvo que el caller ya midió que el +volumen lo justifica. El costo de mantener un pipeline + WGSL propios +es alto comparado con seguir usando vello. + +Patrón "buffer persistente": para el use case denso real (catálogo +fijo, particles iniciales, dataset estático), construir el +`wgpu::Buffer` y `BindGroup` UNA vez con `GpuPipelines::{rects, tris, +lines, bind_layout}` expuestos y emitir el draw call manualmente +desde el `gpu_paint_with` reusando esos recursos. Eso da factores +~11× vs vello a 1M en GPU mid (medido Iris Xe), y >140 fps. +`GpuBatch` queda para datos transitorios (UI dinámica densa). + +Convivencia: una misma `View` puede registrar AMBOS hooks. El runtime +pinta vello primero (toda la Scene), luego ejecuta los GPU painters +en orden DFS. Para texto encima de un render GPU denso, se usa +`App::view_overlay` (segunda Scene vello sobre el main). + +**Estimado total: 10–15 días de trabajo concentrado.** +**Trabajo real (1 día, 2026-05-28):** todas las fases completas, sólo +falta validar el criterio formal (≥5× a 500K, 60 fps @ 1M) en GPU mid +real — el bench corrió en llvmpipe. + +### Trigger + +No empezar hasta tener un caller real que mida una falla concreta. +El candidato natural es cosmos (starfield Gaia o sky-survey overlay). +Hasta entonces, el item queda acá en este SDD como decisión arquitectónica +tomada — todas las apps saben que el techo existe y que la salida +está diseñada. + +### No-objetivos explícitos + +- **No** reemplazar vello. Coexisten — vello para vector/text/AA fino, + GPU directo para volumen. +- **No** hacer un layer de abstracción tipo Skia. El trait `Canvas` de + pineal y el `paint_with` de llimphi son la abstracción; no se agrega + más arriba. +- **No** soportar texto en el backend GPU directo. Texto siempre por + vello+parley; si una vista mezcla millones de puntos + labels, hace + `gpu_paint_with` para los puntos y un `paint_with` superpuesto para + los labels. diff --git a/android/LEEME.md b/android/LEEME.md new file mode 100644 index 0000000..0b1c254 --- /dev/null +++ b/android/LEEME.md @@ -0,0 +1,108 @@ +# Llimphi · Android + +Port nativo de Llimphi a Android. Una `NativeActivity` en C que +delega al `android_main` que `android-activity` exporta desde la +`.so` Rust, idéntico patrón que un binario `main()` en desktop. + +## Estado + +| crate | estado | +|---|---| +| `clear-screen-android` | ✓ APK firmado v2, instalable en Android 7+ | +| resto de apps Llimphi | pendientes — el patrón es reusar `android_main` | + +## Tesis + +El motor Llimphi (HAL + raster + layout + text + ui) **no se toca**. +Lo único nuevo por target Android es: + +1. Entry-point `#[no_mangle] android_main(app: AndroidApp)` en vez de + `fn main()`. +2. Construir el `EventLoop` con `with_android_app(app)` para que + `winit` reciba `Resumed` / `Suspended` / `InputAvailable` desde el + Looper de Android. +3. Recrear la `Surface` en cada `Resumed`: Android invalida la + NativeWindow al pasar a background. El `App::state: Option` + ya está estructurado para eso. + +Las apps existentes que viven sobre Llimphi compilan sin cambios — lo +que se reescribe es el **lifecycle wrapper**, no la lógica de render +ni los widgets. + +## Cómo construir + +Una sola pasada — el script wrapper: + +```sh +./scripts/build-android.sh clear-screen-android +``` + +Resultado: `target/x/release/android/clear-screen-android.apk` +firmado con APK Signature Scheme v2, listo para +`adb install -r `. + +## Setup inicial (una vez por máquina) + +```sh +# Targets Rust +rustup target add aarch64-linux-android x86_64-linux-android + +# Wrapper de build de Rust mobile (binario `x`) +cargo install xbuild + +# NDK r27c (~640 MB descomprimido, ~1.5 GB) +curl -L -o /tmp/ndk.zip \ + https://dl.google.com/android/repository/android-ndk-r27c-linux.zip +unzip /tmp/ndk.zip -d $HOME/ +export ANDROID_NDK_HOME=$HOME/android-ndk-r27c + +# SDK (sólo build-tools + platform-tools, no se necesita la plataforma +# completa porque el APK se genera con aapt2 + apksigner del SDK). +# En Artix viene del paquete `android-sdk-build-tools`. +``` + +El script `build-android.sh` genera automáticamente un PEM RSA2048 +self-signed en `~/.local/share/llimphi-android/debug.pem` la primera +vez que corre. Para firma de release usar un PEM propio y exportarlo +en `LLIMPHI_PEM`. + +## Estructura del APK generado + +``` +clear-screen-android.apk +├── AndroidManifest.xml ← xbuild genera; NativeActivity +└── lib/arm64-v8a/ + └── libclear_screen_android.so ← 7.5 MB sin strip, ~2 MB stripped +``` + +Sin assets, sin recursos, sin Java/Kotlin. Todo el "código" de la app +es la `.so` Rust. El bootstrap Java de NativeActivity lo provee el +framework Android. + +## Apps por portar (orden de menor a mayor fricción) + +Las apps que **menos** se modifican al portar son las que ya tienen +poca interacción con teclado/mouse y mucho rendering: + +1. **mirada-image-viewer-llimphi** — visor de imágenes, gestos = ok +2. **nahual-text-viewer-llimphi** — sólo scroll + zoom +3. **nahual-image-viewer-llimphi** — idem +4. **pluma-md-reader** — visor markdown, mismo patrón que la web +5. **chasqui-explorer-llimphi** — listas y tarjetas, taps obvios +6. **shuma-shell-llimphi** — teclado virtual, ya casi no usa shortcuts +7. **mirada-app-llimphi** — el compositor; touch desktop = problema UX + +Las apps con paleta de comandos (nada, pluma-app full) son las +**últimas** porque su UX core (Ctrl+Shift+P, multi-pane splitter, +file picker) necesita ser repensada para touch. + +## Próximos hitos + +- **Tier 1.5**: hello-world con vello rasterizando un texto + figura + (smoke test del stack raster completo en Android). +- **Tier 2**: portar `mirada-image-viewer-llimphi` — primer APK + funcional con UI real. +- **Tier 3**: input handling proper (touch events, soft keyboard, + back button), theming responsivo (dpi/density). +- **Tier 4**: distribución (Play Store internal track, F-Droid build + reproducible). diff --git a/android/clear-screen-android/Cargo.toml b/android/clear-screen-android/Cargo.toml new file mode 100644 index 0000000..ee6b91f --- /dev/null +++ b/android/clear-screen-android/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "clear-screen-android" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "Demo Android Tier 1: pinta la pantalla con LEAD_GRAY usando llimphi-hal sobre Android NativeActivity." + +# Android NativeActivity carga la lib nativa como .so via dlopen; el +# binario final es una `cdylib` con `android_main` exportado. xbuild / +# cargo-apk se encargan de empaquetar el .so dentro del APK. +[lib] +crate-type = ["cdylib"] + +[dependencies] +llimphi-hal = { path = "../../llimphi-hal" } +# Activamos el feature de NativeActivity en winit para que linkee con la +# clase NativeActivity del NDK y reciba eventos de surface/input desde la +# Activity Java/Kotlin generada por android-activity. +winit = { workspace = true, features = ["android-native-activity"] } +wgpu.workspace = true +pollster.workspace = true +# `log` se declara aquí (no en el bloque condicional Android) para que +# `cargo check --workspace` en host pase: los macros de `log` son no-op +# sin logger instalado. En Android, `android_logger` (más abajo) instala +# el sink real hacia `logcat`. +log = "0.4" + +[target.'cfg(target_os = "android")'.dependencies] +android-activity = { version = "0.6", features = ["native-activity"] } +android_logger = "0.14" + +# Metadata para xbuild / cargo-apk — define el manifiesto Android que se +# inyecta en el APK final. +[package.metadata.android] +package = "net.gioser.llimphi.clearscreen" +build_targets = ["aarch64-linux-android", "x86_64-linux-android"] +min_sdk_version = 24 +target_sdk_version = 34 + +[package.metadata.android.application] +label = "Llimphi · clear_screen" +debuggable = true + +[package.metadata.android.application.activity] +config_changes = "orientation|screenSize|keyboardHidden" +launch_mode = "singleTop" +orientation = "unspecified" diff --git a/android/clear-screen-android/LEEME.md b/android/clear-screen-android/LEEME.md new file mode 100644 index 0000000..2b7e4c5 --- /dev/null +++ b/android/clear-screen-android/LEEME.md @@ -0,0 +1,11 @@ +# clear-screen-android + +> Smoke test del HAL Android de [llimphi](../../README.md). + +App mínima que limpia la pantalla con un color sólido. Sirve para verificar que el HAL Android compila + corre + dibuja sin que el resto del stack ofusque el problema. + +## Build + +```sh +cargo apk build -p clear-screen-android +``` diff --git a/android/clear-screen-android/README.md b/android/clear-screen-android/README.md new file mode 100644 index 0000000..0dbaf6f --- /dev/null +++ b/android/clear-screen-android/README.md @@ -0,0 +1,11 @@ +# clear-screen-android + +> Android HAL smoke test of [llimphi](../../README.md). + +Minimal app that clears the screen with a solid color. Verifies the Android HAL compiles + runs + draws without the rest of the stack obscuring the problem. + +## Build + +```sh +cargo apk build -p clear-screen-android +``` diff --git a/android/clear-screen-android/src/lib.rs b/android/clear-screen-android/src/lib.rs new file mode 100644 index 0000000..4632869 --- /dev/null +++ b/android/clear-screen-android/src/lib.rs @@ -0,0 +1,291 @@ +//! Demo Tier 1 Android: pinta la pantalla con LEAD_GRAY usando llimphi-hal. +//! +//! Logging exhaustivo en cada paso del bootstrap para diagnosticar +//! cuelgues en device real desde `adb logcat -s llimphi-android:V`. +//! Panic hook captura backtraces a logcat — sin esto el crash es +//! invisible (Android cierra el proceso silenciosamente). +//! +//! Orden de inicialización en `resumed`: +//! 1. crear Window via winit +//! 2. crear wgpu::Instance +//! 3. crear Surface con la NativeWindow +//! 4. request_adapter pasándole compatible_surface=Some(&surface) +//! 5. request_device +//! 6. configurar surface (formato, tamaño) +//! 7. crear textura intermedia + blitter (llimphi-hal::WinitSurface) +//! +//! El orden 3 antes que 4 es lo que **garantiza** que el adapter +//! elegido sabe presentar a esa NativeWindow concreta. Llamar +//! `Hal::new(None)` (como hacía la primera versión) elige un adapter +//! "cualquiera" y después la creación de surface puede fallar — o +//! peor, parecer OK y crashear en el primer `present`. + +use std::sync::Arc; +use std::time::Instant; + +use llimphi_hal::winit::application::ApplicationHandler; +use llimphi_hal::winit::event::WindowEvent; +use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId}; +use llimphi_hal::{wgpu, Hal, Surface, WinitSurface}; + +const LEAD_GRAY: wgpu::Color = wgpu::Color { + r: 0.235, + g: 0.239, + b: 0.247, + a: 1.0, +}; + +const TAG: &str = "llimphi-android"; + +struct State { + window: Arc, + hal: Hal, + surface: WinitSurface, +} + +struct App { + state: Option, + frames: u64, + last_report: Instant, +} + +impl App { + fn new() -> Self { + Self { + state: None, + frames: 0, + last_report: Instant::now(), + } + } + + /// Bootstrap: crea el estado completo o devuelve un mensaje + /// explicando dónde falló. **No panic-ea** — los panics en + /// `android_main` arrancan la cierre del proceso antes que el + /// logcat flushee. + fn boot(&self, event_loop: &ActiveEventLoop) -> Result { + log::info!("[boot] 1/7 creando Window"); + let window = event_loop + .create_window(WindowAttributes::default().with_title("llimphi · clear_screen")) + .map_err(|e| format!("create_window: {e}"))?; + let window = Arc::new(window); + let size = window.inner_size(); + log::info!( + "[boot] window ok · inner_size = {}x{}", + size.width, + size.height + ); + + log::info!("[boot] 2/7 creando wgpu::Instance"); + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends: wgpu::Backends::all(), + ..Default::default() + }); + log::info!("[boot] instance ok · backends activos = {:?}", instance); + + log::info!("[boot] 3/7 creando Surface contra la NativeWindow"); + let surface = instance + .create_surface(window.clone()) + .map_err(|e| format!("create_surface: {e}"))?; + log::info!("[boot] surface creada"); + + log::info!("[boot] 4/7 request_adapter (compatible_surface=Some)"); + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, + compatible_surface: Some(&surface), + })) + .ok_or_else(|| "request_adapter devolvió None — sin GPU compatible".to_string())?; + let info = adapter.get_info(); + log::info!( + "[boot] adapter ok · backend={:?} name={:?} driver={:?}", + info.backend, + info.name, + info.driver_info + ); + + log::info!("[boot] 5/7 request_device"); + // En Android (Mali/Adreno entry-level) Limits::default suele exceder + // el hardware. using_resolution recorta lo recortable preservando + // los counts mínimos (5 storage buffers/stage que vello necesita). + let limits = wgpu::Limits::default().using_resolution(adapter.limits()); + let (device, queue) = pollster::block_on(adapter.request_device( + &wgpu::DeviceDescriptor { + label: Some("clear-screen-android-device"), + required_features: wgpu::Features::empty(), + required_limits: limits, + memory_hints: wgpu::MemoryHints::Performance, + }, + None, + )) + .map_err(|e| format!("request_device: {e}"))?; + log::info!("[boot] device + queue ok"); + + log::info!("[boot] 6/7 ensamblando Hal"); + let hal = Hal { + instance, + adapter, + device, + queue, + }; + + log::info!("[boot] 7/7 envolviendo en WinitSurface (intermediate + blitter)"); + // Crítico: usar `from_surface` (no `new`), pasando la surface que + // ya creamos en el paso 3. `WinitSurface::new` haría un segundo + // create_surface contra la misma NativeWindow y Android responde + // ERROR_NATIVE_WINDOW_IN_USE_KHR → panic. + let llimphi_surface = WinitSurface::from_surface(&hal, window.clone(), surface) + .map_err(|e| format!("WinitSurface::from_surface: {e}"))?; + log::info!("[boot] ✓ bootstrap completo, pidiendo redraw"); + window.request_redraw(); + + Ok(State { + window, + hal, + surface: llimphi_surface, + }) + } +} + +impl ApplicationHandler for App { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + log::info!("Resumed event"); + match self.boot(event_loop) { + Ok(state) => self.state = Some(state), + Err(e) => { + log::error!("BOOT FAILED: {e}"); + // No exit-amos para que el process siga vivo y se vea el + // log; el usuario cerrará la app manualmente. + } + } + } + + fn suspended(&mut self, _event_loop: &ActiveEventLoop) { + log::info!("Suspended event — liberando surface"); + self.state = None; + } + + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + _id: WindowId, + event: WindowEvent, + ) { + let Some(state) = self.state.as_mut() else { + return; + }; + match event { + WindowEvent::CloseRequested => { + log::info!("CloseRequested"); + event_loop.exit(); + } + WindowEvent::Resized(size) => { + log::info!("Resized → {}x{}", size.width, size.height); + state.surface.resize(size.width, size.height); + state.window.request_redraw(); + } + WindowEvent::RedrawRequested => { + let frame = match state.surface.acquire() { + Ok(f) => f, + Err(e) => { + log::warn!("acquire falló ({e}); reconfigurando"); + let (w, h) = state.surface.size(); + state.surface.resize(w, h); + state.window.request_redraw(); + return; + } + }; + let mut encoder = + state + .hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("clear_screen-encoder"), + }); + { + let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("clear_screen-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: frame.view(), + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(LEAD_GRAY), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + } + state.hal.queue.submit(std::iter::once(encoder.finish())); + state.surface.present(frame, &state.hal); + + self.frames += 1; + let elapsed = self.last_report.elapsed(); + if elapsed.as_secs() >= 1 { + let fps = self.frames as f64 / elapsed.as_secs_f64(); + log::info!("{fps:.1} fps"); + self.frames = 0; + self.last_report = Instant::now(); + } + state.window.request_redraw(); + } + _ => {} + } + } +} + +#[cfg(target_os = "android")] +fn install_panic_logger() { + // Sin esto los panic son invisibles: Android mata el proceso antes + // que la línea de stderr llegue a logcat. set_hook redirige el panic + // info a log::error que sí sale en logcat (vía android_logger). + std::panic::set_hook(Box::new(|info| { + let payload = info + .payload() + .downcast_ref::<&str>() + .copied() + .or_else(|| info.payload().downcast_ref::().map(|s| s.as_str())) + .unwrap_or(""); + let location = info + .location() + .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())) + .unwrap_or_else(|| "".into()); + log::error!("PANIC at {location} — {payload}"); + // Forzar flush stdio del android_logger (mejor que nada). + })); +} + +#[cfg(target_os = "android")] +#[no_mangle] +fn android_main(app: android_activity::AndroidApp) { + android_logger::init_once( + android_logger::Config::default() + .with_max_level(log::LevelFilter::Debug) + .with_tag(TAG), + ); + install_panic_logger(); + + log::info!("android_main START"); + + use llimphi_hal::winit::event_loop::EventLoopBuilder; + use llimphi_hal::winit::platform::android::EventLoopBuilderExtAndroid; + + let event_loop: EventLoop<()> = match EventLoopBuilder::default().with_android_app(app).build() + { + Ok(el) => el, + Err(e) => { + log::error!("EventLoop::build failed: {e}"); + return; + } + }; + event_loop.set_control_flow(ControlFlow::Poll); + log::info!("event_loop construido, entrando a run_app"); + + let mut app_handler = App::new(); + if let Err(e) = event_loop.run_app(&mut app_handler) { + log::error!("run_app: {e}"); + } + log::info!("android_main END"); +} diff --git a/android/scripts/build-android.sh b/android/scripts/build-android.sh new file mode 100755 index 0000000..e99519a --- /dev/null +++ b/android/scripts/build-android.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# ============================================================================ +# build-android.sh — empaca un crate Llimphi-Android como APK firmado. +# +# Uso: +# ./build-android.sh [arch] [profile] +# +# crate-dir : path al Cargo.toml del crate Android (cdylib + android_main) +# arch : arm64 | x64 (default arm64) +# profile : release | debug (default release) +# +# Requisitos: +# - rustup target add aarch64-linux-android x86_64-linux-android +# - cargo install xbuild (binario `x`) +# - cargo install cargo-ndk (opcional, sólo si querés build sin APK) +# - NDK r27+ en $ANDROID_NDK_HOME +# - Android SDK en $ANDROID_HOME (cmdline-tools + build-tools) +# - PEM dev en $LLIMPHI_PEM (se crea automáticamente la primera vez) +# +# Resultado: +# target/x//android/.apk — APK firmado v2, instalable con +# `adb install -r `. +# ============================================================================ +set -euo pipefail + +CRATE_DIR="${1:?se requiere crate-dir como primer argumento}" +ARCH="${2:-arm64}" +PROFILE="${3:-release}" + +# --- toolchain ------------------------------------------------------------- +: "${ANDROID_NDK_HOME:=/home/sergio/android-ndk-r27c}" +: "${ANDROID_NDK_ROOT:=$ANDROID_NDK_HOME}" +: "${ANDROID_HOME:=/opt/android-sdk}" +: "${LLIMPHI_PEM:=$HOME/.local/share/llimphi-android/debug.pem}" +export ANDROID_NDK_HOME ANDROID_NDK_ROOT ANDROID_HOME + +X_BIN="${X_BIN:-$HOME/.cargo/bin/x}" +test -x "$X_BIN" || { echo "❌ xbuild (cargo install xbuild)"; exit 1; } +test -d "$ANDROID_NDK_HOME" || { echo "❌ NDK no encontrado en $ANDROID_NDK_HOME"; exit 1; } +test -d "$ANDROID_HOME" || { echo "❌ SDK no encontrado en $ANDROID_HOME"; exit 1; } + +# --- PEM de firma dev (RSA 2048 + cert auto-firmado) ----------------------- +if [ ! -f "$LLIMPHI_PEM" ]; then + echo "→ generando PEM de firma dev en $LLIMPHI_PEM" + mkdir -p "$(dirname "$LLIMPHI_PEM")" + openssl req -x509 -newkey rsa:2048 \ + -keyout "${LLIMPHI_PEM}.key" \ + -out "${LLIMPHI_PEM}.cert" \ + -days 36500 -nodes \ + -subj "/CN=llimphi-dev/O=gioser/C=AR" 2>/dev/null + cat "${LLIMPHI_PEM}.key" "${LLIMPHI_PEM}.cert" > "$LLIMPHI_PEM" +fi + +# --- flags ----------------------------------------------------------------- +PROFILE_FLAG="--release" +[ "$PROFILE" = "debug" ] && PROFILE_FLAG="--debug" + +# --- build ---------------------------------------------------------------- +cd "$CRATE_DIR" +CRATE_NAME=$(grep '^name *=' Cargo.toml | head -1 | sed -E 's/.*"([^"]+)".*/\1/') +echo "→ building $CRATE_NAME · $ARCH · $PROFILE" + +"$X_BIN" build \ + --platform android \ + --arch "$ARCH" \ + --format apk \ + $PROFILE_FLAG \ + --pem "$LLIMPHI_PEM" + +# --- locate + verify ------------------------------------------------------- +APK=$(find ../../../../target/x/$PROFILE/android -name "${CRATE_NAME}.apk" 2>/dev/null | head -1) +[ -z "$APK" ] && APK=$(find . -name "${CRATE_NAME}.apk" 2>/dev/null | head -1) +[ -z "$APK" ] && { echo "❌ APK no encontrado"; exit 1; } +APK=$(readlink -f "$APK") +SIZE=$(du -h "$APK" | cut -f1) + +APKSIGNER="$ANDROID_HOME/build-tools/37.0.0/apksigner" +if [ -x "$APKSIGNER" ]; then + if "$APKSIGNER" verify --min-sdk-version 24 "$APK" 2>/dev/null; then + echo "✓ firma verificada (APK Signature Scheme v2)" + else + echo "⚠ firma no verifica" + fi +fi + +echo "✓ $APK ($SIZE)" +echo +echo "Instalar en device:" +echo " adb install -r $APK" diff --git a/android/vello-hello-android/Cargo.toml b/android/vello-hello-android/Cargo.toml new file mode 100644 index 0000000..187fe94 --- /dev/null +++ b/android/vello-hello-android/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "vello-hello-android" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "Tier 1.5 Android: vello + llimphi-raster pintando una chacana animada como smoke test del stack completo." + +[lib] +crate-type = ["cdylib"] + +[dependencies] +llimphi-hal = { path = "../../llimphi-hal" } +llimphi-raster = { path = "../../llimphi-raster" } +winit = { workspace = true, features = ["android-native-activity"] } +wgpu.workspace = true +vello.workspace = true +pollster.workspace = true +# `log` se declara aquí (no en el bloque condicional Android) para que +# `cargo check --workspace` en host pase: los macros de `log` son no-op +# sin logger instalado. En Android, `android_logger` (más abajo) instala +# el sink real hacia `logcat`. +log = "0.4" + +[target.'cfg(target_os = "android")'.dependencies] +android-activity = { version = "0.6", features = ["native-activity"] } +android_logger = "0.14" + +[package.metadata.android] +package = "net.gioser.llimphi.vellohello" +build_targets = ["aarch64-linux-android", "x86_64-linux-android"] +min_sdk_version = 24 +target_sdk_version = 34 + +[package.metadata.android.application] +label = "Llimphi · vello-hello" +debuggable = true + +[package.metadata.android.application.activity] +config_changes = "orientation|screenSize|keyboardHidden" +launch_mode = "singleTop" +orientation = "unspecified" diff --git a/android/vello-hello-android/LEEME.md b/android/vello-hello-android/LEEME.md new file mode 100644 index 0000000..8b8be30 --- /dev/null +++ b/android/vello-hello-android/LEEME.md @@ -0,0 +1,11 @@ +# vello-hello-android + +> Vello hello-world Android de [llimphi](../../README.md). + +App que dibuja un par de shapes con `vello` sobre el HAL Android. Siguiente paso después de [`clear-screen-android`](../clear-screen-android/README.md): valida que vello/wgpu corren en el dispositivo. + +## Build + +```sh +cargo apk build -p vello-hello-android +``` diff --git a/android/vello-hello-android/README.md b/android/vello-hello-android/README.md new file mode 100644 index 0000000..d3e79a6 --- /dev/null +++ b/android/vello-hello-android/README.md @@ -0,0 +1,11 @@ +# vello-hello-android + +> Vello hello-world Android of [llimphi](../../README.md). + +App that draws a couple of shapes with `vello` over the Android HAL. Next step after [`clear-screen-android`](../clear-screen-android/README.md): validates that vello/wgpu run on the device. + +## Build + +```sh +cargo apk build -p vello-hello-android +``` diff --git a/android/vello-hello-android/src/lib.rs b/android/vello-hello-android/src/lib.rs new file mode 100644 index 0000000..e8fc845 --- /dev/null +++ b/android/vello-hello-android/src/lib.rs @@ -0,0 +1,376 @@ +//! Tier 1.5 Android: chacana animada con vello + llimphi-raster. +//! +//! Smoke test del stack raster completo en device móvil: +//! wgpu (Vulkan/Adreno) → llimphi-hal (intermediate Rgba8) → +//! vello::Scene (kurbo paths + peniko brushes) → +//! llimphi_raster::Renderer (compute pipeline AA) → +//! blit a swapchain. +//! +//! El bootstrap es el mismo orden estricto que `clear-screen-android`: +//! create_surface antes que request_adapter (compatible_surface=Some), +//! WinitSurface::from_surface (no `new`), panic hook al logcat. +//! +//! Si esta app pinta y mantiene fps en device, todas las apps Llimphi +//! basadas en vello están listas para portar mecánicamente — solo hay +//! que envolver su `build_scene` con este shell. + +use std::sync::Arc; +use std::time::Instant; + +use llimphi_hal::winit::application::ApplicationHandler; +use llimphi_hal::winit::event::WindowEvent; +use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId}; +use llimphi_hal::{wgpu, Hal, Surface, WinitSurface}; +use llimphi_raster::kurbo::{Affine, BezPath, Circle, Stroke}; +use llimphi_raster::peniko::{Color, Fill}; +use llimphi_raster::{vello, Renderer}; + +const TAG: &str = "llimphi-vello"; + +// Paleta gioser (mismos hex que la web/Llimphi-theme). +const COSMOS_NIGHT: Color = Color::from_rgba8(0x0E, 0x10, 0x16, 255); +const ACCENT_CYAN: Color = Color::from_rgba8(0xA6, 0xD8, 0xFF, 255); +const ACCENT_AMBER: Color = Color::from_rgba8(0xE8, 0xC9, 0x7A, 255); +const ACCENT_BLUE: Color = Color::from_rgba8(0x6E, 0x8C, 0xDC, 255); +const ACCENT_VIOLET: Color = Color::from_rgba8(0xC3, 0x9C, 0xE8, 255); + +struct State { + window: Arc, + hal: Hal, + surface: WinitSurface, + renderer: Renderer, + scene: vello::Scene, +} + +struct App { + state: Option, + started: Instant, + frames: u64, + last_report: Instant, +} + +impl App { + fn new() -> Self { + let now = Instant::now(); + Self { + state: None, + started: now, + frames: 0, + last_report: now, + } + } + + fn boot(&self, event_loop: &ActiveEventLoop) -> Result { + log::info!("[boot] 1/8 Window"); + let window = event_loop + .create_window(WindowAttributes::default().with_title("llimphi · vello-hello")) + .map_err(|e| format!("create_window: {e}"))?; + let window = Arc::new(window); + + log::info!("[boot] 2/8 wgpu::Instance"); + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends: wgpu::Backends::all(), + ..Default::default() + }); + + log::info!("[boot] 3/8 Surface (única create_surface en este boot)"); + let surface = instance + .create_surface(window.clone()) + .map_err(|e| format!("create_surface: {e}"))?; + + log::info!("[boot] 4/8 Adapter compatible con surface"); + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, + compatible_surface: Some(&surface), + })) + .ok_or_else(|| "request_adapter → None".to_string())?; + let info = adapter.get_info(); + log::info!( + "[boot] adapter ok · {:?} · {} · {:?}", + info.backend, + info.name, + info.driver_info + ); + + log::info!("[boot] 5/8 Device + Queue"); + let limits = wgpu::Limits::default().using_resolution(adapter.limits()); + let (device, queue) = pollster::block_on(adapter.request_device( + &wgpu::DeviceDescriptor { + label: Some("vello-hello-device"), + required_features: wgpu::Features::empty(), + required_limits: limits, + memory_hints: wgpu::MemoryHints::Performance, + }, + None, + )) + .map_err(|e| format!("request_device: {e}"))?; + + log::info!("[boot] 6/8 Hal"); + let hal = Hal { + instance, + adapter, + device, + queue, + }; + + log::info!("[boot] 7/8 WinitSurface::from_surface"); + let surface = WinitSurface::from_surface(&hal, window.clone(), surface) + .map_err(|e| format!("WinitSurface: {e}"))?; + + log::info!("[boot] 8/8 vello Renderer"); + let renderer = + Renderer::new(&hal).map_err(|e| format!("Renderer::new: {e}"))?; + + log::info!("[boot] ✓ stack raster listo, primer redraw"); + window.request_redraw(); + + Ok(State { + window, + hal, + surface, + renderer, + scene: vello::Scene::new(), + }) + } +} + +impl ApplicationHandler for App { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + log::info!("Resumed"); + match self.boot(event_loop) { + Ok(s) => self.state = Some(s), + Err(e) => log::error!("BOOT FAILED: {e}"), + } + } + + fn suspended(&mut self, _event_loop: &ActiveEventLoop) { + log::info!("Suspended — liberando state"); + self.state = None; + } + + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + _id: WindowId, + event: WindowEvent, + ) { + let Some(state) = self.state.as_mut() else { + return; + }; + match event { + WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::Resized(size) => { + log::info!("Resized → {}x{}", size.width, size.height); + state.surface.resize(size.width, size.height); + state.window.request_redraw(); + } + WindowEvent::RedrawRequested => { + let frame = match state.surface.acquire() { + Ok(f) => f, + Err(e) => { + log::warn!("acquire {e}, reconfig"); + let (w, h) = state.surface.size(); + state.surface.resize(w, h); + state.window.request_redraw(); + return; + } + }; + let (w, h) = frame.size(); + let t = self.started.elapsed().as_secs_f64(); + state.scene.reset(); + build_chacana(&mut state.scene, w as f64, h as f64, t); + if let Err(e) = state.renderer.render( + &state.hal, + &state.scene, + &frame, + COSMOS_NIGHT, + ) { + log::error!("render: {e}"); + } + state.surface.present(frame, &state.hal); + + self.frames += 1; + let elapsed = self.last_report.elapsed(); + if elapsed.as_secs() >= 1 { + let fps = self.frames as f64 / elapsed.as_secs_f64(); + log::info!("{fps:.1} fps · {w}x{h}"); + self.frames = 0; + self.last_report = Instant::now(); + } + state.window.request_redraw(); + } + _ => {} + } + } +} + +/// Construye la chacana (cruz andina escalonada) animada, centrada en el +/// viewport. El sol central late con sin(t); cuatro rayos cardinales +/// rotan en una vuelta cada 12 s; halo cyan constante. +fn build_chacana(scene: &mut vello::Scene, w: f64, h: f64, t: f64) { + let cx = w * 0.5; + let cy = h * 0.5; + let unit = (w.min(h)) * 0.06; // tamaño de la escala de la cruz + + // Halo radial (anillo cyan suave) + scene.stroke( + &Stroke::new(2.0), + Affine::IDENTITY, + Color::from_rgba8(0xA6, 0xD8, 0xFF, 80), + None, + &Circle::new((cx, cy), unit * 4.6), + ); + scene.stroke( + &Stroke::new(1.0), + Affine::IDENTITY, + Color::from_rgba8(0xA6, 0xD8, 0xFF, 140), + None, + &Circle::new((cx, cy), unit * 4.0), + ); + + // Rayos cardinales rotantes (4 trazos a 90°) + let theta = t * (std::f64::consts::TAU / 12.0); // 1 vuelta cada 12 s + let rotate = Affine::translate((cx, cy)) * Affine::rotate(theta); + for i in 0..4 { + let angle = i as f64 * std::f64::consts::FRAC_PI_2; + let dir = (angle.cos(), angle.sin()); + let mut p = BezPath::new(); + p.move_to((dir.0 * unit * 3.2, dir.1 * unit * 3.2)); + p.line_to((dir.0 * unit * 4.4, dir.1 * unit * 4.4)); + scene.stroke( + &Stroke::new(1.5), + rotate, + ACCENT_BLUE, + None, + &p, + ); + } + + // Chacana: cruz escalonada de 12 puntas. Construida como BezPath. + // La forma clásica: cuadrado central + escalones en 4 direcciones. + let chacana = chacana_path(unit); + let center = Affine::translate((cx, cy)); + + // Glow ambar exterior + scene.stroke( + &Stroke::new(6.0), + center, + Color::from_rgba8(0xE8, 0xC9, 0x7A, 110), + None, + &chacana, + ); + // Outline cyan + scene.stroke( + &Stroke::new(2.0), + center, + ACCENT_CYAN, + None, + &chacana, + ); + // Relleno violeta tenue + scene.fill( + Fill::NonZero, + center, + Color::from_rgba8(0xC3, 0x9C, 0xE8, 40), + None, + &chacana, + ); + + // Sol central que late + let pulse = 1.0 + 0.18 * (t * 1.8).sin(); + let r_sun = unit * 0.7 * pulse; + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + ACCENT_AMBER, + None, + &Circle::new((cx, cy), r_sun), + ); + // Corona + scene.stroke( + &Stroke::new(1.0), + Affine::IDENTITY, + Color::from_rgba8(0xE8, 0xC9, 0x7A, 120), + None, + &Circle::new((cx, cy), r_sun * 1.7), + ); + // Punto interior violeta para contraste + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + ACCENT_VIOLET, + None, + &Circle::new((cx, cy), r_sun * 0.35), + ); +} + +/// Path de la chacana centrada en el origen, con `u` como ancho de cada +/// escalón. Reconstruye la forma clásica de 12 esquinas escalonadas +/// (3 escalones por cada brazo cardinal). +fn chacana_path(u: f64) -> BezPath { + let mut p = BezPath::new(); + // Empezamos en la esquina superior-derecha del brazo norte y vamos + // en sentido horario alrededor de toda la cruz. + p.move_to((u, 3.0 * u)); + p.line_to((u, u)); + p.line_to((3.0 * u, u)); + p.line_to((3.0 * u, -u)); + p.line_to((u, -u)); + p.line_to((u, -3.0 * u)); + p.line_to((-u, -3.0 * u)); + p.line_to((-u, -u)); + p.line_to((-3.0 * u, -u)); + p.line_to((-3.0 * u, u)); + p.line_to((-u, u)); + p.line_to((-u, 3.0 * u)); + p.close_path(); + p +} + +#[cfg(target_os = "android")] +fn install_panic_logger() { + std::panic::set_hook(Box::new(|info| { + let payload = info + .payload() + .downcast_ref::<&str>() + .copied() + .or_else(|| info.payload().downcast_ref::().map(|s| s.as_str())) + .unwrap_or(""); + let loc = info + .location() + .map(|l| format!("{}:{}", l.file(), l.line())) + .unwrap_or_else(|| "".into()); + log::error!("PANIC at {loc} — {payload}"); + })); +} + +#[cfg(target_os = "android")] +#[no_mangle] +fn android_main(app: android_activity::AndroidApp) { + android_logger::init_once( + android_logger::Config::default() + .with_max_level(log::LevelFilter::Info) + .with_tag(TAG), + ); + install_panic_logger(); + log::info!("android_main START"); + + use llimphi_hal::winit::event_loop::EventLoopBuilder; + use llimphi_hal::winit::platform::android::EventLoopBuilderExtAndroid; + + let event_loop: EventLoop<()> = match EventLoopBuilder::default().with_android_app(app).build() + { + Ok(el) => el, + Err(e) => { + log::error!("EventLoop: {e}"); + return; + } + }; + event_loop.set_control_flow(ControlFlow::Poll); + let mut handler = App::new(); + if let Err(e) = event_loop.run_app(&mut handler) { + log::error!("run_app: {e}"); + } +} diff --git a/android/vello-text-android/Cargo.toml b/android/vello-text-android/Cargo.toml new file mode 100644 index 0000000..dd8cdf5 --- /dev/null +++ b/android/vello-text-android/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "vello-text-android" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "Tier 1.75 Android: parley + vello + llimphi-text rasterizando texto multi-script con fallback CJK/Arabic via fontique." + +[lib] +crate-type = ["cdylib"] + +[dependencies] +llimphi-hal = { path = "../../llimphi-hal" } +llimphi-raster = { path = "../../llimphi-raster" } +llimphi-text = { path = "../../llimphi-text" } +winit = { workspace = true, features = ["android-native-activity"] } +wgpu.workspace = true +vello.workspace = true +pollster.workspace = true +# `log` se declara aquí (no en el bloque condicional Android) para que +# `cargo check --workspace` en host pase: los macros de `log` son no-op +# sin logger instalado. En Android, `android_logger` (más abajo) instala +# el sink real hacia `logcat`. +log = "0.4" + +[target.'cfg(target_os = "android")'.dependencies] +android-activity = { version = "0.6", features = ["native-activity"] } +android_logger = "0.14" + +[package.metadata.android] +package = "net.gioser.llimphi.vellotext" +build_targets = ["aarch64-linux-android", "x86_64-linux-android"] +min_sdk_version = 24 +target_sdk_version = 34 + +[package.metadata.android.application] +label = "Llimphi · vello-text" +debuggable = true + +[package.metadata.android.application.activity] +config_changes = "orientation|screenSize|keyboardHidden" +launch_mode = "singleTop" +orientation = "unspecified" diff --git a/android/vello-text-android/LEEME.md b/android/vello-text-android/LEEME.md new file mode 100644 index 0000000..6a0c760 --- /dev/null +++ b/android/vello-text-android/LEEME.md @@ -0,0 +1,11 @@ +# vello-text-android + +> Text shaping Android de [llimphi](../../README.md). + +Dibuja texto con `vello` + `fontdue` sobre Android. Tercer hito: confirma que [`llimphi-text`](../../llimphi-text/README.md) shapea correctamente con DPI de móvil. + +## Build + +```sh +cargo apk build -p vello-text-android +``` diff --git a/android/vello-text-android/README.md b/android/vello-text-android/README.md new file mode 100644 index 0000000..7d1f846 --- /dev/null +++ b/android/vello-text-android/README.md @@ -0,0 +1,11 @@ +# vello-text-android + +> Android text shaping of [llimphi](../../README.md). + +Draws text with `vello` + `fontdue` on Android. Third milestone: confirms [`llimphi-text`](../../llimphi-text/README.md) shapes correctly with mobile DPI. + +## Build + +```sh +cargo apk build -p vello-text-android +``` diff --git a/android/vello-text-android/src/lib.rs b/android/vello-text-android/src/lib.rs new file mode 100644 index 0000000..234f7ad --- /dev/null +++ b/android/vello-text-android/src/lib.rs @@ -0,0 +1,406 @@ +//! Tier 1.75 Android: texto multi-script con parley + vello + llimphi-text. +//! +//! Verifica que en Android funciona: +//! - parley::FontContext::new() resolviendo fuentes via fontique sobre +//! /system/fonts (Roboto + Noto fallback CJK/Arabic vienen en todas +//! las builds AOSP). +//! - shaping con kerning, ligaduras, bidi, fallback inter-script en +//! una misma línea. +//! - rasterización de glifos por vello::Scene::draw_glyphs (compute +//! pipeline sobre la intermediate Rgba8). +//! +//! Si esta corre estable y se ven los tres scripts (latino, arábigo, +//! CJK) sin tofu (cuadrados vacíos), llimphi-ui está habilitado en +//! Android — el resto de las apps (text-viewer, file-explorer, +//! pluma-md-reader) usan exactamente esta misma pipa. +//! +//! El factor de scale por DPI se calcula desde el `inner_size` real +//! del Window que Android nos pasa (ya incluye la densidad del +//! display). En desktop el window es 960x540 lógico; en mobile típico +//! es ~1080x2400 físico → fuentes 2-3× más grandes para legibilidad. + +use std::sync::Arc; +use std::time::Instant; + +use llimphi_hal::winit::application::ApplicationHandler; +use llimphi_hal::winit::event::WindowEvent; +use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId}; +use llimphi_hal::{wgpu, Hal, Surface, WinitSurface}; +use llimphi_raster::peniko::Color; +use llimphi_raster::vello; +use llimphi_text::{draw_block, Alignment, TextBlock, Typesetter}; + +const TAG: &str = "llimphi-text"; + +const COSMOS_NIGHT: Color = Color::from_rgba8(0x0E, 0x10, 0x16, 255); +const FG_TEXT: Color = Color::from_rgba8(0xD6, 0xDE, 0xE8, 255); +const FG_MUTED: Color = Color::from_rgba8(0x8C, 0x98, 0xAA, 255); +const ACCENT: Color = Color::from_rgba8(0x6E, 0x8C, 0xDC, 255); +const AMBER: Color = Color::from_rgba8(0xE8, 0xC9, 0x7A, 255); + +const PARRAFO: &str = "Llimphi pinta vector preciso sobre el silicio: \ +geometrías exactas, sin cajas negras. شكراً 你好 こんにちは — el shaping \ +de parley maneja kerning, ligaduras y fallback CJK/Árabe en la misma \ +línea, resuelto por fontique sobre las fuentes Noto de Android."; + +const TECNICO: &str = "stack: wgpu(Vulkan) → llimphi-hal → vello compute → \ +parley shaping → fontique fallback. APK firmado v2, ~7 MB stripped."; + +struct State { + window: Arc, + hal: Hal, + surface: WinitSurface, + renderer: llimphi_raster::Renderer, + scene: vello::Scene, + typesetter: Typesetter, +} + +struct App { + state: Option, + frames: u64, + last_report: Instant, + /// `None` antes del primer present; al loguearse pasa a `Some` para + /// no spamear. Mide el tiempo "tiempo en pantalla" real del usuario. + first_paint: Option, + started: Instant, +} + +impl App { + fn new() -> Self { + Self { + state: None, + frames: 0, + last_report: Instant::now(), + first_paint: None, + started: Instant::now(), + } + } + + fn boot(&self, event_loop: &ActiveEventLoop) -> Result { + // Timings paso a paso — Android tarda 3-5s en el cold-start, + // queremos saber si es vello shader compile, fontique scan, + // request_device, o el primer render. `step` toma el delta + // desde la marca anterior y lo loguea. + let t0 = Instant::now(); + let mut tprev = t0; + let mut step = |name: &str| { + let now = Instant::now(); + let dt = now.duration_since(tprev); + let total = now.duration_since(t0); + log::info!( + "[boot+{:>5}ms] {} (+{}ms)", + total.as_millis(), + name, + dt.as_millis() + ); + tprev = now; + }; + + step("0/9 START"); + let window = event_loop + .create_window(WindowAttributes::default().with_title("llimphi · vello-text")) + .map_err(|e| format!("create_window: {e}"))?; + let window = Arc::new(window); + let size = window.inner_size(); + step(&format!("1/9 Window {}x{}", size.width, size.height)); + + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends: wgpu::Backends::all(), + ..Default::default() + }); + step("2/9 wgpu::Instance"); + + let surface = instance + .create_surface(window.clone()) + .map_err(|e| format!("create_surface: {e}"))?; + step("3/9 Surface"); + + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, + compatible_surface: Some(&surface), + })) + .ok_or_else(|| "request_adapter → None".to_string())?; + let info = adapter.get_info(); + step(&format!("4/9 Adapter {:?} {}", info.backend, info.name)); + + let limits = wgpu::Limits::default().using_resolution(adapter.limits()); + let (device, queue) = pollster::block_on(adapter.request_device( + &wgpu::DeviceDescriptor { + label: Some("vello-text-device"), + required_features: wgpu::Features::empty(), + required_limits: limits, + memory_hints: wgpu::MemoryHints::Performance, + }, + None, + )) + .map_err(|e| format!("request_device: {e}"))?; + step("5/9 Device + Queue"); + + let hal = Hal { + instance, + adapter, + device, + queue, + }; + step("6/9 Hal armado"); + + let surface = WinitSurface::from_surface(&hal, window.clone(), surface) + .map_err(|e| format!("WinitSurface: {e}"))?; + step("7/9 WinitSurface::from_surface"); + + // Sospechoso #1: vello compila ~20 shaders WGSL + crea pipelines + // de compute. En desktop ~150ms; en Adreno entry-level estimamos + // 1-3s. Si es esto, la solución es pipeline_cache persistente. + let renderer = + llimphi_raster::Renderer::new(&hal).map_err(|e| format!("Renderer: {e}"))?; + step("8/9 vello Renderer (shaders + pipelines)"); + + // Sospechoso #2: fontique escanea /system/fonts y parsea cada + // TTF/OTF para indexar metadata (family, style, scripts). + // Android tiene ~50-80 fuentes Noto + Roboto. + let typesetter = Typesetter::new(); + step("9/9 Typesetter (fontique scan /system/fonts)"); + + log::info!( + "[boot ✓ total {}ms] stack texto listo", + t0.elapsed().as_millis() + ); + + window.request_redraw(); + Ok(State { + window, + hal, + surface, + renderer, + scene: vello::Scene::new(), + typesetter, + }) + } +} + +impl ApplicationHandler for App { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + log::info!("Resumed"); + match self.boot(event_loop) { + Ok(s) => self.state = Some(s), + Err(e) => log::error!("BOOT FAILED: {e}"), + } + } + + fn suspended(&mut self, _event_loop: &ActiveEventLoop) { + log::info!("Suspended"); + self.state = None; + } + + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + _id: WindowId, + event: WindowEvent, + ) { + let Some(state) = self.state.as_mut() else { + return; + }; + match event { + WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::Resized(size) => { + state.surface.resize(size.width, size.height); + state.window.request_redraw(); + } + WindowEvent::RedrawRequested => { + let frame = match state.surface.acquire() { + Ok(f) => f, + Err(e) => { + log::warn!("acquire {e}"); + let (w, h) = state.surface.size(); + state.surface.resize(w, h); + state.window.request_redraw(); + return; + } + }; + let (w, h) = frame.size(); + state.scene.reset(); + paint_page(&mut state.scene, &mut state.typesetter, w, h); + if let Err(e) = state.renderer.render( + &state.hal, + &state.scene, + &frame, + COSMOS_NIGHT, + ) { + log::error!("render: {e}"); + } + state.surface.present(frame, &state.hal); + + if self.first_paint.is_none() { + let elapsed = self.started.elapsed(); + log::info!( + "[FIRST PAINT] {}ms desde android_main START", + elapsed.as_millis() + ); + self.first_paint = Some(Instant::now()); + } + + self.frames += 1; + if self.last_report.elapsed().as_secs() >= 2 { + let fps = self.frames as f64 / self.last_report.elapsed().as_secs_f64(); + log::info!("{fps:.1} fps · {w}x{h}"); + self.frames = 0; + self.last_report = Instant::now(); + } + // No request_redraw: el texto es estático, evita drenar batería. + } + _ => {} + } + } +} + +/// Pinta la página completa de texto. Escala las fuentes proporcionales al +/// ancho del viewport: en mobile (1080+ px) el texto queda ~1.4× más +/// grande que en desktop (960 px) — lectura cómoda con device a 30 cm. +fn paint_page(scene: &mut vello::Scene, ts: &mut Typesetter, w: u32, h: u32) { + // Escala lineal sobre el ancho del viewport. base = 1080 px → factor 1.0. + let scale = (w as f32 / 1080.0).clamp(0.6, 2.4); + let margin_x = (w as f64 * 0.06).max(24.0); + let margin_y = (h as f64 * 0.08).max(32.0); + let inner_w = (w as f32 - 2.0 * margin_x as f32).max(160.0); + + // Título grande + draw_block( + scene, + ts, + &TextBlock { + text: "Llimphi", + size_px: 96.0 * scale, + color: FG_TEXT, + origin: (margin_x, margin_y), + max_width: Some(inner_w), + alignment: Alignment::Center, + line_height: 1.0, + + italic: false, + font_family: None, + }, + ); + + // Subtítulo en accent + draw_block( + scene, + ts, + &TextBlock { + text: "texto multi-script sobre Android", + size_px: 22.0 * scale, + color: ACCENT, + origin: (margin_x, margin_y + (110.0 * scale as f64)), + max_width: Some(inner_w), + alignment: Alignment::Center, + line_height: 1.0, + + italic: false, + font_family: None, + }, + ); + + // Línea separadora dorada (un guion largo en amber) + draw_block( + scene, + ts, + &TextBlock { + text: "—", + size_px: 32.0 * scale, + color: AMBER, + origin: (margin_x, margin_y + (155.0 * scale as f64)), + max_width: Some(inner_w), + alignment: Alignment::Center, + line_height: 1.0, + + italic: false, + font_family: None, + }, + ); + + // Párrafo justificado con scripts mixtos + draw_block( + scene, + ts, + &TextBlock { + text: PARRAFO, + size_px: 22.0 * scale, + color: FG_TEXT, + origin: (margin_x, margin_y + (220.0 * scale as f64)), + max_width: Some(inner_w), + alignment: Alignment::Justify, + line_height: 1.5, + + italic: false, + font_family: None, + }, + ); + + // Pie técnico mute + draw_block( + scene, + ts, + &TextBlock { + text: TECNICO, + size_px: 16.0 * scale, + color: FG_MUTED, + origin: (margin_x, h as f64 - margin_y - (50.0 * scale as f64)), + max_width: Some(inner_w), + alignment: Alignment::Start, + line_height: 1.3, + + italic: false, + font_family: None, + }, + ); +} + +#[cfg(target_os = "android")] +fn install_panic_logger() { + std::panic::set_hook(Box::new(|info| { + let payload = info + .payload() + .downcast_ref::<&str>() + .copied() + .or_else(|| info.payload().downcast_ref::().map(|s| s.as_str())) + .unwrap_or(""); + let loc = info + .location() + .map(|l| format!("{}:{}", l.file(), l.line())) + .unwrap_or_else(|| "".into()); + log::error!("PANIC at {loc} — {payload}"); + })); +} + +#[cfg(target_os = "android")] +#[no_mangle] +fn android_main(app: android_activity::AndroidApp) { + android_logger::init_once( + android_logger::Config::default() + .with_max_level(log::LevelFilter::Info) + .with_tag(TAG), + ); + install_panic_logger(); + log::info!("android_main START"); + + use llimphi_hal::winit::event_loop::EventLoopBuilder; + use llimphi_hal::winit::platform::android::EventLoopBuilderExtAndroid; + + let event_loop: EventLoop<()> = match EventLoopBuilder::default().with_android_app(app).build() + { + Ok(el) => el, + Err(e) => { + log::error!("EventLoop: {e}"); + return; + } + }; + // Wait (no Poll): el texto es estático, el redraw lo dispara + // Resized/Resumed. Ahorra batería vs vello-hello que anima. + event_loop.set_control_flow(ControlFlow::Wait); + let mut handler = App::new(); + if let Err(e) = event_loop.run_app(&mut handler) { + log::error!("run_app: {e}"); + } +} diff --git a/llimphi-compositor/Cargo.toml b/llimphi-compositor/Cargo.toml new file mode 100644 index 0000000..49bcc92 --- /dev/null +++ b/llimphi-compositor/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "llimphi-compositor" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-compositor — el núcleo declarativo de Llimphi sin winit: el árbol `View`, el mount sobre taffy, el paint a `vello::Scene` y el hit-test. No depende de llimphi-hal ni de una surface concreta, así que la misma composición sirve sobre winit (llimphi-ui) o, a futuro, sobre el framebuffer del kernel wawa. `wgpu` entra sólo por la firma de `GpuPaintFn` (tipos, no windowing)." + +[dependencies] +llimphi-layout = { path = "../llimphi-layout" } +llimphi-text = { path = "../llimphi-text" } +vello = { workspace = true } +# Sólo para los tipos de la firma de GpuPaintFn (Device/Queue/Encoder/View). +# wgpu NO depende de winit — el compositor sigue libre de windowing. +wgpu = { workspace = true } diff --git a/llimphi-compositor/src/lib.rs b/llimphi-compositor/src/lib.rs new file mode 100644 index 0000000..7172d2c --- /dev/null +++ b/llimphi-compositor/src/lib.rs @@ -0,0 +1,348 @@ +//! llimphi-compositor — el núcleo declarativo de Llimphi, sin winit. +//! +//! Aquí vive el árbol de vista `View` (DSL declarativo), su instalación +//! sobre taffy (`mount`), el pintado a `vello::Scene` (`paint`/`paint_gpu`) y +//! el hit-test. Nada de esto necesita una ventana ni `llimphi-hal`: la +//! composición `view → layout → scene` es pura y reutilizable. +//! +//! El runtime que la maneja vive aparte: +//! - `llimphi-ui` la corre sobre winit (`run()`). +//! - a futuro, un runtime sobre el framebuffer del kernel `wawa` puede +//! reusar exactamente este compositor sin arrastrar winit. +//! +//! `wgpu` entra sólo por la firma de [`GpuPaintFn`] (tipos de Device/Queue/ +//! Encoder/TextureView); `wgpu` no depende de winit, así que el compositor +//! sigue libre de windowing. + +use std::collections::HashMap; +use std::sync::Arc; + +use llimphi_layout::taffy::NodeId; +use llimphi_layout::{ComputedLayout, LayoutTree, Style}; +use vello::kurbo::{Affine, Point, Rect as KurboRect, RoundedRect}; +use vello::peniko::{Color, Fill, Image, Mix}; + +mod render; +mod view; +pub use render::*; + +/// Texto a pintar dentro de un nodo. Alineación por defecto `Center` +/// (horizontal y vertical), apta para labels de botón. Para layouts tipo +/// editor o párrafo, usar `.text_aligned(...)` con `Alignment::Start`. +pub struct TextSpec { + pub content: String, + pub size_px: f32, + pub color: Color, + pub alignment: llimphi_text::Alignment, + /// `true` = forzar variante italic en la fuente activa. Default false. + pub italic: bool, + /// CSS-style font-family string (acepta lista con fallbacks). `None` + /// = la fuente default de parley. + pub font_family: Option, + /// Múltiplo de interlínea (`line-height` / `font-size`). 1.2 es el + /// default que usaban todos los callers; puriy lo sobreescribe con el + /// valor computado de CSS. Se usa tanto al **medir** (para que taffy + /// reserve el alto correcto) como al **pintar**, así medida y dibujo + /// coinciden. + pub line_height: f32, + /// Colores por rango de **bytes** sobre `content`, para texto multicolor + /// (syntax highlighting) en una sola pasada de shaping. `None` = color + /// uniforme (`color`). Cuando es `Some`, el runtime usa + /// `Typesetter::layout_runs` + `draw_layout_runs`, y `color` actúa como + /// color por defecto de lo no cubierto por ningún run. + pub runs: Option>, +} + +/// Fase de un drag activo. `Move` se emite por cada `CursorMoved` con el +/// delta desde el evento anterior; `End` se emite al soltar el botón. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DragPhase { + Move, + End, +} + +/// Handler de drag. Recibe la fase + delta (`dx`, `dy`) **desde el evento +/// anterior** (no acumulado desde el press). Devolver `None` deja el drag +/// activo sin disparar Msg. `Arc` para que el runtime pueda +/// clonarlo barato al iniciar el drag y mantenerlo vivo aunque el cache +/// de la vista se regenere mientras tanto. +pub type DragFn = Arc Option + Send + Sync>; + +/// Handler de drop. El runtime lo invoca cuando un drag activo se suelta +/// sobre este nodo. Recibe el `payload` `u64` que el origen del drag +/// declaró vía [`View::drag_payload`]. Devolver `None` ignora el drop. +/// +/// Los IDs `u64` son opacos para el runtime: el widget elige una +/// convención (índice de tile, hash del item, etc.) y el handler decide +/// qué Msg emitir en función de ese ID. +pub type DropFn = Arc Option + Send + Sync>; + +/// Handler de click con posición. Recibe `(x_local, y_local, rect_w, +/// rect_h)`: las dos primeras son la posición del cursor **relativa a +/// la esquina superior-izquierda del nodo** y las dos últimas son el +/// ancho/alto actual del nodo en pixels — útil cuando el caller +/// necesita centrar o normalizar. Devolver `None` no dispara update. +pub type ClickAtFn = Arc Option + Send + Sync>; + +/// Handler de rueda **local a un nodo**. Recibe el delta `(dx, dy)` en +/// líneas lógicas (misma normalización que `App::on_wheel`: `dy` positivo +/// = scroll hacia abajo). El runtime lo invoca cuando la rueda gira con el +/// cursor sobre este nodo, ANTES de caer al `App::on_wheel` global: si el +/// handler devuelve `Some(Msg)`, el evento se consume acá. Permite áreas +/// de scroll autocontenidas (el widget `scroll` lo usa) sin que cada app +/// rutee la rueda a mano por su `Model`. Devolver `None` deja pasar el +/// evento al `on_wheel` global. +pub type ScrollFn = Arc Option + Send + Sync>; + +/// Variante de [`DragFn`] que **conoce la posición inicial del press** +/// relativa al rect del nodo. Útil cuando el caller necesita identificar +/// qué entidad (Concepto, lemming, etc.) bajo el cursor agarró el drag. +/// Recibe `(phase, dx, dy, initial_lx, initial_ly)`. +pub type DragAtFn = Arc Option + Send + Sync>; + +/// Rect absoluto del nodo (en coordenadas físicas del frame). Lo +/// recibe el callback de [`View::paint_with`] para que pueda +/// posicionar sus primitivas custom dentro del nodo. +#[derive(Debug, Clone, Copy, Default)] +pub struct PaintRect { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + +/// Callback de pintura custom. El runtime lo invoca durante el paint +/// del nodo (entre el `fill`/`image` y el `text`) con el `Scene` vivo +/// + el `Typesetter` cacheado del runtime + el rect absoluto del nodo. +/// Pensado para "canvas elements" tipo `dominium-canvas`, +/// `pluma-editor` (osciloscopio de coherencia), `cosmos` (charts). +/// +/// El `Typesetter` se pasa porque crearlo por frame es caro +/// (`FontContext::new` enumera las fontes del sistema vía fontique). +/// Los callers que no necesiten texto pueden ignorar el argumento. +/// +/// El callback no debe llamar a `scene.push_layer` sin un `pop_layer` +/// correspondiente, ni reset el scene — sólo agregar primitivas que +/// pertenezcan al rect del nodo. +pub type PaintFn = Arc< + dyn Fn(&mut vello::Scene, &mut llimphi_text::Typesetter, PaintRect) + Send + Sync, +>; + +/// Callback de pintura GPU directo, sin vello intermedio. Recibe el +/// `device`/`queue` ya construidos por el runtime más un +/// `CommandEncoder` y la `TextureView` del frame (la intermediate +/// `Rgba8Unorm` de `WinitSurface`), todo durante el paint del nodo. +/// +/// El caller abre su propio `begin_render_pass` con `LoadOp::Load` para +/// no sobrescribir lo que ya pintó vello, dibuja sus primitivas y +/// cierra el pass. El runtime se encarga de dispatchear (`queue.submit`) +/// el encoder ya con todas las pasadas de todos los nodos acumuladas — +/// es un solo submit por frame. +/// +/// **Orden de pintura en Fase 1**: todos los `gpu_painter` corren +/// DESPUÉS de la pasada completa de vello (fill, image, painter, +/// text) sobre el `mounted` tree. Entre sí mantienen el orden DFS +/// pre-orden. Si una app necesita pintar texto **encima** del render +/// GPU directo, la forma idiomática es ponerlo en `App::view_overlay`, +/// que se renderiza como una segunda Scene de vello encima de todo. +/// +/// Pensado para apps con volumen masivo de primitivos (cosmos +/// starfield Gaia, tinkuy particle viewer, nakui viewport, pineal +/// denso) — el hook que paga el costo de mantener pipelines WGSL +/// propias en `llimphi-raster` (ver `02_ruway/llimphi/SDD.md` +/// §"Roadmap — GPU directo wgpu"). +pub type GpuPaintFn = Arc< + dyn Fn( + &wgpu::Device, + &wgpu::Queue, + &mut wgpu::CommandEncoder, + &wgpu::TextureView, + PaintRect, + (u32, u32), + ) + Send + + Sync, +>; + +/// Nodo de la vista declarativa. Estilo de layout (taffy) + relleno opcional +/// (vello) + texto opcional (skrifa+vello) + Msg al click opcional + hijos. +pub struct View { + pub style: Style, + pub fill: Option, + /// Relleno cuando el cursor está sobre este nodo. Sin valor (`None`) + /// = no se reacciona al hover. + pub hover_fill: Option, + pub radius: f64, + pub text: Option, + /// Imagen a pintar dentro del rect del nodo. Se centra y escala + /// preservando aspect ratio (`min(rect.w/img.w, rect.h/img.h)`). + /// El alfa por píxel de la imagen y el `Image::alpha` global se + /// respetan; el `fill` (si lo hay) se pinta debajo como background. + pub image: Option, + /// Callback de pintura custom. Si está presente, el runtime lo + /// invoca durante el paint del nodo con el `Scene` vivo + el rect + /// absoluto. Pensado para "canvas elements" (dominium, pluma, + /// cosmos) que pintan primitivas custom no expresables como una + /// composición de Views. + pub painter: Option, + /// Pintor GPU directo. Se invoca DESPUÉS de la pasada vello del + /// frame; comparte tree y orden DFS con los demás. Ver + /// [`GpuPaintFn`]. + pub gpu_painter: Option, + pub on_click: Option, + /// Handler de click que recibe la posición **relativa al rect del + /// nodo** (esquina superior-izquierda del nodo = `(0, 0)`). Útil + /// para canvas elements que quieren mapear el click a coordenadas + /// de mundo. Si está presente, gana sobre `on_click`. Devolver + /// `None` no dispara update. + pub on_click_at: Option>, + /// Equivalente a `on_click` pero para el botón derecho del ratón. + /// Pensado para menús contextuales: el nodo declara qué `Msg` + /// emitir cuando se le hace right-click, y la app abre el overlay + /// con el menú. + pub on_right_click: Option, + /// Variante posicional de [`Self::on_right_click`]. Útil para + /// grillas que necesitan saber *qué celda* del rect recibió el + /// click derecho (la celda no es un nodo aparte, sino una región + /// dentro del nodo). Si está presente, gana sobre `on_right_click`. + pub on_right_click_at: Option>, + /// Equivalente a `on_click` pero para el botón del medio del ratón + /// (rueda presionada). Pensado para abrir en pestaña nueva — los + /// browsers usan middle-click como atajo equivalente a Ctrl+Click. + pub on_middle_click: Option, + /// Handler de drag. Si está presente, este nodo arrastra (y NO emite + /// `on_click` al presionar — un nodo es uno u otro). + pub drag: Option>, + /// Variante de drag que recibe la posición inicial del press relativa + /// al rect del nodo. Gana sobre `drag` si ambos están presentes. + pub drag_at: Option>, + /// Payload `u64` que viaja con el drag iniciado sobre este nodo. Lo + /// recibe el handler [`Self::on_drop`] del drop target. Sin payload, + /// el drag funciona igual pero ningún drop target reacciona. + pub drag_payload: Option, + /// Handler invocado al soltar un drag sobre este nodo (drop target). + pub on_drop: Option>, + /// Color a pintar mientras un drag activo está hovereando este drop + /// target. Sobrepone a `fill`/`hover_fill` cuando aplica. + pub drop_hover_fill: Option, + /// Si `true`, los descendientes se recortan al rect del nodo (vía + /// `scene.push_layer` con `Mix::Clip`). El hit-test también respeta + /// el recorte: clicks fuera del rect ignoran a los hijos. + pub clip: bool, + /// Msg a emitir cuando el cursor entra al rect del nodo (transición + /// no-hover → hover). Útil para previews tipo "URL del link al + /// pasar el mouse". + pub on_pointer_enter: Option, + /// Msg a emitir cuando el cursor sale del rect del nodo. + pub on_pointer_leave: Option, + /// Handler de rueda local. Si está presente y el cursor cae sobre este + /// nodo, el runtime lo invoca antes del `App::on_wheel` global; un + /// `Some(Msg)` consume el evento. Base de las áreas de scroll + /// autocontenidas. Ver [`ScrollFn`]. + pub on_scroll: Option>, + /// Marca este nodo como **enfocable** con el id opaco `u64`. El runtime + /// mantiene el foco (uno por ventana) y lo mueve con Tab/Shift+Tab en + /// orden de árbol (pre-orden) y al clickear un nodo enfocable; notifica + /// a la app vía `App::on_focus` para que pinte el ring y rutee el + /// teclado. El id lo elige el caller (índice de campo, hash, etc.). + pub focusable: Option, + /// Opacidad multiplicada sobre TODO el subtree (este nodo + hijos), + /// en `[0.0, 1.0]`. Se realiza con `scene.push_layer(Mix::Normal, a, …)` + /// alrededor del rect del nodo: el subárbol se rasteriza en una capa + /// intermedia y se compone al alfa indicado contra lo que ya hay + /// detrás. `None` = sin capa (caso de la abrumadora mayoría de + /// nodos). Útil para fade-in/out de overlays, ghosts mientras se + /// arrastra, modales que aparecen, panels "vidrio". Note que la + /// composición tiene costo (allocate + blit), por lo que sólo + /// poblar este slot cuando hace falta — no es un atributo gratis. + pub alpha: Option, + /// Transformación afín 2D aplicada a este nodo y todo su subtree + /// **alrededor del centro de su propio rect** (convención CSS + /// `transform-origin: 50% 50%`). El runtime resuelve el centro en + /// `paint` (sólo entonces conoce el layout computado) y compone + /// `T(centro) · transform · T(-centro)` sobre la transformación + /// acumulada del padre, así nodos anidados transforman en el espacio + /// ya transformado de su ancestro — igual que CSS. `None` = identidad + /// (la abrumadora mayoría de nodos). Pensado para `transform`/ + /// `@keyframes` CSS de puriy (rotate/scale/translate). El hit-test + /// **respeta** el afín (un nodo transformado recibe clicks donde se ve + /// pintado). Limitación restante: los `painter`/`runs` custom no heredan + /// el afín, y la posición local que reciben los handlers `*_at` se + /// reporta en espacio de pantalla, no en el espacio local del nodo. + pub transform: Option, + /// Texto de **tooltip**: si está, el runtime/cliente puede mostrar un + /// rótulo flotante cuando el cursor se posa sobre este nodo. Llimphi sólo + /// transporta el dato hasta el [`MountedNode`]; *quién* lo pinta (un overlay + /// del runtime, una surface popup del cliente) lo decide el consumidor. El + /// hit-test de hover ya localiza el nodo bajo el cursor. `None` = sin tip. + pub tooltip: Option, + pub children: Vec>, +} + +/// Versión "instalada" del árbol: cada nodo tiene su NodeId de taffy, color +/// y handler. Se mantiene en orden de inserción (recorrido pre-orden), así +/// el hit-test puede iterar al revés para honrar el orden de pintado. +/// +/// `pub` (con campos `pub`) porque el runtime (llimphi-ui) lee el árbol +/// montado para hit-test y para la pasada GPU directa, pero vive en otro +/// crate. No se construye fuera de [`mount`]. +pub struct Mounted { + pub root: NodeId, + pub nodes: Vec>, + /// Contenido de texto por nodo-hoja, para que el runtime lo mida con + /// parley durante `compute_with_measure` y taffy reserve el alto real + /// del texto envuelto (varias líneas) en vez de una sola. Sin esto un + /// párrafo que envuelve a N líneas se aplastaría en la altura de una + /// (el bug clásico de "textos aplastados"). Sólo se pueblan hojas con + /// texto uniforme (sin `runs` multicolor, que el caller dimensiona). + pub text_measures: HashMap, +} + +/// Datos de un nodo-hoja de texto necesarios para medirlo (shaping + +/// line-break) sin volver a tocar el `View`. Lo consume el runtime en la +/// función de medición que le pasa a [`LayoutTree::compute_with_measure`]. +#[derive(Clone)] +pub struct TextMeasure { + pub content: String, + pub size_px: f32, + pub alignment: llimphi_text::Alignment, + pub italic: bool, + pub font_family: Option, + pub line_height: f32, +} + +pub struct MountedNode { + pub id: NodeId, + pub fill: Option, + pub hover_fill: Option, + pub radius: f64, + pub text: Option, + pub image: Option, + pub painter: Option, + pub gpu_painter: Option, + pub on_click: Option, + pub on_click_at: Option>, + pub on_right_click: Option, + pub on_right_click_at: Option>, + pub on_middle_click: Option, + pub drag: Option>, + pub drag_at: Option>, + pub drag_payload: Option, + pub on_drop: Option>, + pub drop_hover_fill: Option, + pub clip: bool, + pub on_pointer_enter: Option, + pub on_pointer_leave: Option, + pub on_scroll: Option>, + pub focusable: Option, + pub alpha: Option, + /// Transformación afín 2D del nodo (alrededor del centro de su rect). + /// Ver [`View::transform`]. `paint` la compone con la del padre. + pub transform: Option, + /// Texto de tooltip de este nodo (ver [`View::tooltip`]). El consumidor lo + /// lee tras un hit-test de hover para pintar el rótulo flotante. + pub tooltip: Option, + /// Índice (exclusivo) del fin del subárbol en `Mounted::nodes`. Los + /// descendientes ocupan `[idx + 1, subtree_end)`. Hace de "barrera" en + /// paint/hit_test para `pop_layer` y para saltar subárboles enteros. + pub subtree_end: usize, +} diff --git a/llimphi-compositor/src/render.rs b/llimphi-compositor/src/render.rs new file mode 100644 index 0000000..074e815 --- /dev/null +++ b/llimphi-compositor/src/render.rs @@ -0,0 +1,705 @@ +use super::*; + +pub fn mount(layout: &mut LayoutTree, v: View) -> Mounted { + let mut nodes = Vec::new(); + let mut text_measures = std::collections::HashMap::new(); + let root = mount_recursive(layout, v, &mut nodes, &mut text_measures); + Mounted { root, nodes, text_measures } +} + +/// Mount en pre-orden directo sobre `out`: pusheamos el padre como +/// placeholder (id real desconocido hasta crear el taffy node), recursamos +/// hijos sobre el mismo `out`, y al volver completamos `id` + `subtree_end`. +pub fn mount_recursive( + layout: &mut LayoutTree, + v: View, + out: &mut Vec>, + text_measures: &mut std::collections::HashMap, +) -> NodeId { + let View { + style, + fill, + hover_fill, + radius, + text, + image, + painter, + gpu_painter, + on_click, + on_click_at, + on_right_click, + on_right_click_at, + on_middle_click, + drag, + drag_at, + drag_payload, + on_drop, + drop_hover_fill, + clip, + on_pointer_enter, + on_pointer_leave, + on_scroll, + focusable, + alpha, + transform, + tooltip, + children, + } = v; + let parent_idx = out.len(); + out.push(MountedNode { + id: NodeId::new(0), // placeholder, lo sobreescribimos abajo + fill, + hover_fill, + radius, + text, + image, + painter, + gpu_painter, + on_click, + on_click_at, + on_right_click, + on_right_click_at, + on_middle_click, + drag, + drag_at, + drag_payload, + on_drop, + drop_hover_fill, + clip, + on_pointer_enter, + on_pointer_leave, + on_scroll, + focusable, + alpha, + transform, + tooltip, + subtree_end: 0, + }); + let mut child_ids = Vec::with_capacity(children.len()); + for child in children { + child_ids.push(mount_recursive(layout, child, out, text_measures)); + } + let id = if child_ids.is_empty() { + layout.leaf(style).expect("layout leaf") + } else { + layout.node(style, &child_ids).expect("layout node") + }; + out[parent_idx].id = id; + out[parent_idx].subtree_end = out.len(); + // Hoja de texto uniforme: registrá su contenido para que el runtime lo + // mida con parley. El texto multicolor (`runs`) lo dimensiona el caller + // (editor: un nodo por línea), así que no lo medimos acá. + if child_ids.is_empty() { + if let Some(text) = out[parent_idx].text.as_ref() { + if text.runs.is_none() { + text_measures.insert( + id, + TextMeasure { + content: text.content.clone(), + size_px: text.size_px, + alignment: text.alignment, + italic: text.italic, + font_family: text.font_family.clone(), + line_height: text.line_height, + }, + ); + } + } + } + id +} + +/// Mide una hoja de texto para taffy: shaping + line-break con parley contra +/// el ancho disponible, devolviendo el bounding box. Si el ancho ya está +/// resuelto (`known.width`) se usa ese; si no, se deriva del `available` +/// (Definite → ese ancho; MaxContent → sin límite = una línea; MinContent → +/// 0 = envuelve a la palabra más ancha). El `line_height` sale del propio +/// `TextMeasure`, el mismo que usa `paint`, así medida y pintado coinciden. +pub fn measure_text_node( + ts: &mut llimphi_text::Typesetter, + tm: &TextMeasure, + known: llimphi_layout::taffy::Size>, + available: llimphi_layout::taffy::Size, +) -> llimphi_layout::taffy::Size { + use llimphi_layout::taffy::AvailableSpace; + let max_width: Option = known.width.or(match available.width { + AvailableSpace::Definite(w) => Some(w), + AvailableSpace::MaxContent => None, + AvailableSpace::MinContent => Some(0.0), + }); + let block = llimphi_text::TextBlock { + text: &tm.content, + size_px: tm.size_px, + color: Color::BLACK, + origin: (0.0, 0.0), + max_width, + alignment: tm.alignment, + line_height: tm.line_height, + italic: tm.italic, + font_family: tm.font_family.clone(), + }; + let m = llimphi_text::measure(ts, &block); + llimphi_layout::taffy::Size { width: m.width, height: m.height } +} + +pub fn paint( + scene: &mut vello::Scene, + mounted: &Mounted, + computed: &ComputedLayout, + typesetter: &mut llimphi_text::Typesetter, + hover_idx: Option, + drop_hover_idx: Option, +) { + // Stack de subtree_end de los `push_layer` activos (clip y/o alpha). + // Vello requiere pop_layer en orden LIFO estricto, así que mantenemos + // un único stack común y popeamos en el orden en que se pushearon. + // Dos entradas con el mismo `subtree_end` (alpha + clip sobre el + // mismo nodo) se cierran en el orden inverso al push. + let mut layer_stack: Vec = Vec::new(); + // Stack de transformaciones afines de subtree. Cada entrada guarda el + // `subtree_end` y la `cur_xf` previa para restaurarla al salir del + // subárbol. `cur_xf` es el producto acumulado de todos los `transform` + // de los ancestros activos — se multiplica en cada draw call. Cuando + // ningún nodo transforma, queda en `IDENTITY` y el paint es idéntico + // al previo (cero regresión). + let mut xf_stack: Vec<(usize, Affine)> = Vec::new(); + let mut cur_xf = Affine::IDENTITY; + for (idx, node) in mounted.nodes.iter().enumerate() { + // Cierre de capas que ya quedaron atrás (idx ≥ subtree_end). + while let Some(&end) = layer_stack.last() { + if idx >= end { + scene.pop_layer(); + layer_stack.pop(); + } else { + break; + } + } + // Restaurá la transformación al salir de subárboles transformados. + while let Some(&(end, prev)) = xf_stack.last() { + if idx >= end { + cur_xf = prev; + xf_stack.pop(); + } else { + break; + } + } + let Some(r) = computed.get(node.id) else { + continue; + }; + // Transform CSS del nodo: se aplica alrededor del centro de su rect + // (`transform-origin: 50% 50%`) y se compone sobre la del padre. Se + // empuja ANTES del alpha/fill para que toda la pintura del subtree + // (incl. la capa de alpha y el clip) caiga en el espacio transformado. + if let Some(local) = node.transform { + let cx = (r.x + r.w * 0.5) as f64; + let cy = (r.y + r.h * 0.5) as f64; + let centered = + Affine::translate((cx, cy)) * local * Affine::translate((-cx, -cy)); + xf_stack.push((node.subtree_end, cur_xf)); + cur_xf *= centered; + } + // Alpha de subtree: push ANTES de cualquier paint de este nodo + // para que fill/text/image/painter/children entren en la misma + // capa y se compongan juntos al alfa indicado. Si el nodo tiene + // hijos, su `subtree_end > idx + 1` y la capa permanece abierta + // hasta que el loop alcance el primer índice fuera del subárbol. + // Para nodos hoja con alpha el push y el pop son consecutivos — + // funcionalmente equivalente a multiplicar el alpha del fill, + // pero permite usar el mismo API sin distinguir hoja vs rama. + if let Some(a) = node.alpha { + let rect = KurboRect::new( + r.x as f64, + r.y as f64, + (r.x + r.w) as f64, + (r.y + r.h) as f64, + ); + scene.push_layer(Mix::Normal, a, cur_xf, &rect); + layer_stack.push(node.subtree_end); + } + // Prioridad de pintura: drop-hover (drag activo) > hover normal > + // fill base. Solo aplica el override si el slot correspondiente + // está poblado; el siguiente cae como fallback. + let effective_fill = if Some(idx) == drop_hover_idx { + node.drop_hover_fill.or(node.hover_fill).or(node.fill) + } else if Some(idx) == hover_idx { + node.hover_fill.or(node.fill) + } else { + node.fill + }; + if let Some(color) = effective_fill { + let rr = RoundedRect::new( + r.x as f64, + r.y as f64, + (r.x + r.w) as f64, + (r.y + r.h) as f64, + node.radius, + ); + scene.fill(Fill::NonZero, cur_xf, color, None, &rr); + } + if let Some(image) = node.image.as_ref() { + // Aspect-fit centrado: el min de las dos escalas ocupa + // todo el rect en el eje más restrictivo y deja banda en + // el otro. Defensivo: envolvemos en push_layer/pop_layer + // con el rect del nodo para que, aunque el caller pida + // un layout mal-dimensionado, la imagen nunca pinte fuera + // del nodo (visualmente preferible a un overflow opaco). + if image.width > 0 && image.height > 0 && r.w > 0.0 && r.h > 0.0 { + let sx = r.w as f64 / image.width as f64; + let sy = r.h as f64 / image.height as f64; + let s = sx.min(sy); + let disp_w = image.width as f64 * s; + let disp_h = image.height as f64 * s; + let tx = r.x as f64 + (r.w as f64 - disp_w) * 0.5; + let ty = r.y as f64 + (r.h as f64 - disp_h) * 0.5; + let transform = Affine::translate((tx, ty)) * Affine::scale(s); + let node_rect = KurboRect::new( + r.x as f64, + r.y as f64, + (r.x + r.w) as f64, + (r.y + r.h) as f64, + ); + scene.push_layer(Mix::Clip, 1.0, cur_xf, &node_rect); + scene.draw_image(image, cur_xf * transform); + scene.pop_layer(); + } + } + if let Some(painter) = node.painter.as_ref() { + (painter)( + scene, + typesetter, + PaintRect { + x: r.x, + y: r.y, + w: r.w, + h: r.h, + }, + ); + } + if let Some(text) = node.text.as_ref() { + if let Some(runs) = text.runs.as_ref() { + // Texto multicolor (syntax highlighting): una sola pasada de + // shaping con color por rango, anclado arriba-izquierda. Cae + // por el flujo normal (clip/alpha se cierran como siempre). + let layout = typesetter.layout_runs( + &text.content, + text.size_px, + text.color, + runs, + text.alignment, + text.line_height, + ); + llimphi_text::draw_layout_runs(scene, &layout, (r.x as f64, r.y as f64)); + } else { + // Parley resuelve la alineación horizontal vía max_width + + // alignment. Para Center también centramos verticalmente; para + // Start/End/Justify anclamos arriba (párrafo/editor). + let block = llimphi_text::TextBlock { + text: &text.content, + size_px: text.size_px, + color: text.color, + origin: (r.x as f64, r.y as f64), + max_width: Some(r.w), + alignment: text.alignment, + line_height: text.line_height, + italic: text.italic, + font_family: text.font_family.clone(), + }; + // Shaping una sola vez: el `Layout` retornado se reusa para + // medir (cuando hay centrado vertical) y para pintar. + let layout = llimphi_text::layout_block(typesetter, &block); + let origin = + if matches!(text.alignment, llimphi_text::Alignment::Center) { + let m = llimphi_text::measurement(&layout); + ( + r.x as f64, + r.y as f64 + ((r.h - m.height) as f64 * 0.5).max(0.0), + ) + } else { + block.origin + }; + llimphi_text::draw_layout_xf( + scene, + &layout, + text.color, + cur_xf * Affine::translate(origin), + ); + } + } + if node.clip { + let clip_rect = KurboRect::new( + r.x as f64, + r.y as f64, + (r.x + r.w) as f64, + (r.y + r.h) as f64, + ); + scene.push_layer(Mix::Clip, 1.0, cur_xf, &clip_rect); + layer_stack.push(node.subtree_end); + } + } + // Cerrá capas (clip + alpha) que llegaron al final sin pop intermedio. + while layer_stack.pop().is_some() { + scene.pop_layer(); + } +} + +/// Pasada GPU directo: recorre el `Mounted` en pre-orden DFS (mismo orden +/// que [`paint`]) e invoca cada `gpu_painter` con el encoder y la +/// `TextureView` del frame. Se ejecuta DESPUÉS de la pasada vello — la +/// intermediate ya tiene fill/image/painter/text encima cuando los +/// callbacks corren, así que su `LoadOp` debe ser `Load`. Devuelve si +/// se invocó al menos un painter (para que el caller decida si vale la +/// pena finalizar y submitir el encoder). +/// `true` si algún nodo del árbol registró un `gpu_painter` (p. ej. el video +/// de media vía `gpu_paint_with`). El eventloop lo usa para decidir si la +/// capa de overlay necesita componerse aparte (sobre el contenido gpu) en vez +/// de pintarse en la escena principal. +pub fn has_gpu_painter(mounted: &Mounted) -> bool { + mounted.nodes.iter().any(|n| n.gpu_painter.is_some()) +} + +pub fn paint_gpu( + mounted: &Mounted, + computed: &ComputedLayout, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + view: &wgpu::TextureView, + viewport: (u32, u32), +) -> bool { + let mut any = false; + for node in &mounted.nodes { + let Some(painter) = node.gpu_painter.as_ref() else { + continue; + }; + let Some(r) = computed.get(node.id) else { + continue; + }; + (painter)( + device, + queue, + encoder, + view, + PaintRect { + x: r.x, + y: r.y, + w: r.w, + h: r.h, + }, + viewport, + ); + any = true; + } + any +} + +/// Hit-test parametrizado por elegibilidad. Devuelve el índice del nodo +/// más al frente (último en pre-orden) cuyo rect contiene `(x, y)` y para +/// el cual `pred` devuelve `true`, respetando `clip`: si el punto cae +/// afuera de un nodo con clip, el subárbol entero es invisible. +/// +/// **Respeta `transform`**: igual que [`paint`], compone el afín acumulado +/// de los ancestros (cada `transform` alrededor del centro del rect del +/// nodo, convención CSS `transform-origin: 50% 50%`). El punto de pantalla +/// `(x, y)` se lleva al espacio local del nodo invirtiendo ese afín, y se +/// testea contra el rect sin transformar. Así un nodo rotado/escalado/ +/// trasladado recibe los clicks donde realmente se ve pintado (recorrido +/// tipo Prezi, lienzos de tullpu, `@keyframes` de puriy). Un subárbol con +/// afín singular (escala 0) es inalcanzable, igual que es invisible. +pub fn hit_test_pred( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, + pred: F, +) -> Option +where + F: Fn(&MountedNode) -> bool, +{ + let mut hit: Option = None; + let mut clip_stack: Vec = Vec::new(); + // Espejo del stack de transformaciones de `paint`: `cur_xf` es el + // producto acumulado de los `transform` de los ancestros activos + // (local → pantalla). Vacío ⇒ identidad ⇒ camino directo sin invertir + // (cero costo para la abrumadora mayoría de árboles sin transform). + let mut xf_stack: Vec<(usize, Affine)> = Vec::new(); + let mut cur_xf = Affine::IDENTITY; + let mut idx = 0; + while idx < mounted.nodes.len() { + while let Some(&end) = clip_stack.last() { + if idx >= end { + clip_stack.pop(); + } else { + break; + } + } + while let Some(&(end, prev)) = xf_stack.last() { + if idx >= end { + cur_xf = prev; + xf_stack.pop(); + } else { + break; + } + } + let node = &mounted.nodes[idx]; + let Some(r) = computed.get(node.id) else { + idx += 1; + continue; + }; + // Componé el transform de este nodo igual que `paint`, ANTES de + // resolver el punto local (su propio rect ya cae en el espacio + // transformado). + if let Some(local) = node.transform { + let cx = (r.x + r.w * 0.5) as f64; + let cy = (r.y + r.h * 0.5) as f64; + let centered = + Affine::translate((cx, cy)) * local * Affine::translate((-cx, -cy)); + xf_stack.push((node.subtree_end, cur_xf)); + cur_xf *= centered; + } + // Punto en el espacio local del nodo. Sin transform activo, es el + // punto de pantalla tal cual. Con transform, se invierte el afín; + // si es singular (no invertible) el subárbol es inalcanzable. + let (lx, ly) = if xf_stack.is_empty() { + (x as f64, y as f64) + } else if cur_xf.determinant().abs() < 1e-9 { + idx = node.subtree_end; + continue; + } else { + let p = cur_xf.inverse() * Point::new(x as f64, y as f64); + (p.x, p.y) + }; + let inside = lx >= r.x as f64 + && lx < (r.x + r.w) as f64 + && ly >= r.y as f64 + && ly < (r.y + r.h) as f64; + if node.clip { + if !inside { + idx = node.subtree_end; + continue; + } + clip_stack.push(node.subtree_end); + } + if inside && pred(node) { + hit = Some(idx); + } + idx += 1; + } + hit +} + +/// Hit-test específico para clicks (incluye nodos draggables). +pub fn hit_test_click( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Option { + hit_test_pred(mounted, computed, x, y, |n| { + n.on_click.is_some() + || n.on_click_at.is_some() + || n.drag.is_some() + || n.drag_at.is_some() + }) +} + +/// Hit-test específico para right-click. Sólo considera nodos que +/// declararon `on_right_click` o `on_right_click_at` — un right-click +/// sobre un nodo sin handler no hace nada (no se "filtra" al click +/// izquierdo). +pub fn hit_test_right_click( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Option { + hit_test_pred(mounted, computed, x, y, |n| { + n.on_right_click.is_some() || n.on_right_click_at.is_some() + }) +} + +/// Hit-test específico para middle-click. Mismo modelo que right-click: +/// sólo nodos que declararon `on_middle_click` reaccionan. +pub fn hit_test_middle_click( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Option { + hit_test_pred(mounted, computed, x, y, |n| n.on_middle_click.is_some()) +} + +/// Hit-test específico para hover (nodos con `hover_fill`). +pub fn hit_test_hover( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Option { + hit_test_pred(mounted, computed, x, y, |n| n.hover_fill.is_some()) +} + +/// Hit-test específico para drop targets (nodos con `on_drop`). Usado +/// durante un drag activo para resaltar el destino y para invocar el +/// handler al soltar. +pub fn hit_test_drop( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Option { + hit_test_pred(mounted, computed, x, y, |n| n.on_drop.is_some()) +} + +/// Hit-test específico para áreas de scroll (nodos con `on_scroll`). El +/// runtime lo usa al recibir la rueda: el nodo más al frente bajo el +/// cursor con handler de scroll consume el evento antes del `on_wheel` +/// global. +pub fn hit_test_scroll( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Option { + hit_test_pred(mounted, computed, x, y, |n| n.on_scroll.is_some()) +} + +/// Hit-test para foco: el id `focusable` del nodo más al frente bajo el +/// cursor (click-to-focus). `None` si no se clickeó nada enfocable. +pub fn hit_test_focusable( + mounted: &Mounted, + computed: &ComputedLayout, + x: f32, + y: f32, +) -> Option { + hit_test_pred(mounted, computed, x, y, |n| n.focusable.is_some()) + .and_then(|i| mounted.nodes[i].focusable) +} + +/// Ids enfocables en orden de Tab (pre-orden del árbol = orden de +/// inserción de `Mounted::nodes`). Sólo nodos con rect computado +/// (presentes en el layout). Es el orden DOM-like de tabulación. +pub fn focus_order(mounted: &Mounted, computed: &ComputedLayout) -> Vec { + mounted + .nodes + .iter() + .filter_map(|n| { + n.focusable + .filter(|_| computed.get(n.id).is_some()) + }) + .collect() +} + +/// Próximo id de foco al pulsar Tab (o Shift+Tab si `reverse`), dado el +/// `order` (de [`focus_order`]) y el `current`. Envuelve en los extremos. +/// Si no hay enfocables devuelve `None`; si `current` ya no existe en el +/// orden, arranca por el primero (Tab) o el último (Shift+Tab). +pub fn next_focus(order: &[u64], current: Option, reverse: bool) -> Option { + if order.is_empty() { + return None; + } + let n = order.len(); + let pos = current.and_then(|c| order.iter().position(|&id| id == c)); + let next_idx = match pos { + Some(i) => { + if reverse { + (i + n - 1) % n + } else { + (i + 1) % n + } + } + None => { + if reverse { + n - 1 + } else { + 0 + } + } + }; + Some(order[next_idx]) +} + +#[cfg(test)] +mod tests { + use crate::{hit_test_click, mount, View}; + use llimphi_layout::taffy::prelude::*; + use llimphi_layout::{LayoutTree, Style}; + use vello::kurbo::Affine; + + /// Un hijo clickeable de 100×100 anclado arriba-izquierda. Devuelve + /// `(mounted, computed)` ya layouteados sobre un viewport 400×400. + fn fixture( + transform: Option, + ) -> (crate::Mounted<()>, llimphi_layout::ComputedLayout) { + let mut child = View::<()>::new(Style { + size: Size { + width: length(100.0), + height: length(100.0), + }, + ..Default::default() + }) + .on_click(()); + if let Some(xf) = transform { + child = child.transform(xf); + } + let root = View::<()>::new(Style { + align_items: Some(AlignItems::FlexStart), + justify_content: Some(JustifyContent::FlexStart), + ..Default::default() + }) + .children(vec![child]); + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, root); + let computed = layout.compute(mounted.root, (400.0, 400.0)).expect("layout"); + (mounted, computed) + } + + #[test] + fn sin_transform_el_hit_cae_en_el_rect() { + let (m, c) = fixture(None); + assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), Some(1)); // dentro + assert_eq!(hit_test_click(&m, &c, 250.0, 50.0), None); // fuera + } + + #[test] + fn traslacion_mueve_el_area_clickeable() { + // El nodo se ve corrido +200px en x; el click debe seguirlo. + let (m, c) = fixture(Some(Affine::translate((200.0, 0.0)))); + assert_eq!(hit_test_click(&m, &c, 250.0, 50.0), Some(1)); // donde se ve + assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), None); // ya no donde estaba + } + + #[test] + fn rotacion_180_grados_alrededor_del_centro() { + // Rotar 180° alrededor del centro (50,50) deja el rect en su sitio: + // una esquina mapea a la opuesta, pero el cuadrado cubre lo mismo. + let (m, c) = fixture(Some(Affine::rotate(std::f64::consts::PI))); + assert_eq!(hit_test_click(&m, &c, 10.0, 10.0), Some(1)); + assert_eq!(hit_test_click(&m, &c, 90.0, 90.0), Some(1)); + assert_eq!(hit_test_click(&m, &c, 150.0, 150.0), None); + } + + #[test] + fn escala_cero_es_inalcanzable() { + let (m, c) = fixture(Some(Affine::scale(0.0))); + assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), None); + } + + #[test] + fn tab_traversal_envuelve_en_los_extremos() { + use crate::next_focus; + let order = [10u64, 20, 30]; + // Avanza. + assert_eq!(next_focus(&order, Some(10), false), Some(20)); + assert_eq!(next_focus(&order, Some(30), false), Some(10)); // wrap + // Retrocede (Shift+Tab). + assert_eq!(next_focus(&order, Some(20), true), Some(10)); + assert_eq!(next_focus(&order, Some(10), true), Some(30)); // wrap + // Sin foco previo: Tab → primero, Shift+Tab → último. + assert_eq!(next_focus(&order, None, false), Some(10)); + assert_eq!(next_focus(&order, None, true), Some(30)); + // Foco obsoleto (id que ya no está) → arranca por el extremo. + assert_eq!(next_focus(&order, Some(99), false), Some(10)); + // Lista vacía. + assert_eq!(next_focus(&[], Some(10), false), None); + } +} diff --git a/llimphi-compositor/src/view.rs b/llimphi-compositor/src/view.rs new file mode 100644 index 0000000..32bd1e4 --- /dev/null +++ b/llimphi-compositor/src/view.rs @@ -0,0 +1,408 @@ +use super::*; + +impl View { + pub fn new(style: Style) -> Self { + Self { + style, + fill: None, + hover_fill: None, + radius: 0.0, + text: None, + image: None, + painter: None, + gpu_painter: None, + on_pointer_enter: None, + on_pointer_leave: None, + on_click: None, + on_click_at: None, + on_right_click: None, + on_right_click_at: None, + on_middle_click: None, + drag: None, + drag_at: None, + drag_payload: None, + on_drop: None, + drop_hover_fill: None, + clip: false, + on_scroll: None, + focusable: None, + alpha: None, + transform: None, + tooltip: None, + children: Vec::new(), + } + } + + /// Asocia un texto de **tooltip** a este nodo. Llimphi sólo lo transporta + /// hasta el [`MountedNode`](crate::MountedNode); el consumidor decide cómo + /// mostrarlo (un overlay del runtime, una surface popup del cliente) tras + /// localizar el nodo bajo el cursor con el hit-test de hover. + pub fn tooltip(mut self, text: impl Into) -> Self { + self.tooltip = Some(text.into()); + self + } + + /// Registra un handler de rueda local: si el cursor está sobre este + /// nodo cuando la rueda gira, el runtime lo invoca con el delta + /// `(dx, dy)` en líneas lógicas ANTES de caer al `App::on_wheel` + /// global. Devolver `Some(Msg)` consume el evento. Es la base de las + /// áreas de scroll autocontenidas (`llimphi-widget-scroll`). + pub fn on_scroll(mut self, handler: F) -> Self + where + F: Fn(f32, f32) -> Option + Send + Sync + 'static, + { + self.on_scroll = Some(Arc::new(handler)); + self + } + + /// Marca este nodo como enfocable con el id opaco `id`. El runtime lo + /// incluye en el orden de Tab (pre-orden del árbol) y le da foco al + /// clickearlo; cada cambio de foco se notifica vía `App::on_focus`. + /// El caller pinta el focus-ring comparando el id contra el foco que + /// guardó en su `Model`. + pub fn focusable(mut self, id: u64) -> Self { + self.focusable = Some(id); + self + } + + /// Aplica una transformación afín 2D a este nodo y todo su subtree, + /// **alrededor del centro de su rect** (CSS `transform-origin: 50% + /// 50%`). El centro se resuelve en `paint` contra el layout computado; + /// el caller sólo provee el afín "local" (producto de sus + /// `rotate`/`scale`/`translate`). Nodos anidados componen en el + /// espacio ya transformado del padre. Pensado para `transform` y + /// `@keyframes` CSS de puriy. `Affine::IDENTITY` equivale a no setear. + pub fn transform(mut self, xf: Affine) -> Self { + self.transform = Some(xf); + self + } + + pub fn fill(mut self, color: Color) -> Self { + self.fill = Some(color); + self + } + + /// Opacidad uniforme aplicada a este nodo y todos sus descendientes + /// vía `scene.push_layer(Mix::Normal, a, …)`. Pensado para fade-in/out + /// de overlays, toasts y modales sin tener que tunear el alpha de + /// cada color del subtree. Valores fuera de `[0.0, 1.0]` se clampean. + /// Hace que el subtree se componga en una capa intermedia — usar sólo + /// cuando sea necesario (no es gratuito). + pub fn alpha(mut self, a: f32) -> Self { + self.alpha = Some(a.clamp(0.0, 1.0)); + self + } + + /// Color a usar cuando el cursor está sobre este nodo. Habilita + /// el hit-test de hover sobre el nodo. + pub fn hover_fill(mut self, color: Color) -> Self { + self.hover_fill = Some(color); + self + } + + /// Marca este nodo como draggable. Mientras el usuario sostenga el + /// botón izquierdo sobre él, el runtime llama `handler(Move, dx, dy)` + /// por cada `CursorMoved` (dx/dy = delta desde el evento anterior) y + /// `handler(End, 0, 0)` al soltar. Sobreescribe `on_click` para este + /// nodo: un nodo es draggable **o** clickable. + pub fn draggable(mut self, handler: F) -> Self + where + F: Fn(DragPhase, f32, f32) -> Option + Send + Sync + 'static, + { + self.drag = Some(Arc::new(handler)); + self + } + + /// Como `draggable`, pero el handler también recibe la posición + /// inicial del press relativa al rect del nodo `(initial_lx, + /// initial_ly)`. Útil cuando el caller necesita resolver qué + /// entidad bajo el cursor inició el drag (Conceptos, lemmings, + /// nodos de un grafo, etc.). Gana sobre `draggable` si ambos están. + pub fn draggable_at(mut self, handler: F) -> Self + where + F: Fn(DragPhase, f32, f32, f32, f32) -> Option + Send + Sync + 'static, + { + self.drag_at = Some(Arc::new(handler)); + self + } + + /// Declara el payload `u64` que viaja con el drag de este nodo. Los + /// drop targets bajo cursor al soltar reciben este valor en su + /// `on_drop`. Sin payload, los drop targets no reaccionan (útil para + /// drags de "resize/scroll" que no representan transferencia). + pub fn drag_payload(mut self, payload: u64) -> Self { + self.drag_payload = Some(payload); + self + } + + /// Marca este nodo como drop target. El runtime invoca `handler(payload)` + /// cuando un drag termina sobre el rect de este nodo y el origen del + /// drag declaró un payload. Si devuelve `Some(Msg)`, se dispatchea al + /// `update` antes del `DragPhase::End` del origen. + pub fn on_drop(mut self, handler: F) -> Self + where + F: Fn(u64) -> Option + Send + Sync + 'static, + { + self.on_drop = Some(Arc::new(handler)); + self + } + + /// Color de relleno cuando un drag activo está hovereando este drop + /// target. Análogo a `hover_fill` pero solo aplica mientras dura un + /// drag. Útil para resaltar el destino válido. + pub fn drop_hover_fill(mut self, color: Color) -> Self { + self.drop_hover_fill = Some(color); + self + } + + pub fn radius(mut self, r: f64) -> Self { + self.radius = r; + self + } + + pub fn text(mut self, content: impl Into, size_px: f32, color: Color) -> Self { + self.text = Some(TextSpec { + content: content.into(), + size_px, + color, + alignment: llimphi_text::Alignment::Center, + italic: false, + font_family: None, + line_height: 1.2, + runs: None, + }); + self + } + + pub fn text_aligned( + mut self, + content: impl Into, + size_px: f32, + color: Color, + alignment: llimphi_text::Alignment, + ) -> Self { + self.text = Some(TextSpec { + content: content.into(), + size_px, + color, + alignment, + italic: false, + font_family: None, + line_height: 1.2, + runs: None, + }); + self + } + + /// Como `text_aligned` pero con un flag `italic`. Si la fuente activa + /// no tiene variante italic, parley aplica synthesizing. + pub fn text_aligned_italic( + mut self, + content: impl Into, + size_px: f32, + color: Color, + alignment: llimphi_text::Alignment, + italic: bool, + ) -> Self { + self.text = Some(TextSpec { + content: content.into(), + size_px, + color, + alignment, + italic, + font_family: None, + line_height: 1.2, + runs: None, + }); + self + } + + /// Como `text_aligned_italic` pero con font-family explícito. + /// La cadena se pasa como `parley::FontStack::Source` (acepta listas + /// CSS con fallbacks). + pub fn text_aligned_full( + mut self, + content: impl Into, + size_px: f32, + color: Color, + alignment: llimphi_text::Alignment, + italic: bool, + font_family: Option, + ) -> Self { + self.text = Some(TextSpec { + content: content.into(), + size_px, + color, + alignment, + italic, + font_family, + line_height: 1.2, + runs: None, + }); + self + } + + /// Texto **multicolor** en una sola pasada de shaping: `content` se pinta + /// con `default_color` y cada `(start_byte, end_byte, color)` de `runs` + /// sobreescribe su rango (offsets en bytes). Pensado para syntax + /// highlighting — un nodo por línea en vez de uno por token. Anclado + /// arriba-izquierda (sin centrado vertical); el caller dimensiona el rect. + pub fn text_runs( + mut self, + content: impl Into, + size_px: f32, + default_color: Color, + runs: Vec<(usize, usize, Color)>, + alignment: llimphi_text::Alignment, + ) -> Self { + self.text = Some(TextSpec { + content: content.into(), + size_px, + color: default_color, + alignment, + italic: false, + font_family: None, + line_height: 1.2, + runs: Some(runs), + }); + self + } + + /// Sobreescribe el múltiplo de interlínea del texto ya seteado (default + /// 1.2). No-op si el nodo no tiene texto. Pensado para puriy, que pasa + /// el `line-height` computado de CSS para que medición y pintado usen + /// el mismo valor. + pub fn line_height(mut self, mult: f32) -> Self { + if let Some(t) = self.text.as_mut() { + t.line_height = mult; + } + self + } + + pub fn on_click(mut self, msg: Msg) -> Self { + self.on_click = Some(msg); + self + } + + /// Dispatch `msg` cuando el cursor entra al rect del nodo + /// (transición no-hover → hover). Sólo emite una vez por entrada — + /// el runtime no repite el msg si el cursor se mueve dentro del rect. + pub fn on_pointer_enter(mut self, msg: Msg) -> Self { + self.on_pointer_enter = Some(msg); + self + } + + /// Dispatch `msg` cuando el cursor sale del rect del nodo. + pub fn on_pointer_leave(mut self, msg: Msg) -> Self { + self.on_pointer_leave = Some(msg); + self + } + + /// Como `on_click`, pero el handler recibe `(local_x, local_y, + /// rect_w, rect_h)` — la posición del cursor relativa al rect del + /// nodo más las dimensiones actuales del nodo. Útil para canvas + /// elements que necesitan saber dónde fue el click para convertirlo + /// a coordenadas de mundo. Sobrescribe `on_click` para este nodo + /// si ambos están presentes. + pub fn on_click_at(mut self, handler: F) -> Self + where + F: Fn(f32, f32, f32, f32) -> Option + Send + Sync + 'static, + { + self.on_click_at = Some(Arc::new(handler)); + self + } + + /// Declara el `Msg` a emitir cuando el usuario hace click derecho + /// sobre este nodo. Para menús contextuales, conviene pasar un + /// `Msg::OpenMenu { ... }` y dejar que el modelo guarde la + /// posición; el overlay se abre vía [`App::view_overlay`]. + pub fn on_right_click(mut self, msg: Msg) -> Self { + self.on_right_click = Some(msg); + self + } + + /// Variante posicional de [`Self::on_right_click`]. El handler recibe + /// `(local_x, local_y, rect_w, rect_h)` para que un nodo "grilla" + /// pueda resolver internamente qué subcelda recibió el click. La + /// posición está relativa al rect del nodo. + pub fn on_right_click_at(mut self, handler: F) -> Self + where + F: Fn(f32, f32, f32, f32) -> Option + Send + Sync + 'static, + { + self.on_right_click_at = Some(Arc::new(handler)); + self + } + + /// Declara el `Msg` a emitir cuando el usuario hace click con el + /// botón del medio (rueda presionada). Usado típicamente para abrir + /// links en pestaña nueva — igual que Ctrl+Click pero más rápido. + pub fn on_middle_click(mut self, msg: Msg) -> Self { + self.on_middle_click = Some(msg); + self + } + + /// Pinta `image` dentro del rect del nodo, centrada y escalada + /// preservando aspect ratio. Re-exporta `peniko::Image` vía + /// `llimphi_raster::peniko::Image` — el caller decodifica los + /// bytes con el crate `image` (u otro) y construye el `Image` + /// con `Blob` + `ImageFormat::Rgba8`. + pub fn image(mut self, image: Image) -> Self { + self.image = Some(image); + self + } + + /// Registra una closure de pintura custom. El runtime la invoca + /// con `(&mut vello::Scene, &mut Typesetter, PaintRect)` durante + /// el paint del nodo. La closure es responsable de pintar + /// primitivas custom dentro del rect; no debe dejar `push_layer` + /// sin par. Soporte para canvas elements estilo + /// dominium/pluma/cosmos. + pub fn paint_with(mut self, painter: F) -> Self + where + F: Fn(&mut vello::Scene, &mut llimphi_text::Typesetter, PaintRect) + + Send + + Sync + + 'static, + { + self.painter = Some(Arc::new(painter)); + self + } + + /// Registra una closure de pintura GPU directo. La closure recibe + /// `(&Device, &Queue, &mut CommandEncoder, &TextureView, PaintRect, (viewport_w, viewport_h))` + /// y debe escribir sobre el `TextureView` con `LoadOp::Load` (no + /// clear) para preservar la pasada vello previa. El último + /// argumento es el tamaño en pixels de la `TextureView` destino + /// (la intermedia del frame) — necesario para calcular NDC sin + /// asumir un viewport fijo. Ver [`GpuPaintFn`] para semántica + /// completa, contexto y orden de pintura. + pub fn gpu_paint_with(mut self, painter: F) -> Self + where + F: Fn( + &wgpu::Device, + &wgpu::Queue, + &mut wgpu::CommandEncoder, + &wgpu::TextureView, + PaintRect, + (u32, u32), + ) + Send + + Sync + + 'static, + { + self.gpu_painter = Some(Arc::new(painter)); + self + } + + /// Recorta los hijos al rect de este nodo (paint y hit-test). Útil + /// para paneles con contenido virtualizado que no debe sangrar a + /// vecinos (listas, scrollers, viewers). + pub fn clip(mut self, enabled: bool) -> Self { + self.clip = enabled; + self + } + + pub fn children(mut self, children: Vec>) -> Self { + self.children = children; + self + } +} diff --git a/llimphi-compositor/tests/text_measure.rs b/llimphi-compositor/tests/text_measure.rs new file mode 100644 index 0000000..32b9b82 --- /dev/null +++ b/llimphi-compositor/tests/text_measure.rs @@ -0,0 +1,87 @@ +//! Verifica que un párrafo largo, dentro de un bloque angosto, reserva el +//! alto de **varias líneas** (no se aplasta en una). Es el regresor del bug +//! "textos aplastados" de puriy: sin medición con parley, taffy le daba a la +//! hoja de texto una sola línea de alto y las líneas envueltas se solapaban. + +use llimphi_compositor::{measure_text_node, mount, View}; +use llimphi_layout::taffy::prelude::*; +use llimphi_layout::taffy::Size as TSize; +use llimphi_layout::LayoutTree; + +#[derive(Clone)] +enum Msg {} + +#[test] +fn parrafo_largo_reserva_varias_lineas() { + // Bloque de 200px de ancho con un párrafo que claramente excede una línea. + let texto = "Lorem ipsum dolor sit amet consectetur adipiscing elit sed do \ + eiusmod tempor incididunt ut labore et dolore magna aliqua ut \ + enim ad minim veniam quis nostrud exercitation ullamco laboris."; + let block: View = View::new(Style { + size: TSize { width: length(200.0_f32), height: auto() }, + flex_direction: FlexDirection::Row, + flex_wrap: FlexWrap::Wrap, + ..Default::default() + }) + .children(vec![View::new(Style { + size: TSize { width: auto(), height: auto() }, + flex_shrink: 1.0, + ..Default::default() + }) + .text_aligned(texto, 16.0_f32, vello::peniko::Color::BLACK, llimphi_text::Alignment::Start)]); + + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, block); + let mut ts = llimphi_text::Typesetter::new(); + let tmap = &mounted.text_measures; + assert_eq!(tmap.len(), 1, "debería haber exactamente una hoja de texto"); + + let computed = layout + .compute_with_measure(mounted.root, (800.0, 600.0), |nid, known, avail| match tmap.get(&nid) + { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => TSize::ZERO, + }) + .expect("layout"); + + // El nodo de texto es el segundo en orden DFS (root, luego la hoja). + let leaf_id = mounted.nodes[1].id; + let rect = computed.get(leaf_id).expect("rect de la hoja"); + // A 16px y ~1.2 de interlínea, una línea ≈ 19px. Con ~150px de texto en + // 200px de ancho deberían ser >= 4 líneas → bastante más de una. + assert!( + rect.h > 40.0, + "el párrafo se aplastó: alto={} (esperaba varias líneas)", + rect.h + ); + assert!(rect.w <= 200.0 + 1.0, "no debería exceder el ancho del bloque"); +} + +#[test] +fn line_height_mayor_reserva_mas_alto() { + let texto = "una línea de texto que envuelve en dos o tres renglones según \ + el ancho disponible para el bloque contenedor angosto"; + let medir = |lh: f32| -> f32 { + let mut ts = llimphi_text::Typesetter::new(); + let tm = llimphi_compositor::TextMeasure { + content: texto.to_string(), + size_px: 16.0, + alignment: llimphi_text::Alignment::Start, + italic: false, + font_family: None, + line_height: lh, + }; + let known = TSize { width: Some(180.0_f32), height: None }; + let avail = TSize { + width: AvailableSpace::Definite(180.0), + height: AvailableSpace::MaxContent, + }; + measure_text_node(&mut ts, &tm, known, avail).height + }; + let compacto = medir(1.0); + let comodo = medir(2.0); + assert!( + comodo > compacto * 1.5, + "line-height: 2 debería reservar bastante más alto que 1.0 (got {compacto} vs {comodo})" + ); +} diff --git a/llimphi-gallery/Cargo.toml b/llimphi-gallery/Cargo.toml new file mode 100644 index 0000000..bd0388a --- /dev/null +++ b/llimphi-gallery/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "llimphi-gallery" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-gallery — demo único que prueba el kit transversal de elegancia. Binario standalone; `cargo run -p llimphi-gallery --release`." + +[[bin]] +name = "llimphi-gallery" +path = "src/main.rs" + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-motion = { workspace = true } +llimphi-icons = { workspace = true } +llimphi-widget-wawa-mark = { workspace = true } +llimphi-widget-tooltip = { workspace = true } +llimphi-widget-spinner = { workspace = true } +llimphi-widget-progress = { workspace = true } +llimphi-widget-toast = { workspace = true } +llimphi-widget-modal = { workspace = true } +llimphi-widget-empty = { workspace = true } +llimphi-widget-status-bar = { workspace = true } +llimphi-widget-shortcuts-help = { workspace = true } +llimphi-widget-splash = { workspace = true } +llimphi-widget-switch = { workspace = true } +llimphi-widget-segmented = { workspace = true } +llimphi-widget-breadcrumb = { workspace = true } +llimphi-widget-badge = { workspace = true } +llimphi-widget-avatar = { workspace = true } +llimphi-widget-skeleton = { workspace = true } +llimphi-widget-field = { workspace = true } +llimphi-widget-panel = { workspace = true } +llimphi-widget-card = { workspace = true } +llimphi-widget-context-menu = { workspace = true } +llimphi-widget-menubar = { workspace = true } +app-bus = { workspace = true } diff --git a/llimphi-gallery/src/main.rs b/llimphi-gallery/src/main.rs new file mode 100644 index 0000000..807f45e --- /dev/null +++ b/llimphi-gallery/src/main.rs @@ -0,0 +1,966 @@ +//! `llimphi-gallery` — demo único del kit transversal de elegancia. +//! +//! Una sola ventana que muestra cómo se ven los widgets del kit +//! juntos sobre el theme dark. Útil para verificar paleta, escala, +//! cinética y consistencia visual de un vistazo. +//! +//! `cargo run -p llimphi-gallery --release` +//! +//! Controles: +//! - Click en switches/segments/breadcrumb: dispatchea Msg +//! - Click en "Mostrar toast": apila un toast en bottom-right +//! - Click en "Abrir modal": muestra el modal +//! - `?`: abre/cierra el overlay de atajos +//! - Esc: cierra overlay activo + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, percent, FlexDirection, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View}; + +use llimphi_icons::{icon_view, Icon}; +use llimphi_theme::Theme; + +use app_bus::{AppMenu, Menu, MenuItem}; +use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H}; + +use llimphi_widget_avatar::avatar_view; +use llimphi_widget_badge::{count_badge_view, dot_badge_view, BadgeKind}; +use llimphi_widget_breadcrumb::{breadcrumb_view, BreadcrumbPalette}; +use llimphi_widget_card::{card_view, CardOptions, CardPalette}; +use llimphi_widget_context_menu::{ + context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec, +}; +use llimphi_widget_empty::{empty_view, EmptyPalette}; +use llimphi_widget_field::{field_view, FieldPalette, FieldSpec}; +use llimphi_widget_modal::{modal_view, ModalButton, ModalPalette, ModalSpec}; +use llimphi_widget_panel::{panel_signature_painter, PanelStyle}; +use llimphi_widget_progress::{linear_progress_view, radial_progress_view}; +use llimphi_widget_segmented::{segmented_view, SegmentedPalette}; +use llimphi_widget_shortcuts_help::{ + shortcuts_help_view, ShortcutEntry, ShortcutGroup, ShortcutsHelpPalette, ShortcutsHelpSpec, +}; +use llimphi_widget_skeleton::{skeleton_box_view, skeleton_line_view, SkeletonPalette}; +use llimphi_widget_spinner::spinner_view; +use llimphi_widget_splash::splash_view; +use llimphi_widget_status_bar::{status_bar_view, StatusBarPalette, StatusSegment}; +use llimphi_widget_switch::{switch_view, SwitchPalette}; +use llimphi_widget_toast::{toast_stack_view, Toast}; +use llimphi_widget_tooltip::{tooltip_view, Side, TooltipPalette, TooltipSpec}; +use llimphi_widget_wawa_mark::{wawa_mark_view, WawaMarkPalette}; + +#[derive(Clone)] +enum Msg { + /// Tick para forzar repaint (animaciones por reloj absoluto). + Tick, + ToggleA, + ToggleB, + SelectSeg(usize), + #[allow(dead_code)] + BreadcrumbJump(usize), + PushToast, + DismissToast(u64), + OpenModal, + CloseModal, + ConfirmModal, + ToggleShortcuts, + OpenContextMenu, + CloseContextMenu, + ContextMenuPick(usize), + /// Abrir/cerrar un menú raíz de la barra principal (`None` = cerrar). + MenuOpen(Option), + /// Comando elegido en la barra principal (id `menu.`). + MenuCommand(String), +} + +struct Model { + started_at: Instant, + switch_a: bool, + switch_b: bool, + seg: usize, + toasts: Vec, + next_toast_id: u64, + modal_open: bool, + shortcuts_open: bool, + viewport: (f32, f32), + /// Anchor del context-menu si está abierto. None = cerrado. + menu_open: Option<(f32, f32)>, + /// Item resaltado del menú (`usize::MAX` = ninguno, estado inicial). + menu_active: usize, + /// Última opción elegida del menú — se muestra como toast. + menu_last_pick: Option, + /// Índice del menú raíz de la barra principal abierto. `None` = ninguno. + menubar_open: Option, +} + +struct Gallery; + +impl App for Gallery { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · gallery" + } + + fn initial_size() -> (u32, u32) { + (1280, 800) + } + + fn init(handle: &Handle) -> Self::Model { + // Loop infinito de ticks para animar spinner/skeleton/splash. + // En una app real esto se gateaba según haya animaciones vivas. + handle.spawn_periodic(Duration::from_millis(50), || Msg::Tick); + Model { + started_at: Instant::now(), + switch_a: true, + switch_b: false, + seg: 1, + toasts: Vec::new(), + next_toast_id: 0, + modal_open: false, + shortcuts_open: false, + viewport: (1280.0, 800.0), + menu_open: None, + menu_active: usize::MAX, + menu_last_pick: None, + menubar_open: None, + } + } + + fn update(model: Self::Model, msg: Self::Msg, _handle: &Handle) -> Self::Model { + let mut m = model; + // Filtrar toasts expirados oportunamente. + let now = Instant::now(); + m.toasts.retain(|t| t.is_alive(now)); + match msg { + Msg::Tick => {} + Msg::ToggleA => m.switch_a = !m.switch_a, + Msg::ToggleB => m.switch_b = !m.switch_b, + Msg::SelectSeg(i) => m.seg = i, + Msg::BreadcrumbJump(_) => {} // sólo demo + Msg::PushToast => { + let kinds = [ + (BadgeKind::Info, "guardado en disco"), + (BadgeKind::Success, "publicado correctamente"), + (BadgeKind::Warning, "espacio bajo en cache"), + (BadgeKind::Error, "no se pudo conectar"), + ]; + let (kind, text) = kinds[(m.next_toast_id as usize) % kinds.len()]; + let id = m.next_toast_id; + m.next_toast_id += 1; + let toast = match kind { + BadgeKind::Info => Toast::info(id, text, Duration::from_secs(4)), + BadgeKind::Success => Toast::success(id, text, Duration::from_secs(4)), + BadgeKind::Warning => Toast::warning(id, text, Duration::from_secs(4)), + BadgeKind::Error => Toast::error(id, text, Duration::from_secs(4)), + BadgeKind::Neutral => Toast::info(id, text, Duration::from_secs(4)), + }; + m.toasts.push(toast); + } + Msg::DismissToast(id) => m.toasts.retain(|t| t.id != id), + Msg::OpenModal => m.modal_open = true, + Msg::CloseModal => m.modal_open = false, + Msg::ConfirmModal => m.modal_open = false, + Msg::ToggleShortcuts => m.shortcuts_open = !m.shortcuts_open, + Msg::OpenContextMenu => { + // Posición fija razonable — el botón está en la columna + // derecha; abrir el menú con anchor relativo al + // viewport mantiene la demo predecible aunque la + // ventana cambie de tamaño. + m.menu_open = Some((m.viewport.0 * 0.72, m.viewport.1 * 0.55)); + m.menu_active = usize::MAX; + m.menubar_open = None; + } + Msg::CloseContextMenu => { + m.menu_open = None; + m.menu_active = usize::MAX; + } + Msg::ContextMenuPick(idx) => { + let labels = ["Copiar", "Cortar", "Pegar", "", "Eliminar"]; + let label = labels.get(idx).copied().unwrap_or("?"); + m.menu_last_pick = Some(label.to_string()); + m.menu_open = None; + m.menu_active = usize::MAX; + // Confirmación visible. + let id = m.next_toast_id; + m.next_toast_id += 1; + m.toasts.push(Toast::info( + id, + format!("Menú → {label}"), + Duration::from_secs(3), + )); + } + Msg::MenuOpen(idx) => { + m.menubar_open = idx; + // El dropdown de la barra y el contextual son mutuamente + // excluyentes. + m.menu_open = None; + } + Msg::MenuCommand(cmd) => { + m.menubar_open = None; + match cmd.as_str() { + "app.quit" => std::process::exit(0), + "view.toast" => return Self::update(m, Msg::PushToast, _handle), + "view.modal" => m.modal_open = true, + "view.context" => { + m.menu_open = Some((m.viewport.0 * 0.5, m.viewport.1 * 0.45)); + m.menu_active = usize::MAX; + } + "help.shortcuts" => m.shortcuts_open = true, + "help.about" => { + let id = m.next_toast_id; + m.next_toast_id += 1; + m.toasts.push(Toast::info( + id, + "llimphi · gallery — vitrina del kit de elegancia", + Duration::from_secs(4), + )); + } + _ => {} + } + } + } + m + } + + fn on_key(_model: &Self::Model, ev: &KeyEvent) -> Option { + if ev.state != KeyState::Pressed { + return None; + } + match &ev.key { + Key::Named(NamedKey::Escape) => Some(Msg::CloseModal), + Key::Character(s) if s == "?" => Some(Msg::ToggleShortcuts), + _ => None, + } + } + + fn view(model: &Self::Model) -> View { + let theme = Theme::dark(); + + // Tres columnas equilibradas + status bar inferior. + let left = column_left(model, &theme); + let center = column_center(model, &theme); + let right = column_right(model, &theme); + + let cols = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + gap: Size { + width: length(16.0_f32), + height: length(0.0_f32), + }, + padding: Rect { + left: length(16.0_f32), + right: length(16.0_f32), + top: length(16.0_f32), + bottom: length(8.0_f32), + }, + ..Default::default() + }) + .children(vec![left, center, right]); + + let status = status_bar_view( + vec![ + StatusSegment::text("llimphi-gallery").with_icon(Icon::Home), + StatusSegment::text(if model.switch_a { "modo: pleno" } else { "modo: simple" }) + .emphasized(), + ], + vec![], + vec![ + StatusSegment::text("Ln 1, Col 1"), + StatusSegment::text("UTF-8"), + StatusSegment::text("? atajos") + .clickable(Msg::ToggleShortcuts) + .with_icon(Icon::Info), + ], + &StatusBarPalette::from_theme(&theme), + ); + + let menu = app_menu(); + let bar = menubar_view(&menubar_spec(&menu, model, &theme)); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_app) + .children(vec![bar, cols, status]) + } + + fn view_overlay(model: &Self::Model) -> Option> { + let theme = Theme::dark(); + // Prioridad: modal > shortcuts > toasts. + if model.modal_open { + return Some(modal_view(ModalSpec { + title: "Confirmar acción".to_string(), + body: modal_body_view(&theme), + buttons: vec![ + ModalButton::cancel("Cancelar", Msg::CloseModal), + ModalButton::primary("Aplicar", Msg::ConfirmModal), + ], + size: (440.0, 220.0), + viewport: model.viewport, + on_dismiss: Msg::CloseModal, + palette: ModalPalette::from_theme(&theme), + })); + } + if model.shortcuts_open { + return Some(shortcuts_help_view(ShortcutsHelpSpec { + title: "Atajos de teclado".to_string(), + groups: vec![ + ShortcutGroup::new( + "General", + vec![ + ShortcutEntry::new("?", "Mostrar/ocultar esta ayuda"), + ShortcutEntry::new("Esc", "Cerrar overlay activo"), + ], + ), + ShortcutGroup::new( + "Demo", + vec![ + ShortcutEntry::new("Click", "Toasts, modal y switches"), + ShortcutEntry::new("Hover", "Tooltips sobre los avatares"), + ], + ), + ], + viewport: model.viewport, + on_dismiss: Msg::ToggleShortcuts, + palette: ShortcutsHelpPalette::from_theme(&theme), + })); + } + if let Some(anchor) = model.menu_open { + return Some(context_menu_view(ContextMenuSpec { + anchor, + viewport: model.viewport, + header: Some("Lienzo".into()), + items: vec![ + ContextMenuItem::action("Copiar").with_shortcut("Ctrl+C"), + ContextMenuItem::action("Cortar").with_shortcut("Ctrl+X"), + ContextMenuItem::action("Pegar").with_shortcut("Ctrl+V").disabled(), + ContextMenuItem::separator(), + ContextMenuItem::action("Eliminar") + .with_shortcut("Del") + .destructive(), + ], + active: model.menu_active, + on_pick: Arc::new(Msg::ContextMenuPick), + on_dismiss: Msg::CloseContextMenu, + palette: ContextMenuPalette::from_theme(&theme), + })); + } + // Dropdown de la barra de menú principal. + let menu = app_menu(); + if let Some(v) = menubar_overlay(&menubar_spec(&menu, model, &theme)) { + return Some(v); + } + if !model.toasts.is_empty() { + return Some(toast_stack_view( + &model.toasts, + model.viewport, + Msg::DismissToast, + )); + } + None + } +} + +// --------------------------------------------------------------------- +// Barra de menú principal +// --------------------------------------------------------------------- + +/// Menú principal de la vitrina. Sólo comandos que mapean a `Msg` reales. +fn app_menu() -> AppMenu { + AppMenu::new() + .menu(Menu::new("Archivo").item(MenuItem::new("Salir", "app.quit").shortcut("Ctrl+Q"))) + .menu( + Menu::new("Ver") + .item(MenuItem::new("Mostrar toast", "view.toast")) + .item(MenuItem::new("Abrir modal", "view.modal")) + .item(MenuItem::new("Menú contextual", "view.context").separated()), + ) + .menu( + Menu::new("Ayuda") + .item(MenuItem::new("Atajos", "help.shortcuts").shortcut("?")) + .item(MenuItem::new("Acerca de", "help.about")), + ) +} + +/// Arma el `MenuBarSpec` compartido entre `view` y `view_overlay`. +fn menubar_spec<'a>(menu: &'a AppMenu, model: &Model, theme: &'a Theme) -> MenuBarSpec<'a, Msg> { + MenuBarSpec { + menu, + open: model.menubar_open, + theme, + viewport: model.viewport, + height: MENU_H, + on_open: Arc::new(Msg::MenuOpen), + on_command: Arc::new(|cmd: &str| Msg::MenuCommand(cmd.to_string())), + } +} + +// --------------------------------------------------------------------- +// Columnas +// --------------------------------------------------------------------- + +fn column_left(model: &Model, theme: &Theme) -> View { + let mut children: Vec> = Vec::new(); + + children.push(section_title("Identidad")); + // Sello wawa en chico + grande. + children.push( + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(128.0_f32), + }, + gap: Size { + width: length(16.0_f32), + height: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .children(vec![ + wawa_frame(48.0), + wawa_frame(96.0), + wawa_frame(128.0), + ]), + ); + + children.push(section_title("Splash")); + children.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(220.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(theme.bg_panel) + .radius(llimphi_theme::radius::MD) + .children(vec![splash_view(model.started_at, theme.bg_panel, theme.fg_text)]), + ); + + children.push(section_title("Empty state")); + children.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(200.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(theme.bg_panel) + .radius(llimphi_theme::radius::MD) + .children(vec![empty_view( + Icon::Folder, + "Sin documentos abiertos", + Some("Abrí uno con Ctrl+O o creá un nuevo lienzo para empezar."), + &EmptyPalette::from_theme(theme), + )]), + ); + + panel_view(children, theme) +} + +fn column_center(model: &Model, theme: &Theme) -> View { + let mut children: Vec> = Vec::new(); + + children.push(section_title("Navegación")); + children.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: auto(), + }, + ..Default::default() + }) + .children(vec![breadcrumb_view( + &["home", "docs", "2026", "elegancia.md"], + Msg::BreadcrumbJump, + &BreadcrumbPalette::from_theme(theme), + )]), + ); + + children.push(section_title("Controles")); + children.push(switch_row("Modo pleno", model.switch_a, Msg::ToggleA, theme)); + children.push(switch_row("Telemetría", model.switch_b, Msg::ToggleB, theme)); + children.push(spacer_v(8.0)); + children.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(28.0_f32), + }, + ..Default::default() + }) + .children(vec![segmented_view( + &["lista", "grilla", "kanban"], + model.seg, + Msg::SelectSeg, + &SegmentedPalette::from_theme(theme), + )]), + ); + + children.push(section_title("Formulario")); + children.push(field_view(FieldSpec { + label: "Nombre del lienzo".to_string(), + control: fake_text_input("introducción a wawa", theme), + required: true, + helper: Some("Aparece como título en la pestaña.".to_string()), + error: None, + palette: FieldPalette::from_theme(theme), + })); + children.push(spacer_v(12.0)); + children.push(field_view(FieldSpec { + label: "Slug".to_string(), + control: fake_text_input("intro-wawa-x@123", theme), + required: false, + helper: None, + error: Some("Sólo letras, números y guiones.".to_string()), + palette: FieldPalette::from_theme(theme), + })); + + children.push(section_title("Acciones")); + children.push(button_row(theme)); + + panel_view(children, theme) +} + +fn column_right(_model: &Model, theme: &Theme) -> View { + let mut children: Vec> = Vec::new(); + + children.push(section_title("Identidades")); + // Avatares en línea con badge encima. + children.push( + 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), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .children(vec![ + avatar_view("sergio", 40.0), + avatar_view("calcetín", 40.0), + avatar_view("amaru", 40.0), + avatar_view("pacha", 40.0), + avatar_view("inti", 40.0), + ]), + ); + + children.push(section_title("Badges")); + children.push( + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(24.0_f32), + }, + gap: Size { + width: length(10.0_f32), + height: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .children(vec![ + count_badge_view(3, BadgeKind::Info), + count_badge_view(12, BadgeKind::Success), + count_badge_view(99, BadgeKind::Warning), + count_badge_view(120, BadgeKind::Error), + dot_badge_view(BadgeKind::Success), + dot_badge_view(BadgeKind::Warning), + dot_badge_view(BadgeKind::Error), + ]), + ); + + children.push(section_title("Carga")); + children.push( + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(48.0_f32), + }, + gap: Size { + width: length(16.0_f32), + height: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .children(vec![ + View::new(Style { + size: Size { + width: length(40.0_f32), + height: length(40.0_f32), + }, + ..Default::default() + }) + .children(vec![spinner_view(theme.accent, 0.12, 1.0)]), + View::new(Style { + size: Size { + width: length(40.0_f32), + height: length(40.0_f32), + }, + ..Default::default() + }) + .children(vec![radial_progress_view( + 0.66, + theme.bg_button, + theme.accent, + 0.14, + )]), + linear_progress_view(0.42, theme.bg_button, theme.accent, 8.0), + ]), + ); + + children.push(section_title("Skeleton")); + let palette = SkeletonPalette::from_theme(theme); + children.push(skeleton_line_view::(200.0, &palette)); + children.push(spacer_v(6.0)); + children.push(skeleton_line_view::(280.0, &palette)); + children.push(spacer_v(6.0)); + children.push(skeleton_line_view::(160.0, &palette)); + children.push(spacer_v(10.0)); + children.push(skeleton_box_view::(percent_to_px(0.9, 360.0), 60.0, &palette)); + + children.push(section_title("Cards")); + // Dos cards apilados: el primero con la firma (gradient sutil + + // hairline en el top), el segundo con `accent` lateral y fill plano. + // Para apreciar la firma hay que mirar de cerca: el ojo registra + // "tallado" sin saber por qué. + let card_palette = CardPalette::from_theme(theme); + children.push(card_view( + vec![ + text_line("Documento — multilienzo", 13.0, theme.fg_text), + text_line("3 cuerpos · 412 átomos · BLAKE3 verificado", 11.0, theme.fg_muted), + ], + CardOptions::with_signature(theme), + &card_palette, + )); + children.push(spacer_v(8.0)); + children.push(card_view( + vec![ + text_line("Build pasó — wawa-kernel", 13.0, theme.fg_text), + text_line("x86_64-unknown-none · 1.42s · 0 warnings", 11.0, theme.fg_muted), + ], + CardOptions { + accent: Some(Color::from_rgba8(110, 200, 130, 255)), + ..Default::default() + }, + &card_palette, + )); + + children.push(section_title("Menú contextual")); + children.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(32.0_f32), + }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + padding: Rect { + left: length(12.0_f32), + right: length(12.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_button) + .hover_fill(theme.bg_button_hover) + .radius(llimphi_theme::radius::SM) + .text_aligned( + "Mostrar menú".to_string(), + 12.0, + theme.fg_text, + Alignment::Center, + ) + .on_click(Msg::OpenContextMenu), + ); + + children.push(section_title("Iconografía")); + children.push(icon_grid(theme)); + + panel_view(children, theme) +} + +// --------------------------------------------------------------------- +// Helpers de composición +// --------------------------------------------------------------------- + +fn text_line(text: &str, size: f32, color: Color) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(size + 6.0), + }, + ..Default::default() + }) + .text_aligned(text.to_string(), size, color, Alignment::Start) +} + +fn section_title(text: &str) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(20.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned( + text.to_uppercase(), + 10.0, + Color::from_rgba8(140, 160, 200, 255), + Alignment::Start, + ) +} + +fn panel_view(children: Vec>, theme: &Theme) -> View { + let style = PanelStyle::from_theme(theme); + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + padding: Rect { + left: length(16.0_f32), + right: length(16.0_f32), + top: length(14.0_f32), + bottom: length(14.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(10.0_f32), + }, + ..Default::default() + }) + .paint_with(panel_signature_painter(style)) + .radius(style.radius) + .clip(true) + .children(children) +} + +fn switch_row(label: &str, value: bool, msg: Msg, theme: &Theme) -> View { + let progress = if value { 1.0 } else { 0.0 }; + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(28.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::SpaceBetween), + ..Default::default() + }) + .children(vec![ + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(label.to_string(), 12.0, theme.fg_text, Alignment::Start), + switch_view(progress, msg, &SwitchPalette::from_theme(theme)), + ]) +} + +fn fake_text_input(text: &str, theme: &Theme) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(28.0_f32), + }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(theme.bg_input) + .radius(llimphi_theme::radius::SM) + .text_aligned(text.to_string(), 12.0, theme.fg_text, Alignment::Start) +} + +fn button_row(theme: &Theme) -> View { + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(32.0_f32), + }, + gap: Size { + width: length(8.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![ + btn("Mostrar toast", theme.accent, theme.bg_app, Msg::PushToast), + btn("Abrir modal", theme.bg_button, theme.fg_text, Msg::OpenModal), + btn("Atajos (?)", theme.bg_button, theme.fg_text, Msg::ToggleShortcuts), + ]) +} + +fn btn(label: &str, bg: Color, fg: Color, msg: Msg) -> View { + let w = label.chars().count() as f32 * 7.5 + 24.0; + View::new(Style { + size: Size { + width: length(w), + height: length(32.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(bg) + .radius(llimphi_theme::radius::SM) + .text_aligned(label.to_string(), 12.0, fg, Alignment::Center) + .on_click(msg) +} + +fn icon_grid(theme: &Theme) -> View { + let icons = [ + Icon::File, Icon::Folder, Icon::Save, Icon::Open, Icon::Search, + Icon::Plus, Icon::Minus, Icon::X, Icon::Check, Icon::Edit, + Icon::Trash, Icon::Home, Icon::Settings, Icon::Bell, Icon::More, + Icon::Info, Icon::Warning, Icon::Error, Icon::ChevronUp, + Icon::ChevronDown, Icon::ChevronLeft, Icon::ChevronRight, + Icon::FolderOpen, + ]; + let cells: Vec> = icons + .iter() + .map(|i| { + View::new(Style { + size: Size { + width: length(28.0_f32), + height: length(28.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(theme.bg_panel_alt) + .radius(llimphi_theme::radius::XS) + .children(vec![icon_view(*i, theme.fg_text, 1.6)]) + }) + .collect(); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: auto(), + }, + gap: Size { + width: length(6.0_f32), + height: length(6.0_f32), + }, + flex_wrap: llimphi_ui::llimphi_layout::taffy::FlexWrap::Wrap, + ..Default::default() + }) + .children(cells) +} + +fn modal_body_view(theme: &Theme) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + ..Default::default() + }) + .text_aligned( + "Esta acción reescribirá la configuración local. \ + Sólo dura mientras no salgas — al guardar quedará persistida en disco." + .to_string(), + 12.0, + theme.fg_muted, + Alignment::Start, + ) +} + +fn wawa_frame(side: f32) -> View { + View::new(Style { + size: Size { + width: length(side), + height: length(side), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![wawa_mark_view(&WawaMarkPalette::default())]) +} + +fn spacer_v(h: f32) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(h), + }, + flex_shrink: 0.0, + ..Default::default() + }) +} + +fn percent_to_px(p: f32, base: f32) -> f32 { + p * base +} + +// Tooltip placeholder — la demo no instrumenta hover-to-show porque +// requeriría más Msgs; queda como código de referencia para apps reales. +#[allow(dead_code)] +fn demo_tooltip(viewport: (f32, f32), text: &str, theme: &Theme) -> View { + tooltip_view::(TooltipSpec { + anchor: (viewport.0 * 0.5, viewport.1 * 0.5), + viewport, + side: Side::Bottom, + text: text.to_string(), + palette: TooltipPalette::from_theme(theme), + }) +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/llimphi-gpu-bench/Cargo.toml b/llimphi-gpu-bench/Cargo.toml new file mode 100644 index 0000000..e189878 --- /dev/null +++ b/llimphi-gpu-bench/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "llimphi-gpu-bench" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "Binario standalone que valida el SDD §'GPU directo wgpu' en una máquina con GPU real: imprime info del adapter, corre vello vs GPU directo a varios N, evalúa el criterio (≥5× a 500K, ≥60 fps @ 1M) y exporta PNGs de verificación." + +[dependencies] +llimphi-hal = { path = "../llimphi-hal" } +llimphi-raster = { path = "../llimphi-raster" } +vello = { workspace = true } +pollster = { workspace = true } +png = { workspace = true } diff --git a/llimphi-gpu-bench/src/main.rs b/llimphi-gpu-bench/src/main.rs new file mode 100644 index 0000000..d00247e --- /dev/null +++ b/llimphi-gpu-bench/src/main.rs @@ -0,0 +1,941 @@ +//! `llimphi-gpu-bench` — binario standalone para validar el SDD +//! `02_ruway/llimphi/SDD.md` §"GPU directo wgpu" en una máquina con GPU +//! real. +//! +//! Hace cuatro cosas en orden y lo imprime todo a stdout en formato +//! markdown / tabla copy-paste friendly: +//! +//! 1. **Header del sistema** — versión, hora, OS, GPU detectado. +//! 2. **Info del adapter wgpu** — backend (Vulkan/Metal/DX12/GL), +//! device name, vendor, limits relevantes. +//! 3. **Spike vello vs GPU directo** — para N ∈ {25K, 50K, 100K, 200K, +//! 500K, 1M}. Mide ms/frame de cada uno y el factor. Evalúa el +//! criterio del SDD: ≥5× a 500K → PASA; < → ABORTAR. +//! 4. **Escalado GPU directo solo** — para N ∈ {100K, 500K, 1M, 2M, +//! 5M, 10M}. Mide ms/frame, fps equivalente, Mprim/s. Evalúa el +//! objetivo de 60 fps @ 1M. +//! 5. **PNGs de verificación visual** — exporta 2 archivos al cwd: +//! `bench_vello_100k.png` y `bench_directo_100k.png`. La forma del +//! cielo de puntos debe coincidir entre los dos (LCG determinista). +//! +//! Pegar el output completo en chat para la verificación. +//! +//! Corre con: `cargo run -p llimphi-gpu-bench --release`. + +use std::fs::File; +use std::io::{BufWriter, Write}; +use std::time::Instant; + +use llimphi_hal::{wgpu, Hal}; +use llimphi_raster::kurbo::{Affine, Rect}; +use llimphi_raster::peniko::{color::palette, Color, Fill}; +use llimphi_raster::{vello, GpuBatch, GpuPipelines}; + +const W: u32 = 1024; +const H: u32 = 1024; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +const WARMUP: usize = 5; +const MEASURED: usize = 15; + +const SPIKE_SIZES: &[u32] = &[25_000, 50_000, 100_000, 200_000, 500_000, 1_000_000]; +const SCALE_SIZES: &[u32] = &[100_000, 500_000, 1_000_000, 2_000_000, 5_000_000, 10_000_000]; + +/// Overrides via env vars (para correr en hosts limitados sin tumbar el +/// binario). En GPU real ignorarlos y dejar los defaults. +/// +/// - `LLIMPHI_BENCH_SPIKE_MAX=N` — recorta SPIKE_SIZES a los ≤ N. +/// - `LLIMPHI_BENCH_SCALE_MAX=N` — idem SCALE_SIZES. +/// - `LLIMPHI_BENCH_SKIP_VELLO=1` — saltea totalmente la columna vello +/// (útil si vello revienta con SIGSEGV en este host). +fn spike_sizes() -> Vec { + let max = std::env::var("LLIMPHI_BENCH_SPIKE_MAX") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(u32::MAX); + SPIKE_SIZES.iter().copied().filter(|&n| n <= max).collect() +} + +fn scale_sizes() -> Vec { + let max = std::env::var("LLIMPHI_BENCH_SCALE_MAX") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(u32::MAX); + SCALE_SIZES.iter().copied().filter(|&n| n <= max).collect() +} + +fn skip_vello() -> bool { + std::env::var("LLIMPHI_BENCH_SKIP_VELLO").ok().as_deref() == Some("1") +} + +fn main() { + print_header(); + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + print_adapter(&hal); + + let (target, view) = make_target(&hal.device); + + let pipelines = GpuPipelines::new(&hal.device, FMT); + let mut vello_renderer = vello::Renderer::new( + &hal.device, + vello::RendererOptions { + use_cpu: false, + antialiasing_support: vello::AaSupport { + area: true, + msaa8: false, + msaa16: false, + }, + num_init_threads: None, + pipeline_cache: None, + }, + ) + .expect("vello renderer"); + + println!("## Spike vello vs GPU directo"); + println!(); + println!("Target: {W}×{H} Rgba8Unorm, headless. Cada N corre {WARMUP} warmup + {MEASURED} medidos, reporta mediana."); + println!(); + println!("| N | vello ms | directo ms | factor | nota |"); + println!("|---:|---:|---:|---:|---|"); + let mut spike_rows: Vec = Vec::new(); + let skip_v = skip_vello(); + for n in spike_sizes() { + let row = bench_spike(&hal, &mut vello_renderer, &pipelines, &view, n, skip_v); + let note = if row.vello_crashed { + "vello SIGSEGV/error" + } else if let Some(f) = row.factor { + if f >= 5.0 { "≥5×" } else { "<5×" } + } else { + "-" + }; + let vello_str = if row.vello_crashed { + "—".to_string() + } else { + format!("{:.2}", row.vello_ms.unwrap_or(0.0)) + }; + let factor_str = match row.factor { + Some(f) => format!("{:.2}×", f), + None => "—".to_string(), + }; + println!( + "| {} | {} | {:.2} | {} | {} |", + fmt_int(n), + vello_str, + row.directo_ms, + factor_str, + note + ); + let _ = std::io::stdout().flush(); + spike_rows.push(row); + } + println!(); + print_spike_verdict(&spike_rows); + + println!("## Escalado GPU directo"); + println!(); + println!("API real (`GpuPipelines` + `GpuBatch::add_rect`). Sólo se mide el lado GPU directo — vello no llega acá."); + println!(); + println!("| N | ms / frame | fps (1000/ms) | Mprim/s |"); + println!("|---:|---:|---:|---:|"); + let mut scale_rows: Vec = Vec::new(); + for n in scale_sizes() { + let ms = bench_directo(&hal, &pipelines, &view, n); + let fps = 1000.0 / ms; + let mps = (n as f64 / 1_000_000.0) / (ms / 1000.0); + println!( + "| {} | {:.2} | {:.1} | {:.2} |", + fmt_int(n), + ms, + fps, + mps + ); + let _ = std::io::stdout().flush(); + scale_rows.push(ScaleRow { n, ms, fps, mps }); + } + println!(); + print_scale_verdict(&scale_rows); + + // ---------------------------------------------------------------- + // Variantes persistentes: el rebuild del batch/scene por frame es + // el peor caso. En apps reales (cosmos starfield Gaia, tinkuy + // particles iniciales, nakui viewport estático) los datos no + // cambian por frame — se uploadean UNA vez y el bucle solo redraw. + // Estos benches lo miden. + // ---------------------------------------------------------------- + println!("## Persistente — datos fijos, sólo redraw por frame"); + println!(); + println!("Setup (LCG + write_buffer / Scene fill) fuera de la medición; el bucle medido sólo emite render_pass + draw + submit + wait."); + println!(); + println!("### vello (Scene reutilizada sin reset)"); + println!(); + println!("| N | ms / frame | fps (1000/ms) |"); + println!("|---:|---:|---:|"); + let mut vello_persist_rows: Vec<(u32, f64)> = Vec::new(); + let skip_v = skip_vello(); + for n in scale_sizes() { + if skip_v { + println!("| {} | skipped | — |", fmt_int(n)); + continue; + } + let attempt = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + bench_vello_persistent(&hal, &mut vello_renderer, &view, n) + })); + match attempt { + Ok(ms) => { + let fps = 1000.0 / ms; + println!("| {} | {:.2} | {:.1} |", fmt_int(n), ms, fps); + let _ = std::io::stdout().flush(); + vello_persist_rows.push((n, ms)); + } + Err(_) => { + println!("| {} | crash | — |", fmt_int(n)); + } + } + } + println!(); + println!("### GPU directo (buffer + bind group persistentes)"); + println!(); + println!("| N | ms / frame | fps (1000/ms) | Mprim/s |"); + println!("|---:|---:|---:|---:|"); + let mut directo_persist_rows: Vec = Vec::new(); + for n in scale_sizes() { + let ms = bench_directo_persistent(&hal, &pipelines, &view, n); + let fps = 1000.0 / ms; + let mps = (n as f64 / 1_000_000.0) / (ms / 1000.0); + println!("| {} | {:.2} | {:.1} | {:.2} |", fmt_int(n), ms, fps, mps); + let _ = std::io::stdout().flush(); + directo_persist_rows.push(ScaleRow { n, ms, fps, mps }); + } + println!(); + print_persistent_verdict(&directo_persist_rows, &vello_persist_rows); + + println!("## Validación visual"); + println!(); + let png_vello = "bench_vello_100k.png"; + let png_directo = "bench_directo_100k.png"; + if let Err(e) = export_vello_png(&hal, &mut vello_renderer, &target, &view, 100_000, png_vello) + { + println!("vello PNG fallo: {e}"); + } else { + println!("- vello 100K → `{}` ({W}×{H})", png_vello); + } + if let Err(e) = + export_directo_png(&hal, &pipelines, &target, &view, 100_000, png_directo) + { + println!("directo PNG fallo: {e}"); + } else { + println!("- directo 100K → `{}` ({W}×{H})", png_directo); + } + println!(); + println!("Las dos imágenes deben mostrar la misma constelación de puntos (LCG determinista)."); + println!("Mirar en visor: si vello tiene halo AA suave y directo tiene pixeles hard-edged, todo bien."); + println!(); + + println!("## Resumen"); + println!(); + print_summary( + &spike_rows, + &scale_rows, + &directo_persist_rows, + &vello_persist_rows, + ); +} + +// ============================================================ +// IO / header +// ============================================================ + +fn print_header() { + println!("# llimphi-gpu-bench"); + println!(); + println!("Validación de Fase 0 del SDD `02_ruway/llimphi/SDD.md` §\"GPU directo wgpu\"."); + println!("Criterio: factor ≥ 5× a 500K Y ≥ 60 fps @ 1M en GPU mid (Radeon 5500M, Iris Xe)."); + println!(); + println!("- crate version: {}", env!("CARGO_PKG_VERSION")); + println!("- host OS: {}", std::env::consts::OS); + println!("- host arch: {}", std::env::consts::ARCH); + println!(); +} + +fn print_adapter(hal: &Hal) { + let info = hal.adapter.get_info(); + let limits = hal.adapter.limits(); + println!("## Adapter wgpu"); + println!(); + println!("- backend: `{:?}`", info.backend); + println!("- device name: `{}`", info.name); + println!("- vendor: `0x{:04x}`", info.vendor); + println!("- device id: `0x{:04x}`", info.device); + println!("- device type: `{:?}`", info.device_type); + println!("- driver: `{}`", info.driver); + println!("- driver info: `{}`", info.driver_info); + println!(); + println!("Limits relevantes:"); + println!(); + println!("- max texture 2D: {}", limits.max_texture_dimension_2d); + println!("- max buffer size: {} MB", limits.max_buffer_size / (1024 * 1024)); + println!("- max storage buffer binding: {} MB", limits.max_storage_buffer_binding_size / (1024 * 1024)); + println!(); + let is_software = matches!( + info.device_type, + wgpu::DeviceType::Cpu + ) || info.driver.to_lowercase().contains("llvmpipe") + || info.driver.to_lowercase().contains("software") + || info.name.to_lowercase().contains("llvmpipe") + || info.name.to_lowercase().contains("swiftshader"); + if is_software { + println!("⚠️ Adapter parece software (`{}`). Los números no reflejan GPU real.", info.name); + println!(); + } +} + +fn fmt_int(n: u32) -> String { + let s = n.to_string(); + let mut out = String::new(); + for (i, c) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + out.push('_'); + } + out.push(c); + } + out.chars().rev().collect() +} + +// ============================================================ +// Benches +// ============================================================ + +struct SpikeRow { + n: u32, + vello_ms: Option, + vello_crashed: bool, + directo_ms: f64, + factor: Option, +} + +struct ScaleRow { + n: u32, + ms: f64, + fps: f64, + mps: f64, +} + +fn bench_spike( + hal: &Hal, + vello_renderer: &mut vello::Renderer, + pipelines: &GpuPipelines, + view: &wgpu::TextureView, + n: u32, + skip_vello: bool, +) -> SpikeRow { + let directo_ms = bench_directo(hal, pipelines, view, n); + if skip_vello { + return SpikeRow { + n, + vello_ms: None, + vello_crashed: true, // tratamos "skipped" como "no llegó" + directo_ms, + factor: None, + }; + } + // catch_unwind sólo atrapa panics, no SIGSEGV. En vello pre-200K + // este path debería ser suficiente; si el binario muere igual, + // re-correr con `LLIMPHI_BENCH_SKIP_VELLO=1`. + let vello_attempt = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + bench_vello(hal, vello_renderer, view, n) + })); + match vello_attempt { + Ok(ms) => { + let factor = ms / directo_ms; + SpikeRow { + n, + vello_ms: Some(ms), + vello_crashed: false, + directo_ms, + factor: Some(factor), + } + } + Err(_) => SpikeRow { + n, + vello_ms: None, + vello_crashed: true, + directo_ms, + factor: None, + }, + } +} + +fn bench_vello( + hal: &Hal, + renderer: &mut vello::Renderer, + view: &wgpu::TextureView, + n: u32, +) -> f64 { + let mut scene = vello::Scene::new(); + let mut samples: Vec = Vec::with_capacity(MEASURED); + for frame in 0..(WARMUP + MEASURED) { + let t0 = Instant::now(); + scene.reset(); + let mut state: u32 = 0x1234_5678; + for _ in 0..n { + let (x, y, rgba) = lcg_point(&mut state); + let r = (rgba & 0xFF) as u8; + let g = ((rgba >> 8) & 0xFF) as u8; + let b = ((rgba >> 16) & 0xFF) as u8; + let a = ((rgba >> 24) & 0xFF) as u8; + let xf = x as f64; + let yf = y as f64; + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + Color::from_rgba8(r, g, b, a), + None, + &Rect::new(xf, yf, xf + POINT_PX as f64, yf + POINT_PX as f64), + ); + } + renderer + .render_to_texture( + &hal.device, + &hal.queue, + &scene, + view, + &vello::RenderParams { + base_color: palette::css::BLACK, + width: W, + height: H, + antialiasing_method: vello::AaConfig::Area, + }, + ) + .expect("vello render"); + hal.device.poll(wgpu::Maintain::Wait); + let dt = t0.elapsed().as_secs_f64() * 1000.0; + if frame >= WARMUP { + samples.push(dt); + } + } + median(&mut samples) +} + +fn bench_directo( + hal: &Hal, + pipelines: &GpuPipelines, + view: &wgpu::TextureView, + n: u32, +) -> f64 { + let mut samples: Vec = Vec::with_capacity(MEASURED); + for frame in 0..(WARMUP + MEASURED) { + let t0 = Instant::now(); + let mut batch = GpuBatch::new(pipelines); + let mut state: u32 = 0x1234_5678; + for _ in 0..n { + let (x, y, rgba) = lcg_point(&mut state); + let r = (rgba & 0xFF) as u8; + let g = ((rgba >> 8) & 0xFF) as u8; + let b = ((rgba >> 16) & 0xFF) as u8; + let a = ((rgba >> 24) & 0xFF) as u8; + batch.add_rect(x, y, POINT_PX, POINT_PX, Color::from_rgba8(r, g, b, a)); + } + let mut encoder = hal.device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { + label: Some("bench-directo-enc"), + }, + ); + batch.flush( + &hal.device, + &hal.queue, + &mut encoder, + view, + (W as f32, H as f32), + wgpu::LoadOp::Clear(wgpu::Color::BLACK), + ); + hal.queue.submit(std::iter::once(encoder.finish())); + hal.device.poll(wgpu::Maintain::Wait); + let dt = t0.elapsed().as_secs_f64() * 1000.0; + if frame >= WARMUP { + samples.push(dt); + } + } + median(&mut samples) +} + +/// Vello persistente: la Scene se construye UNA vez (fill N rects) y +/// el bucle medido sólo invoca `render_to_texture`. Sin `scene.reset()`. +fn bench_vello_persistent( + hal: &Hal, + renderer: &mut vello::Renderer, + view: &wgpu::TextureView, + n: u32, +) -> f64 { + let mut scene = vello::Scene::new(); + scene.reset(); + let mut state: u32 = 0x1234_5678; + for _ in 0..n { + let (x, y, rgba) = lcg_point(&mut state); + let r = (rgba & 0xFF) as u8; + let g = ((rgba >> 8) & 0xFF) as u8; + let b = ((rgba >> 16) & 0xFF) as u8; + let a = ((rgba >> 24) & 0xFF) as u8; + let xf = x as f64; + let yf = y as f64; + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + Color::from_rgba8(r, g, b, a), + None, + &Rect::new(xf, yf, xf + POINT_PX as f64, yf + POINT_PX as f64), + ); + } + let mut samples: Vec = Vec::with_capacity(MEASURED); + for frame in 0..(WARMUP + MEASURED) { + let t0 = Instant::now(); + renderer + .render_to_texture( + &hal.device, + &hal.queue, + &scene, + view, + &vello::RenderParams { + base_color: palette::css::BLACK, + width: W, + height: H, + antialiasing_method: vello::AaConfig::Area, + }, + ) + .expect("vello render"); + hal.device.poll(wgpu::Maintain::Wait); + let dt = t0.elapsed().as_secs_f64() * 1000.0; + if frame >= WARMUP { + samples.push(dt); + } + } + median(&mut samples) +} + +/// GPU directo persistente: instance buffer + uniform buffer + bind +/// group se construyen UNA vez. Bucle medido sólo abre render_pass, +/// hace `draw(0..6, 0..n)` y submit. +/// +/// Replica el layout que pinta `GpuBatch::add_rect` por debajo +/// (instance stride 20 B = [x:f32, y:f32, w:f32, h:f32, rgba:u32]), +/// usando el `rects` pipeline + `bind_layout` expuestos por +/// `GpuPipelines`. +fn bench_directo_persistent( + hal: &Hal, + pipelines: &GpuPipelines, + view: &wgpu::TextureView, + n: u32, +) -> f64 { + // Empaquetar instancias UNA vez. + let mut bytes = Vec::with_capacity(n as usize * 20); + let mut state: u32 = 0x1234_5678; + for _ in 0..n { + let (x, y, rgba) = lcg_point(&mut state); + bytes.extend_from_slice(&x.to_ne_bytes()); + bytes.extend_from_slice(&y.to_ne_bytes()); + bytes.extend_from_slice(&POINT_PX.to_ne_bytes()); + bytes.extend_from_slice(&POINT_PX.to_ne_bytes()); + bytes.extend_from_slice(&rgba.to_ne_bytes()); + } + let inst_buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("persist-rects"), + size: bytes.len() as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + hal.queue.write_buffer(&inst_buf, 0, &bytes); + + // Uniforms (viewport + line_width). + let u_data: [f32; 4] = [W as f32, H as f32, 1.0, 0.0]; + let mut u_bytes = Vec::with_capacity(16); + for v in u_data { + u_bytes.extend_from_slice(&v.to_ne_bytes()); + } + let uniforms = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("persist-uniforms"), + size: 16, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + hal.queue.write_buffer(&uniforms, 0, &u_bytes); + + let bind_group = hal.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("persist-bg"), + layout: &pipelines.bind_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniforms.as_entire_binding(), + }], + }); + + // Asegurar que toda la escritura previa esté en la GPU antes de + // empezar a medir frames — si no, el primer frame paga el upload. + hal.queue.submit(std::iter::empty::()); + hal.device.poll(wgpu::Maintain::Wait); + + let mut samples: Vec = Vec::with_capacity(MEASURED); + for frame in 0..(WARMUP + MEASURED) { + let t0 = Instant::now(); + let mut encoder = hal.device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { + label: Some("persist-enc"), + }, + ); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("persist-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + pass.set_pipeline(&pipelines.rects); + pass.set_bind_group(0, &bind_group, &[]); + pass.set_vertex_buffer(0, inst_buf.slice(..)); + pass.draw(0..6, 0..n); + } + hal.queue.submit(std::iter::once(encoder.finish())); + hal.device.poll(wgpu::Maintain::Wait); + let dt = t0.elapsed().as_secs_f64() * 1000.0; + if frame >= WARMUP { + samples.push(dt); + } + } + median(&mut samples) +} + +fn lcg_point(state: &mut u32) -> (f32, f32, u32) { + *state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let x = (*state % W) as f32; + *state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let y = (*state % H) as f32; + *state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + // Colores: piso 128 por canal para que las PNGs de verificación + // se vean (sin esto el LCG produce muchos negros casi puros, y + // los puntos quedan invisibles en pantalla aunque estén pintados). + let r = 128 | ((*state >> 0) & 0x7F) as u8; + let g = 128 | ((*state >> 8) & 0x7F) as u8; + let b = 128 | ((*state >> 16) & 0x7F) as u8; + let rgba = (r as u32) | ((g as u32) << 8) | ((b as u32) << 16) | 0xFF00_0000; + (x, y, rgba) +} + +const POINT_PX: f32 = 2.5; + +fn median(samples: &mut [f64]) -> f64 { + samples.sort_by(|a, b| a.partial_cmp(b).unwrap()); + samples[samples.len() / 2] +} + +// ============================================================ +// Veredictos +// ============================================================ + +fn print_spike_verdict(rows: &[SpikeRow]) { + let at_500k = rows.iter().find(|r| r.n == 500_000); + match at_500k { + Some(r) if r.vello_crashed => { + println!("**Veredicto Fase 0:** Vello revienta antes de 500K → directo es el único path posible en ese régimen. PASA cualitativo."); + } + Some(r) => match r.factor { + Some(f) if f >= 5.0 => { + println!("**Veredicto Fase 0:** factor a 500K = {:.2}× ≥ 5 → **PASA** (criterio SDD cumplido).", f); + } + Some(f) => { + println!("**Veredicto Fase 0:** factor a 500K = {:.2}× < 5 → **ABORTAR** según criterio literal del SDD.", f); + println!("Pero ver si vello revienta a tamaños mayores — eso cambia el veredicto cualitativamente."); + } + None => { + println!("**Veredicto Fase 0:** sin datos para 500K (vello crashed o N no medido). Revisar tabla arriba."); + } + }, + None => { + println!("**Veredicto Fase 0:** no se midió 500K en este run. Revisar tabla arriba."); + } + } + println!(); +} + +fn print_persistent_verdict( + directo: &[ScaleRow], + vello: &[(u32, f64)], +) { + let d_1m = directo.iter().find(|r| r.n == 1_000_000); + let v_1m = vello.iter().find(|(n, _)| *n == 1_000_000); + match d_1m { + Some(r) if r.fps >= 60.0 => { + println!( + "**Veredicto persistente @ 1M:** directo {:.1} fps ≥ 60 → **PASA**.", + r.fps + ); + } + Some(r) => { + println!( + "**Veredicto persistente @ 1M:** directo {:.1} fps < 60 → falla incluso sin rebuild.", + r.fps + ); + } + None => println!("**Veredicto:** sin datos a 1M."), + } + if let (Some(d), Some((_, v_ms))) = (d_1m, v_1m) { + let factor = v_ms / d.ms; + println!( + "**Factor persistente @ 1M:** vello {:.1} ms / directo {:.1} ms = {:.2}× ({})", + v_ms, + d.ms, + factor, + if factor >= 5.0 { "≥5×" } else { "<5×" } + ); + } + println!(); +} + +fn print_scale_verdict(rows: &[ScaleRow]) { + let at_1m = rows.iter().find(|r| r.n == 1_000_000); + match at_1m { + Some(r) if r.fps >= 60.0 => { + println!("**Veredicto Fase 0 (objetivo 60 fps @ 1M):** {:.1} fps ≥ 60 → **PASA**.", r.fps); + } + Some(r) => { + println!("**Veredicto Fase 0 (objetivo 60 fps @ 1M):** {:.1} fps < 60 → marginal. ¿Es CPU-bound el bench (write_buffer de 12-20 MB por frame)? Probar también con `mapped_at_creation` para sacar el camino más rápido.", r.fps); + } + None => { + println!("**Veredicto:** sin datos para 1M."); + } + } + println!(); +} + +fn print_summary( + spike: &[SpikeRow], + scale: &[ScaleRow], + persist_directo: &[ScaleRow], + persist_vello: &[(u32, f64)], +) { + println!("Copiar lo que sigue al chat:"); + println!(); + println!("```"); + println!("rebuild por frame — vello vs directo:"); + for r in spike { + let v = match (r.vello_crashed, r.vello_ms) { + (true, _) => "crash".to_string(), + (_, Some(ms)) => format!("{:.1}ms", ms), + _ => "-".to_string(), + }; + let f = r + .factor + .map(|x| format!("{:.2}x", x)) + .unwrap_or_else(|| "-".to_string()); + println!(" {:>10} vello={:>10} directo={:>7.1}ms factor={}", fmt_int(r.n), v, r.directo_ms, f); + } + println!(); + println!("rebuild por frame — escalado directo:"); + for r in scale { + println!(" {:>10} {:>7.1}ms {:>5.1}fps {:>5.2}Mprim/s", fmt_int(r.n), r.ms, r.fps, r.mps); + } + println!(); + println!("persistente (datos fijos, sólo redraw):"); + for r in persist_directo { + let v_ms = persist_vello + .iter() + .find(|(n, _)| *n == r.n) + .map(|(_, ms)| format!("{:>7.1}ms", ms)) + .unwrap_or_else(|| " —".to_string()); + let factor = persist_vello + .iter() + .find(|(n, _)| *n == r.n) + .map(|(_, vms)| format!("factor={:.2}x", vms / r.ms)) + .unwrap_or_else(|| "factor= — ".to_string()); + println!( + " {:>10} vello={} directo={:>7.1}ms {} {:>5.1}fps {:>5.2}Mprim/s", + fmt_int(r.n), + v_ms, + r.ms, + factor, + r.fps, + r.mps, + ); + } + println!("```"); +} + +// ============================================================ +// Textura destino + PNG export +// ============================================================ + +fn make_target(device: &wgpu::Device) -> (wgpu::Texture, wgpu::TextureView) { + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("bench-target"), + size: wgpu::Extent3d { + width: W, + height: H, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + // RENDER_ATTACHMENT para el directo, STORAGE_BINDING para vello, + // TEXTURE_BINDING + COPY_SRC para poder leer (PNG export). + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); + (tex, view) +} + +fn export_vello_png( + hal: &Hal, + renderer: &mut vello::Renderer, + target: &wgpu::Texture, + view: &wgpu::TextureView, + n: u32, + path: &str, +) -> Result<(), String> { + let mut scene = vello::Scene::new(); + let mut state: u32 = 0x1234_5678; + for _ in 0..n { + let (x, y, rgba) = lcg_point(&mut state); + let r = (rgba & 0xFF) as u8; + let g = ((rgba >> 8) & 0xFF) as u8; + let b = ((rgba >> 16) & 0xFF) as u8; + let a = ((rgba >> 24) & 0xFF) as u8; + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + Color::from_rgba8(r, g, b, a), + None, + &Rect::new(x as f64, y as f64, x as f64 + POINT_PX as f64, y as f64 + POINT_PX as f64), + ); + } + renderer + .render_to_texture( + &hal.device, + &hal.queue, + &scene, + view, + &vello::RenderParams { + base_color: palette::css::BLACK, + width: W, + height: H, + antialiasing_method: vello::AaConfig::Area, + }, + ) + .map_err(|e| format!("{e:?}"))?; + write_texture_png(hal, target, path) +} + +fn export_directo_png( + hal: &Hal, + pipelines: &GpuPipelines, + target: &wgpu::Texture, + view: &wgpu::TextureView, + n: u32, + path: &str, +) -> Result<(), String> { + let mut batch = GpuBatch::new(pipelines); + let mut state: u32 = 0x1234_5678; + for _ in 0..n { + let (x, y, rgba) = lcg_point(&mut state); + let r = (rgba & 0xFF) as u8; + let g = ((rgba >> 8) & 0xFF) as u8; + let b = ((rgba >> 16) & 0xFF) as u8; + let a = ((rgba >> 24) & 0xFF) as u8; + batch.add_rect(x, y, POINT_PX, POINT_PX, Color::from_rgba8(r, g, b, a)); + } + let mut encoder = hal.device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { + label: Some("png-directo-enc"), + }, + ); + batch.flush( + &hal.device, + &hal.queue, + &mut encoder, + view, + (W as f32, H as f32), + wgpu::LoadOp::Clear(wgpu::Color::BLACK), + ); + hal.queue.submit(std::iter::once(encoder.finish())); + hal.device.poll(wgpu::Maintain::Wait); + write_texture_png(hal, target, path) +} + +/// Copia la textura a un buffer mapeable + lee + escribe PNG. +fn write_texture_png(hal: &Hal, target: &wgpu::Texture, path: &str) -> Result<(), String> { + // wgpu pide stride alineado a 256 B en COPY_TEXTURE_TO_BUFFER. + let unpadded = (W * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = ((unpadded + align - 1) / align) * align; + let buf_size = (padded * H as usize) as u64; + + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("png-readback"), + size: buf_size, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut encoder = hal.device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { + label: Some("png-copy-enc"), + }, + ); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { + width: W, + height: H, + depth_or_array_layers: 1, + }, + ); + hal.queue.submit(std::iter::once(encoder.finish())); + + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + hal.device.poll(wgpu::Maintain::Wait); + rx.recv().map_err(|e| e.to_string())?.map_err(|e| e.to_string())?; + let data = slice.get_mapped_range(); + + // Desempaquetar las filas (skip padding) y escribir PNG. + let mut pixels = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H { + let start = row as usize * padded; + let end = start + unpadded; + pixels.extend_from_slice(&data[start..end]); + } + drop(data); + buf.unmap(); + + let file = File::create(path).map_err(|e| e.to_string())?; + let writer = BufWriter::new(file); + let mut encoder = png::Encoder::new(writer, W, H); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut w = encoder.write_header().map_err(|e| e.to_string())?; + w.write_image_data(&pixels).map_err(|e| e.to_string())?; + Ok(()) +} diff --git a/llimphi-hal/Cargo.toml b/llimphi-hal/Cargo.toml new file mode 100644 index 0000000..f9288fd --- /dev/null +++ b/llimphi-hal/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "llimphi-hal" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true + +[dependencies] +wgpu = { workspace = true } +raw-window-handle = { workspace = true } +winit = { workspace = true } +pollster = { workspace = true } + +[[example]] +name = "clear_screen" +path = "examples/clear_screen.rs" diff --git a/llimphi-hal/LEEME.md b/llimphi-hal/LEEME.md new file mode 100644 index 0000000..6f0bb0a --- /dev/null +++ b/llimphi-hal/LEEME.md @@ -0,0 +1,10 @@ +# llimphi-hal + +> Abstracción de superficie de [llimphi](../README.md). Multi-plataforma. + +Trait `Surface` que abstrae window/framebuffer/canvas. Implementaciones: `winit` (Linux/macOS/Windows desktop), `android` (NDK), `wawa` (framebuffer del kernel). El resto del stack llimphi habla `Surface`; mover Wayland → Wawa es cambiar el HAL, no el árbol gráfico. + +## Deps + +- `winit`, `raw-window-handle` +- `serde`, `wgpu` (re-export para que widgets puedan paint_with) diff --git a/llimphi-hal/README.md b/llimphi-hal/README.md new file mode 100644 index 0000000..85b907b --- /dev/null +++ b/llimphi-hal/README.md @@ -0,0 +1,10 @@ +# llimphi-hal + +> Surface abstraction of [llimphi](../README.md). Multi-platform. + +`Surface` trait that abstracts window/framebuffer/canvas. Implementations: `winit` (Linux/macOS/Windows desktop), `android` (NDK), `wawa` (kernel framebuffer). The rest of the llimphi stack talks to `Surface`; moving Wayland → Wawa is swapping the HAL, not the scene tree. + +## Deps + +- `winit`, `raw-window-handle` +- `serde`, `wgpu` (re-export so widgets can paint_with) diff --git a/llimphi-hal/examples/clear_screen.rs b/llimphi-hal/examples/clear_screen.rs new file mode 100644 index 0000000..0b1233c --- /dev/null +++ b/llimphi-hal/examples/clear_screen.rs @@ -0,0 +1,135 @@ +//! Fase 1 de Llimphi: ventana gris plomo a la frecuencia máxima del display. +//! +//! Corre con: `cargo run -p llimphi-hal --example clear_screen --release`. +//! +//! Imprime fps por stderr cada segundo. En un panel de 144 Hz con AutoVsync +//! debe estabilizarse cerca de 144; en uno de 60 Hz, cerca de 60. + +use std::sync::Arc; +use std::time::Instant; + +use llimphi_hal::winit::application::ApplicationHandler; +use llimphi_hal::winit::dpi::LogicalSize; +use llimphi_hal::winit::event::WindowEvent; +use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId}; +use llimphi_hal::{wgpu, Hal, Surface, WinitSurface}; + +const LEAD_GRAY: wgpu::Color = wgpu::Color { + r: 0.235, + g: 0.239, + b: 0.247, + a: 1.0, +}; + +struct State { + window: Arc, + hal: Hal, + surface: WinitSurface, +} + +struct App { + state: Option, + frames: u64, + last_report: Instant, +} + +impl ApplicationHandler for App { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.state.is_some() { + return; + } + let window = event_loop + .create_window( + WindowAttributes::default() + .with_title("llimphi · clear_screen") + .with_inner_size(LogicalSize::new(960u32, 540u32)), + ) + .expect("create window"); + let window = Arc::new(window); + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let surface = WinitSurface::new(&hal, window.clone()).expect("surface"); + window.request_redraw(); + self.state = Some(State { + window, + hal, + surface, + }); + } + + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + _id: WindowId, + event: WindowEvent, + ) { + let Some(state) = self.state.as_mut() else { + return; + }; + match event { + WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::Resized(size) => { + state.surface.resize(size.width, size.height); + state.window.request_redraw(); + } + WindowEvent::RedrawRequested => { + let frame = match state.surface.acquire() { + Ok(f) => f, + Err(_) => { + let (w, h) = state.surface.size(); + state.surface.resize(w, h); + state.window.request_redraw(); + return; + } + }; + let mut encoder = + state + .hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("clear_screen-encoder"), + }); + { + let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("clear_screen-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: frame.view(), + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(LEAD_GRAY), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + } + state.hal.queue.submit(std::iter::once(encoder.finish())); + state.surface.present(frame, &state.hal); + + self.frames += 1; + let elapsed = self.last_report.elapsed(); + if elapsed.as_secs() >= 1 { + let fps = self.frames as f64 / elapsed.as_secs_f64(); + eprintln!("llimphi · clear_screen — {fps:.1} fps"); + self.frames = 0; + self.last_report = Instant::now(); + } + state.window.request_redraw(); + } + _ => {} + } + } +} + +fn main() { + let event_loop = EventLoop::new().expect("event loop"); + event_loop.set_control_flow(ControlFlow::Poll); + let mut app = App { + state: None, + frames: 0, + last_report: Instant::now(), + }; + event_loop.run_app(&mut app).expect("run app"); +} diff --git a/llimphi-hal/src/lib.rs b/llimphi-hal/src/lib.rs new file mode 100644 index 0000000..36822db --- /dev/null +++ b/llimphi-hal/src/lib.rs @@ -0,0 +1,823 @@ +//! llimphi-hal — Puente al Silicio. +//! +//! Aísla el motor del sistema operativo. Pinta en ventana Wayland/X11 +//! (vía `mirada` en producción, vía `winit` en dev) o framebuffer directo +//! del kernel `wawa` (TODO). Trait `Surface` abstracto + struct `Hal` +//! que posee Instance/Adapter/Device/Queue de wgpu. + +use std::sync::Arc; + +pub use raw_window_handle; +pub use wgpu; +pub use winit; + +use winit::window::Window; + +/// Errores al adquirir un frame de la superficie. +#[derive(Debug)] +pub enum SurfaceError { + Lost, + Outdated, + OutOfMemory, + Timeout, + Other(String), +} + +impl std::fmt::Display for SurfaceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Lost => write!(f, "surface lost"), + Self::Outdated => write!(f, "surface outdated"), + Self::OutOfMemory => write!(f, "surface out of memory"), + Self::Timeout => write!(f, "surface timeout"), + Self::Other(s) => write!(f, "surface error: {s}"), + } + } +} + +impl std::error::Error for SurfaceError {} + +/// Errores al construir Hal o crear una Surface. +#[derive(Debug)] +pub enum HalError { + NoAdapter, + RequestDevice(String), + CreateSurface(String), +} + +impl std::fmt::Display for HalError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoAdapter => write!(f, "no GPU adapter available"), + Self::RequestDevice(s) => write!(f, "request_device failed: {s}"), + Self::CreateSurface(s) => write!(f, "create_surface failed: {s}"), + } + } +} + +impl std::error::Error for HalError {} + +/// Superficie gráfica donde llimphi pinta. +/// +/// Vello (rasterizador) emite a una textura intermedia con storage binding +/// (la única forma portable: los formatos de swapchain no aceptan writes +/// de compute shader en muchos adapters). En `present` se blittea la +/// intermedia al swapchain real y se hace el flip. +/// +/// Implementaciones: +/// - [`WinitSurface`]: ventana Wayland/X11 (dev + producción vía mirada). +/// - `WawaFramebufferSurface` (TODO): framebuffer directo del kernel wawa. +pub trait Surface { + fn size(&self) -> (u32, u32); + fn resize(&mut self, width: u32, height: u32); + /// Adquiere la textura intermedia donde el raster pinta este frame. + fn acquire(&mut self) -> Result; + /// Blittea la intermedia al swapchain y la presenta. + fn present(&mut self, frame: Frame, hal: &Hal); +} + +/// Frame en curso. `view()` devuelve la textura intermedia (Rgba8Unorm, +/// STORAGE_BINDING) lista para que vello escriba sobre ella. +pub struct Frame { + surface_texture: wgpu::SurfaceTexture, + surface_view: wgpu::TextureView, + intermediate_view: wgpu::TextureView, + /// Textura secundaria para la capa de overlay (menús/paleta/modal) + /// cuando hay contenido `gpu_paint` que la taparía. El overlay se + /// rasteriza acá con fondo transparente y luego se compone con + /// alpha SOBRE la intermedia (que ya tiene UI + video). Ver + /// [`OverlayCompositor`] y el eventloop de `llimphi-ui`. + overlay_view: wgpu::TextureView, + width: u32, + height: u32, +} + +impl Frame { + pub fn view(&self) -> &wgpu::TextureView { + &self.intermediate_view + } + + /// Vista de la textura de overlay (mismo tamaño y formato que la + /// intermedia). Sólo se usa en el camino de compositing del overlay. + pub fn overlay_view(&self) -> &wgpu::TextureView { + &self.overlay_view + } + + pub fn size(&self) -> (u32, u32) { + (self.width, self.height) + } +} + +/// Estado wgpu compartido. Una instancia por proceso. `Device` y `Queue` +/// son `Arc` internamente, así que clonar es barato. +pub struct Hal { + pub instance: wgpu::Instance, + pub adapter: wgpu::Adapter, + pub device: wgpu::Device, + pub queue: wgpu::Queue, +} + +impl Hal { + /// Construye Hal pidiendo un adapter compatible con una surface dada + /// (recomendado: pasar `Some(&surface)` para garantizar que el adapter + /// elegido sabe presentar a esa surface). + pub async fn new( + compatible_surface: Option<&wgpu::Surface<'static>>, + ) -> Result { + let opts = wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, + compatible_surface, + }; + // Preferimos backends PRIMARY (Vulkan/Metal/DX12). El backend GL de + // wgpu sobre Mesa/Wayland tiene un bug de teardown: al soltar la + // instancia, `eglTerminate` marshalea sobre una conexión Wayland ya + // muerta (`wl_proxy_marshal`) y revienta con SIGSEGV. Con + // `Backends::all()` (el default), wgpu puede elegir GL aun habiendo + // Vulkan, y la app crashea al cerrar/teardown. Forzamos PRIMARY; si la + // máquina no tiene Vulkan/Metal/DX12 (VM vieja, etc.) caemos a todos + // los backends —incluido GL— para no dejarla sin gráficos. En el + // camino de escritorio `compatible_surface` es `None` (la surface se + // crea después contra esta misma instancia), así que cambiar de + // instancia aquí es seguro. + let primary = wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends: wgpu::Backends::PRIMARY, + ..Default::default() + }); + let (instance, adapter) = match primary.request_adapter(&opts).await { + Some(a) => (primary, a), + None => { + let all = wgpu::Instance::new(&wgpu::InstanceDescriptor::default()); + let a = all.request_adapter(&opts).await.ok_or(HalError::NoAdapter)?; + (all, a) + } + }; + // `Limits::default()` cubre los 5 storage buffers/stage que vello + // necesita. `downlevel_defaults()` solo expone 4 y rompe el raster. + // Si el adapter no lo aguanta, `using_resolution` recorta lo recortable + // (texturas/buffers grandes) preservando los conteos mínimos. + let limits = wgpu::Limits::default().using_resolution(adapter.limits()); + let (device, queue) = adapter + .request_device( + &wgpu::DeviceDescriptor { + label: Some("llimphi-hal-device"), + required_features: wgpu::Features::empty(), + required_limits: limits, + memory_hints: wgpu::MemoryHints::Performance, + }, + None, + ) + .await + .map_err(|e| HalError::RequestDevice(e.to_string()))?; + Ok(Self { + instance, + adapter, + device, + queue, + }) + } + + /// Construye el `Hal` **y** una [`RawSurface`] a la vez, eligiendo el adaptador + /// **compatible con esa surface** — el dispositivo que el compositor sabe + /// presentar. Es el camino correcto para el backend layer-shell de `pata`. + /// + /// El problema que resuelve: en sistemas multi-GPU (Optimus), pedir el + /// adaptador sin pista de surface (`new(None)` con `HighPerformance`) puede + /// elegir la dGPU mientras el compositor compone en la iGPU → los dmabuf + /// cruzan dispositivos y `get_capabilities` devuelve 0 formatos (la surface + /// "no expone formatos"). Pasar `compatible_surface` ata el adaptador al + /// dispositivo del compositor. Como la surface hace falta ANTES de pedir el + /// adaptador, y `new` crea la instancia internamente, este constructor une los + /// dos pasos. + /// + /// `make_target` reconstruye el `SurfaceTargetUnsafe` cada vez que se llama + /// (los `RawHandle` son `Copy`): `create_surface_unsafe` consume el target y + /// puede que probemos dos instancias (PRIMARY y, si no hay adaptador, todos + /// los backends — el GL de Mesa/Wayland revienta en teardown, por eso PRIMARY + /// primero, igual que [`Hal::new`]). + /// + /// # Safety + /// Los handles que produce `make_target` deben apuntar a objetos Wayland/… + /// vivos durante toda la vida de la `RawSurface` devuelta. + pub async unsafe fn new_for_raw_surface( + make_target: impl Fn() -> wgpu::SurfaceTargetUnsafe, + width: u32, + height: u32, + ) -> Result<(Self, RawSurface), HalError> { + // PRIMARY (Vulkan/Metal/DX12) primero; si no hay adaptador compatible, a + // todos los backends recreando instancia y surface. + let primary = wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends: wgpu::Backends::PRIMARY, + ..Default::default() + }); + let prim_surface = unsafe { primary.create_surface_unsafe(make_target()) } + .map_err(|e| HalError::CreateSurface(e.to_string()))?; + let prim_adapter = primary + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, + compatible_surface: Some(&prim_surface), + }) + .await; + let (instance, adapter, wgpu_surface) = match prim_adapter { + Some(a) => (primary, a, prim_surface), + None => { + let all = wgpu::Instance::new(&wgpu::InstanceDescriptor::default()); + let surface = unsafe { all.create_surface_unsafe(make_target()) } + .map_err(|e| HalError::CreateSurface(e.to_string()))?; + let a = all + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, + compatible_surface: Some(&surface), + }) + .await + .ok_or(HalError::NoAdapter)?; + (all, a, surface) + } + }; + let limits = wgpu::Limits::default().using_resolution(adapter.limits()); + let (device, queue) = adapter + .request_device( + &wgpu::DeviceDescriptor { + label: Some("llimphi-hal-device"), + required_features: wgpu::Features::empty(), + required_limits: limits, + memory_hints: wgpu::MemoryHints::Performance, + }, + None, + ) + .await + .map_err(|e| HalError::RequestDevice(e.to_string()))?; + let hal = Self { + instance, + adapter, + device, + queue, + }; + let surface = RawSurface::from_surface(&hal, wgpu_surface, width, height)?; + Ok((hal, surface)) + } +} + +/// Surface basada en `winit::window::Window`. Mantiene una textura +/// intermedia `Rgba8Unorm` con storage binding (donde pinta vello) y +/// un `TextureBlitter` que la copia al swapchain al presentar. +pub struct WinitSurface { + _window: Arc, + surface: wgpu::Surface<'static>, + config: wgpu::SurfaceConfiguration, + device: wgpu::Device, + intermediate: wgpu::Texture, + intermediate_view: wgpu::TextureView, + /// Textura de la capa de overlay (ver [`Frame::overlay_view`]). + overlay: wgpu::Texture, + overlay_view: wgpu::TextureView, + blitter: wgpu::util::TextureBlitter, +} + +const INTERMEDIATE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +impl WinitSurface { + /// Constructor "feliz": crea la `wgpu::Surface` internamente. + /// Conveniente en desktop donde la secuencia normal es + /// `Hal::new(None)` → `WinitSurface::new(hal, window)`. **En Android + /// usar [`WinitSurface::from_surface`]** — allí la surface debe + /// existir antes del `request_adapter(compatible_surface=Some(...))`, + /// y crearla dos veces sobre la misma `ANativeWindow` falla con + /// `ERROR_NATIVE_WINDOW_IN_USE_KHR`. + pub fn new(hal: &Hal, window: Arc) -> Result { + let surface = hal + .instance + .create_surface(window.clone()) + .map_err(|e| HalError::CreateSurface(e.to_string()))?; + Self::from_surface(hal, window, surface) + } + + /// Constructor reutilizable: arma el `WinitSurface` envolviendo una + /// `wgpu::Surface` ya creada por el caller. Necesario en Android + /// porque el orden allí es: + /// + /// 1. `instance.create_surface(window)` + /// 2. `instance.request_adapter(compatible_surface=Some(&surface))` + /// 3. `adapter.request_device(...)` + /// 4. `WinitSurface::from_surface(hal, window, surface)` + /// + /// — no se puede dropear la surface entre 2 y 4 ni recrearla, porque + /// Android reserva la `ANativeWindow` por VkSurface y rechaza un + /// segundo `vkCreateAndroidSurfaceKHR` sobre la misma ventana. + pub fn from_surface( + hal: &Hal, + window: Arc, + surface: wgpu::Surface<'static>, + ) -> Result { + let size = window.inner_size(); + let caps = surface.get_capabilities(&hal.adapter); + // Preferimos Bgra8Unorm o Rgba8Unorm (no sRGB) para que el blit + // desde la intermedia lineal preserve los valores tal cual. + let format = caps + .formats + .iter() + .copied() + .find(|f| matches!(f, wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm)) + .unwrap_or(caps.formats[0]); + let config = wgpu::SurfaceConfiguration { + // El swapchain solo necesita render-attachment: vello no escribe + // directo, escribe a la intermedia y luego se blittea. + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format, + width: size.width.max(1), + height: size.height.max(1), + present_mode: choose_present_mode(&caps), + desired_maximum_frame_latency: 2, + alpha_mode: caps.alpha_modes[0], + view_formats: vec![], + }; + surface.configure(&hal.device, &config); + let (intermediate, intermediate_view) = + create_intermediate(&hal.device, config.width, config.height); + let (overlay, overlay_view) = + create_intermediate(&hal.device, config.width, config.height); + let blitter = wgpu::util::TextureBlitter::new(&hal.device, format); + Ok(Self { + _window: window, + surface, + config, + device: hal.device.clone(), + intermediate, + intermediate_view, + overlay, + overlay_view, + blitter, + }) + } + + pub fn format(&self) -> wgpu::TextureFormat { + self.config.format + } +} + +/// Surface sobre una `wgpu::Surface` creada desde **handles raw** (sin +/// `winit::Window`): la usa el backend `wlr-layer-shell` de `pata` para pintar +/// en una *layer surface* de Wayland (barras/paneles al nivel de eww/waybar). +/// Misma mecánica que [`WinitSurface`] —intermedia `Rgba8Unorm` + blit al +/// swapchain— pero el tamaño se pasa explícito porque no hay ventana que +/// consultar. La `wgpu::Surface` la crea el caller (típicamente con +/// `instance.create_surface_unsafe` desde los punteros `wl_display`/`wl_surface`). +pub struct RawSurface { + surface: wgpu::Surface<'static>, + config: wgpu::SurfaceConfiguration, + device: wgpu::Device, + intermediate: wgpu::Texture, + intermediate_view: wgpu::TextureView, + overlay: wgpu::Texture, + overlay_view: wgpu::TextureView, + blitter: wgpu::util::TextureBlitter, +} + +impl RawSurface { + /// Envuelve una `wgpu::Surface` ya creada, con el tamaño físico inicial. + pub fn from_surface( + hal: &Hal, + surface: wgpu::Surface<'static>, + width: u32, + height: u32, + ) -> Result { + let caps = surface.get_capabilities(&hal.adapter); + let info = hal.adapter.get_info(); + // Si la superficie no expone formatos, el compositor no la soporta por + // este backend (Vulkan/GL WSI): error claro en vez de un panic por + // indexar `formats[0]` sobre una lista vacía. + let format = match caps + .formats + .iter() + .copied() + .find(|f| matches!(f, wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm)) + .or_else(|| caps.formats.first().copied()) + { + Some(f) => f, + None => { + return Err(HalError::CreateSurface(format!( + "la superficie no expone formatos (adapter {:?}/{:?}): el compositor no la soporta por {:?} WSI", + info.backend, info.device_type, info.backend + ))) + } + }; + let alpha_mode = caps + .alpha_modes + .first() + .copied() + .unwrap_or(wgpu::CompositeAlphaMode::Auto); + let config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format, + width: width.max(1), + height: height.max(1), + present_mode: choose_present_mode(&caps), + desired_maximum_frame_latency: 2, + alpha_mode, + view_formats: vec![], + }; + surface.configure(&hal.device, &config); + let (intermediate, intermediate_view) = + create_intermediate(&hal.device, config.width, config.height); + let (overlay, overlay_view) = + create_intermediate(&hal.device, config.width, config.height); + let blitter = wgpu::util::TextureBlitter::new(&hal.device, format); + Ok(Self { + surface, + config, + device: hal.device.clone(), + intermediate, + intermediate_view, + overlay, + overlay_view, + blitter, + }) + } + + pub fn format(&self) -> wgpu::TextureFormat { + self.config.format + } +} + +impl Surface for RawSurface { + fn size(&self) -> (u32, u32) { + (self.config.width, self.config.height) + } + + fn resize(&mut self, width: u32, height: u32) { + let (w, h) = (width.max(1), height.max(1)); + // Sin cambio de tamaño NO reconfiguramos. El backend layer-shell de `pata` + // llama a `resize` en cada cuadro (no tiene eventos de resize como winit); + // reconfigurar el swapchain por cuadro lo reconstruye una y otra vez, y en + // Vulkan WSI eso **destruye el `wl_buffer` recién presentado antes de que el + // compositor lo componga** — wlroots lo tolera, smithay (mirada) no, y la + // superficie queda en negro (el compositor ve `buffer=None`). + if self.config.width == w && self.config.height == h { + return; + } + self.config.width = w; + self.config.height = h; + self.surface.configure(&self.device, &self.config); + let (tex, view) = create_intermediate(&self.device, self.config.width, self.config.height); + self.intermediate = tex; + self.intermediate_view = view; + let (otex, oview) = + create_intermediate(&self.device, self.config.width, self.config.height); + self.overlay = otex; + self.overlay_view = oview; + } + + fn acquire(&mut self) -> Result { + let texture = match self.surface.get_current_texture() { + Ok(t) => t, + // El backend layer-shell no tiene un evento de resize que reconfigure + // el swapchain; si quedó obsoleto/perdido, lo reconstruimos aquí mismo + // y reintentamos una vez. Sin esto el panel quedaría en negro para + // siempre tras el primer `Outdated`. + Err(e @ (wgpu::SurfaceError::Outdated | wgpu::SurfaceError::Lost)) => { + self.surface.configure(&self.device, &self.config); + self.surface.get_current_texture().map_err(|_| match e { + wgpu::SurfaceError::Lost => SurfaceError::Lost, + _ => SurfaceError::Outdated, + })? + } + Err(wgpu::SurfaceError::OutOfMemory) => return Err(SurfaceError::OutOfMemory), + Err(wgpu::SurfaceError::Timeout) => return Err(SurfaceError::Timeout), + Err(other) => return Err(SurfaceError::Other(format!("{other:?}"))), + }; + let surface_view = texture + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + Ok(Frame { + surface_texture: texture, + surface_view, + intermediate_view: self.intermediate_view.clone(), + overlay_view: self.overlay_view.clone(), + width: self.config.width, + height: self.config.height, + }) + } + + fn present(&mut self, frame: Frame, hal: &Hal) { + let mut encoder = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("llimphi-blit-raw"), + }); + self.blitter.copy( + &hal.device, + &mut encoder, + &frame.intermediate_view, + &frame.surface_view, + ); + hal.queue.submit(std::iter::once(encoder.finish())); + frame.surface_texture.present(); + } +} + +/// Elige el modo de presentación del swapchain. +/// +/// Default: **Mailbox** si el driver lo expone, sino **Fifo**. La razón es +/// el cuelgue observado en las apps Llimphi (investigación 2026-05-30): con +/// `Fifo`/`AutoVsync`, `surface.get_current_texture()` **bloquea** esperando +/// el frame-callback del compositor Wayland — si el compositor no suelta un +/// buffer, el hilo del UI queda dormido (CPU baja, deadlock aparente). +/// `Mailbox` no bloquea (triple-buffer, descarta frames viejos), así que el +/// loop nunca se queda esperando al compositor. `Fifo` está garantizado por +/// spec como fallback. +/// +/// Override por entorno para A/B sin recompilar (útil en la laptop con +/// display real): `LLIMPHI_PRESENT_MODE = fifo | mailbox | immediate | +/// fifo_relaxed`. Si el modo pedido no está soportado, se ignora y se aplica +/// el default. +fn choose_present_mode(caps: &wgpu::SurfaceCapabilities) -> wgpu::PresentMode { + use wgpu::PresentMode::{Fifo, FifoRelaxed, Immediate, Mailbox}; + if let Ok(v) = std::env::var("LLIMPHI_PRESENT_MODE") { + let want = match v.trim().to_ascii_lowercase().as_str() { + "fifo" | "vsync" => Some(Fifo), + "fifo_relaxed" | "fiforelaxed" => Some(FifoRelaxed), + "mailbox" => Some(Mailbox), + "immediate" | "novsync" => Some(Immediate), + _ => None, + }; + if let Some(m) = want { + if caps.present_modes.contains(&m) { + return m; + } + } + } + if caps.present_modes.contains(&Mailbox) { + Mailbox + } else { + Fifo + } +} + +fn create_intermediate( + device: &wgpu::Device, + width: u32, + height: u32, +) -> (wgpu::Texture, wgpu::TextureView) { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("llimphi-intermediate"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: INTERMEDIATE_FORMAT, + // STORAGE_BINDING: vello escribe via compute shader. + // TEXTURE_BINDING: el blitter la lee como sampler source. + // RENDER_ATTACHMENT: render passes con clear-only (sin vello) + // también escriben acá — desktop drivers lo tolerían sin este + // flag, Adreno con validación estricta rechaza el frame. + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + (texture, view) +} + +/// Compositor de la capa de overlay: alpha-blittea una textura source (el +/// overlay rasterizado por vello sobre fondo transparente) SOBRE una textura +/// target (la intermedia, que ya tiene la UI principal + el video pintado por +/// `gpu_paint`). Resuelve el z-order: sin esto, el blit de `gpu_paint` (video) +/// queda encima de la capa vello del overlay y los menús se ven por debajo del +/// video. +/// +/// Es un pase de pantalla completa (triángulo) que samplea el source y lo +/// emite con alpha-over. El factor de blend asume alpha **premultiplicado** +/// (lo que produce vello); si en pantalla los menús se ven con halos oscuros o +/// transparencia rara, exportar `LLIMPHI_OVERLAY_BLEND=straight` para usar +/// alpha recto sin recompilar. +pub struct OverlayCompositor { + pipeline: wgpu::RenderPipeline, + sampler: wgpu::Sampler, + bind_layout: wgpu::BindGroupLayout, +} + +impl OverlayCompositor { + pub fn new(device: &wgpu::Device) -> Self { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("llimphi-overlay-composite"), + source: wgpu::ShaderSource::Wgsl(OVERLAY_COMPOSITE_WGSL.into()), + }); + let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("llimphi-overlay-bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("llimphi-overlay-pl"), + bind_group_layouts: &[&bind_layout], + push_constant_ranges: &[], + }); + // Alpha-over. `src_factor` distingue premultiplicado (One) de recto + // (SrcAlpha); el resto es siempre OneMinusSrcAlpha. + let straight = std::env::var("LLIMPHI_OVERLAY_BLEND") + .map(|v| v.trim().eq_ignore_ascii_case("straight")) + .unwrap_or(false); + let color_src = if straight { + wgpu::BlendFactor::SrcAlpha + } else { + wgpu::BlendFactor::One + }; + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("llimphi-overlay-pipe"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs"), + targets: &[Some(wgpu::ColorTargetState { + format: INTERMEDIATE_FORMAT, + blend: Some(wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: color_src, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + }), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("llimphi-overlay-sampler"), + ..Default::default() + }); + OverlayCompositor { + pipeline, + sampler, + bind_layout, + } + } + + /// Compone `source` (overlay con fondo transparente) sobre `target` (la + /// intermedia), preservando el contenido previo del target (LoadOp::Load) + /// y mezclando con alpha. Graba un render pass en `encoder`. + pub fn composite( + &self, + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + target: &wgpu::TextureView, + source: &wgpu::TextureView, + ) { + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("llimphi-overlay-bg"), + layout: &self.bind_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(source), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + ], + }); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("llimphi-overlay-composite-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &bind_group, &[]); + pass.draw(0..3, 0..1); + } +} + +/// Pase de pantalla completa que samplea la textura de overlay y la emite +/// para alpha-over. Triángulo grande que cubre el viewport; UV mapea clip +/// → texel 1:1 (Y invertida, igual que un blit estándar). +const OVERLAY_COMPOSITE_WGSL: &str = r#" +struct VsOut { + @builtin(position) pos: vec4, + @location(0) uv: vec2, +}; + +@vertex +fn vs(@builtin(vertex_index) vi: u32) -> VsOut { + var corners = array, 3>( + vec2(-1.0, -1.0), + vec2( 3.0, -1.0), + vec2(-1.0, 3.0), + ); + let xy = corners[vi]; + var out: VsOut; + out.pos = vec4(xy, 0.0, 1.0); + out.uv = vec2((xy.x + 1.0) * 0.5, (1.0 - xy.y) * 0.5); + return out; +} + +@group(0) @binding(0) var src_tex: texture_2d; +@group(0) @binding(1) var src_samp: sampler; + +@fragment +fn fs(in: VsOut) -> @location(0) vec4 { + return textureSample(src_tex, src_samp, in.uv); +} +"#; + +impl Surface for WinitSurface { + fn size(&self) -> (u32, u32) { + (self.config.width, self.config.height) + } + + fn resize(&mut self, width: u32, height: u32) { + self.config.width = width.max(1); + self.config.height = height.max(1); + self.surface.configure(&self.device, &self.config); + let (tex, view) = create_intermediate(&self.device, self.config.width, self.config.height); + self.intermediate = tex; + self.intermediate_view = view; + let (otex, oview) = + create_intermediate(&self.device, self.config.width, self.config.height); + self.overlay = otex; + self.overlay_view = oview; + } + + fn acquire(&mut self) -> Result { + let texture = self.surface.get_current_texture().map_err(|e| match e { + wgpu::SurfaceError::Lost => SurfaceError::Lost, + wgpu::SurfaceError::Outdated => SurfaceError::Outdated, + wgpu::SurfaceError::OutOfMemory => SurfaceError::OutOfMemory, + wgpu::SurfaceError::Timeout => SurfaceError::Timeout, + other => SurfaceError::Other(format!("{other:?}")), + })?; + let surface_view = texture + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + // `TextureView` envuelve un Arc — clonar es atomic-incref, no + // recrea la vista. La intermedia sólo cambia en `resize`. + Ok(Frame { + surface_texture: texture, + surface_view, + intermediate_view: self.intermediate_view.clone(), + overlay_view: self.overlay_view.clone(), + width: self.config.width, + height: self.config.height, + }) + } + + fn present(&mut self, frame: Frame, hal: &Hal) { + let mut encoder = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("llimphi-blit"), + }); + self.blitter.copy( + &hal.device, + &mut encoder, + &frame.intermediate_view, + &frame.surface_view, + ); + hal.queue.submit(std::iter::once(encoder.finish())); + frame.surface_texture.present(); + } +} diff --git a/llimphi-icons/Cargo.toml b/llimphi-icons/Cargo.toml new file mode 100644 index 0000000..e731f53 --- /dev/null +++ b/llimphi-icons/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "llimphi-icons" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-icons — set mínimo de iconos vectoriales (BezPath en grid 24×24) renderizables vía paint_with. Stroke-based, escalables. Cubre las acciones canónicas de cualquier UI gioser." + +[dependencies] +llimphi-ui = { workspace = true } diff --git a/llimphi-icons/examples/app_icons_gallery.rs b/llimphi-icons/examples/app_icons_gallery.rs new file mode 100644 index 0000000..97652ca --- /dev/null +++ b/llimphi-icons/examples/app_icons_gallery.rs @@ -0,0 +1,136 @@ +//! Galería de los iconos de marca de todas las apps de gioser. +//! +//! Pinta los 29 [`AppIcon`] en una grilla, cada uno en su color de marca +//! con su nombre debajo. Sirve para eyeballear de un vistazo que el set +//! es coherente (mismo peso de trazo, mismo aire) y que cada glifo es +//! reconocible. +//! +//! `cargo run -p llimphi-icons --example app_icons_gallery --release` + +use llimphi_icons::app_icons::{app_icon_view, AppIcon, ALL}; +use llimphi_ui::llimphi_layout::taffy::prelude::{ + auto, length, percent, AlignItems, FlexDirection, JustifyContent, Size, Style, +}; +use llimphi_ui::llimphi_layout::taffy::Rect; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{App, Handle, View}; + +const COLS: usize = 6; +const BG: Color = Color::from_rgb8(18, 20, 24); +const CELL: Color = Color::from_rgb8(28, 31, 38); +const LABEL: Color = Color::from_rgb8(196, 202, 212); + +struct Model; + +#[derive(Clone)] +enum Msg {} + +fn cell(icon: AppIcon) -> View { + // Recuadro del glifo (cuadrado, el icono se escala al lado menor). + let icon_box = View::new(Style { + size: Size { + width: length(52.0_f32), + height: length(52.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![app_icon_view(icon, 2.0)]); + + let label = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(16.0_f32), + }, + ..Default::default() + }) + .text_aligned(icon.name().to_string(), 11.0, LABEL, Alignment::Center); + + View::new(Style { + size: Size { + width: length(118.0_f32), + height: length(96.0_f32), + }, + flex_direction: FlexDirection::Column, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + gap: Size { + width: length(0.0_f32), + height: length(8.0_f32), + }, + ..Default::default() + }) + .fill(CELL) + .radius(12.0) + .children(vec![icon_box, label]) +} + +fn row(icons: &[AppIcon]) -> View { + View::new(Style { + size: Size { + width: auto(), + height: auto(), + }, + flex_direction: FlexDirection::Row, + gap: Size { + width: length(14.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(icons.iter().copied().map(cell).collect()) +} + +struct Gallery; + +impl App for Gallery { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi-icons · galería de apps" + } + + fn initial_size() -> (u32, u32) { + (820, 620) + } + + fn init(_: &Handle) -> Model { + Model + } + + fn update(_model: Model, msg: Msg, _: &Handle) -> Model { + match msg {} + } + + fn view(_: &Model) -> View { + let rows: Vec> = ALL.chunks(COLS).map(row).collect(); + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_direction: FlexDirection::Column, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + gap: Size { + width: length(0.0_f32), + height: length(14.0_f32), + }, + padding: Rect { + left: length(20.0_f32), + right: length(20.0_f32), + top: length(20.0_f32), + bottom: length(20.0_f32), + }, + ..Default::default() + }) + .fill(BG) + .children(rows) + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/llimphi-icons/src/app_icons.rs b/llimphi-icons/src/app_icons.rs new file mode 100644 index 0000000..cd97aa5 --- /dev/null +++ b/llimphi-icons/src/app_icons.rs @@ -0,0 +1,824 @@ +//! `app_icons` — iconos de marca, uno por dominio/app de gioser. +//! +//! A diferencia del set canónico de [`crate::Icon`] (glifos genéricos de +//! acción: file, save, search…), acá vive **un glifo distintivo por app**. +//! Cada app tiene su símbolo y su **color de marca** propios, pero todos +//! comparten el mismo lenguaje visual: +//! +//! - **Mismo grid lógico 24×24**, origen top-left, eje Y hacia abajo. +//! - **Stroke-based, sin fill**: trazos con `Join::Round` + `Cap::Round`. +//! - **Geometría minimal**: reconocible al primer vistazo aún en 16×16. +//! - **Aire de ~3 unidades** en los bordes para que respire dentro de un chip. +//! +//! La idea es que un dock/spotlight/menú pinte `app_icon_view(AppIcon::Pluma)` +//! y obtenga el glifo de la pluma en su color de tinta, sin que la app tenga +//! que cargar un PNG ni declarar su propia geometría. +//! +//! ```ignore +//! use llimphi_icons::app_icons::{AppIcon, app_icon_view}; +//! +//! // Resuelve desde el id del registro de apps: +//! if let Some(icon) = AppIcon::from_app_id("cosmos") { +//! let chip = View::new(style).children(vec![app_icon_view(icon, 1.8)]); +//! } +//! ``` + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{percent, Size, Style}, + Position, +}; +use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Cap, Join, Stroke}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::View; + +/// Una app de gioser con icono de marca. El identificador (`name`) coincide +/// con el `id` del `AppEntry` en `app-bus`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppIcon { + // --- 00_unanchay · PERCIBIR --- + Chaka, + Khipu, + Pineal, + Pluma, + Puriy, + Rimay, + // --- 01_yachay · CONOCER --- + Cosmos, + Dominium, + Iniy, + Nakui, + Tinkuy, + // --- 02_ruway · HACER --- + Ayni, + Cards, + Chasqui, + Llimphi, + Media, + Mirada, + Nada, + Nahual, + Shuma, + Supay, + Takiy, + Tullpu, + Wawa, + // --- 03_ukupacha · RAÍZ --- + Agora, + Arje, + Minga, + Sandokan, + WawaExplorer, +} + +/// Las 29 apps, en orden de cuadrante. Útil para iterar (galerías, tests). +pub const ALL: [AppIcon; 29] = [ + AppIcon::Chaka, + AppIcon::Khipu, + AppIcon::Pineal, + AppIcon::Pluma, + AppIcon::Puriy, + AppIcon::Rimay, + AppIcon::Cosmos, + AppIcon::Dominium, + AppIcon::Iniy, + AppIcon::Nakui, + AppIcon::Tinkuy, + AppIcon::Ayni, + AppIcon::Cards, + AppIcon::Chasqui, + AppIcon::Llimphi, + AppIcon::Media, + AppIcon::Mirada, + AppIcon::Nada, + AppIcon::Nahual, + AppIcon::Shuma, + AppIcon::Supay, + AppIcon::Takiy, + AppIcon::Tullpu, + AppIcon::Wawa, + AppIcon::Agora, + AppIcon::Arje, + AppIcon::Minga, + AppIcon::Sandokan, + AppIcon::WawaExplorer, +]; + +impl AppIcon { + /// Id estable de la app (coincide con `AppEntry.id` / nombre del dominio). + pub const fn name(self) -> &'static str { + match self { + AppIcon::Chaka => "chaka", + AppIcon::Khipu => "khipu", + AppIcon::Pineal => "pineal", + AppIcon::Pluma => "pluma", + AppIcon::Puriy => "puriy", + AppIcon::Rimay => "rimay", + AppIcon::Cosmos => "cosmos", + AppIcon::Dominium => "dominium", + AppIcon::Iniy => "iniy", + AppIcon::Nakui => "nakui", + AppIcon::Tinkuy => "tinkuy", + AppIcon::Ayni => "ayni", + AppIcon::Cards => "cards", + AppIcon::Chasqui => "chasqui", + AppIcon::Llimphi => "llimphi", + AppIcon::Media => "media", + AppIcon::Mirada => "mirada", + AppIcon::Nada => "nada", + AppIcon::Nahual => "nahual", + AppIcon::Shuma => "shuma", + AppIcon::Supay => "supay", + AppIcon::Takiy => "takiy", + AppIcon::Tullpu => "tullpu", + AppIcon::Wawa => "wawa", + AppIcon::Agora => "agora", + AppIcon::Arje => "arje", + AppIcon::Minga => "minga", + AppIcon::Sandokan => "sandokan", + AppIcon::WawaExplorer => "wawa-explorer", + } + } + + /// Resuelve una app desde su `id` del registro. Acepta tanto + /// `"wawa-explorer"` como `"wawa_explorer"`. + pub fn from_app_id(id: &str) -> Option { + let id = id.trim().to_ascii_lowercase(); + let id = id.replace('_', "-"); + ALL.into_iter().find(|a| a.name() == id) + } + + /// Color de marca de la app — el que el dock/menú debería usar para + /// pintar el glifo por default. + pub const fn brand(self) -> Color { + let (r, g, b) = match self { + AppIcon::Chaka => (43, 166, 164), + AppIcon::Khipu => (181, 101, 29), + AppIcon::Pineal => (108, 79, 216), + AppIcon::Pluma => (61, 59, 142), + AppIcon::Puriy => (63, 163, 77), + AppIcon::Rimay => (232, 131, 58), + AppIcon::Cosmos => (230, 184, 0), + AppIcon::Dominium => (74, 111, 165), + AppIcon::Iniy => (124, 179, 66), + AppIcon::Nakui => (194, 84, 157), + AppIcon::Tinkuy => (217, 83, 79), + AppIcon::Ayni => (42, 168, 196), + AppIcon::Cards => (142, 99, 206), + AppIcon::Chasqui => (52, 179, 106), + AppIcon::Llimphi => (229, 91, 122), + AppIcon::Media => (226, 62, 87), + AppIcon::Mirada => (45, 125, 210), + AppIcon::Nada => (136, 147, 160), + AppIcon::Nahual => (124, 77, 191), + AppIcon::Shuma => (224, 165, 38), + AppIcon::Supay => (155, 63, 181), + AppIcon::Takiy => (229, 99, 155), + AppIcon::Tullpu => (224, 96, 58), + AppIcon::Wawa => (91, 141, 239), + AppIcon::Agora => (47, 158, 143), + AppIcon::Arje => (176, 141, 87), + AppIcon::Minga => (224, 123, 57), + AppIcon::Sandokan => (192, 57, 43), + AppIcon::WawaExplorer => (110, 160, 240), + }; + Color::from_rgb8(r, g, b) + } + + /// `BezPath` del glifo en coords del grid 24×24. + pub fn path(self) -> BezPath { + match self { + AppIcon::Chaka => path_chaka(), + AppIcon::Khipu => path_khipu(), + AppIcon::Pineal => path_pineal(), + AppIcon::Pluma => path_pluma(), + AppIcon::Puriy => path_puriy(), + AppIcon::Rimay => path_rimay(), + AppIcon::Cosmos => path_cosmos(), + AppIcon::Dominium => path_dominium(), + AppIcon::Iniy => path_iniy(), + AppIcon::Nakui => path_nakui(), + AppIcon::Tinkuy => path_tinkuy(), + AppIcon::Ayni => path_ayni(), + AppIcon::Cards => path_cards(), + AppIcon::Chasqui => path_chasqui(), + AppIcon::Llimphi => path_llimphi(), + AppIcon::Media => path_media(), + AppIcon::Mirada => path_mirada(), + AppIcon::Nada => path_nada(), + AppIcon::Nahual => path_nahual(), + AppIcon::Shuma => path_shuma(), + AppIcon::Supay => path_supay(), + AppIcon::Takiy => path_takiy(), + AppIcon::Tullpu => path_tullpu(), + AppIcon::Wawa => path_wawa(), + AppIcon::Agora => path_agora(), + AppIcon::Arje => path_arje(), + AppIcon::Minga => path_minga(), + AppIcon::Sandokan => path_sandokan(), + AppIcon::WawaExplorer => path_wawa_explorer(), + } + } +} + +/// `View` que pinta el icono de app en su **color de marca**, ocupando todo +/// el rect del padre, escalado uniforme y centrado. +/// +/// - `stroke_width` en unidades del grid 24×24 (típico de marca: `1.8`). +pub fn app_icon_view(icon: AppIcon, stroke_width: f32) -> View { + app_icon_view_colored(icon, icon.brand(), stroke_width) +} + +/// Igual que [`app_icon_view`] pero forzando un color (p.ej. monocromo +/// `theme.fg_text` para un menú denso donde el color distrae). +pub fn app_icon_view_colored( + icon: AppIcon, + color: Color, + stroke_width: f32, +) -> View { + View::new(Style { + position: Position::Absolute, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| { + paint_app_icon(scene, rect, icon, color, stroke_width); + }) +} + +/// Pintor crudo — para stampear varios iconos de app dentro del mismo +/// `paint_with` (una grilla de launcher, por ejemplo). +pub fn paint_app_icon( + scene: &mut llimphi_ui::llimphi_raster::vello::Scene, + rect: llimphi_ui::PaintRect, + icon: AppIcon, + color: Color, + stroke_width: f32, +) { + let side = rect.w.min(rect.h) as f64; + if side <= 0.0 { + return; + } + let scale = side / 24.0; + let tx = rect.x as f64 + (rect.w as f64 - side) * 0.5; + let ty = rect.y as f64 + (rect.h as f64 - side) * 0.5; + let xform = Affine::translate((tx, ty)) * Affine::scale(scale); + + let stroke = Stroke::new(stroke_width as f64) + .with_join(Join::Round) + .with_caps(Cap::Round); + let path = icon.path(); + scene.stroke(&stroke, xform, color, None, &path); +} + +// ===================================================================== +// Helpers +// ===================================================================== + +/// Círculo aproximado con `segments` lados rectos (liso por el Cap::Round). +fn circle(cx: f64, cy: f64, r: f64, segments: usize) -> BezPath { + let mut p = BezPath::new(); + for i in 0..=segments { + let theta = std::f64::consts::TAU * (i as f64) / (segments as f64); + let x = cx + r * theta.cos(); + let y = cy + r * theta.sin(); + if i == 0 { + p.move_to((x, y)); + } else { + p.line_to((x, y)); + } + } + p +} + +/// Empuja todos los elementos de `src` dentro de `dst` (para componer +/// glifos hechos de varias subformas). +fn push_all(dst: &mut BezPath, src: BezPath) { + for el in src.elements() { + dst.push(*el); + } +} + +// ===================================================================== +// Glifos — uno por app. Grid 24×24, margen ~3. +// ===================================================================== + +// --- 00_unanchay · PERCIBIR --- + +fn path_chaka() -> BezPath { + // chaka = puente: tablero recto + arco + dos pilotes. + let mut p = BezPath::new(); + // Tablero. + p.move_to((3.0, 9.0)); + p.line_to((21.0, 9.0)); + // Arco bajo el tablero. + p.move_to((5.0, 18.0)); + p.curve_to((5.0, 11.0), (19.0, 11.0), (19.0, 18.0)); + // Pilotes que conectan tablero y arco. + p.move_to((9.0, 9.0)); + p.line_to((9.0, 12.5)); + p.move_to((15.0, 9.0)); + p.line_to((15.0, 12.5)); + p +} + +fn path_khipu() -> BezPath { + // khipu: cordón principal + tres ramales con nudos (puntos). + let mut p = BezPath::new(); + // Cordón superior. + p.move_to((4.0, 6.0)); + p.line_to((20.0, 6.0)); + // Ramales. + p.move_to((7.0, 6.0)); + p.line_to((7.0, 19.0)); + p.move_to((12.0, 6.0)); + p.line_to((12.0, 20.0)); + p.move_to((17.0, 6.0)); + p.line_to((17.0, 18.0)); + // Nudos. + push_all(&mut p, circle(7.0, 12.0, 1.3, 10)); + push_all(&mut p, circle(12.0, 10.0, 1.3, 10)); + push_all(&mut p, circle(12.0, 16.0, 1.3, 10)); + push_all(&mut p, circle(17.0, 11.0, 1.3, 10)); + p +} + +fn path_pineal() -> BezPath { + // pineal = tercer ojo: párpado almendrado + iris + antena/rayo arriba. + let mut p = BezPath::new(); + p.move_to((4.0, 12.0)); + p.curve_to((8.0, 7.0), (16.0, 7.0), (20.0, 12.0)); + p.curve_to((16.0, 17.0), (8.0, 17.0), (4.0, 12.0)); + push_all(&mut p, circle(12.0, 12.0, 2.6, 14)); + p.move_to((12.0, 3.0)); + p.line_to((12.0, 5.5)); + p +} + +fn path_pluma() -> BezPath { + // pluma = plumín: rombo apuntando abajo + ranura + ojal. + let mut p = BezPath::new(); + p.move_to((12.0, 3.0)); + p.line_to((16.0, 9.0)); + p.line_to((13.5, 20.0)); + p.line_to((10.5, 20.0)); + p.line_to((8.0, 9.0)); + p.close_path(); + // Ranura. + p.move_to((12.0, 11.5)); + p.line_to((12.0, 19.0)); + // Ojal. + push_all(&mut p, circle(12.0, 9.5, 1.2, 10)); + p +} + +fn path_puriy() -> BezPath { + // puriy = caminar/recorrido: senda curva ascendente con flecha. + let mut p = BezPath::new(); + p.move_to((6.0, 20.0)); + p.curve_to((6.0, 12.0), (18.0, 12.0), (18.0, 4.0)); + // Cabeza de flecha. + p.move_to((15.0, 6.0)); + p.line_to((18.0, 4.0)); + p.line_to((20.5, 6.5)); + p +} + +fn path_rimay() -> BezPath { + // rimay = palabra/habla: globo de diálogo con cola + dos renglones. + let mut p = BezPath::new(); + p.move_to((4.0, 6.0)); + p.line_to((20.0, 6.0)); + p.line_to((20.0, 15.0)); + p.line_to((11.0, 15.0)); + p.line_to((8.0, 19.0)); + p.line_to((8.0, 15.0)); + p.line_to((4.0, 15.0)); + p.close_path(); + // Renglones. + p.move_to((8.0, 9.5)); + p.line_to((16.0, 9.5)); + p.move_to((8.0, 12.0)); + p.line_to((13.0, 12.0)); + p +} + +// --- 01_yachay · CONOCER --- + +fn path_cosmos() -> BezPath { + // cosmos = destello de 4 puntas + dos estrellas pequeñas. + let mut p = BezPath::new(); + p.move_to((12.0, 4.0)); + p.line_to((13.4, 10.6)); + p.line_to((20.0, 12.0)); + p.line_to((13.4, 13.4)); + p.line_to((12.0, 20.0)); + p.line_to((10.6, 13.4)); + p.line_to((4.0, 12.0)); + p.line_to((10.6, 10.6)); + p.close_path(); + // Estrellas chicas. + push_all(&mut p, circle(19.0, 6.0, 0.8, 8)); + push_all(&mut p, circle(5.5, 18.0, 0.8, 8)); + p +} + +fn path_dominium() -> BezPath { + // dominium = ERP/libro mayor: barras de distinta altura sobre una base. + let mut p = BezPath::new(); + // Base. + p.move_to((3.0, 20.0)); + p.line_to((21.0, 20.0)); + // Columnas. + p.move_to((6.0, 14.0)); + p.line_to((9.0, 14.0)); + p.line_to((9.0, 20.0)); + p.line_to((6.0, 20.0)); + p.close_path(); + p.move_to((10.5, 8.0)); + p.line_to((13.5, 8.0)); + p.line_to((13.5, 20.0)); + p.line_to((10.5, 20.0)); + p.close_path(); + p.move_to((15.0, 11.0)); + p.line_to((18.0, 11.0)); + p.line_to((18.0, 20.0)); + p.line_to((15.0, 20.0)); + p.close_path(); + p +} + +fn path_iniy() -> BezPath { + // iniy = aliento/creer: brote con tallo y dos hojas. + let mut p = BezPath::new(); + // Tallo. + p.move_to((12.0, 20.0)); + p.line_to((12.0, 10.0)); + // Hoja izquierda. + p.move_to((12.0, 14.0)); + p.curve_to((8.0, 14.0), (6.0, 11.0), (7.0, 8.0)); + p.curve_to((10.0, 9.0), (12.0, 11.0), (12.0, 14.0)); + // Hoja derecha. + p.move_to((12.0, 12.0)); + p.curve_to((15.5, 12.0), (17.0, 9.0), (16.5, 6.0)); + p.curve_to((14.0, 7.0), (12.0, 9.0), (12.0, 12.0)); + p +} + +fn path_nakui() -> BezPath { + // nakui = grafo de morfismos: tres nodos + aristas. + let mut p = BezPath::new(); + // Aristas (primero, para que queden bajo los nodos). + p.move_to((7.5, 9.0)); + p.line_to((16.5, 9.0)); + p.move_to((7.5, 9.8)); + p.line_to((10.8, 16.0)); + p.move_to((16.5, 9.8)); + p.line_to((13.2, 16.0)); + // Nodos. + push_all(&mut p, circle(6.0, 8.0, 2.2, 14)); + push_all(&mut p, circle(18.0, 8.0, 2.2, 14)); + push_all(&mut p, circle(12.0, 18.0, 2.2, 14)); + p +} + +fn path_tinkuy() -> BezPath { + // tinkuy = encuentro/choque: dos flechas que convergen + chispa. + let mut p = BezPath::new(); + // Flecha izquierda → + p.move_to((3.0, 12.0)); + p.line_to((9.5, 12.0)); + p.move_to((7.5, 10.0)); + p.line_to((9.5, 12.0)); + p.line_to((7.5, 14.0)); + // Flecha derecha ← + p.move_to((21.0, 12.0)); + p.line_to((14.5, 12.0)); + p.move_to((16.5, 10.0)); + p.line_to((14.5, 12.0)); + p.line_to((16.5, 14.0)); + // Chispa central. + push_all(&mut p, circle(12.0, 12.0, 1.6, 10)); + p +} + +// --- 02_ruway · HACER --- + +fn path_ayni() -> BezPath { + // ayni = reciprocidad: dos flechas curvas en ciclo. + let mut p = BezPath::new(); + // Arco superior, flecha hacia la derecha-abajo. + p.move_to((6.0, 8.0)); + p.curve_to((9.0, 4.0), (15.0, 4.0), (18.0, 8.5)); + p.move_to((15.5, 8.0)); + p.line_to((18.0, 8.5)); + p.line_to((18.5, 5.8)); + // Arco inferior, flecha hacia la izquierda-arriba. + p.move_to((18.0, 16.0)); + p.curve_to((15.0, 20.0), (9.0, 20.0), (6.0, 15.5)); + p.move_to((8.5, 16.0)); + p.line_to((6.0, 15.5)); + p.line_to((5.5, 18.2)); + p +} + +fn path_cards() -> BezPath { + // cards = naipes apilados: carta frontal + borde de la de atrás. + let mut p = BezPath::new(); + // Carta de atrás (asoma arriba y a la derecha). + p.move_to((8.0, 5.0)); + p.line_to((19.0, 5.0)); + p.line_to((19.0, 16.0)); + // Carta frontal. + p.move_to((5.0, 9.0)); + p.line_to((15.0, 9.0)); + p.line_to((15.0, 20.0)); + p.line_to((5.0, 20.0)); + p.close_path(); + p +} + +fn path_chasqui() -> BezPath { + // chasqui = mensajero: avión de papel. + let mut p = BezPath::new(); + p.move_to((4.0, 11.0)); + p.line_to((20.0, 4.0)); + p.line_to((13.0, 20.0)); + p.line_to((11.0, 13.0)); + p.close_path(); + // Pliegue central. + p.move_to((11.0, 13.0)); + p.line_to((20.0, 4.0)); + p +} + +fn path_llimphi() -> BezPath { + // llimphi = pintura/color: paleta con apoyo para el pulgar + 3 gotas. + let mut p = BezPath::new(); + p.move_to((4.0, 12.0)); + p.curve_to((4.0, 6.0), (11.0, 4.0), (15.0, 5.0)); + p.curve_to((20.0, 6.5), (21.0, 12.0), (18.0, 15.0)); + p.curve_to((16.0, 16.5), (16.5, 13.5), (14.0, 14.0)); + p.curve_to((11.5, 14.5), (12.5, 18.0), (9.0, 18.0)); + p.curve_to((5.5, 18.0), (4.0, 15.0), (4.0, 12.0)); + p.close_path(); + // Gotas de pintura. + push_all(&mut p, circle(8.0, 9.0, 1.1, 10)); + push_all(&mut p, circle(12.0, 8.0, 1.1, 10)); + push_all(&mut p, circle(15.5, 10.0, 1.1, 10)); + p +} + +fn path_media() -> BezPath { + // media = reproducción: marco + triángulo de play. + let mut p = BezPath::new(); + p.move_to((4.0, 6.0)); + p.line_to((20.0, 6.0)); + p.line_to((20.0, 18.0)); + p.line_to((4.0, 18.0)); + p.close_path(); + // Play. + p.move_to((10.0, 9.0)); + p.line_to((10.0, 15.0)); + p.line_to((16.0, 12.0)); + p.close_path(); + p +} + +fn path_mirada() -> BezPath { + // mirada = ojo: párpado + iris + pupila. + let mut p = BezPath::new(); + p.move_to((3.0, 12.0)); + p.curve_to((8.0, 6.0), (16.0, 6.0), (21.0, 12.0)); + p.curve_to((16.0, 18.0), (8.0, 18.0), (3.0, 12.0)); + p.close_path(); + push_all(&mut p, circle(12.0, 12.0, 3.4, 18)); + push_all(&mut p, circle(12.0, 12.0, 1.0, 8)); + p +} + +fn path_nada() -> BezPath { + // nada = vacío: conjunto vacío ∅ (anillo + diagonal). + let mut p = circle(12.0, 12.0, 8.0, 28); + p.move_to((6.5, 17.5)); + p.line_to((17.5, 6.5)); + p +} + +fn path_nahual() -> BezPath { + // nahual = máscara/mutación de forma: antifaz con dos ojos. + let mut p = BezPath::new(); + p.move_to((4.0, 9.0)); + p.curve_to((4.0, 6.5), (8.0, 6.0), (10.0, 7.5)); + p.curve_to((11.0, 8.2), (13.0, 8.2), (14.0, 7.5)); + p.curve_to((16.0, 6.0), (20.0, 6.5), (20.0, 9.0)); + p.curve_to((20.0, 13.5), (16.0, 16.5), (12.0, 15.5)); + p.curve_to((8.0, 16.5), (4.0, 13.5), (4.0, 9.0)); + p.close_path(); + push_all(&mut p, circle(9.0, 10.0, 1.3, 10)); + push_all(&mut p, circle(15.0, 10.0, 1.3, 10)); + p +} + +fn path_shuma() -> BezPath { + // shuma = discernir: embudo/filtro. + let mut p = BezPath::new(); + p.move_to((4.0, 6.0)); + p.line_to((20.0, 6.0)); + p.line_to((13.0, 14.0)); + p.line_to((13.0, 19.0)); + p.line_to((11.0, 20.0)); + p.line_to((11.0, 14.0)); + p.close_path(); + p +} + +fn path_supay() -> BezPath { + // supay = espíritu del ukhupacha: llama doble. + let mut p = BezPath::new(); + // Llama exterior. + p.move_to((12.0, 3.0)); + p.curve_to((17.0, 9.0), (16.0, 14.0), (12.0, 21.0)); + p.curve_to((8.0, 14.0), (7.0, 9.0), (12.0, 3.0)); + p.close_path(); + // Llama interior. + p.move_to((12.0, 9.0)); + p.curve_to((14.0, 12.0), (13.0, 16.0), (12.0, 18.0)); + p.curve_to((11.0, 16.0), (10.0, 12.0), (12.0, 9.0)); + p.close_path(); + p +} + +fn path_takiy() -> BezPath { + // takiy = cantar: corchea + ondas de sonido. + let mut p = BezPath::new(); + // Cabeza de nota. + push_all(&mut p, circle(8.0, 18.0, 2.4, 16)); + // Plica. + p.move_to((10.4, 18.0)); + p.line_to((10.4, 6.0)); + // Banderola. + p.move_to((10.4, 6.0)); + p.curve_to((13.5, 7.0), (14.5, 9.0), (13.5, 11.0)); + // Ondas. + p.move_to((16.0, 9.0)); + p.curve_to((18.0, 11.0), (18.0, 13.0), (16.0, 15.0)); + p +} + +fn path_tullpu() -> BezPath { + // tullpu = tinte/color: tres gotas. + let mut p = BezPath::new(); + // Gota 1. + p.move_to((8.0, 5.0)); + p.curve_to((11.0, 9.0), (11.0, 11.0), (8.0, 12.0)); + p.curve_to((5.0, 11.0), (5.0, 9.0), (8.0, 5.0)); + p.close_path(); + // Gota 2. + p.move_to((16.0, 6.0)); + p.curve_to((19.0, 10.0), (19.0, 12.0), (16.0, 13.0)); + p.curve_to((13.0, 12.0), (13.0, 10.0), (16.0, 6.0)); + p.close_path(); + // Gota 3. + p.move_to((12.0, 13.0)); + p.curve_to((15.0, 17.0), (15.0, 19.0), (12.0, 20.0)); + p.curve_to((9.0, 19.0), (9.0, 17.0), (12.0, 13.0)); + p.close_path(); + p +} + +fn path_wawa() -> BezPath { + // wawa = célula/semilla (el SO en gestación): membrana + núcleo. + let mut p = circle(12.0, 12.0, 8.0, 28); + push_all(&mut p, circle(12.0, 12.0, 3.0, 16)); + p +} + +// --- 03_ukupacha · RAÍZ --- + +fn path_agora() -> BezPath { + // agora = firma/confianza: escudo con check. + let mut p = BezPath::new(); + p.move_to((12.0, 3.0)); + p.line_to((20.0, 6.0)); + p.line_to((20.0, 12.0)); + p.curve_to((20.0, 17.0), (16.0, 20.0), (12.0, 21.0)); + p.curve_to((8.0, 20.0), (4.0, 17.0), (4.0, 12.0)); + p.line_to((4.0, 6.0)); + p.close_path(); + // Check. + p.move_to((8.5, 12.0)); + p.line_to((11.0, 14.5)); + p.line_to((16.0, 8.5)); + p +} + +fn path_arje() -> BezPath { + // arje = arché/raíz de confianza: ancla. + let mut p = BezPath::new(); + // Anillo. + push_all(&mut p, circle(12.0, 5.0, 2.2, 14)); + // Caña. + p.move_to((12.0, 7.2)); + p.line_to((12.0, 19.0)); + // Travesaño. + p.move_to((8.0, 10.0)); + p.line_to((16.0, 10.0)); + // Uñas/brazos. + p.move_to((6.0, 14.0)); + p.curve_to((6.0, 18.5), (9.0, 20.0), (12.0, 20.0)); + p.move_to((18.0, 14.0)); + p.curve_to((18.0, 18.5), (15.0, 20.0), (12.0, 20.0)); + p +} + +fn path_minga() -> BezPath { + // minga = trabajo comunal: tres figuras. + let mut p = BezPath::new(); + // Figura central. + push_all(&mut p, circle(12.0, 7.0, 2.2, 14)); + p.move_to((8.0, 18.0)); + p.curve_to((8.0, 13.0), (16.0, 13.0), (16.0, 18.0)); + // Figura izquierda. + push_all(&mut p, circle(5.5, 10.0, 1.6, 12)); + p.move_to((2.5, 18.0)); + p.curve_to((2.5, 14.5), (6.0, 13.5), (7.5, 15.0)); + // Figura derecha. + push_all(&mut p, circle(18.5, 10.0, 1.6, 12)); + p.move_to((21.5, 18.0)); + p.curve_to((21.5, 14.5), (18.0, 13.5), (16.5, 15.0)); + p +} + +fn path_sandokan() -> BezPath { + // sandokan = caja/contenedor aislado: cubo isométrico. + let mut p = BezPath::new(); + // Cara frontal. + p.move_to((5.0, 8.0)); + p.line_to((14.0, 8.0)); + p.line_to((14.0, 18.0)); + p.line_to((5.0, 18.0)); + p.close_path(); + // Tapa. + p.move_to((5.0, 8.0)); + p.line_to((9.0, 4.0)); + p.line_to((18.0, 4.0)); + p.line_to((14.0, 8.0)); + // Cara lateral. + p.move_to((14.0, 8.0)); + p.line_to((18.0, 4.0)); + p.line_to((18.0, 14.0)); + p.line_to((14.0, 18.0)); + p +} + +fn path_wawa_explorer() -> BezPath { + // wawa-explorer = launchpad: grilla 2×2. + let mut p = BezPath::new(); + for (x, y) in &[(5.0, 5.0), (13.0, 5.0), (5.0, 13.0), (13.0, 13.0)] { + p.move_to((*x, *y)); + p.line_to((*x + 6.0, *y)); + p.line_to((*x + 6.0, *y + 6.0)); + p.line_to((*x, *y + 6.0)); + p.close_path(); + } + p +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_app_icons_have_nonempty_path() { + for icon in ALL { + let p = icon.path(); + assert!( + p.elements().len() > 0, + "icono de app {} produjo path vacío", + icon.name() + ); + } + } + + #[test] + fn app_names_are_unique() { + let mut names: Vec<&str> = ALL.iter().map(|i| i.name()).collect(); + let n = names.len(); + names.sort(); + names.dedup(); + assert_eq!(names.len(), n, "nombres duplicados en AppIcon::name()"); + } + + #[test] + fn from_app_id_roundtrips() { + for icon in ALL { + assert_eq!(AppIcon::from_app_id(icon.name()), Some(icon)); + } + // Tolera underscores y mayúsculas. + assert_eq!(AppIcon::from_app_id("WAWA_EXPLORER"), Some(AppIcon::WawaExplorer)); + assert_eq!(AppIcon::from_app_id("desconocida"), None); + } +} diff --git a/llimphi-icons/src/lib.rs b/llimphi-icons/src/lib.rs new file mode 100644 index 0000000..e237da9 --- /dev/null +++ b/llimphi-icons/src/lib.rs @@ -0,0 +1,1005 @@ +//! `llimphi-icons` — set canónico de iconos vectoriales para apps gioser. +//! +//! Cada icono es una función pura que devuelve un `BezPath` definido en +//! un grid lógico de **24×24 unidades**. El renderer escala al rect que +//! reciba, así un mismo icono sirve para 12px (en una fila de lista) y +//! para 64px (en una hero card) sin pérdida de nitidez — es vector, +//! no bitmap. +//! +//! ## Diseño +//! +//! - **Stroke-based, no fill**: los iconos son trazos de ancho 2 (en +//! unidades del grid) con joins/caps suaves. El stroke se renderiza +//! con el color que la app elija (típicamente `theme.fg_text` o +//! `theme.accent`). +//! - **Geometría minimal, no marca**: glifos genéricos universales, +//! no "marca registrada". Cada uno debe ser reconocible al primer +//! vistazo aún en 12×12. +//! - **Set acotado**: suficientes para cubrir el grueso de acciones y +//! tipos que aparecen en cualquier UI gioser. Si una app necesita uno +//! más, lo agrega aquí (no en su propio crate) — la consistencia +//! visual importa más que el aislamiento. +//! +//! ## Catálogo +//! +//! | Categoría | Iconos | +//! |--------------|-----------------------------------------------------| +//! | Documento | `file`, `folder`, `folder_open`, `save`, `open` | +//! | Edición | `plus`, `minus`, `x`, `check`, `edit`, `trash` | +//! | Navegación | `chevron_up`, `chevron_down`, `chevron_left`, `chevron_right`, `home`, `search` | +//! | Estado | `info`, `warning`, `error`, `bell` | +//! | Sistema | `settings`, `more` | +//! | Multimedia | `play`, `pause`, `stop`, `skip_*`, `volume*`, `repeat`, `shuffle`, `record`, `equalizer`, `camera`, `gauge` | +//! | Archivos | `image`, `music`, `film`, `archive`, `code`, `file_text`, `link`, `font` | +//! +//! ## Uso +//! +//! ```ignore +//! use llimphi_icons::{Icon, icon_view}; +//! +//! // Botón con icono "save": +//! let btn = View::new(style) +//! .fill(palette.bg_button) +//! .children(vec![icon_view(Icon::Save, palette.fg_text, 1.6)]); +//! ``` +//! +//! El parámetro `stroke_width` (3er arg de `icon_view`) está en unidades +//! del grid (24×24). `1.6` es el default armonioso; `2.0` para énfasis; +//! `1.2` para iconos en tipografías pequeñas. + +#![forbid(unsafe_code)] + +pub mod app_icons; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{percent, Size, Style}, + Position, +}; +use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Cap, Join, Stroke}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::View; + +/// Catálogo de iconos del set canónico. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Icon { + // --- Documento --- + File, + Folder, + FolderOpen, + Save, + Open, + // --- Edición --- + Plus, + Minus, + X, + Check, + Edit, + Trash, + // --- Navegación --- + ChevronUp, + ChevronDown, + ChevronLeft, + ChevronRight, + Home, + Search, + // --- Estado --- + Info, + Warning, + Error, + Bell, + // --- Sistema --- + Settings, + More, + // --- Multimedia --- + Play, + Pause, + Stop, + SkipBack, + SkipForward, + Rewind, + FastForward, + Volume, + VolumeMute, + Repeat, + Shuffle, + Record, + Equalizer, + Camera, + Gauge, + // --- Archivos (tipos por extensión, para listados de file manager) --- + Image, + Music, + Film, + Archive, + Code, + FileText, + Link, + Font, +} + +impl Icon { + /// Identificador estable en lowercase con underscores. Útil para + /// debugging, persistir choices del usuario, o mapear desde strings + /// en config. + pub const fn name(self) -> &'static str { + match self { + Icon::File => "file", + Icon::Folder => "folder", + Icon::FolderOpen => "folder_open", + Icon::Save => "save", + Icon::Open => "open", + Icon::Plus => "plus", + Icon::Minus => "minus", + Icon::X => "x", + Icon::Check => "check", + Icon::Edit => "edit", + Icon::Trash => "trash", + Icon::ChevronUp => "chevron_up", + Icon::ChevronDown => "chevron_down", + Icon::ChevronLeft => "chevron_left", + Icon::ChevronRight => "chevron_right", + Icon::Home => "home", + Icon::Search => "search", + Icon::Info => "info", + Icon::Warning => "warning", + Icon::Error => "error", + Icon::Bell => "bell", + Icon::Settings => "settings", + Icon::More => "more", + Icon::Play => "play", + Icon::Pause => "pause", + Icon::Stop => "stop", + Icon::SkipBack => "skip_back", + Icon::SkipForward => "skip_forward", + Icon::Rewind => "rewind", + Icon::FastForward => "fast_forward", + Icon::Volume => "volume", + Icon::VolumeMute => "volume_mute", + Icon::Repeat => "repeat", + Icon::Shuffle => "shuffle", + Icon::Record => "record", + Icon::Equalizer => "equalizer", + Icon::Camera => "camera", + Icon::Gauge => "gauge", + Icon::Image => "image", + Icon::Music => "music", + Icon::Film => "film", + Icon::Archive => "archive", + Icon::Code => "code", + Icon::FileText => "file_text", + Icon::Link => "link", + Icon::Font => "font", + } + } + + /// Devuelve el `BezPath` del icono en coords del grid 24×24. La + /// app raramente lo necesita directamente — es lo que consume + /// internamente [`icon_view`] / [`paint_icon`]. + pub fn path(self) -> BezPath { + match self { + Icon::File => path_file(), + Icon::Folder => path_folder(), + Icon::FolderOpen => path_folder_open(), + Icon::Save => path_save(), + Icon::Open => path_open(), + Icon::Plus => path_plus(), + Icon::Minus => path_minus(), + Icon::X => path_x(), + Icon::Check => path_check(), + Icon::Edit => path_edit(), + Icon::Trash => path_trash(), + Icon::ChevronUp => path_chevron(0.0), + Icon::ChevronDown => path_chevron(180.0), + Icon::ChevronLeft => path_chevron(270.0), + Icon::ChevronRight => path_chevron(90.0), + Icon::Home => path_home(), + Icon::Search => path_search(), + Icon::Info => path_info(), + Icon::Warning => path_warning(), + Icon::Error => path_error(), + Icon::Bell => path_bell(), + Icon::Settings => path_settings(), + Icon::More => path_more(), + Icon::Play => path_play(), + Icon::Pause => path_pause(), + Icon::Stop => path_stop(), + Icon::SkipBack => path_skip(true), + Icon::SkipForward => path_skip(false), + Icon::Rewind => path_seek(true), + Icon::FastForward => path_seek(false), + Icon::Volume => path_volume(false), + Icon::VolumeMute => path_volume(true), + Icon::Repeat => path_repeat(), + Icon::Shuffle => path_shuffle(), + Icon::Record => path_record(), + Icon::Equalizer => path_equalizer(), + Icon::Camera => path_camera(), + Icon::Gauge => path_gauge(), + Icon::Image => path_image(), + Icon::Music => path_music(), + Icon::Film => path_film(), + Icon::Archive => path_archive(), + Icon::Code => path_code(), + Icon::FileText => path_file_text(), + Icon::Link => path_link(), + Icon::Font => path_font(), + } + } +} + +/// Construye un `View` que pinta el icono ocupando todo el rect del +/// padre. El icono se escala uniformemente al mínimo lado y se centra. +/// +/// - `stroke_width` en unidades del grid 24×24 (típico: `1.6`). +pub fn icon_view( + icon: Icon, + color: Color, + stroke_width: f32, +) -> View { + View::new(Style { + position: Position::Absolute, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| { + paint_icon(scene, rect, icon, color, stroke_width); + }) +} + +/// Pintor crudo — útil cuando una app quiere stampear varios iconos +/// dentro del mismo `paint_with` (paneles compuestos, toolbars +/// generadas dinámicamente). +pub fn paint_icon( + scene: &mut llimphi_ui::llimphi_raster::vello::Scene, + rect: llimphi_ui::PaintRect, + icon: Icon, + color: Color, + stroke_width: f32, +) { + let side = rect.w.min(rect.h) as f64; + if side <= 0.0 { + return; + } + let scale = side / 24.0; + let tx = rect.x as f64 + (rect.w as f64 - side) * 0.5; + let ty = rect.y as f64 + (rect.h as f64 - side) * 0.5; + let xform = Affine::translate((tx, ty)) * Affine::scale(scale); + + let stroke = Stroke::new(stroke_width as f64) + .with_join(Join::Round) + .with_caps(Cap::Round); + let path = icon.path(); + scene.stroke(&stroke, xform, color, None, &path); +} + +// ===================================================================== +// Paths — todos en grid 24×24, origen top-left, eje Y hacia abajo +// ===================================================================== +// +// Cada path es geometría minimalista. Los joins y caps son Round (los +// fija el renderer), así que los corners salen suaves sin tener que +// definir curvas extra. + +fn path_file() -> BezPath { + // Documento: rectángulo con esquina superior-derecha plegada. + let mut p = BezPath::new(); + p.move_to((6.0, 3.0)); + p.line_to((14.0, 3.0)); + p.line_to((19.0, 8.0)); + p.line_to((19.0, 21.0)); + p.line_to((6.0, 21.0)); + p.close_path(); + // Pliegue: línea desde la esquina superior-derecha del file hasta + // donde "se dobla", luego al borde. + p.move_to((14.0, 3.0)); + p.line_to((14.0, 8.0)); + p.line_to((19.0, 8.0)); + p +} + +fn path_folder() -> BezPath { + // Folder cerrado: cuerpo + lengüeta arriba-izquierda. + let mut p = BezPath::new(); + p.move_to((3.0, 8.0)); + p.line_to((3.0, 19.0)); + p.line_to((21.0, 19.0)); + p.line_to((21.0, 8.0)); + p.line_to((11.0, 8.0)); + p.line_to((9.0, 5.0)); + p.line_to((3.0, 5.0)); + p.close_path(); + p +} + +fn path_folder_open() -> BezPath { + // Folder con tapa levantada: el "techo" se inclina hacia la derecha. + let mut p = BezPath::new(); + // Caja inferior. + p.move_to((3.0, 8.0)); + p.line_to((3.0, 19.0)); + p.line_to((21.0, 19.0)); + p.line_to((23.0, 11.0)); + p.line_to((7.0, 11.0)); + p.line_to((5.0, 19.0)); + // Lengüeta de la izquierda (sigue ahí). + p.move_to((3.0, 8.0)); + p.line_to((9.0, 8.0)); + p.line_to((11.0, 11.0)); + p.line_to((21.0, 11.0)); + p +} + +fn path_save() -> BezPath { + // Floppy: cuadrado con muesca top-right y rectángulo de label abajo. + let mut p = BezPath::new(); + p.move_to((4.0, 4.0)); + p.line_to((17.0, 4.0)); + p.line_to((20.0, 7.0)); + p.line_to((20.0, 20.0)); + p.line_to((4.0, 20.0)); + p.close_path(); + // Slot del shutter arriba. + p.move_to((8.0, 4.0)); + p.line_to((8.0, 9.0)); + p.line_to((15.0, 9.0)); + p.line_to((15.0, 4.0)); + // Rectángulo de label abajo. + p.move_to((7.0, 13.0)); + p.line_to((17.0, 13.0)); + p.line_to((17.0, 20.0)); + p.line_to((7.0, 20.0)); + p.close_path(); + p +} + +fn path_open() -> BezPath { + // Carpeta abriéndose hacia arriba con una flecha que entra. + let mut p = BezPath::new(); + // Folder base. + p.move_to((3.0, 19.0)); + p.line_to((3.0, 8.0)); + p.line_to((9.0, 8.0)); + p.line_to((11.0, 10.0)); + p.line_to((21.0, 10.0)); + p.line_to((21.0, 19.0)); + p.close_path(); + // Flecha entrando desde arriba al centro. + p.move_to((12.0, 2.0)); + p.line_to((12.0, 14.0)); + p.move_to((9.0, 11.0)); + p.line_to((12.0, 14.0)); + p.line_to((15.0, 11.0)); + p +} + +fn path_plus() -> BezPath { + let mut p = BezPath::new(); + p.move_to((12.0, 5.0)); + p.line_to((12.0, 19.0)); + p.move_to((5.0, 12.0)); + p.line_to((19.0, 12.0)); + p +} + +fn path_minus() -> BezPath { + let mut p = BezPath::new(); + p.move_to((5.0, 12.0)); + p.line_to((19.0, 12.0)); + p +} + +fn path_x() -> BezPath { + let mut p = BezPath::new(); + p.move_to((6.0, 6.0)); + p.line_to((18.0, 18.0)); + p.move_to((18.0, 6.0)); + p.line_to((6.0, 18.0)); + p +} + +fn path_check() -> BezPath { + let mut p = BezPath::new(); + p.move_to((5.0, 13.0)); + p.line_to((10.0, 18.0)); + p.line_to((20.0, 6.0)); + p +} + +fn path_edit() -> BezPath { + // Lápiz: cuerpo diagonal + punta + tag de borrador. + let mut p = BezPath::new(); + p.move_to((4.0, 20.0)); + p.line_to((8.0, 19.0)); + p.line_to((20.0, 7.0)); + p.line_to((17.0, 4.0)); + p.line_to((5.0, 16.0)); + p.close_path(); + p.move_to((14.0, 7.0)); + p.line_to((17.0, 10.0)); + p +} + +fn path_trash() -> BezPath { + // Tacho: tapa con manijita + cuerpo con tres barras verticales. + let mut p = BezPath::new(); + // Tapa. + p.move_to((4.0, 6.0)); + p.line_to((20.0, 6.0)); + // Manijita. + p.move_to((9.0, 6.0)); + p.line_to((9.0, 4.0)); + p.line_to((15.0, 4.0)); + p.line_to((15.0, 6.0)); + // Cuerpo. + p.move_to((6.0, 6.0)); + p.line_to((7.0, 21.0)); + p.line_to((17.0, 21.0)); + p.line_to((18.0, 6.0)); + // Barras internas. + p.move_to((10.0, 10.0)); + p.line_to((10.0, 17.0)); + p.move_to((14.0, 10.0)); + p.line_to((14.0, 17.0)); + p +} + +/// Chevron apuntando hacia arriba (default) o rotado por `angle_deg` +/// alrededor del centro del grid (12, 12). 90° = derecha, 180° = abajo, +/// 270° = izquierda. +fn path_chevron(angle_deg: f64) -> BezPath { + let mut p = BezPath::new(); + // Chevron base: forma de ^ apuntando arriba. + p.move_to((6.0, 14.0)); + p.line_to((12.0, 8.0)); + p.line_to((18.0, 14.0)); + let theta = angle_deg.to_radians(); + let center = (12.0, 12.0); + Affine::translate(center) + * Affine::rotate(theta) + * Affine::translate((-center.0, -center.1)) + * p +} + +fn path_home() -> BezPath { + // Casa: triángulo de techo + caja rectangular. + let mut p = BezPath::new(); + p.move_to((3.0, 12.0)); + p.line_to((12.0, 4.0)); + p.line_to((21.0, 12.0)); + // Cuerpo. + p.move_to((5.0, 11.0)); + p.line_to((5.0, 20.0)); + p.line_to((19.0, 20.0)); + p.line_to((19.0, 11.0)); + // Puerta. + p.move_to((10.0, 20.0)); + p.line_to((10.0, 14.0)); + p.line_to((14.0, 14.0)); + p.line_to((14.0, 20.0)); + p +} + +fn path_search() -> BezPath { + // Lupa: círculo (poligonal 16 segmentos) + mango diagonal. + let mut p = BezPath::new(); + let cx = 10.5; + let cy = 10.5; + let r = 5.5; + let segments = 24; + for i in 0..=segments { + let theta = std::f64::consts::TAU * (i as f64) / (segments as f64); + let x = cx + r * theta.cos(); + let y = cy + r * theta.sin(); + if i == 0 { + p.move_to((x, y)); + } else { + p.line_to((x, y)); + } + } + // Mango. + p.move_to((14.5, 14.5)); + p.line_to((20.0, 20.0)); + p +} + +fn path_info() -> BezPath { + // i: círculo + punto arriba + barra abajo. + let mut p = path_circle(12.0, 12.0, 9.0, 32); + // Punto. + p.move_to((12.0, 7.0)); + p.line_to((12.0, 8.5)); + // Barra. + p.move_to((12.0, 11.0)); + p.line_to((12.0, 17.0)); + p +} + +fn path_warning() -> BezPath { + // Triángulo con ! adentro. + let mut p = BezPath::new(); + p.move_to((12.0, 3.0)); + p.line_to((22.0, 21.0)); + p.line_to((2.0, 21.0)); + p.close_path(); + p.move_to((12.0, 10.0)); + p.line_to((12.0, 15.0)); + p.move_to((12.0, 17.5)); + p.line_to((12.0, 18.5)); + p +} + +fn path_error() -> BezPath { + // Círculo con X adentro. + let mut p = path_circle(12.0, 12.0, 9.0, 32); + p.move_to((8.5, 8.5)); + p.line_to((15.5, 15.5)); + p.move_to((15.5, 8.5)); + p.line_to((8.5, 15.5)); + p +} + +fn path_bell() -> BezPath { + // Campana: domo + base + badajo. + let mut p = BezPath::new(); + // Cuerpo con curva suave. + p.move_to((5.0, 17.0)); + p.curve_to((5.0, 8.0), (8.0, 5.0), (12.0, 5.0)); + p.curve_to((16.0, 5.0), (19.0, 8.0), (19.0, 17.0)); + p.close_path(); + // Base. + p.move_to((3.5, 17.0)); + p.line_to((20.5, 17.0)); + // Badajo. + p.move_to((10.5, 20.0)); + p.line_to((13.5, 20.0)); + p +} + +fn path_settings() -> BezPath { + // Engranaje: 8 dientes radiales + agujero central. + let mut p = BezPath::new(); + let cx = 12.0; + let cy = 12.0; + let inner_r = 6.5; + let outer_r = 9.5; + let teeth = 8; + for i in 0..teeth * 2 { + let theta = std::f64::consts::TAU * (i as f64) / (teeth as f64 * 2.0); + // Cada paso alterna entre inner y outer para formar los dientes. + let r = if i % 2 == 0 { outer_r } else { inner_r }; + let x = cx + r * theta.cos(); + let y = cy + r * theta.sin(); + if i == 0 { + p.move_to((x, y)); + } else { + p.line_to((x, y)); + } + } + p.close_path(); + // Agujero central. + let inner = path_circle(cx, cy, 3.0, 16); + for el in inner.elements() { + p.push(*el); + } + p +} + +fn path_more() -> BezPath { + // Tres puntos horizontales (cada "punto" es un círculo pequeño). + let mut p = BezPath::new(); + for (cx, cy) in &[(6.0, 12.0), (12.0, 12.0), (18.0, 12.0)] { + let dot = path_circle(*cx, *cy, 1.5, 12); + for el in dot.elements() { + p.push(*el); + } + } + p +} + +/// Helper: aproxima un círculo con `segments` lados rectos. Para iconos +/// stroke esto se ve liso a partir de ~16 segmentos por la suavidad del +/// Cap::Round. Más barato y más predecible que cubic Beziers para los +/// glifos chiquitos donde vivimos. +fn path_circle(cx: f64, cy: f64, r: f64, segments: usize) -> BezPath { + let mut p = BezPath::new(); + for i in 0..=segments { + let theta = std::f64::consts::TAU * (i as f64) / (segments as f64); + let x = cx + r * theta.cos(); + let y = cy + r * theta.sin(); + if i == 0 { + p.move_to((x, y)); + } else { + p.line_to((x, y)); + } + } + p +} + +// --------------------------------------------------------------------- +// Multimedia — transporte de reproductor (media-app y demás) +// --------------------------------------------------------------------- + +fn append(dst: &mut BezPath, src: &BezPath) { + for el in src.elements() { + dst.push(*el); + } +} + +fn path_play() -> BezPath { + // Triángulo apuntando a la derecha. + let mut p = BezPath::new(); + p.move_to((8.0, 5.0)); + p.line_to((8.0, 19.0)); + p.line_to((18.0, 12.0)); + p.close_path(); + p +} + +fn path_pause() -> BezPath { + // Dos barras verticales. + let mut p = BezPath::new(); + p.move_to((9.0, 6.0)); + p.line_to((9.0, 18.0)); + p.move_to((15.0, 6.0)); + p.line_to((15.0, 18.0)); + p +} + +fn path_stop() -> BezPath { + let mut p = BezPath::new(); + p.move_to((7.0, 7.0)); + p.line_to((17.0, 7.0)); + p.line_to((17.0, 17.0)); + p.line_to((7.0, 17.0)); + p.close_path(); + p +} + +/// Saltar pista: barra + triángulo (a la izquierda si `back`). +fn path_skip(back: bool) -> BezPath { + let mut p = BezPath::new(); + if back { + p.move_to((7.0, 6.0)); + p.line_to((7.0, 18.0)); + p.move_to((17.0, 6.0)); + p.line_to((17.0, 18.0)); + p.line_to((8.0, 12.0)); + p.close_path(); + } else { + p.move_to((7.0, 6.0)); + p.line_to((7.0, 18.0)); + p.line_to((16.0, 12.0)); + p.close_path(); + p.move_to((17.0, 6.0)); + p.line_to((17.0, 18.0)); + } + p +} + +/// Avance rápido: dos triángulos (a la izquierda si `rewind`). +fn path_seek(rewind: bool) -> BezPath { + let mut p = BezPath::new(); + if rewind { + p.move_to((11.0, 6.0)); + p.line_to((11.0, 18.0)); + p.line_to((4.0, 12.0)); + p.close_path(); + p.move_to((20.0, 6.0)); + p.line_to((20.0, 18.0)); + p.line_to((13.0, 12.0)); + p.close_path(); + } else { + p.move_to((4.0, 6.0)); + p.line_to((4.0, 18.0)); + p.line_to((11.0, 12.0)); + p.close_path(); + p.move_to((13.0, 6.0)); + p.line_to((13.0, 18.0)); + p.line_to((20.0, 12.0)); + p.close_path(); + } + p +} + +/// Altavoz; con ondas (normal) o con una X (mute). +fn path_volume(mute: bool) -> BezPath { + let mut p = BezPath::new(); + p.move_to((3.0, 9.0)); + p.line_to((8.0, 9.0)); + p.line_to((12.0, 5.0)); + p.line_to((12.0, 19.0)); + p.line_to((8.0, 15.0)); + p.line_to((3.0, 15.0)); + p.close_path(); + if mute { + p.move_to((15.0, 9.0)); + p.line_to((21.0, 15.0)); + p.move_to((21.0, 9.0)); + p.line_to((15.0, 15.0)); + } else { + p.move_to((15.0, 9.0)); + p.quad_to((17.5, 12.0), (15.0, 15.0)); + p.move_to((17.5, 7.0)); + p.quad_to((21.5, 12.0), (17.5, 17.0)); + } + p +} + +fn path_repeat() -> BezPath { + // Dos flechas horizontales opuestas (loop compacto). + let mut p = BezPath::new(); + p.move_to((6.0, 9.0)); + p.line_to((16.0, 9.0)); + p.move_to((14.0, 7.0)); + p.line_to((17.0, 9.0)); + p.line_to((14.0, 11.0)); + p.move_to((18.0, 15.0)); + p.line_to((8.0, 15.0)); + p.move_to((10.0, 13.0)); + p.line_to((7.0, 15.0)); + p.line_to((10.0, 17.0)); + p +} + +fn path_shuffle() -> BezPath { + // Dos flechas que se cruzan. + let mut p = BezPath::new(); + p.move_to((5.0, 8.0)); + p.line_to((19.0, 16.0)); + p.move_to((16.0, 15.5)); + p.line_to((20.0, 16.5)); + p.line_to((17.5, 13.0)); + p.move_to((5.0, 16.0)); + p.line_to((19.0, 8.0)); + p.move_to((17.5, 11.0)); + p.line_to((20.0, 7.5)); + p.line_to((16.0, 8.5)); + p +} + +fn path_record() -> BezPath { + path_circle(12.0, 12.0, 5.0, 20) +} + +fn path_equalizer() -> BezPath { + let mut p = BezPath::new(); + // Tres deslizadores verticales. + p.move_to((7.0, 5.0)); + p.line_to((7.0, 19.0)); + p.move_to((12.0, 5.0)); + p.line_to((12.0, 19.0)); + p.move_to((17.0, 5.0)); + p.line_to((17.0, 19.0)); + // Perillas a distinta altura. + p.move_to((5.0, 9.0)); + p.line_to((9.0, 9.0)); + p.move_to((10.0, 14.0)); + p.line_to((14.0, 14.0)); + p.move_to((15.0, 8.0)); + p.line_to((19.0, 8.0)); + p +} + +fn path_camera() -> BezPath { + let mut p = BezPath::new(); + p.move_to((4.0, 8.0)); + p.line_to((7.0, 8.0)); + p.line_to((9.0, 6.0)); + p.line_to((15.0, 6.0)); + p.line_to((17.0, 8.0)); + p.line_to((20.0, 8.0)); + p.line_to((20.0, 18.0)); + p.line_to((4.0, 18.0)); + p.close_path(); + append(&mut p, &path_circle(12.0, 13.0, 3.5, 16)); + p +} + +fn path_gauge() -> BezPath { + // Esfera + aguja (velocidad). + let mut p = path_circle(12.0, 13.0, 6.0, 20); + p.move_to((12.0, 13.0)); + p.line_to((16.0, 9.0)); + p +} + +// --------------------------------------------------------------------- +// Archivos — tipos por extensión (listados de file manager / shell) +// --------------------------------------------------------------------- + +fn path_image() -> BezPath { + // Marco con una montaña y un sol (el clásico "imagen"). + let mut p = BezPath::new(); + p.move_to((4.0, 5.0)); + p.line_to((20.0, 5.0)); + p.line_to((20.0, 19.0)); + p.line_to((4.0, 19.0)); + p.close_path(); + // Sol. + append(&mut p, &path_circle(8.5, 9.5, 1.6, 12)); + // Montaña (línea quebrada hasta el borde derecho). + p.move_to((4.0, 17.0)); + p.line_to((10.0, 12.0)); + p.line_to((14.0, 15.0)); + p.line_to((17.0, 12.0)); + p.line_to((20.0, 15.0)); + p +} + +fn path_music() -> BezPath { + // Nota musical: dos cabezas redondas unidas por una plica con bandera. + let mut p = BezPath::new(); + // Plicas. + p.move_to((9.0, 18.0)); + p.line_to((9.0, 6.0)); + p.line_to((19.0, 4.0)); + p.line_to((19.0, 16.0)); + // Cabeza izquierda. + append(&mut p, &path_circle(7.0, 18.0, 2.0, 14)); + // Cabeza derecha. + append(&mut p, &path_circle(17.0, 16.0, 2.0, 14)); + p +} + +fn path_film() -> BezPath { + // Tira de película: rectángulo con perforaciones a los lados. + let mut p = BezPath::new(); + p.move_to((4.0, 5.0)); + p.line_to((20.0, 5.0)); + p.line_to((20.0, 19.0)); + p.line_to((4.0, 19.0)); + p.close_path(); + // Rieles internos (separan perforaciones del cuadro central). + p.move_to((8.0, 5.0)); + p.line_to((8.0, 19.0)); + p.move_to((16.0, 5.0)); + p.line_to((16.0, 19.0)); + // Perforaciones (cuatro tics por lado). + for y in [7.5, 11.0, 14.5] { + p.move_to((4.0, y)); + p.line_to((8.0, y)); + p.move_to((16.0, y)); + p.line_to((20.0, y)); + } + p +} + +fn path_archive() -> BezPath { + // Caja/paquete: tapa arriba + cuerpo + tirador del cierre. + let mut p = BezPath::new(); + // Tapa. + p.move_to((3.0, 5.0)); + p.line_to((21.0, 5.0)); + p.line_to((21.0, 9.0)); + p.line_to((3.0, 9.0)); + p.close_path(); + // Cuerpo. + p.move_to((4.5, 9.0)); + p.line_to((4.5, 20.0)); + p.line_to((19.5, 20.0)); + p.line_to((19.5, 9.0)); + // Pestaña del cierre. + p.move_to((10.0, 12.0)); + p.line_to((14.0, 12.0)); + p +} + +fn path_code() -> BezPath { + // Corchetes angulares — universal para "código". + let mut p = BezPath::new(); + // Chevron izquierdo. + p.move_to((9.0, 7.0)); + p.line_to((4.0, 12.0)); + p.line_to((9.0, 17.0)); + // Chevron derecho. + p.move_to((15.0, 7.0)); + p.line_to((20.0, 12.0)); + p.line_to((15.0, 17.0)); + // Barra diagonal central. + p.move_to((13.0, 6.0)); + p.line_to((11.0, 18.0)); + p +} + +fn path_file_text() -> BezPath { + // Documento (como `file`) con líneas de texto adentro. + let mut p = path_file(); + p.move_to((8.5, 12.0)); + p.line_to((16.5, 12.0)); + p.move_to((8.5, 15.0)); + p.line_to((16.5, 15.0)); + p.move_to((8.5, 18.0)); + p.line_to((13.5, 18.0)); + p +} + +fn path_link() -> BezPath { + // Symlink: dos eslabones de cadena en diagonal. + let mut p = BezPath::new(); + // Eslabón superior-izquierdo (cápsula inclinada). + p.move_to((10.0, 14.0)); + p.line_to((7.0, 11.0)); + p.curve_to((5.0, 9.0), (5.0, 7.0), (7.0, 5.0)); + p.curve_to((9.0, 3.0), (11.0, 3.0), (13.0, 5.0)); + p.line_to((15.0, 7.0)); + // Eslabón inferior-derecho. + p.move_to((14.0, 10.0)); + p.line_to((17.0, 13.0)); + p.curve_to((19.0, 15.0), (19.0, 17.0), (17.0, 19.0)); + p.curve_to((15.0, 21.0), (13.0, 21.0), (11.0, 19.0)); + p.line_to((9.0, 17.0)); + p +} + +fn path_font() -> BezPath { + // Letra "A" serif — glifo de fuente tipográfica. + let mut p = BezPath::new(); + // Astas de la A. + p.move_to((6.0, 20.0)); + p.line_to((12.0, 4.0)); + p.line_to((18.0, 20.0)); + // Travesaño. + p.move_to((8.5, 14.0)); + p.line_to((15.5, 14.0)); + // Serifas inferiores. + p.move_to((4.5, 20.0)); + p.line_to((7.5, 20.0)); + p.move_to((16.5, 20.0)); + p.line_to((19.5, 20.0)); + p +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_icons_have_nonempty_path() { + let all = [ + Icon::File, Icon::Folder, Icon::FolderOpen, Icon::Save, Icon::Open, + Icon::Plus, Icon::Minus, Icon::X, Icon::Check, Icon::Edit, Icon::Trash, + Icon::ChevronUp, Icon::ChevronDown, Icon::ChevronLeft, Icon::ChevronRight, + Icon::Home, Icon::Search, Icon::Info, Icon::Warning, Icon::Error, + Icon::Bell, Icon::Settings, Icon::More, + Icon::Play, Icon::Pause, Icon::Stop, Icon::SkipBack, Icon::SkipForward, + Icon::Rewind, Icon::FastForward, Icon::Volume, Icon::VolumeMute, + Icon::Repeat, Icon::Shuffle, Icon::Record, Icon::Equalizer, + Icon::Camera, Icon::Gauge, + Icon::Image, Icon::Music, Icon::Film, Icon::Archive, + Icon::Code, Icon::FileText, Icon::Link, Icon::Font, + ]; + for icon in all { + let p = icon.path(); + assert!( + p.elements().len() > 0, + "icono {} produjo path vacío", + icon.name() + ); + } + } + + #[test] + fn icon_names_are_unique() { + let all = [ + Icon::File, Icon::Folder, Icon::FolderOpen, Icon::Save, Icon::Open, + Icon::Plus, Icon::Minus, Icon::X, Icon::Check, Icon::Edit, Icon::Trash, + Icon::ChevronUp, Icon::ChevronDown, Icon::ChevronLeft, Icon::ChevronRight, + Icon::Home, Icon::Search, Icon::Info, Icon::Warning, Icon::Error, + Icon::Bell, Icon::Settings, Icon::More, + Icon::Play, Icon::Pause, Icon::Stop, Icon::SkipBack, Icon::SkipForward, + Icon::Rewind, Icon::FastForward, Icon::Volume, Icon::VolumeMute, + Icon::Repeat, Icon::Shuffle, Icon::Record, Icon::Equalizer, + Icon::Camera, Icon::Gauge, + Icon::Image, Icon::Music, Icon::Film, Icon::Archive, + Icon::Code, Icon::FileText, Icon::Link, Icon::Font, + ]; + let mut names: Vec<&str> = all.iter().map(|i| i.name()).collect(); + let n = names.len(); + names.sort(); + names.dedup(); + assert_eq!(names.len(), n, "nombres duplicados en Icon::name()"); + } +} diff --git a/llimphi-layout/Cargo.toml b/llimphi-layout/Cargo.toml new file mode 100644 index 0000000..bbcb88b --- /dev/null +++ b/llimphi-layout/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "llimphi-layout" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true + +[dependencies] +taffy = { workspace = true } + +[dev-dependencies] +llimphi-hal = { path = "../llimphi-hal" } +llimphi-raster = { path = "../llimphi-raster" } +pollster = { workspace = true } + +[[example]] +name = "layout_panels" +path = "examples/layout_panels.rs" diff --git a/llimphi-layout/LEEME.md b/llimphi-layout/LEEME.md new file mode 100644 index 0000000..35f1fa1 --- /dev/null +++ b/llimphi-layout/LEEME.md @@ -0,0 +1,10 @@ +# llimphi-layout + +> Layout taffy + extensiones de [llimphi](../README.md). + +Wrapper sobre `taffy` (Flexbox + Grid) con tipos ergonómicos para `View`. Cache del layout calculado entre frames; invalidación dirigida cuando el árbol cambia. + +## Deps + +- `taffy`, `glam` +- `serde` diff --git a/llimphi-layout/README.md b/llimphi-layout/README.md new file mode 100644 index 0000000..01e1677 --- /dev/null +++ b/llimphi-layout/README.md @@ -0,0 +1,10 @@ +# llimphi-layout + +> Taffy layout + extensions of [llimphi](../README.md). + +Wrapper over `taffy` (Flexbox + Grid) with ergonomic types for `View`. Cache of computed layout between frames; directed invalidation when the tree changes. + +## Deps + +- `taffy`, `glam` +- `serde` diff --git a/llimphi-layout/examples/layout_panels.rs b/llimphi-layout/examples/layout_panels.rs new file mode 100644 index 0000000..f3ecdb2 --- /dev/null +++ b/llimphi-layout/examples/layout_panels.rs @@ -0,0 +1,250 @@ +//! Fase 3 de Llimphi: 3 paneles (sidebar + header/body/footer) que se +//! reorganizan al redimensionar la ventana. Pintados por vello a través +//! de llimphi-raster. +//! +//! Corre con: `cargo run -p llimphi-layout --example layout_panels --release`. + +use std::sync::Arc; + +use llimphi_hal::winit::application::ApplicationHandler; +use llimphi_hal::winit::dpi::LogicalSize; +use llimphi_hal::winit::event::WindowEvent; +use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId}; +use llimphi_hal::{Hal, Surface, WinitSurface}; +use llimphi_layout::{ + taffy::{prelude::*, Style}, + ComputedLayout, LayoutTree, Rect, +}; +use llimphi_raster::kurbo::{Affine, RoundedRect}; +use llimphi_raster::peniko::{color::palette, Color, Fill}; +use llimphi_raster::{vello, Renderer}; + +struct Panels { + sidebar: NodeId, + header: NodeId, + body: NodeId, + footer: NodeId, + root: NodeId, +} + +struct State { + window: Arc, + hal: Hal, + surface: WinitSurface, + renderer: Renderer, + scene: vello::Scene, + layout: LayoutTree, + panels: Panels, +} + +struct App { + state: Option, +} + +fn build_tree(layout: &mut LayoutTree) -> Panels { + let sidebar = layout + .leaf(Style { + size: Size { + width: length(220.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .unwrap(); + + let header = layout + .leaf(Style { + size: Size { + width: percent(1.0_f32), + height: length(64.0_f32), + }, + ..Default::default() + }) + .unwrap(); + + let body = layout + .leaf(Style { + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + flex_grow: 1.0, + ..Default::default() + }) + .unwrap(); + + let footer = layout + .leaf(Style { + size: Size { + width: percent(1.0_f32), + height: length(40.0_f32), + }, + ..Default::default() + }) + .unwrap(); + + let content = layout + .node( + Style { + flex_direction: FlexDirection::Column, + flex_grow: 1.0, + size: Size { + width: Dimension::auto(), + height: percent(1.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(8.0_f32), + }, + padding: Rect_(length(8.0_f32)), + ..Default::default() + }, + &[header, body, footer], + ) + .unwrap(); + + let root = layout + .node( + Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }, + &[sidebar, content], + ) + .unwrap(); + + Panels { + sidebar, + header, + body, + footer, + root, + } +} + +/// Helper para pasar el mismo length a todos los lados de un Rect. +#[allow(non_snake_case)] +fn Rect_(v: LengthPercentage) -> taffy::Rect { + taffy::Rect { + left: v, + right: v, + top: v, + bottom: v, + } +} + +fn paint(scene: &mut vello::Scene, computed: &ComputedLayout, panels: &Panels) { + fn rect(scene: &mut vello::Scene, r: Rect, color: Color, radius: f64) { + let rr = RoundedRect::new( + r.x as f64, + r.y as f64, + (r.x + r.w) as f64, + (r.y + r.h) as f64, + radius, + ); + scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &rr); + } + + if let Some(r) = computed.get(panels.sidebar) { + rect(scene, r, Color::from_rgba8(36, 44, 60, 255), 0.0); + } + if let Some(r) = computed.get(panels.header) { + rect(scene, r, Color::from_rgba8(60, 80, 110, 255), 8.0); + } + if let Some(r) = computed.get(panels.body) { + rect(scene, r, Color::from_rgba8(80, 110, 150, 255), 8.0); + } + if let Some(r) = computed.get(panels.footer) { + rect(scene, r, Color::from_rgba8(60, 80, 110, 255), 8.0); + } +} + +impl ApplicationHandler for App { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.state.is_some() { + return; + } + let window = event_loop + .create_window( + WindowAttributes::default() + .with_title("llimphi · layout_panels") + .with_inner_size(LogicalSize::new(960u32, 540u32)), + ) + .expect("create window"); + let window = Arc::new(window); + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let surface = WinitSurface::new(&hal, window.clone()).expect("surface"); + let renderer = Renderer::new(&hal).expect("renderer"); + let mut layout = LayoutTree::new(); + let panels = build_tree(&mut layout); + window.request_redraw(); + self.state = Some(State { + window, + hal, + surface, + renderer, + scene: vello::Scene::new(), + layout, + panels, + }); + } + + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + _id: WindowId, + event: WindowEvent, + ) { + let Some(state) = self.state.as_mut() else { + return; + }; + match event { + WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::Resized(size) => { + state.surface.resize(size.width, size.height); + state.window.request_redraw(); + } + WindowEvent::RedrawRequested => { + let frame = match state.surface.acquire() { + Ok(f) => f, + Err(_) => { + let (w, h) = state.surface.size(); + state.surface.resize(w, h); + state.window.request_redraw(); + return; + } + }; + let (w, h) = frame.size(); + let computed = state + .layout + .compute(state.panels.root, (w as f32, h as f32)) + .expect("compute layout"); + state.scene.reset(); + paint(&mut state.scene, &computed, &state.panels); + if let Err(e) = state.renderer.render( + &state.hal, + &state.scene, + &frame, + palette::css::BLACK, + ) { + eprintln!("render error: {e}"); + } + state.surface.present(frame, &state.hal); + state.window.request_redraw(); + } + _ => {} + } + } +} + +fn main() { + let event_loop = EventLoop::new().expect("event loop"); + event_loop.set_control_flow(ControlFlow::Poll); + let mut app = App { state: None }; + event_loop.run_app(&mut app).expect("run app"); +} diff --git a/llimphi-layout/src/lib.rs b/llimphi-layout/src/lib.rs new file mode 100644 index 0000000..b6932e5 --- /dev/null +++ b/llimphi-layout/src/lib.rs @@ -0,0 +1,184 @@ +//! llimphi-layout — Física del Espacio. +//! +//! Wrapper sobre `taffy` que resuelve árboles flex/grid y devuelve +//! coordenadas absolutas (no relativas al padre). El consumidor pasa el +//! árbol a `compute(root, viewport)` y obtiene un [`ComputedLayout`] con +//! un rect absoluto por nodo, listo para `llimphi-raster`. + +use std::collections::HashMap; + +pub use taffy; +pub use taffy::prelude::*; + +/// Errores del motor de layout. +#[derive(Debug)] +pub enum LayoutError { + Taffy(String), +} + +impl std::fmt::Display for LayoutError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Taffy(s) => write!(f, "taffy: {s}"), + } + } +} + +impl std::error::Error for LayoutError {} + +/// Caja absoluta de un nodo (origen en la esquina superior izquierda del viewport). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Rect { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + +/// Resultado de [`LayoutTree::compute`]: rect absoluto por nodo del árbol. +#[derive(Debug, Default)] +pub struct ComputedLayout { + pub rects: HashMap, +} + +impl ComputedLayout { + pub fn get(&self, node: NodeId) -> Option { + self.rects.get(&node).copied() + } +} + +/// Árbol de layout. Encapsula la `TaffyTree` y la lógica de absolutización. +pub struct LayoutTree { + inner: TaffyTree<()>, +} + +impl Default for LayoutTree { + fn default() -> Self { + Self::new() + } +} + +impl LayoutTree { + pub fn new() -> Self { + Self { + inner: TaffyTree::new(), + } + } + + /// Vacía el árbol conservando la capacidad ya asignada. Permite + /// reusar la misma `LayoutTree` entre frames sin re-allocar el + /// slotmap interno de taffy: `clear()` + `mount` en vez de + /// `LayoutTree::new()` por frame. Los `NodeId` emitidos antes de + /// `clear()` quedan inválidos (el caller ya volcó lo que necesitaba + /// a un `ComputedLayout`, que es dueño de sus rects). + pub fn clear(&mut self) { + self.inner.clear(); + } + + /// Crea una hoja (nodo sin hijos). + pub fn leaf(&mut self, style: Style) -> Result { + self.inner + .new_leaf(style) + .map_err(|e| LayoutError::Taffy(e.to_string())) + } + + /// Crea un nodo contenedor con hijos. + pub fn node(&mut self, style: Style, children: &[NodeId]) -> Result { + self.inner + .new_with_children(style, children) + .map_err(|e| LayoutError::Taffy(e.to_string())) + } + + /// Calcula el layout para `root` con viewport `(w, h)` y devuelve rects absolutos. + pub fn compute( + &mut self, + root: NodeId, + viewport: (f32, f32), + ) -> Result { + self.inner + .compute_layout( + root, + taffy::Size { + width: AvailableSpace::Definite(viewport.0), + height: AvailableSpace::Definite(viewport.1), + }, + ) + .map_err(|e| LayoutError::Taffy(e.to_string()))?; + let mut out = ComputedLayout::default(); + flatten(&self.inner, root, 0.0, 0.0, &mut out.rects)?; + Ok(out) + } + + /// Como [`Self::compute`] pero pasando una función de medición por + /// nodo. Taffy la invoca sobre las **hojas** que necesita dimensionar + /// (texto que envuelve, contenido intrínseco) con el `NodeId`, las + /// dimensiones ya conocidas y el espacio disponible; el caller devuelve + /// el tamaño en px. Devolver `Size::ZERO` deja que el estilo decida (el + /// comportamiento de [`Self::compute`] para hojas sin contenido). El + /// `NodeId` permite al caller mantener su propio mapa nodo→contenido + /// (p. ej. texto a shapear con parley) sin acoplar este crate a la capa + /// de tipografía. + pub fn compute_with_measure( + &mut self, + root: NodeId, + viewport: (f32, f32), + mut measure: F, + ) -> Result + where + F: FnMut(NodeId, taffy::Size>, taffy::Size) -> taffy::Size, + { + self.inner + .compute_layout_with_measure( + root, + taffy::Size { + width: AvailableSpace::Definite(viewport.0), + height: AvailableSpace::Definite(viewport.1), + }, + |known, available, node_id, _ctx, _style| { + measure(node_id, known, available) + }, + ) + .map_err(|e| LayoutError::Taffy(e.to_string()))?; + let mut out = ComputedLayout::default(); + flatten(&self.inner, root, 0.0, 0.0, &mut out.rects)?; + Ok(out) + } + + pub fn inner(&self) -> &TaffyTree<()> { + &self.inner + } + + pub fn inner_mut(&mut self) -> &mut TaffyTree<()> { + &mut self.inner + } +} + +fn flatten( + tree: &TaffyTree<()>, + node: NodeId, + ox: f32, + oy: f32, + out: &mut HashMap, +) -> Result<(), LayoutError> { + let layout = tree + .layout(node) + .map_err(|e| LayoutError::Taffy(e.to_string()))?; + let x = ox + layout.location.x; + let y = oy + layout.location.y; + out.insert( + node, + Rect { + x, + y, + w: layout.size.width, + h: layout.size.height, + }, + ); + let children = tree + .children(node) + .map_err(|e| LayoutError::Taffy(e.to_string()))?; + for child in children { + flatten(tree, child, x, y, out)?; + } + Ok(()) +} diff --git a/llimphi-motion/Cargo.toml b/llimphi-motion/Cargo.toml new file mode 100644 index 0000000..cb28654 --- /dev/null +++ b/llimphi-motion/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-motion" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-motion — Tween + helpers de animación integrados al bucle Elm de llimphi-ui (Handle::spawn_periodic). Lerp para f32, Color, (f32,f32). Easings comparten convenciones de llimphi-theme::motion." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/llimphi-motion/src/lib.rs b/llimphi-motion/src/lib.rs new file mode 100644 index 0000000..2b636d8 --- /dev/null +++ b/llimphi-motion/src/lib.rs @@ -0,0 +1,259 @@ +//! `llimphi-motion` — animaciones simples sobre el bucle Elm de Llimphi. +//! +//! Llimphi es Elm puro: `update(msg) -> model`. Para animar un valor en +//! el tiempo (un alpha que sube de 0 a 1, una posición que se desliza) +//! la app guarda un [`Tween`] en su modelo y pide al `Handle` que le +//! dispatchee un `Msg::Tick` periódicamente (cada ~16 ms) hasta que la +//! animación termine. Cada `update` lee `tween.value()` y la `view` la +//! pinta. +//! +//! Esta crate es deliberadamente chiquita: +//! - [`Lerp`] — interpolación lineal genérica (impls para `f32`, +//! `(f32, f32)` y `Color`). +//! - [`Tween`] — interpolación temporizada con easing entre dos valores. +//! - [`animate`] — helper que arranca un loop de ticks autosuficiente +//! sobre un `Handle`. +//! +//! Las duraciones y easings canónicos viven en [`llimphi_theme::motion`]. +//! +//! ## Patrón típico +//! +//! ```ignore +//! use llimphi_motion::{Tween, animate}; +//! use llimphi_theme::motion; +//! +//! enum Msg { ToastShow, Tick, ToastHidden } +//! struct Model { toast_alpha: Tween } +//! +//! // update: +//! Msg::ToastShow => { +//! model.toast_alpha = Tween::new(0.0, 1.0, motion::NORMAL, motion::ease_out_cubic); +//! animate(handle, motion::NORMAL, || Msg::Tick); +//! model +//! } +//! Msg::Tick => { +//! // El loop interno terminará solo cuando el tween esté done; +//! // la `view` ya lee el alpha actual sin más. +//! model +//! } +//! +//! // view: +//! toast_view().alpha(model.toast_alpha.value()) +//! ``` + +#![forbid(unsafe_code)] + +use std::time::{Duration, Instant}; + +pub use llimphi_theme::motion; +pub use llimphi_theme::Color; +use llimphi_ui::Handle; + +/// Interpolación lineal genérica entre `self` y `other` con factor `t` +/// en `[0.0, 1.0]`. Cada impl decide cómo combinar componentes; los +/// callers pasan `t` ya con el easing aplicado. +pub trait Lerp: Copy { + fn lerp(self, other: Self, t: f32) -> Self; +} + +impl Lerp for f32 { + #[inline] + fn lerp(self, other: Self, t: f32) -> Self { + self + (other - self) * t + } +} + +impl Lerp for f64 { + #[inline] + fn lerp(self, other: Self, t: f32) -> Self { + self + (other - self) * t as f64 + } +} + +impl Lerp for (f32, f32) { + #[inline] + fn lerp(self, other: Self, t: f32) -> Self { + (self.0.lerp(other.0, t), self.1.lerp(other.1, t)) + } +} + +impl Lerp for (f64, f64) { + #[inline] + fn lerp(self, other: Self, t: f32) -> Self { + (self.0.lerp(other.0, t), self.1.lerp(other.1, t)) + } +} + +impl Lerp for Color { + /// Interpolación componente a componente sobre los 4 canales RGBA + /// en espacio sRGB lineal-asumido. No es colorimetricamente correcto + /// (debería ser oklab), pero para fades de alpha/tinte de UI es + /// indistinguible y mucho más barato. + #[inline] + fn lerp(self, other: Self, t: f32) -> Self { + let a = self.components; + let b = other.components; + Color { + components: [ + a[0].lerp(b[0], t), + a[1].lerp(b[1], t), + a[2].lerp(b[2], t), + a[3].lerp(b[3], t), + ], + ..self + } + } +} + +/// Animación temporizada de un valor `T: Lerp` entre `from` y `to`. +/// +/// El tween es **observable**: la app llama [`Tween::value`] desde su +/// `view` y obtiene el valor interpolado para el frame actual. No tiene +/// estado mutable: el tiempo se mide contra un `Instant` de inicio, así +/// que el mismo `Tween` puede ser leído desde múltiples lugares sin +/// que se desincronice. +#[derive(Debug, Clone, Copy)] +pub struct Tween { + pub from: T, + pub to: T, + started: Instant, + pub duration: Duration, + /// Función de easing aplicada a `t ∈ [0, 1]` antes de interpolar. + /// Las canónicas viven en [`llimphi_theme::motion`]. + pub easing: fn(f32) -> f32, +} + +impl Tween { + /// Arranca el tween *ahora*. La primera lectura siguiente devuelve + /// `from`; cuando hayan pasado `duration` segundos, devuelve `to`. + pub fn new(from: T, to: T, duration: Duration, easing: fn(f32) -> f32) -> Self { + Self { + from, + to, + started: Instant::now(), + duration, + easing, + } + } + + /// Tween que ya está terminado y siempre devuelve el mismo valor. + /// Útil para inicializar un campo de modelo antes de cualquier animación. + pub fn idle(value: T) -> Self { + Self { + from: value, + to: value, + started: Instant::now() - Duration::from_secs(1), + duration: Duration::from_millis(1), + easing: motion::linear, + } + } + + /// Progreso normalizado en `[0.0, 1.0]`, ya con easing aplicado. + pub fn progress(&self) -> f32 { + if self.duration.is_zero() { + return 1.0; + } + let elapsed = self.started.elapsed().as_secs_f32(); + let t = (elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0); + (self.easing)(t) + } + + /// Valor actual interpolado. + pub fn value(&self) -> T { + self.from.lerp(self.to, self.progress()) + } + + /// `true` si la animación ya completó su `duration`. + pub fn done(&self) -> bool { + self.started.elapsed() >= self.duration + } +} + +/// Lanza un loop de ticks de animación que dispara `make_msg()` a ~60 Hz +/// durante `duration`, y se autodetiene cuando termina. El callback no +/// hace falta que verifique el tiempo: la app lee `tween.value()` y el +/// hilo interno se encarga de los frames. +/// +/// Cada tick dispatcha un `Msg` al `update` — la app no tiene que hacer +/// nada en ese update salvo, eventualmente, leer el `Tween` cuya +/// `progress()` cambió desde la última lectura. La `view` luego se +/// repinta con el valor interpolado del frame. +/// +/// **Detención**: el hilo de ticks vive `duration + 32ms` (un frame +/// extra de gracia para que el último tick caiga *después* del tope +/// del tween y la `view` final pinte el valor `to`). No hace falta +/// cancelar manualmente. Para tweens encadenados (A → B → C) la app +/// llama `animate()` de nuevo desde el `update` cuando el tween anterior +/// termina. +/// +/// Internamente usa un hilo dedicado (no `spawn_periodic`, que es +/// infinito) y dispatcha vía `Handle::dispatch` clonado. +pub fn animate(handle: &Handle, duration: Duration, make_msg: F) +where + F: Fn() -> Msg + Send + Sync + 'static, + Msg: Clone + Send + 'static, +{ + let frame = Duration::from_millis(16); + let total = duration + Duration::from_millis(32); + let handle = handle.clone(); + std::thread::spawn(move || { + let start = Instant::now(); + while start.elapsed() <= total { + handle.dispatch(make_msg()); + std::thread::sleep(frame); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lerp_f32_endpoints() { + assert!((0.0_f32.lerp(10.0, 0.0) - 0.0).abs() < 1e-6); + assert!((0.0_f32.lerp(10.0, 1.0) - 10.0).abs() < 1e-6); + assert!((0.0_f32.lerp(10.0, 0.5) - 5.0).abs() < 1e-6); + } + + #[test] + fn lerp_tuple_componentwise() { + let p = (0.0_f32, 100.0).lerp((10.0, 0.0), 0.5); + assert!((p.0 - 5.0).abs() < 1e-6); + assert!((p.1 - 50.0).abs() < 1e-6); + } + + #[test] + fn lerp_color_endpoints() { + let a = Color::from_rgba8(0, 0, 0, 0); + let b = Color::from_rgba8(255, 255, 255, 255); + let mid = a.lerp(b, 0.5); + let [r, g, bl, al] = mid.components; + assert!((r - 0.5).abs() < 1e-3); + assert!((g - 0.5).abs() < 1e-3); + assert!((bl - 0.5).abs() < 1e-3); + assert!((al - 0.5).abs() < 1e-3); + } + + #[test] + fn tween_idle_returns_constant_value() { + let t = Tween::idle(42.0_f32); + assert!((t.value() - 42.0).abs() < 1e-6); + assert!(t.done()); + } + + #[test] + fn tween_zero_duration_immediately_done() { + let t = Tween::new(0.0_f32, 1.0, Duration::ZERO, motion::linear); + assert!((t.progress() - 1.0).abs() < 1e-6); + assert!((t.value() - 1.0).abs() < 1e-6); + } + + #[test] + fn tween_progress_clamps_after_duration() { + let t = Tween::new(0.0_f32, 10.0, Duration::from_millis(1), motion::linear); + std::thread::sleep(Duration::from_millis(10)); + assert!((t.progress() - 1.0).abs() < 1e-6); + assert!((t.value() - 10.0).abs() < 1e-6); + } +} diff --git a/llimphi-raster/Cargo.toml b/llimphi-raster/Cargo.toml new file mode 100644 index 0000000..37ce9d1 --- /dev/null +++ b/llimphi-raster/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "llimphi-raster" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true + +[dependencies] +llimphi-hal = { path = "../llimphi-hal" } +vello = { workspace = true } +pollster = { workspace = true } + +[[example]] +name = "render_node" +path = "examples/render_node.rs" + +[[example]] +name = "spike_gpu_directo" +path = "examples/spike_gpu_directo.rs" + +[[example]] +name = "gpu_million_points" +path = "examples/gpu_million_points.rs" diff --git a/llimphi-raster/LEEME.md b/llimphi-raster/LEEME.md new file mode 100644 index 0000000..7e3e456 --- /dev/null +++ b/llimphi-raster/LEEME.md @@ -0,0 +1,10 @@ +# llimphi-raster + +> Rasterizer vello + cache de scenes de [llimphi](../README.md). + +Wrapper sobre `vello`/`wgpu` con cache LRU de `Scene`s pre-renderizadas (para layouts estáticos que no cambian frame a frame). Manejo de antialiasing, clipping, blend modes. Trabaja contra `Surface` del HAL. + +## Deps + +- [`llimphi-hal`](../llimphi-hal/README.md) +- `vello`, `wgpu`, `peniko`, `kurbo` diff --git a/llimphi-raster/README.md b/llimphi-raster/README.md new file mode 100644 index 0000000..c929b08 --- /dev/null +++ b/llimphi-raster/README.md @@ -0,0 +1,10 @@ +# llimphi-raster + +> Vello rasterizer + scene cache of [llimphi](../README.md). + +Wrapper over `vello`/`wgpu` with LRU cache of pre-rendered `Scene`s (for static layouts that don't change frame to frame). Antialiasing, clipping, blend modes. Works against the HAL's `Surface`. + +## Deps + +- [`llimphi-hal`](../llimphi-hal/README.md) +- `vello`, `wgpu`, `peniko`, `kurbo` diff --git a/llimphi-raster/examples/gpu_million_points.rs b/llimphi-raster/examples/gpu_million_points.rs new file mode 100644 index 0000000..1449bcb --- /dev/null +++ b/llimphi-raster/examples/gpu_million_points.rs @@ -0,0 +1,111 @@ +//! Demo headless del HAL GPU directo — Fase 6 del SDD +//! `02_ruway/llimphi/SDD.md` §"GPU directo wgpu". +//! +//! A diferencia de `spike_gpu_directo` (que compara vello vs un pipeline +//! mock para tomar la decisión arquitectónica), este ejemplo usa +//! directamente la API pública `GpuPipelines` + `GpuBatch` sobre N +//! puntos (rects 1.2×1.2 px) sintéticos. Su rol es: +//! +//! - Documentar el uso mínimo: 8 líneas de código + uso de Color. +//! - Ejercitar el HAL sin ninguna app (sin winit, sin runtime Elm). +//! - Servir de benchmark de referencia post-implementación: tiempo +//! total CPU+GPU para 100K / 500K / 1M / 5M rects. +//! +//! Corre con: `cargo run -p llimphi-raster --example gpu_million_points --release`. + +use std::io::Write; +use std::time::Instant; + +use llimphi_hal::{wgpu, Hal}; +use llimphi_raster::peniko::Color; +use llimphi_raster::{GpuBatch, GpuPipelines}; + +const W: u32 = 1024; +const H: u32 = 1024; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +const WARMUP: usize = 5; +const MEASURED: usize = 15; +const SIZES: &[u32] = &[100_000, 500_000, 1_000_000, 5_000_000]; + +fn main() { + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let pipelines = GpuPipelines::new(&hal.device, FMT); + + let (_tex, view) = make_target(&hal.device); + + println!(); + println!("gpu_million_points — GpuBatch + 3 pipelines · target {W}×{H} Rgba8Unorm"); + println!("warmup {WARMUP}, measured {MEASURED}"); + println!(" {:>10} | {:>14} | {:>14}", "N", "ms / frame", "Mprim/s"); + println!(" {:->10} + {:->14} + {:->14}", "", "", ""); + + for &n in SIZES { + let ms = bench(&hal, &pipelines, &view, n); + let throughput = (n as f64 / 1_000_000.0) / (ms / 1000.0); + println!(" {:>10} | {:>14.3} | {:>14.2}", n, ms, throughput); + let _ = std::io::stdout().flush(); + } + println!(); + println!("(en llvmpipe estos números son CPU-bound — ver Fase 0 del SDD)"); + println!(); +} + +fn make_target(device: &wgpu::Device) -> (wgpu::Texture, wgpu::TextureView) { + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("gpu_million_points-target"), + size: wgpu::Extent3d { + width: W, + height: H, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); + (tex, view) +} + +fn bench(hal: &Hal, pipelines: &GpuPipelines, view: &wgpu::TextureView, n: u32) -> f64 { + let mut samples: Vec = Vec::with_capacity(MEASURED); + for frame in 0..(WARMUP + MEASURED) { + let t0 = Instant::now(); + let mut batch = GpuBatch::new(pipelines); + let mut state: u32 = 0x1234_5678; + for _ in 0..n { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let x = (state % W) as f32; + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let y = (state % H) as f32; + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let r = ((state >> 0) & 0xFF) as f32 / 255.0; + let g = ((state >> 8) & 0xFF) as f32 / 255.0; + let b = ((state >> 16) & 0xFF) as f32 / 255.0; + batch.add_rect(x, y, 1.2, 1.2, Color::new([r, g, b, 1.0])); + } + let mut encoder = hal.device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { + label: Some("gpu_million_points-enc"), + }, + ); + batch.flush( + &hal.device, + &hal.queue, + &mut encoder, + view, + (W as f32, H as f32), + wgpu::LoadOp::Clear(wgpu::Color::BLACK), + ); + hal.queue.submit(std::iter::once(encoder.finish())); + hal.device.poll(wgpu::Maintain::Wait); + let dt = t0.elapsed().as_secs_f64() * 1000.0; + if frame >= WARMUP { + samples.push(dt); + } + } + samples.sort_by(|a, b| a.partial_cmp(b).unwrap()); + samples[samples.len() / 2] +} diff --git a/llimphi-raster/examples/render_node.rs b/llimphi-raster/examples/render_node.rs new file mode 100644 index 0000000..dcfa31a --- /dev/null +++ b/llimphi-raster/examples/render_node.rs @@ -0,0 +1,143 @@ +//! Fase 2 de Llimphi: un nodo (círculo + halo) renderizado por vello con AA +//! perfecto sobre el swapchain de llimphi-hal. +//! +//! Corre con: `cargo run -p llimphi-raster --example render_node --release`. + +use std::sync::Arc; +use std::time::Instant; + +use llimphi_hal::winit::application::ApplicationHandler; +use llimphi_hal::winit::dpi::LogicalSize; +use llimphi_hal::winit::event::WindowEvent; +use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId}; +use llimphi_hal::{Hal, Surface, WinitSurface}; +use llimphi_raster::kurbo::{Affine, Circle, Stroke}; +use llimphi_raster::peniko::{color::palette, Color, Fill}; +use llimphi_raster::{vello, Renderer}; + +struct State { + window: Arc, + hal: Hal, + surface: WinitSurface, + renderer: Renderer, + scene: vello::Scene, +} + +struct App { + state: Option, + started: Instant, +} + +impl ApplicationHandler for App { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.state.is_some() { + return; + } + let window = event_loop + .create_window( + WindowAttributes::default() + .with_title("llimphi · render_node") + .with_inner_size(LogicalSize::new(960u32, 540u32)), + ) + .expect("create window"); + let window = Arc::new(window); + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let surface = WinitSurface::new(&hal, window.clone()).expect("surface"); + let renderer = Renderer::new(&hal).expect("renderer"); + window.request_redraw(); + self.state = Some(State { + window, + hal, + surface, + renderer, + scene: vello::Scene::new(), + }); + } + + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + _id: WindowId, + event: WindowEvent, + ) { + let Some(state) = self.state.as_mut() else { + return; + }; + match event { + WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::Resized(size) => { + state.surface.resize(size.width, size.height); + state.window.request_redraw(); + } + WindowEvent::RedrawRequested => { + let frame = match state.surface.acquire() { + Ok(f) => f, + Err(_) => { + let (w, h) = state.surface.size(); + state.surface.resize(w, h); + state.window.request_redraw(); + return; + } + }; + let (w, h) = frame.size(); + state.scene.reset(); + build_node(&mut state.scene, w as f64, h as f64, self.started.elapsed().as_secs_f64()); + if let Err(e) = state.renderer.render( + &state.hal, + &state.scene, + &frame, + palette::css::BLACK, + ) { + eprintln!("render error: {e}"); + } + state.surface.present(frame, &state.hal); + state.window.request_redraw(); + } + _ => {} + } + } +} + +/// Pinta un nodo centrado (círculo lleno + halo) que respira con `t`. +fn build_node(scene: &mut vello::Scene, w: f64, h: f64, t: f64) { + let cx = w * 0.5; + let cy = h * 0.5; + let pulse = 1.0 + 0.06 * (t * 1.6).sin(); + let r = (h.min(w) * 0.18) * pulse; + + // Halo + scene.stroke( + &Stroke::new(2.0), + Affine::IDENTITY, + Color::from_rgba8(60, 120, 200, 180), + None, + &Circle::new((cx, cy), r * 1.35), + ); + // Cuerpo + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + Color::from_rgba8(90, 160, 230, 255), + None, + &Circle::new((cx, cy), r), + ); + // Borde + scene.stroke( + &Stroke::new(3.0), + Affine::IDENTITY, + Color::from_rgba8(20, 50, 100, 255), + None, + &Circle::new((cx, cy), r), + ); +} + +fn main() { + let event_loop = EventLoop::new().expect("event loop"); + event_loop.set_control_flow(ControlFlow::Poll); + let mut app = App { + state: None, + started: Instant::now(), + }; + event_loop.run_app(&mut app).expect("run app"); +} diff --git a/llimphi-raster/examples/spike_gpu_directo.rs b/llimphi-raster/examples/spike_gpu_directo.rs new file mode 100644 index 0000000..5d82cbd --- /dev/null +++ b/llimphi-raster/examples/spike_gpu_directo.rs @@ -0,0 +1,390 @@ +//! Spike Fase 0 — GPU directo vs vello. +//! +//! Compara el tiempo total CPU+GPU por frame para pintar N puntos en una +//! textura `Rgba8Unorm` 1024×1024 con dos estrategias: +//! +//! - **Vello**: una llamada `Scene::fill(Rect 1×1)` por punto, luego +//! `vello::Renderer::render_to_texture`. +//! - **GPU directo**: un pipeline `wgpu` con instanced quad. Cada punto es +//! una instancia `[x:f32, y:f32, rgba:u32]`. Una sola draw call. +//! +//! Tamaños: 100K, 500K, 1M puntos. 10 frames de warmup + 20 medidos por +//! tamaño. Reporta mediana y factor de aceleración. +//! +//! Criterio de aceptación del SDD (`llimphi/SDD.md` §"GPU directo wgpu"): +//! factor ≥ 5× a 500K → seguir con Fase 1. Si no, abortar. +//! +//! Corre con: `cargo run -p llimphi-raster --example spike_gpu_directo --release`. + +use std::io::Write; +use std::time::Instant; + +use llimphi_hal::{wgpu, Hal}; +use llimphi_raster::{ + kurbo::{Affine, Rect}, + peniko::{color::palette, Color, Fill}, + vello, +}; + +const W: u32 = 1024; +const H: u32 = 1024; +const TARGET_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +const WARMUP_FRAMES: usize = 5; +const MEASURED_FRAMES: usize = 15; +// Vello revienta (SIGSEGV en `vello_encoding::path::flatten`) cuando la +// escena pasa de ~200K paths con los `Limits::default()` que pide el HAL. +// Es exactamente el techo del SDD §"GPU directo wgpu". Lo medimos hasta +// donde vello aguanta; el lado directo se mide a sizes mucho mayores para +// confirmar el régimen post-techo. +const VELLO_SIZES: &[usize] = &[25_000, 50_000, 100_000, 200_000]; +const DIRECTO_SIZES: &[usize] = &[100_000, 500_000, 1_000_000, 5_000_000]; + +fn main() { + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + + // Textura destino compartida por ambos backends. STORAGE_BINDING para + // vello (compute), RENDER_ATTACHMENT para el pipeline directo. Idéntica + // al `intermediate` de `WinitSurface` (HAL real). + let (target, target_view) = create_target(&hal.device); + + let mut vello_renderer = vello::Renderer::new( + &hal.device, + vello::RendererOptions { + use_cpu: false, + antialiasing_support: vello::AaSupport { + area: true, + msaa8: false, + msaa16: false, + }, + num_init_threads: None, + pipeline_cache: None, + }, + ) + .expect("vello renderer"); + + let directo = DirectoPipeline::new(&hal.device); + + println!(); + println!("spike GPU directo — target {W}×{H} Rgba8Unorm, headless"); + println!("warmup {WARMUP_FRAMES}, measured {MEASURED_FRAMES}"); + println!(); + println!("vello (scene.fill por punto):"); + println!(" {:>10} | {:>14}", "N", "ms / frame"); + println!(" {:->10} + {:->14}", "", ""); + let mut vello_100k_ms: Option = None; + for &n in VELLO_SIZES { + let points = gen_points(n); + let ms = bench_vello(&hal, &mut vello_renderer, &target_view, &points); + println!(" {:>10} | {:>14.3}", n, ms); + let _ = std::io::stdout().flush(); + if n == 100_000 { + vello_100k_ms = Some(ms); + } + } + println!(); + println!("GPU directo (instanced quad, 1 draw call):"); + println!(" {:>10} | {:>14}", "N", "ms / frame"); + println!(" {:->10} + {:->14}", "", ""); + let mut directo_100k_ms: Option = None; + for &n in DIRECTO_SIZES { + let points = gen_points(n); + let ms = bench_directo(&hal, &directo, &target_view, &points); + println!(" {:>10} | {:>14.3}", n, ms); + let _ = std::io::stdout().flush(); + if n == 100_000 { + directo_100k_ms = Some(ms); + } + } + println!(); + if let (Some(v), Some(d)) = (vello_100k_ms, directo_100k_ms) { + let factor = v / d; + let verdict = if factor >= 5.0 { "PASA" } else { "ABORTAR" }; + println!( + "veredicto Fase 0 @ 100K: vello {:.2} ms / directo {:.2} ms = {:.2}× → {}", + v, d, factor, verdict + ); + println!("(SDD pide ≥5× a 500K, pero vello no llega a 500K — techo medido <300K)"); + } + println!(); + // Mantener vivo el texture para evitar warnings. + drop(target); +} + +fn create_target(device: &wgpu::Device) -> (wgpu::Texture, wgpu::TextureView) { + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("spike-target"), + size: wgpu::Extent3d { + width: W, + height: H, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: TARGET_FORMAT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); + (tex, view) +} + +/// LCG numerical recipes — determinista, sin dependencias. +fn gen_points(n: usize) -> Vec<(f32, f32, u32)> { + let mut state: u32 = 0x1234_5678; + let mut out = Vec::with_capacity(n); + for _ in 0..n { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let x = (state % W) as f32; + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let y = (state % H) as f32; + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + // RGBA packed little-endian: R en byte bajo (queda igual a como lo + // lee el shader: `rgba & 0xFF` → R). + let rgba = (state & 0x00FF_FFFF) | 0xFF00_0000; + out.push((x, y, rgba)); + } + out +} + +fn bench_vello( + hal: &Hal, + renderer: &mut vello::Renderer, + target: &wgpu::TextureView, + points: &[(f32, f32, u32)], +) -> f64 { + let mut scene = vello::Scene::new(); + let mut samples: Vec = Vec::with_capacity(MEASURED_FRAMES); + for frame in 0..(WARMUP_FRAMES + MEASURED_FRAMES) { + let t0 = Instant::now(); + scene.reset(); + for &(x, y, rgba) in points { + let r = (rgba & 0xFF) as u8; + let g = ((rgba >> 8) & 0xFF) as u8; + let b = ((rgba >> 16) & 0xFF) as u8; + let a = ((rgba >> 24) & 0xFF) as u8; + let xf = x as f64; + let yf = y as f64; + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + Color::from_rgba8(r, g, b, a), + None, + &Rect::new(xf, yf, xf + 1.0, yf + 1.0), + ); + } + renderer + .render_to_texture( + &hal.device, + &hal.queue, + &scene, + target, + &vello::RenderParams { + base_color: palette::css::BLACK, + width: W, + height: H, + antialiasing_method: vello::AaConfig::Area, + }, + ) + .expect("vello render"); + // Bloquear hasta que la GPU termine este frame. Sin esto medimos + // sólo el submit + queue building, no el trabajo real. + hal.device.poll(wgpu::Maintain::Wait); + let dt = t0.elapsed().as_secs_f64() * 1000.0; + if frame >= WARMUP_FRAMES { + samples.push(dt); + } + } + median(&mut samples) +} + +fn bench_directo( + hal: &Hal, + pipe: &DirectoPipeline, + target: &wgpu::TextureView, + points: &[(f32, f32, u32)], +) -> f64 { + // Buffer de instancias dimensionado para el peor caso. + let bytes_per_inst = std::mem::size_of::<[u32; 3]>(); // [x:f32, y:f32, rgba:u32] = 12B + let inst_buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("spike-directo-inst"), + size: (points.len() * bytes_per_inst) as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let mut samples: Vec = Vec::with_capacity(MEASURED_FRAMES); + for frame in 0..(WARMUP_FRAMES + MEASURED_FRAMES) { + let t0 = Instant::now(); + // Empaquetar instancias: igual a la "scene build" del lado vello, + // para que la comparación sea fair (ambos parten de los mismos + // puntos crudos). + let bytes = pack_instances(points); + hal.queue.write_buffer(&inst_buf, 0, &bytes); + + let mut encoder = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("spike-directo-enc"), + }); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("spike-directo-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + pass.set_pipeline(&pipe.pipeline); + pass.set_vertex_buffer(0, inst_buf.slice(..)); + // 6 vértices por instancia (2 tris = quad), N instancias. + pass.draw(0..6, 0..points.len() as u32); + } + hal.queue.submit(std::iter::once(encoder.finish())); + hal.device.poll(wgpu::Maintain::Wait); + let dt = t0.elapsed().as_secs_f64() * 1000.0; + if frame >= WARMUP_FRAMES { + samples.push(dt); + } + } + median(&mut samples) +} + +fn pack_instances(points: &[(f32, f32, u32)]) -> Vec { + let mut v = Vec::with_capacity(points.len() * 12); + for &(x, y, rgba) in points { + v.extend_from_slice(&x.to_ne_bytes()); + v.extend_from_slice(&y.to_ne_bytes()); + v.extend_from_slice(&rgba.to_ne_bytes()); + } + v +} + +fn median(samples: &mut [f64]) -> f64 { + samples.sort_by(|a, b| a.partial_cmp(b).unwrap()); + samples[samples.len() / 2] +} + +/// Pipeline trivial para el bench: instanced quad sin texturas, color +/// per-instance. No es código de producción — es el "mock GPU directo" +/// que pide la Fase 0 del SDD para medir el techo alcanzable. +struct DirectoPipeline { + pipeline: wgpu::RenderPipeline, +} + +impl DirectoPipeline { + fn new(device: &wgpu::Device) -> Self { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("spike-directo-shader"), + source: wgpu::ShaderSource::Wgsl(WGSL.into()), + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("spike-directo-layout"), + bind_group_layouts: &[], + push_constant_ranges: &[], + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("spike-directo-pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs"), + compilation_options: Default::default(), + buffers: &[wgpu::VertexBufferLayout { + array_stride: 12, + step_mode: wgpu::VertexStepMode::Instance, + attributes: &[ + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x2, + offset: 0, + shader_location: 0, + }, + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Uint32, + offset: 8, + shader_location: 1, + }, + ], + }], + }, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + unclipped_depth: false, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format: TARGET_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview: None, + cache: None, + }); + Self { pipeline } + } +} + +const WGSL: &str = r#" +struct Inst { + @location(0) xy: vec2, + @location(1) rgba: u32, +}; + +struct V2F { + @builtin(position) pos: vec4, + @location(0) color: vec4, +}; + +const W: f32 = 1024.0; +const H: f32 = 1024.0; + +@vertex +fn vs(@builtin(vertex_index) vid: u32, inst: Inst) -> V2F { + // Quad 1.5px alrededor de (inst.xy + 0.5). Pixel-centered. + var corners = array, 6>( + vec2(-0.75, -0.75), + vec2( 0.75, -0.75), + vec2( 0.75, 0.75), + vec2(-0.75, -0.75), + vec2( 0.75, 0.75), + vec2(-0.75, 0.75), + ); + let off = corners[vid]; + let px = inst.xy + vec2(0.5, 0.5) + off; + // pixel → NDC, Y invertido (vello / textura framebuffer). + let ndc = vec2(px.x / W * 2.0 - 1.0, 1.0 - px.y / H * 2.0); + + let r = f32( inst.rgba & 0xFFu) / 255.0; + let g = f32((inst.rgba >> 8u) & 0xFFu) / 255.0; + let b = f32((inst.rgba >> 16u) & 0xFFu) / 255.0; + let a = f32((inst.rgba >> 24u) & 0xFFu) / 255.0; + + var out: V2F; + out.pos = vec4(ndc, 0.0, 1.0); + out.color = vec4(r, g, b, a); + return out; +} + +@fragment +fn fs(in: V2F) -> @location(0) vec4 { + return in.color; +} +"#; diff --git a/llimphi-raster/src/gpu.rs b/llimphi-raster/src/gpu.rs new file mode 100644 index 0000000..d25885b --- /dev/null +++ b/llimphi-raster/src/gpu.rs @@ -0,0 +1,553 @@ +//! Backend GPU directo (Fases 2 + 3 del SDD §"GPU directo wgpu"). +//! +//! Tres pipelines `wgpu` cacheadas en [`GpuPipelines`] (lines / tris / +//! rects) + un acumulador [`GpuBatch`] que las apps usan por frame para +//! emitir centenares de miles a millones de primitivos en una draw call +//! por tipo, sin pasar por vello. +//! +//! Diseño minimal Fase 2/3: +//! +//! - Vertex format triángulos: `[x: f32, y: f32, rgba: u32]` (12 B/vert). +//! - Instance format líneas: `[x0, y0, x1, y1, rgba]` (20 B/seg). +//! - Instance format rects: `[x, y, w, h, rgba]` (20 B/rect). +//! - Sin texturas. Sin AA por shader — quien necesite AA fino sigue por +//! vello. Para puntos densos el "popping" no se nota. +//! - Blending alfa habilitado: el alpha del color es respetado. +//! - El viewport `(width, height)` se pasa al flush y va en un uniform — +//! los shaders convierten pixel → NDC ahí. +//! +//! Cache de pipelines: una sola instancia de `GpuPipelines` por +//! `(device, color_format)`. Construirla compila los 3 pipelines en +//! caliente (~ms en hardware moderno). Los callers la mantienen viva +//! entre frames (en su Model o vía `OnceLock`). +//! +//! Grow strategy: `flush` crea un buffer por tipo no vacío en el +//! mismo frame. Sin reuso entre frames — Fase 4 (`GpuSceneCanvas`) +//! introducirá el `GpuBuffers` persistente que dobla capacidad si +//! aparece la necesidad. + +use llimphi_hal::wgpu; +use vello::peniko::Color; + +/// Pipelines cacheadas. Crear uno por proceso (o por surface format). +/// +/// Para uso típico via [`GpuBatch`] los campos no se tocan directo. La +/// API pública existe para callers avanzados que quieran montar su propio +/// buffer persistente (datos que no cambian por frame: starfield Gaia, +/// particles iniciales, viewport estático) y emitir draw calls +/// manualmente reusando estas pipelines. +/// +/// Layouts: +/// - Vertex buffer triángulos: `[x: f32, y: f32, rgba: u32]` (12 B/vert). +/// - Instance buffer rects: `[x, y, w, h, rgba]` (20 B/inst). +/// - Instance buffer líneas: `[x0, y0, x1, y1, rgba]` (20 B/inst). +/// - Bind group 0 binding 0: uniform `{viewport: vec2, line_width: f32, _pad: f32}` (16 B). +pub struct GpuPipelines { + pub lines: wgpu::RenderPipeline, + pub tris: wgpu::RenderPipeline, + pub rects: wgpu::RenderPipeline, + pub bind_layout: wgpu::BindGroupLayout, +} + +impl GpuPipelines { + /// Compila los 3 pipelines apuntando al `color_format` del target + /// que recibirán en `flush` (el de la intermediate de `WinitSurface`, + /// normalmente `Rgba8Unorm`). + pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("llimphi-raster-gpu-shader"), + source: wgpu::ShaderSource::Wgsl(WGSL.into()), + }); + + let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("llimphi-raster-gpu-bgl"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("llimphi-raster-gpu-pl"), + bind_group_layouts: &[&bind_layout], + push_constant_ranges: &[], + }); + + let color_targets = [Some(wgpu::ColorTargetState { + format: color_format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })]; + + // Triángulos (vertex buffer plano, color per-vertex). + let tris = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("llimphi-raster-gpu-tris"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_tris"), + compilation_options: Default::default(), + buffers: &[wgpu::VertexBufferLayout { + array_stride: 12, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[ + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x2, + offset: 0, + shader_location: 0, + }, + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Uint32, + offset: 8, + shader_location: 1, + }, + ], + }], + }, + primitive: tri_primitive(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs"), + compilation_options: Default::default(), + targets: &color_targets, + }), + multiview: None, + cache: None, + }); + + // Rects (instanced quad). + let rects = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("llimphi-raster-gpu-rects"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_rects"), + compilation_options: Default::default(), + buffers: &[wgpu::VertexBufferLayout { + array_stride: 20, + step_mode: wgpu::VertexStepMode::Instance, + attributes: &[ + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x2, + offset: 0, + shader_location: 0, + }, + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x2, + offset: 8, + shader_location: 1, + }, + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Uint32, + offset: 16, + shader_location: 2, + }, + ], + }], + }, + primitive: tri_primitive(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs"), + compilation_options: Default::default(), + targets: &color_targets, + }), + multiview: None, + cache: None, + }); + + // Líneas con grosor: cada segmento es una instancia de 20 B; el + // VS expande a un quad de 6 vértices perpendicular al segmento + // usando un grosor uniforme en píxeles (vienen del uniform). + let lines = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("llimphi-raster-gpu-lines"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_lines"), + compilation_options: Default::default(), + buffers: &[wgpu::VertexBufferLayout { + array_stride: 20, + step_mode: wgpu::VertexStepMode::Instance, + attributes: &[ + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x4, + offset: 0, + shader_location: 0, + }, + wgpu::VertexAttribute { + format: wgpu::VertexFormat::Uint32, + offset: 16, + shader_location: 1, + }, + ], + }], + }, + primitive: tri_primitive(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs"), + compilation_options: Default::default(), + targets: &color_targets, + }), + multiview: None, + cache: None, + }); + + Self { + lines, + tris, + rects, + bind_layout, + } + } +} + +fn tri_primitive() -> wgpu::PrimitiveState { + wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + unclipped_depth: false, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + } +} + +/// Acumulador de primitivas por frame. Construir → `add_*` → `flush`. +pub struct GpuBatch<'a> { + pipelines: &'a GpuPipelines, + line_verts: Vec, + tri_verts: Vec, + rect_insts: Vec, + line_width: f32, + line_count: u32, + tri_vert_count: u32, + rect_count: u32, +} + +impl<'a> GpuBatch<'a> { + pub fn new(pipelines: &'a GpuPipelines) -> Self { + Self { + pipelines, + line_verts: Vec::new(), + tri_verts: Vec::new(), + rect_insts: Vec::new(), + line_width: 1.0, + line_count: 0, + tri_vert_count: 0, + rect_count: 0, + } + } + + /// Grosor de las próximas líneas (en pixels del frame, sin AA). + /// Se aplica a todas las líneas del batch — el lado bueno de una + /// sola draw call es que sólo hay un grosor "vivo" por flush. + pub fn line_width(&mut self, w: f32) { + self.line_width = w; + } + + /// Añade un segmento de línea como instancia. + pub fn add_line(&mut self, p0: (f32, f32), p1: (f32, f32), color: Color) { + let rgba = pack_rgba(color); + self.line_verts.extend_from_slice(&p0.0.to_ne_bytes()); + self.line_verts.extend_from_slice(&p0.1.to_ne_bytes()); + self.line_verts.extend_from_slice(&p1.0.to_ne_bytes()); + self.line_verts.extend_from_slice(&p1.1.to_ne_bytes()); + self.line_verts.extend_from_slice(&rgba.to_ne_bytes()); + self.line_count += 1; + } + + /// Añade una polilínea como secuencia de segmentos individuales + /// (line-list). Para N puntos emite N-1 instancias. + pub fn add_polyline(&mut self, points: &[(f32, f32)], color: Color) { + if points.len() < 2 { + return; + } + for w in points.windows(2) { + self.add_line(w[0], w[1], color); + } + } + + /// Añade un triángulo con color por vértice. + pub fn add_tri( + &mut self, + a: (f32, f32), + b: (f32, f32), + c: (f32, f32), + ca: Color, + cb: Color, + cc: Color, + ) { + self.push_tri_vert(a, ca); + self.push_tri_vert(b, cb); + self.push_tri_vert(c, cc); + } + + fn push_tri_vert(&mut self, p: (f32, f32), color: Color) { + let rgba = pack_rgba(color); + self.tri_verts.extend_from_slice(&p.0.to_ne_bytes()); + self.tri_verts.extend_from_slice(&p.1.to_ne_bytes()); + self.tri_verts.extend_from_slice(&rgba.to_ne_bytes()); + self.tri_vert_count += 1; + } + + /// Añade un triangle list crudo `[(x, y); 3*N]` con un mismo color + /// uniforme por vértice. Útil para teselaciones precomputadas + /// (contornos, polígonos rellenos). + pub fn add_tri_list(&mut self, verts: &[(f32, f32)], color: Color) { + for &p in verts { + self.push_tri_vert(p, color); + } + } + + /// Añade un rectángulo lleno como instancia (sin radio — para + /// rounded rects sigue por vello). + pub fn add_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Color) { + let rgba = pack_rgba(color); + self.rect_insts.extend_from_slice(&x.to_ne_bytes()); + self.rect_insts.extend_from_slice(&y.to_ne_bytes()); + self.rect_insts.extend_from_slice(&w.to_ne_bytes()); + self.rect_insts.extend_from_slice(&h.to_ne_bytes()); + self.rect_insts.extend_from_slice(&rgba.to_ne_bytes()); + self.rect_count += 1; + } + + /// Cuenta total de primitivas pendientes (útil para benches). + pub fn primitive_count(&self) -> u32 { + self.line_count + self.rect_count + self.tri_vert_count / 3 + } + + /// Despacha las primitivas acumuladas como 1 draw call por tipo + /// no vacío contra `view`. `viewport` es el tamaño en pixels del + /// target (lo usa el VS para mapear pixel → NDC). + /// + /// `load_op` decide si la pasada conserva el contenido previo + /// (`Load`, lo normal cuando vello ya pintó algo) o limpia + /// (`Clear(color)`). Apps que llamen a `GpuBatch` desde + /// `gpu_paint_with` quieren `Load`. + pub fn flush( + self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + view: &wgpu::TextureView, + viewport: (f32, f32), + load_op: wgpu::LoadOp, + ) { + let total = self.line_count + self.tri_vert_count + self.rect_count; + if total == 0 { + return; + } + + // Uniforms: [viewport.w, viewport.h, line_width, _pad]. + let u_data = [viewport.0, viewport.1, self.line_width, 0.0]; + let mut u_bytes = Vec::with_capacity(16); + for v in u_data { + u_bytes.extend_from_slice(&v.to_ne_bytes()); + } + let uniforms = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("llimphi-raster-gpu-u"), + size: 16, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + queue.write_buffer(&uniforms, 0, &u_bytes); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("llimphi-raster-gpu-bg"), + layout: &self.pipelines.bind_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniforms.as_entire_binding(), + }], + }); + + // Buffers por tipo (sólo si hay datos). + let lines_buf = (!self.line_verts.is_empty()).then(|| { + let b = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("llimphi-raster-gpu-lines-buf"), + size: self.line_verts.len() as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + queue.write_buffer(&b, 0, &self.line_verts); + b + }); + let tris_buf = (!self.tri_verts.is_empty()).then(|| { + let b = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("llimphi-raster-gpu-tris-buf"), + size: self.tri_verts.len() as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + queue.write_buffer(&b, 0, &self.tri_verts); + b + }); + let rects_buf = (!self.rect_insts.is_empty()).then(|| { + let b = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("llimphi-raster-gpu-rects-buf"), + size: self.rect_insts.len() as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + queue.write_buffer(&b, 0, &self.rect_insts); + b + }); + + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("llimphi-raster-gpu-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + ops: wgpu::Operations { + load: load_op, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + pass.set_bind_group(0, &bind_group, &[]); + + // Orden de draws: rects (fondo) → tris → lines (encima). Match + // de la convención usual "fill abajo, stroke arriba". + if let Some(buf) = rects_buf.as_ref() { + pass.set_pipeline(&self.pipelines.rects); + pass.set_vertex_buffer(0, buf.slice(..)); + pass.draw(0..6, 0..self.rect_count); + } + if let Some(buf) = tris_buf.as_ref() { + pass.set_pipeline(&self.pipelines.tris); + pass.set_vertex_buffer(0, buf.slice(..)); + pass.draw(0..self.tri_vert_count, 0..1); + } + if let Some(buf) = lines_buf.as_ref() { + pass.set_pipeline(&self.pipelines.lines); + pass.set_vertex_buffer(0, buf.slice(..)); + pass.draw(0..6, 0..self.line_count); + } + } +} + +/// Empaqueta un `peniko::Color` a u32 little-endian RGBA8. +/// El shader lo lee como `inst.rgba` y separa bytes — debe coincidir +/// con la convención del WGSL (`r = rgba & 0xFF`, etc.). +fn pack_rgba(c: Color) -> u32 { + let [r, g, b, a] = c.to_rgba8().to_u8_array(); + (r as u32) | ((g as u32) << 8) | ((b as u32) << 16) | ((a as u32) << 24) +} + +const WGSL: &str = r#" +struct Uniforms { + viewport: vec2, + line_width: f32, + _pad: f32, +}; + +@group(0) @binding(0) var u: Uniforms; + +struct V2F { + @builtin(position) pos: vec4, + @location(0) color: vec4, +}; + +fn unpack_rgba(c: u32) -> vec4 { + let r = f32( c & 0xFFu) / 255.0; + let g = f32((c >> 8u) & 0xFFu) / 255.0; + let b = f32((c >> 16u) & 0xFFu) / 255.0; + let a = f32((c >> 24u) & 0xFFu) / 255.0; + return vec4(r, g, b, a); +} + +fn px_to_ndc(p: vec2) -> vec2 { + return vec2(p.x / u.viewport.x * 2.0 - 1.0, 1.0 - p.y / u.viewport.y * 2.0); +} + +// -------- triángulos: 1 vértice = (xy, rgba) -------- + +@vertex +fn vs_tris(@location(0) xy: vec2, @location(1) rgba: u32) -> V2F { + var out: V2F; + out.pos = vec4(px_to_ndc(xy), 0.0, 1.0); + out.color = unpack_rgba(rgba); + return out; +} + +// -------- rects: 1 instancia = (xy, wh, rgba), 6 vértices/quad -------- + +@vertex +fn vs_rects( + @builtin(vertex_index) vid: u32, + @location(0) inst_xy: vec2, + @location(1) inst_wh: vec2, + @location(2) inst_rgba: u32, +) -> V2F { + var corners = array, 6>( + vec2(0.0, 0.0), + vec2(1.0, 0.0), + vec2(1.0, 1.0), + vec2(0.0, 0.0), + vec2(1.0, 1.0), + vec2(0.0, 1.0), + ); + let local = corners[vid]; + let px = inst_xy + local * inst_wh; + var out: V2F; + out.pos = vec4(px_to_ndc(px), 0.0, 1.0); + out.color = unpack_rgba(inst_rgba); + return out; +} + +// -------- líneas: 1 instancia = (p0xy, p1xy, rgba), expandida a quad ---- + +@vertex +fn vs_lines( + @builtin(vertex_index) vid: u32, + @location(0) seg: vec4, + @location(1) rgba: u32, +) -> V2F { + // Quad perpendicular al segmento, grosor uniforme `u.line_width` px. + // vid 0..5 mapea a los 6 vértices del quad (2 tris). + let p0 = seg.xy; + let p1 = seg.zw; + let dir = normalize(p1 - p0); + let n = vec2(-dir.y, dir.x); + let half_w = u.line_width * 0.5; + let offsets = array, 6>( + vec2(0.0, -half_w), // p0 -n + vec2(0.0, half_w), // p0 +n + vec2(1.0, half_w), // p1 +n + vec2(0.0, -half_w), // p0 -n + vec2(1.0, half_w), // p1 +n + vec2(1.0, -half_w), // p1 -n + ); + let o = offsets[vid]; + let along = mix(p0, p1, o.x); + let across = n * o.y; + let px = along + across; + var out: V2F; + out.pos = vec4(px_to_ndc(px), 0.0, 1.0); + out.color = unpack_rgba(rgba); + return out; +} + +@fragment +fn fs(in: V2F) -> @location(0) vec4 { + return in.color; +} +"#; diff --git a/llimphi-raster/src/lib.rs b/llimphi-raster/src/lib.rs new file mode 100644 index 0000000..cf63a08 --- /dev/null +++ b/llimphi-raster/src/lib.rs @@ -0,0 +1,120 @@ +//! llimphi-raster — Brocha Matemática. +//! +//! Traduce primitivas vectoriales (líneas, curvas de Bézier, texto) a +//! píxeles via Compute Shaders. Backend: `vello`. +//! +//! Punto de entrada: [`Renderer`]. Recibe una [`vello::Scene`] y la pinta +//! sobre un [`llimphi_hal::Frame`]. + +use llimphi_hal::{Frame, Hal}; +pub use vello; +pub use vello::kurbo; +pub use vello::peniko; + +pub mod gpu; +pub use gpu::{GpuBatch, GpuPipelines}; + +/// Errores del rasterizador. +#[derive(Debug)] +pub enum RasterError { + Init(String), + Render(String), +} + +impl std::fmt::Display for RasterError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Init(s) => write!(f, "vello init: {s}"), + Self::Render(s) => write!(f, "vello render: {s}"), + } + } +} + +impl std::error::Error for RasterError {} + +/// Rasterizador vectorial. Una instancia por surface (porque vello cachea +/// resources contra un `surface_format` específico). +pub struct Renderer { + inner: vello::Renderer, +} + +impl Renderer { + /// Inicializa el rasterizador. Vello acepta cualquier textura compatible + /// (Rgba8Unorm / Bgra8Unorm) en `render`, así que no se fija un formato + /// en construcción. + /// + /// **`antialiasing_support`**: pedimos `area` solamente, no `all()`. + /// `area` es el único método que `render()` usa (`AaConfig::Area` + /// fijo). Pedir `all()` haría a vello compilar también pipelines + /// para `msaa8` y `msaa16` que nunca se invocan — en Mali-G57 eso + /// triplica el cold-start (medido: 3.7s vs ~1.2s). Si alguna app + /// futura necesita MSAA, agregamos un constructor explícito. + /// + /// **`num_init_threads: None`**: vello paraleliza la compilación + /// de shaders en `None` → todos los CPU cores. Mali-G57 viene en + /// SoCs octa-core ARM; con 1 thread tardamos 2.0s, con 8 esperamos + /// ~400-600ms. La compilación de shaders es 100% CPU (Rust → + /// SPIR-V), el GPU no participa, así que multi-thread escala + /// casi linealmente hasta saturar el queue del Naga compiler. + pub fn new(hal: &Hal) -> Result { + let inner = vello::Renderer::new( + &hal.device, + vello::RendererOptions { + use_cpu: false, + antialiasing_support: vello::AaSupport { + area: true, + msaa8: false, + msaa16: false, + }, + num_init_threads: None, + pipeline_cache: None, + }, + ) + .map_err(|e| RasterError::Init(e.to_string()))?; + Ok(Self { inner }) + } + + /// Renderiza `scene` sobre `frame` limpiando con `base_color`. AA fija + /// en area-sampling (precisión Δ < 10⁻⁹ rad del SDD). + pub fn render( + &mut self, + hal: &Hal, + scene: &vello::Scene, + frame: &Frame, + base_color: peniko::Color, + ) -> Result<(), RasterError> { + let (width, height) = frame.size(); + self.render_to_view(hal, scene, frame.view(), width, height, base_color) + } + + /// Como [`render`](Self::render) pero contra una vista de textura + /// explícita (mismo formato/tamaño que la intermedia). Lo usa el + /// compositor de overlay de `llimphi-ui` para rasterizar la capa de + /// overlay sobre fondo transparente en su propia textura. Ojo: + /// `render_to_texture` **limpia** el target con `base_color` y escribe + /// todos los píxeles — no compone sobre contenido previo. + pub fn render_to_view( + &mut self, + hal: &Hal, + scene: &vello::Scene, + view: &llimphi_hal::wgpu::TextureView, + width: u32, + height: u32, + base_color: peniko::Color, + ) -> Result<(), RasterError> { + self.inner + .render_to_texture( + &hal.device, + &hal.queue, + scene, + view, + &vello::RenderParams { + base_color, + width, + height, + antialiasing_method: vello::AaConfig::Area, + }, + ) + .map_err(|e| RasterError::Render(e.to_string())) + } +} diff --git a/llimphi-raster/tests/gpu_batch_smoke.rs b/llimphi-raster/tests/gpu_batch_smoke.rs new file mode 100644 index 0000000..0241dc0 --- /dev/null +++ b/llimphi-raster/tests/gpu_batch_smoke.rs @@ -0,0 +1,128 @@ +//! Smoke test del backend GPU directo (`llimphi_raster::gpu`). +//! +//! No verifica píxeles — eso requiere AA y un patrón conocido, y por +//! ahora el módulo no garantiza pixel-exactness. Sí verifica que: +//! +//! - `GpuPipelines::new` compila los 3 shaders WGSL sin errores de naga. +//! - `GpuBatch` acepta líneas, triángulos y rects mezclados sin pánico. +//! - `flush` ejecuta sin errores wgpu y la `Maintain::Wait` retorna +//! (= la GPU/llvmpipe terminó las pasadas). +//! +//! Corre en cualquier adapter wgpu disponible — en CI sin GPU usa +//! llvmpipe, donde igual valida el ensamblado y la sintaxis WGSL. + +use llimphi_hal::{wgpu, Hal}; +use llimphi_raster::gpu::{GpuBatch, GpuPipelines}; +use llimphi_raster::peniko::Color; + +const W: u32 = 256; +const H: u32 = 256; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +fn make_target(device: &wgpu::Device) -> (wgpu::Texture, wgpu::TextureView) { + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("smoke-target"), + size: wgpu::Extent3d { + width: W, + height: H, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); + (tex, view) +} + +#[test] +fn batch_with_rects_lines_tris_does_not_panic() { + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let pipelines = GpuPipelines::new(&hal.device, FMT); + let (_tex, view) = make_target(&hal.device); + + let mut batch = GpuBatch::new(&pipelines); + batch.line_width(2.0); + + // Cuadrícula 8×8 de rects con color que varía. + for j in 0..8 { + for i in 0..8 { + let x = 8.0 + i as f32 * 30.0; + let y = 8.0 + j as f32 * 30.0; + let c = Color::from_rgba8( + (i * 32) as u8, + (j * 32) as u8, + 100, + 255, + ); + batch.add_rect(x, y, 24.0, 24.0, c); + } + } + + // Diagonal de líneas. + for k in 0..16 { + batch.add_line( + (0.0, k as f32 * 16.0), + (W as f32, (k + 1) as f32 * 16.0), + Color::from_rgba8(220, 220, 250, 180), + ); + } + + // Triángulo grande con color por vértice. + batch.add_tri( + (128.0, 32.0), + (64.0, 220.0), + (220.0, 220.0), + Color::from_rgba8(255, 80, 80, 200), + Color::from_rgba8(80, 255, 80, 200), + Color::from_rgba8(80, 80, 255, 200), + ); + + assert!(batch.primitive_count() > 0, "batch debería tener primitivas"); + + let mut encoder = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("smoke-enc"), + }); + batch.flush( + &hal.device, + &hal.queue, + &mut encoder, + &view, + (W as f32, H as f32), + wgpu::LoadOp::Clear(wgpu::Color::BLACK), + ); + hal.queue.submit(std::iter::once(encoder.finish())); + hal.device.poll(wgpu::Maintain::Wait); +} + +#[test] +fn empty_batch_flush_is_no_op() { + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let pipelines = GpuPipelines::new(&hal.device, FMT); + let (_tex, view) = make_target(&hal.device); + + let batch = GpuBatch::new(&pipelines); + assert_eq!(batch.primitive_count(), 0); + + let mut encoder = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("smoke-empty-enc"), + }); + // Con batch vacío, flush no debe crear render pass ni buffers. + batch.flush( + &hal.device, + &hal.queue, + &mut encoder, + &view, + (W as f32, H as f32), + wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + ); + hal.queue.submit(std::iter::once(encoder.finish())); + hal.device.poll(wgpu::Maintain::Wait); +} diff --git a/llimphi-surface/Cargo.toml b/llimphi-surface/Cargo.toml new file mode 100644 index 0000000..af35105 --- /dev/null +++ b/llimphi-surface/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-surface" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true + +[dependencies] +llimphi-hal = { path = "../llimphi-hal" } +llimphi-ui = { path = "../llimphi-ui" } +parking_lot = { workspace = true } diff --git a/llimphi-surface/src/lib.rs b/llimphi-surface/src/lib.rs new file mode 100644 index 0000000..06279c8 --- /dev/null +++ b/llimphi-surface/src/lib.rs @@ -0,0 +1,404 @@ +//! llimphi-surface — superficies externas dentro del bucle Elm. +//! +//! Un `ExternalSurface` es una textura RGBA8 que vive en GPU y se pinta +//! sobre un rect del frame Llimphi cada vez que la app lo expone vía +//! `View::gpu_paint_with`. La fuente de bytes corre afuera del bucle +//! Elm: un decoder de video, un capture de cámara, un raster de PDF, +//! una textura raw producida por otro motor — cualquier productor que +//! genere RGBA puede empujar frames con [`ExternalSurface::upload`] y +//! ver el resultado en la próxima pasada de raster. +//! +//! El crate provee: +//! +//! - [`ExternalSurface`]: dueño de la textura + render pipeline + bind +//! group. `upload(rgba, w, h)` sube bytes y recrea la textura si +//! `w`/`h` cambiaron. +//! - [`ExternalSurface::view`]: helper que construye un [`View`] con +//! `gpu_paint_with` ya conectado. La app sólo elige el `Style` del +//! nodo (qué porción del layout ocupa). +//! +//! ## Diseño +//! +//! El pipeline es un textured-quad clásico: dos triángulos cubren el +//! rect destino, el fragment shader samplea la textura externa con +//! sampler bilineal. Las coordenadas NDC del quad se computan en GPU +//! a partir de `(rect, viewport)` que viajan por uniform — por eso +//! el callback necesita el `viewport` que `llimphi-ui` empezó a +//! propagar en `GpuPaintFn`. +//! +//! La textura intermedia donde Llimphi pinta vello es `Rgba8Unorm` +//! (ver `llimphi-hal::INTERMEDIATE_FORMAT`). El pipeline emite +//! `Rgba8Unorm` también — el target del render pass es esa misma +//! intermedia con `LoadOp::Load`, así el fondo vello queda preservado. + +use std::sync::Arc; + +use llimphi_hal::wgpu; +use llimphi_ui::{PaintRect, View}; +use parking_lot::Mutex; + +const TARGET_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +const SOURCE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +struct Inner { + device: wgpu::Device, + queue: wgpu::Queue, + pipeline: wgpu::RenderPipeline, + bgl: wgpu::BindGroupLayout, + sampler: wgpu::Sampler, + uniforms: wgpu::Buffer, + // Textura + bind group recreados cuando cambia (w, h) del frame de + // entrada. Empieza en (1, 1) con un pixel transparente para que el + // pipeline funcione antes del primer `upload`. + tex: wgpu::Texture, + bind_group: wgpu::BindGroup, + tex_size: (u32, u32), +} + +/// Superficie externa: textura GPU + pipeline que la blittea al rect +/// que ocupe en el árbol Llimphi. Clonar es barato (Arc interno). +#[derive(Clone)] +pub struct ExternalSurface { + inner: Arc>, +} + +impl ExternalSurface { + /// Construye la surface usando el `Device`/`Queue` del Hal de la app. + /// La textura arranca en 1×1 transparente; el primer + /// [`Self::upload`] la redimensiona al tamaño real del frame. + pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self { + let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("llimphi-surface-bgl"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("llimphi-surface-pl"), + bind_group_layouts: &[&bgl], + push_constant_ranges: &[], + }); + + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("llimphi-surface-shader"), + source: wgpu::ShaderSource::Wgsl(WGSL.into()), + }); + + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("llimphi-surface-pipe"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs"), + compilation_options: Default::default(), + buffers: &[], + }, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format: TARGET_FORMAT, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview: None, + cache: None, + }); + + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("llimphi-surface-sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + // Uniforms: 8 floats — rect (x, y, w, h) + viewport (vw, vh, _, _). + let uniforms = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("llimphi-surface-uniforms"), + size: 32, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let (tex, bind_group) = + make_texture_and_bg(device, queue, &bgl, &uniforms, &sampler, 1, 1, &[0, 0, 0, 0]); + + Self { + inner: Arc::new(Mutex::new(Inner { + device: device.clone(), + queue: queue.clone(), + pipeline, + bgl, + sampler, + uniforms, + tex, + bind_group, + tex_size: (1, 1), + })), + } + } + + /// Sube `rgba` (8 bits por canal, premultiplicado o no — el blend + /// usa straight alpha) como nuevo contenido de la surface. Si + /// `(width, height)` difiere del tamaño actual, recrea la textura + /// y el bind group. `rgba.len()` debe ser exactamente + /// `width * height * 4`. + pub fn upload(&self, rgba: &[u8], width: u32, height: u32) { + let mut inner = self.inner.lock(); + debug_assert_eq!(rgba.len(), (width as usize) * (height as usize) * 4); + if inner.tex_size != (width, height) { + let (tex, bg) = make_texture_and_bg( + &inner.device, + &inner.queue, + &inner.bgl, + &inner.uniforms, + &inner.sampler, + width, + height, + rgba, + ); + inner.tex = tex; + inner.bind_group = bg; + inner.tex_size = (width, height); + } else { + inner.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &inner.tex, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + rgba, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(width * 4), + rows_per_image: Some(height), + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + } + } + + /// Tamaño actual de la textura interna (último upload o (1,1) si + /// nunca se subió nada). + pub fn size(&self) -> (u32, u32) { + self.inner.lock().tex_size + } + + /// Encola el draw del quad que pinta la surface en `dst_view` dentro + /// de `rect`, escalando la textura para cubrir el rect entero. + /// Llamado típicamente desde el callback de `View::gpu_paint_with`. + pub fn blit( + &self, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + dst_view: &wgpu::TextureView, + rect: PaintRect, + viewport: (u32, u32), + ) { + let inner = self.inner.lock(); + let uniforms = [ + rect.x, + rect.y, + rect.w, + rect.h, + viewport.0 as f32, + viewport.1 as f32, + 0.0, + 0.0, + ]; + let mut bytes = [0u8; 32]; + for (i, v) in uniforms.iter().enumerate() { + bytes[i * 4..(i + 1) * 4].copy_from_slice(&v.to_ne_bytes()); + } + queue.write_buffer(&inner.uniforms, 0, &bytes); + + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("llimphi-surface-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: dst_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + pass.set_pipeline(&inner.pipeline); + pass.set_bind_group(0, &inner.bind_group, &[]); + pass.draw(0..6, 0..1); + } + + /// Construye un `View` cuyo `gpu_paint_with` blittea la surface al + /// rect que le asigne el layout. La app sólo escoge el `Style` + /// (tamaño, flex_grow…). El `Msg` está libre — la View no emite + /// eventos por sí sola. + pub fn view(&self, style: llimphi_ui::llimphi_layout::taffy::Style) -> View + where + Msg: Clone + Send + Sync + 'static, + { + let this = self.clone(); + View::new(style).gpu_paint_with(move |_device, queue, encoder, view, rect, viewport| { + this.blit(queue, encoder, view, rect, viewport); + }) + } +} + +fn make_texture_and_bg( + device: &wgpu::Device, + queue: &wgpu::Queue, + bgl: &wgpu::BindGroupLayout, + uniforms: &wgpu::Buffer, + sampler: &wgpu::Sampler, + width: u32, + height: u32, + initial_rgba: &[u8], +) -> (wgpu::Texture, wgpu::BindGroup) { + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("llimphi-surface-tex"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: SOURCE_FORMAT, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &tex, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + initial_rgba, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(width * 4), + rows_per_image: Some(height), + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("llimphi-surface-bg"), + layout: bgl, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: uniforms.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(sampler), + }, + ], + }); + (tex, bind_group) +} + +const WGSL: &str = r#" +struct Uniforms { + rect: vec4, // x, y, w, h en pixels del frame + viewport: vec4, // vw, vh, _, _ +}; + +@group(0) @binding(0) var u: Uniforms; +@group(0) @binding(1) var tex: texture_2d; +@group(0) @binding(2) var samp: sampler; + +struct V2F { + @builtin(position) pos: vec4, + @location(0) uv: vec2, +}; + +@vertex +fn vs(@builtin(vertex_index) vid: u32) -> V2F { + // Dos triángulos en UV-space, recorridos CCW. + var uvs = array, 6>( + vec2(0.0, 0.0), + vec2(1.0, 0.0), + vec2(1.0, 1.0), + vec2(0.0, 0.0), + vec2(1.0, 1.0), + vec2(0.0, 1.0), + ); + let uv = uvs[vid]; + + let px = u.rect.x + uv.x * u.rect.z; + let py = u.rect.y + uv.y * u.rect.w; + + // NDC: x ∈ [-1, 1] sin flip, y flipeado (en pantalla y-down). + let ndc = vec2( + px / u.viewport.x * 2.0 - 1.0, + 1.0 - py / u.viewport.y * 2.0, + ); + + var out: V2F; + out.pos = vec4(ndc, 0.0, 1.0); + out.uv = uv; + return out; +} + +@fragment +fn fs(in: V2F) -> @location(0) vec4 { + return textureSample(tex, samp, in.uv); +} +"#; diff --git a/llimphi-text/Cargo.toml b/llimphi-text/Cargo.toml new file mode 100644 index 0000000..7fbb288 --- /dev/null +++ b/llimphi-text/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "llimphi-text" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true + +# vello directo (no llimphi-raster): el motor de texto sólo necesita +# Scene/peniko/kurbo para construir y pintar layouts — nada del Renderer ni +# de llimphi-hal. Eso mantiene llimphi-text (y quien lo use: el compositor) +# libre de winit, condición para correr sobre el framebuffer de wawa. +[dependencies] +vello = { workspace = true } +parley = { workspace = true } + +[dev-dependencies] +llimphi-raster = { path = "../llimphi-raster" } +llimphi-hal = { path = "../llimphi-hal" } +pollster = { workspace = true } + +[[example]] +name = "hello_text" +path = "examples/hello_text.rs" diff --git a/llimphi-text/LEEME.md b/llimphi-text/LEEME.md new file mode 100644 index 0000000..82ff585 --- /dev/null +++ b/llimphi-text/LEEME.md @@ -0,0 +1,9 @@ +# llimphi-text + +> Shaping + fonts de [llimphi](../README.md). + +Capa de tipografía. Fontdue para subset minimal; HarfBuzz cuando se requiere shaping complejo (árabe, devanagari, ligaduras). Cache de glyphs rasterizados; medición precisa para layout (`measure(text, font, size) → (w, h)`). + +## Deps + +- `fontdue`, `harfbuzz_rs` (feature) diff --git a/llimphi-text/README.md b/llimphi-text/README.md new file mode 100644 index 0000000..19f9c2d --- /dev/null +++ b/llimphi-text/README.md @@ -0,0 +1,9 @@ +# llimphi-text + +> Shaping + fonts of [llimphi](../README.md). + +Typography layer. Fontdue for minimal subset; HarfBuzz when complex shaping is required (Arabic, Devanagari, ligatures). Cache of rasterized glyphs; precise measurement for layout (`measure(text, font, size) → (w, h)`). + +## Deps + +- `fontdue`, `harfbuzz_rs` (feature) diff --git a/llimphi-text/assets/DejaVuSans.ttf b/llimphi-text/assets/DejaVuSans.ttf new file mode 100644 index 0000000000000000000000000000000000000000..5267218852f631928716a8005d7bf4b492aaf83d GIT binary patch literal 756072 zcmZQzWME(rVPs%nVQ_GB3-QgjoVSC4fzgM7fkDRI#no+!a-S0e17ijQ1Je}u0RP|x zbJV^uFfjjNV3;T89vtelbH7Ct1H&R21_sf4zK$Ut%Z1Gv7#OzyVPIgh^AFZHiqtLB zWnkE6!@$50mYkbdz&7#VDF%jpQy3V;-X)inC@^HoS~4(PZDC+w{Fhdco?B>Amc_tu zy@i2+VRd?9aRCE613Lr54Fd)S2A=es%CwA@3_}Kn8#W9al`$Eqi76bhhuRr917|QW zFqmazq$cvtm*3C88F+z#fk7oBx1{2z>5ab(oPl2$7!uR6Q;YJr_OiJ$aBhxaV6gv_ zlb@WJFzr|m1Lx-m28P6r+{B6knR9G~3|t`(7#J87@)C1X<#T-(GH``5FfiD16yz6| zD6m)bF>p=dU|{(8wV)`qz?%Id0|Ph6xvUus4B*gYIAgo$JOcv*!^PUaU*I$-ocS0S zm^7F`eqmyWWMF36z`)3~k?AM{Gt)7qV+@Q;$C*wsFf*NII>W%obe8EN12fYlrppYB zOjnqmFfcPcWqQfL$n=WoGXpcz7p5-^j7(pdzA`W~ePjB@z{vES=|2NAGXpaN10ypd zGYbPVGb=MI10ypVGaCalGdnXo10ypBGbaNxGZ!-#10ypxGY ziwr9d10yRhD=z~RD<3OA10$;ds{{ixt0b!w10$<6t1JUEs~oEw10$biTe@* z6Zd7|wG52H>x6ePFbVG#-p#-)yhnHs1C#Jx;b#nt!q0`DGcXCi5J_QR6iF3HWndOb z6G>xW6iF9JXJ8h|5XoX-63G_HW?&S_5h-I}5-AsX&A=$~M&vUClgJlwQ3ghFF>wV3 zW^qOF5(Y-`Qt=uFX7O6_YYdFy*Tt_hFpJ-iNMK-OU}0cjU;<+Srj77a!Nhcy={f@w z(+#E@49rY7nQk($Fx_H$!obAzis>x_6Vp4UuMCXfl)%LFo#{IRBhwG2Ukpr4znT6r zFf#pP`p>|`%*f2dz{t$Z%*?>V%)-pVzz9yQT#R>^Vi-hR+Uyi7)cjMSteMw#ry zyhKK=oWy(uMzfs6k~~JY+{BU$#uK~k`49_R$wp*28%#l#K=Ipt;qfcxs4UYN1UM2j==&<+T|t|Wiz;e$q+CZ113{) zvvacSbbJ zW)NUdV$fnRVz6RxV(?)IVTfT!VaQ=9VW?qfVd!C)$FPin4V(gdp!7WkHjsHtAaMo; zMy4AKpx9<&PGfUpU zfrWw1*2L+abDZ0sh?c@F;2g$S^a(=GTEM`_V8Ecpz{p_5z{9}Iz`?-BAi}`N#PE*? zOkM$ze?K!YGBC52vR1IxBC$Ym&j+eQ85kI(KgdvI{fgz0{hoOj}f}xI~g`taK0>d>LO-L>!@#@gG|eLYCtXh|PHfL_*jcCLlIc1k7f?02Tq2KO(H&ti`M)tYxg_td*=) ztktYFtaYsQtP{jb#mmIY#Vf=s#jC`t#cLQC8HiUa!oa`~#K6E1!N9-}$H2gl!oa|g z#lXN&z`(#z#=yW(!@$7M#K6GN!N9=K$H2fag@J)#76Svr0tN<#Wef}qYZw?9HZd?T z>|kJE*vG)Y0QLi~Fj#=hVo3m#bx0(# zNCE>RV-%wr10&-U1|9}w0;zHl!wQCV3|km>F&tnx#&Cw=62lFKdkjw)UNL-N_{Q*u zk%^Imk&jV?f$<;1-+Ku1pD2h8A^+Y&upuH)vJ)(hL_$@9P5V~@mW7ZI)8xS-)?iWs zOhUvhz-(l4KS%^>8b}w@KVgu_KW;F|hnEDa{CkUmiGc&$I%5F0%UGDMFg;@61lPS1 z;F?wjT*u0TYga{Zn@fp-iGdSM&vgb)rdv#p7~Cf#Dwm10xFq10xp$1ET-~1EUxN1EUNB1EUfH z1EU551EU@T1EUE81EUoK1ET{21EU)#TtI2}AE*h-I4DR^u}ZQ_n4B2zF)%XqG4L>0 zF_baXFf=iAF!V7@VVK3RfMFTK8iq{_I~evc9AP-caDm|(!ySf43@;eoF??b8#mKm4c#3;ci$Ed=n#c052#%ROn#OT53#~8vG#hAdD#+buc#8|;t$JoNy#W;a+8si+s zMT{#L*D-Ek+{Ji+@fhP7P&l$>V34+85pe2egODy5B1b_Y>?|M>#e@}L5eNx3nLP|F z0x@ASm<=%#EDO~Owh>g)|6^c-s05P`^N~q5a2^GTFff41A58Ke1JunBTaZa)8IP z9Jm)G5AOdcf_oIo;C_OpxR|&YgMqk&xCDctxRkg8gAur0ZppyJz{9kWi6lF~=E{T3 zRRy>Db--qsGgvS%Fg#*lV0gj6!0?WNfdSM%`~_`1u`w_(@-Q$k3NbJ+N-!`m$}uo7 zsxUAxYB4Y{8Za<0nlUgi+AuIMg8Jhg3=E8Z3=E7R3=E7>3=E733=E8E3=E7p3=E7# z3=E7F3=E8Q3=E8*esvcE1LFh+2F7U&42*La7#J5ZFfguQU|?Lwz`(eLfq`)s0|Vm$ z1_s7s3=E8C7#J8YF)%RRU|?Xp$H2h&gn@za6$1m~2L=YlZww5Ke;62;m>3wCI2ah1 zK)qQJ1_mZ61_mYt1_mZI1_mY_P#m#TV~`s_BCJgKNG!TA%>j$xG6%&395$k;K{5>@ zPK?R8)L@E3%%qfL*a2!kGTdU|Vc=j8Vo+f)WAI>zV#r~rW0=6e!~kknGBPLspToe& zaN>U!bwt259*W7BA~@`V^msu@nx9pGRghJPRhU(TRg_hXRh(4X8g3T;D!dKY#K z3=A#|3=Ccj3=9FFF(L*Ah8PA0h9m|C2GDp)9s>hI2?GN|6$1l914^yP$kY$2ov1*9 z>o-u<0%`+;%oLFjKE}W(V#C10RL=5&)+E*p);!h{)+*Kp);882)=8{0Sm&`WVO_<#fpr_}9@aywCs@z1USYk( z`hfKr>l@ZjtUp-)v9Yjmu?et=vB|J0v1zdBv6--0u{p50vH7qCu|=@Ov8AwOu@$hD zvDL6Ov30QZu}xu{#kPQL8QU7RO>8^Z_OTsdJH>W^?Hb!1wnuC)*xs>yVf)3-z|O|b z!!E=w!7j(H!mh<`z;4EF!|ufH!S2T%!XCw*z@EmQ!(PN*!CuGS!rsL`fqfeL9QH-* zE7;euZ(-lXet`WL`x*92>^Io&u|Hvd#r}c)8~Yy)CJqh`J`NEMDGmh=H4YsPBMu7= zI}R5PFOC3?Fpe0GB#sP@JdP5MDvkz@HjW;SNgOjc=5Z|HSjDk{V;jdFjzb(LIL>ig z;kd=|fa4j*8;(yLKREtzvT$;73UG>X%5W-iYH;dtns8ciI&ivi`fvtuMsUV)rf_C) z7I2nv)^Ij)c5wD_PT`!zxqx#S=NisUoI5!8aUS73#d(4A8s{C(N1QJ>-*JB7{Kdt< z#m2?MCB!AcCC8=0rNw2yWyWR0<;3N|<;NAm6~&dnmBy9BRm4@nRmauB)x|Y|YZ})a zu0>ocxYluP;o8M@fa@678LmrQH@NO`J>h!A^?~af*B@>sZVqlfZV_%NZUt^NZXIqT zZVPTZZWnGZ?f~vE?ilVQ?hNic?h@`Q?gs8Q?jG(*+%vf6aWCOs#l3-h8}}aWL)<60 z&v9SjzQz53`x*Be?oZr5xc~96@Nn@6@QCrq@F?+U@aXZF@L2IU@VN2#@C5Nh@Wk>Mi#4Euo$E(7t#cRN8#%sgt#OuN9#~Z>M#hbvJ#+$=i#9P5z$J@fs_ z8t)w5MZ7C`*YR%Q-Nk!=_ZaUP-b=hUc<=E(;eEyXf%hBlA3i2N4n96U5k4tC1wJ)C z9X=yI3qCtO7d|h(0KPE37``OF48A+zfLTk$*ayYc(*2k}Sn z$ML7|XYm*Cm+{x|H}QAy_wi5RpT)m`e;NN8{!RQl`1kQ2;XlQHf&Uu+9sWoBFZkc_ zf8qZnz#zaTz#||eAR!CK|VndK`B86 zK{Y`gK_fv6K|4VgK`+4o!7#xX!6d;9!92kd!79N9!8XAj!AXKM1m_7Z5nLs>L2#Sk z9>GI`Cj`$4UJ<+{_(1TP;2XhDf^3CRd4326xF37H642{{P43Hb;G z2}KCS38e^S2^9#H3DpQS33Ukd2~822CA2_jna~=cO+q___6Z#kIwf>L=$ghxZ((3$x(6x&nLq>HjEuLzY*6okk!dxE%?K*985u!6OsELc zL6A7ZQxM6x7(_CGdM}KOpuPtq6R5w=$OP&mem)~p zAV>t%OJQJSQU|e_j(|x}!OX}E8ew5%0@VhLjG*=tDk%%9{TLzS7tojuqb#Vu$F%2P z0RtmMoVf+e{_;PIfswfdiPZU9#=yv|^LGW9EMs6~JOq-3k`U9tBvS-P4I1gfz{nI0 zQV%7sgVlgZMjeo=m=*q8FfcOh0l5dp{s$Tb_yAV<4#Z{zyNVHF3)sz2^1A&D7l-f*LD zw}OEYMFbL(;LwAF74tTbOQ0k;B$>B?;|t7&gbajaRsj1Qm9$`BgqouO)dh-u6g6P; z75=(_Rc3)=3KSzudyvR?f7UTDGP8r!FoEL>LNc@e6=Gn7vLR-I!yH#=KvE-=y!vMr zD6~OtW)uUbb59V-2+n0ppb;`grnw*yD4Bp@GlND17@@kd!Q%QLk{LYO3C&+uK_ZNI zK_s&>h=iI0YC|zHL-G`8%!rXW6(kE$$pn^ViUf%;LuA1ulQc*KA`T&;X$+jw7_~uZ z8$yyU4hjKCs%F#%4MXkwp;)#}p*j zGev+(3eO2{~0W>VSL% zA#sa?QYkYyJu^-KrFjU6OFbm@GQ9(p7|3i$s6t4n-XM?~SXlvLLqZioLRq>|SghUos1FMISxWpkf0+?hB2C0XT(ApMLybBZ;OyC*>>Z5mHU*iymq+SS#Ts}bR z6sA2O(~!j>Dj_5^wS#jda(%%V46+eIGJtD&2+0KTDTIX9BVh9(DFi|yr2%MMd;zOL z4s&oWfTc80*$Z(ka$3TYLck>RAYhT-)MeLuz4U5WS2wgeIydvLV&arASAdYfZQs8v>cF0WP2fOXo~{LWJvn~nM5%Y(-yD@ zv;_ibhd@YhD+SpcOg7^ePzxDLn-)^OA*DK4iUOrc2pdweF~0z}55Xi%1eD{LUw}xQ zA_`z#U=pGRCGTUFS{UUOq{WNeiohlUuHT{M1E`e1%+Z)4klLJa2{`Y7NvH_89D|f; znB^NYxc$sH0b~n=#HI`6YsLwn+6O{ns)3Y~kbDgxv50`%+t4;jWa47{vrtj-J!tp2P;405b}to;m5tdm$DFu1UOWfNkU zEc`+wm|>$xhRAD1S#fo7L&k;T#^NrFYs3@8GZ^=YXNhMq9tW+@VLT!JM8bkeiGh(p z0RM<4n-pk;34;>@1A_+x1A`v}149S{149%80|ThcNMm4N$YEe$C}Ln>0IjpAV_;wa zEgb7&U|^WQz`!t#fdM@8PqT4NCdg`;FAT>R7#V&s@GyuIxf%gB@5{)P%+SNY$as;- zfPspiAE9>)jKVz( zJWMexyI2me9Ai1da*5>z%RQDSEU!k#?nlS&N5}3*$LH>+wNU+`sAQ8q1 zAU4w;rgxxGPLK#w1hWEYgbRa=2HC{~CK(U0)PZbatpJUffY;jX0olU%1w=CK0k1vm z0=bH*i+LLZBg9_DLm>AsegWBqOhVlZ*2~-iau0J0$Q+0`a|=ipoc#jkBZvr77aJeQ ze29%;_d|V(F2Z<-&4GcD5$bD@N~j1VBq8F5K)M(Yv91F94z#jz56F*9dmw%Uxf~W& z$ZU{lOnX2shmyF$5F!o+sSD& ?()eSUW01h#z2uLqX7R-jk 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

IQ5fkEPk!5FOV!?2N4 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%0D-E#$A8= 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 UnQ;eWN^38p_D}!}gW8d@0L|;Ty#N3J literal 0 HcmV?d00001 diff --git a/llimphi-text/examples/hello_text.rs b/llimphi-text/examples/hello_text.rs new file mode 100644 index 0000000..7cc8614 --- /dev/null +++ b/llimphi-text/examples/hello_text.rs @@ -0,0 +1,167 @@ +//! Texto via parley sobre vello: párrafo wrappeable + shaping (kerning, +//! ligatures, bidi, fallback CJK/emoji). +//! +//! Corre con: `cargo run -p llimphi-text --example hello_text --release`. + +use std::sync::Arc; + +use llimphi_hal::winit::application::ApplicationHandler; +use llimphi_hal::winit::dpi::LogicalSize; +use llimphi_hal::winit::event::WindowEvent; +use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId}; +use llimphi_hal::{Hal, Surface, WinitSurface}; +use llimphi_text::peniko::{color::palette, Color}; +use llimphi_text::{draw_block, Alignment, TextBlock, Typesetter}; + +const PARRAFO: &str = "Llimphi pinta vector preciso sobre el silicio: \ +geometrías exactas, sin cajas negras. شكراً 你好 — el shaping de parley \ +maneja kerning, ligaduras y fallback CJK/Arabic en la misma línea."; + +struct State { + window: Arc, + hal: Hal, + surface: WinitSurface, + renderer: llimphi_raster::Renderer, + scene: llimphi_raster::vello::Scene, + typesetter: Typesetter, +} + +struct App { + state: Option, +} + +impl ApplicationHandler for App { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.state.is_some() { + return; + } + let window = event_loop + .create_window( + WindowAttributes::default() + .with_title("llimphi · hello_text") + .with_inner_size(LogicalSize::new(960u32, 540u32)), + ) + .expect("create window"); + let window = Arc::new(window); + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let surface = WinitSurface::new(&hal, window.clone()).expect("surface"); + let renderer = llimphi_raster::Renderer::new(&hal).expect("renderer"); + let typesetter = Typesetter::new(); + window.request_redraw(); + self.state = Some(State { + window, + hal, + surface, + renderer, + scene: llimphi_raster::vello::Scene::new(), + typesetter, + }); + } + + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + _id: WindowId, + event: WindowEvent, + ) { + let Some(state) = self.state.as_mut() else { + return; + }; + match event { + WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::Resized(size) => { + state.surface.resize(size.width, size.height); + state.window.request_redraw(); + } + WindowEvent::RedrawRequested => { + let frame = match state.surface.acquire() { + Ok(f) => f, + Err(_) => { + let (w, h) = state.surface.size(); + state.surface.resize(w, h); + state.window.request_redraw(); + return; + } + }; + let (w, _h) = frame.size(); + let margin_x = 64.0_f64; + let margin_y = 64.0_f64; + let inner_w = (w as f32 - 2.0 * margin_x as f32).max(100.0); + state.scene.reset(); + + // Título centrado + draw_block( + &mut state.scene, + &mut state.typesetter, + &TextBlock { + text: "Llimphi", + size_px: 96.0, + color: Color::from_rgba8(220, 230, 240, 255), + origin: (margin_x, margin_y), + max_width: Some(inner_w), + alignment: Alignment::Center, + line_height: 1.0, + + italic: false, + font_family: None, + }, + ); + + // Subtítulo centrado + draw_block( + &mut state.scene, + &mut state.typesetter, + &TextBlock { + text: "motor gráfico soberano · parley + vello", + size_px: 20.0, + color: Color::from_rgba8(140, 160, 180, 255), + origin: (margin_x, margin_y + 110.0), + max_width: Some(inner_w), + alignment: Alignment::Center, + line_height: 1.0, + + italic: false, + font_family: None, + }, + ); + + // Párrafo justificado con wrap + draw_block( + &mut state.scene, + &mut state.typesetter, + &TextBlock { + text: PARRAFO, + size_px: 22.0, + color: Color::from_rgba8(200, 210, 220, 255), + origin: (margin_x, margin_y + 170.0), + max_width: Some(inner_w), + alignment: Alignment::Justify, + line_height: 1.4, + + italic: false, + font_family: None, + }, + ); + + if let Err(e) = state.renderer.render( + &state.hal, + &state.scene, + &frame, + palette::css::BLACK, + ) { + eprintln!("render error: {e}"); + } + state.surface.present(frame, &state.hal); + } + _ => {} + } + } +} + +fn main() { + let event_loop = EventLoop::new().expect("event loop"); + event_loop.set_control_flow(ControlFlow::Wait); + let mut app = App { state: None }; + event_loop.run_app(&mut app).expect("run app"); +} diff --git a/llimphi-text/src/lib.rs b/llimphi-text/src/lib.rs new file mode 100644 index 0000000..730cf96 --- /dev/null +++ b/llimphi-text/src/lib.rs @@ -0,0 +1,359 @@ +//! llimphi-text — Texto sobre vello vía parley. +//! +//! parley hace shaping completo (bidi, ligatures, kerning), line break y +//! alineación; fontique resuelve fuentes del sistema con fallback CJK/emoji. +//! Aquí lo envolvemos en una API mínima centrada en el caso común: un +//! bloque de texto con color uniforme, ancho máximo opcional y alineación. + +use vello::peniko::{Brush, Color}; + +pub use parley; +pub use vello; +pub use vello::peniko; + +/// Estado compartido del motor de texto. Una instancia por proceso es lo +/// recomendado: `FontContext` cachea la base de fuentes y `LayoutContext` +/// reutiliza allocaciones entre layouts. +pub struct Typesetter { + font_cx: parley::FontContext, + layout_cx: parley::LayoutContext<()>, + /// Contexto separado para layouts multicolor (`Brush` por rango). El + /// brush genérico de parley no puede ser `()` y `RunBrush` a la vez en + /// el mismo `LayoutContext`, así que mantenemos uno por sabor. + runs_cx: parley::LayoutContext, +} + +impl Default for Typesetter { + fn default() -> Self { + Self::new() + } +} + +/// DejaVu Sans embebida como **fallback universal de símbolos**. El motor +/// confía en las fuentes del sistema vía fontique, pero muchas instalaciones +/// (p. ej. solo Liberation/Adwaita) carecen de glyphs para flechas (`→`), +/// formas geométricas (`● ▶`), dingbats (`✓ ✗ ✎`), avisos (`⚠`) o astro +/// (`♈ ☉ ☽`) — y entonces parley pinta el "tofu" (□). DejaVu cubre todo ese +/// rango; la registramos y la enganchamos al fallback del script `Common` +/// (`Zyyy`), que es donde Unicode clasifica esos símbolos. Así cualquier app +/// Llimphi deja de mostrar cuadrados sin tocar una línea de su código. +/// Licencia: Bitstream Vera + Arev (libre, redistribuible). +const DEJAVU_SANS: &[u8] = include_bytes!("../assets/DejaVuSans.ttf"); + +impl Typesetter { + pub fn new() -> Self { + let mut font_cx = parley::FontContext::new(); + Self::install_symbol_fallback(&mut font_cx); + Self { + font_cx, + layout_cx: parley::LayoutContext::new(), + runs_cx: parley::LayoutContext::new(), + } + } + + /// Registra DejaVu Sans y la apila como último recurso para los símbolos + /// del script `Common` (flechas, geométricos, dingbats, astro…). Ver la + /// nota de [`DEJAVU_SANS`]. Best-effort: si algo falla, el texto sigue + /// funcionando con las fuentes del sistema (solo reaparecería el tofu). + fn install_symbol_fallback(font_cx: &mut parley::FontContext) { + use parley::fontique::Blob; + let blob = Blob::new(std::sync::Arc::new(DEJAVU_SANS)); + let registered = font_cx.collection.register_fonts(blob, None); + if let Some((family_id, _)) = registered.first() { + // `Zyyy` (Common) es el script de la inmensa mayoría de los + // símbolos que daban tofu; lo apilamos al final del fallback. + font_cx + .collection + .append_fallbacks("Zyyy", std::iter::once(*family_id)); + } + } + + /// Acceso al `FontContext` por si se necesita registrar fuentes extra + /// o cambiar la stack de fallback. + pub fn font_context_mut(&mut self) -> &mut parley::FontContext { + &mut self.font_cx + } + + /// Construye y resuelve un `parley::Layout`. Aplica `font_size`, + /// `line_height` (multiplicador del font_size), `max_width` (line + /// break), y `alignment`. `italic`=true selecciona la variante + /// italic/oblique de la fuente activa (vía `parley::FontStyle`). + pub fn layout( + &mut self, + text: &str, + size_px: f32, + max_width: Option, + alignment: Alignment, + line_height: f32, + italic: bool, + font_family: Option<&str>, + ) -> parley::Layout<()> { + let mut builder = + self.layout_cx + .ranged_builder(&mut self.font_cx, text, 1.0, true); + builder.push_default(parley::StyleProperty::FontSize(size_px)); + builder.push_default(parley::StyleProperty::LineHeight(line_height)); + if italic { + builder.push_default(parley::StyleProperty::FontStyle( + parley::FontStyle::Italic, + )); + } + if let Some(ff) = font_family { + // parley::FontStack::Source acepta CSS-like syntax + // (`"Helvetica", sans-serif`). + builder.push_default(parley::StyleProperty::FontStack( + parley::FontStack::Source(std::borrow::Cow::Borrowed(ff)), + )); + } + let mut layout = builder.build(text); + layout.break_all_lines(max_width); + layout.align( + max_width, + alignment.into(), + parley::AlignmentOptions::default(), + ); + layout + } + + /// Construye un layout **multicolor** en una sola pasada de shaping: + /// `default_color` cubre todo el texto y cada `(start_byte, end_byte, + /// color)` lo sobreescribe en su rango (offsets en **bytes**, no chars — + /// la convención de parley). Pensado para syntax highlighting: shapear + /// la línea entera una vez con un color por token, en vez de un layout + /// por token. Sin wrap (`max_width = None`); el caller posiciona la línea. + pub fn layout_runs( + &mut self, + text: &str, + size_px: f32, + default_color: Color, + runs: &[(usize, usize, Color)], + alignment: Alignment, + line_height: f32, + ) -> parley::Layout { + let mut builder = self + .runs_cx + .ranged_builder(&mut self.font_cx, text, 1.0, true); + builder.push_default(parley::StyleProperty::FontSize(size_px)); + builder.push_default(parley::StyleProperty::LineHeight(line_height)); + builder.push_default(parley::StyleProperty::Brush(RunBrush(default_color))); + let len = text.len(); + for &(start, end, color) in runs { + if start < end && end <= len { + builder.push(parley::StyleProperty::Brush(RunBrush(color)), start..end); + } + } + let mut layout = builder.build(text); + layout.break_all_lines(None); + layout.align(None, alignment.into(), parley::AlignmentOptions::default()); + layout + } +} + +/// Brush por-run para texto multicolor. Newtype sobre [`Color`] porque +/// parley exige que el brush genérico implemente `Default` (que `Color` no +/// garantiza); aquí proveemos uno explícito (negro opaco) que nunca se ve +/// en la práctica: todo run lleva su color o el `default_color` del bloque. +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct RunBrush(pub Color); + +impl Default for RunBrush { + fn default() -> Self { + RunBrush(Color::from_rgba8(0, 0, 0, 255)) + } +} + +/// Alineación horizontal del bloque dentro de su ancho máximo. +#[derive(Debug, Clone, Copy)] +pub enum Alignment { + Start, + Center, + End, + Justify, +} + +impl From for parley::Alignment { + fn from(a: Alignment) -> Self { + match a { + Alignment::Start => parley::Alignment::Start, + Alignment::Center => parley::Alignment::Middle, + Alignment::End => parley::Alignment::End, + Alignment::Justify => parley::Alignment::Justified, + } + } +} + +/// Especificación de un bloque de texto a rasterizar. +pub struct TextBlock<'a> { + pub text: &'a str, + pub size_px: f32, + pub color: Color, + /// Esquina superior-izquierda del bloque (no el baseline — parley se + /// encarga del baseline internamente). + pub origin: (f64, f64), + pub max_width: Option, + pub alignment: Alignment, + /// Múltiplo del font_size (1.0 = compacto, 1.3 = cómodo). + pub line_height: f32, + /// `true` → fuerza variante italic/oblique en la fuente activa. + pub italic: bool, + /// CSS-style `font-family` string. `None` = sans-serif default. + pub font_family: Option, +} + +impl<'a> TextBlock<'a> { + /// Constructor simple para una línea sin wrap. + pub fn simple(text: &'a str, size_px: f32, color: Color, origin: (f64, f64)) -> Self { + Self { + text, + size_px, + color, + origin, + max_width: None, + alignment: Alignment::Start, + line_height: 1.0, + italic: false, + font_family: None, + } + } +} + +/// Medidas resultantes de un layout. +#[derive(Debug, Clone, Copy)] +pub struct Measurement { + pub width: f32, + pub height: f32, +} + +/// Construye el layout (shaping + line break + alineación) listo para medir +/// y/o pintar. Usá esta API cuando necesitás el alto **antes** de elegir el +/// origen (p. ej. centrado vertical) y no querés repetir el shaping en el +/// `draw`: medís sobre el layout retornado y luego lo pasás a +/// [`draw_layout`]. +pub fn layout_block(ts: &mut Typesetter, block: &TextBlock<'_>) -> parley::Layout<()> { + ts.layout( + block.text, + block.size_px, + block.max_width, + block.alignment, + block.line_height, + block.italic, + block.font_family.as_deref(), + ) +} + +/// Devuelve las medidas de un layout ya resuelto. Equivalente conceptual a +/// `(layout.width(), layout.height())` pero envuelto en [`Measurement`]. +pub fn measurement(layout: &parley::Layout<()>) -> Measurement { + Measurement { + width: layout.width(), + height: layout.height(), + } +} + +/// Pinta un layout ya resuelto en `scene` con `color` y un offset `origin` +/// (esquina superior-izquierda del bloque). No alloca: los glifos van +/// directo del iterador de parley al builder de vello. +pub fn draw_layout( + scene: &mut vello::Scene, + layout: &parley::Layout<()>, + color: Color, + origin: (f64, f64), +) { + draw_layout_xf(scene, layout, color, vello::kurbo::Affine::translate(origin)); +} + +/// Igual que [`draw_layout`] pero con una **afín completa** en vez de sólo un +/// desplazamiento: permite pintar texto girado/escalado (p. ej. dentro de un +/// marco rotado en una presentación espacial). El origen del layout (0,0) es el +/// que mapea `transform`; las posiciones de glifo se aplican en ese espacio. +pub fn draw_layout_xf( + scene: &mut vello::Scene, + layout: &parley::Layout<()>, + color: Color, + transform: vello::kurbo::Affine, +) { + draw_layout_brush_xf(scene, layout, &Brush::Solid(color), transform); +} + +/// Igual que [`draw_layout_xf`] pero con un [`Brush`] arbitrario en vez de un +/// color sólido: permite rellenar los glifos con un gradiente o una imagen +/// (p. ej. CSS `background-clip: text`). El brush se interpreta en el espacio +/// **local** del layout (origen 0,0) y `transform` lo lleva al lugar final — +/// así un gradiente construido en coords (0,0)-(w,h) queda alineado con los +/// glifos. Para texto normal usá [`draw_layout_xf`] (solid = máxima compat). +pub fn draw_layout_brush_xf( + scene: &mut vello::Scene, + layout: &parley::Layout<()>, + brush: &Brush, + transform: vello::kurbo::Affine, +) { + for line in layout.lines() { + for item in line.items() { + if let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item { + let run = glyph_run.run(); + let font = run.font().clone(); + let font_size = run.font_size(); + scene + .draw_glyphs(&font) + .font_size(font_size) + .brush(brush) + .transform(transform) + .draw( + peniko::Fill::NonZero, + glyph_run.positioned_glyphs().map(|g| vello::Glyph { + id: g.id as u32, + x: g.x, + y: g.y, + }), + ); + } + } + } +} + +/// Pinta un layout **multicolor** ([`Typesetter::layout_runs`]): cada +/// `glyph_run` usa el color de su propio brush ([`RunBrush`]) en vez de un +/// color uniforme. `origin` es la esquina superior-izquierda del bloque. +pub fn draw_layout_runs( + scene: &mut vello::Scene, + layout: &parley::Layout, + origin: (f64, f64), +) { + let transform = vello::kurbo::Affine::translate(origin); + for line in layout.lines() { + for item in line.items() { + if let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item { + let brush = Brush::Solid(glyph_run.style().brush.0); + let run = glyph_run.run(); + let font = run.font().clone(); + let font_size = run.font_size(); + scene + .draw_glyphs(&font) + .font_size(font_size) + .brush(&brush) + .transform(transform) + .draw( + peniko::Fill::NonZero, + glyph_run.positioned_glyphs().map(|g| vello::Glyph { + id: g.id as u32, + x: g.x, + y: g.y, + }), + ); + } + } + } +} + +/// Mide sin pintar. Atajo de [`layout_block`] + [`measurement`] para +/// llamadores que sólo necesitan el bounding box. +pub fn measure(ts: &mut Typesetter, block: &TextBlock<'_>) -> Measurement { + measurement(&layout_block(ts, block)) +} + +/// Rasteriza el bloque en `scene` haciendo shaping una sola vez. Equivale a +/// `layout_block` + `draw_layout` con `block.origin`. +pub fn draw_block(scene: &mut vello::Scene, ts: &mut Typesetter, block: &TextBlock<'_>) { + let layout = layout_block(ts, block); + draw_layout(scene, &layout, block.color, block.origin); +} diff --git a/llimphi-theme/Cargo.toml b/llimphi-theme/Cargo.toml new file mode 100644 index 0000000..03cc663 --- /dev/null +++ b/llimphi-theme/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-theme" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-theme — paleta compartida entre apps Llimphi. Define los slots semánticos (bg_app, fg_text, accent, etc.) en `peniko::Color`; cada widget toma su paleta del Theme vía `Palette::from_theme(&theme)`." + +[dependencies] +# Reexporta peniko::Color para que las apps consuman sin pull-in directo. +llimphi-raster = { path = "../llimphi-raster" } diff --git a/llimphi-theme/LEEME.md b/llimphi-theme/LEEME.md new file mode 100644 index 0000000..c08bb09 --- /dev/null +++ b/llimphi-theme/LEEME.md @@ -0,0 +1,9 @@ +# llimphi-theme + +> Themes Dark/Light/Aurora/Sunset + paleta de [llimphi](../README.md). + +`Theme { bg_app, bg_panel, bg_input, bg_button, fg_text, fg_muted, accent, border, ... }`. Cuatro variantes built-in; cualquier app puede definir las suyas. Tema reactivo: el cambio se propaga sin re-mount del árbol. + +## Deps + +- `serde` diff --git a/llimphi-theme/README.md b/llimphi-theme/README.md new file mode 100644 index 0000000..cd06e10 --- /dev/null +++ b/llimphi-theme/README.md @@ -0,0 +1,9 @@ +# llimphi-theme + +> Dark/Light/Aurora/Sunset themes + palette of [llimphi](../README.md). + +`Theme { bg_app, bg_panel, bg_input, bg_button, fg_text, fg_muted, accent, border, ... }`. Four built-in variants; any app can define its own. Reactive theme: changes propagate without re-mounting the tree. + +## Deps + +- `serde` diff --git a/llimphi-theme/src/lib.rs b/llimphi-theme/src/lib.rs new file mode 100644 index 0000000..3ff7990 --- /dev/null +++ b/llimphi-theme/src/lib.rs @@ -0,0 +1,361 @@ +//! `llimphi-theme` — paleta compartida entre apps Llimphi. +//! +//! Define un set de slots semánticos (`bg_app`, `fg_text`, `accent`, etc.) +//! que cada widget mapea a su propio `Palette` específico vía +//! `Palette::from_theme(&theme)`. El analógo Llimphi al `nahual-theme` +//! GPUI, pero con colores `peniko::Color` y sin macros de Background / +//! gradiente — Llimphi pinta colores sólidos por ahora. +//! +//! Disponer del Theme en un crate aparte permite: +//! 1. **Consistencia visual**: las apps comparten paleta sin redefinirla. +//! 2. **Temas intercambiables**: `Theme::dark()` vs `Theme::light()` (o +//! más adelante, sobreescritos por config del usuario). +//! 3. **Widgets desacoplados**: cada widget acepta su `Palette` (no el +//! Theme entero), así un consumidor que sólo necesita un botón con +//! colores no-temáticos puede construir su `ButtonPalette` a mano. + +#![forbid(unsafe_code)] + +pub use llimphi_raster::peniko::Color; + +use std::time::Duration; + +// ===================================================================== +// Tokens transversales — motion, alpha, radius +// ===================================================================== +// +// Los widgets de elegancia (tooltip, toast, modal, spinner, splash, …) +// comparten **duraciones**, **alphas** y **radios** para que el sistema +// se sienta uno solo. Cada token es `const`: las apps pueden referenciar +// `motion::NORMAL`/`alpha::SCRIM` directamente, o tomarlos del `Theme` +// vía `theme.motion()` / `theme.alpha()` / `theme.radius()` cuando una +// future variante por preset lo requiera. + +/// Duraciones canónicas (segundo nivel: rítmico, no nervioso, no +/// soporífero). Los widgets eligen `FAST` para microinteracciones +/// (hover, focus), `NORMAL` para transiciones principales (toast entrar, +/// modal abrir) y `SLOW` para énfasis o entradas dramáticas (splash de +/// boot). +pub mod motion { + use super::Duration; + + pub const FAST: Duration = Duration::from_millis(80); + pub const NORMAL: Duration = Duration::from_millis(160); + pub const SLOW: Duration = Duration::from_millis(320); + + /// Easing estándar — cubic-out. Energía inicial, asentamiento suave. + /// La gran mayoría de transiciones de salida / aparición. + #[inline] + pub fn ease_out_cubic(t: f32) -> f32 { + let inv = 1.0 - t.clamp(0.0, 1.0); + 1.0 - inv * inv * inv + } + + /// Easing énfasis — cubic-in-out. Para movimientos que cruzan la + /// pantalla y necesitan acentuar el centro (modales, splashes). + #[inline] + pub fn ease_in_out_cubic(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + if t < 0.5 { + 4.0 * t * t * t + } else { + let f = -2.0 * t + 2.0; + 1.0 - f * f * f / 2.0 + } + } + + /// Lineal — no es elegante pero a veces es lo correcto (barra de + /// progreso, valores numéricos crudos). + #[inline] + pub fn linear(t: f32) -> f32 { + t.clamp(0.0, 1.0) + } +} + +/// Valores de opacidad alfa (0–255) para capas semánticas. Usar siempre +/// que se quiera *transparencia coherente*. El widget que improvisa su +/// propio alpha rompe la firma visual. +pub mod alpha { + /// Scrim que cubre la app cuando hay overlay (menú/modal/picker). + /// Apaga el fondo lo justo para que el overlay tenga jerarquía, + /// sin ocultar contexto. + pub const SCRIM: u8 = 64; + + /// Tinte aplicado a un panel "vidrio" sobre fondo activo (tooltip, + /// status hint). Casi opaco pero deja respirar. + pub const GLASS_PANEL: u8 = 232; + + /// Elementos deshabilitados — visibles pero con menos peso. + pub const DISABLED: u8 = 140; + + /// Hint sutil (text watermark, ghost) — apenas legible. + pub const HINT: u8 = 96; +} + +/// Radios de esquina canónicos. La elegancia se construye en escalera: +/// `XS` para chips e inputs, `SM` para botones, `MD` para paneles, +/// `LG` para superficies grandes (toast, modal, card destacada). +pub mod radius { + pub const XS: f64 = 2.0; + pub const SM: f64 = 4.0; + pub const MD: f64 = 8.0; + pub const LG: f64 = 12.0; + pub const XL: f64 = 20.0; +} + +/// Paleta de la app. Slots semánticos que cubren los casos comunes +/// (fondo, texto, hover, foco, acento). Los widgets reusables toman su +/// `Palette` específico desde acá vía `Palette::from_theme(&theme)`. +#[derive(Debug, Clone, Copy)] +pub struct Theme { + /// Nombre legible del preset — alimenta `Theme::by_name`, + /// `next_after`, y los UIs que ciclan presets (theme-switcher). + pub name: &'static str, + + // --- Fondos --- + /// Fondo de la ventana / superficie raíz. + pub bg_app: Color, + /// Fondo de paneles (sidebars, cards). + pub bg_panel: Color, + /// Fondo alternativo para barras / strips (tab bar, status bar). + pub bg_panel_alt: Color, + /// Fondo de campos de input (texto editable). + pub bg_input: Color, + /// Fondo de input cuando tiene foco. + pub bg_input_focus: Color, + /// Fondo de botón (chip). + pub bg_button: Color, + /// Fondo de botón al hover. + pub bg_button_hover: Color, + /// Fondo de la fila/item seleccionado (lista, tree). + pub bg_selected: Color, + /// Fondo de fila al hover (sin selección). + pub bg_row_hover: Color, + + // --- Foregrounds (texto) --- + pub fg_text: Color, + pub fg_muted: Color, + pub fg_placeholder: Color, + pub fg_destructive: Color, + + // --- Bordes y acento --- + pub border: Color, + pub border_focus: Color, + /// Acento primario — divisores activos, borde de input focado, + /// underline del tab activo, etc. Tono único de la app. + pub accent: Color, +} + +impl Default for Theme { + fn default() -> Self { + Self::dark() + } +} + +impl Theme { + /// Tema oscuro — el default. Análogo al `nahual-theme` dark en su + /// versión Llimphi: tonos azulados profundos, acento azul claro. + pub const fn dark() -> Self { + Self { + name: "Dark", + bg_app: Color::from_rgba8(14, 16, 22, 255), + bg_panel: Color::from_rgba8(22, 26, 36, 255), + bg_panel_alt: Color::from_rgba8(18, 22, 30, 255), + bg_input: Color::from_rgba8(16, 20, 28, 255), + bg_input_focus: Color::from_rgba8(20, 26, 38, 255), + bg_button: Color::from_rgba8(36, 42, 56, 255), + bg_button_hover: Color::from_rgba8(54, 64, 86, 255), + bg_selected: Color::from_rgba8(58, 78, 128, 255), + bg_row_hover: Color::from_rgba8(36, 44, 60, 255), + fg_text: Color::from_rgba8(214, 222, 232, 255), + fg_muted: Color::from_rgba8(140, 152, 170, 255), + fg_placeholder: Color::from_rgba8(95, 105, 122, 255), + fg_destructive: Color::from_rgba8(220, 110, 110, 255), + border: Color::from_rgba8(46, 54, 70, 255), + border_focus: Color::from_rgba8(110, 140, 220, 255), + accent: Color::from_rgba8(110, 140, 220, 255), + } + } + + /// Tema claro — contraste revisado para WCAG AA sobre `bg_app`: + /// `fg_text` ~12:1, `fg_muted` ~5.4:1 (texto secundario legible), + /// `fg_destructive` y `accent` oscurecidos para superar 4.5:1 sobre + /// fondos claros. `fg_placeholder` queda deliberadamente tenue + /// (hint, no contenido). + pub const fn light() -> Self { + Self { + name: "Light", + bg_app: Color::from_rgba8(244, 246, 250, 255), + bg_panel: Color::from_rgba8(232, 236, 242, 255), + bg_panel_alt: Color::from_rgba8(224, 230, 240, 255), + bg_input: Color::from_rgba8(255, 255, 255, 255), + bg_input_focus: Color::from_rgba8(250, 252, 255, 255), + bg_button: Color::from_rgba8(220, 226, 236, 255), + bg_button_hover: Color::from_rgba8(200, 210, 226, 255), + bg_selected: Color::from_rgba8(160, 180, 220, 255), + bg_row_hover: Color::from_rgba8(214, 222, 236, 255), + fg_text: Color::from_rgba8(24, 32, 45, 255), + fg_muted: Color::from_rgba8(86, 98, 116, 255), + fg_placeholder: Color::from_rgba8(140, 150, 168, 255), + fg_destructive: Color::from_rgba8(168, 48, 48, 255), + border: Color::from_rgba8(190, 199, 214, 255), + border_focus: Color::from_rgba8(48, 92, 196, 255), + accent: Color::from_rgba8(48, 92, 196, 255), + } + } + + /// Tema "Aurora" — verdes nocturnos con acento aqua. Análogo al + /// preset del nahual-theme. + pub const fn aurora() -> Self { + Self { + name: "Aurora", + bg_app: Color::from_rgba8(8, 18, 22, 255), + bg_panel: Color::from_rgba8(14, 28, 34, 255), + bg_panel_alt: Color::from_rgba8(12, 24, 30, 255), + bg_input: Color::from_rgba8(10, 22, 28, 255), + bg_input_focus: Color::from_rgba8(14, 30, 38, 255), + bg_button: Color::from_rgba8(20, 44, 52, 255), + bg_button_hover: Color::from_rgba8(30, 66, 78, 255), + bg_selected: Color::from_rgba8(30, 90, 100, 255), + bg_row_hover: Color::from_rgba8(20, 46, 56, 255), + fg_text: Color::from_rgba8(214, 232, 232, 255), + fg_muted: Color::from_rgba8(130, 168, 168, 255), + fg_placeholder: Color::from_rgba8(90, 120, 120, 255), + fg_destructive: Color::from_rgba8(220, 110, 110, 255), + border: Color::from_rgba8(38, 70, 78, 255), + border_focus: Color::from_rgba8(80, 200, 200, 255), + accent: Color::from_rgba8(80, 200, 200, 255), + } + } + + /// Tema "Sunset" — cálidos con acento naranja, sobre base oscura. + pub const fn sunset() -> Self { + Self { + name: "Sunset", + bg_app: Color::from_rgba8(22, 14, 14, 255), + bg_panel: Color::from_rgba8(34, 22, 22, 255), + bg_panel_alt: Color::from_rgba8(28, 18, 18, 255), + bg_input: Color::from_rgba8(28, 18, 18, 255), + bg_input_focus: Color::from_rgba8(36, 24, 22, 255), + bg_button: Color::from_rgba8(54, 34, 28, 255), + bg_button_hover: Color::from_rgba8(78, 50, 38, 255), + bg_selected: Color::from_rgba8(120, 64, 38, 255), + bg_row_hover: Color::from_rgba8(56, 36, 28, 255), + fg_text: Color::from_rgba8(238, 220, 200, 255), + fg_muted: Color::from_rgba8(174, 142, 120, 255), + fg_placeholder: Color::from_rgba8(120, 96, 80, 255), + fg_destructive: Color::from_rgba8(220, 100, 100, 255), + border: Color::from_rgba8(70, 46, 36, 255), + border_focus: Color::from_rgba8(232, 140, 70, 255), + accent: Color::from_rgba8(232, 140, 70, 255), + } + } + + /// Tema "Print" — blanco y negro de alto contraste para impresión. + /// Fondo blanco papel, tinta negra, sin grises decorativos: todo lo + /// que se imprime tiene que leerse en una fotocopiadora. `fg_muted` + /// es un gris medio (3.5:1) reservado a metadatos; el cuerpo va en + /// negro puro. Acento y bordes negros — la tinta es una sola. + pub const fn print() -> Self { + Self { + name: "Print", + bg_app: Color::from_rgba8(255, 255, 255, 255), + bg_panel: Color::from_rgba8(255, 255, 255, 255), + bg_panel_alt: Color::from_rgba8(246, 246, 246, 255), + bg_input: Color::from_rgba8(255, 255, 255, 255), + bg_input_focus: Color::from_rgba8(248, 248, 248, 255), + bg_button: Color::from_rgba8(238, 238, 238, 255), + bg_button_hover: Color::from_rgba8(224, 224, 224, 255), + bg_selected: Color::from_rgba8(220, 220, 220, 255), + bg_row_hover: Color::from_rgba8(240, 240, 240, 255), + fg_text: Color::from_rgba8(0, 0, 0, 255), + fg_muted: Color::from_rgba8(90, 90, 90, 255), + fg_placeholder: Color::from_rgba8(140, 140, 140, 255), + fg_destructive: Color::from_rgba8(0, 0, 0, 255), + border: Color::from_rgba8(0, 0, 0, 255), + border_focus: Color::from_rgba8(0, 0, 0, 255), + accent: Color::from_rgba8(0, 0, 0, 255), + } + } + + /// Todos los presets del repo, en el orden canónico de rotación + /// (Dark → Light → Aurora → Sunset → Dark…). El theme-switcher + /// los consume vía [`Theme::next_after`]. `print()` queda fuera de la + /// rotación a propósito — es un modo deliberado (imprimir), no un + /// gusto estético que se cicle por accidente. + pub fn all() -> Vec { + vec![Self::dark(), Self::light(), Self::aurora(), Self::sunset()] + } + + /// Busca un preset por nombre exacto. + pub fn by_name(name: &str) -> Option { + Self::all().into_iter().find(|t| t.name == name) + } + + /// Próximo preset en la rotación de [`Theme::all`]. Si `current` no + /// se encuentra, retorna el primero — el switcher nunca se traba. + pub fn next_after(current: &str) -> Self { + let all = Self::all(); + let idx = all + .iter() + .position(|t| t.name == current) + .map(|i| (i + 1) % all.len()) + .unwrap_or(0); + all[idx] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn presets_have_unique_names() { + let all = Theme::all(); + let mut names: Vec<&str> = all.iter().map(|t| t.name).collect(); + let n_before = names.len(); + names.sort(); + names.dedup(); + assert_eq!(names.len(), n_before, "nombres duplicados en Theme::all()"); + } + + #[test] + fn by_name_finds_each_preset() { + for t in Theme::all() { + let by = Theme::by_name(t.name).expect("preset registrado"); + assert_eq!(by.name, t.name); + } + } + + #[test] + fn by_name_returns_none_for_unknown() { + assert!(Theme::by_name("ThisDoesNotExist").is_none()); + } + + #[test] + fn next_after_cycles_through_all_presets() { + let all = Theme::all(); + let mut current = all[0].name; + let mut visited = vec![current]; + for _ in 0..all.len() - 1 { + current = Theme::next_after(current).name; + visited.push(current); + } + let names: Vec<&str> = all.iter().map(|t| t.name).collect(); + assert_eq!(visited, names); + // El siguiente debe volver al primero. + let wrapped = Theme::next_after(current).name; + assert_eq!(wrapped, all[0].name); + } + + #[test] + fn next_after_unknown_falls_back_to_first() { + let n = Theme::next_after("Nope").name; + assert_eq!(n, Theme::all()[0].name); + } + + #[test] + fn dark_is_the_default() { + assert_eq!(Theme::default().name, "Dark"); + } +} diff --git a/llimphi-ui/Cargo.toml b/llimphi-ui/Cargo.toml new file mode 100644 index 0000000..303b2ab --- /dev/null +++ b/llimphi-ui/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "llimphi-ui" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true + +[dependencies] +llimphi-hal = { path = "../llimphi-hal" } +llimphi-layout = { path = "../llimphi-layout" } +llimphi-raster = { path = "../llimphi-raster" } +llimphi-text = { path = "../llimphi-text" } +# El compositor declarativo (winit-free): View, mount, paint, hit-test. +llimphi-compositor = { path = "../llimphi-compositor" } +pollster = { workspace = true } + +[[example]] +name = "counter" +path = "examples/counter.rs" + +[[example]] +name = "editor" +path = "examples/editor.rs" + +[[example]] +name = "gpu_paint_demo" +path = "examples/gpu_paint_demo.rs" diff --git a/llimphi-ui/LEEME.md b/llimphi-ui/LEEME.md new file mode 100644 index 0000000..851c27c --- /dev/null +++ b/llimphi-ui/LEEME.md @@ -0,0 +1,9 @@ +# llimphi-ui + +> `View` retained-mode + Elm-arch de [llimphi](../README.md). + +API pública del framework: `App { Model, Msg, init, update, view }`. Reactivo: `update` muta el `Model`, `view(&Model)` produce el árbol; el runtime difea contra el árbol anterior y aplica el mínimo. Hover/focus/click se traducen a `Msg`s tipados. + +## Deps + +- [`llimphi-hal`](../llimphi-hal/README.md), [`llimphi-raster`](../llimphi-raster/README.md), [`llimphi-layout`](../llimphi-layout/README.md), [`llimphi-text`](../llimphi-text/README.md), [`llimphi-theme`](../llimphi-theme/README.md) diff --git a/llimphi-ui/README.md b/llimphi-ui/README.md new file mode 100644 index 0000000..6db31b4 --- /dev/null +++ b/llimphi-ui/README.md @@ -0,0 +1,9 @@ +# llimphi-ui + +> Retained-mode `View` + Elm-arch of [llimphi](../README.md). + +Public API of the framework: `App { Model, Msg, init, update, view }`. Reactive: `update` mutates `Model`, `view(&Model)` produces the tree; the runtime diffs against the previous tree and applies the minimum. Hover/focus/click translate to typed `Msg`s. + +## Deps + +- [`llimphi-hal`](../llimphi-hal/README.md), [`llimphi-raster`](../llimphi-raster/README.md), [`llimphi-layout`](../llimphi-layout/README.md), [`llimphi-text`](../llimphi-text/README.md), [`llimphi-theme`](../llimphi-theme/README.md) diff --git a/llimphi-ui/examples/counter.rs b/llimphi-ui/examples/counter.rs new file mode 100644 index 0000000..8146812 --- /dev/null +++ b/llimphi-ui/examples/counter.rs @@ -0,0 +1,124 @@ +//! Fase 4 de Llimphi: contador Elm puro con texto real. +//! +//! Bucle completo input→update→view→layout→raster→present. El click sobre +//! el botón inferior incrementa el contador; el panel central muestra el +//! número actual rasterizado por skrifa+vello. +//! +//! Corre con: `cargo run -p llimphi-ui --example counter --release`. + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Dimension, FlexDirection, Size, Style}, + AlignItems, JustifyContent, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::{App, Handle, View}; + +#[derive(Clone)] +enum Msg { + Increment, + Reset, +} + +struct Counter; + +impl App for Counter { + type Model = u32; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · counter" + } + + fn init(_: &Handle) -> Self::Model { + 0 + } + + fn update(model: Self::Model, msg: Self::Msg, _: &Handle) -> Self::Model { + match msg { + Msg::Increment => model.saturating_add(1), + Msg::Reset => 0, + } + } + + fn view(model: &Self::Model) -> View { + let number = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + flex_grow: 1.0, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .text(model.to_string(), 160.0, Color::from_rgba8(230, 240, 250, 255)); + + let increment = View::new(Style { + size: Size { + width: length(160.0_f32), + height: length(56.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(Color::from_rgba8(60, 200, 130, 255)) + .radius(12.0) + .text("+1", 28.0, Color::from_rgba8(10, 30, 20, 255)) + .on_click(Msg::Increment); + + let reset = View::new(Style { + size: Size { + width: length(120.0_f32), + height: length(56.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(Color::from_rgba8(220, 80, 80, 255)) + .radius(12.0) + .text("reset", 22.0, Color::from_rgba8(30, 10, 10, 255)) + .on_click(Msg::Reset); + + let buttons = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(56.0_f32), + }, + gap: Size { + width: length(16.0_f32), + height: length(0.0_f32), + }, + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .children(vec![increment, reset]); + + 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(24.0_f32), + }, + padding: llimphi_ui::llimphi_layout::taffy::Rect { + left: length(32.0_f32), + right: length(32.0_f32), + top: length(32.0_f32), + bottom: length(32.0_f32), + }, + ..Default::default() + }) + .fill(Color::from_rgba8(20, 24, 32, 255)) + .children(vec![number, buttons]) + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/llimphi-ui/examples/editor.rs b/llimphi-ui/examples/editor.rs new file mode 100644 index 0000000..5dd8a46 --- /dev/null +++ b/llimphi-ui/examples/editor.rs @@ -0,0 +1,132 @@ +//! Editor mínimo: text field con char insertion, backspace, enter, ctrl+L +//! para limpiar. Valida que el bucle Elm absorbe input de teclado. +//! +//! Corre con: `cargo run -p llimphi-ui --example editor --release`. + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View}; + +#[derive(Clone)] +enum Msg { + Insert(String), + Backspace, + Clear, +} + +struct Editor; + +impl App for Editor { + type Model = String; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · editor" + } + + fn init(_: &Handle) -> Self::Model { + String::new() + } + + fn update(model: Self::Model, msg: Self::Msg, _: &Handle) -> Self::Model { + match msg { + Msg::Insert(s) => { + let mut m = model; + m.push_str(&s); + m + } + Msg::Backspace => { + let mut m = model; + m.pop(); + m + } + Msg::Clear => String::new(), + } + } + + fn on_key(_: &Self::Model, e: &KeyEvent) -> Option { + if e.state != KeyState::Pressed { + return None; + } + if e.modifiers.ctrl { + if let Key::Character(c) = &e.key { + if c.eq_ignore_ascii_case("l") { + return Some(Msg::Clear); + } + } + return None; + } + match &e.key { + Key::Named(NamedKey::Backspace) => Some(Msg::Backspace), + Key::Named(NamedKey::Enter) => Some(Msg::Insert("\n".into())), + Key::Named(NamedKey::Tab) => Some(Msg::Insert(" ".into())), + _ => e.text.clone().map(Msg::Insert), + } + } + + fn view(model: &Self::Model) -> View { + let body_text = if model.is_empty() { + "tipea algo · ctrl+L limpia · enter salto · backspace borra".to_string() + } else { + // Cursor visual al final del contenido. + format!("{model}\u{2588}") + }; + let body_color = if model.is_empty() { + Color::from_rgba8(110, 130, 150, 255) + } else { + Color::from_rgba8(220, 230, 240, 255) + }; + + let body = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + ..Default::default() + }) + .text_aligned(body_text, 22.0, body_color, Alignment::Start); + + let status = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(36.0_f32), + }, + ..Default::default() + }) + .fill(Color::from_rgba8(30, 36, 48, 255)) + .text( + format!("{} chars", model.chars().count()), + 16.0, + Color::from_rgba8(160, 180, 200, 255), + ); + + 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(8.0_f32), + }, + padding: llimphi_ui::llimphi_layout::taffy::Rect { + left: length(24.0_f32), + right: length(24.0_f32), + top: length(24.0_f32), + bottom: length(24.0_f32), + }, + ..Default::default() + }) + .fill(Color::from_rgba8(20, 24, 32, 255)) + .children(vec![body, status]) + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/llimphi-ui/examples/gpu_paint_demo.rs b/llimphi-ui/examples/gpu_paint_demo.rs new file mode 100644 index 0000000..eaba54c --- /dev/null +++ b/llimphi-ui/examples/gpu_paint_demo.rs @@ -0,0 +1,393 @@ +//! Demo del hook GPU directo (`View::gpu_paint_with`) — Fase 1 del SDD +//! `02_ruway/llimphi/SDD.md` §"GPU directo wgpu". +//! +//! Pinta una grilla de N puntos coloridos sobre un panel central usando +//! un pipeline `wgpu` propio (instanced quad), encima de un fondo y +//! títulos pintados por vello. Valida que: +//! +//! - El callback `gpu_paint_with` recibe `(device, queue, encoder, +//! view, rect)` con los recursos del runtime. +//! - El `LoadOp::Load` preserva la pasada vello (el fondo no se borra). +//! - El submit del encoder ocurre antes del `surface.present` (las +//! primitivas GPU son visibles). +//! +//! Corre con: `cargo run -p llimphi-ui --example gpu_paint_demo --release`. + +use std::sync::{Arc, OnceLock}; + +use llimphi_ui::llimphi_hal::wgpu; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, percent, FlexDirection, Size, Style}, + AlignItems, JustifyContent, Rect as TaffyRect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::{App, Handle, PaintRect, View}; + +const POINTS: u32 = 250_000; +const TARGET_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +#[derive(Clone)] +enum Msg { + Bump, +} + +struct GpuDemo; + +impl App for GpuDemo { + type Model = u32; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · gpu_paint_demo" + } + + fn init(_: &Handle) -> Self::Model { + 0 + } + + fn update(model: Self::Model, msg: Self::Msg, _: &Handle) -> Self::Model { + match msg { + Msg::Bump => model.wrapping_add(1), + } + } + + fn view(model: &Self::Model) -> View { + let title = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(48.0_f32), + }, + justify_content: Some(JustifyContent::Center), + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text( + format!("gpu_paint_with — {POINTS} puntos GPU directo · seed {model}"), + 22.0, + Color::from_rgba8(220, 230, 245, 255), + ); + + // Canvas central: vello pinta el fondo (fill + radius), GPU pinta + // la grilla de puntos encima vía gpu_paint_with. El seed del + // modelo se mete en el shader vía una rotación trivial — cada + // click cambia el patrón. El callback se invoca ya con el + // CommandEncoder del frame y la TextureView intermediate. + let seed = *model; + let canvas = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: auto(), + }, + flex_grow: 1.0, + ..Default::default() + }) + .fill(Color::from_rgba8(14, 18, 28, 255)) + .radius(8.0) + .gpu_paint_with(move |device, queue, encoder, view, rect, _viewport| { + draw_points(device, queue, encoder, view, rect, seed); + }) + .on_click(Msg::Bump); + + let footer = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(28.0_f32), + }, + justify_content: Some(JustifyContent::Center), + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text( + "click sobre el canvas → rebobinar el seed", + 14.0, + Color::from_rgba8(150, 165, 185, 255), + ); + + 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(16.0_f32), + }, + padding: TaffyRect { + left: length(24.0_f32), + right: length(24.0_f32), + top: length(16.0_f32), + bottom: length(16.0_f32), + }, + ..Default::default() + }) + .fill(Color::from_rgba8(24, 28, 38, 255)) + .children(vec![title, canvas, footer]) + } +} + +fn main() { + llimphi_ui::run::(); +} + +// ============================================================ +// Lado GPU del demo: pipeline + buffer + draw call. +// ============================================================ + +/// Estado compartido del demo a través de los frames. Se construye en +/// el primer `gpu_paint_with` (cuando ya tenemos device/queue) y se +/// reutiliza después. Sin esto pagaríamos creación de pipeline + write +/// del buffer por frame, que es lo que `GpuBatch` resolverá de raíz en +/// Fase 3. +struct DemoGpu { + pipeline: wgpu::RenderPipeline, + instances: wgpu::Buffer, + uniforms: wgpu::Buffer, + bind_group: wgpu::BindGroup, +} + +fn shared() -> &'static OnceLock> { + static SLOT: OnceLock> = OnceLock::new(); + &SLOT +} + +fn draw_points( + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + view: &wgpu::TextureView, + rect: PaintRect, + seed: u32, +) { + let gpu = shared() + .get_or_init(|| Arc::new(DemoGpu::new(device))) + .clone(); + + // Uniforms: rect + seed → el VS los usa para colocar y colorear. + let uniforms = [rect.x, rect.y, rect.w, rect.h, f32::from_bits(seed), 0.0, 0.0, 0.0]; + let mut bytes = Vec::with_capacity(32); + for v in uniforms { + bytes.extend_from_slice(&v.to_ne_bytes()); + } + queue.write_buffer(&gpu.uniforms, 0, &bytes); + + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("gpu_paint_demo-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + ops: wgpu::Operations { + // Load preserva el fondo vello ya pintado en este frame. + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + pass.set_pipeline(&gpu.pipeline); + pass.set_bind_group(0, &gpu.bind_group, &[]); + pass.set_vertex_buffer(0, gpu.instances.slice(..)); + pass.draw(0..6, 0..POINTS); + } +} + +impl DemoGpu { + fn new(device: &wgpu::Device) -> Self { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("gpu_paint_demo-shader"), + source: wgpu::ShaderSource::Wgsl(WGSL.into()), + }); + + let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("gpu_paint_demo-bgl"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("gpu_paint_demo-pl"), + bind_group_layouts: &[&bind_layout], + push_constant_ranges: &[], + }); + + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("gpu_paint_demo-pipe"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs"), + compilation_options: Default::default(), + buffers: &[wgpu::VertexBufferLayout { + array_stride: 4, + step_mode: wgpu::VertexStepMode::Instance, + attributes: &[wgpu::VertexAttribute { + format: wgpu::VertexFormat::Uint32, + offset: 0, + shader_location: 0, + }], + }], + }, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + unclipped_depth: false, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format: TARGET_FORMAT, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview: None, + cache: None, + }); + + // Instance buffer: índice 0..POINTS empaquetado como u32. + let mut idx_bytes = Vec::with_capacity((POINTS as usize) * 4); + for i in 0..POINTS { + idx_bytes.extend_from_slice(&i.to_ne_bytes()); + } + let instances = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("gpu_paint_demo-inst"), + size: idx_bytes.len() as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + // El buffer ya vive el resto del programa — escribimos una vez. + // Para esto necesitamos el queue, pero `new` no lo recibe. Lo + // mantenemos como "lazy escrito en draw_points la primera vez"; + // por simplicidad lo escribimos en el primer queue.write_buffer + // del flujo de uniforms. Actualmente el shader no usa la + // instancia (sólo @builtin(vertex_index) + uniforms + builtin + // instance_index), así que el buffer es ignorado — lo dejamos + // para que el layout del pipeline siga válido y el día que + // queramos meter datos por instancia ya está el slot listo. + let _ = idx_bytes; // (no se sube — ver comentario arriba) + + let uniforms = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("gpu_paint_demo-u"), + size: 32, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("gpu_paint_demo-bg"), + layout: &bind_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniforms.as_entire_binding(), + }], + }); + + Self { + pipeline, + instances, + uniforms, + bind_group, + } + } +} + +// Hash 32-bit barato (PCG-like) implementado en WGSL para mapear +// `instance_index + seed` → posición/color sin tocar buffers. Mantiene +// el demo en una sola draw call con cero CPU work por frame (salvo +// 32 bytes de uniforms). +const WGSL: &str = r#" +struct Uniforms { + rect: vec4, // x, y, w, h en pixels del frame + seed: u32, + _pad0: u32, + _pad1: u32, + _pad2: u32, +}; + +@group(0) @binding(0) var u: Uniforms; + +struct V2F { + @builtin(position) pos: vec4, + @location(0) color: vec4, +}; + +fn hash(x: u32) -> u32 { + var v = x ^ 2747636419u; + v = v * 2654435769u; + v = v ^ (v >> 16u); + v = v * 2654435769u; + v = v ^ (v >> 16u); + v = v * 2654435769u; + return v; +} + +// La resolución real del frame no la conoce el shader sin un uniform +// adicional. Como aproximación robusta, asumimos que el callback se +// llama sobre un viewport "default" 960×540 (tamaño inicial del demo) +// y dejamos que rect.x/y/w/h centren los puntos dentro del canvas. +// El tamaño real del frame se debería pasar por uniforms en una versión +// no-demo — Fase 2/3 del SDD lo formaliza vía `GpuBatch`. +const FRAME_W: f32 = 960.0; +const FRAME_H: f32 = 540.0; + +@vertex +fn vs(@builtin(vertex_index) vid: u32, @builtin(instance_index) iid: u32) -> V2F { + var corners = array, 6>( + vec2(-1.0, -1.0), + vec2( 1.0, -1.0), + vec2( 1.0, 1.0), + vec2(-1.0, -1.0), + vec2( 1.0, 1.0), + vec2(-1.0, 1.0), + ); + let off = corners[vid] * 1.5; // quad de 3 pixels lado + + let h1 = hash(iid ^ u.seed); + let h2 = hash(h1); + let h3 = hash(h2); + + let fx = f32(h1 & 0xFFFFu) / 65535.0; + let fy = f32(h2 & 0xFFFFu) / 65535.0; + + let px = u.rect.x + fx * u.rect.z + off.x; + let py = u.rect.y + fy * u.rect.w + off.y; + + let ndc = vec2( + px / FRAME_W * 2.0 - 1.0, + 1.0 - py / FRAME_H * 2.0, + ); + + let r = f32( h3 & 0xFFu) / 255.0; + let g = f32((h3 >> 8u) & 0xFFu) / 255.0; + let b = f32((h3 >> 16u) & 0xFFu) / 255.0; + + var out: V2F; + out.pos = vec4(ndc, 0.0, 1.0); + out.color = vec4(r, g, b, 0.85); + return out; +} + +@fragment +fn fs(in: V2F) -> @location(0) vec4 { + return in.color; +} +"#; diff --git a/llimphi-ui/src/eventloop.rs b/llimphi-ui/src/eventloop.rs new file mode 100644 index 0000000..a20abe2 --- /dev/null +++ b/llimphi-ui/src/eventloop.rs @@ -0,0 +1,1301 @@ +use super::*; + +pub(crate) fn build_window_attributes() -> WindowAttributes { + let (w, h) = A::initial_size(); + let attrs = WindowAttributes::default() + .with_title(A::title()) + .with_inner_size(LogicalSize::new(w, h)); + // En Linux, `with_name` del trait de Wayland mapea al `app_id` del + // xdg-toplevel — lo que el compositor (`mirada-compositor`) usa para + // reconocer ventanas especiales (greeter, launcher…). + #[cfg(all(target_os = "linux", not(target_os = "android")))] + { + if let Some(id) = A::app_id() { + use llimphi_hal::winit::platform::wayland::WindowAttributesExtWayland; + return attrs.with_name(id, ""); + } + } + attrs +} + +impl ApplicationHandler> for Runtime { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.state.is_some() { + return; + } + let window = event_loop + .create_window(build_window_attributes::()) + .expect("create window"); + let window = Arc::new(window); + // IME opt-in: sólo se habilita si la app lo pide (ver `App::ime_allowed`). + // Con IME activo el texto compuesto llega por `WindowEvent::Ime`. + if A::ime_allowed() { + window.set_ime_allowed(true); + } + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let surface = WinitSurface::new(&hal, window.clone()).expect("surface"); + let renderer = Renderer::new(&hal).expect("renderer"); + let overlay_compositor = llimphi_hal::OverlayCompositor::new(&hal.device); + let typesetter = llimphi_text::Typesetter::new(); + window.request_redraw(); + self.state = Some(RuntimeState { + window, + hal, + surface, + renderer, + scene: vello::Scene::new(), + overlay_compositor, + model: Some(A::init(&self.handle)), + cursor: PhysicalPosition::new(0.0, 0.0), + modifiers: Modifiers::default(), + typesetter, + layout: LayoutTree::new(), + overlay_layout: LayoutTree::new(), + last_render: None, + hovered: None, + drag: None, + focused: None, + last_title: None, + }); + // Sincroniza el factor de escala inicial (el de la ventana recién + // creada) ANTES del primer render: así una app que dependa del DPI + // (p. ej. `devicePixelRatio` en puriy) ya lo tiene correcto en su + // primera pasada, sin esperar a un ScaleFactorChanged. + if let Some(state) = self.state.as_mut() { + let scale = state.window.scale_factor(); + if let Some(msg) = A::on_scale_factor(state.model.as_ref().expect("model"), scale) { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + } + } + } + + fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { + match event { + UserEvent::Quit => event_loop.exit(), + UserEvent::Msg(msg) => { + // Un Msg del canal (Handle::dispatch, ticks periódicos, trabajo + // de fondo) muta el modelo compartido y repinta TODAS las + // ventanas — así un cambio se refleja tanto en la primaria como + // en las secundarias (config) sin importar de dónde vino. + self.dispatch_model(msg); + } + UserEvent::OpenWindow { key, title, width, height } => { + self.open_secondary(event_loop, key, title, width, height); + } + UserEvent::CloseWindow { key } => { + if let Some(pos) = self.secondaries.iter().position(|s| s.key == key) { + // Drop de la SecondaryState → se destruye la ventana/surface. + self.secondaries.remove(pos); + } + } + } + } + + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + _id: WindowId, + event: WindowEvent, + ) { + // ¿El evento es de una ventana secundaria? Lo atiende su handler + // dedicado (path aparte: la primaria queda 100% intacta). + if let Some(idx) = self.secondaries.iter().position(|s| s.window.id() == _id) { + self.handle_secondary_event(idx, event); + return; + } + let Some(state) = self.state.as_mut() else { + return; + }; + match event { + WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::Resized(size) => { + state.surface.resize(size.width, size.height); + // La app puede reaccionar al nuevo viewport (emitir un + // evento `resize`, recalcular layout, etc.). El update se + // corre tras reconfigurar la surface; el cache se invalida + // para repintar con el tamaño nuevo. + if let Some(msg) = + A::on_resize(state.model.as_ref().expect("model"), size.width, size.height) + { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + } + state.window.request_redraw(); + } + WindowEvent::ScaleFactorChanged { scale_factor, .. } => { + // El DPI de la ventana cambió (movida a otro monitor, escalado + // del sistema). winit envía un Resized aparte para el nuevo + // tamaño físico; aquí sólo propagamos el factor. + if let Some(msg) = + A::on_scale_factor(state.model.as_ref().expect("model"), scale_factor) + { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + } + state.window.request_redraw(); + } + WindowEvent::CursorMoved { position, .. } => { + let prev_cursor = state.cursor; + state.cursor = position; + // Drag activo: dispatchear delta al handler + actualizar + // tracking del drop target hovereado (solo si hay payload). + if let Some(drag) = state.drag.as_mut() { + let dx = (position.x - drag.last_cursor.x) as f32; + let dy = (position.y - drag.last_cursor.y) as f32; + drag.last_cursor = position; + let payload_active = drag.payload.is_some(); + let mut need_redraw = false; + if dx != 0.0 || dy != 0.0 { + let msg_opt = match &drag.handler { + DragHandlerKind::Delta(h) => h(DragPhase::Move, dx, dy), + DragHandlerKind::DeltaAt(h, lx0, ly0) => { + h(DragPhase::Move, dx, dy, *lx0, *ly0) + } + }; + if let Some(msg) = msg_opt { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + // Durante drag NO invalidamos el cache — + // queda válido para el próximo Move. + need_redraw = true; + } + } + if payload_active { + if let Some(cache) = state.last_render.as_mut() { + let new_drop = hit_test_drop( + &cache.mounted, + &cache.computed, + position.x as f32, + position.y as f32, + ); + if new_drop != cache.drop_hover_idx { + cache.drop_hover_idx = new_drop; + need_redraw = true; + } + } + } + if need_redraw { + state.window.request_redraw(); + } + } else { + // Sin drag: chequear hover. Si hay overlay, el + // hover-test va contra él; el árbol principal queda + // congelado mientras el overlay esté arriba. + // + // Además del repintado (para el `hover_fill`), si el + // nodo recién hovereado declara un `on_pointer_enter`, + // lo dispatcheamos: es lo que permite, p.ej., cambiar + // de menú con el mouse o abrir un submenú al pasar por + // encima. Extraemos el Msg en un scope para soltar el + // borrow del cache antes de mutar el modelo. + let mut enter_msg: Option = None; + let mut hovered_changed = false; + let mut new_hovered: Option = state.hovered; + if let Some(cache) = state.last_render.as_ref() { + let (mounted, computed) = match cache.overlay.as_ref() { + Some(ov) => (&ov.mounted, &ov.computed), + None => (&cache.mounted, &cache.computed), + }; + let new_hover = hit_test_hover( + mounted, + computed, + position.x as f32, + position.y as f32, + ); + // Comparamos contra el hover PERSISTENTE (state.hovered), + // no contra el del cache: el render recomputa el del cache + // al cursor actual cada cuadro, así que en una app que + // re-renderiza sin parar la transición de hover se perdería + // (y el hover-switch de menús no andaría). Ver `hovered`. + if new_hover != state.hovered { + hovered_changed = true; + enter_msg = new_hover + .and_then(|i| mounted.nodes.get(i)) + .and_then(|n| n.on_pointer_enter.clone()); + } + new_hovered = new_hover; + } + state.hovered = new_hovered; + if hovered_changed { + state.window.request_redraw(); + } + if let Some(msg) = enter_msg { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + // El estado cambió → invalidamos el cache para + // re-render (p.ej. el submenú que se abre). + state.last_render = None; + } + let _ = prev_cursor; + } + } + WindowEvent::ModifiersChanged(mods) => { + state.modifiers = mods.state().into(); + } + WindowEvent::Ime(ime) if A::ime_allowed() => { + use llimphi_hal::winit::event::Ime; + let ev = match ime { + Ime::Enabled => ImeEvent::Enabled, + Ime::Preedit(text, cursor) => ImeEvent::Preedit { text, cursor }, + Ime::Commit(text) => ImeEvent::Commit(text), + Ime::Disabled => ImeEvent::Disabled, + }; + if let Some(msg) = A::on_ime(state.model.as_ref().expect("model"), &ev) { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); + } + } + WindowEvent::KeyboardInput { event, .. } => { + // Tab / Shift+Tab mueven el foco entre nodos `focusable`, + // que administra el runtime. Sólo intercepta si hay + // enfocables y en Pressed; si no, cae al `on_key` normal + // (apps que usan Tab para otra cosa lo siguen recibiendo). + let is_tab = event.state == ElementState::Pressed + && matches!(event.logical_key, Key::Named(NamedKey::Tab)); + if is_tab { + let order = state + .last_render + .as_ref() + .map(|c| focus_order(&c.mounted, &c.computed)) + .unwrap_or_default(); + if !order.is_empty() { + let next = next_focus(&order, state.focused, state.modifiers.shift); + state.focused = next; + if let Some(msg) = + A::on_focus(state.model.as_ref().expect("model"), next) + { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + } + state.last_render = None; + state.window.request_redraw(); + return; + } + } + let ev = KeyEvent { + key: event.logical_key.clone(), + state: match event.state { + ElementState::Pressed => KeyState::Pressed, + ElementState::Released => KeyState::Released, + }, + text: event.text.as_ref().map(|t| t.to_string()), + modifiers: state.modifiers, + repeat: event.repeat, + }; + if let Some(msg) = A::on_key(state.model.as_ref().expect("model"), &ev) { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); + } + } + WindowEvent::DroppedFile(path) => { + // Un evento por archivo (winit los entrega serializados); si + // el usuario suelta varios, el bucle re-entra y aplicamos + // updates en orden. + if let Some(msg) = A::on_file_drop(state.model.as_ref().expect("model"), path) { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); + } + } + WindowEvent::MouseWheel { delta, .. } => { + // Convención winit: LineDelta es líneas; PixelDelta es + // píxeles físicos (touchpads). En CSS y aquí, positivo + // (rueda hacia adelante / dos dedos arriba) = scroll + // hacia arriba, así que invertimos `y` para que el + // contenido "siga al dedo" en y positivo. `x` queda + // como llega. + let wd = match delta { + MouseScrollDelta::LineDelta(x, y) => WheelDelta { x, y: -y }, + MouseScrollDelta::PixelDelta(p) => WheelDelta { + x: (p.x as f32) / 20.0, + y: -(p.y as f32) / 20.0, + }, + }; + let cursor = (state.cursor.x as f32, state.cursor.y as f32); + // Primero: ¿hay un nodo con `on_scroll` bajo el cursor? Si + // consume el evento (`Some`), no cae al `on_wheel` global. + // El overlay tiene prioridad, igual que con clicks. Se + // extrae el handler en un scope para soltar el borrow del + // cache antes de mutar el modelo. + let scroll_handler: Option> = + if let Some(cache) = state.last_render.as_ref() { + let (m, c) = match cache.overlay.as_ref() { + Some(ov) => (&ov.mounted, &ov.computed), + None => (&cache.mounted, &cache.computed), + }; + hit_test_scroll(m, c, cursor.0, cursor.1) + .and_then(|i| m.nodes[i].on_scroll.clone()) + } else { + None + }; + let msg = match scroll_handler { + Some(h) => h(wd.x, wd.y), + None => A::on_wheel( + state.model.as_ref().expect("model"), + wd, + cursor, + state.modifiers, + ), + }; + if let Some(msg) = msg { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); + } + } + WindowEvent::MouseInput { + state: ElementState::Pressed, + button: MouseButton::Left, + .. + } => { + // Hit-test contra el cache del último redraw (siempre + // representa lo visible). Fallback raro: cache vacío. + let cursor = state.cursor; + // Click-to-focus: si el click cae sobre un nodo enfocable, + // el runtime le da el foco ANTES de procesar la acción de + // click. Extraemos el id en un scope (suelta el borrow del + // cache) y recién después mutamos el foco/modelo. + let focus_hit = state + .last_render + .as_ref() + .and_then(|cache| { + let (m, c) = match cache.overlay.as_ref() { + Some(ov) => (&ov.mounted, &ov.computed), + None => (&cache.mounted, &cache.computed), + }; + hit_test_focusable(m, c, cursor.x as f32, cursor.y as f32) + }); + if focus_hit.is_some() && focus_hit != state.focused { + state.focused = focus_hit; + if let Some(msg) = + A::on_focus(state.model.as_ref().expect("model"), focus_hit) + { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + } + state.last_render = None; + } + // Tupla: (drag_fn, drag_at_fn, payload, on_click_msg, + // on_click_at_handler, rect: (x, y, w, h)) + type HitInfo = ( + Option>, + Option>, + Option, + Option, + Option>, + Option<(f32, f32, f32, f32)>, + ); + let lookup_hit = |m: &Mounted, c: &ComputedLayout| -> Option> { + hit_test_click(m, c, cursor.x as f32, cursor.y as f32).map(|i| { + let node = &m.nodes[i]; + let rect = c.get(node.id).map(|r| (r.x, r.y, r.w, r.h)); + ( + node.drag.clone(), + node.drag_at.clone(), + node.drag_payload, + node.on_click.clone(), + node.on_click_at.clone(), + rect, + ) + }) + }; + // Con overlay activo, los clicks van EXCLUSIVAMENTE a él. + // Si el cursor cae sobre un nodo del overlay sin handler, + // el click se descarta — la convención de "scrim que + // dismissa" pide que la app meta su propio fondo + // clicable con `on_click = DismissOverlay`. + let idx_and_action: Option> = if let Some(cache) = + state.last_render.as_ref() + { + if let Some(ov) = cache.overlay.as_ref() { + lookup_hit(&ov.mounted, &ov.computed) + } else { + lookup_hit(&cache.mounted, &cache.computed) + } + } else { + let model_ref = state.model.as_ref().expect("model"); + let view = A::view(model_ref); + let overlay_view = A::view_overlay(model_ref); + let mut layout = LayoutTree::new(); + let mounted: Mounted = mount(&mut layout, view); + let (w, h) = state.surface.size(); + let ts = &mut state.typesetter; + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(ts, tm, known, avail), + None => llimphi_layout::taffy::Size::ZERO, + } + }) + .expect("layout") + }; + if let Some(ov) = overlay_view { + let mut olay = LayoutTree::new(); + let omounted: Mounted = mount(&mut olay, ov); + let ocomp = { + let tmap = &omounted.text_measures; + olay + .compute_with_measure(omounted.root, (w as f32, h as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(ts, tm, known, avail), + None => llimphi_layout::taffy::Size::ZERO, + } + }) + .expect("layout overlay") + }; + lookup_hit(&omounted, &ocomp) + } else { + lookup_hit(&mounted, &computed) + } + }; + // drag_at + on_click_at COEXISTEN: el press dispara + // on_click_at (si está) y arranca un drag rastreado con la + // posición inicial. Diseño pensado para canvas elements + // que necesitan select-on-press + move-on-drag. + // + // En cambio, `drag` simple (sin _at) mantiene la semántica + // antigua: gana exclusivo sobre on_click. + if let Some((_, Some(handler_at), payload, _, click_at, Some((ox, oy, rw, rh)))) = + &idx_and_action + { + let lx0 = cursor.x as f32 - ox; + let ly0 = cursor.y as f32 - oy; + // Disparar on_click_at en el press (si también está). + if let Some(click_at_h) = click_at { + if let Some(msg) = click_at_h(lx0, ly0, *rw, *rh) { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + } + } + state.drag = Some(DragState { + handler: DragHandlerKind::DeltaAt(handler_at.clone(), lx0, ly0), + last_cursor: cursor, + payload: *payload, + }); + state.window.request_redraw(); + } else if let Some((Some(handler), _, payload, _, _, _)) = &idx_and_action { + state.drag = Some(DragState { + handler: DragHandlerKind::Delta(handler.clone()), + last_cursor: cursor, + payload: *payload, + }); + // Si hay payload, repintar para que el drop target + // bajo cursor (si lo hay) se ilumine de entrada. + if payload.is_some() { + if let Some(cache) = state.last_render.as_mut() { + let new_drop = hit_test_drop( + &cache.mounted, + &cache.computed, + cursor.x as f32, + cursor.y as f32, + ); + if new_drop != cache.drop_hover_idx { + cache.drop_hover_idx = new_drop; + state.window.request_redraw(); + } + } + } + } else if let Some((_, _, _, _, Some(handler), Some((ox, oy, rw, rh)))) = + &idx_and_action + { + // on_click_at gana sobre on_click si ambos existen. + let lx = cursor.x as f32 - ox; + let ly = cursor.y as f32 - oy; + if let Some(msg) = handler(lx, ly, *rw, *rh) { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); + } + } else if let Some((_, _, _, Some(msg), _, _)) = idx_and_action { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); + } + } + WindowEvent::MouseInput { + state: ElementState::Pressed, + button: MouseButton::Middle, + .. + } => { + // Middle-click: dispatcha `on_middle_click` del nodo + // bajo cursor si lo declaró. La capa overlay tiene + // prioridad (mismo razonamiento que el left/right click). + let cursor = state.cursor; + let lookup = + |m: &Mounted, c: &ComputedLayout| -> Option { + hit_test_middle_click(m, c, cursor.x as f32, cursor.y as f32) + .and_then(|i| m.nodes[i].on_middle_click.clone()) + }; + let msg = if let Some(cache) = state.last_render.as_ref() { + if let Some(ov) = cache.overlay.as_ref() { + lookup(&ov.mounted, &ov.computed) + } else { + lookup(&cache.mounted, &cache.computed) + } + } else { + None + }; + if let Some(msg) = msg { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); + } + } + WindowEvent::MouseInput { + state: ElementState::Pressed, + button: MouseButton::Right, + .. + } => { + // Right-click: dispatcheamos `on_right_click` o + // `on_right_click_at` del nodo bajo cursor. La capa + // overlay tiene prioridad (mismo razonamiento que el + // left-click). Nodos sin handler de right-click no + // reaccionan — no "filtramos" al left. + let cursor = state.cursor; + let lookup = + |m: &Mounted, c: &ComputedLayout| -> Option<(Option, Option>, (f32, f32, f32, f32))> { + hit_test_right_click(m, c, cursor.x as f32, cursor.y as f32).map(|i| { + let node = &m.nodes[i]; + let rect = c + .get(node.id) + .map(|r| (r.x, r.y, r.w, r.h)) + .unwrap_or((0.0, 0.0, 0.0, 0.0)); + ( + node.on_right_click.clone(), + node.on_right_click_at.clone(), + rect, + ) + }) + }; + let hit = if let Some(cache) = state.last_render.as_ref() { + if let Some(ov) = cache.overlay.as_ref() { + lookup(&ov.mounted, &ov.computed) + } else { + lookup(&cache.mounted, &cache.computed) + } + } else { + None + }; + if let Some((msg_opt, at_opt, (ox, oy, rw, rh))) = hit { + let msg = if let Some(handler) = at_opt { + handler( + cursor.x as f32 - ox, + cursor.y as f32 - oy, + rw, + rh, + ) + } else { + msg_opt + }; + if let Some(msg) = msg { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + state.last_render = None; + state.window.request_redraw(); + } + } + } + WindowEvent::MouseInput { + state: ElementState::Released, + button: MouseButton::Left, + .. + } => { + if let Some(drag) = state.drag.take() { + let cursor = state.cursor; + // 1. Drop: si hay payload + drop target bajo cursor, + // invocamos su handler. El Msg resultante se aplica + // ANTES del End del drag — la convención es "drop + // primero, cleanup del drag después". + if let Some(payload) = drag.payload { + if let Some(cache) = state.last_render.as_ref() { + if let Some(idx) = hit_test_drop( + &cache.mounted, + &cache.computed, + cursor.x as f32, + cursor.y as f32, + ) { + if let Some(drop_h) = + cache.mounted.nodes[idx].on_drop.clone() + { + if let Some(msg) = (drop_h)(payload) { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + } + } + } + } + } + // 2. Cierre del drag. + let end_msg = match &drag.handler { + DragHandlerKind::Delta(h) => h(DragPhase::End, 0.0, 0.0), + DragHandlerKind::DeltaAt(h, lx0, ly0) => { + h(DragPhase::End, 0.0, 0.0, *lx0, *ly0) + } + }; + if let Some(msg) = end_msg { + let model = state.model.take().expect("model"); + state.model = Some(A::update(model, msg, &self.handle)); + } + // Cache invalidado siempre — hover/drop pueden cambiar + // y el modelo posiblemente mutó. + state.last_render = None; + state.window.request_redraw(); + } + } + WindowEvent::RedrawRequested => { + // Título dinámico (App::window_title): si cambió respecto del + // último aplicado, se lo pasamos a winit. Barato: una + // comparación de String por frame, set_title sólo en el cambio. + if let Some(t) = A::window_title(state.model.as_ref().expect("model")) { + if state.last_title.as_deref() != Some(t.as_str()) { + state.window.set_title(&t); + state.last_title = Some(t); + } + } + // Posicioná la ventana de candidatos del IME junto al caret + // (sólo con IME activo y si la app reporta el área). + if A::ime_allowed() { + if let Some((x, y, w, h)) = + A::ime_cursor_area(state.model.as_ref().expect("model")) + { + state.window.set_ime_cursor_area( + PhysicalPosition::new(x as f64, y as f64), + llimphi_hal::winit::dpi::PhysicalSize::new( + w.max(1.0) as u32, + h.max(1.0) as u32, + ), + ); + } + } + let frame = match state.surface.acquire() { + Ok(f) => f, + Err(_) => { + let (w, h) = state.surface.size(); + state.surface.resize(w, h); + state.window.request_redraw(); + return; + } + }; + let (w, h) = frame.size(); + let model_ref = state.model.as_ref().expect("model"); + let view = A::view(model_ref); + let overlay_view = A::view_overlay(model_ref); + // Reusamos los árboles de layout del runtime: `clear()` + + // `mount` evita re-allocar el slotmap de taffy por frame. + state.layout.clear(); + let mounted: Mounted = mount(&mut state.layout, view); + let computed = { + let ts = &mut state.typesetter; + let tmap = &mounted.text_measures; + state + .layout + .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(ts, tm, known, avail), + None => llimphi_layout::taffy::Size::ZERO, + } + }) + .expect("layout") + }; + // Mount + layout del overlay en un árbol aparte. Lo + // computamos con el mismo tamaño de viewport para que + // un scrim a percent(1.0) cubra toda la pantalla. + let overlay_built = if let Some(v) = overlay_view { + state.overlay_layout.clear(); + let omounted: Mounted = mount(&mut state.overlay_layout, v); + let ocomputed = { + let ts = &mut state.typesetter; + let tmap = &omounted.text_measures; + state + .overlay_layout + .compute_with_measure(omounted.root, (w as f32, h as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(ts, tm, known, avail), + None => llimphi_layout::taffy::Size::ZERO, + } + }) + .expect("layout overlay") + }; + let ohover = hit_test_hover( + &omounted, + &ocomputed, + state.cursor.x as f32, + state.cursor.y as f32, + ); + Some(OverlayCache { + mounted: omounted, + computed: ocomputed, + hover_idx: ohover, + }) + } else { + None + }; + // Hover en el main solo si NO hay overlay — durante un + // menú abierto, el fondo no debe reaccionar al ratón. + let hover_idx = if overlay_built.is_some() { + None + } else { + hit_test_hover( + &mounted, + &computed, + state.cursor.x as f32, + state.cursor.y as f32, + ) + }; + // Drop hover sólo si hay drag activo con payload (un + // drag bloquea el overlay; rara combinación pero la + // resolvemos a favor del drag). + let drop_hover_idx = state + .drag + .as_ref() + .and_then(|d| d.payload.map(|_| ())) + .and_then(|_| { + hit_test_drop( + &mounted, + &computed, + state.cursor.x as f32, + state.cursor.y as f32, + ) + }); + // Z-order del overlay sobre contenido `gpu_paint`: si el + // árbol principal tiene painters gpu (p. ej. el video de + // media) Y hay un overlay activo, el overlay NO va en la + // escena principal (quedaría debajo del blit gpu). Se + // rasteriza aparte sobre fondo transparente y se compone con + // alpha DESPUÉS del pase gpu. Sin gpu o sin overlay, el camino + // de siempre (overlay en la escena principal) — coste cero. + let composite_overlay = + overlay_built.is_some() && has_gpu_painter(&mounted); + + state.scene.reset(); + paint( + &mut state.scene, + &mounted, + &computed, + &mut state.typesetter, + hover_idx, + drop_hover_idx, + ); + if !composite_overlay { + if let Some(ov) = overlay_built.as_ref() { + paint( + &mut state.scene, + &ov.mounted, + &ov.computed, + &mut state.typesetter, + ov.hover_idx, + None, + ); + } + } + if let Err(e) = state.renderer.render( + &state.hal, + &state.scene, + &frame, + palette::css::BLACK, + ) { + eprintln!("render error: {e}"); + } + let (vw, vh) = frame.size(); + // Capa de overlay aparte (camino composite): vello la + // rasteriza con fondo transparente en `frame.overlay_view()`. + // Se renderiza ANTES del pase gpu para que el blit del + // compositor (en `gpu_encoder`) la lea ya escrita. + if composite_overlay { + if let Some(ov) = overlay_built.as_ref() { + state.scene.reset(); + paint( + &mut state.scene, + &ov.mounted, + &ov.computed, + &mut state.typesetter, + ov.hover_idx, + None, + ); + if let Err(e) = state.renderer.render_to_view( + &state.hal, + &state.scene, + frame.overlay_view(), + vw, + vh, + palette::css::TRANSPARENT, + ) { + eprintln!("render overlay error: {e}"); + } + } + } + // Pasada GPU directo (Fase 1 del SDD §"GPU directo wgpu"): + // si algún View del main o del overlay registró un + // `gpu_painter`, ejecutamos todos sus callbacks contra un + // único `CommandEncoder`, encima de lo que vello acaba de + // pintar sobre la intermediate. Submitimos antes del + // present para que el blit al swapchain incluya las + // primitivas GPU. Si nadie usó el hook, no se crea ni + // submitea nada — coste cero. + let mut gpu_encoder = state.hal.device.create_command_encoder( + &llimphi_hal::wgpu::CommandEncoderDescriptor { + label: Some("llimphi-ui-gpu-paint"), + }, + ); + let viewport = frame.size(); + let mut any_gpu = paint_gpu( + &mounted, + &computed, + &state.hal.device, + &state.hal.queue, + &mut gpu_encoder, + frame.view(), + viewport, + ); + if let Some(ov) = overlay_built.as_ref() { + // En el camino composite, los painters gpu del overlay van + // sobre SU textura; si no, sobre la intermedia. + let target = if composite_overlay { + frame.overlay_view() + } else { + frame.view() + }; + any_gpu |= paint_gpu( + &ov.mounted, + &ov.computed, + &state.hal.device, + &state.hal.queue, + &mut gpu_encoder, + target, + viewport, + ); + } + // Composición alpha del overlay SOBRE la intermedia (que ya + // tiene UI + video). Último pase del encoder → corre después + // del blit del video. Garantiza menús por encima del video. + if composite_overlay { + state.overlay_compositor.composite( + &state.hal.device, + &mut gpu_encoder, + frame.view(), + frame.overlay_view(), + ); + any_gpu = true; + } + if any_gpu { + state + .hal + .queue + .submit(std::iter::once(gpu_encoder.finish())); + } + state.surface.present(frame, &state.hal); + state.last_render = Some(RenderCache { + mounted, + computed, + hover_idx, + drop_hover_idx, + overlay: overlay_built, + }); + } + _ => {} + } + } +} + +// ── Ventanas secundarias (multiventana, opt-in) ────────────────────────────── +// Path APARTE del de la primaria: comparten modelo (vive en `self.state`) y +// `Hal`/`Renderer`, pero cada secundaria lleva su surface + caches. Sin +// overlay ni foco (la config no los necesita); se puede ampliar luego. +impl Runtime { + /// Aplica un Msg al modelo (que vive en la primaria) e invalida + repinta + /// TODAS las ventanas. Es el camino de cualquier evento de una secundaria, + /// así un cambio hecho en la config se refleja al toque en el reproductor + /// (y viceversa, vía los ticks que pasan por `user_event`). + fn dispatch_model(&mut self, msg: A::Msg) { + if let Some(prim) = self.state.as_mut() { + let model = prim.model.take().expect("model"); + prim.model = Some(A::update(model, msg, &self.handle)); + prim.last_render = None; + prim.window.request_redraw(); + } + // OJO: NO repintamos las secundarias acá. `dispatch_model` corre en + // cada Msg (incluido el tick ~33 fps), y repintar una secundaria por + // tick serializaba dos `acquire()` de swapchain en Wayland FIFO → + // ralentización y cuelgue. Cada secundaria se repinta sola al + // interactuar con ella (`handle_secondary_event` llama + // `render_secondary` tras un cambio) y en su `RedrawRequested` del + // compositor (expose/resize). El modelo igual quedó actualizado, así + // que el próximo repintado de la secundaria refleja el cambio. + } + + /// Despacha un Msg y repinta la secundaria `idx` en el acto (si sigue + /// viva). El camino de los eventos de una secundaria: como su + /// `request_redraw` no dispara `RedrawRequested` en algunos compositores, + /// la pintamos directo tras el cambio. + fn dispatch_and_render_secondary(&mut self, idx: usize, msg: A::Msg) { + self.dispatch_model(msg); + if idx < self.secondaries.len() { + self.render_secondary(idx); + } + } + + /// Crea una ventana OS secundaria (o enfoca la existente con esa key). Toma + /// el `Hal` de la primaria — no levanta un segundo device GPU. + fn open_secondary( + &mut self, + event_loop: &ActiveEventLoop, + key: u64, + title: String, + width: u32, + height: u32, + ) { + if let Some(sec) = self.secondaries.iter().find(|s| s.key == key) { + sec.window.focus_window(); + return; + } + let Some(prim) = self.state.as_ref() else { + return; // no hay primaria todavía (no debería pasar) + }; + let attrs = WindowAttributes::default() + .with_title(title) + .with_inner_size(LogicalSize::new(width, height)); + let window = match event_loop.create_window(attrs) { + Ok(w) => Arc::new(w), + Err(e) => { + eprintln!("open_window: no pude crear la ventana: {e}"); + return; + } + }; + let surface = match WinitSurface::new(&prim.hal, window.clone()) { + Ok(s) => s, + Err(e) => { + eprintln!("open_window: no pude crear la surface: {e}"); + return; + } + }; + window.request_redraw(); + self.secondaries.push(SecondaryState { + key, + window, + surface, + scene: vello::Scene::new(), + typesetter: llimphi_text::Typesetter::new(), + layout: LayoutTree::new(), + cursor: PhysicalPosition::new(0.0, 0.0), + modifiers: Modifiers::default(), + last_render: None, + hovered: None, + drag: None, + last_title: None, + }); + } + + /// Pinta la ventana secundaria `idx` con `A::secondary_view`. Reusa el + /// `Hal`/`Renderer` de la primaria; camino simple (sin overlay ni + /// composite gpu de menús), pero soporta `gpu_paint` por si el contenido + /// lo usa. + fn render_secondary(&mut self, idx: usize) { + let key = self.secondaries[idx].key; + let Some(prim) = self.state.as_mut() else { + return; + }; + // Título dinámico de la secundaria. + if let Some(t) = A::secondary_title(prim.model.as_ref().expect("model"), key) { + let sec = &mut self.secondaries[idx]; + if sec.last_title.as_deref() != Some(t.as_str()) { + sec.window.set_title(&t); + sec.last_title = Some(t); + } + } + let view = A::secondary_view(prim.model.as_ref().expect("model"), key) + .unwrap_or_else(|| View::new(Default::default())); + let hal = &prim.hal; + let renderer = &mut prim.renderer; + let sec = &mut self.secondaries[idx]; + + let frame = match sec.surface.acquire() { + Ok(f) => f, + Err(_) => { + let (w, h) = sec.surface.size(); + sec.surface.resize(w, h); + sec.window.request_redraw(); + return; + } + }; + let (w, h) = frame.size(); + sec.layout.clear(); + let mounted: Mounted = mount(&mut sec.layout, view); + let computed = { + let ts = &mut sec.typesetter; + let tmap = &mounted.text_measures; + sec.layout + .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(ts, tm, known, avail), + None => llimphi_layout::taffy::Size::ZERO, + } + }) + .expect("layout secundario") + }; + let hover_idx = hit_test_hover(&mounted, &computed, sec.cursor.x as f32, sec.cursor.y as f32); + let drop_hover_idx = sec + .drag + .as_ref() + .and_then(|d| d.payload) + .and_then(|_| hit_test_drop(&mounted, &computed, sec.cursor.x as f32, sec.cursor.y as f32)); + sec.scene.reset(); + paint( + &mut sec.scene, + &mounted, + &computed, + &mut sec.typesetter, + hover_idx, + drop_hover_idx, + ); + if let Err(e) = renderer.render(hal, &sec.scene, &frame, palette::css::BLACK) { + eprintln!("render secundario error: {e}"); + } + // gpu_paint del contenido de la secundaria (si lo hubiera). + let mut enc = hal + .device + .create_command_encoder(&llimphi_hal::wgpu::CommandEncoderDescriptor { + label: Some("llimphi-ui-sec-gpu"), + }); + let viewport = frame.size(); + let any = paint_gpu( + &mounted, + &computed, + &hal.device, + &hal.queue, + &mut enc, + frame.view(), + viewport, + ); + if any { + hal.queue.submit(std::iter::once(enc.finish())); + } + sec.surface.present(frame, hal); + let _ = (hover_idx, drop_hover_idx); // se usaron al pintar; no se cachean + sec.last_render = Some(SecRenderCache { mounted, computed }); + } + + /// Atiende un evento de la ventana secundaria `idx`. Subconjunto de lo que + /// hace la primaria (sin overlay/foco/IME): render, resize, cierre, hover, + /// click, drag, teclado y rueda — suficiente para un panel de config. + fn handle_secondary_event(&mut self, idx: usize, event: WindowEvent) { + match event { + WindowEvent::CloseRequested => { + let key = self.secondaries[idx].key; + let msg = self + .state + .as_ref() + .and_then(|p| A::on_secondary_close(p.model.as_ref().expect("model"), key)); + self.secondaries.remove(idx); + if let Some(msg) = msg { + self.dispatch_model(msg); + } + } + WindowEvent::Resized(size) => { + self.secondaries[idx].surface.resize(size.width, size.height); + self.render_secondary(idx); + } + WindowEvent::ScaleFactorChanged { .. } => { + self.render_secondary(idx); + } + WindowEvent::RedrawRequested => { + self.render_secondary(idx); + } + WindowEvent::ModifiersChanged(mods) => { + self.secondaries[idx].modifiers = mods.state().into(); + } + WindowEvent::CursorMoved { position, .. } => { + let mut drag_msg: Option = None; + let mut redraw = false; + { + let sec = &mut self.secondaries[idx]; + sec.cursor = position; + if let Some(drag) = sec.drag.as_mut() { + let dx = (position.x - drag.last_cursor.x) as f32; + let dy = (position.y - drag.last_cursor.y) as f32; + drag.last_cursor = position; + if dx != 0.0 || dy != 0.0 { + drag_msg = match &drag.handler { + DragHandlerKind::Delta(h) => h(DragPhase::Move, dx, dy), + DragHandlerKind::DeltaAt(h, lx0, ly0) => { + h(DragPhase::Move, dx, dy, *lx0, *ly0) + } + }; + } + redraw = true; + } else { + let new_hover = sec.last_render.as_ref().and_then(|c| { + hit_test_hover(&c.mounted, &c.computed, position.x as f32, position.y as f32) + }); + if new_hover != sec.hovered { + sec.hovered = new_hover; + redraw = true; + } + } + } + if let Some(msg) = drag_msg { + self.dispatch_and_render_secondary(idx, msg); + } else if redraw { + self.render_secondary(idx); + } + } + WindowEvent::MouseInput { + state: ElementState::Pressed, + button: MouseButton::Left, + .. + } => { + type SecHit = ( + Option>, + Option>, + Option, + Option, + Option>, + Option<(f32, f32, f32, f32)>, + ); + let cursor = self.secondaries[idx].cursor; + let hit: Option> = { + let sec = &self.secondaries[idx]; + sec.last_render.as_ref().and_then(|c| { + hit_test_click(&c.mounted, &c.computed, cursor.x as f32, cursor.y as f32).map( + |i| { + let node = &c.mounted.nodes[i]; + let rect = c.computed.get(node.id).map(|r| (r.x, r.y, r.w, r.h)); + ( + node.drag.clone(), + node.drag_at.clone(), + node.drag_payload, + node.on_click.clone(), + node.on_click_at.clone(), + rect, + ) + }, + ) + }) + }; + // Misma prioridad que la primaria: drag_at + on_click_at, luego + // drag simple, luego on_click_at, luego on_click. + match hit { + Some((_, Some(handler_at), payload, _, click_at, Some((ox, oy, rw, rh)))) => { + let lx0 = cursor.x as f32 - ox; + let ly0 = cursor.y as f32 - oy; + if let Some(h) = click_at { + if let Some(msg) = h(lx0, ly0, rw, rh) { + self.dispatch_model(msg); + } + } + self.secondaries[idx].drag = Some(DragState { + handler: DragHandlerKind::DeltaAt(handler_at, lx0, ly0), + last_cursor: cursor, + payload, + }); + self.render_secondary(idx); + } + Some((Some(handler), _, payload, _, _, _)) => { + self.secondaries[idx].drag = Some(DragState { + handler: DragHandlerKind::Delta(handler), + last_cursor: cursor, + payload, + }); + self.render_secondary(idx); + } + Some((_, _, _, _, Some(handler), Some((ox, oy, rw, rh)))) => { + let lx = cursor.x as f32 - ox; + let ly = cursor.y as f32 - oy; + if let Some(msg) = handler(lx, ly, rw, rh) { + self.dispatch_and_render_secondary(idx, msg); + } + } + Some((_, _, _, Some(msg), _, _)) => { + self.dispatch_and_render_secondary(idx, msg); + } + _ => {} + } + } + WindowEvent::MouseInput { + state: ElementState::Released, + button: MouseButton::Left, + .. + } => { + let cursor = self.secondaries[idx].cursor; + let drag = self.secondaries[idx].drag.take(); + if let Some(drag) = drag { + // Drop primero (si hay payload + target), luego End. + if let Some(payload) = drag.payload { + let drop_h = self.secondaries[idx].last_render.as_ref().and_then(|c| { + hit_test_drop(&c.mounted, &c.computed, cursor.x as f32, cursor.y as f32) + .and_then(|i| c.mounted.nodes[i].on_drop.clone()) + }); + if let Some(h) = drop_h { + if let Some(msg) = h(payload) { + self.dispatch_model(msg); + } + } + } + let end_msg = match &drag.handler { + DragHandlerKind::Delta(h) => h(DragPhase::End, 0.0, 0.0), + DragHandlerKind::DeltaAt(h, lx0, ly0) => h(DragPhase::End, 0.0, 0.0, *lx0, *ly0), + }; + if let Some(msg) = end_msg { + self.dispatch_model(msg); + } + self.render_secondary(idx); + } + } + WindowEvent::MouseWheel { delta, .. } => { + let wd = match delta { + MouseScrollDelta::LineDelta(x, y) => WheelDelta { x, y: -y }, + MouseScrollDelta::PixelDelta(p) => WheelDelta { + x: (p.x as f32) / 20.0, + y: -(p.y as f32) / 20.0, + }, + }; + let cursor = self.secondaries[idx].cursor; + let handler = { + let sec = &self.secondaries[idx]; + sec.last_render.as_ref().and_then(|c| { + hit_test_scroll(&c.mounted, &c.computed, cursor.x as f32, cursor.y as f32) + .and_then(|i| c.mounted.nodes[i].on_scroll.clone()) + }) + }; + if let Some(msg) = handler.and_then(|h| h(wd.x, wd.y)) { + self.dispatch_and_render_secondary(idx, msg); + } + } + WindowEvent::KeyboardInput { event, .. } => { + let ev = KeyEvent { + key: event.logical_key.clone(), + state: match event.state { + ElementState::Pressed => KeyState::Pressed, + ElementState::Released => KeyState::Released, + }, + text: event.text.as_ref().map(|t| t.to_string()), + modifiers: self.secondaries[idx].modifiers, + repeat: event.repeat, + }; + let msg = self + .state + .as_ref() + .and_then(|p| A::on_key(p.model.as_ref().expect("model"), &ev)); + if let Some(msg) = msg { + self.dispatch_and_render_secondary(idx, msg); + } + } + _ => {} + } + } +} diff --git a/llimphi-ui/src/lib.rs b/llimphi-ui/src/lib.rs new file mode 100644 index 0000000..304dbf7 --- /dev/null +++ b/llimphi-ui/src/lib.rs @@ -0,0 +1,604 @@ +//! llimphi-ui — Runtime Elm sobre winit. +//! +//! Maneja el bucle `input → update(model, msg) → view(model) → layout → +//! raster → present` sobre una ventana winit + GPU (`llimphi-hal` + +//! `llimphi-raster`). La parte declarativa y winit-agnóstica (el árbol +//! `View`, `mount`, `paint`, hit-test) vive en `llimphi-compositor` y +//! se re-exporta tal cual, así los consumidores siguen escribiendo +//! `llimphi_ui::View` sin enterarse del split. +//! +//! El estado del [`App`] es inmutable: cada evento produce un `Model` +//! nuevo. La vista (`view`) es una función pura `&Model -> View`. + +use std::sync::Arc; + +use llimphi_hal::winit::application::ApplicationHandler; +use llimphi_hal::winit::dpi::{LogicalSize, PhysicalPosition}; +use llimphi_hal::winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent}; +use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy}; +use llimphi_hal::winit::keyboard::ModifiersState; +use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId}; +use llimphi_hal::{Hal, Surface, WinitSurface}; + +pub use llimphi_hal::winit::keyboard::{Key, NamedKey}; +use llimphi_layout::{ComputedLayout, LayoutTree}; +use llimphi_raster::peniko::color::palette; +use llimphi_raster::{vello, Renderer}; + +pub use llimphi_hal; +pub use llimphi_layout; +pub use llimphi_raster; +pub use llimphi_text; + +// El compositor declarativo (View, mount, paint, hit-test, tipos de +// handler) se re-exporta entero: `llimphi_ui::View`, `llimphi_ui::DragFn`, +// etc. siguen resolviendo igual que antes del split. +pub use llimphi_compositor; +pub use llimphi_compositor::*; + +/// Aplicación Elm: estado inmutable, transición pura, vista pura. +/// +/// `init` y `update` reciben un [`Handle`] que permite hablar con el runtime +/// desde dentro de la transición (cerrar la ventana, lanzar trabajo en otro +/// hilo y reentrar con un Msg al terminar). Mantener la transición pura del +/// modelo sigue siendo el contrato — `Handle` sólo escala efectos. +pub trait App: 'static { + type Model: 'static; + type Msg: Clone + Send + 'static; + + fn init(handle: &Handle) -> Self::Model; + fn update(model: Self::Model, msg: Self::Msg, handle: &Handle) -> Self::Model; + fn view(model: &Self::Model) -> View; + + /// Maneja una pulsación de tecla. Devuelve `Some(Msg)` para disparar + /// una transición; `None` (default) ignora la tecla. + fn on_key(_model: &Self::Model, _event: &KeyEvent) -> Option { + None + } + + /// El foco cambió: el runtime movió el foco a `id` (`None` = nada + /// enfocado). Pasa al pulsar Tab/Shift+Tab (recorre los nodos + /// `View::focusable` en orden de árbol, envolviendo) o al clickear un + /// nodo enfocable. La app guarda `id` en su `Model` para (a) pintar el + /// focus-ring (`if model.focus == Some(id) { … }` en `view`) y (b) + /// rutear el teclado al campo activo desde `on_key`. Devolver + /// `Some(Msg)` dispara una transición; `None` (default) ignora. + /// + /// El foco lo administra el runtime (única fuente de verdad), así que + /// Tab y click-to-focus quedan consistentes sin que la app los cablee. + fn on_focus(_model: &Self::Model, _id: Option) -> Option { + None + } + + /// ¿Habilitar IME (input method editor) en esta ventana? Default + /// `false`. Con IME activo, el texto compuesto (CJK, acentos muertos, + /// emoji picker) llega por [`App::on_ime`] como `Commit`, **no** por + /// `KeyEvent.text` — por eso es opt-in: las apps que sólo leen + /// `on_key` siguen funcionando igual. Las que editan texto + /// (`text-input`, `text-editor`) la activan e implementan `on_ime`. + fn ime_allowed() -> bool { + false + } + + /// Maneja un evento de IME (sólo llega si [`App::ime_allowed`] es + /// `true`). El flujo típico: `Enabled` → uno o más `Preedit` (texto en + /// composición, a pintar subrayado en el caret) → `Commit(texto)` (el + /// texto final, a insertar como si se hubiera tecleado) o `Disabled`. + /// El `Preedit` no es definitivo: cada uno reemplaza al anterior, y un + /// `Commit` o `Preedit` vacío lo cierra. Devolver `Some(Msg)` dispara + /// una transición. + fn on_ime(_model: &Self::Model, _event: &ImeEvent) -> Option { + None + } + + /// Área del caret en **píxeles físicos** `(x, y, w, h)` para posicionar + /// la ventana de candidatos del IME (CJK) junto al cursor de texto. El + /// runtime la consulta por frame cuando [`App::ime_allowed`] es `true`. + /// `None` (default) deja que el sistema la ubique por defecto. + fn ime_cursor_area(_model: &Self::Model) -> Option<(f32, f32, f32, f32)> { + None + } + + /// Maneja una rueda del mouse. `delta` está normalizado a "líneas" + /// (positivo arriba/izquierda, negativo abajo/derecha). En backends + /// que reportan píxeles, llimphi-ui divide por 20 para aproximar. + fn on_wheel( + _model: &Self::Model, + _delta: WheelDelta, + _cursor: (f32, f32), + _modifiers: Modifiers, + ) -> Option { + None + } + + /// Capa de overlay opcional. Si devuelve `Some(view)`, el runtime + /// la pinta encima del árbol principal y los clicks/hover se + /// rutean exclusivamente a ella (el árbol de fondo queda "bajo + /// vidrio" hasta que se cierre el overlay). Pensado para menús + /// contextuales, diálogos modales, popovers — el patrón usual es + /// envolver los items en un scrim a pantalla completa con + /// `on_click = DismissOverlay` para que los clicks afuera lo + /// cierren. + /// + /// La transición entre "con overlay" y "sin overlay" la maneja la + /// app vía su Model: cuando el state diga "menu abierto", + /// `view_overlay` devuelve `Some`; cuando se cierre, `None`. + fn view_overlay(_model: &Self::Model) -> Option> { + None + } + + /// Maneja un drop de archivo desde el sistema operativo (drag&drop + /// desde el file manager hacia la ventana). El runtime invoca este + /// callback una vez por archivo soltado — si el usuario suelta varios, + /// llega un evento por path. Devolver `Some(Msg)` dispara un update; + /// `None` (default) ignora el drop. + /// + /// Backend: mapea directamente `winit::WindowEvent::DroppedFile(PathBuf)`. + /// La posición del drop no se reporta porque winit no la expone hasta + /// que el compositor la propague — en Wayland depende del extension + /// `data_device_manager`, en X11 viene en el ClientMessage XDND. + fn on_file_drop(_model: &Self::Model, _path: std::path::PathBuf) -> Option { + None + } + + /// Maneja un redimensionado de la ventana. `width`/`height` son el + /// nuevo tamaño en **píxeles físicos** (lo que reporta + /// `winit::WindowEvent::Resized` y lo que recibe la surface). El + /// runtime ya reconfiguró la surface y pedirá redraw; este callback + /// es para que la app reaccione al nuevo viewport (recalcular layout + /// dependiente del tamaño, emitir un evento `resize`, etc.). + /// Devolver `Some(Msg)` dispara un update; `None` (default) lo ignora. + fn on_resize(_model: &Self::Model, _width: u32, _height: u32) -> Option { + None + } + + /// Maneja un cambio del factor de escala de la ventana (`scale_factor` + /// de winit: 1.0 en pantallas normales, 2.0 en HiDPI/Retina, fraccional + /// con escalado del compositor). El runtime lo invoca una vez al arrancar + /// (con el factor inicial de la ventana, tras `init`) y luego en cada + /// `WindowEvent::ScaleFactorChanged` (mover la ventana entre monitores, + /// cambiar el escalado del sistema). Es lo que permite, p. ej., que + /// `window.devicePixelRatio` refleje el DPI real. Devolver `Some(Msg)` + /// dispara un update; `None` (default) lo ignora. + fn on_scale_factor(_model: &Self::Model, _scale: f64) -> Option { + None + } + + /// Título de la ventana (sólo se lee al arrancar). Es el título inicial; + /// para uno que cambie en runtime, ver [`App::window_title`]. + fn title() -> &'static str { + "llimphi" + } + + /// Título **dinámico** de la ventana, derivado del modelo. El runtime lo + /// consulta tras cada render y, si cambió, lo aplica con `Window::set_title` + /// — así el título de la barra del SO puede reflejar el estado (p. ej. el + /// medio que se reproduce). `None` (default) deja el título fijo de + /// [`App::title`]; una app que no lo implemente no paga nada. + fn window_title(_model: &Self::Model) -> Option { + None + } + + /// Vista de una ventana OS **secundaria** identificada por `key` (la que + /// se pasó a [`Handle::open_window`]). El runtime la pinta en su propia + /// ventana y rutea sus eventos al mismo [`App::update`] — comparte modelo + /// con la primaria. `None` (default, o para una key desconocida) deja la + /// ventana en blanco. Las secundarias NO tienen capa de overlay + /// ([`App::view_overlay`] es sólo de la primaria); para diálogos dentro de + /// una secundaria, componerlos en su propio `secondary_view`. + fn secondary_view(_model: &Self::Model, _key: u64) -> Option> { + None + } + + /// Título dinámico de una ventana secundaria (análogo a + /// [`App::window_title`] para la primaria). `None` deja el título con el + /// que se abrió. + fn secondary_title(_model: &Self::Model, _key: u64) -> Option { + None + } + + /// El usuario cerró una ventana secundaria con el botón del SO. El runtime + /// ya la destruyó; este callback es para que la app sincronice su modelo + /// (p. ej. marcar el panel como cerrado). Devolver `Some(Msg)` dispara un + /// `update`; `None` (default) no hace nada. + fn on_secondary_close(_model: &Self::Model, _key: u64) -> Option { + None + } + + /// Identificador de aplicación. En Wayland se mapea al `app_id` del + /// xdg-toplevel (lo que el compositor usa para reconocer la ventana, + /// p. ej. `carmen.greeter`). `None` deja que el sistema asigne uno. + fn app_id() -> Option<&'static str> { + None + } + + /// Tamaño lógico inicial de la ventana, en píxeles. El usuario puede + /// redimensionar después; sólo se lee al arrancar. + fn initial_size() -> (u32, u32) { + (960, 540) + } +} + +/// Mensaje interno del event loop. `Msg` lo dispara la app desde un hilo de +/// fondo vía [`Handle::dispatch`] o [`Handle::spawn`]; `Quit` cierra la +/// ventana y termina el proceso. +pub enum UserEvent { + Msg(Msg), + Quit, + /// Pide abrir una ventana OS **secundaria** con la `key` dada (la app la + /// usa para distinguir cuál es en [`App::secondary_view`]). Idempotente: + /// si ya existe una con esa key, se enfoca en vez de duplicar. La crea el + /// event loop (que tiene el `ActiveEventLoop`); por eso va por mensaje. + OpenWindow { + key: u64, + title: String, + width: u32, + height: u32, + }, + /// Pide cerrar la ventana secundaria con esa `key`. No afecta a la primaria. + CloseWindow { key: u64 }, +} + +/// Asa al runtime de Llimphi. Clonable y enviable entre hilos: la usás para +/// pedir cerrar la ventana o para lanzar trabajo (PAM, IO, etc.) que al +/// terminar reentra con un Msg al `update`. +/// +/// Tests pueden construir un handle "muerto" con [`Handle::for_test`]: los +/// `dispatch`/`quit`/`spawn` siguen siendo seguros de llamar pero los +/// `Msg` que generan no van a ningún lado (no hay event loop detrás). +pub struct Handle { + inner: HandleInner, +} + +enum HandleInner { + Real(EventLoopProxy>), + /// Handle de tests: drop silencioso de todos los dispatches. Permite + /// llamar funciones que toman `&Handle` sin levantar un event + /// loop real (que en CI sin display tiraría). + Test, +} + +impl Clone for Handle { + fn clone(&self) -> Self { + Self { + inner: match &self.inner { + HandleInner::Real(p) => HandleInner::Real(p.clone()), + HandleInner::Test => HandleInner::Test, + }, + } + } +} + +impl Handle { + /// Construye un handle desactivado para tests — todos los dispatch + /// se descartan silenciosamente. Útil para probar funciones que toman + /// `&Handle` sin levantar un event loop real (que en CI sin + /// display tiraría). + pub fn for_test() -> Self { + Self { + inner: HandleInner::Test, + } + } + + /// Cierra la ventana y termina el bucle. La transición en curso (si la + /// hay) se completa antes de salir. + pub fn quit(&self) { + match &self.inner { + HandleInner::Real(p) => { + let _ = p.send_event(UserEvent::Quit); + } + HandleInner::Test => {} + } + } + + /// Abre una ventana OS **secundaria** (ver [`App::secondary_view`]). La + /// `key` la elige la app para reconocerla luego; abrir con una key que ya + /// existe sólo la enfoca (no duplica). El contenido lo pinta + /// `App::secondary_view(model, key)` y los eventos (click/tecla/…) reentran + /// al mismo `update`, así que la ventana comparte el modelo con la primaria. + /// Cerrala con [`Self::close_window`] o con el botón del SO. + pub fn open_window(&self, key: u64, title: impl Into, width: u32, height: u32) { + if let HandleInner::Real(p) = &self.inner { + let _ = p.send_event(UserEvent::OpenWindow { + key, + title: title.into(), + width, + height, + }); + } + } + + /// Cierra la ventana secundaria con esa `key` (no-op si no existe). La + /// ventana primaria nunca se cierra por acá — para eso está [`Self::quit`]. + pub fn close_window(&self, key: u64) { + if let HandleInner::Real(p) = &self.inner { + let _ = p.send_event(UserEvent::CloseWindow { key }); + } + } + + /// Encola un Msg para procesarse en el próximo turno del bucle. Útil + /// para que un callback externo reentre al update. + pub fn dispatch(&self, msg: Msg) { + match &self.inner { + HandleInner::Real(p) => { + let _ = p.send_event(UserEvent::Msg(msg)); + } + HandleInner::Test => {} + } + } + + /// Lanza una closure en un hilo aparte; cuando devuelve `Msg`, el + /// runtime la entrega al `update` en el hilo de UI. Pensado para + /// trabajo bloqueante (PAM tarda ~2 s ante un fallo, p. ej.). + pub fn spawn(&self, f: F) + where + F: FnOnce() -> Msg + Send + 'static, + { + match &self.inner { + HandleInner::Real(p) => { + let proxy = p.clone(); + std::thread::spawn(move || { + let msg = f(); + let _ = proxy.send_event(UserEvent::Msg(msg)); + }); + } + HandleInner::Test => { + // Corremos la closure igual (para no perder side-effects de + // tests que dependan de su side) pero el msg se descarta. + std::thread::spawn(move || { + let _ = f(); + }); + } + } + } + + /// Lanza un loop periódico en un hilo aparte: cada `period` invoca + /// `f()` y dispatcha el `Msg` resultante al `update`. El thread + /// queda corriendo hasta que el event loop se cierra (en ese + /// punto el `send_event` falla silenciosamente y el thread spinea + /// hasta el exit del proceso, costo despreciable). + /// + /// Útil para ticks de simulación (~11 Hz en dominium), polling de + /// hardware, o cualquier feed que necesite Msgs a intervalos + /// regulares. Si `f` necesita state, capturalo en la closure por + /// move; la closure se ejecuta en un thread aparte así que el + /// state capturado debe ser `Send`. + pub fn spawn_periodic(&self, period: std::time::Duration, f: F) + where + F: Fn() -> Msg + Send + 'static, + { + match &self.inner { + HandleInner::Real(p) => { + let proxy = p.clone(); + std::thread::spawn(move || loop { + std::thread::sleep(period); + if proxy.send_event(UserEvent::Msg(f())).is_err() { + // Event loop cerrado — el thread puede morir. + break; + } + }); + } + HandleInner::Test => { + // Un thread vivo eternamente sin sumidero ni manera de + // pararlo sería un leak — en for_test simplemente no + // arrancamos el loop. Los tests que necesiten verificar + // periodic behaviour deben usar el callback directo. + let _ = f; + } + } + } +} + +/// Evento de teclado normalizado. +#[derive(Debug, Clone)] +pub struct KeyEvent { + pub key: Key, + pub state: KeyState, + /// Texto resultante (con modifiers e IME aplicados). Útil para inserción + /// directa; `None` para teclas que no producen texto (flechas, etc.). + pub text: Option, + pub modifiers: Modifiers, + pub repeat: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyState { + Pressed, + Released, +} + +/// Evento de IME normalizado (espeja `winit::event::Ime`). Ver +/// [`App::on_ime`] para el flujo Enabled → Preedit* → Commit/Disabled. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ImeEvent { + /// El IME se activó para esta ventana. + Enabled, + /// Texto en composición (aún no confirmado). `cursor` es el rango + /// `(inicio, fin)` en bytes a resaltar dentro de `text`, si el IME lo + /// reporta. Cada `Preedit` reemplaza al anterior; uno con `text` + /// vacío cierra la preedición sin confirmar. + Preedit { + text: String, + cursor: Option<(usize, usize)>, + }, + /// Texto confirmado: insertarlo como si se hubiera tecleado. + Commit(String), + /// El IME se desactivó (perder foco, cambiar de método). + Disabled, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Modifiers { + pub shift: bool, + pub ctrl: bool, + pub alt: bool, + pub meta: bool, +} + +/// Delta de rueda en "líneas" lógicas (normalizado a través de backends). +/// Convención CSS: positivo = scroll **hacia abajo** (contenido sube). +/// `x` similar para scroll horizontal (touchpads, ratones de 2 ejes). +#[derive(Debug, Clone, Copy, Default)] +pub struct WheelDelta { + pub x: f32, + pub y: f32, +} + +impl From for Modifiers { + fn from(m: ModifiersState) -> Self { + Self { + shift: m.shift_key(), + ctrl: m.control_key(), + alt: m.alt_key(), + meta: m.super_key(), + } + } +} + +// --- Runtime winit. El event loop (impl ApplicationHandler) vive en +// `eventloop` y accede los campos privados de estos structs vía +// `use super::*`. La composición declarativa (View, mount, paint, +// hit-test) la trae el re-export de `llimphi_compositor`. --- +mod eventloop; + +struct Runtime { + handle: Handle, + state: Option>, + /// Ventanas OS secundarias abiertas (opt-in vía [`Handle::open_window`]). + /// Comparten el `Hal`/`Renderer` y el modelo de la primaria (`state`); + /// cada una lleva su propia surface + caches de interacción. Vacío en la + /// inmensa mayoría de las apps (monoventana) — coste cero. + secondaries: Vec>, +} + +/// Estado por **ventana secundaria**. Espeja los campos de interacción de +/// [`RuntimeState`] pero SIN modelo (vive en la primaria), sin overlay y sin +/// `Hal`/`Renderer` propios (los toma prestados de la primaria al pintar). +struct SecondaryState { + /// La key con la que la app la abrió (la pasa a `secondary_view`). + key: u64, + window: Arc, + surface: WinitSurface, + scene: vello::Scene, + typesetter: llimphi_text::Typesetter, + layout: LayoutTree, + cursor: PhysicalPosition, + modifiers: Modifiers, + last_render: Option>, + hovered: Option, + drag: Option>, + last_title: Option, +} + +/// Cache de render de una ventana secundaria (como [`RenderCache`] pero sin +/// capa de overlay). Sólo guarda el árbol montado + layout para hit-testear el +/// próximo click/hover; el `hover_idx` actual vive en `SecondaryState::hovered`. +struct SecRenderCache { + mounted: Mounted, + computed: ComputedLayout, +} + +struct RuntimeState { + window: Arc, + hal: Hal, + surface: WinitSurface, + renderer: Renderer, + scene: vello::Scene, + /// Compositor de la capa de overlay sobre contenido `gpu_paint` (video). + /// Sólo entra en juego cuando el árbol principal tiene painters gpu y hay + /// un overlay activo; resuelve el z-order (menús por encima del video). + overlay_compositor: llimphi_hal::OverlayCompositor, + model: Option, + cursor: PhysicalPosition, + modifiers: Modifiers, + typesetter: llimphi_text::Typesetter, + /// Árboles de layout reusados entre frames: `clear()` + `mount` en + /// vez de re-allocar el slotmap de taffy en cada redraw. Uno para el + /// árbol principal, otro para el overlay (sus `NodeId` no deben + /// colisionar dentro del mismo frame). + layout: LayoutTree, + overlay_layout: LayoutTree, + /// Último frame renderizado: árbol montado + rects absolutos + + /// nodo con hover. Lo consume el handler de click para hit-testear + /// sin reconstruir `view` + layout, y CursorMoved para detectar si + /// el hover cambió y disparar redraw. + last_render: Option>, + /// Nodo hovereado **persistente** entre frames, actualizado SÓLO en + /// `CursorMoved`. Es contra esto que se detecta el `on_pointer_enter` + /// (no contra `last_render.hover_idx`, que el render recomputa cada + /// cuadro): en una app que re-renderiza sin parar (visores `paint_with`) + /// el render "se comería" la transición de hover antes de que el handler + /// del mouse la detecte, y el hover-switch de menús no funcionaría. + hovered: Option, + /// Drag activo. Mantiene su propio handler clonado del MountedNode + /// — así el drag sobrevive aunque el cache se invalide entre + /// eventos. + drag: Option>, + /// Foco actual (id de un nodo `View::focusable`). El runtime es la + /// única fuente de verdad: lo mueve con Tab/Shift+Tab y click-to-focus + /// y lo notifica vía `App::on_focus`. `None` = nada enfocado. + focused: Option, + /// Último título dinámico aplicado a la ventana (ver [`App::window_title`]). + /// Evita llamar `set_title` en cada frame cuando no cambió. + last_title: Option, +} + +struct RenderCache { + mounted: Mounted, + computed: ComputedLayout, + /// Índice del nodo en hover en el frame ya pintado. `None` si el + /// cursor no toca ningún `hover_fill`. + hover_idx: Option, + /// Índice del drop target hovereado en el frame ya pintado. Solo + /// se setea durante un drag activo con `payload` declarado. + drop_hover_idx: Option, + /// Capa de overlay (menú contextual, modal). Cuando está presente, + /// hover/click/right-click se rutean a ella exclusivamente — el + /// árbol principal queda "bajo vidrio" hasta que la app cierre el + /// overlay devolviendo `None` desde [`App::view_overlay`]. + overlay: Option>, +} + +struct OverlayCache { + mounted: Mounted, + computed: ComputedLayout, + hover_idx: Option, +} + +/// Dos sabores de handler de drag activo: el simple `(phase, dx, dy)` +/// o la variante que conserva la posición local del press original +/// `(phase, dx, dy, lx0, ly0)`. El runtime elige uno al iniciar el drag. +enum DragHandlerKind { + Delta(DragFn), + DeltaAt(DragAtFn, f32, f32), +} + +struct DragState { + handler: DragHandlerKind, + /// Cursor en el último evento (Press o CursorMoved). El delta del + /// próximo Move se calcula contra este, no contra el inicio del + /// drag — el caller acumula los deltas en su modelo si los necesita. + last_cursor: PhysicalPosition, + /// Payload `u64` que viaja con el drag. `None` si el draggable + /// origen no declaró ninguno (drag de resize/scroll/etc.). Los drop + /// targets sólo reaccionan cuando hay payload. + payload: Option, +} + +/// Punto de entrada: corre el bucle Elm hasta que el usuario cierre la +/// ventana (o la app llame [`Handle::quit`]). +pub fn run() { + let event_loop = EventLoop::>::with_user_event() + .build() + .expect("event loop"); + event_loop.set_control_flow(ControlFlow::Wait); + let handle = Handle { + inner: HandleInner::Real(event_loop.create_proxy()), + }; + let mut runtime: Runtime = Runtime { + handle, + state: None, + secondaries: Vec::new(), + }; + event_loop.run_app(&mut runtime).expect("run app"); +} diff --git a/llimphi-workspace/Cargo.toml b/llimphi-workspace/Cargo.toml new file mode 100644 index 0000000..4477080 --- /dev/null +++ b/llimphi-workspace/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-workspace" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-workspace — chasis genérico estilo tmux: hospeda N paneles en un árbol BSP (llimphi-widget-panes) con la máquina de estados (split/close/focus/resize) + chrome estándar. La capa sobre la que cualquier app de gioser se monta en un layout intercambiable." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-panes = { path = "../widgets/panes" } diff --git a/llimphi-workspace/examples/workspace_demo.rs b/llimphi-workspace/examples/workspace_demo.rs new file mode 100644 index 0000000..54ebcc4 --- /dev/null +++ b/llimphi-workspace/examples/workspace_demo.rs @@ -0,0 +1,212 @@ +//! Demo del chasis `llimphi-workspace`. +//! +//! Mismo resultado que `panes_demo` pero la app ya no reimplementa la +//! máquina de estados: guarda un `Workspace` + un mapa de paneles, y deja +//! que el chasis maneje split/cerrar/foco/resize y el chrome. Esto es el +//! molde que después adopta cada app de gioser. +//! +//! Correr: `cargo run -p llimphi-workspace --example workspace_demo --release` + +use std::collections::HashMap; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, FlexDirection, Size, Style}, + Rect, +}; +use llimphi_ui::{App, Handle, View}; +use llimphi_theme::Theme; +use llimphi_workspace::{workspace_view, Axis, PaneId, Workspace, WorkspacePalette, WsEffect, WsMsg}; + +struct Demo; + +#[derive(Clone)] +enum Msg { + Ws(WsMsg), + Panel(PaneId, PanelMsg), +} + +#[derive(Clone)] +enum PanelMsg { + Inc, + Dec, + AddNote, +} + +enum Kind { + Counter(i64), + Notes(Vec), +} + +struct Model { + ws: Workspace, + panes: HashMap, + theme: Theme, +} + +impl App for Demo { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "workspace — chasis tmux de gioser" + } + + fn init(_: &Handle) -> Model { + let mut ws = Workspace::new(); // panel 0 + let mut panes = HashMap::new(); + panes.insert(0, Kind::Counter(0)); + let id = ws.split(Axis::Horizontal); + panes.insert(id, Kind::Notes(vec!["arrastrá el divisor del medio →".into()])); + ws.focus(0); + Model { + ws, + panes, + theme: Theme::dark(), + } + } + + fn update(mut model: Model, msg: Msg, _: &Handle) -> Model { + match msg { + Msg::Ws(m) => match model.ws.apply(m) { + WsEffect::Created(id) => { + // Alternamos tipo para ilustrar paneles heterogéneos. + let kind = if id % 2 == 0 { + Kind::Counter(0) + } else { + Kind::Notes(vec![]) + }; + model.panes.insert(id, kind); + } + WsEffect::Closed(id) => { + model.panes.remove(&id); + } + WsEffect::None => {} + }, + Msg::Panel(id, pm) => { + if let Some(kind) = model.panes.get_mut(&id) { + match (kind, pm) { + (Kind::Counter(n), PanelMsg::Inc) => *n += 1, + (Kind::Counter(n), PanelMsg::Dec) => *n -= 1, + (Kind::Notes(v), PanelMsg::AddNote) => { + let n = v.len() + 1; + v.push(format!("nota #{n}")); + } + _ => {} + } + } + } + } + model + } + + fn view(model: &Model) -> View { + let palette = WorkspacePalette::from_theme(&model.theme); + let panes = &model.panes; + let theme = &model.theme; + workspace_view( + &model.ws, + &palette, + move |id| render_pane(panes, theme, id), + Msg::Ws, + ) + } +} + +fn render_pane(panes: &HashMap, t: &Theme, id: PaneId) -> View { + let Some(kind) = panes.get(&id) else { + return label("(vacío)".to_string(), 14.0, t.fg_muted); + }; + let body = match kind { + Kind::Counter(n) => col( + 8.0, + vec![ + label(format!("{n}"), 44.0, t.accent), + row( + 8.0, + vec![ + button("−", Msg::Panel(id, PanelMsg::Dec), t), + button("+", Msg::Panel(id, PanelMsg::Inc), t), + ], + ), + ], + ), + Kind::Notes(v) => { + let mut lines: Vec> = v + .iter() + .map(|s| label(format!("• {s}"), 14.0, t.fg_text)) + .collect(); + lines.push(button("+ nota", Msg::Panel(id, PanelMsg::AddNote), t)); + col(6.0, lines) + } + }; + View::new(Style { + flex_direction: FlexDirection::Column, + gap: Size { + width: length(10.0), + height: length(10.0), + }, + padding: uniform(12.0), + flex_grow: 1.0, + ..Default::default() + }) + .children(vec![label(format!("panel #{id}"), 13.0, t.fg_muted), body]) +} + +fn button(text: &str, msg: Msg, t: &Theme) -> View { + View::new(Style { + padding: Rect { + left: length(12.0), + right: length(12.0), + top: length(6.0), + bottom: length(6.0), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(t.bg_button) + .hover_fill(t.bg_button_hover) + .radius(6.0) + .on_click(msg) + .children(vec![label(text.to_string(), 14.0, t.fg_text)]) +} + +fn col(gap: f32, children: Vec>) -> View { + View::new(Style { + flex_direction: FlexDirection::Column, + gap: Size { + width: length(gap), + height: length(gap), + }, + ..Default::default() + }) + .children(children) +} + +fn row(gap: f32, children: Vec>) -> View { + View::new(Style { + flex_direction: FlexDirection::Row, + gap: Size { + width: length(gap), + height: length(gap), + }, + ..Default::default() + }) + .children(children) +} + +fn label(text: String, size: f32, color: llimphi_ui::llimphi_raster::peniko::Color) -> View { + View::new(Style::default()).text(text, size, color) +} + +fn uniform(px: f32) -> Rect { + Rect { + left: length(px), + right: length(px), + top: length(px), + bottom: length(px), + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/llimphi-workspace/src/lib.rs b/llimphi-workspace/src/lib.rs new file mode 100644 index 0000000..11e73f7 --- /dev/null +++ b/llimphi-workspace/src/lib.rs @@ -0,0 +1,378 @@ +//! `llimphi-workspace` — chasis genérico estilo tmux. +//! +//! Paso 2 de la visión "montar cualquier componente de gioser en un layout +//! intercambiable con splits resizables". Donde [`llimphi_widget_panes`] +//! aporta el **árbol** (estructura + render + drag), este crate aporta la +//! **máquina de estados** (qué panel está enfocado, cómo se parte/cierra, +//! el contador de ids) + el **chrome estándar** (toolbar split/cerrar). +//! +//! ## Cómo lo usa una app +//! +//! La app guarda un [`Workspace`] en su `Model` y un `HashMap` +//! con el estado de cada panel. Su `Msg` envuelve dos cosas: +//! +//! ```ignore +//! enum Msg { +//! Ws(WsMsg), // mensajes del chasis (focus/split/…) +//! Panel(PaneId, PanelMsg), // mensajes de un panel concreto +//! } +//! ``` +//! +//! En `update`, los `Ws` se aplican con [`Workspace::apply`], que devuelve +//! un [`WsEffect`] indicando si hay que **crear** el estado de un panel +//! nuevo o **borrar** el de uno cerrado. En `view`, [`workspace_view`] arma +//! el chrome + el árbol; la app sólo provee el contenido de cada hoja (ya +//! lifteado a su propio `Msg` — el chasis no toca los `PanelMsg`). +//! +//! El lift se hace al construir la vista (igual que `shuma-module`), así +//! sorteamos la falta de `View::map` sin `Box`: el chasis es +//! genérico sobre el `Msg` del host y nunca downcastea. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Dimension, FlexDirection, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::View; +use llimphi_widget_panes::{panes_view, Layout, PanesPalette}; + +pub use llimphi_widget_panes::{Axis, PaneId, Side}; + +/// Estado del workspace: el árbol de paneles + cuál está enfocado + el +/// contador para asignar ids nuevos. Agnóstico del contenido — el host +/// guarda el estado real de cada panel por su `PaneId`. +#[derive(Debug, Clone)] +pub struct Workspace { + layout: Layout, + focused: PaneId, + next_id: PaneId, +} + +impl Workspace { + /// Workspace con un único panel (id `0`). + pub fn new() -> Self { + Self { + layout: Layout::single(0), + focused: 0, + next_id: 1, + } + } + + /// Id del panel enfocado. + pub fn focused(&self) -> PaneId { + self.focused + } + + /// Cantidad de paneles. + pub fn count(&self) -> usize { + self.layout.count() + } + + /// Ids de todos los paneles, en orden espacial. + pub fn leaves(&self) -> Vec { + self.layout.leaves() + } + + /// El árbol crudo (para casos avanzados; lo normal es [`workspace_view`]). + pub fn layout(&self) -> &Layout { + &self.layout + } + + /// Enfoca un panel (no-op si no existe). + pub fn focus(&mut self, id: PaneId) { + if self.layout.contains(id) { + self.focused = id; + } + } + + /// Parte el panel enfocado en `axis`; el nuevo queda enfocado. Devuelve + /// el `PaneId` nuevo para que el host cree su estado. + pub fn split(&mut self, axis: Axis) -> PaneId { + let id = self.next_id; + self.next_id += 1; + self.layout.split(self.focused, id, axis); + self.focused = id; + id + } + + /// Cierra el panel enfocado (no cierra el último). Devuelve el id + /// removido para que el host libere su estado, o `None` si no removió. + pub fn close(&mut self) -> Option { + if self.count() <= 1 { + return None; + } + let target = self.focused; + let (nl, removed) = self.layout.clone().without(target); + if removed { + self.layout = nl; + self.focused = self.layout.first_leaf(); + Some(target) + } else { + None + } + } + + /// Ajusta el ratio del split direccionado por `path`. + pub fn resize(&mut self, path: &[Side], delta: f32) { + self.layout.resize(path, delta); + } + + /// Aplica un mensaje del chasis y reporta el efecto a atender. + pub fn apply(&mut self, msg: WsMsg) -> WsEffect { + match msg { + WsMsg::Focus(id) => { + self.focus(id); + WsEffect::None + } + WsMsg::Split(axis) => WsEffect::Created(self.split(axis)), + WsMsg::Close => match self.close() { + Some(id) => WsEffect::Closed(id), + None => WsEffect::None, + }, + WsMsg::Resize(path, d) => { + self.resize(&path, d); + WsEffect::None + } + } + } +} + +impl Default for Workspace { + fn default() -> Self { + Self::new() + } +} + +/// Mensajes del chasis. El host los envuelve en su propio `Msg` y los rutea +/// a [`Workspace::apply`]. +#[derive(Debug, Clone, PartialEq)] +pub enum WsMsg { + Focus(PaneId), + Split(Axis), + Close, + Resize(Vec, f32), +} + +/// Resultado de [`Workspace::apply`] — qué tiene que hacer el host con su +/// mapa de estados de panel. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WsEffect { + /// Nada que hacer. + None, + /// Se creó un panel nuevo con este id: inicializá su estado. + Created(PaneId), + /// Se cerró este panel: borrá su estado. + Closed(PaneId), +} + +/// Paleta del chasis. +#[derive(Debug, Clone, Copy)] +pub struct WorkspacePalette { + pub panes: PanesPalette, + pub bar_bg: Color, + pub btn_bg: Color, + pub btn_hover: Color, + pub label: Color, + pub muted: Color, +} + +impl Default for WorkspacePalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl WorkspacePalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + panes: PanesPalette::from_theme(t), + bar_bg: t.bg_panel, + btn_bg: t.bg_button, + btn_hover: t.bg_button_hover, + label: t.fg_text, + muted: t.fg_muted, + } + } +} + +/// Arma el chasis completo: toolbar (Split →/↓, Cerrar, estado) + el árbol +/// de paneles. +/// +/// - `leaf` materializa el contenido de cada panel — **ya lifteado al `Msg` +/// del host** (el host hace el lift internamente con su `Panel(id, …)`). +/// - `lift` mapea los [`WsMsg`] del chasis al `Msg` del host. +pub fn workspace_view( + ws: &Workspace, + palette: &WorkspacePalette, + mut leaf: impl FnMut(PaneId) -> View, + lift: impl Fn(WsMsg) -> Host + Clone + Send + Sync + 'static, +) -> View +where + Host: Clone + Send + Sync + 'static, +{ + let toolbar = View::new(Style { + flex_direction: FlexDirection::Row, + gap: Size { + width: length(8.0), + height: length(8.0), + }, + padding: uniform(8.0), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bar_bg) + .children(vec![ + button("Split →", lift(WsMsg::Split(Axis::Horizontal)), palette), + button("Split ↓", lift(WsMsg::Split(Axis::Vertical)), palette), + button("Cerrar", lift(WsMsg::Close), palette), + View::new(Style { + flex_grow: 1.0, + ..Default::default() + }), + text( + format!("foco #{} · {} paneles", ws.focused(), ws.count()), + 13.0, + palette.muted, + ), + ]); + + let lift_resize = lift.clone(); + let lift_focus = lift.clone(); + let area = panes_view( + ws.layout(), + ws.focused(), + |id| leaf(id), + move |path, phase, d| { + let _ = phase; + Some((lift_resize)(WsMsg::Resize(path, d))) + }, + move |id| (lift_focus)(WsMsg::Focus(id)), + &palette.panes, + ); + + let area_wrap = View::new(Style { + flex_grow: 1.0, + size: full(), + min_size: zero(), + ..Default::default() + }) + .children(vec![area]); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: full(), + ..Default::default() + }) + .children(vec![toolbar, area_wrap]) +} + +fn button(label: &str, msg: Host, palette: &WorkspacePalette) -> View +where + Host: Clone + Send + Sync + 'static, +{ + View::new(Style { + padding: Rect { + left: length(12.0), + right: length(12.0), + top: length(6.0), + bottom: length(6.0), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.btn_bg) + .hover_fill(palette.btn_hover) + .radius(6.0) + .on_click(msg) + .children(vec![text(label.to_string(), 14.0, palette.label)]) +} + +fn text(content: String, size: f32, color: Color) -> View +where + Host: Clone + Send + Sync + 'static, +{ + View::new(Style::default()).text(content, size, color) +} + +fn full() -> Size { + Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + } +} + +fn zero() -> Size { + Size { + width: length(0.0_f32), + height: length(0.0_f32), + } +} + +fn uniform(px: f32) -> Rect { + Rect { + left: length(px), + right: length(px), + top: length(px), + bottom: length(px), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn starts_with_one_pane() { + let ws = Workspace::new(); + assert_eq!(ws.count(), 1); + assert_eq!(ws.focused(), 0); + } + + #[test] + fn split_creates_and_focuses_new() { + let mut ws = Workspace::new(); + let id = ws.split(Axis::Horizontal); + assert_eq!(ws.count(), 2); + assert_eq!(ws.focused(), id); + assert_ne!(id, 0); + } + + #[test] + fn apply_split_reports_created() { + let mut ws = Workspace::new(); + match ws.apply(WsMsg::Split(Axis::Vertical)) { + WsEffect::Created(id) => assert_eq!(id, ws.focused()), + other => panic!("esperaba Created, fue {other:?}"), + } + } + + #[test] + fn close_reports_closed_and_refocuses() { + let mut ws = Workspace::new(); + let id = ws.split(Axis::Horizontal); // foco en el nuevo + match ws.apply(WsMsg::Close) { + WsEffect::Closed(closed) => { + assert_eq!(closed, id); + assert_eq!(ws.count(), 1); + assert_eq!(ws.focused(), 0); + } + other => panic!("esperaba Closed, fue {other:?}"), + } + } + + #[test] + fn cannot_close_last_pane() { + let mut ws = Workspace::new(); + assert_eq!(ws.apply(WsMsg::Close), WsEffect::None); + assert_eq!(ws.count(), 1); + } + + #[test] + fn focus_ignores_unknown() { + let mut ws = Workspace::new(); + ws.focus(999); + assert_eq!(ws.focused(), 0); + } +} diff --git a/modules/bookmarks/Cargo.toml b/modules/bookmarks/Cargo.toml new file mode 100644 index 0000000..9a94244 --- /dev/null +++ b/modules/bookmarks/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "llimphi-module-bookmarks" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-module-bookmarks - marcadores per-file persistentes en la sesion del editor. Modulo Llimphi: el host emite ToggleAt(path, line) al disparar Ctrl+Alt+B, JumpNext/JumpPrev para navegar (devuelve JumpTo accion), y OpenList para abrir un overlay tipo symbol-outline con fuzzy filter sobre los marks. No persiste a disco - el host puede serializar marks si quiere." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-text-input = { workspace = true } +nucleo-matcher = { workspace = true } + +[dev-dependencies] diff --git a/modules/bookmarks/LEEME.md b/modules/bookmarks/LEEME.md new file mode 100644 index 0000000..bc86f96 --- /dev/null +++ b/modules/bookmarks/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-module-bookmarks + +> Bookmarks por archivo de [llimphi](../../README.md). + +Marca posiciones en un archivo (línea + columna + nombre); navegación rápida (`F2`/`Shift+F2`). Persiste por workspace. diff --git a/modules/bookmarks/README.md b/modules/bookmarks/README.md new file mode 100644 index 0000000..011fd2e --- /dev/null +++ b/modules/bookmarks/README.md @@ -0,0 +1,5 @@ +# llimphi-module-bookmarks + +> Per-file bookmarks of [llimphi](../../README.md). + +Marks positions in a file (line + column + name); quick navigation (`F2`/`Shift+F2`). Persists per workspace. diff --git a/modules/bookmarks/src/lib.rs b/modules/bookmarks/src/lib.rs new file mode 100644 index 0000000..d974177 --- /dev/null +++ b/modules/bookmarks/src/lib.rs @@ -0,0 +1,424 @@ +//! llimphi-module-bookmarks - marcadores per-file persistentes en sesion. +//! +//! El usuario marca lineas con Ctrl+Alt+B y luego salta con +//! Ctrl+Alt+N / Ctrl+Alt+P. Ctrl+Shift+B abre un overlay con la +//! lista filtrable. +//! +//! Los marks son tuplas (PathBuf, line). Viven en memoria del +//! proceso; el host puede serializar marks si quiere persistir. +//! +//! Sigue el contrato Llimphi de docs/MODULES.md. + +#![forbid(unsafe_code)] + +use std::path::{Path, PathBuf}; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View}; +use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState}; + +/// Capabilities que aporta este modulo al host. +pub const CAPABILITIES: &[&str] = &["editor.bookmarks"]; + +pub const MAX_RESULTS: usize = 500; + +const PANEL_H: f32 = 320.0; +const ROW_H: f32 = 20.0; +const MAX_VISIBLE: usize = 12; + +/// Sub-state del overlay tipo lista (input + results + selected). +/// None cuando no hay panel abierto. +pub struct BookmarksOverlay { + pub input: TextInputState, + /// Indices a state.marks rankeados por fuzzy match. Cap MAX_RESULTS. + pub results: Vec, + pub selected: usize, +} + +impl BookmarksOverlay { + pub fn new() -> Self { + Self { input: TextInputState::new(), results: Vec::new(), selected: 0 } + } +} + +/// Estado interno. Persiste durante toda la sesion (no es Option en +/// el host como otros modulos): los marks viven siempre, el overlay si +/// es opcional. Hace de mini-registro de waypoints del usuario. +pub struct BookmarksState { + /// Marks en orden de creacion. Cada uno es (path, line). + /// Toggle quita uno existente o agrega uno nuevo al final. + pub marks: Vec<(PathBuf, usize)>, + /// Overlay-list abierto cuando Some. + pub overlay: Option, +} + +impl Default for BookmarksState { + fn default() -> Self { Self::new() } +} + +impl BookmarksState { + pub fn new() -> Self { + Self { marks: Vec::new(), overlay: None } + } + + /// True si existe un mark con la misma (path, line). + pub fn contains(&self, path: &Path, line: usize) -> bool { + self.marks.iter().any(|(p, l)| p == path && *l == line) + } + + /// Toggle: si ya existe lo remueve; si no, lo agrega al final. + /// Devuelve true si quedo agregado. + pub fn toggle(&mut self, path: PathBuf, line: usize) -> bool { + if let Some(idx) = self.marks.iter().position(|(p, l)| p == &path && *l == line) { + self.marks.remove(idx); + false + } else { + self.marks.push((path, line)); + true + } + } +} + +/// Vocabulario interno. El host lo wrapea en su Msg. +#[derive(Debug, Clone)] +pub enum BookmarksMsg { + /// Toggle del mark en (path, line). El host emite esto cuando + /// detecta el shortcut (Ctrl+Alt+B) y conoce la posicion del caret. + ToggleAt { path: PathBuf, line: usize }, + /// Saltar al proximo mark cronologicamente despues de + /// (current_path, current_line). Si no hay marks, no-op. + JumpNext { current_path: PathBuf, current_line: usize }, + /// Saltar al previo. Misma semantica reversa. + JumpPrev { current_path: PathBuf, current_line: usize }, + /// Abrir el overlay-list. + OpenList, + /// Cerrar el overlay. + CloseList, + /// Teclas para el input del overlay. + ListKey(KeyEvent), + /// Navegacion en la lista del overlay. + ListNav(i32), + /// Enter: salta al mark seleccionado. + ListApply, + /// Limpia todos los marks. + ClearAll, +} + +/// Efecto solicitado al host. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BookmarksAction { + None, + /// El host deberia cerrar el overlay (limpiar la sub-state). + Close, + /// El host deberia abrir ese path (si no esta abierto) y + /// posicionar el caret. Cierra el overlay automaticamente cuando + /// llega vinculado a ListApply. + JumpTo { path: PathBuf, line: usize }, + /// Mensaje informativo para la status bar (eg toggle feedback). + SetStatus(String), +} + +/// Aplica un mensaje al estado. +pub fn apply(state: &mut BookmarksState, msg: BookmarksMsg) -> BookmarksAction { + match msg { + BookmarksMsg::ToggleAt { path, line } => { + let added = state.toggle(path.clone(), line); + let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?"); + let msg = if added { + format!("bookmark agregado en {} linea {}", name, line + 1) + } else { + format!("bookmark removido de {} linea {}", name, line + 1) + }; + BookmarksAction::SetStatus(msg) + } + BookmarksMsg::JumpNext { current_path, current_line } => { + match next_after(state, ¤t_path, current_line) { + Some((p, l)) => BookmarksAction::JumpTo { path: p, line: l }, + None => BookmarksAction::SetStatus("sin bookmarks".into()), + } + } + BookmarksMsg::JumpPrev { current_path, current_line } => { + match prev_before(state, ¤t_path, current_line) { + Some((p, l)) => BookmarksAction::JumpTo { path: p, line: l }, + None => BookmarksAction::SetStatus("sin bookmarks".into()), + } + } + BookmarksMsg::OpenList => BookmarksAction::None, + BookmarksMsg::CloseList => BookmarksAction::Close, + BookmarksMsg::ListKey(ev) => { + if let Some(ov) = state.overlay.as_mut() { + ov.input.apply_key(&ev); + refilter_overlay(state); + } + BookmarksAction::None + } + BookmarksMsg::ListNav(d) => { + if let Some(ov) = state.overlay.as_mut() { + let n = ov.results.len() as i32; + if n > 0 { + ov.selected = (ov.selected as i32 + d).rem_euclid(n) as usize; + } + } + BookmarksAction::None + } + BookmarksMsg::ListApply => { + let Some(ov) = state.overlay.as_ref() else { return BookmarksAction::None }; + let Some(&idx) = ov.results.get(ov.selected) else { return BookmarksAction::None }; + let Some((p, l)) = state.marks.get(idx).cloned() else { return BookmarksAction::None }; + BookmarksAction::JumpTo { path: p, line: l } + } + BookmarksMsg::ClearAll => { + let n = state.marks.len(); + state.marks.clear(); + if let Some(ov) = state.overlay.as_mut() { + ov.results.clear(); + ov.selected = 0; + } + BookmarksAction::SetStatus(format!("bookmarks limpios ({} removidos)", n)) + } + } +} + +/// Devuelve el mark inmediatamente posterior a (path, line) en orden +/// de marks. Wraparound al final. +fn next_after(state: &BookmarksState, path: &Path, line: usize) -> Option<(PathBuf, usize)> { + if state.marks.is_empty() { return None; } + let n = state.marks.len(); + let cur_idx = state.marks.iter().position(|(p, l)| p == path && *l == line); + let start = match cur_idx { + Some(i) => (i + 1) % n, + None => 0, + }; + Some(state.marks[start].clone()) +} + +/// Devuelve el mark inmediatamente previo. Wraparound al inicio. +fn prev_before(state: &BookmarksState, path: &Path, line: usize) -> Option<(PathBuf, usize)> { + if state.marks.is_empty() { return None; } + let n = state.marks.len(); + let cur_idx = state.marks.iter().position(|(p, l)| p == path && *l == line); + let start = match cur_idx { + Some(i) if i > 0 => i - 1, + Some(_) => n - 1, + None => n - 1, + }; + Some(state.marks[start].clone()) +} + +/// Routing de teclas cuando el overlay esta abierto. +pub fn on_key(state: &BookmarksState, event: &KeyEvent) -> Option { + state.overlay.as_ref()?; + if event.state != KeyState::Pressed { return None; } + Some(match &event.key { + Key::Named(NamedKey::Escape) => BookmarksMsg::CloseList, + Key::Named(NamedKey::Enter) => BookmarksMsg::ListApply, + Key::Named(NamedKey::ArrowDown) => BookmarksMsg::ListNav(1), + Key::Named(NamedKey::ArrowUp) => BookmarksMsg::ListNav(-1), + _ => BookmarksMsg::ListKey(event.clone()), + }) +} + +/// Atajo de toggle: Ctrl+Alt+B. +pub fn toggle_shortcut(event: &KeyEvent) -> bool { + event.state == KeyState::Pressed + && event.modifiers.ctrl + && event.modifiers.alt + && !event.modifiers.shift + && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("b")) +} + +/// Atajo de open-list: Ctrl+Shift+B. Tambien sirve como toggle del +/// panel (cierra si ya estaba abierto). El host decide en base a su +/// state. +pub fn open_shortcut(event: &KeyEvent) -> bool { + event.state == KeyState::Pressed + && event.modifiers.ctrl + && event.modifiers.shift + && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("b")) +} + +/// Atajo de next: Ctrl+Alt+N. +pub fn next_shortcut(event: &KeyEvent) -> bool { + event.state == KeyState::Pressed + && event.modifiers.ctrl + && event.modifiers.alt + && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("n")) +} + +/// Atajo de prev: Ctrl+Alt+P. +pub fn prev_shortcut(event: &KeyEvent) -> bool { + event.state == KeyState::Pressed + && event.modifiers.ctrl + && event.modifiers.alt + && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("p")) +} + +/// Recalcula overlay.results con fuzzy match contra path+line. +/// Query vacio = todos los marks en orden. +pub fn refilter_overlay(state: &mut BookmarksState) { + let Some(ov) = state.overlay.as_mut() else { return; }; + let q = ov.input.text(); + if q.trim().is_empty() { + ov.results = (0..state.marks.len().min(MAX_RESULTS)).collect(); + ov.selected = 0; + return; + } + use nucleo_matcher::{pattern::{CaseMatching, Normalization, Pattern}, Config, Matcher, Utf32Str}; + let mut matcher = Matcher::new(Config::DEFAULT); + let pat = Pattern::parse(&q, CaseMatching::Smart, Normalization::Smart); + let mut scored: Vec<(u32, usize)> = Vec::new(); + let mut buf = Vec::new(); + for (i, (p, l)) in state.marks.iter().enumerate() { + let hay_str = format!("{} {}", p.display(), l + 1); + buf.clear(); + let hay = Utf32Str::new(&hay_str, &mut buf); + if let Some(score) = pat.score(hay, &mut matcher) { + scored.push((score, i)); + } + } + scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); + scored.truncate(MAX_RESULTS); + ov.results = scored.into_iter().map(|(_, i)| i).collect(); + ov.selected = 0; +} + +/// Paleta visual. +#[derive(Debug, Clone)] +pub struct BookmarksPalette { + pub bg_panel: Color, + pub bg_header: Color, + pub bg_selected: Color, + pub fg_text: Color, + pub fg_muted: Color, + pub fg_accent: Color, + theme: llimphi_theme::Theme, +} + +impl BookmarksPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg_panel: t.bg_panel, + bg_header: t.bg_panel_alt, + bg_selected: t.bg_selected, + fg_text: t.fg_text, + fg_muted: t.fg_muted, + fg_accent: t.accent, + theme: t.clone(), + } + } +} + +/// Render del overlay. Solo se llama cuando state.overlay es Some. +/// El host pasa root para mostrar paths relativos en la lista. +pub fn view( + state: &BookmarksState, + root: &Path, + palette: &BookmarksPalette, + to_host: F, +) -> View +where + HostMsg: Clone + 'static, + F: Fn(BookmarksMsg) -> HostMsg + Copy + 'static, +{ + let ov = match state.overlay.as_ref() { + Some(o) => o, + None => return View::new(Style::default()), + }; + let header = if state.marks.is_empty() { + "bookmarks - sin marks - Ctrl+Alt+B agrega - Esc cierra".to_string() + } else if ov.results.is_empty() { + format!("bookmarks - sin matches - {} marks - Esc cierra", state.marks.len()) + } else { + format!( + "bookmarks - {} / {} - flechas navegan - Enter salta - Esc cierra", + ov.selected + 1, + ov.results.len(), + ) + }; + let header_view = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(18.0_f32) }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_header) + .text_aligned(header, 10.0, palette.fg_muted, Alignment::Start); + + let tp = TextInputPalette::from_theme(&palette.theme); + let input_view = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(26.0_f32) }, + padding: Rect { + left: length(6.0_f32), + right: length(6.0_f32), + top: length(2.0_f32), + bottom: length(2.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_panel) + .children(vec![text_input_view( + &ov.input, + "filtro: path o numero de linea", + true, + &tp, + to_host(BookmarksMsg::OpenList), + )]); + + let visible_start = ov.selected.saturating_sub(MAX_VISIBLE.saturating_sub(1)); + let visible_end = (visible_start + MAX_VISIBLE).min(ov.results.len()); + let mut rows: Vec> = Vec::with_capacity(MAX_VISIBLE); + for i in visible_start..visible_end { + let Some(&idx) = ov.results.get(i) else { continue }; + let Some((p, line)) = state.marks.get(idx) else { continue }; + let rel: String = match p.strip_prefix(root) { + Ok(r) => r.display().to_string(), + Err(_) => p.display().to_string(), + }; + let label = format!("{} : linea {}", rel, line + 1); + let selected = i == ov.selected; + let bg = if selected { palette.bg_selected } else { palette.bg_panel }; + let fg = if selected { palette.fg_text } else { palette.fg_muted }; + rows.push( + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(ROW_H) }, + padding: Rect { + left: length(10.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(bg) + .text_aligned(label, 11.0, fg, Alignment::Start), + ); + } + + let mut children: Vec> = Vec::with_capacity(2 + rows.len()); + children.push(header_view); + children.push(input_view); + children.extend(rows); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: length(PANEL_H) }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_panel) + .children(children) +} diff --git a/modules/bookmarks/tests/smoke.rs b/modules/bookmarks/tests/smoke.rs new file mode 100644 index 0000000..65d6f68 --- /dev/null +++ b/modules/bookmarks/tests/smoke.rs @@ -0,0 +1,94 @@ +//! Smoke tests del modulo bookmarks: toggle, jump-next/prev, +//! shortcuts, fuzzy refilter del overlay. + +use std::path::PathBuf; + +use llimphi_module_bookmarks::{ + self as bm, BookmarksAction, BookmarksMsg, BookmarksOverlay, BookmarksState, +}; +use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers}; + +fn key_with(ctrl: bool, alt: bool, shift: bool, ch: &str) -> KeyEvent { + KeyEvent { + key: Key::Character(ch.into()), + state: KeyState::Pressed, + text: Some(ch.into()), + modifiers: Modifiers { ctrl, alt, shift, ..Modifiers::default() }, + repeat: false, + } +} + +#[test] +fn toggle_agrega_y_remueve() { + let mut s = BookmarksState::new(); + let p = PathBuf::from("/x/foo.rs"); + let a1 = bm::apply(&mut s, BookmarksMsg::ToggleAt { path: p.clone(), line: 5 }); + assert!(matches!(a1, BookmarksAction::SetStatus(_))); + assert!(s.contains(&p, 5)); + let a2 = bm::apply(&mut s, BookmarksMsg::ToggleAt { path: p.clone(), line: 5 }); + assert!(matches!(a2, BookmarksAction::SetStatus(_))); + assert!(!s.contains(&p, 5)); +} + +#[test] +fn jump_next_wraparound() { + let mut s = BookmarksState::new(); + let a = PathBuf::from("/x/a.rs"); + let b = PathBuf::from("/x/b.rs"); + s.toggle(a.clone(), 10); + s.toggle(b.clone(), 20); + s.toggle(a.clone(), 30); + // Estamos en (a, 10) - next debe ser (b, 20). + let action = bm::apply(&mut s, BookmarksMsg::JumpNext { current_path: a.clone(), current_line: 10 }); + assert_eq!(action, BookmarksAction::JumpTo { path: b.clone(), line: 20 }); + // Estamos en (a, 30) - next wrappea a (a, 10). + let action = bm::apply(&mut s, BookmarksMsg::JumpNext { current_path: a.clone(), current_line: 30 }); + assert_eq!(action, BookmarksAction::JumpTo { path: a.clone(), line: 10 }); +} + +#[test] +fn jump_prev_wraparound() { + let mut s = BookmarksState::new(); + let a = PathBuf::from("/x/a.rs"); + s.toggle(a.clone(), 10); + s.toggle(a.clone(), 20); + s.toggle(a.clone(), 30); + // Estamos en (a, 10) - prev wrappea a (a, 30). + let action = bm::apply(&mut s, BookmarksMsg::JumpPrev { current_path: a.clone(), current_line: 10 }); + assert_eq!(action, BookmarksAction::JumpTo { path: a.clone(), line: 30 }); +} + +#[test] +fn jump_sin_marks_es_setstatus() { + let mut s = BookmarksState::new(); + let action = bm::apply(&mut s, BookmarksMsg::JumpNext { current_path: PathBuf::from("/x"), current_line: 0 }); + assert!(matches!(action, BookmarksAction::SetStatus(_))); +} + +#[test] +fn shortcuts_distinguibles() { + assert!(bm::toggle_shortcut(&key_with(true, true, false, "b"))); + assert!(!bm::toggle_shortcut(&key_with(true, true, true, "b"))); // ctrl+alt+shift+b no + assert!(bm::open_shortcut(&key_with(true, false, true, "b"))); + assert!(bm::next_shortcut(&key_with(true, true, false, "n"))); + assert!(bm::prev_shortcut(&key_with(true, true, false, "p"))); +} + +#[test] +fn refilter_con_query_vacio_lista_todos() { + let mut s = BookmarksState::new(); + s.toggle(PathBuf::from("/x/a.rs"), 1); + s.toggle(PathBuf::from("/x/b.rs"), 2); + s.overlay = Some(BookmarksOverlay::new()); + bm::refilter_overlay(&mut s); + assert_eq!(s.overlay.as_ref().unwrap().results.len(), 2); +} + +#[test] +fn clear_all_vacia_marks() { + let mut s = BookmarksState::new(); + s.toggle(PathBuf::from("/x"), 1); + s.toggle(PathBuf::from("/y"), 2); + let _ = bm::apply(&mut s, BookmarksMsg::ClearAll); + assert!(s.marks.is_empty()); +} diff --git a/modules/command-palette/Cargo.toml b/modules/command-palette/Cargo.toml new file mode 100644 index 0000000..0934b8f --- /dev/null +++ b/modules/command-palette/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "llimphi-module-command-palette" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-module-command-palette — paleta de comandos estilo Ctrl+Shift+P de VS Code. Módulo Llimphi reutilizable: state + Msg + Action + apply/on_key/view sobre un slice de Commands que provee el host. Fuzzy match con nucleo-matcher." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-text-input = { workspace = true } +nucleo-matcher = { workspace = true } diff --git a/modules/command-palette/LEEME.md b/modules/command-palette/LEEME.md new file mode 100644 index 0000000..dbce288 --- /dev/null +++ b/modules/command-palette/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-module-command-palette + +> Paleta de comandos de [llimphi](../../README.md). + +`Ctrl+Shift+P` abre un fuzzy-finder de comandos registrados (`Command { id, label, shortcut, action }`). Cada app declara sus comandos al iniciar. diff --git a/modules/command-palette/README.md b/modules/command-palette/README.md new file mode 100644 index 0000000..1f8fc3e --- /dev/null +++ b/modules/command-palette/README.md @@ -0,0 +1,5 @@ +# llimphi-module-command-palette + +> Command palette of [llimphi](../../README.md). + +`Ctrl+Shift+P` opens a fuzzy-finder of registered commands (`Command { id, label, shortcut, action }`). Each app declares its commands on init. diff --git a/modules/command-palette/src/lib.rs b/modules/command-palette/src/lib.rs new file mode 100644 index 0000000..7259684 --- /dev/null +++ b/modules/command-palette/src/lib.rs @@ -0,0 +1,352 @@ +//! `llimphi-module-command-palette` — paleta de comandos reutilizable. +//! +//! Equivalente a Ctrl+Shift+P de VS Code: el host declara una lista +//! plana de [`Command`]s (id opaco + título visible + grupo + hint del +//! atajo) y el módulo presenta un overlay con input + resultados +//! rankeados por fuzzy match. Cuando el user pica uno, el módulo emite +//! [`PaletteAction::Invoke`] con el `id` — el host hace match y +//! dispatcha lo que corresponda en su propio Msg. +//! +//! El módulo no sabe **qué** hacen los comandos. Eso es deliberado: +//! mantiene al palette agnóstico de la app, y permite que aplicaciones +//! muy distintas (un editor, un explorador de grafos, un viewer de +//! imágenes) lo enchufen con sus respectivas listas sin acoplarse. +//! +//! Sigue el contrato Llimphi de `docs/MODULES.md`: +//! `State + Msg + Action + apply/on_key/open_shortcut/view + Palette`. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View}; +use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState}; + +/// Capabilities que aporta este módulo al host. +pub const CAPABILITIES: &[&str] = &["editor.command-palette"]; + +/// Tope de resultados rankeados visibles. +pub const MAX_RESULTS: usize = 200; + +const BAR_H: f32 = 280.0; +const ROW_H: f32 = 22.0; +const MAX_VISIBLE: usize = 10; + +/// Una entrada del catálogo de comandos que el host arma. +/// +/// Los campos son convencionales: +/// - `id`: identificador opaco, único dentro del catálogo del host. +/// El host lo recibe en [`PaletteAction::Invoke`] y hace match a su +/// propio Msg. Por convención, formato `"namespace.action"` (ej. +/// `"editor.save"`, `"terminal.open"`). +/// - `title`: lo que el user lee. Idealmente en lengua de la app. +/// - `group`: categoría visible a la derecha de la fila (ej. `"Editor"`, +/// `"Terminal"`, `"LSP"`). Sirve para escanear visualmente. +/// - `shortcut`: hint textual del atajo nativo del comando, si existe +/// (ej. `"Ctrl+S"`). Sólo decorativo — el módulo no captura nada +/// distinto a Enter/Esc/↑↓. +#[derive(Debug, Clone)] +pub struct Command { + pub id: String, + pub title: String, + pub group: String, + pub shortcut: Option, +} + +impl Command { + pub fn new( + id: impl Into, + title: impl Into, + group: impl Into, + ) -> Self { + Self { id: id.into(), title: title.into(), group: group.into(), shortcut: None } + } + + pub fn with_shortcut(mut self, s: impl Into) -> Self { + self.shortcut = Some(s.into()); + self + } +} + +/// Estado interno. `results` son índices al slice de commands que pasa +/// el host: el módulo no copia, sólo guarda índices. +pub struct PaletteState { + pub input: TextInputState, + pub results: Vec, + pub selected: usize, +} + +impl Default for PaletteState { + fn default() -> Self { + Self::new_empty() + } +} + +impl PaletteState { + pub fn new_empty() -> Self { + Self { + input: TextInputState::new(), + results: Vec::new(), + selected: 0, + } + } + + /// Crea un palette pre-poblado con todos los comandos sin filtro, + /// listo para mostrar después del shortcut de apertura. + pub fn new(commands: &[Command]) -> Self { + let mut s = Self::new_empty(); + refilter(&mut s, commands); + s + } +} + +/// Vocabulario interno. El host lo wrapea en su Msg. +#[derive(Clone)] +pub enum PaletteMsg { + /// Símbolo conveniente para que el host dispatche al detectar el + /// shortcut. El módulo no construye el state él mismo — eso lo hace + /// el host con la lista canónica de commands. + Open, + Close, + KeyInput(KeyEvent), + Nav(i32), + /// Enter: invoca el comando seleccionado. + Apply, +} + +/// Efecto solicitado al host. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PaletteAction { + None, + /// El host debería remover el state del modelo. + Close, + /// El host debería ejecutar el comando con este `id`. El módulo NO + /// se cierra automáticamente — el host decide (típicamente sí, igual + /// que un menú). + Invoke(String), +} + +/// Aplica un mensaje al estado. +pub fn apply( + state: &mut PaletteState, + msg: PaletteMsg, + commands: &[Command], +) -> PaletteAction { + match msg { + PaletteMsg::Open => PaletteAction::None, + PaletteMsg::Close => PaletteAction::Close, + PaletteMsg::KeyInput(ev) => { + state.input.apply_key(&ev); + refilter(state, commands); + PaletteAction::None + } + PaletteMsg::Nav(d) => { + let n = state.results.len() as i32; + if n > 0 { + state.selected = (state.selected as i32 + d).rem_euclid(n) as usize; + } + PaletteAction::None + } + PaletteMsg::Apply => { + let Some(&cmd_idx) = state.results.get(state.selected) else { + return PaletteAction::None; + }; + let Some(cmd) = commands.get(cmd_idx) else { + return PaletteAction::None; + }; + PaletteAction::Invoke(cmd.id.clone()) + } + } +} + +/// Routing de teclas cuando el palette está abierto. +pub fn on_key(_state: &PaletteState, event: &KeyEvent) -> Option { + if event.state != KeyState::Pressed { + return None; + } + Some(match &event.key { + Key::Named(NamedKey::Escape) => PaletteMsg::Close, + Key::Named(NamedKey::Enter) => PaletteMsg::Apply, + Key::Named(NamedKey::ArrowDown) => PaletteMsg::Nav(1), + Key::Named(NamedKey::ArrowUp) => PaletteMsg::Nav(-1), + _ => PaletteMsg::KeyInput(event.clone()), + }) +} + +/// El atajo recomendado: **Ctrl+Shift+P**, igual que VS Code. +pub fn open_shortcut(event: &KeyEvent) -> bool { + event.state == KeyState::Pressed + && event.modifiers.ctrl + && event.modifiers.shift + && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("p")) +} + +/// Recalcula `state.results` según el query del input. Fuzzy match con +/// `nucleo-matcher` sobre `"title · group"` (mismo string para que el +/// usuario pueda buscar por grupo: "term" matchea "Open Terminal · Editor"). +/// Query vacío = lista completa ordenada como vino del host. +/// Cap: [`MAX_RESULTS`]. +pub fn refilter(state: &mut PaletteState, commands: &[Command]) { + let q = state.input.text(); + if q.trim().is_empty() { + state.results = (0..commands.len().min(MAX_RESULTS)).collect(); + state.selected = 0; + return; + } + use nucleo_matcher::{ + pattern::{CaseMatching, Normalization, Pattern}, + Config, Matcher, Utf32Str, + }; + let mut matcher = Matcher::new(Config::DEFAULT); + let pat = Pattern::parse(&q, CaseMatching::Smart, Normalization::Smart); + let mut scored: Vec<(u32, usize)> = Vec::new(); + let mut buf = Vec::new(); + for (i, cmd) in commands.iter().enumerate() { + let hay_str = format!("{} {}", cmd.title, cmd.group); + buf.clear(); + let hay = Utf32Str::new(&hay_str, &mut buf); + if let Some(score) = pat.score(hay, &mut matcher) { + scored.push((score, i)); + } + } + scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); + scored.truncate(MAX_RESULTS); + state.results = scored.into_iter().map(|(_, i)| i).collect(); + state.selected = 0; +} + +/// Paleta visual. +#[derive(Debug, Clone)] +pub struct PalettePalette { + pub bg_panel: Color, + pub bg_header: Color, + pub bg_selected: Color, + pub fg_text: Color, + pub fg_muted: Color, + theme: llimphi_theme::Theme, +} + +impl PalettePalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg_panel: t.bg_panel, + bg_header: t.bg_panel_alt, + bg_selected: t.bg_selected, + fg_text: t.fg_text, + fg_muted: t.fg_muted, + theme: t.clone(), + } + } +} + +/// Render del overlay. `to_host` mapea cada `PaletteMsg` interno al +/// `Msg` de la app. +pub fn view( + state: &PaletteState, + commands: &[Command], + palette: &PalettePalette, + to_host: F, +) -> View +where + HostMsg: Clone + 'static, + F: Fn(PaletteMsg) -> HostMsg + Copy + 'static, +{ + let header = if state.results.is_empty() { + format!("command palette · sin matches · {} comandos · Esc cierra", commands.len()) + } else { + format!( + "command palette · {} / {} · ↓↑ navega · Enter ejecuta · Esc cierra", + state.selected + 1, + state.results.len(), + ) + }; + + let header_view = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(18.0_f32) }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_header) + .text_aligned(header, 10.0, palette.fg_muted, Alignment::Start); + + let tp = TextInputPalette::from_theme(&palette.theme); + let input_view = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(26.0_f32) }, + padding: Rect { + left: length(6.0_f32), + right: length(6.0_f32), + top: length(2.0_f32), + bottom: length(2.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_panel) + .children(vec![text_input_view( + &state.input, + "filtro: nombre del comando…", + true, + &tp, + to_host(PaletteMsg::Open), + )]); + + let visible_start = state.selected.saturating_sub(MAX_VISIBLE.saturating_sub(1)); + let visible_end = (visible_start + MAX_VISIBLE).min(state.results.len()); + let mut rows: Vec> = Vec::with_capacity(MAX_VISIBLE); + for i in visible_start..visible_end { + let Some(&cmd_idx) = state.results.get(i) else { continue }; + let Some(cmd) = commands.get(cmd_idx) else { continue }; + let label = match (&cmd.shortcut, cmd.group.as_str()) { + (Some(sc), grp) if !grp.is_empty() => { + format!("{} {} [{sc}]", cmd.title, cmd.group) + } + (Some(sc), _) => format!("{} [{sc}]", cmd.title), + (None, grp) if !grp.is_empty() => format!("{} {}", cmd.title, cmd.group), + (None, _) => cmd.title.clone(), + }; + let selected = i == state.selected; + let bg = if selected { palette.bg_selected } else { palette.bg_panel }; + let fg = if selected { palette.fg_text } else { palette.fg_muted }; + rows.push( + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(ROW_H) }, + padding: Rect { + left: length(10.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(bg) + .text_aligned(label, 12.0, fg, Alignment::Start), + ); + } + + let mut children: Vec> = Vec::with_capacity(2 + rows.len()); + children.push(header_view); + children.push(input_view); + children.extend(rows); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: length(BAR_H) }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_panel) + .children(children) +} diff --git a/modules/command-palette/tests/smoke.rs b/modules/command-palette/tests/smoke.rs new file mode 100644 index 0000000..5a6a6cd --- /dev/null +++ b/modules/command-palette/tests/smoke.rs @@ -0,0 +1,125 @@ +//! Smoke tests del fuzzy match y del flujo `Open → KeyInput → Apply`. +//! No requieren backend gráfico — sólo el reducer puro y `refilter`. + +use llimphi_module_command_palette::{ + self as palette, Command, PaletteAction, PaletteMsg, PaletteState, +}; +use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers}; + +fn seed() -> Vec { + vec![ + Command::new("editor.save", "Save File", "Editor").with_shortcut("Ctrl+S"), + Command::new("editor.open", "Open File", "Editor").with_shortcut("Ctrl+P"), + Command::new("editor.findInFiles", "Find in Files", "Editor") + .with_shortcut("Ctrl+Shift+F"), + Command::new("terminal.open", "Open Terminal", "Terminal") + .with_shortcut("Ctrl+`"), + Command::new("lsp.format", "Format Document", "LSP") + .with_shortcut("Ctrl+Alt+L"), + Command::new("lsp.goto", "Go to Definition", "LSP").with_shortcut("F12"), + ] +} + +fn key_char(c: &str) -> KeyEvent { + KeyEvent { + key: Key::Character(c.into()), + state: KeyState::Pressed, + text: Some(c.into()), + modifiers: Modifiers::default(), + repeat: false, + } +} + +#[test] +fn estado_vacio_lista_todos_los_comandos() { + let cmds = seed(); + let s = PaletteState::new(&cmds); + assert_eq!(s.results.len(), cmds.len()); + assert_eq!(s.selected, 0); +} + +#[test] +fn fuzzy_match_acerca_el_comando_correcto_al_top() { + let cmds = seed(); + let mut s = PaletteState::new(&cmds); + + // Tipear "term" debería rankear "Open Terminal" o "Terminal" arriba. + for ch in ["t", "e", "r", "m"] { + let action = palette::apply(&mut s, PaletteMsg::KeyInput(key_char(ch)), &cmds); + assert_eq!(action, PaletteAction::None); + } + let top = s.results.first().expect("debe haber al menos un match"); + assert_eq!( + cmds[*top].id, "terminal.open", + "esperaba terminal.open al top, vi {:?}", + cmds[*top].title + ); +} + +#[test] +fn enter_emite_invoke_con_el_id_seleccionado() { + let cmds = seed(); + let mut s = PaletteState::new(&cmds); + + for ch in ["s", "a", "v"] { + palette::apply(&mut s, PaletteMsg::KeyInput(key_char(ch)), &cmds); + } + let action = palette::apply(&mut s, PaletteMsg::Apply, &cmds); + assert_eq!(action, PaletteAction::Invoke("editor.save".into())); +} + +#[test] +fn nav_circula_por_los_resultados() { + let cmds = seed(); + let mut s = PaletteState::new(&cmds); + assert_eq!(s.selected, 0); + + palette::apply(&mut s, PaletteMsg::Nav(1), &cmds); + assert_eq!(s.selected, 1); + + // Saltar al final desde la cima con -1 (wrap-around). + let mut s = PaletteState::new(&cmds); + palette::apply(&mut s, PaletteMsg::Nav(-1), &cmds); + assert_eq!(s.selected, cmds.len() - 1); +} + +#[test] +fn escape_emite_close() { + let cmds = seed(); + let mut s = PaletteState::new(&cmds); + let action = palette::apply(&mut s, PaletteMsg::Close, &cmds); + assert_eq!(action, PaletteAction::Close); +} + +#[test] +fn open_shortcut_es_ctrl_shift_p() { + use llimphi_ui::Modifiers; + let mk = |ctrl: bool, shift: bool, c: &str| KeyEvent { + key: Key::Character(c.into()), + state: KeyState::Pressed, + text: Some(c.into()), + modifiers: Modifiers { ctrl, shift, ..Modifiers::default() }, + repeat: false, + }; + assert!(palette::open_shortcut(&mk(true, true, "p"))); + assert!(palette::open_shortcut(&mk(true, true, "P"))); + // Sin shift no — ese es Ctrl+P del file-picker. + assert!(!palette::open_shortcut(&mk(true, false, "p"))); + // Sin ctrl no. + assert!(!palette::open_shortcut(&mk(false, true, "p"))); + // Otra letra no. + assert!(!palette::open_shortcut(&mk(true, true, "q"))); +} + +#[test] +fn busqueda_por_grupo_funciona() { + let cmds = seed(); + let mut s = PaletteState::new(&cmds); + // "lsp" debería traer Format y Goto Definition (ambos del grupo LSP). + for ch in ["l", "s", "p"] { + palette::apply(&mut s, PaletteMsg::KeyInput(key_char(ch)), &cmds); + } + let ids: Vec<&str> = s.results.iter().map(|&i| cmds[i].id.as_str()).collect(); + assert!(ids.contains(&"lsp.format"), "esperaba lsp.format en {ids:?}"); + assert!(ids.contains(&"lsp.goto"), "esperaba lsp.goto en {ids:?}"); +} diff --git a/modules/diff-viewer/Cargo.toml b/modules/diff-viewer/Cargo.toml new file mode 100644 index 0000000..4fba92d --- /dev/null +++ b/modules/diff-viewer/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-module-diff-viewer" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-module-diff-viewer — visualización side-by-side de cambios entre dos textos. Módulo Llimphi: el host provee before/after (typically HEAD vs working tree, o snapshot vs current buffer), el módulo computa el diff con `similar` y lo presenta en dos columnas con marcadores +/- y números de línea." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +similar = { workspace = true } diff --git a/modules/diff-viewer/LEEME.md b/modules/diff-viewer/LEEME.md new file mode 100644 index 0000000..b295b14 --- /dev/null +++ b/modules/diff-viewer/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-module-diff-viewer + +> Diff side-by-side de [llimphi](../../README.md). + +Toma dos textos y muestra diff por línea: inserciones, eliminaciones, modificaciones. Algoritmo Myers; resaltado intra-línea opcional. diff --git a/modules/diff-viewer/README.md b/modules/diff-viewer/README.md new file mode 100644 index 0000000..a082f50 --- /dev/null +++ b/modules/diff-viewer/README.md @@ -0,0 +1,5 @@ +# llimphi-module-diff-viewer + +> Side-by-side diff of [llimphi](../../README.md). + +Takes two texts and shows line-by-line diff: insertions, deletions, modifications. Myers algorithm; optional intra-line highlight. diff --git a/modules/diff-viewer/src/lib.rs b/modules/diff-viewer/src/lib.rs new file mode 100644 index 0000000..3d96f03 --- /dev/null +++ b/modules/diff-viewer/src/lib.rs @@ -0,0 +1,398 @@ +//! `llimphi-module-diff-viewer` — visualización side-by-side de cambios. +//! +//! Equivalente al "Compare with Saved" de VS Code o el panel "Compare" +//! de JetBrains, pero como módulo Llimphi enchufable. El host le pasa +//! dos textos (`before`/`after`) y dos etiquetas (`"HEAD"`, `"Working +//! Tree"`, `"Buffer"` — lo que tenga sentido en su contexto), y el +//! módulo computa el diff line-based con [`similar`] y lo renderiza +//! en dos columnas con marcadores `+`/`-` y números de línea. +//! +//! El módulo no abre archivos, no llama a `git`, no toca disco. Toda +//! la fuente del diff la decide el host: puede comparar el disco vs +//! el buffer dirty, dos branches, dos snapshots de history, etc. +//! +//! Sigue el contrato Llimphi de `docs/MODULES.md`: +//! `State + Msg + Action + apply/on_key/open_shortcut/view + Palette`. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View}; +use similar::{ChangeTag, TextDiff}; + +/// Capabilities que aporta este módulo al host. +pub const CAPABILITIES: &[&str] = &["editor.diff-viewer"]; + +const HEADER_H: f32 = 18.0; +const ROW_H: f32 = 15.0; + +/// Una línea del diff alineada para render side-by-side. +/// +/// El render usa dos celdas por fila (izquierda = `before`, derecha = +/// `after`). En una línea `Equal`, ambas celdas tienen el mismo +/// contenido. En `Delete`, sólo la izquierda; en `Insert`, sólo la +/// derecha. La struct cumple las dos roles para simplificar el render. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiffRow { + pub kind: DiffKind, + /// Contenido de la celda izquierda (Equal o Delete) o vacío. + pub left: Option, + /// Contenido de la celda derecha (Equal o Insert) o vacío. + pub right: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiffCell { + /// Número de línea 1-based en el lado correspondiente. + pub line_no: usize, + pub text: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiffKind { + Equal, + Delete, + Insert, +} + +/// Estado del panel. +pub struct DiffState { + pub before_label: String, + pub after_label: String, + pub rows: Vec, + pub scroll: usize, + /// Conteo agregado para mostrar en el header (`+12 / -3` etc.). + pub stats: DiffStats, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct DiffStats { + pub inserts: usize, + pub deletes: usize, + pub equals: usize, +} + +impl DiffState { + /// Construye el state computando el diff entre `before` y `after`. + /// Líneas se separan por '\n'; el último '\n' se conserva como + /// separador (no aparece como línea extra vacía). + pub fn new( + before_label: impl Into, + after_label: impl Into, + before: &str, + after: &str, + ) -> Self { + let (rows, stats) = compute_rows(before, after); + Self { + before_label: before_label.into(), + after_label: after_label.into(), + rows, + scroll: 0, + stats, + } + } +} + +/// Computa las filas alineadas a partir de los dos textos. La salida +/// preserva el orden lineal del archivo: bloques `Equal` mantienen las +/// líneas pareadas; un `Delete` que no tiene contraparte en el otro +/// lado aparece con `right = None`, y viceversa para `Insert`. No se +/// emparejan visualmente delete con insert — siguen la convención de +/// VS Code, que los muestra como líneas separadas. +pub fn compute_rows(before: &str, after: &str) -> (Vec, DiffStats) { + let diff = TextDiff::from_lines(before, after); + let mut rows: Vec = Vec::new(); + let mut stats = DiffStats::default(); + let mut left_no = 0usize; + let mut right_no = 0usize; + for change in diff.iter_all_changes() { + let text = change.value().trim_end_matches('\n').to_string(); + match change.tag() { + ChangeTag::Equal => { + left_no += 1; + right_no += 1; + stats.equals += 1; + rows.push(DiffRow { + kind: DiffKind::Equal, + left: Some(DiffCell { line_no: left_no, text: text.clone() }), + right: Some(DiffCell { line_no: right_no, text }), + }); + } + ChangeTag::Delete => { + left_no += 1; + stats.deletes += 1; + rows.push(DiffRow { + kind: DiffKind::Delete, + left: Some(DiffCell { line_no: left_no, text }), + right: None, + }); + } + ChangeTag::Insert => { + right_no += 1; + stats.inserts += 1; + rows.push(DiffRow { + kind: DiffKind::Insert, + left: None, + right: Some(DiffCell { line_no: right_no, text }), + }); + } + } + } + (rows, stats) +} + +/// Vocabulario interno. El host lo wrapea en su Msg. +#[derive(Clone)] +pub enum DiffMsg { + Open, + Close, + /// Scroll vertical en líneas (positivo = baja). + Scroll(i32), + /// Salta al próximo hunk (∆+/-) en dirección. + NextHunk, + PrevHunk, +} + +/// Efecto solicitado al host. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DiffAction { + None, + /// El host debería remover el state del modelo. + Close, +} + +pub fn apply(state: &mut DiffState, msg: DiffMsg, visible_rows: usize) -> DiffAction { + match msg { + DiffMsg::Open => DiffAction::None, + DiffMsg::Close => DiffAction::Close, + DiffMsg::Scroll(delta) => { + scroll_by(state, delta, visible_rows); + DiffAction::None + } + DiffMsg::NextHunk => { + jump_to_hunk(state, true, visible_rows); + DiffAction::None + } + DiffMsg::PrevHunk => { + jump_to_hunk(state, false, visible_rows); + DiffAction::None + } + } +} + +fn scroll_by(state: &mut DiffState, delta: i32, visible_rows: usize) { + let max_scroll = state.rows.len().saturating_sub(visible_rows); + let new_scroll = (state.scroll as i64 + delta as i64).max(0) as usize; + state.scroll = new_scroll.min(max_scroll); +} + +/// Busca la próxima fila con `kind != Equal` en la dirección dada, +/// empezando justo después/antes del scroll actual. Si no hay más, +/// no-op. +fn jump_to_hunk(state: &mut DiffState, forward: bool, visible_rows: usize) { + let start = state.scroll; + let n = state.rows.len(); + let found = if forward { + (start + 1..n).find(|&i| !matches!(state.rows[i].kind, DiffKind::Equal)) + } else { + (0..start.min(n)).rev().find(|&i| !matches!(state.rows[i].kind, DiffKind::Equal)) + }; + if let Some(i) = found { + let max_scroll = n.saturating_sub(visible_rows); + state.scroll = i.min(max_scroll); + } +} + +/// Routing de teclas cuando el panel está abierto. +pub fn on_key(_state: &DiffState, event: &KeyEvent) -> Option { + if event.state != KeyState::Pressed { + return None; + } + Some(match &event.key { + Key::Named(NamedKey::Escape) => DiffMsg::Close, + Key::Named(NamedKey::ArrowDown) => DiffMsg::Scroll(1), + Key::Named(NamedKey::ArrowUp) => DiffMsg::Scroll(-1), + Key::Named(NamedKey::PageDown) => DiffMsg::Scroll(20), + Key::Named(NamedKey::PageUp) => DiffMsg::Scroll(-20), + Key::Named(NamedKey::Home) => DiffMsg::Scroll(-(i32::MAX / 4)), + Key::Named(NamedKey::End) => DiffMsg::Scroll(i32::MAX / 4), + Key::Character(s) if s == "n" => DiffMsg::NextHunk, + Key::Character(s) if s == "N" => DiffMsg::PrevHunk, + _ => return None, + }) +} + +/// El atajo recomendado: **Ctrl+Shift+D**, similar al "Compare with +/// Saved" de VS Code (que usa Ctrl+Shift+P + comando). +pub fn open_shortcut(event: &KeyEvent) -> bool { + event.state == KeyState::Pressed + && event.modifiers.ctrl + && event.modifiers.shift + && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("d")) +} + +/// Paleta visual con colores diff convencionales (verde para insert, +/// rojo apagado para delete). +#[derive(Debug, Clone)] +pub struct DiffPalette { + pub bg_panel: Color, + pub bg_header: Color, + pub bg_insert: Color, + pub bg_delete: Color, + pub bg_empty: Color, + pub fg_text: Color, + pub fg_muted: Color, + pub fg_insert: Color, + pub fg_delete: Color, +} + +impl DiffPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + // Verde/rojo apagados — visibles sobre fondo oscuro pero sin + // saturar. Si el theme expone colores semánticos de diff en + // el futuro, los usamos; por ahora hardcoded. + Self { + bg_panel: t.bg_panel, + bg_header: t.bg_panel_alt, + bg_insert: Color::from_rgba8(40, 80, 50, 255), + bg_delete: Color::from_rgba8(90, 40, 45, 255), + bg_empty: t.bg_panel_alt, + fg_text: t.fg_text, + fg_muted: t.fg_muted, + fg_insert: Color::from_rgba8(170, 230, 180, 255), + fg_delete: Color::from_rgba8(240, 180, 185, 255), + } + } +} + +/// Render del panel side-by-side. `height_px` es la altura total +/// disponible; el módulo divide entre el header de 18 px y la grid. +pub fn view( + state: &DiffState, + palette: &DiffPalette, + height_px: f32, + to_host: F, +) -> View +where + HostMsg: Clone + 'static, + F: Fn(DiffMsg) -> HostMsg + Copy + 'static, +{ + let _ = to_host; // v0 no monta eventos puntuales sobre filas + + let header_text = format!( + "diff · {} ↔ {} · +{} -{} ={} · ↑↓ scroll · n/N hunk · Esc cierra", + state.before_label, + state.after_label, + state.stats.inserts, + state.stats.deletes, + state.stats.equals, + ); + let header = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(HEADER_H) }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_header) + .text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start); + + let grid_h = (height_px - HEADER_H).max(0.0); + let max_rows = ((grid_h / ROW_H) as usize).max(1); + let end = (state.scroll + max_rows).min(state.rows.len()); + + let mut grid_rows: Vec> = Vec::with_capacity(max_rows); + for row in &state.rows[state.scroll..end] { + grid_rows.push(render_row(row, palette)); + } + while grid_rows.len() < max_rows { + // Padding visual para mantener altura constante. + grid_rows.push(empty_row(palette)); + } + + let mut children: Vec> = Vec::with_capacity(1 + grid_rows.len()); + children.push(header); + children.extend(grid_rows); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: length(height_px) }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_panel) + .children(children) +} + +fn render_row(row: &DiffRow, palette: &DiffPalette) -> View +where + HostMsg: Clone + 'static, +{ + let (left_bg, left_fg, left_mark) = match row.kind { + DiffKind::Equal => (palette.bg_panel, palette.fg_text, " "), + DiffKind::Delete => (palette.bg_delete, palette.fg_delete, "-"), + DiffKind::Insert => (palette.bg_empty, palette.fg_muted, " "), + }; + let (right_bg, right_fg, right_mark) = match row.kind { + DiffKind::Equal => (palette.bg_panel, palette.fg_text, " "), + DiffKind::Insert => (palette.bg_insert, palette.fg_insert, "+"), + DiffKind::Delete => (palette.bg_empty, palette.fg_muted, " "), + }; + + let left_text = match &row.left { + Some(c) => format!("{:>4} {}{}", c.line_no, left_mark, c.text), + None => String::new(), + }; + let right_text = match &row.right { + Some(c) => format!("{:>4} {}{}", c.line_no, right_mark, c.text), + None => String::new(), + }; + + let cell = |bg: Color, fg: Color, text: String| { + View::new(Style { + flex_grow: 1.0, + size: Size { width: percent(0.5_f32), height: length(ROW_H) }, + padding: Rect { + left: length(6.0_f32), + right: length(6.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(bg) + .text_aligned(text, 10.5, fg, Alignment::Start) + }; + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(ROW_H) }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![cell(left_bg, left_fg, left_text), cell(right_bg, right_fg, right_text)]) +} + +fn empty_row(palette: &DiffPalette) -> View +where + HostMsg: Clone + 'static, +{ + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(ROW_H) }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_panel) +} diff --git a/modules/diff-viewer/tests/smoke.rs b/modules/diff-viewer/tests/smoke.rs new file mode 100644 index 0000000..bd0dbc8 --- /dev/null +++ b/modules/diff-viewer/tests/smoke.rs @@ -0,0 +1,155 @@ +//! Smoke tests del cómputo de filas y el routing de teclas. Sin +//! backend gráfico — pruebas puras sobre `compute_rows` y `apply`. + +use llimphi_module_diff_viewer::{ + self as diff, DiffAction, DiffKind, DiffMsg, DiffState, +}; + +#[test] +fn diff_basico_inserts_y_deletes() { + let before = "a\nb\nc\n"; + let after = "a\nB\nc\nd\n"; + let (rows, stats) = diff::compute_rows(before, after); + + // El diff esperado: + // = a / a + // - b + // + B + // = c / c + // + d + assert_eq!(stats.equals, 2); + assert_eq!(stats.deletes, 1); + assert_eq!(stats.inserts, 2); + + assert_eq!(rows[0].kind, DiffKind::Equal); + assert_eq!(rows[0].left.as_ref().unwrap().text, "a"); + assert_eq!(rows[0].right.as_ref().unwrap().text, "a"); + + // El primer cambio debe ser un Delete o Insert (similar agrupa); + // verificamos que B aparezca y b no. + let texts_left: Vec<&str> = rows + .iter() + .filter_map(|r| r.left.as_ref().map(|c| c.text.as_str())) + .collect(); + let texts_right: Vec<&str> = rows + .iter() + .filter_map(|r| r.right.as_ref().map(|c| c.text.as_str())) + .collect(); + assert!(texts_left.contains(&"b")); + assert!(texts_right.contains(&"B")); + assert!(texts_right.contains(&"d")); +} + +#[test] +fn numeros_de_linea_son_correctos() { + let before = "alpha\nbeta\ngamma\n"; + let after = "alpha\nBETA\ngamma\ndelta\n"; + let (rows, _) = diff::compute_rows(before, after); + + // alpha en línea 1 de ambos. + let alpha_row = rows.iter().find(|r| { + r.left.as_ref().map(|c| c.text == "alpha").unwrap_or(false) + }).unwrap(); + assert_eq!(alpha_row.left.as_ref().unwrap().line_no, 1); + assert_eq!(alpha_row.right.as_ref().unwrap().line_no, 1); + + // beta (delete) en línea 2 izquierda. + let beta_row = rows.iter().find(|r| { + r.left.as_ref().map(|c| c.text == "beta").unwrap_or(false) + }).unwrap(); + assert_eq!(beta_row.left.as_ref().unwrap().line_no, 2); + assert!(beta_row.right.is_none()); + + // delta (insert) en línea 4 derecha. + let delta_row = rows.iter().find(|r| { + r.right.as_ref().map(|c| c.text == "delta").unwrap_or(false) + }).unwrap(); + assert_eq!(delta_row.right.as_ref().unwrap().line_no, 4); + assert!(delta_row.left.is_none()); +} + +#[test] +fn textos_identicos_solo_equal() { + let text = "uno\ndos\ntres\n"; + let (rows, stats) = diff::compute_rows(text, text); + assert_eq!(rows.len(), 3); + assert!(rows.iter().all(|r| r.kind == DiffKind::Equal)); + assert_eq!(stats.inserts, 0); + assert_eq!(stats.deletes, 0); + assert_eq!(stats.equals, 3); +} + +#[test] +fn scroll_no_excede_los_limites() { + let before = (0..50).map(|i| i.to_string()).collect::>().join("\n"); + let after = before.clone(); // identical → 50 Equal rows + let mut state = DiffState::new("a", "b", &before, &after); + assert_eq!(state.scroll, 0); + + // Scroll grande hacia abajo: tope = 50 - visible_rows. + diff::apply(&mut state, DiffMsg::Scroll(1000), 10); + assert_eq!(state.scroll, 40); + + // Scroll arriba: tope mínimo 0. + diff::apply(&mut state, DiffMsg::Scroll(-1000), 10); + assert_eq!(state.scroll, 0); +} + +#[test] +fn next_hunk_salta_a_la_proxima_diferencia() { + // 20 líneas iguales + 2 cambios + 20 más. visible_rows=5 deja + // espacio real para scrollear. + let mut before = String::new(); + let mut after = String::new(); + for i in 0..20 { + before.push_str(&format!("eq{i}\n")); + after.push_str(&format!("eq{i}\n")); + } + before.push_str("DEL\n"); + after.push_str("INS\n"); + for i in 20..40 { + before.push_str(&format!("eq{i}\n")); + after.push_str(&format!("eq{i}\n")); + } + let mut state = DiffState::new("a", "b", &before, &after); + assert_eq!(state.scroll, 0); + + diff::apply(&mut state, DiffMsg::NextHunk, 5); + assert!(state.scroll > 0, "scroll quedó en 0 — no saltó al hunk"); + let row = &state.rows[state.scroll]; + assert!( + !matches!(row.kind, DiffKind::Equal), + "esperaba aterrizar en un hunk, vi {:?}", + row.kind + ); + + // PrevHunk: vuelve al inicio (no hay hunk antes del primer cambio). + diff::apply(&mut state, DiffMsg::PrevHunk, 5); + // Puede quedarse en el mismo hunk si era el único accesible hacia + // atrás, o saltar más arriba. Lo único que verificamos es que no + // hubo panic ni scroll fuera de rango. + assert!(state.scroll < state.rows.len()); +} + +#[test] +fn escape_cierra() { + let mut state = DiffState::new("a", "b", "x\n", "y\n"); + let action = diff::apply(&mut state, DiffMsg::Close, 10); + assert_eq!(action, DiffAction::Close); +} + +#[test] +fn open_shortcut_es_ctrl_shift_d() { + use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers}; + let mk = |ctrl: bool, shift: bool, c: &str| KeyEvent { + key: Key::Character(c.into()), + state: KeyState::Pressed, + text: Some(c.into()), + modifiers: Modifiers { ctrl, shift, ..Modifiers::default() }, + repeat: false, + }; + assert!(diff::open_shortcut(&mk(true, true, "d"))); + assert!(diff::open_shortcut(&mk(true, true, "D"))); + assert!(!diff::open_shortcut(&mk(true, false, "d"))); + assert!(!diff::open_shortcut(&mk(false, true, "d"))); +} diff --git a/modules/fif/Cargo.toml b/modules/fif/Cargo.toml new file mode 100644 index 0000000..c8de60f --- /dev/null +++ b/modules/fif/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-module-fif" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-module-fif — find-in-files reusable (estilo JetBrains). Módulo Llimphi: state + Msg + Action + apply/on_key/view. Cualquier app que mantenga una lista de paths puede enchufarlo." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-text-input = { workspace = true } diff --git a/modules/fif/LEEME.md b/modules/fif/LEEME.md new file mode 100644 index 0000000..4c6fe9a --- /dev/null +++ b/modules/fif/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-module-fif + +> Find-in-files de [llimphi](../../README.md). + +Buscar en todos los archivos del workspace con regex + glob de filenames. Streaming de resultados (no espera al fin del scan). Click en resultado abre el archivo en la línea. diff --git a/modules/fif/README.md b/modules/fif/README.md new file mode 100644 index 0000000..fa072a4 --- /dev/null +++ b/modules/fif/README.md @@ -0,0 +1,5 @@ +# llimphi-module-fif + +> Find-in-files of [llimphi](../../README.md). + +Search across all workspace files with regex + filename glob. Streamed results (doesn't wait for scan end). Click on result opens the file at the line. diff --git a/modules/fif/src/lib.rs b/modules/fif/src/lib.rs new file mode 100644 index 0000000..35165aa --- /dev/null +++ b/modules/fif/src/lib.rs @@ -0,0 +1,815 @@ +//! `llimphi-module-fif` — find-in-files reutilizable (estilo JetBrains). +//! +//! Módulo Llimphi con dos vistas independientes: +//! +//! - [`view_dialog`] — popup compacto (header + input) que el host pinta +//! como overlay modal centrado. Sólo visible cuando +//! [`FifState::dialog_open`] es `true`. +//! - [`view_results_bar`] — barra inferior persistente con la lista de +//! matches. El host la pinta como tool window al pie (estilo JetBrains +//! "Find" tool window). Sobrevive al cierre del dialog: el user puede +//! Esc-cerrar el popup y seguir clickeando los resultados. +//! +//! El flujo típico es: `Ctrl+Shift+F` abre el dialog → tipear → Enter +//! ejecuta `search` → resultados aparecen en la barra inferior → Esc +//! cierra el popup pero la barra queda → click en una fila abre el +//! archivo. Re-disparar `Ctrl+Shift+F` reabre el popup conservando los +//! últimos resultados. +//! +//! ## Cómo lo enchufa una app +//! +//! ```ignore +//! struct AppModel { +//! all_files: Vec, +//! fif: Option, +//! // … +//! } +//! +//! enum AppMsg { Fif(llimphi_module_fif::FifMsg), … } +//! +//! // En update(model, msg): +//! AppMsg::Fif(fm) => { +//! // Lazy-init en Open: +//! if matches!(fm, FifMsg::Open) && model.fif.is_none() { +//! model.fif = Some(FifState::new()); +//! } else if matches!(fm, FifMsg::Open) { +//! model.fif.as_mut().unwrap().dialog_open = true; +//! } +//! let action = match model.fif.as_mut() { +//! Some(s) => llimphi_module_fif::apply(s, fm, &model.all_files), +//! None => FifAction::None, +//! }; +//! match action { +//! FifAction::None => {} +//! FifAction::CloseDialog => { +//! if let Some(s) = model.fif.as_mut() { s.dialog_open = false; } +//! } +//! FifAction::CloseAll => model.fif = None, +//! FifAction::Searched { .. } => { /* actualizar status bar */ } +//! FifAction::OpenAt { path, line, col } => { +//! if let Some(s) = model.fif.as_mut() { s.dialog_open = false; } +//! open_path_in_app(path, line, col); +//! } +//! } +//! } +//! +//! // En on_key(model, event): solo rutea cuando el dialog está visible. +//! if let Some(state) = model.fif.as_ref() { +//! if let Some(fm) = llimphi_module_fif::on_key(state, event) { +//! return Some(AppMsg::Fif(fm)); +//! } +//! } +//! if llimphi_module_fif::open_shortcut(event) { +//! return Some(AppMsg::Fif(FifMsg::Open)); +//! } +//! +//! // En view(model): +//! // - dialog como overlay arriba del editor: +//! if let Some(s) = model.fif.as_ref().filter(|s| s.dialog_open) { +//! overlay_children.push(view_dialog(s, &palette, AppMsg::Fif)); +//! } +//! // - barra de resultados como panel inferior persistente: +//! if let Some(s) = model.fif.as_ref().filter(|s| !s.results.is_empty()) { +//! bottom_panels.push(view_results_bar( +//! s, &model.all_files, &model.root, &palette, AppMsg::Fif, +//! )); +//! } +//! ``` +//! +//! ## Por qué Action en lugar de un trait `FifHost` +//! +//! El módulo no toma `&mut Host` porque acoplar el módulo a un trait +//! arrastra problemas de ownership/lifetimes en el loop tipo Elm que usa +//! Llimphi (Model se mueve por value en update). Devolver una [`FifAction`] +//! deja al host libre de aplicar el efecto donde y como quiera, y mantiene +//! al módulo libre de cualquier conocimiento sobre el host. + +#![forbid(unsafe_code)] + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View}; +use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState}; + +/// Capabilities que este módulo aporta al host. Convención del protocolo +/// Brahman Card aplicada a módulos compile-time: el host (cuando construye +/// su [`card_core::Card`]) puede agregar esto a `provides` para anunciar +/// — vía broker — que su instancia ofrece find-in-files al ecosistema. +pub const CAPABILITIES: &[&str] = &["editor.find-in-files"]; + +/// Caps razonables para que un workspace grande no funda el UI. +pub const MAX_RESULTS: usize = 1000; +pub const MAX_FILE_SIZE: u64 = 2_000_000; +pub const SNIPPET_MAX_CHARS: usize = 160; +pub const MIN_QUERY_LEN: usize = 2; + +const DIALOG_W: f32 = 560.0; +const DIALOG_H: f32 = 116.0; +const BAR_H: f32 = 220.0; +const ROW_H: f32 = 20.0; +const MAX_VISIBLE: usize = 9; + +/// Qué input tiene el foco dentro del dialog. `Tab` alterna. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FifFocus { + Search, + Replace, +} + +/// Un match individual. +#[derive(Debug, Clone)] +pub struct FifMatch { + /// Índice dentro del slice de paths que el host pasa a [`apply`] y + /// las vistas. Convención: el host no debe reordenar/mutar el slice + /// entre frames mientras el módulo esté abierto. + pub file_idx: usize, + /// 0-based. + pub line: usize, + /// 0-based, en chars (no bytes). + pub col: usize, + /// Línea matcheada trimmed-left y truncada a [`SNIPPET_MAX_CHARS`]. + pub snippet: String, +} + +/// Estado interno del módulo. +pub struct FifState { + pub input: TextInputState, + /// Texto de reemplazo. Si vacío, `ReplaceAll` borra los matches. + pub replace: TextInputState, + pub focus: FifFocus, + pub results: Vec, + pub selected: usize, + /// Última query realmente ejecutada (puede diferir del input si el + /// user siguió tipeando sin re-Enter). + pub last_query: String, + /// `true` cuando el popup modal está visible. La barra de resultados + /// se pinta independientemente de esto: sobrevive al cierre del popup. + pub dialog_open: bool, +} + +impl Default for FifState { + fn default() -> Self { + Self::new() + } +} + +impl FifState { + pub fn new() -> Self { + Self { + input: TextInputState::new(), + replace: TextInputState::new(), + focus: FifFocus::Search, + results: Vec::new(), + selected: 0, + last_query: String::new(), + dialog_open: true, + } + } +} + +/// Vocabulario interno. El host lo wrapea en su propio Msg. +#[derive(Clone)] +pub enum FifMsg { + /// El host detectó el atajo de apertura (o un comando). Lazy-init del + /// state lo hace el host; `apply` sólo marca `dialog_open = true`. + Open, + /// El user pidió cerrar el popup (Esc). Los resultados quedan en la + /// barra inferior. + CloseDialog, + /// Cerrar todo: el host debería tirar el `FifState` completo. + CloseAll, + /// Tecla rumbo al input. + KeyInput(KeyEvent), + /// Navegación dentro de la lista de resultados. + Nav(i32), + /// Enter: la primera vez ejecuta search; subsiguientes abren el + /// match seleccionado. + Submit, + /// Click en una fila de la barra inferior: selecciona y abre. + ActivateAt(usize), + /// Alterna el foco entre los inputs search ↔ replace (Tab). + ToggleFocus, + /// Reemplaza el texto matcheado por `replace.text()` en todos los + /// matches actuales. Idempotente: re-leer el archivo, sustituir + /// case-insensitive por la query, escribir. Vacía `results` para + /// forzar nueva búsqueda si el user quiere ver el estado posterior. + ReplaceAll, +} + +/// Efecto solicitado al host. El módulo nunca toca el FS ni el resto del +/// modelo de la app — devuelve el deseo, el host elige cómo lo aplica. +#[derive(Debug, Clone)] +pub enum FifAction { + None, + /// El host debería marcar `state.dialog_open = false` y dejar el + /// resto del state intacto (resultados visibles en la barra). + CloseDialog, + /// El host debería remover el state del modelo entero. + CloseAll, + /// Tras un Submit que ejecutó search. + Searched { matches: usize, elapsed: Duration, query: String }, + /// El host debería abrir `path` y posicionar el caret en `(line, col)`. + /// El módulo NO se cierra automáticamente: el host decide si ocultar + /// el dialog tras abrir el match. + OpenAt { path: PathBuf, line: usize, col: usize }, + /// Tras `ReplaceAll`: cuántos archivos tocados, cuántos matches + /// sustituidos, cuántos fallaron. El host debería refrescar buffers + /// abiertos (recargar de disco si no-dirty) y mostrar status. + Replaced { + files_changed: usize, + replacements: usize, + failures: usize, + query: String, + replacement: String, + }, +} + +/// Aplica un mensaje al estado y retorna el efecto que el host debe ejecutar. +/// +/// `paths` es la lista canónica de archivos sobre la que buscar. El host +/// la pasa por referencia; cuando Submit dispara una búsqueda, este +/// vector se itera y se leen los archivos (skip binarios y >MAX_FILE_SIZE). +pub fn apply(state: &mut FifState, msg: FifMsg, paths: &[PathBuf]) -> FifAction { + match msg { + FifMsg::Open => { + state.dialog_open = true; + FifAction::None + } + FifMsg::CloseDialog => FifAction::CloseDialog, + FifMsg::CloseAll => FifAction::CloseAll, + FifMsg::KeyInput(ev) => { + let _ = match state.focus { + FifFocus::Search => state.input.apply_key(&ev), + FifFocus::Replace => state.replace.apply_key(&ev), + }; + FifAction::None + } + FifMsg::ToggleFocus => { + state.focus = match state.focus { + FifFocus::Search => FifFocus::Replace, + FifFocus::Replace => FifFocus::Search, + }; + FifAction::None + } + FifMsg::ReplaceAll => { + let query = state.last_query.clone(); + if query.is_empty() || state.results.is_empty() { + return FifAction::None; + } + let replacement = state.replace.text(); + let (files_changed, replacements, failures) = + replace_all(paths, &state.results, &query, &replacement); + // Invalidamos resultados: las posiciones (line, col) ya no + // necesariamente apuntan al mismo texto. El user puede re-Enter. + state.results.clear(); + state.selected = 0; + FifAction::Replaced { + files_changed, + replacements, + failures, + query, + replacement, + } + } + FifMsg::Nav(d) => { + let n = state.results.len() as i32; + if n > 0 { + state.selected = (state.selected as i32 + d).rem_euclid(n) as usize; + } + FifAction::None + } + FifMsg::Submit => { + let query = state.input.text(); + let needs_search = query != state.last_query || state.results.is_empty(); + if needs_search { + if query.len() < MIN_QUERY_LEN { + return FifAction::None; + } + let started = std::time::Instant::now(); + let results = search(paths, &query); + let elapsed = started.elapsed(); + let n = results.len(); + state.results = results; + state.selected = 0; + state.last_query = query.clone(); + FifAction::Searched { matches: n, elapsed, query } + } else { + let Some(fm) = state.results.get(state.selected).cloned() else { + return FifAction::None; + }; + let Some(path) = paths.get(fm.file_idx).cloned() else { + return FifAction::None; + }; + FifAction::OpenAt { path, line: fm.line, col: fm.col } + } + } + FifMsg::ActivateAt(idx) => { + if idx >= state.results.len() { + return FifAction::None; + } + state.selected = idx; + let fm = state.results[idx].clone(); + let Some(path) = paths.get(fm.file_idx).cloned() else { + return FifAction::None; + }; + FifAction::OpenAt { path, line: fm.line, col: fm.col } + } + } +} + +/// Routing de teclas cuando el dialog está abierto. Si el popup está +/// cerrado, devuelve `None` y el host puede seguir routeando al editor. +pub fn on_key(state: &FifState, event: &KeyEvent) -> Option { + if !state.dialog_open { + return None; + } + if event.state != KeyState::Pressed { + return None; + } + Some(match &event.key { + Key::Named(NamedKey::Escape) => FifMsg::CloseDialog, + Key::Named(NamedKey::Enter) => FifMsg::Submit, + Key::Named(NamedKey::Tab) => FifMsg::ToggleFocus, + Key::Named(NamedKey::ArrowDown) => FifMsg::Nav(1), + Key::Named(NamedKey::ArrowUp) => FifMsg::Nav(-1), + _ => FifMsg::KeyInput(event.clone()), + }) +} + +/// Chequea si el evento es el atajo recomendado: **Ctrl+Shift+F**. El +/// host puede ignorar esto y definir su propio binding. +pub fn open_shortcut(event: &KeyEvent) -> bool { + event.state == KeyState::Pressed + && event.modifiers.ctrl + && event.modifiers.shift + && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("f")) +} + +/// Paleta visual. Construible desde un [`llimphi_theme::Theme`]. +#[derive(Debug, Clone)] +pub struct FifPalette { + pub bg_panel: Color, + pub bg_header: Color, + pub bg_selected: Color, + pub fg_text: Color, + pub fg_muted: Color, + pub border: Color, + /// Theme cacheado para reusar en `TextInputPalette::from_theme`. + theme: llimphi_theme::Theme, +} + +impl FifPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg_panel: t.bg_panel, + bg_header: t.bg_panel_alt, + bg_selected: t.bg_selected, + fg_text: t.fg_text, + fg_muted: t.fg_muted, + border: t.border, + theme: t.clone(), + } + } +} + +/// Popup modal compacto: header + input. Sin lista de resultados — esa +/// vive en [`view_results_bar`]. El host lo pinta como overlay centrado. +/// +/// El `View` devuelto tiene tamaño fijo ([`DIALOG_W`] × [`DIALOG_H`]). Si +/// el host quiere centrarlo, debe envolverlo en un container con +/// `JustifyContent::Center`/`AlignItems::Center` o usar el slot de overlay. +pub fn view_dialog( + state: &FifState, + palette: &FifPalette, + to_host: F, +) -> View +where + HostMsg: Clone + 'static, + F: Fn(FifMsg) -> HostMsg + Copy + 'static, +{ + let dirty_query = state.input.text() != state.last_query; + let header = if state.last_query.is_empty() { + "find in files · Enter busca · Esc cierra".to_string() + } else if state.results.is_empty() { + format!("«{}» · sin matches · Esc cierra", state.last_query) + } else { + let staleness = if dirty_query { " · Enter re-busca" } else { "" }; + format!( + "«{}» · {} matches · ↓↑ navega · Enter abre{staleness} · Esc cierra", + state.last_query, + state.results.len(), + ) + }; + + let header_view = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(20.0_f32) }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_header) + .text_aligned(header, 10.0, palette.fg_muted, Alignment::Start); + + let tp = TextInputPalette::from_theme(&palette.theme); + let search_focus = state.focus == FifFocus::Search; + let search_view = labelled_input( + "buscar", + &state.input, + "buscar en archivos…", + search_focus, + palette, + &tp, + to_host(FifMsg::Open), + ); + let replace_view = labelled_input( + "reemplazar", + &state.replace, + "(vacío para borrar)", + !search_focus, + palette, + &tp, + to_host(FifMsg::Open), + ); + + let replace_btn = View::new(Style { + size: Size { width: length(118.0_f32), height: length(20.0_f32) }, + padding: Rect { + left: length(6.0_f32), + right: length(6.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_header) + .radius(3.0) + .text_aligned( + "reemplazar todo".to_string(), + 10.0, + palette.fg_muted, + Alignment::Center, + ) + .on_click(to_host(FifMsg::ReplaceAll)); + + let hint = View::new(Style { + flex_grow: 1.0, + size: Size { width: percent(0.0_f32), height: length(20.0_f32) }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned("Tab alterna campos".to_string(), 9.0, palette.fg_muted, Alignment::Start); + + let actions = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(20.0_f32) }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_panel) + .children(vec![hint, replace_btn]); + + // Wrapper exterior: tamaño fijo del dialog + borde sutil. + let dialog = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: length(DIALOG_W), height: length(DIALOG_H) }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_panel) + .radius(6.0) + .children(vec![header_view, search_view, replace_view, actions]); + + // Container que centra el dialog horizontalmente — el host pone esto + // como overlay arriba del editor; un click en zona vacía no hace nada + // (no cerramos por click-outside, sería sorpresivo si el user está + // ojeando resultados en la barra). + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(DIALOG_H + 16.0) }, + padding: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(12.0_f32), + bottom: length(4.0_f32), + }, + justify_content: Some(JustifyContent::Center), + align_items: Some(AlignItems::Start), + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![dialog]) +} + +/// Barra inferior persistente con los matches. Filas clickeables (click +/// → [`FifMsg::ActivateAt`]). El host la pinta como tool window al pie +/// del editor, hermana del terminal/output (estilo JetBrains). +/// +/// Si no hay resultados, devuelve una barra mínima con un mensaje — el +/// host puede usar `state.results.is_empty()` para no renderizarla. +pub fn view_results_bar( + state: &FifState, + paths: &[PathBuf], + root: &Path, + palette: &FifPalette, + to_host: F, +) -> View +where + HostMsg: Clone + 'static, + F: Fn(FifMsg) -> HostMsg + Copy + 'static, +{ + let header_text = if state.results.is_empty() { + format!("find · «{}» · sin matches", state.last_query) + } else { + format!( + "find · «{}» · {} / {} matches · click abre · Ctrl+Shift+F reabre", + state.last_query, + state.selected + 1, + state.results.len(), + ) + }; + + let close_btn = View::new(Style { + size: Size { width: length(54.0_f32), height: length(18.0_f32) }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_header) + .text_aligned("cerrar ✕".to_string(), 10.0, palette.fg_muted, Alignment::Center) + .on_click(to_host(FifMsg::CloseAll)); + + let header_label = View::new(Style { + flex_grow: 1.0, + size: Size { width: percent(0.0_f32), height: length(20.0_f32) }, + padding: Rect { + left: length(10.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start); + + let header_bar = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(20.0_f32) }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_header) + .children(vec![header_label, close_btn]); + + let visible_start = state + .selected + .saturating_sub(MAX_VISIBLE.saturating_sub(1)); + let visible_end = (visible_start + MAX_VISIBLE).min(state.results.len()); + let mut rows: Vec> = Vec::with_capacity(MAX_VISIBLE); + for i in visible_start..visible_end { + let Some(fm) = state.results.get(i) else { continue }; + let Some(path) = paths.get(fm.file_idx) else { continue }; + let rel = relative_to(root, path); + let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?"); + let dir = rel.strip_suffix(name).unwrap_or("").trim_end_matches('/'); + let dir_label = if dir.is_empty() { String::new() } else { format!(" {dir}") }; + let label = format!("{name}:{}{dir_label} {}", fm.line + 1, fm.snippet); + let selected = i == state.selected; + let bg = if selected { palette.bg_selected } else { palette.bg_panel }; + let fg = if selected { palette.fg_text } else { palette.fg_muted }; + rows.push( + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(ROW_H) }, + padding: Rect { + left: length(12.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(bg) + .text_aligned(label, 11.0, fg, Alignment::Start) + .on_click(to_host(FifMsg::ActivateAt(i))), + ); + } + + let mut children: Vec> = Vec::with_capacity(1 + rows.len()); + children.push(header_bar); + children.extend(rows); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: length(BAR_H) }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_panel) + .children(children) +} + +/// Búsqueda substring case-insensitive. Pública para tests / hosts que +/// quieran disparar una búsqueda sin pasar por el state machine. +pub fn search(paths: &[PathBuf], query: &str) -> Vec { + let mut out: Vec = Vec::new(); + let q_lc = query.to_lowercase(); + for (file_idx, path) in paths.iter().enumerate() { + if out.len() >= MAX_RESULTS { + break; + } + if let Ok(meta) = std::fs::metadata(path) { + if meta.len() > MAX_FILE_SIZE { + continue; + } + } + let Ok(content) = std::fs::read_to_string(path) else { continue }; + for (line_idx, line) in content.lines().enumerate() { + if out.len() >= MAX_RESULTS { + break; + } + let line_lc = line.to_ascii_lowercase(); + let Some(byte_off) = line_lc.find(&q_lc) else { continue }; + let col = line[..byte_off.min(line.len())].chars().count(); + let trimmed = line.trim_start(); + let snippet = if trimmed.chars().count() <= SNIPPET_MAX_CHARS { + trimmed.to_string() + } else { + let cut: String = trimmed.chars().take(SNIPPET_MAX_CHARS - 1).collect(); + format!("{cut}…") + }; + out.push(FifMatch { file_idx, line: line_idx, col, snippet }); + } + } + out +} + +/// Reemplazo case-insensitive sobre los archivos involucrados en +/// `results`. Devuelve `(files_changed, replacements, failures)`. +/// Lee cada archivo una sola vez, sustituye todas las apariciones de +/// `query` por `replacement` (case-insensitive, preservando el resto), y +/// escribe sólo si hubo cambios. No toca buffers en memoria del host — +/// el host es responsable de recargar tabs si quiere ver los cambios. +pub fn replace_all( + paths: &[PathBuf], + results: &[FifMatch], + query: &str, + replacement: &str, +) -> (usize, usize, usize) { + if query.is_empty() { + return (0, 0, 0); + } + let mut touched: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + for fm in results { + touched.insert(fm.file_idx); + } + let mut files_changed = 0usize; + let mut total_replacements = 0usize; + let mut failures = 0usize; + let q_lc = query.to_lowercase(); + for idx in touched { + let Some(path) = paths.get(idx) else { continue }; + let Ok(content) = std::fs::read_to_string(path) else { + failures += 1; + continue; + }; + let (new_content, n) = ci_replace_all(&content, query, &q_lc, replacement); + if n == 0 { + continue; + } + if std::fs::write(path, new_content).is_err() { + failures += 1; + continue; + } + files_changed += 1; + total_replacements += n; + } + (files_changed, total_replacements, failures) +} + +/// Reemplazo case-insensitive preservando los bytes no-matchados. +fn ci_replace_all(haystack: &str, _needle: &str, needle_lc: &str, repl: &str) -> (String, usize) { + let hay_lc = haystack.to_lowercase(); + let mut out = String::with_capacity(haystack.len()); + let mut count = 0usize; + let mut i = 0usize; + while i <= hay_lc.len() { + if let Some(pos) = hay_lc[i..].find(needle_lc) { + let abs = i + pos; + out.push_str(&haystack[i..abs]); + out.push_str(repl); + i = abs + needle_lc.len(); + count += 1; + } else { + out.push_str(&haystack[i..]); + break; + } + } + (out, count) +} + +// --------------------------------------------------------------------- +// Helpers internos +// --------------------------------------------------------------------- + +/// Pinta un input con etiqueta a la izquierda; cuando `focus` es true, +/// el fondo se realza para que el user vea dónde está tipeando. +fn labelled_input( + label: &str, + state: &TextInputState, + placeholder: &str, + focus: bool, + palette: &FifPalette, + tp: &TextInputPalette, + fallback_msg: HostMsg, +) -> View +where + HostMsg: Clone + 'static, +{ + let bg = if focus { palette.bg_selected } else { palette.bg_panel }; + let label_view = View::new(Style { + size: Size { width: length(82.0_f32), height: length(28.0_f32) }, + padding: Rect { + left: length(10.0_f32), + right: length(4.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(label.to_string(), 10.0, palette.fg_muted, Alignment::Start); + + let input_view = View::new(Style { + flex_grow: 1.0, + size: Size { width: percent(0.0_f32), height: length(28.0_f32) }, + padding: Rect { + left: length(4.0_f32), + right: length(10.0_f32), + top: length(2.0_f32), + bottom: length(2.0_f32), + }, + ..Default::default() + }) + .children(vec![text_input_view( + state, + placeholder, + focus, + tp, + fallback_msg, + )]); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(28.0_f32) }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(bg) + .children(vec![label_view, input_view]) +} + +fn relative_to(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| path.display().to_string()) +} diff --git a/modules/file-picker/Cargo.toml b/modules/file-picker/Cargo.toml new file mode 100644 index 0000000..88307c1 --- /dev/null +++ b/modules/file-picker/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-module-file-picker" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-module-file-picker — fuzzy file picker (estilo Ctrl+P de VS Code). Módulo Llimphi reutilizable: state + Msg + Action + apply/on_key/view sobre un slice de paths que provee el host." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-text-input = { workspace = true } diff --git a/modules/file-picker/LEEME.md b/modules/file-picker/LEEME.md new file mode 100644 index 0000000..3480bee --- /dev/null +++ b/modules/file-picker/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-module-file-picker + +> Picker de archivos de [llimphi](../../README.md). + +Fuzzy-finder de paths. Modal sobre la app. Devuelve `PathBuf` por `Msg::FilePicked`. Recientes priorizados. diff --git a/modules/file-picker/README.md b/modules/file-picker/README.md new file mode 100644 index 0000000..e6a192f --- /dev/null +++ b/modules/file-picker/README.md @@ -0,0 +1,5 @@ +# llimphi-module-file-picker + +> File picker of [llimphi](../../README.md). + +Path fuzzy-finder. Modal over the app. Returns `PathBuf` via `Msg::FilePicked`. Recents prioritized. diff --git a/modules/file-picker/src/lib.rs b/modules/file-picker/src/lib.rs new file mode 100644 index 0000000..2109da4 --- /dev/null +++ b/modules/file-picker/src/lib.rs @@ -0,0 +1,382 @@ +//! `llimphi-module-file-picker` — fuzzy file picker reutilizable. +//! +//! Equivalente a Ctrl+P de VS Code / "Go to file" de JetBrains: el host +//! mantiene una lista de paths candidatos (típicamente walk del workspace +//! cacheado al arrancar) y el módulo presenta un overlay con input + +//! resultados rankeados. Cuando el user pica uno, el módulo emite +//! [`PickerAction::Open`] y el host decide cómo abrir (tab nuevo, split, +//! etc.). +//! +//! Sigue el contrato Llimphi de [`docs/MODULES.md`]: +//! `State + Msg + Action + apply/on_key/open_shortcut/view + Palette`. +//! +//! ## Cómo lo enchufa una app +//! +//! ```ignore +//! use llimphi_module_file_picker::{self as picker, PickerAction, PickerMsg, +//! PickerPalette, PickerState}; +//! +//! struct Model { all_files: Vec, picker: Option, … } +//! enum Msg { Picker(PickerMsg), … } +//! +//! // update: +//! Msg::Picker(pm) => { +//! let mut m = model; +//! if matches!(pm, PickerMsg::Open) && m.picker.is_none() { +//! m.picker = Some(PickerState::new(&m.all_files, &m.root)); +//! return m; +//! } +//! let action = match m.picker.as_mut() { +//! Some(s) => picker::apply(s, pm, &m.all_files, &m.root), +//! None => return m, +//! }; +//! match action { +//! PickerAction::Close => m.picker = None, +//! PickerAction::Open(path) => { +//! m.picker = None; +//! m = open_path_in_app(m, path); +//! } +//! PickerAction::None => {} +//! } +//! m +//! } +//! +//! // on_key: +//! if let Some(state) = model.picker.as_ref() { +//! if let Some(pm) = picker::on_key(state, event) { +//! return Some(Msg::Picker(pm)); +//! } +//! } +//! if picker::open_shortcut(event) { +//! return Some(Msg::Picker(PickerMsg::Open)); +//! } +//! +//! // view: +//! if let Some(state) = model.picker.as_ref() { +//! let panel = picker::view( +//! state, &model.all_files, &model.root, +//! &PickerPalette::from_theme(&theme), +//! Msg::Picker, +//! ); +//! children.push(panel); +//! } +//! ``` + +#![forbid(unsafe_code)] + +use std::path::{Path, PathBuf}; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View}; +use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState}; + +/// Capabilities que este módulo aporta al host. El host (cuando construye +/// su `card_core::Card`) puede agregar esto a `provides` para anunciar +/// vía broker que ofrece file-picker al ecosistema. +pub const CAPABILITIES: &[&str] = &["editor.file-picker"]; + +/// Máximo de resultados rankeados que entran al popup. +pub const MAX_RESULTS: usize = 200; + +const BAR_H: f32 = 220.0; +const ROW_H: f32 = 20.0; +const MAX_VISIBLE: usize = 9; + +/// Estado interno. Los `results` son índices al slice de paths que pasa +/// el host: el módulo no copia paths, sólo guarda índices. +pub struct PickerState { + pub input: TextInputState, + pub results: Vec, + pub selected: usize, +} + +impl Default for PickerState { + fn default() -> Self { + Self::new_empty() + } +} + +impl PickerState { + /// Crea un picker vacío. Si querés pre-filtrar con los paths que ya + /// tenés, llamá [`PickerState::new`] en su lugar. + pub fn new_empty() -> Self { + Self { + input: TextInputState::new(), + results: Vec::new(), + selected: 0, + } + } + + /// Crea un picker con todos los `paths` como resultados iniciales + /// (sin filtrar). Conveniente para el ack visual del Ctrl+P recién + /// disparado. + pub fn new(paths: &[PathBuf], root: &Path) -> Self { + let mut s = Self::new_empty(); + refilter(&mut s, paths, root); + s + } +} + +/// Vocabulario interno. El host lo wrapea en su Msg. +#[derive(Clone)] +pub enum PickerMsg { + /// Símbolo conveniente para que el host dispatche al detectar el + /// shortcut. El módulo no maneja Open él mismo — la creación del + /// state corre por cuenta del host (porque típicamente quiere pasar + /// la lista canónica de paths). + Open, + Close, + KeyInput(KeyEvent), + Nav(i32), + /// Enter: abre el match seleccionado. + Apply, +} + +/// Efecto solicitado al host. +#[derive(Debug, Clone)] +pub enum PickerAction { + None, + /// El host debería remover el state del modelo. + Close, + /// El host debería abrir este `path`. El módulo NO se cierra + /// automáticamente — el host decide si ocultar el picker tras abrir. + Open(PathBuf), +} + +/// Aplica un mensaje al estado. +pub fn apply( + state: &mut PickerState, + msg: PickerMsg, + paths: &[PathBuf], + root: &Path, +) -> PickerAction { + match msg { + PickerMsg::Open => PickerAction::None, + PickerMsg::Close => PickerAction::Close, + PickerMsg::KeyInput(ev) => { + state.input.apply_key(&ev); + refilter(state, paths, root); + PickerAction::None + } + PickerMsg::Nav(d) => { + let n = state.results.len() as i32; + if n > 0 { + state.selected = (state.selected as i32 + d).rem_euclid(n) as usize; + } + PickerAction::None + } + PickerMsg::Apply => { + let Some(&file_idx) = state.results.get(state.selected) else { + return PickerAction::None; + }; + let Some(path) = paths.get(file_idx).cloned() else { + return PickerAction::None; + }; + PickerAction::Open(path) + } + } +} + +/// Routing de teclas cuando el panel está abierto. +pub fn on_key(_state: &PickerState, event: &KeyEvent) -> Option { + if event.state != KeyState::Pressed { + return None; + } + Some(match &event.key { + Key::Named(NamedKey::Escape) => PickerMsg::Close, + Key::Named(NamedKey::Enter) => PickerMsg::Apply, + Key::Named(NamedKey::ArrowDown) => PickerMsg::Nav(1), + Key::Named(NamedKey::ArrowUp) => PickerMsg::Nav(-1), + _ => PickerMsg::KeyInput(event.clone()), + }) +} + +/// Chequea si el evento es el atajo recomendado: **Ctrl+P**. +pub fn open_shortcut(event: &KeyEvent) -> bool { + event.state == KeyState::Pressed + && event.modifiers.ctrl + && !event.modifiers.shift + && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("p")) +} + +/// Recalcula `state.results` según el query del input. Match case-insensitive +/// sobre el path relativo. Score penaliza paths largos y premia hits en el +/// basename. Query vacío = todos los paths ordenados por longitud asc. +/// Cap: [`MAX_RESULTS`]. +pub fn refilter(state: &mut PickerState, paths: &[PathBuf], root: &Path) { + let q = state.input.text(); + let q_lc = q.to_lowercase(); + let mut scored: Vec<(i64, usize)> = Vec::new(); + for (i, path) in paths.iter().enumerate() { + let rel = relative_to(root, path); + if q_lc.is_empty() { + scored.push((rel.len() as i64, i)); + continue; + } + let rel_lc = rel.to_lowercase(); + let Some(rel_hit) = rel_lc.find(&q_lc) else { continue }; + let name = path + .file_name() + .and_then(|s| s.to_str()) + .map(|s| s.to_lowercase()) + .unwrap_or_default(); + let name_hit = name.find(&q_lc); + let score = match name_hit { + Some(pos) => pos as i64 * 4 + rel.len() as i64, + None => 10_000 + rel_hit as i64 + rel.len() as i64, + }; + scored.push((score, i)); + } + scored.sort_by_key(|(s, _)| *s); + scored.truncate(MAX_RESULTS); + state.results = scored.into_iter().map(|(_, i)| i).collect(); + state.selected = 0; +} + +/// Paleta visual. +#[derive(Debug, Clone)] +pub struct PickerPalette { + pub bg_panel: Color, + pub bg_header: Color, + pub bg_selected: Color, + pub fg_text: Color, + pub fg_muted: Color, + theme: llimphi_theme::Theme, +} + +impl PickerPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg_panel: t.bg_panel, + bg_header: t.bg_panel_alt, + bg_selected: t.bg_selected, + fg_text: t.fg_text, + fg_muted: t.fg_muted, + theme: t.clone(), + } + } +} + +/// Render del panel. `to_host` mapea cada `PickerMsg` interno al `Msg` +/// de la app. +pub fn view( + state: &PickerState, + paths: &[PathBuf], + root: &Path, + palette: &PickerPalette, + to_host: F, +) -> View +where + HostMsg: Clone + 'static, + F: Fn(PickerMsg) -> HostMsg + Copy + 'static, +{ + let header = if state.results.is_empty() { + format!("file picker · sin matches · {} archivos · Esc cierra", paths.len()) + } else { + format!( + "file picker · {} / {} · ↓↑ navega · Enter abre · Esc cierra", + state.selected + 1, + state.results.len(), + ) + }; + + let header_view = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(18.0_f32) }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_header) + .text_aligned(header, 10.0, palette.fg_muted, Alignment::Start); + + let tp = TextInputPalette::from_theme(&palette.theme); + let input_view = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(26.0_f32) }, + padding: Rect { + left: length(6.0_f32), + right: length(6.0_f32), + top: length(2.0_f32), + bottom: length(2.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_panel) + .children(vec![text_input_view( + &state.input, + "filtro: nombre o ruta…", + true, + &tp, + to_host(PickerMsg::Open), + )]); + + let visible_start = state.selected.saturating_sub(MAX_VISIBLE.saturating_sub(1)); + let visible_end = (visible_start + MAX_VISIBLE).min(state.results.len()); + let mut rows: Vec> = Vec::with_capacity(MAX_VISIBLE); + for i in visible_start..visible_end { + let Some(&file_idx) = state.results.get(i) else { continue }; + let Some(path) = paths.get(file_idx) else { continue }; + let rel = relative_to(root, path); + let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?"); + let dir = rel.strip_suffix(name).unwrap_or(""); + let label = if dir.is_empty() { + name.to_string() + } else { + format!("{name} {}", dir.trim_end_matches('/')) + }; + let selected = i == state.selected; + let bg = if selected { palette.bg_selected } else { palette.bg_panel }; + let fg = if selected { palette.fg_text } else { palette.fg_muted }; + rows.push( + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(ROW_H) }, + padding: Rect { + left: length(10.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(bg) + .text_aligned(label, 11.0, fg, Alignment::Start), + ); + } + + let mut children: Vec> = Vec::with_capacity(2 + rows.len()); + children.push(header_view); + children.push(input_view); + children.extend(rows); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: length(BAR_H) }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_panel) + .children(children) +} + +// --------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------- + +fn relative_to(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| path.display().to_string()) +} diff --git a/modules/mini-map/Cargo.toml b/modules/mini-map/Cargo.toml new file mode 100644 index 0000000..1ce231f --- /dev/null +++ b/modules/mini-map/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "llimphi-module-mini-map" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-module-mini-map — overlay minimap del buffer activo. Modulo Llimphi: el host le pasa un snapshot del buffer + viewport + caret, el modulo pinta un panel vertical con un slab por linea (ancho aprox chars), resalta el viewport visible y emite Jump(line) al click. Estilo VS Code/Sublime." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } + +[dev-dependencies] diff --git a/modules/mini-map/LEEME.md b/modules/mini-map/LEEME.md new file mode 100644 index 0000000..5324a2f --- /dev/null +++ b/modules/mini-map/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-module-mini-map + +> Mini-mapa del editor de [llimphi](../../README.md). + +Overview a la derecha del [`text-editor`](../../widgets/text-editor/README.md): renderizado escalado del archivo con highlight de la posición actual. Click salta a esa porción. diff --git a/modules/mini-map/README.md b/modules/mini-map/README.md new file mode 100644 index 0000000..1643a3e --- /dev/null +++ b/modules/mini-map/README.md @@ -0,0 +1,5 @@ +# llimphi-module-mini-map + +> Editor mini-map of [llimphi](../../README.md). + +Right-side overview of [`text-editor`](../../widgets/text-editor/README.md): scaled rendering of the file with highlight of current position. Click jumps to that section. diff --git a/modules/mini-map/src/lib.rs b/modules/mini-map/src/lib.rs new file mode 100644 index 0000000..3f8c1c9 --- /dev/null +++ b/modules/mini-map/src/lib.rs @@ -0,0 +1,274 @@ +//! `llimphi-module-mini-map` — minimap del buffer activo. +//! +//! Equivalente al "Minimap" de VS Code / "thumbnail" de Sublime: un +//! panel angosto pegado al editor que pinta una linea horizontal por +//! cada linea del buffer (ancho ~= len_chars, cap a `usable_w`), +//! resalta el viewport visible como rect translucido y marca el caret. +//! Click sobre el minimap salta esa linea al editor. +//! +//! El modulo es agnostico del editor: el host pasa un slice con la +//! cantidad de chars por linea, el rango visible y la linea del +//! caret. No depende de `llimphi-widget-text-editor` — cualquier +//! buffer (rope, vec, archivo memmaped) sirve. +//! +//! Sigue el contrato Llimphi de `docs/MODULES.md`: +//! `State + Msg + Action + apply/on_key/open_shortcut/view + Palette`. + +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style}; +use llimphi_ui::llimphi_raster::kurbo::{Affine, Rect as KRect}; +use llimphi_ui::llimphi_raster::peniko::{Color, Fill}; +use llimphi_ui::{Key, KeyEvent, KeyState, View}; + +/// Capabilities que aporta este modulo al host. +pub const CAPABILITIES: &[&str] = &["editor.mini-map"]; + +/// Ancho del panel en pixeles (estilo VS Code). +pub const PANEL_W: f32 = 120.0; +/// Altura maxima por linea del buffer dentro del minimap (cap). +pub const LINE_PX: f32 = 2.0; +/// Escala chars->pixels para el ancho de cada slab. ~75 chars caben +/// completos en `PANEL_W - PAD * 2`; lo demas se trunca. +pub const CHAR_PX: f32 = 1.4; +/// Padding lateral del panel (los slabs no tocan los bordes). +pub const PAD: f32 = 6.0; + +/// Estado interno. Hoy efectivamente vacio — la informacion del buffer +/// la pasa el host en cada frame via [`view`] — pero existe como +/// `Option` en el host para representar abierto/cerrado +/// y para futuras extensiones (scrubbing, fold-aware, syntax per slab). +#[derive(Debug, Default, Clone)] +pub struct MiniMapState { + /// Reservado para drag-scrub: la y inicial en pixeles dentro del + /// panel cuando el usuario empieza a arrastrar. `None` = sin drag + /// activo. Hoy no se consume (click es suficiente); declarado para + /// que el contrato del state no cambie cuando se agregue. + pub drag_anchor_y: Option, +} + +impl MiniMapState { + pub fn new() -> Self { + Self::default() + } +} + +/// Vocabulario interno. El host lo wrapea en su Msg. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MiniMapMsg { + /// Convencional: el host abre el panel guardando un `MiniMapState` + /// en el modelo. El modulo no construye state global. + Open, + Close, + /// El usuario clickeo o arrastro: salta a la linea indicada. + Jump(usize), +} + +/// Efecto solicitado al host. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MiniMapAction { + None, + /// El host deberia remover el state del modelo. + Close, + /// El host deberia centrar el viewport en esta linea del buffer + /// activo. El modulo NO se cierra — el minimap es persistente. + JumpTo(usize), +} + +/// Snapshot del buffer que el host pasa en cada frame. El modulo no +/// copia, solo lee. La cantidad de chars por linea es lo unico que +/// necesita para dibujar; viewport + caret se overlayean encima. +pub struct Snapshot<'a> { + /// `lines[i]` = numero de chars (no bytes) en la linea `i`. + pub lines: &'a [usize], + /// Rango visible en el editor: `[start, end)`. + pub viewport_start: usize, + pub viewport_end: usize, + /// Linea del caret (0-based). Se pinta como marker accent. + pub caret_line: usize, +} + +/// Aplica un mensaje al estado. +pub fn apply(state: &mut MiniMapState, msg: MiniMapMsg) -> MiniMapAction { + match msg { + MiniMapMsg::Open => MiniMapAction::None, + MiniMapMsg::Close => MiniMapAction::Close, + MiniMapMsg::Jump(line) => { + state.drag_anchor_y = None; + MiniMapAction::JumpTo(line) + } + } +} + +/// Routing de teclas. El minimap NO captura teclas (es un viewer +/// pasivo). Devolvemos `None`; el host sigue su routing normal. +pub fn on_key(_state: &MiniMapState, _event: &KeyEvent) -> Option { + None +} + +/// Atajo recomendado: **Ctrl+Shift+M** (mnemonic M = Minimap). +pub fn open_shortcut(event: &KeyEvent) -> bool { + event.state == KeyState::Pressed + && event.modifiers.ctrl + && event.modifiers.shift + && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("m")) +} + +/// Convierte una posicion-y dentro del panel a indice de linea. La +/// conversion es proporcional al total de lineas; clamping en ambos +/// extremos. +pub fn y_to_line(y: f32, panel_h: f32, total_lines: usize) -> usize { + if total_lines == 0 || panel_h <= 0.0 { + return 0; + } + let t = (y / panel_h).clamp(0.0, 1.0); + let line = (t * total_lines as f32) as usize; + line.min(total_lines.saturating_sub(1)) +} + +/// Paleta visual derivable del theme. +#[derive(Debug, Clone)] +pub struct MiniMapPalette { + /// Fondo del panel del minimap. + pub bg_panel: Color, + /// Color de los slabs (uno por linea de buffer). + pub fg_slab: Color, + /// Color del rect translucido que marca el viewport visible. + pub bg_viewport: Color, + /// Borde del rect del viewport. + pub border_viewport: Color, + /// Color del marker del caret. + pub fg_caret: Color, +} + +impl MiniMapPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg_panel: t.bg_panel_alt, + fg_slab: t.fg_muted, + bg_viewport: with_alpha(t.bg_selected, 0.35), + border_viewport: t.border_focus, + fg_caret: t.accent, + } + } +} + +fn with_alpha(c: Color, alpha: f32) -> Color { + let rgba = c.to_rgba8(); + let a = (alpha.clamp(0.0, 1.0) * 255.0) as u8; + Color::from_rgba8(rgba.r, rgba.g, rgba.b, a) +} + +/// Render del panel. `to_host` mapea cada `MiniMapMsg` al `Msg` de la app. +/// `snapshot` es la vista del buffer en este frame (sin copia). +/// +/// Layout: columna fija de `PANEL_W` px que ocupa todo el alto del +/// contenedor padre. El host la mete en el `Row` del editor +/// (tipicamente al final, al estilo VS Code). +pub fn view( + _state: &MiniMapState, + snapshot: &Snapshot, + palette: &MiniMapPalette, + to_host: F, +) -> View +where + HostMsg: Clone + 'static, + F: Fn(MiniMapMsg) -> HostMsg + Copy + Send + Sync + 'static, +{ + // Capturamos por valor porque el painter es Arc: 'static + Send + Sync. + let lines: Vec = snapshot.lines.to_vec(); + let viewport_start = snapshot.viewport_start; + let viewport_end = snapshot.viewport_end; + let caret_line = snapshot.caret_line; + let pal = palette.clone(); + + let total_lines = lines.len(); + let click_host = to_host; + let on_click: Arc Option + Send + Sync> = Arc::new(move |_x: f32, y: f32, _w: f32, h: f32| { + let line = y_to_line(y, h, total_lines); + Some(click_host(MiniMapMsg::Jump(line))) + }); + + let mut view = View::new(Style { + size: Size { width: length(PANEL_W), height: percent(1.0_f32) }, + flex_shrink: 0.0, + flex_direction: FlexDirection::Column, + ..Default::default() + }) + .fill(pal.bg_panel) + .clip(true) + .paint_with(move |scene, _ts, rect| { + if rect.w <= 0.0 || rect.h <= 0.0 || lines.is_empty() { + return; + } + let n = lines.len() as f32; + let line_h = (rect.h / n).min(LINE_PX); + let usable_w = (rect.w - PAD * 2.0).max(1.0); + + // 1) Viewport overlay debajo de los slabs. + if viewport_end > viewport_start { + let y0 = rect.y + (viewport_start as f32 / n) * rect.h; + let y1 = rect.y + (viewport_end as f32 / n) * rect.h; + let vp = KRect::new( + rect.x as f64, + y0 as f64, + (rect.x + rect.w) as f64, + y1.max(y0 + 2.0) as f64, + ); + scene.fill(Fill::NonZero, Affine::IDENTITY, pal.bg_viewport, None, &vp); + } + + // 2) Slabs: uno por linea de buffer. + for (i, &chars) in lines.iter().enumerate() { + if chars == 0 { + continue; + } + let w = (chars as f32 * CHAR_PX).min(usable_w); + let y = rect.y + (i as f32 / n) * rect.h; + let slab_h = line_h.max(1.0); + let r = KRect::new( + (rect.x + PAD) as f64, + y as f64, + (rect.x + PAD + w) as f64, + (y + slab_h) as f64, + ); + scene.fill(Fill::NonZero, Affine::IDENTITY, pal.fg_slab, None, &r); + } + + // 3) Borde del viewport encima de los slabs. + if viewport_end > viewport_start { + let y0 = rect.y + (viewport_start as f32 / n) * rect.h; + let y1 = (rect.y + (viewport_end as f32 / n) * rect.h).max(y0 + 2.0); + let top = KRect::new( + rect.x as f64, + y0 as f64, + (rect.x + rect.w) as f64, + (y0 + 1.0) as f64, + ); + let bot = KRect::new( + rect.x as f64, + (y1 - 1.0) as f64, + (rect.x + rect.w) as f64, + y1 as f64, + ); + scene.fill(Fill::NonZero, Affine::IDENTITY, pal.border_viewport, None, &top); + scene.fill(Fill::NonZero, Affine::IDENTITY, pal.border_viewport, None, &bot); + } + + // 4) Marker del caret: barra horizontal accent. + if caret_line < lines.len() { + let y = rect.y + (caret_line as f32 / n) * rect.h; + let r = KRect::new( + rect.x as f64, + y as f64, + (rect.x + rect.w) as f64, + (y + 2.0) as f64, + ); + scene.fill(Fill::NonZero, Affine::IDENTITY, pal.fg_caret, None, &r); + } + }); + view.on_click_at = Some(on_click); + view +} diff --git a/modules/mini-map/tests/smoke.rs b/modules/mini-map/tests/smoke.rs new file mode 100644 index 0000000..bb9259c --- /dev/null +++ b/modules/mini-map/tests/smoke.rs @@ -0,0 +1,63 @@ +//! Smoke tests del minimap. Sin backend grafico — solo `apply`, +//! `on_key`, `open_shortcut` y la conversion y->line. + +use llimphi_module_mini_map::{ + self as minimap, MiniMapAction, MiniMapMsg, MiniMapState, +}; +use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers}; + +fn key_with(ctrl: bool, shift: bool, ch: &str) -> KeyEvent { + KeyEvent { + key: Key::Character(ch.into()), + state: KeyState::Pressed, + text: Some(ch.into()), + modifiers: Modifiers { ctrl, shift, ..Modifiers::default() }, + repeat: false, + } +} + +#[test] +fn open_shortcut_es_ctrl_shift_m() { + assert!(minimap::open_shortcut(&key_with(true, true, "m"))); + assert!(minimap::open_shortcut(&key_with(true, true, "M"))); + assert!(!minimap::open_shortcut(&key_with(true, false, "m"))); + assert!(!minimap::open_shortcut(&key_with(false, true, "m"))); +} + +#[test] +fn jump_emite_jumpto() { + let mut s = MiniMapState::new(); + let action = minimap::apply(&mut s, MiniMapMsg::Jump(42)); + assert_eq!(action, MiniMapAction::JumpTo(42)); +} + +#[test] +fn close_emite_close() { + let mut s = MiniMapState::new(); + let action = minimap::apply(&mut s, MiniMapMsg::Close); + assert_eq!(action, MiniMapAction::Close); +} + +#[test] +fn y_to_line_proporcional() { + // 100 lineas, panel de 200 px → cada linea ocupa 2 px. + assert_eq!(minimap::y_to_line(0.0, 200.0, 100), 0); + assert_eq!(minimap::y_to_line(100.0, 200.0, 100), 50); + assert_eq!(minimap::y_to_line(200.0, 200.0, 100), 99); + // Clamping fuera de rango. + assert_eq!(minimap::y_to_line(-50.0, 200.0, 100), 0); + assert_eq!(minimap::y_to_line(500.0, 200.0, 100), 99); +} + +#[test] +fn y_to_line_buffer_vacio_no_paniquea() { + assert_eq!(minimap::y_to_line(0.0, 100.0, 0), 0); + assert_eq!(minimap::y_to_line(50.0, 100.0, 0), 0); +} + +#[test] +fn on_key_es_pasivo() { + let s = MiniMapState::new(); + let ev = key_with(false, false, "a"); + assert!(minimap::on_key(&s, &ev).is_none()); +} diff --git a/modules/plugin-host/Cargo.toml b/modules/plugin-host/Cargo.toml new file mode 100644 index 0000000..45ba6f8 --- /dev/null +++ b/modules/plugin-host/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "llimphi-plugin-host" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-plugin-host — runtime de plugins WASM (Tier 2) para apps Llimphi. Carga .wasm + manifest.toml, aplica sandbox por card_core::Permissions, e invoca capabilities devolviendo PluginAction." + +[dependencies] +card-core = { path = "../../../../shared/card/card-core" } +wasmi = { workspace = true } +serde = { workspace = true } +toml = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +# wat sólo aparece en tests: los fixtures se escriben en WAT y se +# compilan en runtime, evitando una dependencia de toolchain wasm32. +wat = { workspace = true } diff --git a/modules/plugin-host/LEEME.md b/modules/plugin-host/LEEME.md new file mode 100644 index 0000000..ee25861 --- /dev/null +++ b/modules/plugin-host/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-module-plugin-host + +> Host para plugins WASM de [llimphi](../../README.md). + +Carga módulos WASM con la capability API del notebook (sandbox), los enchufa a la app como handlers de `Msg` extra. Permite extender la app sin recompilar. diff --git a/modules/plugin-host/README.md b/modules/plugin-host/README.md new file mode 100644 index 0000000..5adc29f --- /dev/null +++ b/modules/plugin-host/README.md @@ -0,0 +1,5 @@ +# llimphi-module-plugin-host + +> WASM plugin host for [llimphi](../../README.md). + +Loads WASM modules with the notebook's capability API (sandbox), plugs them into the app as extra `Msg` handlers. Lets you extend the app without recompiling. diff --git a/modules/plugin-host/src/lib.rs b/modules/plugin-host/src/lib.rs new file mode 100644 index 0000000..12caafe --- /dev/null +++ b/modules/plugin-host/src/lib.rs @@ -0,0 +1,334 @@ +//! llimphi-plugin-host — runtime de plugins WASM Tier 2 para apps Llimphi. +//! +//! Vea `docs/MODULES.md` (§Tier 2 — Plugins WASM) para el contrato +//! completo. En síntesis: +//! +//! - Un plugin es un `.wasm` + un `manifest.toml` hermano que declara +//! `name`, `version`, `capabilities`, y los `Permissions` que pide. +//! - El host expone imports bajo el namespace `"plugin"`. Cada uno se +//! gatea por un campo de `card_core::Permissions`: si el permiso falta, +//! el import **no se enlaza** y el plugin trap-ea al intentar usarlo. +//! - El `.wasm` exporta `_invoke(cap_ptr, cap_len, arg_ptr, arg_len) -> i32` +//! y una `memory` lineal. +//! - Invocar un plugin devuelve `PluginAction` — intención, no ejecución. +//! El host decide cómo materializar `OpenAt`/`SetStatus` en su contexto. + +use std::cell::RefCell; +use std::path::{Path, PathBuf}; + +use card_core::{FsPolicy, Permissions}; +use serde::Deserialize; +use thiserror::Error; +use tracing::{info, warn}; +use wasmi::{Caller, CompilationMode, Config, Engine, Linker, Memory, Module, Store}; + +// ===================================================================== +// Manifest +// ===================================================================== + +/// Manifest sidecar (`manifest.toml`) que acompaña a cada `.wasm`. +/// +/// El formato es estable: campos extra se ignoran con `#[serde(default)]` +/// donde aplica, para que plugins viejos sigan cargando si el host suma +/// metadatos opcionales. +#[derive(Debug, Clone, Deserialize)] +pub struct PluginManifest { + pub name: String, + pub version: String, + /// Capabilities que el plugin atiende. El host enruta invocaciones + /// por el nombre exacto pasado a `PluginHost::invoke(_, cap, _)`. + #[serde(default)] + pub capabilities: Vec, + /// Permisos que el plugin necesita para no trap-ear. Si el manifest + /// pide más de lo que el host está dispuesto a conceder, la carga + /// puede aceptarse "downgraded" — pero el plugin entonces trap-eará + /// al intentar los imports que no se enlazaron. La política la fija + /// quien llama a `PluginHost::load_*`. + #[serde(default)] + pub permissions: Permissions, +} + +impl PluginManifest { + pub fn from_toml(s: &str) -> Result { + toml::from_str(s).map_err(|e| PluginError::Manifest(e.to_string())) + } +} + +// ===================================================================== +// Acciones y errores +// ===================================================================== + +/// Intención que el plugin emite. Igual que en los módulos Tier 1, el +/// plugin no sabe cómo el host materializa cada variante — sólo declara +/// qué quiere que pase. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PluginAction { + None, + SetStatus(String), + OpenAt { path: PathBuf, line: u32, col: u32 }, +} + +#[derive(Debug, Error)] +pub enum PluginError { + #[error("manifest inválido: {0}")] + Manifest(String), + #[error("no se pudo leer {0}: {1}")] + Io(PathBuf, String), + #[error("compilando wasm: {0}")] + Compile(String), + #[error("instanciando wasm: {0}")] + Instantiate(String), + #[error("plugin no exporta `_invoke` con la signatura esperada: {0}")] + MissingEntry(String), + #[error("trap durante la ejecución del plugin: {0}")] + Trap(String), + #[error("no existe plugin con id {0:?}")] + UnknownPlugin(PluginId), +} + +// ===================================================================== +// Host +// ===================================================================== + +/// Identificador opaco de un plugin cargado. Sólo se construye desde +/// `PluginHost::load_*`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PluginId(u32); + +struct LoadedPlugin { + manifest: PluginManifest, + module: Module, +} + +/// Estado por invocación. Vive sólo durante un `invoke` — se descarta al +/// volver. Lo usamos como `Store::data()` para que los host imports +/// puedan emitir su `PluginAction` sin globals. Los permisos no viajan +/// aquí porque su efecto es link-time: los imports prohibidos +/// simplemente no se enlazan. +struct InvokeCtx { + /// Acción a devolver al host. `RefCell` porque los closures de + /// `func_wrap` toman `Caller` por referencia compartida. + pending: RefCell, +} + +pub struct PluginHost { + engine: Engine, + plugins: Vec, +} + +impl Default for PluginHost { + fn default() -> Self { + Self::new() + } +} + +impl PluginHost { + pub fn new() -> Self { + // Eager: mismo modo que arje-wasm, comportamiento predecible y + // los traps de compilación salen en `load_*`, no en `invoke`. + let mut config = Config::default(); + config.compilation_mode(CompilationMode::Eager); + Self { engine: Engine::new(&config), plugins: Vec::new() } + } + + /// Carga `dir/plugin.wasm` + `dir/manifest.toml`. Por convención el + /// `.wasm` se llama igual que el directorio o `plugin.wasm`. Probamos + /// ambos para ser indulgentes con el packaging. + pub fn load_from_dir(&mut self, dir: impl AsRef) -> Result { + let dir = dir.as_ref(); + let manifest_path = dir.join("manifest.toml"); + let manifest_str = std::fs::read_to_string(&manifest_path) + .map_err(|e| PluginError::Io(manifest_path.clone(), e.to_string()))?; + let manifest = PluginManifest::from_toml(&manifest_str)?; + + let candidates = [dir.join("plugin.wasm"), dir.join(format!("{}.wasm", manifest.name))]; + let (wasm_path, wasm_bytes) = candidates + .iter() + .find_map(|p| std::fs::read(p).ok().map(|b| (p.clone(), b))) + .ok_or_else(|| { + PluginError::Io(dir.join("plugin.wasm"), "no encontré ningún .wasm".into()) + })?; + + let _ = wasm_path; + self.load_bytes(manifest, &wasm_bytes) + } + + /// Carga un plugin desde bytes ya en memoria (útil en tests y para + /// plugins embebidos en el binario del host). + pub fn load_bytes( + &mut self, + manifest: PluginManifest, + wasm_bytes: &[u8], + ) -> Result { + let module = Module::new(&self.engine, wasm_bytes) + .map_err(|e| PluginError::Compile(e.to_string()))?; + let id = PluginId(self.plugins.len() as u32); + info!( + plugin = %manifest.name, + version = %manifest.version, + capabilities = ?manifest.capabilities, + "plugin Tier 2 cargado" + ); + self.plugins.push(LoadedPlugin { manifest, module }); + Ok(id) + } + + pub fn manifest(&self, id: PluginId) -> Result<&PluginManifest, PluginError> { + self.plugins + .get(id.0 as usize) + .map(|p| &p.manifest) + .ok_or(PluginError::UnknownPlugin(id)) + } + + /// Devuelve la unión de capabilities de todos los plugins cargados — + /// la lista que el host enrola en su Card antes de `spawn_sidecar()`. + pub fn all_capabilities(&self) -> Vec { + let mut caps: Vec = + self.plugins.iter().flat_map(|p| p.manifest.capabilities.iter().cloned()).collect(); + caps.sort(); + caps.dedup(); + caps + } + + /// Invoca una capability sobre el plugin indicado. `args` se entrega + /// tal cual al plugin (bytes opacos — la app y el plugin acuerdan el + /// schema). El retorno colapsa el `_invoke` exit code y la + /// `PluginAction` que el plugin haya emitido. + pub fn invoke( + &self, + id: PluginId, + capability: &str, + args: &[u8], + ) -> Result { + let plugin = self.plugins.get(id.0 as usize).ok_or(PluginError::UnknownPlugin(id))?; + let ctx = InvokeCtx { pending: RefCell::new(PluginAction::None) }; + let mut store = Store::new(&self.engine, ctx); + let linker = build_linker(&self.engine, &plugin.manifest.permissions)?; + + // wasmi 1.0: `instantiate_and_start` corre la `(start)` section + // si la hay; nuestros plugins no la usan — su entrada es + // `_invoke`, llamada explícitamente más abajo. + let instance = linker + .instantiate_and_start(&mut store, &plugin.module) + .map_err(|e| PluginError::Instantiate(e.to_string()))?; + + let memory = instance + .get_memory(&store, "memory") + .ok_or_else(|| PluginError::MissingEntry("plugin sin export `memory`".into()))?; + + // Escribimos cap + args al inicio de la memoria del plugin. v0 + // del ABI: layout fijo, no negociado. Si el plugin necesita más + // espacio se va a cualquier offset por encima — su asunto. + let cap_bytes = capability.as_bytes(); + write_memory(&mut store, memory, 0, cap_bytes)?; + let args_off = cap_bytes.len(); + write_memory(&mut store, memory, args_off, args)?; + + let func = instance + .get_typed_func::<(i32, i32, i32, i32), i32>(&store, "_invoke") + .map_err(|e| PluginError::MissingEntry(e.to_string()))?; + + let _exit = func + .call( + &mut store, + (0, cap_bytes.len() as i32, args_off as i32, args.len() as i32), + ) + .map_err(|e| PluginError::Trap(e.to_string()))?; + + let action = store.data().pending.borrow().clone(); + Ok(action) + } +} + +// ===================================================================== +// Host imports — gateados por Permissions +// ===================================================================== + +fn build_linker( + engine: &Engine, + perms: &Permissions, +) -> Result, PluginError> { + let mut linker = Linker::::new(engine); + + // log — siempre disponible. Aún plugins sin permisos pueden trazar. + linker + .func_wrap("plugin", "log", |caller: Caller<'_, InvokeCtx>, ptr: i32, len: i32| { + if let Some(s) = read_utf8(&caller, ptr, len) { + info!("[plugin] {s}"); + } + }) + .map_err(|e| PluginError::Instantiate(e.to_string()))?; + + // set_status — siempre disponible. No toca recursos del sistema. + linker + .func_wrap("plugin", "set_status", |caller: Caller<'_, InvokeCtx>, ptr: i32, len: i32| { + if let Some(s) = read_utf8(&caller, ptr, len) { + *caller.data().pending.borrow_mut() = PluginAction::SetStatus(s); + } + }) + .map_err(|e| PluginError::Instantiate(e.to_string()))?; + + // open_at — requiere filesystem >= read-only. Si el permiso falta NO + // enlazamos el import: el plugin trap-eará al invocarlo, que es la + // semántica correcta para un sandbox. + if matches!(perms.filesystem, FsPolicy::ReadOnly | FsPolicy::ReadWrite) { + linker + .func_wrap( + "plugin", + "open_at", + |caller: Caller<'_, InvokeCtx>, ptr: i32, len: i32, line: i32, col: i32| { + if let Some(s) = read_utf8(&caller, ptr, len) { + *caller.data().pending.borrow_mut() = PluginAction::OpenAt { + path: PathBuf::from(s), + line: line.max(0) as u32, + col: col.max(0) as u32, + }; + } + }, + ) + .map_err(|e| PluginError::Instantiate(e.to_string()))?; + } else { + warn!( + "plugin sin permiso filesystem — `plugin.open_at` no enlazado; \ + llamarlo trap-eará" + ); + } + + Ok(linker) +} + +// ===================================================================== +// Helpers de memoria +// ===================================================================== + +fn read_utf8(caller: &Caller<'_, InvokeCtx>, ptr: i32, len: i32) -> Option { + let memory = caller.get_export("memory")?.into_memory()?; + let bytes = read_memory(caller, memory, ptr, len)?; + String::from_utf8(bytes).ok() +} + +fn read_memory( + caller: &Caller<'_, InvokeCtx>, + memory: Memory, + ptr: i32, + len: i32, +) -> Option> { + let ptr = ptr.max(0) as usize; + let len = len.max(0) as usize; + let data = memory.data(caller); + if ptr.saturating_add(len) > data.len() { + return None; + } + Some(data[ptr..ptr + len].to_vec()) +} + +fn write_memory( + store: &mut Store, + memory: Memory, + off: usize, + bytes: &[u8], +) -> Result<(), PluginError> { + memory + .write(store, off, bytes) + .map_err(|e| PluginError::Trap(format!("write_memory off={off} len={}: {e}", bytes.len()))) +} diff --git a/modules/plugin-host/tests/fixtures/hello-status/.gitignore b/modules/plugin-host/tests/fixtures/hello-status/.gitignore new file mode 100644 index 0000000..ef7d91f --- /dev/null +++ b/modules/plugin-host/tests/fixtures/hello-status/.gitignore @@ -0,0 +1 @@ +plugin.wasm diff --git a/modules/plugin-host/tests/fixtures/hello-status/manifest.toml b/modules/plugin-host/tests/fixtures/hello-status/manifest.toml new file mode 100644 index 0000000..d2ab1af --- /dev/null +++ b/modules/plugin-host/tests/fixtures/hello-status/manifest.toml @@ -0,0 +1,11 @@ +name = "hello-status" +version = "0.1.0" +capabilities = ["status.greet"] + +[permissions] +networking = "none" +filesystem = "none" +processes = false + +[permissions.ipc] +allow = [] diff --git a/modules/plugin-host/tests/fixtures/hello-status/plugin.wat b/modules/plugin-host/tests/fixtures/hello-status/plugin.wat new file mode 100644 index 0000000..b890bc7 --- /dev/null +++ b/modules/plugin-host/tests/fixtures/hello-status/plugin.wat @@ -0,0 +1,44 @@ +;; Plugin fixture: "hello-status". +;; +;; Lee el payload de args que el host escribió en memoria justo +;; después del nombre de la capability, y lo concatena con un saludo +;; fijo "hola, " en otro offset. Después emite el resultado via +;; `plugin.set_status`. +;; +;; Layout de memoria al entrar `_invoke`: +;; [0 .. cap_len) nombre de capability (UTF-8) +;; [cap_len .. cap_len+arg_len) args del host (UTF-8) +;; +;; El plugin coloca su buffer de salida en el offset 256 para no +;; pisar lo anterior. v0 del ABI no negocia layouts — la convención +;; es que el plugin elige offsets altos. +(module + (import "plugin" "log" (func $log (param i32 i32))) + (import "plugin" "set_status" (func $set_status (param i32 i32))) + + (memory (export "memory") 1) + + ;; "hola, " en offset 256 (6 bytes) + (data (i32.const 256) "hola, ") + + (func (export "_invoke") + (param $cap_ptr i32) (param $cap_len i32) + (param $arg_ptr i32) (param $arg_len i32) + (result i32) + ;; Traza para debug: el host capturará "[plugin] greet" + (call $log (i32.const 256) (i32.const 5)) + + ;; Copia los args al final del prefijo "hola, " en 256+6=262 + (memory.copy + (i32.const 262) ;; dst = 256 + len("hola, ") + (local.get $arg_ptr) ;; src = donde el host puso args + (local.get $arg_len)) + + ;; Total len = 6 ("hola, ") + arg_len + (call $set_status + (i32.const 256) + (i32.add (i32.const 6) (local.get $arg_len))) + + (i32.const 0) + ) +) diff --git a/modules/plugin-host/tests/smoke.rs b/modules/plugin-host/tests/smoke.rs new file mode 100644 index 0000000..e6ec36d --- /dev/null +++ b/modules/plugin-host/tests/smoke.rs @@ -0,0 +1,109 @@ +//! Smoke tests del runtime Tier 2 — verifican: +//! +//! 1. Carga desde disco (`manifest.toml` + `.wasm`) e invocación que +//! devuelve `PluginAction::SetStatus` con el saludo concatenado. +//! 2. Sandbox por permisos: un plugin con `filesystem = "none"` que +//! intenta llamar `plugin.open_at` trap-ea — el import no se +//! enlazó, así que el módulo importa una función inexistente. +//! 3. Permiso concedido: el mismo plugin con `filesystem = "read-only"` +//! sí enlaza, ejecuta, y emite `PluginAction::OpenAt`. + +use std::path::PathBuf; + +use card_core::{FsPolicy, Permissions}; +use llimphi_plugin_host::{PluginAction, PluginError, PluginHost, PluginManifest}; + +fn fixture_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/hello-status") +} + +/// Compila el .wat del fixture a .wasm en el OUT_DIR efímero del test. +/// Lo hacemos por test (no en build.rs) para mantener el crate sin +/// build script — el costo es trivial y la lógica vive con el test. +fn compile_fixture_to(dir: &std::path::Path) { + let wat = std::fs::read_to_string(dir.join("plugin.wat")).expect("leo plugin.wat"); + let wasm = wat::parse_str(&wat).expect("WAT del fixture compila a wasm"); + std::fs::write(dir.join("plugin.wasm"), wasm).expect("escribo plugin.wasm"); +} + +#[test] +fn carga_desde_directorio_y_devuelve_set_status() { + let dir = fixture_dir(); + compile_fixture_to(&dir); + + let mut host = PluginHost::new(); + let id = host.load_from_dir(&dir).expect("plugin carga desde dir"); + + let manifest = host.manifest(id).expect("manifest accesible"); + assert_eq!(manifest.name, "hello-status"); + assert_eq!(manifest.capabilities, vec!["status.greet".to_string()]); + + let action = host.invoke(id, "status.greet", b"mundo").expect("invoke ok"); + assert_eq!(action, PluginAction::SetStatus("hola, mundo".into())); + + // El host puede enumerar capabilities agregadas para construir su Card. + assert_eq!(host.all_capabilities(), vec!["status.greet".to_string()]); +} + +/// WAT que intenta importar `plugin.open_at`. Sirve como "plugin +/// malicioso" para verificar el sandbox: si el host no concede +/// `filesystem`, el linker no enlaza el import → wasmi rechaza la +/// instanciación con un error de import faltante. +fn wants_open_at_wat() -> &'static str { + // El path va en offset 256 para no colisionar con el buffer + // [cap | args] que el host escribe a partir del offset 0. + r#" +(module + (import "plugin" "open_at" (func $open_at (param i32 i32 i32 i32))) + (memory (export "memory") 1) + (data (i32.const 256) "/etc/passwd") + (func (export "_invoke") + (param i32) (param i32) (param i32) (param i32) + (result i32) + (call $open_at (i32.const 256) (i32.const 11) (i32.const 10) (i32.const 5)) + (i32.const 0) + ) +) +"# +} + +#[test] +fn sin_permiso_filesystem_el_plugin_no_instancia() { + let bytes = wat::parse_str(wants_open_at_wat()).unwrap(); + let manifest = PluginManifest { + name: "wants-fs".into(), + version: "0.1.0".into(), + capabilities: vec!["fs.open".into()], + permissions: Permissions::default(), // filesystem = none + }; + + let mut host = PluginHost::new(); + let id = host.load_bytes(manifest, &bytes).expect("carga ok — el sandbox actúa al invocar"); + + let err = host.invoke(id, "fs.open", b"").expect_err("debe fallar sin permiso fs"); + // wasmi reporta el import faltante en la instanciación. + assert!( + matches!(err, PluginError::Instantiate(_)), + "esperaba Instantiate, vi {err:?}" + ); +} + +#[test] +fn con_permiso_filesystem_el_plugin_emite_open_at() { + let bytes = wat::parse_str(wants_open_at_wat()).unwrap(); + let manifest = PluginManifest { + name: "wants-fs".into(), + version: "0.1.0".into(), + capabilities: vec!["fs.open".into()], + permissions: Permissions { filesystem: FsPolicy::ReadOnly, ..Permissions::default() }, + }; + + let mut host = PluginHost::new(); + let id = host.load_bytes(manifest, &bytes).unwrap(); + let action = host.invoke(id, "fs.open", b"").expect("con permiso, debe correr"); + + assert_eq!( + action, + PluginAction::OpenAt { path: PathBuf::from("/etc/passwd"), line: 10, col: 5 } + ); +} diff --git a/modules/selector/Cargo.toml b/modules/selector/Cargo.toml new file mode 100644 index 0000000..ace3212 --- /dev/null +++ b/modules/selector/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-module-selector" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-module-selector — trait Selector con dos backends: HostSelector (paths del FS via std::fs) y WawaSelector (khipus por hash, sello digital). Una sola API 'abrir/guardar' que funciona en cualquier entorno gioser." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/modules/selector/src/lib.rs b/modules/selector/src/lib.rs new file mode 100644 index 0000000..5910a3c --- /dev/null +++ b/modules/selector/src/lib.rs @@ -0,0 +1,245 @@ +//! `llimphi-module-selector` — abstracción de "abrir/guardar" portable +//! entre host (paths del FS) y wawa (khipus por hash). +//! +//! ## Por qué +//! +//! Una app gioser que sólo conoce paths (`PathBuf`) se rompe en wawa, +//! donde el almacenamiento es direccionado por contenido (BLAKE3 + DAG) +//! y no existe el concepto de "carpeta /home/usuario". Pero la mayoría +//! de las apps no necesitan saber la diferencia: sólo quieren preguntar +//! "qué item quiere abrir el usuario" o "dónde guardo este blob". +//! +//! Este crate expone: +//! - El trait [`Selector`] con dos métodos: `list_candidates()` (para +//! armar la UI del picker) y `realize(handle)` (para resolver el +//! item elegido a bytes). +//! - Un `ItemHandle` opaco — la app no debe inspeccionarlo, sólo +//! pasarlo de vuelta al selector. +//! - [`HostSelector`] con root path + extension filter (impl real). +//! - [`WawaSelector`] como **placeholder** con la API definida — la +//! integración real con `akasha` / `wawa-kernel` ocurre cuando la +//! suite empiece a correr in-cage. Por ahora exporta tipos y panica +//! si se invoca, lo cual está bien: el código que lo construye +//! queda compilable y las apps pueden tipar contra el trait. +//! +//! ## API mínima +//! +//! ```ignore +//! use llimphi_module_selector::{HostSelector, Selector}; +//! +//! let sel = HostSelector::new("/home/usr/docs", &[".pluma", ".khipu"]); +//! let items = sel.list_candidates()?; +//! // (la app muestra `items.iter().map(|i| &i.display_name)` en su picker) +//! // user elige el index N: +//! let bytes = sel.realize(&items[N].handle)?; +//! ``` + +#![forbid(unsafe_code)] + +use std::path::{Path, PathBuf}; + +/// Resultado de la operación — `String` como error para que no le +/// importe a la app si el backend es FS o wawa. +pub type SelectorResult = Result; + +/// Item visible en el picker. `handle` es opaco — sólo el `Selector` +/// que lo emitió sabe interpretarlo. +#[derive(Debug, Clone)] +pub struct Item { + /// Nombre legible para mostrar en el picker. Para `HostSelector` + /// es el path relativo al root; para `WawaSelector` será el alias + /// del khipu o un hash truncado si no tiene alias. + pub display_name: String, + /// Tamaño en bytes si se conoce — para mostrar al lado del nombre. + /// `None` cuando es caro de calcular (e.g. khipu blob remoto). + pub size_bytes: Option, + pub handle: ItemHandle, +} + +/// Handle opaco. Internamente puede ser un path (host) o un hash +/// (wawa). La app no debe construir uno a mano — lo recibe del +/// `Selector` y se lo devuelve al `realize()`. +#[derive(Debug, Clone)] +pub enum ItemHandle { + /// Path absoluto en el FS del host. + HostPath(PathBuf), + /// Hash de contenido BLAKE3 (32 bytes hex) en el almacén wawa. + /// La integración real lo resuelve via `almacen::cargar(hash)`. + WawaHash([u8; 32]), +} + +/// Trait que abstrae el medio de almacenamiento. Una app gioser que +/// quiera funcionar tanto en host como en wawa toma un `&dyn Selector` +/// en su modelo en lugar de un `PathBuf` concreto. +pub trait Selector { + /// Lista los items "abribles" según los criterios del selector + /// (extensión, glob, scope). Para host suele ser un walk del root; + /// para wawa, los khipus marcados con cierto namespace. + fn list_candidates(&self) -> SelectorResult>; + + /// Resuelve un `ItemHandle` a los bytes del item. + fn realize(&self, handle: &ItemHandle) -> SelectorResult>; + + /// Guarda `bytes` bajo el nombre lógico `name`. Devuelve el + /// `ItemHandle` del item recién creado. Para host esto es + /// `root.join(name) + write`; para wawa, ingerir en el almacén. + fn save(&self, name: &str, bytes: &[u8]) -> SelectorResult; +} + +// ===================================================================== +// HostSelector — backend de FS clásico +// ===================================================================== + +/// Selector que walkea un root del filesystem y filtra por extensión. +/// Implementación lineal — para roots gigantes la app debería cachear +/// los candidates al arrancar (igual que hace el `file-picker` actual). +pub struct HostSelector { + root: PathBuf, + /// Lista de extensiones aceptadas (con el punto, ej. `".pluma"`). + /// Vacío = todas. + extensions: Vec, +} + +impl HostSelector { + pub fn new(root: impl Into, extensions: &[&str]) -> Self { + Self { + root: root.into(), + extensions: extensions.iter().map(|s| (*s).to_string()).collect(), + } + } + + fn accept(&self, path: &Path) -> bool { + if self.extensions.is_empty() { + return true; + } + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + return false; + }; + self.extensions.iter().any(|ext| name.ends_with(ext)) + } + + fn walk(&self, dir: &Path, out: &mut Vec) -> SelectorResult<()> { + let entries = std::fs::read_dir(dir).map_err(|e| e.to_string())?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + // Saltamos directorios "ruidosos" (target, .git, node_modules). + if let Some(name) = path.file_name().and_then(|s| s.to_str()) { + if matches!(name, "target" | ".git" | "node_modules" | ".idea") { + continue; + } + } + self.walk(&path, out)?; + } else if self.accept(&path) { + let display_name = path + .strip_prefix(&self.root) + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| path.display().to_string()); + let size_bytes = entry.metadata().ok().map(|m| m.len()); + out.push(Item { + display_name, + size_bytes, + handle: ItemHandle::HostPath(path), + }); + } + } + Ok(()) + } +} + +impl Selector for HostSelector { + fn list_candidates(&self) -> SelectorResult> { + let mut out = Vec::new(); + self.walk(&self.root, &mut out)?; + Ok(out) + } + + fn realize(&self, handle: &ItemHandle) -> SelectorResult> { + match handle { + ItemHandle::HostPath(p) => std::fs::read(p).map_err(|e| e.to_string()), + ItemHandle::WawaHash(_) => Err("HostSelector no resuelve hashes wawa".into()), + } + } + + fn save(&self, name: &str, bytes: &[u8]) -> SelectorResult { + let path = self.root.join(name); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + std::fs::write(&path, bytes).map_err(|e| e.to_string())?; + Ok(ItemHandle::HostPath(path)) + } +} + +// ===================================================================== +// WawaSelector — placeholder para integración con akasha/almacen +// ===================================================================== + +/// Selector para entorno wawa. **No implementado** — la integración real +/// requiere bindings al `wawa-kernel::almacen` (BLAKE3 + log + GC), que +/// vive fuera del workspace global. Por ahora expone la API para que el +/// código que la usa compile, y panica en runtime para flaggear que +/// alguien intentó usarla antes de tiempo. +/// +/// Cuando llegue la integración real: +/// 1. `wawa-kernel` exporta una crate `wawa-almacen-client` cross-bound +/// accesible desde apps WASM. +/// 2. `WawaSelector::new(namespace)` se conecta a ese cliente. +/// 3. `list_candidates()` consulta `almacen::listar(namespace)`. +/// 4. `realize(WawaHash(h))` invoca `almacen::cargar(h)`. +/// 5. `save(name, bytes)` invoca `almacen::ingerir(bytes)` y registra +/// el alias `name → hash`. +pub struct WawaSelector { + /// Namespace lógico (ej. `"pluma.documentos"`) — el almacén filtra + /// los khipus marcados con este tag. + pub namespace: String, +} + +impl WawaSelector { + pub fn new(namespace: impl Into) -> Self { + Self { namespace: namespace.into() } + } +} + +impl Selector for WawaSelector { + fn list_candidates(&self) -> SelectorResult> { + Err(format!( + "WawaSelector('{}') sin backend wawa registrado — pendiente de integración con wawa-almacen-client", + self.namespace + )) + } + + fn realize(&self, _handle: &ItemHandle) -> SelectorResult> { + Err("WawaSelector::realize sin backend wawa registrado".into()) + } + + fn save(&self, _name: &str, _bytes: &[u8]) -> SelectorResult { + Err("WawaSelector::save sin backend wawa registrado".into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_selector_accept_with_extensions() { + let s = HostSelector::new("/tmp", &[".pluma", ".khipu"]); + assert!(s.accept(Path::new("/tmp/foo.pluma"))); + assert!(s.accept(Path::new("/tmp/bar.khipu"))); + assert!(!s.accept(Path::new("/tmp/baz.txt"))); + } + + #[test] + fn host_selector_empty_extensions_accepts_all() { + let s = HostSelector::new("/tmp", &[]); + assert!(s.accept(Path::new("/tmp/anything.rs"))); + assert!(s.accept(Path::new("/tmp/anything.unknown"))); + } + + #[test] + fn wawa_selector_returns_err_until_backend_lands() { + let s = WawaSelector::new("pluma.documentos"); + assert!(s.list_candidates().is_err()); + } +} diff --git a/modules/shuma-term/Cargo.toml b/modules/shuma-term/Cargo.toml new file mode 100644 index 0000000..49beab4 --- /dev/null +++ b/modules/shuma-term/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "llimphi-module-shuma-term" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-module-shuma-term — terminal integrado tipo Ctrl+\\` de VS Code. Módulo Llimphi sobre shuma-exec (PTY real) + vt100 (emulación). Cualquier app Llimphi puede enchufar un terminal sandboxeado por el shell del usuario." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +shuma-exec = { path = "../../../shuma/sandbox/shuma-exec" } +vt100 = { workspace = true } diff --git a/modules/shuma-term/LEEME.md b/modules/shuma-term/LEEME.md new file mode 100644 index 0000000..b1d6806 --- /dev/null +++ b/modules/shuma-term/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-module-shuma-term + +> Terminal embebida (shell shuma) de [llimphi](../../README.md). + +Wrapper de [`shuma-shell-llimphi`](../../../shuma/shuma-shell-llimphi/README.md) montable adentro de cualquier app. Útil para `nada`, IDE-like setups. diff --git a/modules/shuma-term/README.md b/modules/shuma-term/README.md new file mode 100644 index 0000000..182a103 --- /dev/null +++ b/modules/shuma-term/README.md @@ -0,0 +1,5 @@ +# llimphi-module-shuma-term + +> Embedded terminal (shuma shell) of [llimphi](../../README.md). + +Wrapper of [`shuma-shell-llimphi`](../../../shuma/shuma-shell-llimphi/README.md) mountable inside any app. Useful for `nada`, IDE-like setups. diff --git a/modules/shuma-term/src/lib.rs b/modules/shuma-term/src/lib.rs new file mode 100644 index 0000000..5d14cc6 --- /dev/null +++ b/modules/shuma-term/src/lib.rs @@ -0,0 +1,511 @@ +//! `llimphi-module-shuma-term` — terminal integrado al estilo Ctrl+` de +//! VS Code o "Terminal" de JetBrains, pero enchufable en cualquier app +//! Llimphi. +//! +//! Lo monta sobre dos piezas que ya existen: +//! +//! - [`shuma_exec::Exec::Pty`] aloja un pseudo-terminal cross-platform +//! (`portable-pty`), lanza el shell con `TERM=xterm-256color`, y +//! entrega los bytes crudos por un canal MPSC. El módulo no toca +//! syscalls — sólo consume eventos. +//! - [`vt100::Parser`] convierte esos bytes en un buffer de pantalla +//! ANSI: cursor, erase, OSC, scrollback. El módulo le pasa los bytes +//! y al renderizar pide `screen().contents()`. +//! +//! Sigue el contrato Llimphi de `docs/MODULES.md`: `State + Msg + +//! Action + apply/on_key/open_shortcut/view + Palette`. +//! +//! ## Cómo lo enchufa una app (resumen) +//! +//! ```ignore +//! struct Model { term: Option, … } +//! enum Msg { Term(ShumaTermMsg), Tick, … } +//! +//! // open: shuma_term::spawn("/home/user", 100, 30)? +//! // on_key: si term.is_some() y on_key devuelve Some(msg) → Msg::Term(msg) +//! // si term.is_none() y open_shortcut(ev) → Msg::Term(Open) +//! // tick periódico: dispatch Msg::Term(Tick) para drenar PTY +//! // apply: match action { Close → model.term = None, SetStatus(s) → … } +//! // view: si term.is_some() → push view(...) +//! ``` + +#![forbid(unsafe_code)] + +use std::time::Instant; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View}; +use shuma_exec::{CommandSpec, Exec, Killer, RunEvent, RunHandle}; + +/// Capabilities que aporta este módulo al host. El host las puede +/// agregar a `provides` en su `card_core::Card` para que el broker +/// chasqui descubra que la instancia ofrece terminal integrado. +pub const CAPABILITIES: &[&str] = &["editor.terminal"]; + +/// Dimensiones por defecto del PTY. Cubren un panel inferior tipo +/// VS Code en una pantalla 1080p. Las apps pueden pasar otras a +/// [`spawn_with`]. +pub const DEFAULT_COLS: u16 = 100; +pub const DEFAULT_ROWS: u16 = 24; + +const SCROLLBACK: usize = 2000; + +// ===================================================================== +// State +// ===================================================================== + +/// Estado del panel terminal. Encapsula el `RunHandle` del shell y un +/// `vt100::Parser` que mantiene el buffer de pantalla. No es `Clone` +/// (los handles son únicos), y el host lo embebe como +/// `Option`. +pub struct ShumaTermState { + handle: RunHandle, + killer: Killer, + parser: vt100::Parser, + cols: u16, + rows: u16, + /// Si el shell ya emitió `Exited(code)`. El panel se queda visible + /// para que el usuario pueda leer la última salida antes de cerrar. + exit_code: Option, + /// CWD inicial — útil para el header sin tener que tocar /proc. + cwd: String, + started_at: Instant, +} + +impl ShumaTermState { + /// Bytes que el módulo ya consumió desde el PTY. Útil para tests y + /// debug — no es parte del contrato Tier 1. + pub fn screen_contents(&self) -> String { + self.parser.screen().contents() + } + + pub fn cols(&self) -> u16 { + self.cols + } + pub fn rows(&self) -> u16 { + self.rows + } + pub fn exit_code(&self) -> Option { + self.exit_code + } + pub fn cwd(&self) -> &str { + &self.cwd + } +} + +impl Drop for ShumaTermState { + fn drop(&mut self) { + // Si el host descarta el state (panel cerrado), no dejamos al + // shell huérfano consumiendo CPU. SIGTERM educado primero; + // shuma-exec se encarga del SIGKILL si hace falta. + self.killer.term(); + } +} + +/// Lanza el shell por defecto (`$SHELL`, fallback `/bin/sh`) en `cwd` +/// con tamaño de PTY por defecto. +pub fn spawn(cwd: impl Into) -> ShumaTermState { + spawn_with(cwd, default_shell(), Vec::new(), DEFAULT_COLS, DEFAULT_ROWS) +} + +/// Variante con control fino de programa, args y tamaño. +pub fn spawn_with( + cwd: impl Into, + program: String, + args: Vec, + cols: u16, + rows: u16, +) -> ShumaTermState { + let cwd = cwd.into(); + let spec = CommandSpec { + exec: Exec::Pty { program, args, cols, rows }, + cwd: cwd.clone(), + capture_limit: 0, + spill_path: None, + stdin_data: None, + capture_stages: false, + }; + let handle = shuma_exec::run(&spec); + let killer = handle.killer(); + ShumaTermState { + handle, + killer, + parser: vt100::Parser::new(rows, cols, SCROLLBACK), + cols, + rows, + exit_code: None, + cwd, + started_at: Instant::now(), + } +} + +fn default_shell() -> String { + std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()) +} + +// ===================================================================== +// Msg / Action +// ===================================================================== + +/// Vocabulario interno. El host lo wrapea en su `Msg`. +#[derive(Debug, Clone)] +pub enum ShumaTermMsg { + /// Símbolo conveniente para que el host dispatche al detectar el + /// shortcut. El módulo no crea el state él mismo — el host lo crea + /// con [`spawn`] porque conoce el cwd canónico de la app. + Open, + /// El usuario pidió cerrar el panel. + Close, + /// Tecla mientras el panel está enfocado. Se traduce a bytes y se + /// reenvía al PTY. + KeyInput(KeyEvent), + /// Tick del host: drena eventos pendientes del PTY (bytes y exit). + /// El host debe enviar este Msg de forma periódica (en cada frame, + /// o cuando hay actividad). Sin Tick el terminal no avanza. + Tick, + /// Mata el shell (SIGTERM); el panel queda visible mostrando el + /// estado final hasta que el host reciba `Close`. + Terminate, +} + +/// Efecto solicitado al host. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShumaTermAction { + None, + /// El host debería remover el state del modelo. + Close, + /// El host debería actualizar su barra de estado. + SetStatus(String), +} + +// ===================================================================== +// apply / on_key / open_shortcut +// ===================================================================== + +/// Aplica un mensaje al estado. +pub fn apply(state: &mut ShumaTermState, msg: ShumaTermMsg) -> ShumaTermAction { + match msg { + ShumaTermMsg::Open => ShumaTermAction::None, + ShumaTermMsg::Close => ShumaTermAction::Close, + ShumaTermMsg::Terminate => { + state.killer.term(); + ShumaTermAction::SetStatus("shuma · SIGTERM".into()) + } + ShumaTermMsg::Tick => drain(state), + ShumaTermMsg::KeyInput(ev) => { + // Interceptaciones del módulo (no llegan al PTY): + // Ctrl+Shift+W → cierra el panel. + // Cualquier otra combinación se traduce a bytes y se envía. + if ev.state == KeyState::Pressed + && ev.modifiers.ctrl + && ev.modifiers.shift + && matches!(&ev.key, Key::Character(s) if s.eq_ignore_ascii_case("w")) + { + return ShumaTermAction::Close; + } + let bytes = key_to_bytes(&ev); + if !bytes.is_empty() { + state.handle.write_input(bytes); + } + ShumaTermAction::None + } + } +} + +/// Routing de teclas cuando el panel está enfocado. Devuelve `Some` para +/// todo evento `Pressed` — el terminal **traga** las teclas; el host no +/// debe reusarlas para sus propios atajos mientras este panel esté +/// activo (la excepción es el atajo de apertura, que el host filtra +/// antes de delegar). +pub fn on_key(_state: &ShumaTermState, event: &KeyEvent) -> Option { + if event.state != KeyState::Pressed { + return None; + } + Some(ShumaTermMsg::KeyInput(event.clone())) +} + +/// El atajo recomendado para abrir: **Ctrl+`** (backtick), igual que +/// VS Code. Los hosts pueden ignorarlo y usar otro. +pub fn open_shortcut(event: &KeyEvent) -> bool { + event.state == KeyState::Pressed + && event.modifiers.ctrl + && !event.modifiers.shift + && matches!(&event.key, Key::Character(s) if s == "`") +} + +// ===================================================================== +// Drenado del PTY +// ===================================================================== + +fn drain(state: &mut ShumaTermState) -> ShumaTermAction { + let mut bytes_in = 0usize; + let mut final_action = ShumaTermAction::None; + for ev in state.handle.try_events() { + match ev { + RunEvent::Bytes(b) => { + bytes_in += b.len(); + state.parser.process(&b); + } + RunEvent::Exited(code) => { + state.exit_code = Some(code); + let elapsed = state.started_at.elapsed().as_secs_f64(); + final_action = ShumaTermAction::SetStatus(format!( + "shuma · exit {code} · {elapsed:.1}s" + )); + } + RunEvent::Failed(err) => { + state.exit_code = Some(-1); + final_action = + ShumaTermAction::SetStatus(format!("shuma · falló: {err}")); + } + // Stdout/Stderr/Truncated/Spilled no aplican al modo Pty. + _ => {} + } + } + if matches!(final_action, ShumaTermAction::None) && bytes_in > 0 { + // Nada que reportar — el repaint que el host hará por el frame + // basta para mostrar lo nuevo. + ShumaTermAction::None + } else { + final_action + } +} + +// ===================================================================== +// Mapeo KeyEvent → bytes +// ===================================================================== + +/// Convierte un `KeyEvent` ya recibido en los bytes que un terminal +/// xterm espera. Cubre el subset usable (chars + control + flechas + +/// home/end/page + fn keys), suficiente para shells modernos, TUIs +/// (vim, htop, less) y CLIs interactivas (claude code, fzf). +pub fn key_to_bytes(ev: &KeyEvent) -> Vec { + if ev.state != KeyState::Pressed { + return Vec::new(); + } + + // Teclas con nombre primero: flechas, etc. Se mapean a CSI/SS3 + // estándar (xterm-256color). + if let Key::Named(named) = &ev.key { + return named_to_bytes(*named); + } + + // Caracter: si hay Ctrl+letra → control byte (Ctrl+C = 0x03). + if let Key::Character(s) = &ev.key { + if ev.modifiers.ctrl && !ev.modifiers.alt { + if let Some(b) = ctrl_byte(s) { + return vec![b]; + } + } + // Alt+x → ESC + x (convención xterm meta-sends-escape). + if ev.modifiers.alt { + let mut out = vec![0x1b]; + out.extend_from_slice(s.as_bytes()); + return out; + } + } + + // Caso general: si el backend ya nos dio el texto resultante + // (con shift/IME aplicados), eso es lo correcto para mandar. + if let Some(text) = &ev.text { + return text.as_bytes().to_vec(); + } + Vec::new() +} + +fn named_to_bytes(k: NamedKey) -> Vec { + match k { + // PTYs en modo raw esperan CR para Enter; el driver convierte a LF. + NamedKey::Enter => b"\r".to_vec(), + // Backspace moderno = DEL (0x7f). Los shells lo entienden mejor + // que 0x08, que se reserva para ^H en TUIs viejos. + NamedKey::Backspace => vec![0x7f], + NamedKey::Tab => b"\t".to_vec(), + NamedKey::Escape => vec![0x1b], + NamedKey::ArrowUp => b"\x1b[A".to_vec(), + NamedKey::ArrowDown => b"\x1b[B".to_vec(), + NamedKey::ArrowRight => b"\x1b[C".to_vec(), + NamedKey::ArrowLeft => b"\x1b[D".to_vec(), + NamedKey::Home => b"\x1b[H".to_vec(), + NamedKey::End => b"\x1b[F".to_vec(), + NamedKey::PageUp => b"\x1b[5~".to_vec(), + NamedKey::PageDown => b"\x1b[6~".to_vec(), + NamedKey::Delete => b"\x1b[3~".to_vec(), + NamedKey::Insert => b"\x1b[2~".to_vec(), + NamedKey::F1 => b"\x1bOP".to_vec(), + NamedKey::F2 => b"\x1bOQ".to_vec(), + NamedKey::F3 => b"\x1bOR".to_vec(), + NamedKey::F4 => b"\x1bOS".to_vec(), + NamedKey::F5 => b"\x1b[15~".to_vec(), + NamedKey::F6 => b"\x1b[17~".to_vec(), + NamedKey::F7 => b"\x1b[18~".to_vec(), + NamedKey::F8 => b"\x1b[19~".to_vec(), + NamedKey::F9 => b"\x1b[20~".to_vec(), + NamedKey::F10 => b"\x1b[21~".to_vec(), + NamedKey::F11 => b"\x1b[23~".to_vec(), + NamedKey::F12 => b"\x1b[24~".to_vec(), + _ => Vec::new(), + } +} + +/// Ctrl+letter → byte de control ASCII (Ctrl+A=1, Ctrl+B=2, ..., Ctrl+Z=26). +/// Maneja también Ctrl+@ (NUL), Ctrl+[ (ESC), Ctrl+\\ (FS), Ctrl+] (GS), +/// Ctrl+^ (RS), Ctrl+_ (US), Ctrl+? (DEL). +fn ctrl_byte(s: &str) -> Option { + let c = s.chars().next()?; + match c { + 'a'..='z' => Some((c as u8) - b'a' + 1), + 'A'..='Z' => Some((c as u8) - b'A' + 1), + '@' => Some(0), + '[' => Some(0x1b), + '\\' => Some(0x1c), + ']' => Some(0x1d), + '^' => Some(0x1e), + '_' => Some(0x1f), + '?' => Some(0x7f), + ' ' => Some(0), // Ctrl+Space = NUL, convención xterm + _ => None, + } +} + +// ===================================================================== +// View +// ===================================================================== + +/// Paleta visual del terminal. Monospace; fondo más oscuro que el +/// panel general para que el terminal "viva" visualmente. +#[derive(Debug, Clone)] +pub struct ShumaTermPalette { + pub bg_panel: llimphi_ui::llimphi_raster::peniko::Color, + pub bg_header: llimphi_ui::llimphi_raster::peniko::Color, + pub fg_text: llimphi_ui::llimphi_raster::peniko::Color, + pub fg_muted: llimphi_ui::llimphi_raster::peniko::Color, +} + +impl ShumaTermPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg_panel: t.bg_panel_alt, + bg_header: t.bg_panel, + fg_text: t.fg_text, + fg_muted: t.fg_muted, + } + } +} + +const HEADER_H: f32 = 18.0; +const ROW_H: f32 = 14.0; +const CHAR_W: f32 = 7.5; + +/// Render del panel. `to_host` mapea cada `ShumaTermMsg` al `Msg` del +/// host. `height_px` es la altura total del panel — el módulo divide +/// entre header + grid. +pub fn view( + state: &ShumaTermState, + palette: &ShumaTermPalette, + height_px: f32, + to_host: F, +) -> View +where + HostMsg: Clone + 'static, + F: Fn(ShumaTermMsg) -> HostMsg + Copy + 'static, +{ + let _ = to_host; // v0 no monta eventos puntuales sobre el grid + + let header_text = match state.exit_code { + Some(code) => format!( + "shuma · {} · exit {code} · Ctrl+Shift+W cierra", + state.cwd + ), + None => format!( + "shuma · {} · {}×{} · Ctrl+Shift+W cierra · Esc envía al shell", + state.cwd, state.cols, state.rows + ), + }; + + let header = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(HEADER_H) }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_header) + .text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start); + + let contents = state.parser.screen().contents(); + let grid_h = (height_px - HEADER_H).max(0.0); + let max_rows = ((grid_h / ROW_H) as usize).max(1); + + // Tomamos las últimas `max_rows` líneas — preferimos mostrar el + // tail (donde está el cursor / prompt) si el render no alcanza + // para toda la pantalla. + let all_lines: Vec<&str> = contents.split('\n').collect(); + let start = all_lines.len().saturating_sub(max_rows); + let mut rows: Vec> = Vec::with_capacity(max_rows); + for line in &all_lines[start..] { + rows.push( + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(ROW_H) }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_panel) + .text_aligned((*line).to_string(), 11.0, palette.fg_text, Alignment::Start), + ); + } + // Si el render quedó corto, rellenamos con líneas vacías para que el + // panel mantenga su altura visual. + while rows.len() < max_rows { + rows.push( + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(ROW_H) }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_panel), + ); + } + + let mut children: Vec> = Vec::with_capacity(1 + rows.len()); + children.push(header); + children.extend(rows); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: length(height_px) }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_panel) + .children(children) +} + +/// Estimación heurística de cuántas columnas caben en `width_px` con la +/// fuente actual. Útil para que el host calcule el tamaño antes de +/// llamar a [`spawn_with`]. +pub fn cols_for_width(width_px: f32) -> u16 { + ((width_px / CHAR_W).floor() as u16).max(20) +} + +/// Idem para filas a partir de la altura disponible del panel +/// (descontando el header). +pub fn rows_for_height(height_px: f32) -> u16 { + (((height_px - HEADER_H) / ROW_H).floor() as u16).max(5) +} diff --git a/modules/shuma-term/tests/smoke.rs b/modules/shuma-term/tests/smoke.rs new file mode 100644 index 0000000..1200a6c --- /dev/null +++ b/modules/shuma-term/tests/smoke.rs @@ -0,0 +1,157 @@ +//! Smoke test del terminal: spawnea un shell, le tipea `echo hola`, +//! drena hasta ver el output, y verifica que el contenido del screen +//! contenga "hola". Cierre con SIGTERM se valida por el Drop. +//! +//! Requiere `/bin/sh` y un sistema Linux real (no corre en sandbox +//! puro). Es razonable porque shuma-exec ya lo asume. + +use std::time::{Duration, Instant}; + +use llimphi_module_shuma_term::{self as term, ShumaTermAction, ShumaTermMsg}; + +#[test] +fn echo_a_traves_del_pty_aparece_en_el_screen() { + let mut state = term::spawn_with( + "/tmp".to_string(), + "/bin/sh".to_string(), + Vec::new(), + 80, + 24, + ); + + // El shell escribe su prompt al arrancar; lo drenamos sin asumir + // su contenido (cambia por distro). + spin_drain(&mut state, Duration::from_millis(200)); + + // Tipeamos el comando. Sin Llimphi alrededor llamamos a write_input + // directamente — el módulo permite hacerlo via KeyInput, pero + // construir KeyEvents acá es ruido para este test. + write_raw(&mut state, b"echo hola_del_test\n"); + + // Esperamos hasta 2s a que el output llegue al screen. + let deadline = Instant::now() + Duration::from_secs(2); + let mut visto = false; + while Instant::now() < deadline { + spin_drain(&mut state, Duration::from_millis(50)); + if state.screen_contents().contains("hola_del_test") { + visto = true; + break; + } + } + assert!( + visto, + "esperaba ver 'hola_del_test' en el screen, contenido actual:\n{}", + state.screen_contents() + ); +} + +#[test] +fn ctrl_shift_w_emite_action_close_sin_pasar_al_pty() { + use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers}; + + let mut state = term::spawn_with( + "/tmp".to_string(), + "/bin/sh".to_string(), + Vec::new(), + 80, + 24, + ); + spin_drain(&mut state, Duration::from_millis(100)); + + let ev = KeyEvent { + key: Key::Character("w".into()), + state: KeyState::Pressed, + text: Some("w".into()), + modifiers: Modifiers { ctrl: true, shift: true, ..Modifiers::default() }, + repeat: false, + }; + let action = term::apply(&mut state, ShumaTermMsg::KeyInput(ev)); + assert_eq!(action, ShumaTermAction::Close); +} + +#[test] +fn key_to_bytes_mapea_los_casos_canonicos() { + use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers, NamedKey}; + + let mk = |key: Key, mods: Modifiers, text: Option<&str>| KeyEvent { + key, + state: KeyState::Pressed, + text: text.map(|s| s.to_string()), + modifiers: mods, + repeat: false, + }; + + // Enter → CR (no LF — el driver del PTY lo expande). + assert_eq!( + term::key_to_bytes(&mk(Key::Named(NamedKey::Enter), Modifiers::default(), None)), + b"\r" + ); + // Backspace → DEL. + assert_eq!( + term::key_to_bytes(&mk( + Key::Named(NamedKey::Backspace), + Modifiers::default(), + None + )), + vec![0x7f] + ); + // ArrowUp → CSI A. + assert_eq!( + term::key_to_bytes(&mk( + Key::Named(NamedKey::ArrowUp), + Modifiers::default(), + None + )), + b"\x1b[A" + ); + // Ctrl+C → 0x03. + let ctrl = Modifiers { ctrl: true, ..Modifiers::default() }; + assert_eq!( + term::key_to_bytes(&mk(Key::Character("c".into()), ctrl, Some("c"))), + vec![0x03] + ); + // Texto plano (con shift aplicado por el backend) → ese mismo texto. + assert_eq!( + term::key_to_bytes(&mk(Key::Character("A".into()), Modifiers::default(), Some("A"))), + b"A" + ); + // Alt+x → ESC + x. + let alt = Modifiers { alt: true, ..Modifiers::default() }; + assert_eq!( + term::key_to_bytes(&mk(Key::Character("x".into()), alt, Some("x"))), + vec![0x1b, b'x'] + ); +} + +// --------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------- + +/// Pequeño polling: dispara Tick varias veces durante `total` para que +/// el módulo drene los bytes que el reader thread haya emitido. +fn spin_drain(state: &mut llimphi_module_shuma_term::ShumaTermState, total: Duration) { + let deadline = Instant::now() + total; + while Instant::now() < deadline { + term::apply(state, ShumaTermMsg::Tick); + std::thread::sleep(Duration::from_millis(10)); + } +} + +/// Atajo: enviar bytes crudos al PTY sin construir un KeyEvent. Usa la +/// API pública via un truco — convertimos a un KeyEvent "texto" para +/// evitar exponer write_input crudo en el contrato. +fn write_raw(state: &mut llimphi_module_shuma_term::ShumaTermState, bytes: &[u8]) { + use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers}; + // Texto entero (incluyendo el LF) en un solo KeyInput. apply() lo + // copia tal cual al PTY via la rama `text`. + let s = std::str::from_utf8(bytes).expect("test usa ascii"); + let ev = KeyEvent { + // Key::Character vacío para que no entremos por la rama ctrl/alt. + key: Key::Character("".into()), + state: KeyState::Pressed, + text: Some(s.to_string()), + modifiers: Modifiers::default(), + repeat: false, + }; + term::apply(state, ShumaTermMsg::KeyInput(ev)); +} diff --git a/modules/symbol-outline/Cargo.toml b/modules/symbol-outline/Cargo.toml new file mode 100644 index 0000000..88d6866 --- /dev/null +++ b/modules/symbol-outline/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "llimphi-module-symbol-outline" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-module-symbol-outline — outline del documento (funciones, structs, métodos) navegable con fuzzy filter. Módulo Llimphi reutilizable: el host le pasa un Vec y el módulo emite GoTo(line, col). No depende de LSP — el host puede poblarlo desde cualquier fuente (tree-sitter, parser propio, LSP)." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-text-input = { workspace = true } +nucleo-matcher = { workspace = true } diff --git a/modules/symbol-outline/LEEME.md b/modules/symbol-outline/LEEME.md new file mode 100644 index 0000000..9b7d8e2 --- /dev/null +++ b/modules/symbol-outline/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-module-symbol-outline + +> Outline de símbolos LSP de [llimphi](../../README.md). + +Vista jerárquica de los símbolos del archivo activo (funciones, structs, módulos, ...) — alimentada por el LSP servidor del lenguaje. Click salta al símbolo. diff --git a/modules/symbol-outline/README.md b/modules/symbol-outline/README.md new file mode 100644 index 0000000..9b71192 --- /dev/null +++ b/modules/symbol-outline/README.md @@ -0,0 +1,5 @@ +# llimphi-module-symbol-outline + +> LSP symbol outline of [llimphi](../../README.md). + +Hierarchical view of the active file's symbols (functions, structs, modules, ...) — fed by the language's LSP server. Click jumps to the symbol. diff --git a/modules/symbol-outline/src/lib.rs b/modules/symbol-outline/src/lib.rs new file mode 100644 index 0000000..5e5b02d --- /dev/null +++ b/modules/symbol-outline/src/lib.rs @@ -0,0 +1,352 @@ +//! `llimphi-module-symbol-outline` — outline navegable de símbolos. +//! +//! Equivalente al "Outline" panel de VS Code o "Structure" de JetBrains. +//! El host arma una lista plana de [`SymbolItem`] (funciones, structs, +//! métodos, con su posición en el buffer) y el módulo presenta un +//! overlay con input + lista rankeada por fuzzy. Cuando el user pica +//! uno, el módulo emite [`OutlineAction::GoTo`] y el host mueve el caret. +//! +//! El módulo es **agnóstico de la fuente de símbolos**. El host puede +//! poblarlo desde: +//! +//! - LSP (`textDocument/documentSymbol`) — fuente canónica. +//! - tree-sitter — sirve para archivos sin LSP. +//! - parser propio del lenguaje del host. +//! - una lista hardcodeada (en una app no-código que tenga "secciones"). +//! +//! Sigue el contrato Llimphi de `docs/MODULES.md`: +//! `State + Msg + Action + apply/on_key/open_shortcut/view + Palette`. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View}; +use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState}; + +/// Capabilities que aporta este módulo al host. +pub const CAPABILITIES: &[&str] = &["editor.symbol-outline"]; + +pub const MAX_RESULTS: usize = 500; + +const BAR_H: f32 = 320.0; +const ROW_H: f32 = 20.0; +const MAX_VISIBLE: usize = 12; + +/// Un símbolo del documento. Los campos son convencionales: +/// +/// - `name`: nombre visible (`foo`, `MyStruct`, `parse_line`). +/// - `kind`: etiqueta corta del tipo de símbolo (`fn`, `struct`, `method`, +/// `mod`, `const`, …). El módulo la pinta sin interpretar — el host +/// elige el vocabulario (LSP usa `SymbolKind` numérico; el host +/// convierte a string). +/// - `line`, `col`: posición 0-based en el buffer. El módulo no toca +/// coordenadas — sólo las devuelve en `GoTo`. +/// - `container`: nombre del símbolo padre (`Some("MyStruct")` para +/// un método). Visible en el render como anotación a la derecha; +/// también participa del fuzzy match para que tipear el nombre de +/// la clase filtre sus métodos. +/// - `depth`: profundidad jerárquica para indentación visual. El +/// módulo asume que la lista ya viene ordenada (parent antes que +/// children, en orden de aparición). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SymbolItem { + pub name: String, + pub kind: String, + pub line: usize, + pub col: usize, + pub container: Option, + pub depth: u32, +} + +/// Estado interno. `results` son índices al slice de symbols que pasa +/// el host: el módulo no copia, sólo guarda índices. +pub struct OutlineState { + pub input: TextInputState, + pub results: Vec, + pub selected: usize, +} + +impl Default for OutlineState { + fn default() -> Self { + Self::new_empty() + } +} + +impl OutlineState { + pub fn new_empty() -> Self { + Self { + input: TextInputState::new(), + results: Vec::new(), + selected: 0, + } + } + + /// Crea un outline poblado con todos los símbolos sin filtro. + pub fn new(items: &[SymbolItem]) -> Self { + let mut s = Self::new_empty(); + refilter(&mut s, items); + s + } +} + +/// Vocabulario interno. El host lo wrapea en su Msg. +#[derive(Clone)] +pub enum OutlineMsg { + /// Símbolo conveniente que el host emite al detectar el shortcut. + /// El módulo no construye el state ni la lista él mismo. + Open, + Close, + KeyInput(KeyEvent), + Nav(i32), + /// Enter: salta al símbolo seleccionado. + Apply, +} + +/// Efecto solicitado al host. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OutlineAction { + None, + /// El host debería remover el state del modelo. + Close, + /// El host debería mover el caret a esta posición del buffer activo. + /// El módulo NO se cierra automáticamente — el host decide + /// (típicamente sí, para que la navegación sea "salta y mira"). + GoTo { line: usize, col: usize }, +} + +/// Aplica un mensaje al estado. +pub fn apply( + state: &mut OutlineState, + msg: OutlineMsg, + items: &[SymbolItem], +) -> OutlineAction { + match msg { + OutlineMsg::Open => OutlineAction::None, + OutlineMsg::Close => OutlineAction::Close, + OutlineMsg::KeyInput(ev) => { + state.input.apply_key(&ev); + refilter(state, items); + OutlineAction::None + } + OutlineMsg::Nav(d) => { + let n = state.results.len() as i32; + if n > 0 { + state.selected = (state.selected as i32 + d).rem_euclid(n) as usize; + } + OutlineAction::None + } + OutlineMsg::Apply => { + let Some(&idx) = state.results.get(state.selected) else { + return OutlineAction::None; + }; + let Some(it) = items.get(idx) else { + return OutlineAction::None; + }; + OutlineAction::GoTo { line: it.line, col: it.col } + } + } +} + +/// Routing de teclas cuando el outline está abierto. +pub fn on_key(_state: &OutlineState, event: &KeyEvent) -> Option { + if event.state != KeyState::Pressed { + return None; + } + Some(match &event.key { + Key::Named(NamedKey::Escape) => OutlineMsg::Close, + Key::Named(NamedKey::Enter) => OutlineMsg::Apply, + Key::Named(NamedKey::ArrowDown) => OutlineMsg::Nav(1), + Key::Named(NamedKey::ArrowUp) => OutlineMsg::Nav(-1), + _ => OutlineMsg::KeyInput(event.clone()), + }) +} + +/// El atajo recomendado: **Ctrl+Shift+O**, igual que VS Code. +pub fn open_shortcut(event: &KeyEvent) -> bool { + event.state == KeyState::Pressed + && event.modifiers.ctrl + && event.modifiers.shift + && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("o")) +} + +/// Recalcula `state.results` con fuzzy match sobre `"name kind container"`. +/// Query vacío = lista completa. Cap: [`MAX_RESULTS`]. +pub fn refilter(state: &mut OutlineState, items: &[SymbolItem]) { + let q = state.input.text(); + if q.trim().is_empty() { + state.results = (0..items.len().min(MAX_RESULTS)).collect(); + state.selected = 0; + return; + } + use nucleo_matcher::{ + pattern::{CaseMatching, Normalization, Pattern}, + Config, Matcher, Utf32Str, + }; + let mut matcher = Matcher::new(Config::DEFAULT); + let pat = Pattern::parse(&q, CaseMatching::Smart, Normalization::Smart); + let mut scored: Vec<(u32, usize)> = Vec::new(); + let mut buf = Vec::new(); + for (i, it) in items.iter().enumerate() { + let hay_str = match &it.container { + Some(c) => format!("{} {} {c}", it.name, it.kind), + None => format!("{} {}", it.name, it.kind), + }; + buf.clear(); + let hay = Utf32Str::new(&hay_str, &mut buf); + if let Some(score) = pat.score(hay, &mut matcher) { + scored.push((score, i)); + } + } + scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); + scored.truncate(MAX_RESULTS); + state.results = scored.into_iter().map(|(_, i)| i).collect(); + state.selected = 0; +} + +/// Paleta visual. +#[derive(Debug, Clone)] +pub struct OutlinePalette { + pub bg_panel: Color, + pub bg_header: Color, + pub bg_selected: Color, + pub fg_text: Color, + pub fg_muted: Color, + theme: llimphi_theme::Theme, +} + +impl OutlinePalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg_panel: t.bg_panel, + bg_header: t.bg_panel_alt, + bg_selected: t.bg_selected, + fg_text: t.fg_text, + fg_muted: t.fg_muted, + theme: t.clone(), + } + } +} + +/// Render del overlay. `to_host` mapea cada `OutlineMsg` al `Msg` de la +/// app. +pub fn view( + state: &OutlineState, + items: &[SymbolItem], + palette: &OutlinePalette, + to_host: F, +) -> View +where + HostMsg: Clone + 'static, + F: Fn(OutlineMsg) -> HostMsg + Copy + 'static, +{ + let header = if items.is_empty() { + "outline · sin símbolos · Esc cierra".to_string() + } else if state.results.is_empty() { + format!("outline · sin matches · {} símbolos · Esc cierra", items.len()) + } else { + format!( + "outline · {} / {} · ↓↑ navega · Enter salta · Esc cierra", + state.selected + 1, + state.results.len(), + ) + }; + + let header_view = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(18.0_f32) }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_header) + .text_aligned(header, 10.0, palette.fg_muted, Alignment::Start); + + let tp = TextInputPalette::from_theme(&palette.theme); + let input_view = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(26.0_f32) }, + padding: Rect { + left: length(6.0_f32), + right: length(6.0_f32), + top: length(2.0_f32), + bottom: length(2.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_panel) + .children(vec![text_input_view( + &state.input, + "filtro: nombre del símbolo o clase…", + true, + &tp, + to_host(OutlineMsg::Open), + )]); + + let visible_start = state.selected.saturating_sub(MAX_VISIBLE.saturating_sub(1)); + let visible_end = (visible_start + MAX_VISIBLE).min(state.results.len()); + let mut rows: Vec> = Vec::with_capacity(MAX_VISIBLE); + for i in visible_start..visible_end { + let Some(&idx) = state.results.get(i) else { continue }; + let Some(it) = items.get(idx) else { continue }; + // Indentación visual por depth (sólo cuando no hay query — con + // query el orden ya vino del ranking y la jerarquía se pierde). + let indent = if state.input.text().trim().is_empty() { + " ".repeat(it.depth as usize) + } else { + String::new() + }; + let container_tag = match &it.container { + Some(c) if !c.is_empty() => format!(" in {c}"), + _ => String::new(), + }; + let label = format!( + "{indent}{} {} line {}{container_tag}", + it.kind, + it.name, + it.line + 1, + ); + let selected = i == state.selected; + let bg = if selected { palette.bg_selected } else { palette.bg_panel }; + let fg = if selected { palette.fg_text } else { palette.fg_muted }; + rows.push( + View::new(Style { + size: Size { width: percent(1.0_f32), height: length(ROW_H) }, + padding: Rect { + left: length(10.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(bg) + .text_aligned(label, 11.0, fg, Alignment::Start), + ); + } + + let mut children: Vec> = Vec::with_capacity(2 + rows.len()); + children.push(header_view); + children.push(input_view); + children.extend(rows); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: length(BAR_H) }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_panel) + .children(children) +} diff --git a/modules/symbol-outline/tests/smoke.rs b/modules/symbol-outline/tests/smoke.rs new file mode 100644 index 0000000..c72686a --- /dev/null +++ b/modules/symbol-outline/tests/smoke.rs @@ -0,0 +1,130 @@ +//! Smoke tests del fuzzy match y el routing de teclas. Sin backend +//! gráfico — sólo `apply` + `refilter`. + +use llimphi_module_symbol_outline::{ + self as outline, OutlineAction, OutlineMsg, OutlineState, SymbolItem, +}; +use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers}; + +fn seed() -> Vec { + vec![ + SymbolItem { + name: "Model".into(), + kind: "struct".into(), + line: 100, + col: 0, + container: None, + depth: 0, + }, + SymbolItem { + name: "init".into(), + kind: "fn".into(), + line: 110, + col: 4, + container: Some("Model".into()), + depth: 1, + }, + SymbolItem { + name: "update".into(), + kind: "fn".into(), + line: 200, + col: 4, + container: Some("Model".into()), + depth: 1, + }, + SymbolItem { + name: "Renderer".into(), + kind: "struct".into(), + line: 300, + col: 0, + container: None, + depth: 0, + }, + SymbolItem { + name: "draw".into(), + kind: "fn".into(), + line: 310, + col: 4, + container: Some("Renderer".into()), + depth: 1, + }, + ] +} + +fn key_char(c: &str) -> KeyEvent { + KeyEvent { + key: Key::Character(c.into()), + state: KeyState::Pressed, + text: Some(c.into()), + modifiers: Modifiers::default(), + repeat: false, + } +} + +#[test] +fn estado_vacio_lista_todos_los_simbolos() { + let items = seed(); + let s = OutlineState::new(&items); + assert_eq!(s.results.len(), items.len()); +} + +#[test] +fn fuzzy_match_filtra_por_nombre_de_clase_contenedora() { + // Tipear "render" debería traer `draw` (su container es "Renderer") + // gracias a que refilter incluye container en la haystack. + let items = seed(); + let mut s = OutlineState::new(&items); + for ch in ["r", "e", "n", "d", "e", "r"] { + outline::apply(&mut s, OutlineMsg::KeyInput(key_char(ch)), &items); + } + let names: Vec<&str> = s.results.iter().map(|&i| items[i].name.as_str()).collect(); + assert!( + names.contains(&"draw") || names.contains(&"Renderer"), + "esperaba draw o Renderer en {names:?}" + ); +} + +#[test] +fn apply_emite_goto_con_line_col_del_item_seleccionado() { + let items = seed(); + let mut s = OutlineState::new(&items); + // Filtrar "update". + for ch in ["u", "p", "d", "a", "t", "e"] { + outline::apply(&mut s, OutlineMsg::KeyInput(key_char(ch)), &items); + } + let action = outline::apply(&mut s, OutlineMsg::Apply, &items); + assert_eq!(action, OutlineAction::GoTo { line: 200, col: 4 }); +} + +#[test] +fn nav_wrap_around() { + let items = seed(); + let mut s = OutlineState::new(&items); + assert_eq!(s.selected, 0); + outline::apply(&mut s, OutlineMsg::Nav(-1), &items); + assert_eq!(s.selected, items.len() - 1); +} + +#[test] +fn open_shortcut_es_ctrl_shift_o() { + let mk = |ctrl: bool, shift: bool, c: &str| KeyEvent { + key: Key::Character(c.into()), + state: KeyState::Pressed, + text: Some(c.into()), + modifiers: Modifiers { ctrl, shift, ..Modifiers::default() }, + repeat: false, + }; + assert!(outline::open_shortcut(&mk(true, true, "o"))); + assert!(outline::open_shortcut(&mk(true, true, "O"))); + assert!(!outline::open_shortcut(&mk(true, false, "o"))); + assert!(!outline::open_shortcut(&mk(false, true, "o"))); +} + +#[test] +fn items_vacios_no_paniquean() { + let items: Vec = Vec::new(); + let mut s = OutlineState::new(&items); + assert!(s.results.is_empty()); + let action = outline::apply(&mut s, OutlineMsg::Apply, &items); + assert_eq!(action, OutlineAction::None); +} diff --git a/widgets/app-header/Cargo.toml b/widgets/app-header/Cargo.toml new file mode 100644 index 0000000..ac9d061 --- /dev/null +++ b/widgets/app-header/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-app-header" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-app-header — tira superior estándar para apps Llimphi: label dinámico a la izquierda + slot de acciones opcional a la derecha. Análogo Llimphi al `nahual-widget-app-header` GPUI." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-panel = { workspace = true } diff --git a/widgets/app-header/LEEME.md b/widgets/app-header/LEEME.md new file mode 100644 index 0000000..98d8427 --- /dev/null +++ b/widgets/app-header/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-app-header + +> Header común de app para [llimphi](../../README.md). + +Barra superior estándar: logo/icono · título · acciones a la derecha · breadcrumb opcional. Cualquier app del monorepo lo usa para coherencia visual. diff --git a/widgets/app-header/README.md b/widgets/app-header/README.md new file mode 100644 index 0000000..5786f78 --- /dev/null +++ b/widgets/app-header/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-app-header + +> Common app header for [llimphi](../../README.md). + +Standard top bar: logo/icon · title · right-side actions · optional breadcrumb. Any monorepo app uses it for visual coherence. diff --git a/widgets/app-header/src/lib.rs b/widgets/app-header/src/lib.rs new file mode 100644 index 0000000..b8fd724 --- /dev/null +++ b/widgets/app-header/src/lib.rs @@ -0,0 +1,145 @@ +//! `llimphi-widget-app-header` — tira superior estándar de las apps. +//! +//! Reproduce el contrato del `nahual-widget-app-header` GPUI: label +//! dinámico a la izquierda con `flex_grow`, slot a la derecha para +//! acciones (theme switcher, botones de toolbar, etc.). bg = `bg_panel`, +//! line-bottom como `border` del theme. +//! +//! Uso típico: +//! +//! ```ignore +//! app_header(format!("Log: {} · {} entries", path, n), vec![], &palette) +//! ``` + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_widget_panel::{panel_signature_painter, PanelStyle}; + +/// Paleta del header. Defaults desde el theme global. +#[derive(Debug, Clone, Copy)] +pub struct AppHeaderPalette { + pub bg: Color, + pub border_bottom: Color, + pub fg_text: Color, + pub height: f32, + /// Firma visual: gradient sutil + hairline accent en el top edge. Se + /// activa por defecto al construir desde theme. `None` cae al fill + /// plano de `bg` (modo back-compat para sitios que arman la palette + /// a mano sin theme). + pub signature: Option, +} + +impl Default for AppHeaderPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl AppHeaderPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg: t.bg_panel, + border_bottom: t.border, + fg_text: t.fg_text, + height: 40.0, + signature: Some(PanelStyle { + radius: 0.0, + ..PanelStyle::from_theme(t) + }), + } + } +} + +/// Header con `label` a la izquierda y `actions` a la derecha. `actions` +/// es vacío para apps sin toolbar; viene como Vec para que la app meta +/// botones / switcher / status pill / lo que necesite. +pub fn app_header( + label: impl Into, + actions: Vec>, + palette: &AppHeaderPalette, +) -> View { + let label_view = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(palette.height), + }, + flex_grow: 1.0, + padding: Rect { + left: length(16.0_f32), + right: length(16.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(label.into(), 14.0, palette.fg_text, Alignment::Start); + + let actions_view = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: llimphi_ui::llimphi_layout::taffy::prelude::Dimension::auto(), + height: length(palette.height), + }, + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + gap: Size { + width: length(6.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(actions); + + // Bottom border: el header rellena `bg` (o aplica la firma si está + // habilitada), y debajo va una línea 1px de `border_bottom`. Lo + // metemos como un wrapper column. + let bar_style = Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(palette.height), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }; + let bar = match palette.signature { + Some(style) => View::new(bar_style) + .paint_with(panel_signature_painter(style)) + .children(vec![label_view, actions_view]), + None => View::new(bar_style) + .fill(palette.bg) + .children(vec![label_view, actions_view]), + }; + + let underline = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(1.0_f32), + }, + ..Default::default() + }) + .fill(palette.border_bottom); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: length(palette.height + 1.0), + }, + ..Default::default() + }) + .children(vec![bar, underline]) +} diff --git a/widgets/avatar/Cargo.toml b/widgets/avatar/Cargo.toml new file mode 100644 index 0000000..73fe6f7 --- /dev/null +++ b/widgets/avatar/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-avatar" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-avatar — círculo de identidad con inicial sobre color generado del hash del nombre. Determinista (mismo nombre → mismo color) y tonal (paleta limitada para que no choque)." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/avatar/src/lib.rs b/widgets/avatar/src/lib.rs new file mode 100644 index 0000000..b6a1622 --- /dev/null +++ b/widgets/avatar/src/lib.rs @@ -0,0 +1,116 @@ +//! `llimphi-widget-avatar` — círculo de identidad con inicial. +//! +//! Genera un avatar **determinista** de un nombre: el color de fondo +//! viene de un hash del nombre, mapeado a una paleta limitada de 8 +//! tonos (para que dos usuarios distintos no acaben con colores que +//! se confundan). La inicial es la primera letra del nombre (uppercase), +//! pintada centrada en blanco-cálido. +//! +//! Útil para chats (ayni), authorship en pluma, presencia en +//! herramientas colaborativas. Una sola función — sin state, sin +//! animación, sin paleta configurable (la consistencia importa más +//! que la personalización). + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, Size, Style}, + AlignItems, JustifyContent, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; + +/// Construye el avatar de `name` con diámetro `size_px`. +pub fn avatar_view(name: &str, size_px: f32) -> View { + let bg = color_for(name); + let initial = name + .chars() + .next() + .map(|c| c.to_uppercase().next().unwrap_or(c)) + .unwrap_or('·'); + let fg = Color::from_rgba8(248, 248, 250, 255); + let font = (size_px * 0.42).max(8.0); + + View::new(Style { + size: Size { + width: length(size_px), + height: length(size_px), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(bg) + .radius((size_px * 0.5) as f64) + .paint_with(move |scene, _ts, rect| { + // Highlight radial en el cuadrante superior — el avatar se lee + // como esfera. paint_with corre entre el fill y la inicial, así + // que la luz se suma al color del nombre sin tapar el texto. + // Mismo patrón dot-badge / switch-thumb (P6/P7). + use llimphi_ui::llimphi_raster::kurbo::{Affine, Circle}; + use llimphi_ui::llimphi_raster::peniko::Fill; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let cx = (rect.x + rect.w * 0.5) as f64; + let cy = (rect.y + rect.h * 0.30) as f64; + let r = (rect.w as f64 * 0.18).max(1.0); + let highlight = Color::from_rgba8(255, 255, 255, 60); + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + highlight, + None, + &Circle::new((cx, cy), r), + ); + }) + .text_aligned(initial.to_string(), font, fg, Alignment::Center) +} + +/// Paleta tonal limitada — 8 colores HSL-ish elegidos para destacar +/// sobre fondos oscuros sin ser estridentes. +const PALETTE: &[Color] = &[ + Color::from_rgba8(96, 130, 220, 255), // azul + Color::from_rgba8(110, 180, 130, 255), // verde aurora + Color::from_rgba8(220, 140, 80, 255), // naranja sunset + Color::from_rgba8(160, 110, 220, 255), // púrpura + Color::from_rgba8(80, 180, 180, 255), // aqua + Color::from_rgba8(220, 120, 160, 255), // rosa + Color::from_rgba8(180, 170, 90, 255), // mostaza + Color::from_rgba8(130, 150, 175, 255), // gris-azul +]; + +/// Hash FNV-1a simple sobre los bytes del nombre, mod paleta. No +/// requiere crypto — sólo necesitamos que mismo input dé mismo color. +fn color_for(name: &str) -> Color { + let mut h: u32 = 0x811c9dc5; + for b in name.bytes() { + h ^= b as u32; + h = h.wrapping_mul(0x01000193); + } + PALETTE[(h as usize) % PALETTE.len()] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn color_for_is_deterministic() { + assert_eq!(color_for("sergio").components, color_for("sergio").components); + assert_eq!(color_for("calcetin").components, color_for("calcetin").components); + } + + #[test] + fn different_names_can_have_different_colors() { + let names = ["a", "b", "c", "d", "e", "f", "g", "h"]; + let colors: Vec<_> = names.iter().map(|n| color_for(n)).collect(); + // Al menos 2 colores distintos en 8 nombres — el hash es trivial, + // colisiones esperadas, no garantizamos 8 distintos. + let unique: std::collections::HashSet<_> = + colors.iter().map(|c| c.components.map(|x| (x * 255.0) as u8)).collect(); + assert!(unique.len() >= 2); + } +} diff --git a/widgets/badge/Cargo.toml b/widgets/badge/Cargo.toml new file mode 100644 index 0000000..f1eefb5 --- /dev/null +++ b/widgets/badge/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-badge" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-badge — chip pequeño (count o dot) para notificaciones, contadores, estado de conexión. Cuatro variants semánticas." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/badge/src/lib.rs b/widgets/badge/src/lib.rs new file mode 100644 index 0000000..7586687 --- /dev/null +++ b/widgets/badge/src/lib.rs @@ -0,0 +1,136 @@ +//! `llimphi-widget-badge` — chip pequeño para conteo o estado. +//! +//! Dos formas: +//! - `count_badge_view(n, kind)` — chip ovalado con número adentro +//! ("3", "12", "99+"). Para notificaciones, items sin leer, etc. +//! - `dot_badge_view(kind)` — círculo de 8px sin contenido. Para +//! estado de conexión (online/offline/idle) o "hay algo nuevo". +//! +//! Cuatro `BadgeKind` con paleta semántica (Info / Success / Warning +//! / Error / Neutral) — los colores no cambian con el theme para +//! mantener la consistencia semántica. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BadgeKind { + Info, + Success, + Warning, + Error, + Neutral, +} + +impl BadgeKind { + pub fn bg(self) -> Color { + match self { + BadgeKind::Info => Color::from_rgba8(60, 130, 220, 255), + BadgeKind::Success => Color::from_rgba8(70, 180, 110, 255), + BadgeKind::Warning => Color::from_rgba8(220, 160, 40, 255), + BadgeKind::Error => Color::from_rgba8(220, 80, 80, 255), + BadgeKind::Neutral => Color::from_rgba8(120, 130, 150, 255), + } + } + pub fn fg(self) -> Color { + // Texto siempre blanco-cálido sobre los colores sólidos del bg. + Color::from_rgba8(248, 248, 250, 255) + } +} + +const BADGE_H: f32 = 16.0; +const FONT: f32 = 10.0; +const DOT_R: f32 = 4.0; // dot diameter = 8 + +/// Chip con número. Si `count >= 100`, muestra "99+". +pub fn count_badge_view(count: u32, kind: BadgeKind) -> View { + let text = if count >= 100 { "99+".to_string() } else { count.to_string() }; + // Ancho proporcional al texto, con padding generoso. + let w = (text.chars().count() as f32 * 6.5 + 10.0).max(BADGE_H); + let badge_radius = (BADGE_H * 0.5) as f64; + + View::new(Style { + size: Size { + width: length(w), + height: length(BADGE_H), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + padding: Rect { + left: length(5.0_f32), + right: length(5.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(kind.bg()) + .radius(badge_radius) + .paint_with(move |scene, _ts, rect| { + // Gloss superior: blanco alpha 35 → 0 sobre la mitad de arriba. + // Da volumen de pill — el chip se lee como una superficie con + // luz cayendo, no como un rect plano. Match: button/splash — + // misma firma vertical descendente. + use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect}; + use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient}; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let x0 = rect.x as f64; + let y0 = rect.y as f64; + let x1 = (rect.x + rect.w) as f64; + let y1 = (rect.y + rect.h) as f64; + let y_mid = y0 + (y1 - y0) * 0.5; + let rr = RoundedRect::new(x0, y0, x1, y1, badge_radius); + let top = Color::from_rgba8(255, 255, 255, 35); + let bot = Color::from_rgba8(255, 255, 255, 0); + let gradient = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid)) + .with_stops([top, bot].as_slice()); + scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &rr); + }) + .text_aligned(text, FONT, kind.fg(), Alignment::Center) +} + +/// Dot sin contenido — sólo color. +pub fn dot_badge_view(kind: BadgeKind) -> View { + let dot_radius = DOT_R as f64; + View::new(Style { + size: Size { + width: length(DOT_R * 2.0), + height: length(DOT_R * 2.0), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(kind.bg()) + .radius(dot_radius) + .paint_with(move |scene, _ts, rect| { + // Highlight radial chiquito en el cuadrante superior — lectura + // de esfera, no de círculo plano. El dot es 8px; el highlight + // ocupa ~3px centrado a 1/3 del top. + use llimphi_ui::llimphi_raster::kurbo::{Affine, Circle}; + use llimphi_ui::llimphi_raster::peniko::Fill; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let cx = (rect.x + rect.w * 0.5) as f64; + let cy = (rect.y + rect.h * 0.33) as f64; + let r = (rect.w as f64 * 0.18).max(1.0); + let highlight = Color::from_rgba8(255, 255, 255, 90); + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + highlight, + None, + &Circle::new((cx, cy), r), + ); + }) +} diff --git a/widgets/banner/Cargo.toml b/widgets/banner/Cargo.toml new file mode 100644 index 0000000..4ac2561 --- /dev/null +++ b/widgets/banner/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "llimphi-widget-banner" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-banner — tiras horizontales de status (Info/Success/Warning/Error). Colores semánticos hardcoded por severidad — no dependen del theme. Análogo Llimphi al `nahual-widget-banner` GPUI." + +[dependencies] +llimphi-ui = { workspace = true } diff --git a/widgets/banner/LEEME.md b/widgets/banner/LEEME.md new file mode 100644 index 0000000..14734a8 --- /dev/null +++ b/widgets/banner/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-banner + +> Banner / alerts para [llimphi](../../README.md). + +Mensaje destacado al tope de la vista: info / warning / error / success. Auto-dismiss configurable. diff --git a/widgets/banner/README.md b/widgets/banner/README.md new file mode 100644 index 0000000..942f338 --- /dev/null +++ b/widgets/banner/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-banner + +> Banner / alerts for [llimphi](../../README.md). + +Prominent message at the top of the view: info / warning / error / success. Configurable auto-dismiss. diff --git a/widgets/banner/src/lib.rs b/widgets/banner/src/lib.rs new file mode 100644 index 0000000..406eb8b --- /dev/null +++ b/widgets/banner/src/lib.rs @@ -0,0 +1,109 @@ +//! `llimphi-widget-banner` — tiras horizontales de status. +//! +//! Cuatro variants con paleta consistente entre apps: +//! +//! - [`BannerKind::Info`] — azul tenue, mensajes neutros. +//! - [`BannerKind::Success`] — verde, confirmaciones de op exitosa. +//! - [`BannerKind::Warning`] — amber, llamadas de atención. +//! - [`BannerKind::Error`] — rojo, errores fatales o de carga. +//! +//! Análogo Llimphi al `nahual-widget-banner` GPUI. Los colores son +//! **semánticos** y no cambian con el theme (un Error en dark y en +//! light tiene que seguir leyéndose como rojo). + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; + +/// Severidad / tono del banner. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BannerKind { + Info, + Success, + Warning, + Error, +} + +impl BannerKind { + pub fn bg(self) -> Color { + match self { + BannerKind::Info => Color::from_rgba8(0x1d, 0x2a, 0x3a, 0xff), + BannerKind::Success => Color::from_rgba8(0x2d, 0x3a, 0x2a, 0xff), + BannerKind::Warning => Color::from_rgba8(0x4a, 0x3a, 0x1a, 0xff), + BannerKind::Error => Color::from_rgba8(0x4a, 0x20, 0x20, 0xff), + } + } + + pub fn fg(self) -> Color { + match self { + BannerKind::Info => Color::from_rgba8(0xc0, 0xd0, 0xe0, 0xff), + BannerKind::Success => Color::from_rgba8(0xc0, 0xe0, 0xa0, 0xff), + BannerKind::Warning => Color::from_rgba8(0xf0, 0xe0, 0xa0, 0xff), + BannerKind::Error => Color::from_rgba8(0xff, 0xd0, 0xd0, 0xff), + } + } +} + +/// Ancho del rail de severidad en el edge izquierdo. Mismo valor que +/// `llimphi-widget-toast` — banner y toast son las versiones persistente +/// y efímera del mismo lenguaje (P5 → P8). +const RAIL_W: f32 = 3.0; + +/// Banner simple: una fila con `message` centrado verticalmente y +/// alineado a la izquierda. bg/fg vienen del `kind`. +pub fn banner_view( + kind: BannerKind, + message: impl Into, +) -> View { + use llimphi_ui::llimphi_layout::taffy::prelude::FlexDirection; + + // Rail de severidad en el edge izquierdo — stripe del color fg + // semántico, visible al pasar el ojo. Mismo patrón que toast P5. + let rail = View::new(Style { + size: Size { + width: length(RAIL_W), + height: percent(1.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(kind.fg()); + + // Contenedor del mensaje: padding original ahora vive acá para que + // el rail pegue al borde sin offset y el texto arranque después. + let body = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(12.0_f32), + right: length(12.0_f32), + top: length(6.0_f32), + bottom: length(6.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(message.into(), 11.0, kind.fg(), Alignment::Start); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(28.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(kind.bg()) + .radius(3.0) + .clip(true) + .children(vec![rail, body]) +} diff --git a/widgets/breadcrumb/Cargo.toml b/widgets/breadcrumb/Cargo.toml new file mode 100644 index 0000000..d741fe3 --- /dev/null +++ b/widgets/breadcrumb/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-breadcrumb" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-breadcrumb — ruta navegable con separadores chevron. Cada segmento clicable salta a su nivel." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-icons = { workspace = true } diff --git a/widgets/breadcrumb/src/lib.rs b/widgets/breadcrumb/src/lib.rs new file mode 100644 index 0000000..f5d1c6a --- /dev/null +++ b/widgets/breadcrumb/src/lib.rs @@ -0,0 +1,128 @@ +//! `llimphi-widget-breadcrumb` — ruta navegable con separadores chevron. +//! +//! Patrón clásico: `home › docs › 2026 › nota.md`. Cada segmento es +//! clicable y emite un Msg con su índice. El último segmento (la +//! "página actual") se renderiza con énfasis y sin click handler. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_icons::{icon_view, Icon}; +use llimphi_theme::Theme; + +/// Paleta del breadcrumb. +#[derive(Debug, Clone, Copy)] +pub struct BreadcrumbPalette { + pub fg_link: Color, + pub fg_current: Color, + pub fg_separator: Color, + pub bg_hover: Color, +} + +impl BreadcrumbPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + fg_link: t.fg_muted, + fg_current: t.fg_text, + fg_separator: t.fg_placeholder, + bg_hover: t.bg_row_hover, + } + } +} + +const SEG_H: f32 = 22.0; +const SEG_PAD: f32 = 6.0; +const FONT: f32 = 11.5; +const SEP_BOX: f32 = 12.0; + +/// Construye el breadcrumb. `segments` son los labels visibles, en +/// orden de raíz a hoja. `make_msg(i)` se llama al click en el +/// segmento `i` (no se llama para el último — la "página actual"). +pub fn breadcrumb_view( + segments: &[&str], + make_msg: F, + palette: &BreadcrumbPalette, +) -> View +where + Msg: Clone + 'static, + F: Fn(usize) -> Msg, +{ + let last = segments.len().saturating_sub(1); + let mut children: Vec> = Vec::with_capacity(segments.len() * 2); + for (i, &label) in segments.iter().enumerate() { + let is_current = i == last; + children.push(segment_view( + label, + is_current, + if is_current { None } else { Some(make_msg(i)) }, + palette, + )); + if !is_current { + children.push(separator_view(palette)); + } + } + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(SEG_H), + }, + align_items: Some(AlignItems::Center), + gap: Size { + width: length(2.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(children) +} + +fn segment_view( + label: &str, + is_current: bool, + msg: Option, + palette: &BreadcrumbPalette, +) -> View { + let fg = if is_current { palette.fg_current } else { palette.fg_link }; + let approx_w = label.chars().count() as f32 * 6.5 + SEG_PAD * 2.0; + let mut node = View::new(Style { + size: Size { + width: length(approx_w), + height: length(SEG_H), + }, + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(SEG_PAD), + right: length(SEG_PAD), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(label.to_string(), FONT, fg, Alignment::Center) + .radius(llimphi_theme::radius::XS); + if let Some(m) = msg { + node = node.hover_fill(palette.bg_hover).on_click(m); + } + node +} + +fn separator_view(palette: &BreadcrumbPalette) -> View { + View::new(Style { + size: Size { + width: length(SEP_BOX), + height: length(SEP_BOX), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![icon_view(Icon::ChevronRight, palette.fg_separator, 1.6)]) +} diff --git a/widgets/button/Cargo.toml b/widgets/button/Cargo.toml new file mode 100644 index 0000000..d0c41b3 --- /dev/null +++ b/widgets/button/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-button" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-button — botón clicable con estado hover. Reusable entre apps Llimphi; cambia el bg cuando el cursor pasa por encima. Compuesto de `View::fill().hover_fill().on_click()` con una paleta tematizable." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/button/LEEME.md b/widgets/button/LEEME.md new file mode 100644 index 0000000..c64508a --- /dev/null +++ b/widgets/button/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-button + +> Botón con variantes para [llimphi](../../README.md). + +Variantes: `primary`, `secondary`, `ghost`, `danger`. Estado hover/active/disabled. Soporta icono + label, o sólo icono. diff --git a/widgets/button/README.md b/widgets/button/README.md new file mode 100644 index 0000000..eb4b252 --- /dev/null +++ b/widgets/button/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-button + +> Button with variants for [llimphi](../../README.md). + +Variants: `primary`, `secondary`, `ghost`, `danger`. Hover/active/disabled states. Supports icon + label, or icon-only. diff --git a/widgets/button/examples/button_demo.rs b/widgets/button/examples/button_demo.rs new file mode 100644 index 0000000..2e0381f --- /dev/null +++ b/widgets/button/examples/button_demo.rs @@ -0,0 +1,136 @@ +//! Showcase de `llimphi-widget-button`: tres botones con hover. +//! +//! Corré con: `cargo run -p llimphi-widget-button --example showcase --release`. + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{App, Handle, View}; +use llimphi_widget_button::{button_styled, button_view, ButtonPalette}; + +#[derive(Clone, Debug)] +enum Msg { + A, + B, + C, +} + +struct Model { + last: Option, + counter: u32, +} + +struct Showcase; + +impl App for Showcase { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · button showcase" + } + + fn init(_: &Handle) -> Model { + Model { + last: None, + counter: 0, + } + } + + fn update(model: Model, msg: Msg, _: &Handle) -> Model { + let mut m = model; + m.counter += 1; + m.last = Some(msg); + m + } + + fn view(model: &Model) -> View { + let palette = ButtonPalette::default(); + let warning = ButtonPalette { + bg: Color::from_rgba8(140, 70, 30, 255), + bg_hover: Color::from_rgba8(200, 100, 40, 255), + ..palette + }; + let danger = ButtonPalette { + bg: Color::from_rgba8(150, 40, 40, 255), + bg_hover: Color::from_rgba8(220, 70, 70, 255), + ..palette + }; + + let a = button_view("acción A", &palette, Msg::A); + let b = button_view("acción B (warning)", &warning, Msg::B); + let c = button_styled( + "borrar (left-aligned, fixed width)", + Style { + size: Size { + width: length(320.0_f32), + height: length(34.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() + }, + Alignment::Start, + &danger, + Msg::C, + ); + + let status = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(40.0_f32), + }, + ..Default::default() + }) + .text_aligned( + format!( + "clicks: {} · último: {}", + model.counter, + match model.last { + Some(Msg::A) => "A", + Some(Msg::B) => "B", + Some(Msg::C) => "C", + None => "—", + } + ), + 14.0, + Color::from_rgba8(180, 190, 205, 255), + 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(14.0_f32), + }, + padding: Rect { + left: length(32.0_f32), + right: length(32.0_f32), + top: length(32.0_f32), + bottom: length(32.0_f32), + }, + align_items: Some(AlignItems::Start), + justify_content: Some(JustifyContent::Start), + ..Default::default() + }) + .fill(Color::from_rgba8(20, 24, 32, 255)) + .children(vec![a, b, c, status]) + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/button/src/lib.rs b/widgets/button/src/lib.rs new file mode 100644 index 0000000..638b7ec --- /dev/null +++ b/widgets/button/src/lib.rs @@ -0,0 +1,124 @@ +//! `llimphi-widget-button` — botón clicable con estado hover. +//! +//! Reusable entre apps Llimphi: `button_view(label, palette, on_click)` +//! devuelve una vista que cambia de color cuando el cursor pasa por +//! encima y emite `on_click` al ser apretada. El caller controla las +//! dimensiones envolviendo el `View` retornado en un contenedor flex +//! con el tamaño que necesite (botón ancho completo, chip 80×30, etc). +//! +//! No expone estado interno — todo el estado vive en el `Model` del App +//! (el hover lo trackea llimphi-ui automáticamente vía `hover_fill`). + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; + +/// Paleta del botón. Por default un chip dark con highlight tenue al +/// hover — similar al patrón `bg_panel_alt` + `bg_row_hover` de +/// `nahual-theme`. +#[derive(Debug, Clone, Copy)] +pub struct ButtonPalette { + pub bg: Color, + pub bg_hover: Color, + pub fg: Color, + pub radius: f64, +} + +impl Default for ButtonPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl ButtonPalette { + /// Construye la paleta desde un `Theme` semántico. + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg: t.bg_button, + bg_hover: t.bg_button_hover, + fg: t.fg_text, + radius: 5.0, + } + } +} + +/// Compone un botón rectangular: bg + texto + on_click + hover. Por +/// default ocupa ancho 100% del padre y alto 30 px; sobre-escribir +/// pasando un `Style` propio vía [`button_styled`]. +pub fn button_view( + label: impl Into, + palette: &ButtonPalette, + on_click: Msg, +) -> View { + button_styled( + label, + Style { + size: Size { + width: percent(1.0_f32), + height: length(30.0_f32), + }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }, + Alignment::Center, + palette, + on_click, + ) +} + +/// Variante con `Style` y alineación de texto explícitos — útil cuando +/// la app necesita un botón con dimensiones particulares o el texto a +/// la izquierda. +pub fn button_styled( + label: impl Into, + style: Style, + text_alignment: Alignment, + palette: &ButtonPalette, + on_click: Msg, +) -> View { + // Gloss superior: gradient blanco alpha 28 → 0 sobre la mitad de + // arriba. `paint_with` corre entre el fill (que respeta hover_fill) + // y el texto, así que la luz se suma al color de base sin sustituirlo + // — el hover sigue funcionando idéntico. El RoundedRect cubre el + // botón completo y `Extend::Pad` (default de peniko) deja la mitad + // inferior en alpha 0. Match: chrome/splash — superficie con luz + // descendente desde el edge superior. + let radius = palette.radius; + View::new(style) + .fill(palette.bg) + .hover_fill(palette.bg_hover) + .radius(radius) + .paint_with(move |scene, _ts, rect| { + use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect}; + use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient}; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let x0 = rect.x as f64; + let y0 = rect.y as f64; + let x1 = (rect.x + rect.w) as f64; + let y1 = (rect.y + rect.h) as f64; + let y_mid = y0 + (y1 - y0) * 0.5; + let rr = RoundedRect::new(x0, y0, x1, y1, radius); + let top = Color::from_rgba8(255, 255, 255, 28); + let bot = Color::from_rgba8(255, 255, 255, 0); + let gradient = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid)) + .with_stops([top, bot].as_slice()); + scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &rr); + }) + .text_aligned(label.into(), 13.0, palette.fg, text_alignment) + .on_click(on_click) +} diff --git a/widgets/card/Cargo.toml b/widgets/card/Cargo.toml new file mode 100644 index 0000000..985db9d --- /dev/null +++ b/widgets/card/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-card" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-card — container card-shape con padding consistente, esquinas redondeadas y opcional accent border a la izquierda. Análogo Llimphi al `nahual-widget-card` GPUI." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-panel = { workspace = true } diff --git a/widgets/card/LEEME.md b/widgets/card/LEEME.md new file mode 100644 index 0000000..aee2f80 --- /dev/null +++ b/widgets/card/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-card + +> Card base para [llimphi](../../README.md). + +Contenedor con borde + radius + padding consistente con el theme. Slot de contenido libre. Base de `stat-card` y otras especializadas. diff --git a/widgets/card/README.md b/widgets/card/README.md new file mode 100644 index 0000000..97b5524 --- /dev/null +++ b/widgets/card/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-card + +> Base card for [llimphi](../../README.md). + +Container with border + radius + theme-consistent padding. Free content slot. Base for `stat-card` and other specialized variants. diff --git a/widgets/card/src/lib.rs b/widgets/card/src/lib.rs new file mode 100644 index 0000000..550ea0e --- /dev/null +++ b/widgets/card/src/lib.rs @@ -0,0 +1,146 @@ +//! `llimphi-widget-card` — container card-shape para entries de +//! timeline, info cards, dashboards, etc. +//! +//! Aporta la **forma**: padding consistente (12/8), `radius` 4, gap +//! pequeño entre children, y opcionalmente un accent vertical +//! (4 px) pegado a la izquierda para entries semánticas (verde = +//! OK, rojo = error, ámbar = warning, etc). +//! +//! Análogo Llimphi al `nahual-widget-card` GPUI. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::View; +use llimphi_widget_panel::{panel_signature_painter, PanelStyle}; + +#[derive(Debug, Clone, Copy)] +pub struct CardPalette { + pub bg: Color, +} + +impl Default for CardPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl CardPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { bg: t.bg_panel } + } +} + +/// Opciones del card. +#[derive(Debug, Clone, Copy)] +pub struct CardOptions { + /// Accent vertical a la izquierda (4 px). `None` = sin accent. + pub accent: Option, + pub padding: f32, + pub gap: f32, + pub radius: f64, + /// Firma visual del panel (gradient sutil + hairline accent en el + /// top). `Some(style)` reemplaza el fill plano del body por el + /// painter de la firma — usar para cards prominentes (dashboards, + /// timeline entries grandes) donde se nota el "tallado". `None` + /// mantiene el fill sólido del `CardPalette` (default). + pub signature: Option, +} + +impl Default for CardOptions { + fn default() -> Self { + Self { + accent: None, + padding: 12.0, + gap: 4.0, + radius: 4.0, + signature: None, + } + } +} + +impl CardOptions { + /// Variante con firma visual derivada del theme. El `radius` del + /// card se alinea al del `PanelStyle` para que la silueta del + /// gradiente coincida con las esquinas del nodo. + pub fn with_signature(t: &llimphi_theme::Theme) -> Self { + let style = PanelStyle::from_theme(t); + Self { + accent: None, + padding: 12.0, + gap: 4.0, + radius: style.radius, + signature: Some(style), + } + } +} + +/// Compone un card: bg + radius + padding + flex-column con gap entre +/// children. Si `opts.accent` está presente, hay una franja vertical +/// de 4 px del color del accent pegada al borde izquierdo. +pub fn card_view( + children: Vec>, + opts: CardOptions, + palette: &CardPalette, +) -> View { + let pad = opts.padding; + let body_style = Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: llimphi_ui::llimphi_layout::taffy::prelude::Dimension::auto(), + }, + flex_grow: 1.0, + padding: Rect { + left: length(pad), + right: length(pad), + top: length(pad * 0.66), + bottom: length(pad * 0.66), + }, + gap: Size { + width: length(0.0_f32), + height: length(opts.gap), + }, + ..Default::default() + }; + let body = if let Some(style) = opts.signature { + View::new(body_style) + .paint_with(panel_signature_painter(style)) + .radius(opts.radius) + .clip(true) + .children(children) + } else { + View::new(body_style) + .fill(palette.bg) + .radius(opts.radius) + .children(children) + }; + + let Some(accent) = opts.accent else { + return body; + }; + + let accent_strip = View::new(Style { + size: Size { + width: length(4.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(accent) + .radius(opts.radius); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: llimphi_ui::llimphi_layout::taffy::prelude::Dimension::auto(), + }, + ..Default::default() + }) + .children(vec![accent_strip, body]) +} diff --git a/widgets/clipboard/Cargo.toml b/widgets/clipboard/Cargo.toml new file mode 100644 index 0000000..8e0add2 --- /dev/null +++ b/widgets/clipboard/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-clipboard" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-clipboard — backend de portapapeles del sistema (vía arboard) que implementa el trait Clipboard del text-editor. Una línea para que el menú de edición y los atajos Ctrl+C/X/V de cualquier app Llimphi usen el clipboard real del SO, con degradación silenciosa a no-op si no hay display." + +[dependencies] +llimphi-widget-text-editor = { workspace = true } +arboard = { workspace = true } diff --git a/widgets/clipboard/src/lib.rs b/widgets/clipboard/src/lib.rs new file mode 100644 index 0000000..1080ffc --- /dev/null +++ b/widgets/clipboard/src/lib.rs @@ -0,0 +1,55 @@ +//! `llimphi-clipboard` — el portapapeles del sistema para apps Llimphi. +//! +//! El `text-editor` define el trait [`Clipboard`] pero deja el backend al +//! caller (no quiere acoplarse a X11/Wayland/macOS/Windows). Este crate +//! aporta el backend obvio — [`arboard`] — para que cualquier app lo +//! enchufe en una línea: +//! +//! ```ignore +//! let mut clip = llimphi_clipboard::SystemClipboard::new(); +//! editor.apply_key_with_clipboard(&ev, &mut clip); +//! ``` +//! +//! Si no hay display (CI headless, sesión sin servidor gráfico) degrada +//! a no-op silencioso: `get` devuelve `None`, `set` descarta. Nunca +//! panica. + +#![forbid(unsafe_code)] + +use llimphi_widget_text_editor::Clipboard; + +/// Portapapeles del sistema vía `arboard`. `None` interno = no se pudo +/// abrir (sin display); en ese caso opera como [`llimphi_widget_text_editor::NullClipboard`]. +pub struct SystemClipboard { + inner: Option, +} + +impl SystemClipboard { + pub fn new() -> Self { + Self { + inner: arboard::Clipboard::new().ok(), + } + } + + /// `true` si el backend del SO está disponible. + pub fn is_available(&self) -> bool { + self.inner.is_some() + } +} + +impl Default for SystemClipboard { + fn default() -> Self { + Self::new() + } +} + +impl Clipboard for SystemClipboard { + 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/widgets/context-menu/Cargo.toml b/widgets/context-menu/Cargo.toml new file mode 100644 index 0000000..2b993e7 --- /dev/null +++ b/widgets/context-menu/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-context-menu" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-context-menu — menú contextual gioser: panel negro, barra accent vertical de 3px a la izquierda, sin esquinas redondeadas ni sombras, header en uppercase tiny. Se monta sobre App::view_overlay con un scrim full-screen que dismissa al click-fuera." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-panel = { workspace = true } diff --git a/widgets/context-menu/LEEME.md b/widgets/context-menu/LEEME.md new file mode 100644 index 0000000..59dd7f2 --- /dev/null +++ b/widgets/context-menu/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-context-menu + +> Menú contextual para [llimphi](../../README.md). + +Look distintivo: barra accent vertical 3px, hard edges, header tiny. API: `View::on_right_click[_at]` + `App::view_overlay`. diff --git a/widgets/context-menu/README.md b/widgets/context-menu/README.md new file mode 100644 index 0000000..4438a4a --- /dev/null +++ b/widgets/context-menu/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-context-menu + +> Context menu for [llimphi](../../README.md). + +Distinctive look: 3px vertical accent bar, hard edges, tiny header. API: `View::on_right_click[_at]` + `App::view_overlay`. diff --git a/widgets/context-menu/src/lib.rs b/widgets/context-menu/src/lib.rs new file mode 100644 index 0000000..5d1e11d --- /dev/null +++ b/widgets/context-menu/src/lib.rs @@ -0,0 +1,761 @@ +//! `llimphi-widget-context-menu` — menú contextual con look gioser. +//! +//! Distintivo y minimalista: +//! +//! ```text +//! ┃ B5 ← header (uppercase tiny) +//! ┃ ✂ Cortar Ctrl+X +//! ┃ ⧉ Copiar Ctrl+C ← gutter de íconos + barra accent (3px) +//! ┃ ⎘ Pegar Ctrl+V +//! ┃ ───────────────────── +//! ┃ ◐ Tema ▸ ← submenú (flyout a la derecha) +//! ``` +//! +//! Cada fila: barra accent vertical (firma) · gutter de ícono · label +//! (centrado vertical) · atajo o chevron de submenú. Sin radios, sin +//! sombras: color sólido + tipografía + la barra accent. +//! +//! Se monta como `View` que se devuelve desde +//! [`llimphi_ui::App::view_overlay`]. Internamente arma: +//! 1. Un **scrim** full-screen con `on_click = on_dismiss` que cierra +//! el menú al click-fuera. +//! 2. Un **panel** absoluto (clampeado al viewport). +//! 3. Si hay un submenú abierto ([`ContextMenuSpec::open_sub`]), un +//! segundo panel-flyout a la derecha del item padre. +//! +//! Animación: [`ContextMenuSpec::appear`] (0..1) controla un fade + un +//! leve desplazamiento vertical de entrada. La app que quiera animarlo +//! guarda un `Tween` y lo va subiendo; pasar `1.0` lo muestra fijo. + +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, percent, FlexDirection, Position, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_widget_panel::{panel_signature_painter, PanelStyle}; + +/// Paleta del menú — estilo "webpage" elegante derivado del theme: +/// panel redondeado con borde hairline, filas como píldoras con hover +/// suave (`bg_hover`) y resaltado de teclado (`bg_active`, más un +/// indicador accent a la izquierda). Defaults dark; override por la app. +#[derive(Debug, Clone, Copy)] +pub struct ContextMenuPalette { + pub bg_panel: Color, + /// Fila bajo el cursor (hover) — tinte suave. + pub bg_hover: Color, + /// Fila activa por teclado (flechas) — algo más marcado que el hover. + pub bg_active: Color, + pub fg_text: Color, + /// Texto de la fila activa/hover (legible sobre el tinte suave). + pub fg_active: Color, + pub fg_shortcut: Color, + pub fg_disabled: Color, + pub fg_destructive: Color, + pub fg_header: Color, + /// Ícono en gutter (estado normal) — algo más apagado que el texto. + pub fg_icon: Color, + pub accent: Color, + pub border: Color, + pub separator: Color, + pub scrim: Color, + /// Radio de las esquinas del panel. + pub radius: f64, + pub panel: PanelStyle, +} + +impl ContextMenuPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + // El panel se eleva sobre el fondo: usa `bg_panel` (no `bg_app`) + // con su gradiente sutil + esquinas redondeadas. + let mut panel = PanelStyle::neutral(t); + panel.bg_base = t.bg_panel; + panel.radius = PANEL_RADIUS as f64; + Self { + bg_panel: t.bg_panel, + bg_hover: t.bg_row_hover, + bg_active: t.bg_selected, + fg_text: t.fg_text, + fg_active: t.fg_text, + fg_shortcut: t.fg_muted, + fg_disabled: t.fg_muted, + fg_destructive: t.fg_destructive, + fg_header: t.fg_muted, + fg_icon: t.fg_muted, + accent: t.accent, + border: t.border, + separator: t.border, + scrim: Color::from_rgba8(0, 0, 0, 64), + radius: PANEL_RADIUS as f64, + panel, + } + } +} + +/// Un item del menú. `separator = true` ignora el resto y pinta una +/// línea. `children` no vacío → es un submenú (muestra chevron ▸ y, si +/// está abierto, despliega un flyout). `icon` es un glifo opcional que +/// se pinta en el gutter izquierdo. +#[derive(Debug, Clone)] +pub struct ContextMenuItem { + pub label: String, + pub shortcut: Option, + pub icon: Option, + pub enabled: bool, + pub separator: bool, + pub destructive: bool, + /// Items del submenú. Vacío = acción simple. + pub children: Vec, +} + +impl ContextMenuItem { + pub fn action(label: impl Into) -> Self { + Self { + label: label.into(), + shortcut: None, + icon: None, + enabled: true, + separator: false, + destructive: false, + children: Vec::new(), + } + } + + pub fn with_shortcut(mut self, shortcut: impl Into) -> Self { + self.shortcut = Some(shortcut.into()); + self + } + + /// Glifo del gutter izquierdo (unicode; no acopla a `llimphi-icons`). + pub fn icon(mut self, glyph: impl Into) -> Self { + self.icon = Some(glyph.into()); + self + } + + pub fn disabled(mut self) -> Self { + self.enabled = false; + self + } + + pub fn destructive(mut self) -> Self { + self.destructive = true; + self + } + + /// Convierte el item en submenú con estos hijos. + pub fn submenu(mut self, children: Vec) -> Self { + self.children = children; + self + } + + pub fn has_submenu(&self) -> bool { + !self.children.is_empty() + } + + pub fn separator() -> Self { + Self { + label: String::new(), + shortcut: None, + icon: None, + enabled: false, + separator: true, + destructive: false, + children: Vec::new(), + } + } +} + +/// Especificación del menú. Mantiene los 8 campos clásicos para no +/// romper los call-sites por literal; las capacidades nuevas (submenús, +/// animación, hover) viajan aparte en [`ContextMenuExtras`] vía +/// [`context_menu_view_ex`]. +pub struct ContextMenuSpec { + pub anchor: (f32, f32), + pub viewport: (f32, f32), + pub header: Option, + pub items: Vec, + /// Índice resaltado por keyboard. `usize::MAX` = ninguno. + pub active: usize, + /// Click en un item de nivel raíz (índice). + pub on_pick: Arc Msg + Send + Sync>, + /// Msg al click-fuera (scrim) o Esc. + pub on_dismiss: Msg, + pub palette: ContextMenuPalette, +} + +/// Capacidades extra opcionales para [`context_menu_view_ex`]: submenús +/// (flyout), animación de aparición y hover. Su `Default` reproduce el +/// menú clásico (sin animación ni submenús). +pub struct ContextMenuExtras { + /// Índice del item-submenú desplegado (flyout). La app lo guarda y lo + /// actualiza vía `on_hover`. + pub open_sub: Option, + /// Progreso de aparición 0..1 (fade + leve slide). `1.0` = fijo. + pub appear: f32, + /// Click en un item de submenú: `(parent_idx, child_idx)`. + pub on_pick_sub: Option Msg + Send + Sync>>, + /// Hover sobre un item raíz: `Some(idx)` si es submenú (abrir flyout), + /// `None` si es item normal (cerrar). La app guarda el resultado en + /// `open_sub`. + pub on_hover: Option) -> Msg + Send + Sync>>, +} + +impl Default for ContextMenuExtras { + fn default() -> Self { + Self { + open_sub: None, + appear: 1.0, + on_pick_sub: None, + on_hover: None, + } + } +} + +const PANEL_W: f32 = 252.0; +/// Altura de cada item (no-separator). +const ITEM_H: f32 = 32.0; +const SEP_H: f32 = 11.0; +const HEADER_H: f32 = 26.0; +/// Gutter del ícono a la izquierda del label. +const ICON_W: f32 = 24.0; +const ITEM_PAD_LEFT: f32 = 10.0; +const ITEM_PAD_RIGHT: f32 = 12.0; +/// Radio de las esquinas del panel (estilo webpage). +const PANEL_RADIUS: f32 = 10.0; +/// Radio de la píldora de hover/activo de cada fila. +const ITEM_RADIUS: f32 = 6.0; +/// Padding interno del panel (entre el borde y la columna de píldoras). +const PANEL_PAD: f32 = 6.0; +/// Ancho del indicador accent vertical de la fila activa. +const INDICATOR_W: f32 = 3.0; +/// Desplazamiento vertical de entrada (px) cuando `appear` = 0. +const APPEAR_SLIDE: f32 = 8.0; + +/// Compone el menú clásico (sin submenús ni animación) como `View` +/// para `App::view_overlay`. Íconos, centrado vertical y separadores ya +/// vienen incluidos. +pub fn context_menu_view(spec: ContextMenuSpec) -> View { + context_menu_view_ex(spec, ContextMenuExtras::default()) +} + +/// Como [`context_menu_view`] pero con [`ContextMenuExtras`]: submenús +/// (flyout en hover), animación de aparición y hover. +pub fn context_menu_view_ex( + spec: ContextMenuSpec, + extras: ContextMenuExtras, +) -> View { + let ContextMenuSpec { + anchor, + viewport, + header, + items, + active, + on_pick, + on_dismiss, + palette, + } = spec; + let ContextMenuExtras { + open_sub, + appear, + on_pick_sub, + on_hover, + } = extras; + + let appear = appear.clamp(0.0, 1.0); + let slide = (1.0 - appear) * APPEAR_SLIDE; + + let (panel, panel_x, panel_y) = panel_view( + anchor, + viewport, + &header, + &items, + active, + slide, + &on_pick, + on_hover.as_ref(), + &palette, + ); + + let mut layers: Vec> = vec![panel]; + + // Flyout del submenú abierto (sólo si la app provee `on_pick_sub`). + if let (Some(pidx), Some(on_pick_sub)) = (open_sub, on_pick_sub.as_ref()) { + if let Some(parent) = items.get(pidx).filter(|it| it.has_submenu()) { + let sub_anchor = submenu_anchor(panel_x, panel_y, &header, &items, pidx); + let flyout = submenu_view( + sub_anchor, + viewport, + pidx, + &parent.children, + slide, + on_pick_sub, + &palette, + ); + layers.push(flyout); + } + } + + // Scrim full-screen: cualquier click "fuera" dismissa. + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(palette.scrim) + .alpha(appear) + .on_click(on_dismiss) + .children(layers) +} + +/// Arma el panel raíz y devuelve `(view, x, y)` ya clampeados. +#[allow(clippy::too_many_arguments)] +fn panel_view( + anchor: (f32, f32), + viewport: (f32, f32), + header: &Option, + items: &[ContextMenuItem], + active: usize, + slide: f32, + on_pick: &Arc Msg + Send + Sync>, + on_hover: Option<&Arc) -> Msg + Send + Sync>>, + palette: &ContextMenuPalette, +) -> (View, f32, f32) { + let header_h = if header.is_some() { HEADER_H } else { 0.0 }; + let items_h: f32 = items + .iter() + .map(|it| if it.separator { SEP_H } else { ITEM_H }) + .sum(); + // borde (1+1) + padding interno (PANEL_PAD ×2) + header + items. + let panel_h = 2.0 + 2.0 * PANEL_PAD + header_h + items_h; + + let margin = 4.0; + let x = anchor + .0 + .min((viewport.0 - PANEL_W - margin).max(margin)) + .max(margin); + let y = anchor + .1 + .min((viewport.1 - panel_h - margin).max(margin)) + .max(margin); + + let mut children: Vec> = Vec::with_capacity(items.len() + 1); + if let Some(text) = header { + children.push(header_view(text.clone(), palette)); + } + for (i, item) in items.iter().enumerate() { + children.push(item_view( + i, + None, + item, + i == active, + on_pick, + on_hover, + palette, + )); + } + + let panel = panel_container(x, y + slide, panel_h, children, palette); + (panel, x, y) +} + +/// Flyout del submenú: mismo look, posicionado a la derecha del padre. +#[allow(clippy::too_many_arguments)] +fn submenu_view( + anchor: (f32, f32), + viewport: (f32, f32), + parent_idx: usize, + children_items: &[ContextMenuItem], + slide: f32, + on_pick_sub: &Arc Msg + Send + Sync>, + palette: &ContextMenuPalette, +) -> View { + let panel_h: f32 = children_items + .iter() + .map(|it| if it.separator { SEP_H } else { ITEM_H }) + .sum::() + + 2.0 + + 2.0 * PANEL_PAD; + let margin = 4.0; + let x = anchor + .0 + .min((viewport.0 - PANEL_W - margin).max(margin)) + .max(margin); + let y = anchor + .1 + .min((viewport.1 - panel_h - margin).max(margin)) + .max(margin); + + let mut children: Vec> = Vec::with_capacity(children_items.len()); + for (j, item) in children_items.iter().enumerate() { + children.push(item_view( + j, + Some((parent_idx, on_pick_sub.clone())), + item, + false, + // on_pick raíz no se usa cuando hay parent; pasamos un dummy. + &dummy_pick(), + None, + palette, + )); + } + panel_container(x, y + slide, panel_h, children, palette) +} + +/// El contenedor visual: panel redondeado con borde hairline (un nodo +/// exterior del color del borde + uno interior con el gradiente del +/// PanelStyle) y padding interno para que las píldoras de cada fila +/// queden inset — el look de menú de webpage. +fn panel_container( + x: f32, + y: f32, + panel_h: f32, + children: Vec>, + palette: &ContextMenuPalette, +) -> View { + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(x), + top: length(y), + right: auto(), + bottom: auto(), + }, + size: Size { + width: length(PANEL_W), + height: length(panel_h), + }, + padding: Rect { + left: length(1.0_f32), + right: length(1.0_f32), + top: length(1.0_f32), + bottom: length(1.0_f32), + }, + ..Default::default() + }) + .fill(palette.border) + .radius(palette.radius as f64) + .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), + }, + padding: Rect { + left: length(PANEL_PAD), + right: length(PANEL_PAD), + top: length(PANEL_PAD), + bottom: length(PANEL_PAD), + }, + ..Default::default() + }) + .radius((palette.radius - 1.0) as f64) + .paint_with(panel_signature_painter(palette.panel)) + .children(children)]) +} + +/// Ancla del flyout: a la derecha del panel padre, alineado al item. +fn submenu_anchor( + panel_x: f32, + panel_y: f32, + header: &Option, + items: &[ContextMenuItem], + parent_idx: usize, +) -> (f32, f32) { + let mut off = if header.is_some() { HEADER_H } else { 0.0 }; + off += 1.0 + PANEL_PAD; // borde + padding interno del contenedor + for it in items.iter().take(parent_idx) { + off += if it.separator { SEP_H } else { ITEM_H }; + } + // pequeño solape para que el flyout se lea continuo con el padre. + (panel_x + PANEL_W - PANEL_PAD, panel_y + off) +} + +fn header_view(text: String, palette: &ContextMenuPalette) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(HEADER_H), + }, + padding: Rect { + left: length(ITEM_PAD_LEFT + INDICATOR_W + ICON_W + 4.0), + right: length(ITEM_PAD_RIGHT), + top: length(2.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(text.to_uppercase(), 9.5, palette.fg_header, Alignment::Start) +} + +/// Pinta una fila. Si `parent` es `Some((pidx, cb))`, es un item de +/// submenú y clickea vía `cb(pidx, idx)`; si es `None`, es raíz y usa +/// `on_pick(idx)` + (si corresponde) `on_hover` para abrir su flyout. +#[allow(clippy::too_many_arguments)] +fn item_view( + idx: usize, + parent: Option<(usize, Arc Msg + Send + Sync>)>, + item: &ContextMenuItem, + is_active: bool, + on_pick: &Arc Msg + Send + Sync>, + on_hover: Option<&Arc) -> Msg + Send + Sync>>, + palette: &ContextMenuPalette, +) -> View { + if item.separator { + return separator_view(palette); + } + + // Color del texto y del atajo según estado. + let (fg, fg_dim): (Color, Color) = if !item.enabled { + (palette.fg_disabled, palette.fg_disabled) + } else if item.destructive { + (palette.fg_destructive, palette.fg_shortcut) + } else if is_active { + (palette.fg_active, palette.fg_active) + } else { + (palette.fg_text, palette.fg_shortcut) + }; + // Ícono: accent cuando la fila está activa (cue del menú), si no + // apagado. + let icon_fg = if !item.enabled { + palette.fg_disabled + } else if is_active { + palette.accent + } else { + palette.fg_icon + }; + + // Indicador accent vertical a la izquierda — visible sólo en la fila + // activa; reserva su ancho siempre para que el texto no salte. + let indicator = View::new(Style { + size: Size { + width: length(INDICATOR_W), + height: percent(0.55_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }); + let indicator = if is_active && item.enabled { + indicator.fill(palette.accent).radius(2.0) + } else { + indicator + }; + + // Gutter de ícono — auto height para que el row lo centre vertical. + let icon_cell = View::new(Style { + size: Size { + width: length(ICON_W), + height: auto(), + }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .text_aligned(item.icon.clone().unwrap_or_default(), 13.0, icon_fg, Alignment::Center); + + // Label — auto height (lo centra el align_items Center del row). + let label = View::new(Style { + size: Size { + width: auto(), + height: auto(), + }, + flex_grow: 1.0, + ..Default::default() + }) + .text_aligned(item.label.clone(), 12.5, fg, Alignment::Start); + + // Cola: chevron de submenú o atajo de teclado. + let trailing_text = if item.has_submenu() { + Some(("\u{203A}".to_string(), fg)) // › + } else { + item.shortcut.clone().map(|s| (s, fg_dim)) + }; + let mut row_children: Vec> = vec![indicator, icon_cell, label]; + if let Some((txt, color)) = trailing_text { + row_children.push( + View::new(Style { + size: Size { + width: length(64.0_f32), + height: auto(), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(txt, 11.0, color, Alignment::End), + ); + } + + let mut row = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(ITEM_H), + }, + flex_direction: FlexDirection::Row, + padding: Rect { + left: length(ITEM_PAD_LEFT), + right: length(ITEM_PAD_RIGHT), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + gap: Size { + width: length(2.0_f32), + height: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .radius(ITEM_RADIUS as f64) + .children(row_children); + + // Fondo: píldora suave en activo (teclado). El hover lo aporta + // `hover_fill` (tinte aún más suave) para no competir con el activo. + if is_active && item.enabled { + row = row.fill(palette.bg_active); + } + + if item.enabled { + row = row.hover_fill(palette.bg_hover); + match &parent { + Some((pidx, cb)) => { + let cb = cb.clone(); + let pidx = *pidx; + row = row.on_click_at(move |_, _, _, _| Some(cb(pidx, idx))); + } + None => { + let on_pick = on_pick.clone(); + row = row.on_click_at(move |_, _, _, _| Some(on_pick(idx))); + // Hover abre/cierra el flyout según sea submenú o no. + if let Some(on_hover) = on_hover { + let on_hover = on_hover.clone(); + let target = if item.has_submenu() { Some(idx) } else { None }; + row = row.on_pointer_enter(on_hover(target)); + } + } + } + } + row +} + +fn separator_view(palette: &ContextMenuPalette) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(SEP_H), + }, + flex_direction: FlexDirection::Column, + justify_content: Some(JustifyContent::Center), + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(ITEM_PAD_LEFT), + right: length(ITEM_PAD_RIGHT), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(1.0_f32), + }, + ..Default::default() + }) + .fill(palette.separator)]) +} + +/// `on_pick` dummy para los items de submenú (que usan `on_pick_sub`). +/// Nunca se invoca: `item_view` con `parent=Some` ignora `on_pick`. +fn dummy_pick() -> Arc Msg + Send + Sync> { + Arc::new(|_| unreachable!("submenu item usa on_pick_sub, no on_pick")) +} + +/// Navegación por teclado: dado el activo + dirección (`+1`/`-1`), el +/// siguiente índice válido (saltea separators y disabled). `usize::MAX` +/// si no hay elegibles. +pub fn step_active(items: &[ContextMenuItem], current: usize, direction: i32) -> usize { + if items.is_empty() { + return usize::MAX; + } + let n = items.len() as i32; + let start = if current == usize::MAX { + if direction >= 0 { + -1 + } else { + n + } + } else { + current as i32 + }; + let mut i = start; + for _ in 0..n { + i += direction; + if i < 0 { + i = n - 1; + } else if i >= n { + i = 0; + } + let item = &items[i as usize]; + if !item.separator && item.enabled { + return i as usize; + } + } + usize::MAX +} + +#[cfg(test)] +mod tests { + use super::*; + + fn it(label: &str) -> ContextMenuItem { + ContextMenuItem::action(label) + } + + #[test] + fn step_active_skips_separators() { + let items = vec![it("A"), ContextMenuItem::separator(), it("B"), it("C")]; + assert_eq!(step_active(&items, 0, 1), 2); + assert_eq!(step_active(&items, 2, -1), 0); + } + + #[test] + fn step_active_skips_disabled() { + let items = vec![it("A"), it("B").disabled(), it("C")]; + assert_eq!(step_active(&items, 0, 1), 2); + assert_eq!(step_active(&items, 2, -1), 0); + } + + #[test] + fn step_active_wraps_around() { + let items = vec![it("A"), it("B"), it("C")]; + assert_eq!(step_active(&items, 2, 1), 0); + assert_eq!(step_active(&items, 0, -1), 2); + } + + #[test] + fn submenu_y_icono_se_setean() { + let item = it("Tema") + .icon("◐") + .submenu(vec![it("Oscuro"), it("Claro")]); + assert!(item.has_submenu()); + assert_eq!(item.children.len(), 2); + assert_eq!(item.icon.as_deref(), Some("◐")); + } + + #[test] + fn extras_default_es_menu_clasico() { + let extras: ContextMenuExtras = ContextMenuExtras::default(); + assert_eq!(extras.appear, 1.0); + assert!(extras.open_sub.is_none()); + assert!(extras.on_hover.is_none()); + assert!(extras.on_pick_sub.is_none()); + } +} diff --git a/widgets/dock-rail/Cargo.toml b/widgets/dock-rail/Cargo.toml new file mode 100644 index 0000000..ca9cf7e --- /dev/null +++ b/widgets/dock-rail/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-dock-rail" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-dock-rail — rail vertical de dientes (pestañas con barra de acento + icono) para sidebars acoplables; clic activa, arrastre mueve entre rails." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/dock-rail/src/lib.rs b/widgets/dock-rail/src/lib.rs new file mode 100644 index 0000000..c69e7a6 --- /dev/null +++ b/widgets/dock-rail/src/lib.rs @@ -0,0 +1,184 @@ +//! `llimphi-widget-dock-rail` — rail vertical de **dientes** para +//! sidebars acoplables. +//! +//! Cada diente es una pestaña vertical: una **barra de acento** de 3px +//! pegada al borde interno (encendida cuando el item está activo) + un +//! **icono** centrado. Los dientes apilan en una columna redondeada que +//! se pinta como una franja flotante al borde del centro — el patrón del +//! dock de cosmos, ahora reutilizable. +//! +//! Render-only y agnóstico del `Msg`: el item se identifica por un `u64` +//! opaco. El clic (en el *press*, para no pelear con el arrastre) activa +//! vía `on_activate(id)`; el diente es **arrastrable** con su `id` como +//! payload, y el rail entero es **drop target** (`on_drop(payload)`) — +//! así una app puede mover un diente de un sidebar a otro soltándolo +//! sobre el rail opuesto. El icono lo dibuja el caller (`make_icon`), que +//! recibe el color ya resuelto según el estado activo/inactivo. +//! +//! ```ignore +//! let items = [DockRailItem { id: 0, active: true }, DockRailItem { id: 1, active: false }]; +//! dock_rail_view( +//! &items, +//! 44.0, +//! &DockRailPalette::from_theme(&theme), +//! |id, size, color| my_icon_view(id, size, color), +//! |id| Msg::DockActivate(side, id), +//! move |payload| Some(Msg::DockDrop(side, payload)), +//! ) +//! ``` + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, percent, FlexDirection, Size, Style}, + AlignItems, JustifyContent, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::{DragPhase, View}; +use llimphi_theme::Theme; + +/// Alto de cada diente (px). +const TOOTH_H: f32 = 42.0; +/// Alto de la barra de acento (px) — un poco menor que el diente para +/// dejar aire arriba y abajo. +const BAR_H: f32 = 40.0; +/// Tamaño del icono que se le pide al caller (px). +const ICON_PX: f32 = 20.0; + +/// Paleta del rail. +#[derive(Debug, Clone, Copy)] +pub struct DockRailPalette { + /// Fondo de la franja del rail. + pub bg_rail: Color, + /// Fondo del diente activo. + pub bg_active: Color, + /// Fondo al hover (diente) y al sobrevolar un drop válido (rail). + pub bg_hover: Color, + /// Color del acento: barra del diente activo + su icono. + pub accent: Color, + /// Color del icono de un diente inactivo. + pub icon_inactive: Color, +} + +impl DockRailPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + bg_rail: t.bg_panel_alt, + bg_active: t.bg_selected, + bg_hover: t.bg_row_hover, + accent: t.accent, + icon_inactive: t.fg_muted, + } + } +} + +/// Un diente del rail: su id opaco y si está activo. +#[derive(Debug, Clone, Copy)] +pub struct DockRailItem { + pub id: u64, + pub active: bool, +} + +/// Construye el rail de dientes. +/// +/// - `items`: en el orden en que se quieren mostrar (el widget no +/// reordena). +/// - `width`: ancho de la franja del rail (px). +/// - `make_icon(id, size, color)`: dibuja el icono del item con el color +/// ya resuelto (acento si activo, atenuado si no). +/// - `on_activate(id)`: `Msg` al clickear (en el press). +/// - `on_drop(payload)`: `Msg` opcional cuando se suelta un diente +/// (cualquier `id`) sobre este rail. +pub fn dock_rail_view( + items: &[DockRailItem], + width: f32, + palette: &DockRailPalette, + make_icon: FIcon, + on_activate: FAct, + on_drop: FDrop, +) -> View +where + Msg: Clone + Send + Sync + 'static, + FIcon: Fn(u64, f32, Color) -> View, + FAct: Fn(u64) -> Msg, + FDrop: Fn(u64) -> Option + Send + Sync + 'static, +{ + let mut teeth: Vec> = Vec::with_capacity(items.len()); + for item in items { + let fg = if item.active { + palette.accent + } else { + palette.icon_inactive + }; + // Barra de acento, pegada al borde interno. + let accent_bar = { + let b = View::new(Style { + size: Size { + width: length(3.0_f32), + height: length(BAR_H), + }, + flex_shrink: 0.0, + ..Default::default() + }); + if item.active { + b.fill(palette.accent).radius(2.0) + } else { + b + } + }; + let icon_box = View::new(Style { + flex_grow: 1.0, + size: Size { + width: percent(0.0_f32), + height: length(TOOTH_H), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .children(vec![make_icon(item.id, ICON_PX, fg)]); + + let id = item.id; + let msg = on_activate(id); + let mut tooth = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(TOOTH_H), + }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .hover_fill(palette.bg_hover) + // Click en el press activa; arrastrar mueve de rail (payload=id). + .on_click_at(move |_, _, _, _| Some(msg.clone())) + .draggable_at(|phase, _, _, _, _| match phase { + DragPhase::Move | DragPhase::End => None, + }) + .drag_payload(id) + .children(vec![accent_bar, icon_box]); + if item.active { + tooth = tooth.fill(palette.bg_active); + } + teeth.push(tooth); + } + + // La franja: sólo del alto de los dientes (el hueco de abajo lo deja + // libre para que el área central lo aproveche si el rail flota como + // overlay). Es además el drop target del lado. + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: length(width), + height: auto(), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_rail) + .radius(5.0) + .on_drop(on_drop) + .drop_hover_fill(palette.bg_hover) + .children(teeth) +} diff --git a/widgets/edit-menu/Cargo.toml b/widgets/edit-menu/Cargo.toml new file mode 100644 index 0000000..1223669 --- /dev/null +++ b/widgets/edit-menu/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "llimphi-widget-edit-menu" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-edit-menu — el menú de edición estándar (Deshacer/Rehacer/Cortar/Copiar/Pegar/Eliminar/Seleccionar todo) para cualquier campo que use EditorState (input single-line e IDE enriquecido). Arma el ContextMenuSpec desde flags derivados del estado y aplica las acciones reutilizando apply_key_with_clipboard." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-context-menu = { workspace = true } +llimphi-widget-text-editor = { workspace = true } diff --git a/widgets/edit-menu/src/lib.rs b/widgets/edit-menu/src/lib.rs new file mode 100644 index 0000000..777900e --- /dev/null +++ b/widgets/edit-menu/src/lib.rs @@ -0,0 +1,361 @@ +//! `llimphi-widget-edit-menu` — el menú de edición estándar para +//! cualquier campo de texto Llimphi. +//! +//! Tanto el input single-line ([`llimphi_widget_text_input`]) como el +//! editor IDE enriquecido ([`llimphi_widget_text_editor`]) se apoyan en +//! el mismo [`EditorState`]. Este widget arma, a partir de ese estado, +//! el menú contextual canónico: +//! +//! ```text +//! ┃ EDICIÓN +//! ┃ Deshacer Ctrl+Z +//! ┃ Rehacer Ctrl+Y +//! ┃ ───────────────────── +//! ┃ Cortar Ctrl+X +//! ┃ Copiar Ctrl+C +//! ┃ Pegar Ctrl+V +//! ┃ Eliminar Supr +//! ┃ ───────────────────── +//! ┃ Seleccionar todo Ctrl+A +//! ``` +//! +//! Cada ítem se habilita o no según el estado real (sin selección → +//! Cortar/Copiar/Eliminar grises; sin historial → Deshacer gris; etc). +//! +//! Uso típico, en tres pasos por app: +//! 1. El campo emite la posición del click derecho — `View::on_right_click_at` +//! → `Msg::AbrirMenuEdicion(x, y)`. El `update` guarda el ancla. +//! 2. `App::view_overlay` devuelve +//! `Some(context_menu_view(edit_menu::edit_context_menu(...)))` cuando el +//! ancla está presente. +//! 3. El pick produce `Msg::Edicion(EditAction)`; el `update` llama a +//! [`apply`] con el `EditorState` del campo focuseado y el clipboard. + +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use llimphi_theme::Theme; +use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers, NamedKey}; +use llimphi_widget_context_menu::{step_active, ContextMenuItem, ContextMenuPalette, ContextMenuSpec}; +use llimphi_widget_text_editor::{ApplyResult, Clipboard, EditorState}; + +/// Una acción de edición del menú estándar. Es `Copy` para que el +/// `on_pick` la capture sin clonar y la app la rebote en un `Msg`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EditAction { + Undo, + Redo, + Cut, + Copy, + Paste, + /// Borra la selección (Supr/Delete sin mover el resto). + Delete, + SelectAll, +} + +/// Banderas que deciden qué ítems van habilitados. Derivalas del estado +/// del campo focuseado con [`EditFlags::from_editor`]. +#[derive(Debug, Clone, Copy)] +pub struct EditFlags { + /// Hay selección no-vacía → Cortar/Copiar/Eliminar habilitados. + pub has_selection: bool, + /// Hay algo que deshacer. + pub can_undo: bool, + /// Hay algo que rehacer. + pub can_redo: bool, + /// El clipboard tiene contenido pegable → Pegar habilitado. Si no se + /// puede saber barato, pasá `true` (Pegar no-opea si está vacío). + pub can_paste: bool, + /// El buffer no está vacío → Seleccionar todo habilitado. + pub has_text: bool, + /// Campo enmascarado (password): Cortar/Copiar se deshabilitan para + /// no filtrar el secreto al clipboard. + pub masked: bool, +} + +impl Default for EditFlags { + fn default() -> Self { + Self { + has_selection: false, + can_undo: false, + can_redo: false, + can_paste: true, + has_text: false, + masked: false, + } + } +} + +impl EditFlags { + /// Deriva las banderas del estado del editor. `can_paste` se deja en + /// `true` (consultar el clipboard real requiere `&mut`; pegar vacío + /// es no-op). `masked` lo decide el caller (el input lo sabe vía + /// `TextInputState::is_masked`). + pub fn from_editor(state: &EditorState, masked: bool) -> Self { + Self { + has_selection: state.has_selection(), + can_undo: state.can_undo(), + can_redo: state.can_redo(), + can_paste: true, + has_text: !state.is_empty(), + masked, + } + } + + /// Igual que [`Self::from_editor`] pero fijando `can_paste` + /// explícitamente (cuando el caller ya sabe si el clipboard tiene + /// algo, p.ej. consultándolo una vez por frame). + pub fn from_editor_with_paste(state: &EditorState, masked: bool, can_paste: bool) -> Self { + Self { + can_paste, + ..Self::from_editor(state, masked) + } + } +} + +/// Los ítems del menú + la acción de cada uno alineadas por índice. Las +/// filas separador llevan una acción de relleno (`SelectAll`) que **nunca +/// se dispara**: el `context-menu` no engancha `on_click` en separadores +/// ni en ítems deshabilitados, así que `on_pick(i)` sólo recibe índices +/// de ítems-acción habilitados. Mantener un `EditAction` por fila (en vez +/// de `Option`) permite que el closure de `on_pick` capture sólo `Arc`s +/// y no un `Msg` crudo — clave para satisfacer `Send + Sync` sin exigirle +/// esos bounds al `Msg` de la app. +fn entries(flags: EditFlags) -> (Vec, Vec) { + let mut items: Vec = Vec::with_capacity(9); + let mut actions: Vec = Vec::with_capacity(9); + const FILL: EditAction = EditAction::SelectAll; + + let mut push = |item: ContextMenuItem, action: EditAction| { + items.push(item); + actions.push(action); + }; + + let undo = ContextMenuItem::action("Deshacer").icon("\u{21A9}").with_shortcut("Ctrl+Z"); + push( + if flags.can_undo { undo } else { undo.disabled() }, + EditAction::Undo, + ); + let redo = ContextMenuItem::action("Rehacer").icon("\u{21AA}").with_shortcut("Ctrl+Y"); + push( + if flags.can_redo { redo } else { redo.disabled() }, + EditAction::Redo, + ); + + push(ContextMenuItem::separator(), FILL); + + let can_copy = flags.has_selection && !flags.masked; + let cut = ContextMenuItem::action("Cortar").icon("\u{2702}").with_shortcut("Ctrl+X"); + push(if can_copy { cut } else { cut.disabled() }, EditAction::Cut); + let copy = ContextMenuItem::action("Copiar").icon("\u{29C9}").with_shortcut("Ctrl+C"); + push(if can_copy { copy } else { copy.disabled() }, EditAction::Copy); + let paste = ContextMenuItem::action("Pegar").icon("\u{2398}").with_shortcut("Ctrl+V"); + push( + if flags.can_paste { paste } else { paste.disabled() }, + EditAction::Paste, + ); + let del = ContextMenuItem::action("Eliminar") + .icon("\u{2717}") + .with_shortcut("Supr") + .destructive(); + push( + if flags.has_selection { del } else { del.disabled() }, + EditAction::Delete, + ); + + push(ContextMenuItem::separator(), FILL); + + let sel = ContextMenuItem::action("Seleccionar todo").icon("\u{2750}").with_shortcut("Ctrl+A"); + push( + if flags.has_text { sel } else { sel.disabled() }, + EditAction::SelectAll, + ); + + (items, actions) +} + +/// Sólo los ítems (para componer un menú custom que incluya el bloque de +/// edición seguido de acciones propias de la app). +pub fn edit_menu_items(flags: EditFlags) -> Vec { + entries(flags).0 +} + +/// Mueve el resaltado de teclado por las filas del menú de edición, saltando +/// separadores y filas deshabilitadas. `active == usize::MAX` significa "ninguna +/// fila"; `direction` +1 baja, −1 sube. Pensado para enganchar flechas +/// arriba/abajo sobre el menú de edición abierto (paralelo a [`step_active`]). +pub fn edit_menu_step(flags: EditFlags, active: usize, direction: i32) -> usize { + let items = entries(flags).0; + step_active(&items, active, direction) +} + +/// La [`EditAction`] de la fila `active`, o `None` si esa fila es un separador, +/// está deshabilitada o fuera de rango. Pensado para resolver la tecla Enter +/// sobre la fila resaltada por [`edit_menu_step`]. +pub fn edit_menu_action_at(flags: EditFlags, active: usize) -> Option { + let (items, actions) = entries(flags); + let item = items.get(active)?; + if item.separator || !item.enabled { + return None; + } + actions.get(active).copied() +} + +/// Arma el [`ContextMenuSpec`] del menú de edición listo para +/// `context_menu_view`. `on_action` rebota cada pick en un `Msg` de la +/// app; `on_dismiss` cierra al click-fuera o Esc. +pub fn edit_context_menu( + anchor: (f32, f32), + viewport: (f32, f32), + theme: &Theme, + flags: EditFlags, + on_action: F, + on_dismiss: Msg, +) -> ContextMenuSpec +where + Msg: Clone + 'static, + F: Fn(EditAction) -> Msg + Send + Sync + 'static, +{ + let (items, actions) = entries(flags); + let actions = Arc::new(actions); + let on_action = Arc::new(on_action); + let on_pick: Arc Msg + Send + Sync> = Arc::new(move |i: usize| { + // `i` siempre cae en un ítem-acción habilitado (los separadores y + // deshabilitados no enganchan click). El `SelectAll` de relleno de + // los separadores nunca se alcanza. + let a = actions.get(i).copied().unwrap_or(EditAction::SelectAll); + (on_action)(a) + }); + + ContextMenuSpec { + anchor, + viewport, + header: Some("Edición".to_string()), + items, + active: usize::MAX, + on_pick, + on_dismiss, + palette: ContextMenuPalette::from_theme(theme), + } +} + +/// Aplica una [`EditAction`] al `EditorState`. Reutiliza +/// `apply_key_with_clipboard` (sintetizando la tecla equivalente) para +/// heredar exactamente el mismo comportamiento — incluido el bookkeeping +/// de parseo incremental — que el atajo de teclado. Devuelve el +/// [`ApplyResult`] para que el caller decida si persistir el cambio. +pub fn apply(state: &mut EditorState, action: EditAction, clipboard: &mut dyn Clipboard) -> ApplyResult { + match action { + EditAction::SelectAll => { + state.select_all(); + ApplyResult::CursorMoved + } + EditAction::Undo => state.apply_key_with_clipboard(&ctrl_char("z"), clipboard), + EditAction::Redo => state.apply_key_with_clipboard(&ctrl_char("y"), clipboard), + EditAction::Cut => state.apply_key_with_clipboard(&ctrl_char("x"), clipboard), + EditAction::Copy => state.apply_key_with_clipboard(&ctrl_char("c"), clipboard), + EditAction::Paste => state.apply_key_with_clipboard(&ctrl_char("v"), clipboard), + EditAction::Delete => state.apply_key_with_clipboard(&named(NamedKey::Delete), clipboard), + } +} + +fn ctrl_char(s: &str) -> KeyEvent { + KeyEvent { + key: Key::Character(s.into()), + state: KeyState::Pressed, + text: Some(s.to_string()), + modifiers: Modifiers { + ctrl: true, + ..Modifiers::default() + }, + repeat: false, + } +} + +fn named(k: NamedKey) -> KeyEvent { + KeyEvent { + key: Key::Named(k), + state: KeyState::Pressed, + text: None, + modifiers: Modifiers::default(), + repeat: false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use llimphi_widget_text_editor::MemClipboard; + + fn lleno() -> EditorState { + let mut s = EditorState::new(); + s.set_text("hola mundo"); + s + } + + #[test] + fn select_all_y_copy_llevan_todo_al_clipboard() { + let mut s = lleno(); + let r = apply(&mut s, EditAction::SelectAll, &mut MemClipboard::new()); + assert_eq!(r, ApplyResult::CursorMoved); + assert!(s.has_selection()); + let mut clip = MemClipboard::new(); + apply(&mut s, EditAction::Copy, &mut clip); + assert_eq!(clip.get().as_deref(), Some("hola mundo")); + } + + #[test] + fn cut_borra_y_copia() { + let mut s = lleno(); + s.select_all(); + let mut clip = MemClipboard::new(); + let r = apply(&mut s, EditAction::Cut, &mut clip); + assert_eq!(r, ApplyResult::Changed); + assert!(s.is_empty()); + assert_eq!(clip.get().as_deref(), Some("hola mundo")); + } + + #[test] + fn paste_inserta_del_clipboard() { + let mut s = EditorState::new(); + let mut clip = MemClipboard::with("XYZ"); + apply(&mut s, EditAction::Paste, &mut clip); + assert_eq!(s.text(), "XYZ"); + } + + #[test] + fn flags_sin_seleccion_deshabilitan_copiar() { + let s = lleno(); + let flags = EditFlags::from_editor(&s, false); + assert!(!flags.has_selection); + let items = edit_menu_items(flags); + // "Cortar" es el primer ítem tras el separador (índice 3). + assert!(!items[3].enabled, "Cortar debería estar deshabilitado sin selección"); + } + + #[test] + fn step_y_action_at_saltan_separadores_y_deshabilitados() { + let mut s = lleno(); + s.select_all(); + let flags = EditFlags::from_editor(&s, false); + // Desde "ninguna fila", bajar cae en la primera seleccionable (Deshacer + // está gris sin historial; Cortar=3 es el primer habilitado real). + let first = edit_menu_step(flags, usize::MAX, 1); + assert!(edit_menu_action_at(flags, first).is_some()); + // El separador (índice 2) nunca da acción. + assert_eq!(edit_menu_action_at(flags, 2), None); + // Avanzar y retroceder vuelve a una fila con acción válida. + let next = edit_menu_step(flags, first, 1); + assert!(edit_menu_action_at(flags, next).is_some()); + } + + #[test] + fn masked_deshabilita_copiar_aun_con_seleccion() { + let mut s = lleno(); + s.select_all(); + let flags = EditFlags::from_editor(&s, true); + let items = edit_menu_items(flags); + assert!(!items[4].enabled, "Copiar debería estar gris en campo enmascarado"); + } +} diff --git a/widgets/empty/Cargo.toml b/widgets/empty/Cargo.toml new file mode 100644 index 0000000..7042e6c --- /dev/null +++ b/widgets/empty/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-empty" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-empty — empty-state con icono grande, título y descripción opcional. Reemplaza pantallas en blanco crudas con orientación al usuario." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-icons = { workspace = true } diff --git a/widgets/empty/src/lib.rs b/widgets/empty/src/lib.rs new file mode 100644 index 0000000..8bb2568 --- /dev/null +++ b/widgets/empty/src/lib.rs @@ -0,0 +1,112 @@ +//! `llimphi-widget-empty` — empty state con icono, título y descripción. +//! +//! Patrón para reemplazar pantallas en blanco con orientación: cuando +//! una lista no tiene items, un editor no tiene archivo abierto, una +//! búsqueda no arrojó resultados — en vez de fondo plano, mostrar +//! un icono grande apagado + título + descripción corta + (opcional) +//! botón de acción primaria. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, JustifyContent, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_icons::{icon_view, Icon}; +use llimphi_theme::{alpha, Theme}; + +/// Paleta del empty state — colores apagados para no competir con la +/// UI principal. +#[derive(Debug, Clone, Copy)] +pub struct EmptyPalette { + pub fg_icon: Color, + pub fg_title: Color, + pub fg_desc: Color, +} + +impl EmptyPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + fg_icon: with_alpha8(t.fg_muted, alpha::HINT), + fg_title: t.fg_muted, + fg_desc: with_alpha8(t.fg_muted, alpha::DISABLED), + } + } +} + +fn with_alpha8(c: Color, a: u8) -> Color { + let [r, g, b, _] = c.components; + use llimphi_ui::llimphi_raster::peniko::color::AlphaColor; + AlphaColor::new([r, g, b, a as f32 / 255.0]) +} + +/// Construye el empty state. La app llama desde su `view()` cuando +/// detecta el caso vacío: +/// +/// ```ignore +/// if model.items.is_empty() { +/// return empty_view(Icon::File, "Sin archivos abiertos", +/// Some("Abrí uno con Ctrl+O para empezar."), +/// &palette); +/// } +/// ``` +pub fn empty_view( + icon: Icon, + title: impl Into, + description: Option<&str>, + palette: &EmptyPalette, +) -> View { + let icon_cell = View::new(Style { + size: Size { + width: length(72.0_f32), + height: length(72.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![icon_view(icon, palette.fg_icon, 1.4)]); + + let title_view = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(28.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(title.into(), 15.5, palette.fg_title, Alignment::Center); + + let mut children = vec![icon_cell, title_view]; + if let Some(desc) = description { + children.push( + View::new(Style { + size: Size { + width: length(360.0_f32), + height: length(40.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(desc.to_string(), 12.0, palette.fg_desc, Alignment::Center), + ); + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + gap: Size { + width: length(0.0_f32), + height: length(14.0_f32), + }, + ..Default::default() + }) + .children(children) +} diff --git a/widgets/field/Cargo.toml b/widgets/field/Cargo.toml new file mode 100644 index 0000000..9179ae6 --- /dev/null +++ b/widgets/field/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-field" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-field — wrapper de formulario: label arriba + slot del input + descripción/error abajo. Patrón estándar para formularios accesibles." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/field/src/lib.rs b/widgets/field/src/lib.rs new file mode 100644 index 0000000..a847c3b --- /dev/null +++ b/widgets/field/src/lib.rs @@ -0,0 +1,127 @@ +//! `llimphi-widget-field` — wrapper de formulario para inputs. +//! +//! Patrón estándar de campo: +//! ```text +//! Nombre del campo (label — bold, fg_text) +//! [ input control aquí ] (slot — viene como View) +//! Descripción o error abajo (helper — fg_muted o fg_destructive) +//! ``` +//! +//! El widget no implementa el input — lo recibe como `View` y lo +//! envuelve. Esto permite usarlo con `text-input`, `text-area`, `switch`, +//! `segmented` o cualquier otro control. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_theme::Theme; + +#[derive(Debug, Clone, Copy)] +pub struct FieldPalette { + pub fg_label: Color, + pub fg_helper: Color, + pub fg_error: Color, + pub fg_required: Color, +} + +impl FieldPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + fg_label: t.fg_text, + fg_helper: t.fg_muted, + fg_error: t.fg_destructive, + fg_required: t.fg_destructive, + } + } +} + +/// Spec del field. `helper` y `error` son mutuamente excluyentes — +/// si hay error, se renderiza el error (rojo); si no, el helper. +pub struct FieldSpec { + pub label: String, + /// El input/control concreto (text-input, switch, segmented, etc). + pub control: View, + /// Marca el field como requerido — agrega un asterisco al label. + pub required: bool, + /// Texto explicativo debajo del control. `None` para omitirlo. + pub helper: Option, + /// Mensaje de error — gana sobre `helper` cuando está presente. + pub error: Option, + pub palette: FieldPalette, +} + +const LABEL_H: f32 = 16.0; +const HELPER_H: f32 = 16.0; +const GAP_LABEL: f32 = 4.0; +const GAP_HELPER: f32 = 4.0; +const LABEL_FONT: f32 = 11.5; +const HELPER_FONT: f32 = 10.5; + +pub fn field_view(spec: FieldSpec) -> View { + let FieldSpec { + label, + control, + required, + helper, + error, + palette, + } = spec; + + // Label: nombre + (asterisco si required). + let label_text = if required { format!("{label} *") } else { label }; + let label_view = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(LABEL_H), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(label_text, LABEL_FONT, palette.fg_label, Alignment::Start); + + // Helper / error — error gana si presente. + let helper_text = error.clone().or(helper.clone()); + let helper_color = if error.is_some() { palette.fg_error } else { palette.fg_helper }; + + let mut children: Vec> = vec![label_view, spacer(GAP_LABEL), control]; + if let Some(t) = helper_text { + children.push(spacer(GAP_HELPER)); + children.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(HELPER_H), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(t, HELPER_FONT, helper_color, Alignment::Start), + ); + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: llimphi_ui::llimphi_layout::taffy::prelude::auto(), + }, + ..Default::default() + }) + .children(children) +} + +fn spacer(h: f32) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(h), + }, + flex_shrink: 0.0, + ..Default::default() + }) +} diff --git a/widgets/gallery/Cargo.toml b/widgets/gallery/Cargo.toml new file mode 100644 index 0000000..4640b7c --- /dev/null +++ b/widgets/gallery/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "llimphi-widget-gallery" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-gallery — app demo que pinta todos los widgets de llimphi en una sola ventana. Pensado como referencia visual y como smoke test al introducir cambios al theme o a los widgets." + +[[bin]] +name = "llimphi-widget-gallery" +path = "src/main.rs" + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-app-header = { workspace = true } +llimphi-widget-banner = { workspace = true } +llimphi-widget-button = { workspace = true } +llimphi-widget-card = { workspace = true } +llimphi-widget-list = { workspace = true } +llimphi-widget-splitter = { workspace = true } +llimphi-widget-stat-card = { workspace = true } +llimphi-widget-tabs = { workspace = true } +llimphi-widget-theme-switcher = { workspace = true } +llimphi-widget-text-input = { workspace = true } +llimphi-widget-tiled = { workspace = true } +llimphi-widget-tree = { workspace = true } +llimphi-widget-menubar = { workspace = true } +llimphi-widget-context-menu = { workspace = true } +app-bus = { workspace = true } diff --git a/widgets/gallery/LEEME.md b/widgets/gallery/LEEME.md new file mode 100644 index 0000000..d94b8f8 --- /dev/null +++ b/widgets/gallery/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-gallery + +> Grid virtualizada de cards para [llimphi](../../README.md). + +Layout responsive con columnas auto-fit; cada card es `View` libre. Reutiliza [`card`](../card/README.md). diff --git a/widgets/gallery/README.md b/widgets/gallery/README.md new file mode 100644 index 0000000..eaf56e0 --- /dev/null +++ b/widgets/gallery/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-gallery + +> Virtualized card grid for [llimphi](../../README.md). + +Responsive layout with auto-fit columns; each card is a free `View`. Reuses [`card`](../card/README.md). diff --git a/widgets/gallery/src/main.rs b/widgets/gallery/src/main.rs new file mode 100644 index 0000000..af16bc4 --- /dev/null +++ b/widgets/gallery/src/main.rs @@ -0,0 +1,569 @@ +//! `llimphi-widget-gallery` — todos los widgets de Llimphi en una sola +//! ventana. Útil como referencia visual y smoke test al cambiar el +//! theme o cualquier widget. +//! +//! Corré con: `cargo run -p llimphi-widget-gallery --release`. + +use std::sync::Arc; + +use llimphi_theme::Theme; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{App, DragPhase, Handle, View}; +use llimphi_widget_app_header::{app_header, AppHeaderPalette}; +use llimphi_widget_banner::{banner_view, BannerKind}; +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_stat_card::{stat_card_view, StatCardPalette}; +use llimphi_widget_tabs::{tabs_view, TabsPalette, TabsSpec}; +use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState}; +use llimphi_widget_theme_switcher::theme_switcher_view; +use llimphi_widget_tiled::{tiled_view_reorderable, TileSpec, TiledPalette}; +use llimphi_widget_context_menu::{ + context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec, +}; +use llimphi_widget_menubar::{menubar_overlay, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H}; + +use app_bus::{AppMenu, Menu, MenuItem}; + +#[derive(Clone)] +enum Msg { + EditKey(llimphi_ui::KeyEvent), + SelectRow(usize), + SelectTab(usize), + ClickAction(u32), + ResizeOuter(f32), + SwapTile { from: usize, to: usize }, + ChangeTheme(Theme), + CycleTheme, + // --- Barra de menú + contextual --- + MenuOpen(Option), + MenuCommand(String), + CloseMenus, + ContextMenuOpen(f32, f32), +} + +struct Model { + text: TextInputState, + list_sel: usize, + tab: usize, + last_action: Option, + left_w: f32, + tile_order: Vec, + theme: Theme, + /// Índice del menú raíz abierto en la barra (None = ninguno). + menu_open: Option, + /// Posición (en coords de ventana) del menú contextual, si está abierto. + context_menu: Option<(f32, f32)>, +} + +struct Gallery; + +impl App for Gallery { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · widget gallery" + } + + fn initial_size() -> (u32, u32) { + (1280, 820) + } + + fn init(_: &Handle) -> Model { + Model { + text: TextInputState::new(), + list_sel: 0, + tab: 0, + last_action: None, + left_w: 380.0, + tile_order: vec![0, 1, 2, 3], + theme: Theme::dark(), + menu_open: None, + context_menu: None, + } + } + + fn on_key(_: &Model, e: &llimphi_ui::KeyEvent) -> Option { + Some(Msg::EditKey(e.clone())) + } + + fn update(model: Model, msg: Msg, handle: &Handle) -> Model { + let mut m = model; + match msg { + Msg::EditKey(ev) => { + m.text.apply_key(&ev); + } + Msg::SelectRow(i) => m.list_sel = i, + Msg::SelectTab(i) => m.tab = i, + Msg::ClickAction(id) => m.last_action = Some(id), + Msg::ResizeOuter(dx) => m.left_w = (m.left_w + dx).clamp(220.0, 800.0), + Msg::SwapTile { from, to } => { + if from != to && from < m.tile_order.len() && to < m.tile_order.len() { + m.tile_order.swap(from, to); + } + } + Msg::ChangeTheme(t) => m.theme = t, + Msg::CycleTheme => m.theme = Theme::next_after(m.theme.name), + Msg::MenuOpen(which) => { + m.menu_open = which; + // Abrir un menú raíz cierra cualquier contextual. + m.context_menu = None; + } + Msg::CloseMenus => { + m.menu_open = None; + m.context_menu = None; + } + Msg::ContextMenuOpen(x, y) => { + m.menu_open = None; + m.context_menu = Some((x, y)); + } + Msg::MenuCommand(cmd) => { + m.menu_open = None; + m.context_menu = None; + handle_menu_command(&cmd, &mut m, handle); + } + } + m + } + + fn view(model: &Model) -> View { + let theme = model.theme; + let menu = app_menu(); + let menubar = menubar_view(&menubar_spec(&menu, model)); + let header_palette = AppHeaderPalette::from_theme(&theme); + let btn_palette = ButtonPalette::from_theme(&theme); + let list_palette = ListPalette::from_theme(&theme); + let splitter_palette = SplitterPalette::from_theme(&theme); + let tabs_palette = TabsPalette::from_theme(&theme); + let stat_palette = StatCardPalette::from_theme(&theme); + let input_palette = TextInputPalette::from_theme(&theme); + + // --- Header con acción a la derecha --- + let header = app_header( + format!( + "llimphi widget gallery · última acción: {}", + match model.last_action { + Some(i) => format!("button #{i}"), + None => "ninguna".to_string(), + } + ), + vec![ + { + let mut btn = button_view("acción A", &btn_palette, Msg::ClickAction(1)); + btn.style.size = Size { + width: length(110.0_f32), + height: length(28.0_f32), + }; + btn + }, + { + let mut btn = button_view("acción B", &btn_palette, Msg::ClickAction(2)); + btn.style.size = Size { + width: length(110.0_f32), + height: length(28.0_f32), + }; + btn + }, + theme_switcher_view::(&theme, Msg::ChangeTheme), + ], + &header_palette, + ); + + // --- Panel izquierdo: lista virtualizada --- + let entries = (0..40) + .map(|i| format!("entry {:02}", i)) + .collect::>(); + let visible_rows: Vec> = entries + .iter() + .enumerate() + .take(20) + .map(|(i, label)| ListRow { + label: label.clone(), + selected: i == model.list_sel, + on_click: Msg::SelectRow(i), + }) + .collect(); + let list = list_view(ListSpec { + rows: visible_rows, + total: entries.len(), + caption: Some(format!("{} entradas", entries.len())), + truncated_hint: Some(format!("… y {} más", entries.len() - 20)), + row_height: 22.0, + palette: list_palette, + }); + + // --- Panel derecho: tabs con stat cards + banners + input + tiled --- + let tiled_palette = TiledPalette::from_theme(&theme); + let tab_content = match model.tab { + 0 => stats_pane(&theme, &stat_palette), + 1 => alerts_pane(), + 2 => input_pane(&model.text, &input_palette, &theme), + _ => tiled_pane(&theme, &tiled_palette, &model.tile_order), + }; + let tabs = tabs_view(TabsSpec { + labels: vec!["Stats".into(), "Banners".into(), "Input".into(), "Tiled".into()], + active: model.tab, + on_select: Msg::SelectTab, + content: tab_content, + tab_height: 32.0, + palette: tabs_palette, + tab_width: Some(120.0), + }); + + let body = splitter_two( + Direction::Row, + list, + PaneSize::Fixed(model.left_w), + tabs, + PaneSize::Flex, + |phase, dx| match phase { + DragPhase::Move => Some(Msg::ResizeOuter(dx)), + DragPhase::End => None, + }, + &splitter_palette, + ); + + 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) + // Origen (0,0) ⇒ las coords locales del right-click son coords de + // ventana, listas para anclar el contextual. + .on_right_click_at(|x, y, _, _| Some(Msg::ContextMenuOpen(x, y))) + .children(vec![menubar, header, body]) + } + + fn view_overlay(model: &Model) -> Option> { + // Prioridad: contextual sobre el dropdown del menú principal. + if let Some((x, y)) = model.context_menu { + return Some(context_menu_for_gallery(model, x, y)); + } + let menu = app_menu(); + menubar_overlay(&menubar_spec(&menu, model)) + } +} + +// --------------------------------------------------------------------- +// Menú principal (barra) + contextual +// --------------------------------------------------------------------- + +/// Viewport para clampear overlays. La gallery no trackea el tamaño real +/// de ventana, así que usamos las constantes de `initial_size()`. +fn viewport_of(_model: &Model) -> (f32, f32) { + let (w, h) = Gallery::initial_size(); + (w as f32, h as f32) +} + +/// `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 de la vitrina. Archivo / Ver / Ayuda — sólo comandos que +/// mapean a `Msg` reales. No hay "Editar": el único campo de texto es el +/// `text_input` del tab Input, que ya recibe teclas por `on_key`. +fn app_menu() -> AppMenu { + AppMenu::new() + .menu( + Menu::new("Archivo").item(MenuItem::new("Salir", "file.quit").shortcut("Esc")), + ) + .menu( + Menu::new("Ver") + .item(MenuItem::new("Cambiar tema", "view.theme")) + .item(MenuItem::new("Pestaña: Stats", "view.tab.0").separated()) + .item(MenuItem::new("Pestaña: Banners", "view.tab.1")) + .item(MenuItem::new("Pestaña: Input", "view.tab.2")) + .item(MenuItem::new("Pestaña: Tiled", "view.tab.3")), + ) + .menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about"))) +} + +/// Traduce un command id (de la barra o del contextual) al `Msg` real y lo +/// aplica. `file.quit` cierra el proceso directo (no hay diálogo). +fn handle_menu_command(cmd: &str, m: &mut Model, handle: &Handle) { + match cmd { + "file.quit" => std::process::exit(0), + // Reusa el Msg de ciclo de tema en vez de duplicar la lógica. + "view.theme" => handle.dispatch(Msg::CycleTheme), + "view.tab.0" => m.tab = 0, + "view.tab.1" => m.tab = 1, + "view.tab.2" => m.tab = 2, + "view.tab.3" => m.tab = 3, + // "help.about" y desconocidos: no-op (sin diálogo todavía). + _ => {} + } +} + +/// Menú contextual de la vitrina. No hay objeto seleccionable en un canvas: +/// el "ítem seleccionado" es la entrada resaltada de la lista izquierda, así +/// que el contextual la nombra y ofrece navegar las pestañas + cambiar tema. +fn context_menu_for_gallery(model: &Model, x: f32, y: f32) -> View { + let header = format!("entrada seleccionada: {:02}", model.list_sel); + + let items = vec![ + ContextMenuItem::action("Cambiar tema"), + ContextMenuItem::separator(), + ContextMenuItem::action("Pestaña: Stats"), + ContextMenuItem::action("Pestaña: Banners"), + ContextMenuItem::action("Pestaña: Input"), + ContextMenuItem::action("Pestaña: Tiled"), + ]; + // Mapeo índice de item → command id de `handle_menu_command`. + let cmds: Vec<&'static str> = vec![ + "view.theme", + "", + "view.tab.0", + "view.tab.1", + "view.tab.2", + "view.tab.3", + ]; + let on_pick: Arc Msg + Send + Sync> = Arc::new(move |i: usize| { + Msg::MenuCommand(cmds.get(i).copied().unwrap_or("").to_string()) + }); + + context_menu_view(ContextMenuSpec { + anchor: (x, y), + viewport: viewport_of(model), + header: Some(header), + items, + active: usize::MAX, + on_pick, + on_dismiss: Msg::CloseMenus, + palette: ContextMenuPalette::from_theme(&model.theme), + }) +} + +// --------------------------------------------------------------------- +// Paneles de tabs +// --------------------------------------------------------------------- + +fn stats_pane(theme: &Theme, palette: &StatCardPalette) -> View { + let valid = Color::from_rgba8(94, 184, 124, 255); + let warn = Color::from_rgba8(238, 178, 53, 255); + let danger = Color::from_rgba8(225, 84, 75, 255); + + let row = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(160.0_f32), + }, + gap: Size { + width: length(12.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![ + wrap_card_cell(stat_card_view::( + "Coherencia", + "247", + "átomos válidos", + valid, + &[], + palette, + )), + wrap_card_cell(stat_card_view::( + "Por evaluar", + "12", + "esperando re-cómputo", + warn, + &[], + palette, + )), + wrap_card_cell(stat_card_view::( + "En conflicto", + "3", + "contradicen su origen", + danger, + &[ + "puerta_amanecer".into(), + "muelle_soledad".into(), + "viento_nuevo".into(), + ], + palette, + )), + ]); + + let _ = theme; + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(20.0_f32), + right: length(20.0_f32), + top: length(20.0_f32), + bottom: length(20.0_f32), + }, + ..Default::default() + }) + .children(vec![row]) +} + +fn wrap_card_cell(view: View) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + ..Default::default() + }) + .children(vec![view]) +} + +fn alerts_pane() -> View { + 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(10.0_f32), + }, + padding: Rect { + left: length(20.0_f32), + right: length(20.0_f32), + top: length(20.0_f32), + bottom: length(20.0_f32), + }, + ..Default::default() + }) + .children(vec![ + banner_view(BannerKind::Info, "info: gallery iniciada con widgets verdes"), + banner_view(BannerKind::Success, "success: 12 widgets cargados ok"), + banner_view(BannerKind::Warning, "warning: el tema light aún tiene contraste subóptimo"), + banner_view(BannerKind::Error, "error: ningún error real — sólo un demo"), + ]) +} + +fn input_pane(state: &TextInputState, palette: &TextInputPalette, theme: &Theme) -> View { + let label = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(18.0_f32), + }, + ..Default::default() + }) + .text_aligned( + "Probá tipear acá:".to_string(), + 12.0, + theme.fg_muted, + Alignment::Start, + ); + let input = text_input_view( + state, + "lo que sea", + true, // siempre focado en este demo + palette, + Msg::ClickAction(0), + ); + + 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(8.0_f32), + }, + padding: Rect { + left: length(20.0_f32), + right: length(20.0_f32), + top: length(20.0_f32), + bottom: length(20.0_f32), + }, + ..Default::default() + }) + .children(vec![label, input]) +} + +fn tiled_pane(theme: &Theme, palette: &TiledPalette, order: &[usize]) -> View { + let body = |text: &str, size: f32, color: Color, align: Alignment| -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + 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(text.to_string(), size, color, align) + }; + + let make_tile = |id: usize| -> TileSpec { + match id { + 0 => TileSpec { + label: "logs".into(), + content: body( + "[12:01] boot\n[12:02] config ok\n[12:03] esperando…", + 12.0, + theme.fg_text, + Alignment::Start, + ), + }, + 1 => TileSpec { + label: "métricas".into(), + content: body("cpu 37%\nram 1.2 G\nnet 12 kB/s", 12.0, theme.fg_text, Alignment::Start), + }, + 2 => TileSpec { + label: "uptime".into(), + content: body("4d 12h", 26.0, theme.accent, Alignment::Center), + }, + _ => TileSpec { + label: "queue".into(), + content: body( + "pending 7\nin-flight 2\ndone 1842", + 12.0, + theme.fg_text, + Alignment::Start, + ), + }, + } + }; + + let tiles: Vec> = order.iter().map(|&id| make_tile(id)).collect(); + + tiled_view_reorderable( + tiles, + |from, to| Some(Msg::SwapTile { from, to }), + palette, + ) +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/grid/Cargo.toml b/widgets/grid/Cargo.toml new file mode 100644 index 0000000..55619fb --- /dev/null +++ b/widgets/grid/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-grid" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-grid — grilla virtualizada 2D para Llimphi: celdas clicables en mosaico, selección, caption/hint opcionales, recorte de overflow. El caller hace la virtualización (calcula la ventana visible con `ventana_visible` y pasa sólo las celdas visibles); el widget las compone en filas. Base para galerías de miniaturas tipo gThumb/FastStone — agnóstico del contenido de la celda (el caller arma cada `View`: thumb, placeholder, lo que sea)." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/grid/src/lib.rs b/widgets/grid/src/lib.rs new file mode 100644 index 0000000..72a61bc --- /dev/null +++ b/widgets/grid/src/lib.rs @@ -0,0 +1,467 @@ +//! `llimphi-widget-grid` — grilla virtualizada 2D para Llimphi. +//! +//! Hermano de [`llimphi-widget-list`], pero en mosaico: celdas clicables +//! dispuestas en `cols` columnas × filas, con selección, caption/hint +//! opcionales y recorte de overflow. Pensado como base para galerías de +//! miniaturas tipo gThumb / FastStone — capaz de listar miles de archivos +//! sin montar todo: el caller renderea **sólo la ventana visible**. +//! +//! Como `list`, el widget **no** scrollea por sí mismo. La virtualización +//! es del caller, que mantiene `scroll_fila` (primera fila de celdas +//! visible) en su estado y lo actualiza con la rueda (calco de +//! `nahual-file-explorer`). La diferencia con `list` es que en 2D el +//! cálculo de la ventana no es trivial — cuántas columnas caben depende +//! del ancho del viewport — así que este crate lo provee como función +//! pura testeable: [`ventana_visible`]. +//! +//! El widget es **agnóstico del contenido**: cada [`GridCell`] lleva un +//! `View` que el caller arma (un thumb `peniko::Image`, un skeleton +//! mientras decodifica, un ícono…). Así el pipeline de miniaturas (cola +//! async + cache) vive afuera y sólo llena la celda con imagen o +//! placeholder. +//! +//! Flujo típico del caller: +//! +//! ```ignore +//! let v = ventana_visible(total, viewport_w, viewport_h, scroll_fila, &metrics); +//! let cells: Vec> = (v.first..v.first + v.count) +//! .map(|i| GridCell { +//! content: thumb_o_placeholder(i), // el caller decide +//! label: Some(nombre(i)), +//! selected: i == seleccionado, +//! on_click: Msg::Seleccionar(i), +//! }) +//! .collect(); +//! let grid = grid_view(GridSpec { +//! cells, cols: v.cols, metrics, +//! caption: Some(format!("{total} imágenes")), +//! truncated_hint: (v.first + v.count < total) +//! .then(|| format!("… y {} más", total - (v.first + v.count))), +//! palette: GridPalette::default(), +//! }); +//! ``` + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; + +/// Geometría de la grilla en pixels. `tile_h` debe incluir el alto del +/// label si el caller lo usa — el widget reserva una franja inferior para +/// él dentro de la celda. `gap` es el espacio entre celdas (y entre filas); +/// `pad` el margen interno del contenedor (cada lado). +#[derive(Debug, Clone, Copy)] +pub struct GridMetrics { + pub tile_w: f32, + pub tile_h: f32, + pub gap: f32, + pub pad: f32, +} + +impl Default for GridMetrics { + fn default() -> Self { + // Default ~thumb mediano estilo gThumb. + Self { + tile_w: 128.0, + tile_h: 148.0, // 128 imagen + ~20 label + gap: 8.0, + pad: 8.0, + } + } +} + +/// Resultado del cálculo de virtualización: qué celdas montar. `first` y +/// `count` delimitan el rango de índices `[first, first + count)` que el +/// caller debe renderear; `cols` cuántas columnas caben (para `grid_view`). +/// Los demás campos son informativos (scrollbars, "fila X de Y", clamping). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VisibleWindow { + /// Columnas que caben en el ancho del viewport (≥ 1). + pub cols: usize, + /// Índice del primer item a renderear. + pub first: usize, + /// Cantidad de items a renderear desde `first`. + pub count: usize, + /// Fila (0-based) del primer item visible — ya clampeada al rango. + pub first_row: usize, + /// Total de filas que ocupa la colección completa. + pub total_rows: usize, + /// Filas que entran en el alto del viewport (incluye 1 de margen). + pub filas_visibles: usize, +} + +/// Calcula la ventana visible de una grilla virtualizada. **Función pura.** +/// +/// - `total`: cantidad total de items. +/// - `viewport_w` / `viewport_h`: dimensiones del área de la grilla en px. +/// - `scroll_fila`: primera fila que el caller quiere ver arriba (se +/// clampa al rango válido; el caller no necesita pre-clampar). +/// - `m`: geometría (tile + gap + pad). +/// +/// El número de columnas se deriva del ancho: `cols = ⌊(ancho_útil + gap) +/// / (tile_w + gap)⌋`, mínimo 1. Las filas visibles incluyen una extra de +/// margen para que una fila parcial al borde no aparezca en blanco al +/// scrollear. +pub fn ventana_visible( + total: usize, + viewport_w: f32, + viewport_h: f32, + scroll_fila: usize, + m: &GridMetrics, +) -> VisibleWindow { + let paso_w = (m.tile_w + m.gap).max(1.0); + let paso_h = (m.tile_h + m.gap).max(1.0); + + let util_w = (viewport_w - 2.0 * m.pad + m.gap).max(0.0); + let cols = ((util_w / paso_w).floor() as usize).max(1); + + let total_rows = total.div_ceil(cols); + + let util_h = (viewport_h - 2.0 * m.pad + m.gap).max(0.0); + let filas_visibles = (util_h / paso_h).ceil() as usize + 1; + + let max_first_row = total_rows.saturating_sub(1); + let first_row = scroll_fila.min(max_first_row); + let first = first_row * cols; + let last_row = (first_row + filas_visibles).min(total_rows); + let count = (last_row * cols).min(total).saturating_sub(first); + + VisibleWindow { + cols, + first, + count, + first_row, + total_rows, + filas_visibles, + } +} + +/// Paleta de la grilla. Defaults dark con selección azulada (calco de +/// `ListPalette`). +#[derive(Debug, Clone, Copy)] +pub struct GridPalette { + pub bg_panel: Color, + pub bg_cell: Color, + pub bg_selected: Color, + pub fg_text: Color, + pub fg_muted: Color, +} + +impl Default for GridPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl GridPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg_panel: t.bg_panel, + bg_cell: t.bg_panel_alt, + bg_selected: t.bg_selected, + fg_text: t.fg_text, + fg_muted: t.fg_muted, + } + } +} + +/// Una celda de la grilla. `content` es el `View` que el caller arma para +/// el cuerpo (thumb/placeholder/ícono); el widget lo centra y, debajo, +/// pinta `label` truncable si está. `on_click` se emite al clickear la +/// celda completa. +pub struct GridCell { + pub content: View, + pub label: Option, + pub selected: bool, + pub on_click: Msg, +} + +/// Especificación completa de la grilla a renderear. `cells` ya viene +/// recortada a la ventana visible (ver [`ventana_visible`]); `cols` es el +/// número de columnas de esa ventana. +pub struct GridSpec { + pub cells: Vec>, + pub cols: usize, + pub metrics: GridMetrics, + pub caption: Option, + pub truncated_hint: Option, + pub palette: GridPalette, +} + +/// Compone la grilla como un `View`. Agrupa `cells` en filas de +/// `cols` celdas y las apila. El contenedor recorta (`clip`) para que las +/// celdas no sangren a vecinos cuando el caller subestima el área. +pub fn grid_view(spec: GridSpec) -> View { + let GridSpec { + cells, + cols, + metrics, + caption, + truncated_hint, + palette, + } = spec; + let cols = cols.max(1); + + let mut children: Vec> = Vec::new(); + + if let Some(text) = caption { + children.push(barra_texto(text, 11.0, palette.fg_muted, 20.0)); + } + + // Agrupar en filas de `cols`. La última fila puede quedar incompleta. + let mut iter = cells.into_iter(); + loop { + let fila: Vec> = iter.by_ref().take(cols).collect(); + if fila.is_empty() { + break; + } + children.push(fila_view(fila, &metrics, &palette)); + } + + if let Some(text) = truncated_hint { + children.push(barra_texto(text, 10.0, palette.fg_muted, 16.0)); + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(metrics.pad), + right: length(metrics.pad), + top: length(metrics.pad), + bottom: length(metrics.pad), + }, + gap: Size { + width: length(0.0_f32), + height: length(metrics.gap), + }, + ..Default::default() + }) + .fill(palette.bg_panel) + .clip(true) + .children(children) +} + +fn fila_view( + fila: Vec>, + m: &GridMetrics, + palette: &GridPalette, +) -> View { + let celdas: Vec> = fila + .into_iter() + .map(|c| celda_view(c, m, palette)) + .collect(); + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(m.tile_h), + }, + gap: Size { + width: length(m.gap), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(celdas) +} + +fn celda_view( + cell: GridCell, + m: &GridMetrics, + palette: &GridPalette, +) -> View { + let bg = if cell.selected { + palette.bg_selected + } else { + palette.bg_cell + }; + + let mut hijos: Vec> = Vec::with_capacity(2); + // Cuerpo de la celda: el content del caller, centrado, ocupa el alto + // restante (tile_h menos la franja de label). + hijos.push( + View::new(Style { + flex_grow: 1.0, + size: Size { + width: percent(1.0_f32), + height: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .clip(true) + .children(vec![cell.content]), + ); + if let Some(label) = cell.label { + hijos.push( + 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(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .clip(true) + .text_aligned(label, 10.0, palette.fg_text, Alignment::Center), + ); + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: length(m.tile_w), + height: length(m.tile_h), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(bg) + .clip(true) + .children(hijos) + .on_click(cell.on_click) +} + +fn barra_texto( + text: String, + size: f32, + color: Color, + height: f32, +) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(height), + }, + padding: Rect { + left: length(4.0_f32), + right: length(4.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(text, size, color, Alignment::Start) +} + +#[cfg(test)] +mod pruebas { + use super::*; + + fn metrics() -> GridMetrics { + GridMetrics { + tile_w: 80.0, + tile_h: 96.0, + gap: 8.0, + pad: 8.0, + } + } + + #[test] + fn cols_se_deriva_del_ancho() { + let m = metrics(); + // util_w = 400 - 16 + 8 = 392; paso = 88; 392/88 = 4.45 → 4. + let v = ventana_visible(100, 400.0, 300.0, 0, &m); + assert_eq!(v.cols, 4); + assert_eq!(v.total_rows, 25); + } + + #[test] + fn ancho_minimo_da_al_menos_una_columna() { + let m = metrics(); + let v = ventana_visible(10, 50.0, 300.0, 0, &m); + assert_eq!(v.cols, 1, "nunca menos de 1 columna"); + } + + #[test] + fn ventana_arriba_monta_filas_visibles_mas_margen() { + let m = metrics(); + // util_h = 300 - 16 + 8 = 292; paso_h = 104; ceil(292/104)=3; +1 = 4. + let v = ventana_visible(100, 400.0, 300.0, 0, &m); + assert_eq!(v.filas_visibles, 4); + assert_eq!(v.first, 0); + // 4 filas × 4 cols = 16 items. + assert_eq!(v.count, 16); + } + + #[test] + fn ventana_al_fondo_clampa_y_recorta_la_cola() { + let m = metrics(); + // 100 items, 4 cols → 25 filas. Pedir fila 22 (cerca del fondo). + let v = ventana_visible(100, 400.0, 300.0, 22, &m); + assert_eq!(v.first_row, 22); + assert_eq!(v.first, 88); + // last_row = min(22+4, 25) = 25 → count = min(100,100) - 88 = 12. + assert_eq!(v.count, 12); + } + + #[test] + fn scroll_mas_alla_del_fondo_se_clampa() { + let m = metrics(); + let v = ventana_visible(100, 400.0, 300.0, 999, &m); + // total_rows 25 → max_first_row 24. + assert_eq!(v.first_row, 24); + assert_eq!(v.first, 96); + // Sólo la última fila: 100 - 96 = 4 items. + assert_eq!(v.count, 4); + } + + #[test] + fn coleccion_vacia_no_monta_nada() { + let m = metrics(); + let v = ventana_visible(0, 400.0, 300.0, 0, &m); + assert!(v.cols >= 1); + assert_eq!(v.total_rows, 0); + assert_eq!(v.count, 0); + assert_eq!(v.first, 0); + } + + #[test] + fn ultima_fila_parcial_cuenta_completa_en_total_rows() { + let m = metrics(); + // 10 items, 4 cols → 3 filas (la última con 2). + let v = ventana_visible(10, 400.0, 1000.0, 0, &m); + assert_eq!(v.cols, 4); + assert_eq!(v.total_rows, 3); + // Viewport alto: entran todas. + assert_eq!(v.count, 10); + } + + #[test] + fn grid_view_agrupa_en_filas_sin_panicar() { + // Smoke: 7 celdas en 3 columnas → 3 filas (3+3+1). Sólo verifica + // que compone sin panicar y devuelve un View. + let cells: Vec> = (0..7) + .map(|i| GridCell { + content: View::new(Style::default()), + label: Some(format!("img{i}")), + selected: i == 2, + on_click: i, + }) + .collect(); + let _v = grid_view(GridSpec { + cells, + cols: 3, + metrics: metrics(), + caption: Some("7 imágenes".into()), + truncated_hint: None, + palette: GridPalette::default(), + }); + } +} diff --git a/widgets/list/Cargo.toml b/widgets/list/Cargo.toml new file mode 100644 index 0000000..4b8d546 --- /dev/null +++ b/widgets/list/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-list" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-list — lista vertical virtualizada para Llimphi: filas clicables, selección, caption opcional, recorte de overflow. El caller hace la virtualización (pasa sólo las filas visibles) y el widget las compone." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/list/LEEME.md b/widgets/list/LEEME.md new file mode 100644 index 0000000..053c036 --- /dev/null +++ b/widgets/list/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-list + +> Lista virtualizada para [llimphi](../../README.md). + +Renderiza sólo los items visibles. Selección single/multi, scroll programático, keyboard nav. diff --git a/widgets/list/README.md b/widgets/list/README.md new file mode 100644 index 0000000..1345bfb --- /dev/null +++ b/widgets/list/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-list + +> Virtualized list for [llimphi](../../README.md). + +Renders only visible items. Single/multi selection, programmatic scroll, keyboard nav. diff --git a/widgets/list/src/lib.rs b/widgets/list/src/lib.rs new file mode 100644 index 0000000..a5de315 --- /dev/null +++ b/widgets/list/src/lib.rs @@ -0,0 +1,201 @@ +//! `llimphi-widget-list` — lista vertical virtualizada. +//! +//! Compone una pila de filas con foco visual en la seleccionada y un Msg +//! por click. Pensado como bloque reusable para file explorers, árboles +//! lineales, paneles de log, listados de items, etc. +//! +//! El widget **no** maneja virtualización por sí mismo: el caller pasa +//! únicamente las filas que deberían renderearse (las visibles según su +//! propio `offset`/`scroll`). El widget se ocupa del resto: caption +//! opcional con el conteo, fondo de selección, hint "… y N más" cuando +//! `total > rows.len()`, y `clip` en el contenedor para que las filas no +//! sangren a vecinos. +//! +//! Ejemplo: +//! +//! ```ignore +//! let rows: Vec> = entries[offset..(offset + visible).min(entries.len())] +//! .iter() +//! .enumerate() +//! .map(|(i, e)| ListRow { +//! label: e.name.clone(), +//! selected: offset + i == selected, +//! on_click: Msg::Select(offset + i), +//! }) +//! .collect(); +//! +//! let panel = list_view(ListSpec { +//! rows, +//! total: entries.len(), +//! caption: Some(format!("{} entradas", entries.len())), +//! truncated_hint: (entries.len() > offset + rows.len()) +//! .then(|| format!("… y {} más", entries.len() - offset - rows.len())), +//! row_height: 22.0, +//! palette: ListPalette::default(), +//! }); +//! ``` + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; + +/// Paleta de la lista. Los defaults son una variante dark con selección +/// azulada — equivalente conceptual a `nahual_theme` en su tema oscuro. +#[derive(Debug, Clone, Copy)] +pub struct ListPalette { + pub bg_panel: Color, + pub bg_selected: Color, + pub fg_text: Color, + pub fg_muted: Color, +} + +impl Default for ListPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl ListPalette { + /// Construye la paleta desde un `Theme` semántico. + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg_panel: t.bg_panel, + bg_selected: t.bg_selected, + fg_text: t.fg_text, + fg_muted: t.fg_muted, + } + } +} + +/// Una fila a renderear. `selected` cambia el fondo; `on_click` se emite al +/// hacer click sobre cualquier parte de la fila. +pub struct ListRow { + pub label: String, + pub selected: bool, + pub on_click: Msg, +} + +/// Especificación completa de la lista a renderear. +pub struct ListSpec { + /// Filas a renderear, ya filtradas a la ventana visible. + pub rows: Vec>, + /// Total de items del modelo (usado para el caption — la lista + /// mostrada puede ser un subconjunto virtualizado). + pub total: usize, + /// Caption opcional arriba de las filas (p. ej. "120 entradas"). + pub caption: Option, + /// Mensaje opcional al pie ("… y 12 más") cuando hay items fuera de + /// la ventana visible. El caller decide qué texto usar. + pub truncated_hint: Option, + /// Altura de cada fila en pixels. + pub row_height: f32, + pub palette: ListPalette, +} + +/// Compone la lista como un `View`. El contenedor tiene `clip = true` +/// para evitar overflow visual cuando el llamador subestima el tamaño +/// disponible — las filas que excedan el área del panel se recortan. +pub fn list_view(spec: ListSpec) -> View { + let ListSpec { + rows, + total: _, + caption, + truncated_hint, + row_height, + palette, + } = spec; + + let mut children: Vec> = Vec::with_capacity(rows.len() + 2); + + if let Some(text) = caption { + children.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(20.0_f32), + }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(text, 10.0, palette.fg_muted, Alignment::Start), + ); + } + + for row in rows { + children.push(row_view(row, row_height, &palette)); + } + + if let Some(text) = truncated_hint { + children.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(16.0_f32), + }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .text_aligned(text, 10.0, palette.fg_muted, Alignment::Start), + ); + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(6.0_f32), + bottom: length(6.0_f32), + }, + ..Default::default() + }) + .fill(palette.bg_panel) + .clip(true) + .children(children) +} + +fn row_view(row: ListRow, height: f32, palette: &ListPalette) -> View { + let bg = if row.selected { + palette.bg_selected + } else { + palette.bg_panel + }; + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(height), + }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(bg) + .text_aligned(row.label, 12.0, palette.fg_text, Alignment::Start) + .on_click(row.on_click) +} diff --git a/widgets/menubar/Cargo.toml b/widgets/menubar/Cargo.toml new file mode 100644 index 0000000..d71f673 --- /dev/null +++ b/widgets/menubar/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "llimphi-widget-menubar" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-menubar — barra de menú principal in-window (Archivo/Editar/Ver/Ayuda) que cualquier app Llimphi monta a partir de un app_bus::AppMenu. menubar_view() pinta la fila de títulos; menubar_overlay() el dropdown (vía context-menu) para App::view_overlay. Decoplado del Surface del launcher: sirve dentro de la ventana de cada app." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-button = { workspace = true } +llimphi-widget-context-menu = { workspace = true } +app-bus = { path = "../../../../shared/app-bus" } diff --git a/widgets/menubar/src/lib.rs b/widgets/menubar/src/lib.rs new file mode 100644 index 0000000..abd178d --- /dev/null +++ b/widgets/menubar/src/lib.rs @@ -0,0 +1,334 @@ +//! `llimphi-widget-menubar` — la barra de menú principal de una app. +//! +//! Toda app Llimphi declara un [`app_bus::AppMenu`] (Archivo / Editar / +//! Ver / Ayuda …) y lo monta in-window con este widget. Es el gemelo de +//! la barra global de [`launcher_llimphi`], pero vive **dentro** de la +//! ventana de la app — para las apps que corren standalone y no bajo el +//! shell del launcher. +//! +//! Sin estado, al estilo Llimphi: el `Model` del host lleva qué menú raíz +//! está abierto (`Option`); el widget aplana el `AppMenu` y emite +//! `Msg` en cada interacción. +//! +//! Dos entradas: +//! - [`menubar_view`] → la fila de títulos, para el tope de `App::view`. +//! - [`menubar_overlay`] → el dropdown del menú abierto, para +//! `App::view_overlay` (devolvé `None` si no hay nada abierto). +//! +//! El `command` de cada ítem es el id que la app entiende (convención +//! `menu.`, ver [`app_bus::AppMenu::standard`]); el widget lo +//! rebota por `on_command`. + +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use app_bus::{AppMenu, Menu}; +use llimphi_theme::Theme; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, percent, AlignItems, FlexDirection, JustifyContent, Position, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_widget_button::{button_styled, ButtonPalette}; +use llimphi_widget_context_menu::{ + context_menu_view_ex, step_active, ContextMenuExtras, ContextMenuItem, ContextMenuPalette, + ContextMenuSpec, +}; + +type MsgFromMenu = Arc) -> Msg + Send + Sync>; +type MsgFromStr = Arc Msg + Send + Sync>; + +/// Todo lo que el render necesita. El host lo arma en cada `view()`. +pub struct MenuBarSpec<'a, Msg: Clone + 'static> { + /// El menú a pintar (típicamente `AppMenu::standard()` + menús propios). + pub menu: &'a AppMenu, + /// Índice del menú raíz abierto (estado del host). `None` = ninguno. + pub open: Option, + pub theme: &'a Theme, + /// Tamaño de la ventana — para clampear el dropdown. + pub viewport: (f32, f32), + /// Alto de la barra (px). Usar [`DEFAULT_HEIGHT`] si no hay razón. + pub height: f32, + /// Abrir/cerrar un menú raíz por índice (`None` = cerrar). + pub on_open: MsgFromMenu, + /// command id → Msg, al elegir un ítem. + pub on_command: MsgFromStr, +} + +/// Alto recomendado de la barra de menú. +pub const DEFAULT_HEIGHT: f32 = 30.0; + +fn title_palette(theme: &Theme) -> ButtonPalette { + ButtonPalette::from_theme(theme) +} + +fn title_palette_active(theme: &Theme) -> ButtonPalette { + let base = ButtonPalette::from_theme(theme); + ButtonPalette { + bg: theme.accent, + bg_hover: theme.accent, + fg: theme.bg_panel, + radius: base.radius, + } +} + +/// La fila de títulos (Archivo / Editar / …). Click sobre un título +/// togglea su dropdown vía `on_open`. El abierto se resalta con el accent. +/// `hover_switch = true` agrega `on_pointer_enter` a cada título para que, +/// con un menú ya abierto, pasar el mouse sobre otro título cambie de menú +/// (comportamiento clásico de barra de menú) — sólo se usa en el overlay, +/// donde los títulos quedan por encima del scrim y son hovereables. +fn titles_row(spec: &MenuBarSpec, hover_switch: bool) -> View { + let pal = title_palette(spec.theme); + let pal_on = title_palette_active(spec.theme); + + let mut titles: Vec> = Vec::with_capacity(spec.menu.menus.len()); + for (i, root) in spec.menu.menus.iter().enumerate() { + let open = spec.open == Some(i); + let target = if open { None } else { Some(i) }; + let mut title = button_styled( + root.label.clone(), + title_style(), + Alignment::Center, + if open { &pal_on } else { &pal }, + (spec.on_open)(target), + ); + // Con un menú abierto, hover sobre otro título lo abre. + if hover_switch && !open { + title = title.on_pointer_enter((spec.on_open)(Some(i))); + } + titles.push(title); + } + + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(spec.height), + }, + flex_shrink: 0.0, + flex_direction: FlexDirection::Row, + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(6.0_f32), + right: length(6.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + gap: Size { + width: length(2.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .fill(spec.theme.bg_panel_alt) + .children(titles) +} + +/// La barra de menú principal — primer hijo del column raíz de `view()`. +pub fn menubar_view(spec: &MenuBarSpec) -> View { + titles_row(spec, false) +} + +/// Aplana un menú raíz al par alineado `(items, commands)` que consume el +/// context-menu (los separadores `separator_before` se insertan como +/// filas y llevan `command = None`). Es la única fuente de verdad del +/// orden de filas — la navegación por teclado y el render comparten esto. +fn dropdown_items(root: &Menu) -> (Vec, Vec>) { + let mut items: Vec = Vec::new(); + let mut commands: Vec> = Vec::new(); + for (k, src) in root.items.iter().enumerate() { + if src.separator_before && k != 0 { + items.push(ContextMenuItem::separator()); + commands.push(None); + } + let mut cm = ContextMenuItem::action(src.label.clone()); + if let Some(s) = &src.shortcut { + cm = cm.with_shortcut(s.clone()); + } + if let Some(ic) = &src.icon { + cm = cm.icon(ic.clone()); + } + if !src.enabled { + cm = cm.disabled(); + } + items.push(cm); + commands.push(Some(src.command.clone())); + } + (items, commands) +} + +/// El dropdown del menú abierto, para `App::view_overlay`. `None` si no +/// hay menú abierto. Hospeda además una copia de la fila de títulos por +/// encima del scrim: así, con el menú abierto, mover el mouse a otro +/// título cambia de menú (hover-switch). +pub fn menubar_overlay(spec: &MenuBarSpec) -> Option> { + menubar_overlay_core(spec, usize::MAX, 1.0) +} + +/// Como [`menubar_overlay`] pero con `active` (fila resaltada por teclado; +/// `usize::MAX` = ninguna) y `appear` (0..1, animación de aparición — útil +/// para que el dropdown se deslice/funda al cambiar de menú por hover o +/// flechas). La app guarda el `active` y un `Tween` para el `appear`. +pub fn menubar_overlay_animated( + spec: &MenuBarSpec, + active: usize, + appear: f32, +) -> Option> { + menubar_overlay_core(spec, active, appear) +} + +fn menubar_overlay_core( + spec: &MenuBarSpec, + active: usize, + appear: f32, +) -> Option> { + let idx = spec.open?; + let root = spec.menu.menus.get(idx)?; + + let mut x = 6.0_f32; + for prev in spec.menu.menus.iter().take(idx) { + x += approx_title_width(&prev.label); + } + + let (items, commands) = dropdown_items(root); + + let on_command = spec.on_command.clone(); + let on_open = spec.on_open.clone(); + let commands = Arc::new(commands); + let on_pick: Arc Msg + Send + Sync> = Arc::new(move |i: usize| { + match commands.get(i).and_then(|c| c.clone()) { + Some(cmd) => (on_command)(&cmd), + None => (on_open)(None), + } + }); + + let dropdown = context_menu_view_ex( + ContextMenuSpec { + anchor: (x, spec.height), + viewport: spec.viewport, + header: Some(root.label.clone()), + items, + active, + on_pick, + on_dismiss: (spec.on_open)(None), + palette: ContextMenuPalette::from_theme(spec.theme), + }, + ContextMenuExtras { + appear, + ..ContextMenuExtras::default() + }, + ); + + // Fila de títulos por encima del scrim del dropdown: queda hovereable + // para cambiar de menú con el mouse. Absoluta al tope para no consumir + // el layout; se pinta después del dropdown ⇒ arriba en z-order ⇒ gana + // el hit-test. + let titles = View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0_f32), + top: length(0.0_f32), + right: auto(), + bottom: auto(), + }, + size: Size { + width: percent(1.0_f32), + height: length(spec.height), + }, + ..Default::default() + }) + .children(vec![titles_row(spec, true)]); + + Some( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .children(vec![dropdown, titles]), + ) +} + +/// Navegación por teclado dentro del dropdown del menú `menu_idx`: dado el +/// `active` actual y la dirección (`+1` baja, `-1` sube), devuelve el +/// próximo índice de fila válido (saltea separadores y deshabilitados). +/// `usize::MAX` si no hay menú abierto o sin filas elegibles. +pub fn menubar_nav(menu: &AppMenu, menu_idx: usize, active: usize, dir: i32) -> usize { + let Some(root) = menu.menus.get(menu_idx) else { + return usize::MAX; + }; + let (items, _) = dropdown_items(root); + step_active(&items, active, dir) +} + +/// El `command` de la fila `active` del menú `menu_idx` (para ejecutar con +/// Enter). `None` si el índice no es una fila-acción. +pub fn menubar_command_at(menu: &AppMenu, menu_idx: usize, active: usize) -> Option { + let root = menu.menus.get(menu_idx)?; + let (_, commands) = dropdown_items(root); + commands.get(active).cloned().flatten() +} + +fn title_style() -> Style { + Style { + size: Size { + width: llimphi_ui::llimphi_layout::taffy::prelude::auto(), + height: length(24.0_f32), + }, + flex_shrink: 0.0, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + } +} + +/// Ancho aproximado de un título — mismo criterio que `launcher-llimphi` +/// para anclar el dropdown sin medir la fuente. +fn approx_title_width(label: &str) -> f32 { + label.chars().count() as f32 * 8.0 + 22.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn overlay_none_si_no_hay_abierto() { + let menu = AppMenu::standard(); + let spec = MenuBarSpec { + menu: &menu, + open: None, + theme: &Theme::dark(), + viewport: (800.0, 600.0), + height: DEFAULT_HEIGHT, + on_open: Arc::new(|_| 0u8), + on_command: Arc::new(|_| 1u8), + }; + assert!(menubar_overlay(&spec).is_none()); + } + + #[test] + fn overlay_some_si_hay_abierto() { + let menu = AppMenu::standard(); + let spec = MenuBarSpec { + menu: &menu, + open: Some(0), + theme: &Theme::dark(), + viewport: (800.0, 600.0), + height: DEFAULT_HEIGHT, + on_open: Arc::new(|_| 0u8), + on_command: Arc::new(|_| 1u8), + }; + assert!(menubar_overlay(&spec).is_some()); + } +} diff --git a/widgets/modal/Cargo.toml b/widgets/modal/Cargo.toml new file mode 100644 index 0000000..3251003 --- /dev/null +++ b/widgets/modal/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-modal" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-modal — diálogo genérico (título + body arbitrario + botones primary/cancel/destructive) con scrim y centrado. Para menús contextuales usar llimphi-widget-context-menu." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-panel = { workspace = true } diff --git a/widgets/modal/src/lib.rs b/widgets/modal/src/lib.rs new file mode 100644 index 0000000..79bc03d --- /dev/null +++ b/widgets/modal/src/lib.rs @@ -0,0 +1,319 @@ +//! `llimphi-widget-modal` — diálogo genérico centrado con scrim. +//! +//! Distinto del `context-menu` (chico, anclado a un click): el modal +//! ocupa una región central de tamaño configurable, presenta un título, +//! un cuerpo arbitrario (lo arma la app) y una barra de botones. +//! +//! Uso típico: +//! 1. La app guarda `Option` en su modelo. +//! 2. `view_overlay` devuelve `Some(modal_view(spec))` cuando hay +//! state, `None` cuando se cerró. +//! 3. La app captura `Esc` en `on_key` → cierra; `Enter` → primary. +//! +//! Tres severidades de botón: +//! - `Primary` — verde/accent, acción principal. +//! - `Cancel` — neutral, descarta. +//! - `Destructive` — rojo, acción irreversible (eliminar, etc). + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, percent, FlexDirection, Position, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_theme::{alpha, radius, Theme}; +use llimphi_widget_panel::{panel_signature_painter, PanelStyle}; + +/// Paleta del modal. +#[derive(Debug, Clone, Copy)] +pub struct ModalPalette { + /// Color del scrim. El alpha se usa como **promedio** del vignette + /// radial: el centro (debajo del panel) queda ~25% más claro y las + /// esquinas ~40% más oscuras, manteniendo la densidad media igual a + /// lo que pidió el caller. Esto focaliza al modal sin "encerrarlo". + pub scrim: Color, + /// Firma visual del panel — gradient sutil + hairline accent en el + /// top edge. La que vuelve consistente el "look gioser" en todos + /// los modales y overlays. + pub panel: PanelStyle, + pub border: Color, + pub fg_title: Color, + pub fg_text: Color, + pub bg_btn: Color, + pub bg_btn_hover: Color, + pub fg_btn: Color, + pub bg_primary: Color, + pub fg_primary: Color, + pub bg_destructive: Color, + pub fg_destructive: Color, +} + +impl ModalPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + scrim: Color::from_rgba8(0, 0, 0, alpha::SCRIM), + panel: PanelStyle::from_theme_large(t), + border: t.border, + fg_title: t.fg_text, + fg_text: t.fg_muted, + bg_btn: t.bg_button, + bg_btn_hover: t.bg_button_hover, + fg_btn: t.fg_text, + bg_primary: t.accent, + fg_primary: t.bg_app, + bg_destructive: t.fg_destructive, + fg_destructive: t.bg_app, + } + } +} + +/// Severidad de un botón del modal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ButtonKind { + Primary, + Cancel, + Destructive, +} + +/// Spec de un botón. `label` se renderiza; `msg` se dispatcha al click. +#[derive(Clone)] +pub struct ModalButton { + pub label: String, + pub kind: ButtonKind, + pub msg: Msg, +} + +impl ModalButton { + pub fn primary(label: impl Into, msg: Msg) -> Self { + Self { label: label.into(), kind: ButtonKind::Primary, msg } + } + pub fn cancel(label: impl Into, msg: Msg) -> Self { + Self { label: label.into(), kind: ButtonKind::Cancel, msg } + } + pub fn destructive(label: impl Into, msg: Msg) -> Self { + Self { label: label.into(), kind: ButtonKind::Destructive, msg } + } +} + +/// Spec completo del modal. +pub struct ModalSpec { + pub title: String, + /// Cuerpo libre — la app construye un `View` con lo que quiera + /// mostrar (texto, form, lista). Se pinta entre título y botones. + pub body: View, + pub buttons: Vec>, + /// Tamaño del panel (clampea al viewport con margen). + pub size: (f32, f32), + pub viewport: (f32, f32), + /// Msg al hacer click en el scrim o presionar Esc (la app maneja + /// Esc en su `on_key`; este Msg es el del click). + pub on_dismiss: Msg, + pub palette: ModalPalette, +} + +const TITLE_H: f32 = 40.0; +const BUTTONS_H: f32 = 56.0; +const TITLE_FONT: f32 = 14.0; +const BTN_FONT: f32 = 12.5; +const PAD: f32 = 16.0; + +pub fn modal_view(spec: ModalSpec) -> View { + let ModalSpec { + title, + body, + buttons, + size, + viewport, + on_dismiss, + palette, + } = spec; + + let (w, h) = ( + size.0.min(viewport.0 - 32.0).max(200.0), + size.1.min(viewport.1 - 32.0).max(140.0), + ); + let x = ((viewport.0 - w) * 0.5).max(0.0); + let y = ((viewport.1 - h) * 0.5).max(0.0); + + // Header — título a la izquierda; al borde inferior, una línea + // separadora se logra con un nodo de 1px. + let header = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(TITLE_H), + }, + padding: Rect { + left: length(PAD), + right: length(PAD), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(title, TITLE_FONT, palette.fg_title, Alignment::Start); + + let separator = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(1.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.border); + + // Body — flex_grow para ocupar todo el espacio sobrante. + let body_wrap = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: auto(), + }, + flex_grow: 1.0, + padding: Rect { + left: length(PAD), + right: length(PAD), + top: length(PAD), + bottom: length(PAD), + }, + ..Default::default() + }) + .children(vec![body]); + + // Botones — flex-row justify-end con gap. + let btn_children: Vec> = buttons + .into_iter() + .map(|b| button_view(b, &palette)) + .collect(); + let buttons_row = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(BUTTONS_H), + }, + flex_direction: FlexDirection::Row, + justify_content: Some(JustifyContent::FlexEnd), + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(PAD), + right: length(PAD), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + gap: Size { + width: length(8.0_f32), + height: length(0.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(btn_children); + + let panel = 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), + }, + flex_direction: FlexDirection::Column, + ..Default::default() + }) + .paint_with(panel_signature_painter(palette.panel)) + .radius(palette.panel.radius) + .clip(true) + .children(vec![header, separator, body_wrap, buttons_row]); + + let scrim_base = palette.scrim; + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| { + use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, Rect as KurboRect}; + use llimphi_ui::llimphi_raster::peniko::{color::AlphaColor, Fill, Gradient}; + + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + // Vignette: el centro toma alpha = base * 0.75 (más translúcido, + // deja ver lo que hay detrás del modal); las esquinas alpha = + // base * 1.4 (más sólido, oscurece los bordes). El promedio + // visual queda cerca de `base` original, así la densidad pedida + // por el caller se preserva. + let [r, g, b, base_a] = scrim_base.components; + let inner: Color = + AlphaColor::new([r, g, b, (base_a * 0.75).clamp(0.0, 1.0)]); + let outer: Color = + AlphaColor::new([r, g, b, (base_a * 1.4).clamp(0.0, 1.0)]); + + let cx = rect.x as f64 + rect.w as f64 * 0.5; + let cy = rect.y as f64 + rect.h as f64 * 0.5; + let diag_half = (((rect.w as f64).powi(2) + (rect.h as f64).powi(2)).sqrt() * 0.5) as f32; + let gradient = Gradient::new_radial(Point::new(cx, cy), diag_half) + .with_stops([inner, outer].as_slice()); + let full = KurboRect::new( + rect.x as f64, + rect.y as f64, + (rect.x + rect.w) as f64, + (rect.y + rect.h) as f64, + ); + scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &full); + }) + .on_click(on_dismiss) + .children(vec![panel]) +} + +fn button_view(btn: ModalButton, palette: &ModalPalette) -> View { + let (bg, fg, hover) = match btn.kind { + ButtonKind::Primary => (palette.bg_primary, palette.fg_primary, brighten(palette.bg_primary, 0.15)), + ButtonKind::Cancel => (palette.bg_btn, palette.fg_btn, palette.bg_btn_hover), + ButtonKind::Destructive => (palette.bg_destructive, palette.fg_destructive, brighten(palette.bg_destructive, 0.15)), + }; + let label = btn.label.clone(); + View::new(Style { + size: Size { + width: length(label.chars().count() as f32 * 7.5 + 28.0), + height: length(32.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + padding: Rect { + left: length(12.0_f32), + right: length(12.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(bg) + .hover_fill(hover) + .radius(radius::SM) + .text_aligned(label, BTN_FONT, fg, Alignment::Center) + .on_click(btn.msg) +} + +/// Aclara un color sumando `delta` a cada componente RGB. Útil para +/// derivar un hover state del color base sin tener que definirlo aparte. +fn brighten(c: Color, delta: f32) -> Color { + let [r, g, b, a] = c.components; + use llimphi_ui::llimphi_raster::peniko::color::AlphaColor; + AlphaColor::new([ + (r + delta).clamp(0.0, 1.0), + (g + delta).clamp(0.0, 1.0), + (b + delta).clamp(0.0, 1.0), + a, + ]) +} diff --git a/widgets/navigator/Cargo.toml b/widgets/navigator/Cargo.toml new file mode 100644 index 0000000..954ee0f --- /dev/null +++ b/widgets/navigator/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "llimphi-widget-navigator" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-navigator — navegador data-agnóstico de nodos (Mónada/Dir/Archivo) en dos modos conmutables: árbol y grafo; click selecciona, right-click abre." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-tree = { workspace = true } +llimphi-widget-nodegraph = { workspace = true } + +[dev-dependencies] +llimphi-widget-segmented = { workspace = true } diff --git a/widgets/navigator/examples/navigator_demo.rs b/widgets/navigator/examples/navigator_demo.rs new file mode 100644 index 0000000..a5fb121 --- /dev/null +++ b/widgets/navigator/examples/navigator_demo.rs @@ -0,0 +1,222 @@ +//! Showcase de `llimphi-widget-navigator`: un bosque de "Mónadas" con sus +//! archivos, conmutable entre **árbol** y **grafo** con un control +//! segmentado. Click selecciona; click en el chevron expande/colapsa; +//! right-click "abre" (acá sólo registra el id en el header). +//! +//! Corré con: +//! `cargo run -p llimphi-widget-navigator --example navigator_demo --release`. + +use std::collections::HashSet; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{App, Handle, View}; +use llimphi_theme::Theme; +use llimphi_widget_navigator::{ + navigator_view, NavId, NavKind, NavMode, NavNode, NavPalette, NavSpec, +}; +use llimphi_widget_segmented::{segmented_view, SegmentedPalette}; + +#[derive(Clone)] +enum Msg { + Toggle(NavId), + Select(NavId), + Open(NavId), + SetMode(usize), +} + +struct Model { + expanded: HashSet, + selected: Option, + mode: NavMode, + last_open: Option, +} + +struct Showcase; + +/// Bosque de demo: tres Mónadas (clusters de nouser), cada una con sus +/// archivos miembros. +fn forest() -> Vec { + vec![ + NavNode::branch( + 1, + "src · código rust", + NavKind::Monad, + vec![ + NavNode::leaf(11, "lib.rs", NavKind::File), + NavNode::leaf(12, "config.rs", NavKind::File), + NavNode::branch( + 13, + "widgets/", + NavKind::Dir, + vec![ + NavNode::leaf(131, "tree.rs", NavKind::File), + NavNode::leaf(132, "navigator.rs", NavKind::File), + ], + ), + ], + ), + NavNode::branch( + 2, + "docs · markdown", + NavKind::Monad, + vec![ + NavNode::leaf(21, "README.md", NavKind::File), + NavNode::leaf(22, "SDD.md", NavKind::File), + ], + ), + NavNode::branch( + 3, + "assets · imágenes", + NavKind::Monad, + vec![ + NavNode::leaf(31, "logo.png", NavKind::File), + NavNode::leaf(32, "icon.svg", NavKind::File), + ], + ), + ] +} + +impl App for Showcase { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · navigator showcase" + } + + fn initial_size() -> (u32, u32) { + (520, 680) + } + + fn init(_: &Handle) -> Model { + let mut expanded = HashSet::new(); + expanded.insert(1); + expanded.insert(13); + Model { + expanded, + selected: None, + mode: NavMode::Tree, + last_open: None, + } + } + + fn update(model: Model, msg: Msg, _: &Handle) -> Model { + let mut m = model; + match msg { + Msg::Toggle(id) => { + if !m.expanded.remove(&id) { + m.expanded.insert(id); + } + } + Msg::Select(id) => m.selected = Some(id), + Msg::Open(id) => m.last_open = Some(id), + Msg::SetMode(i) => m.mode = NavMode::from_index(i), + } + m + } + + fn view(model: &Model) -> View { + let theme = Theme::dark(); + let palette = NavPalette::from_theme(&theme); + + // Toggle de modo. + let toggle = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(36.0_f32), + }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(6.0_f32), + bottom: length(2.0_f32), + }, + ..Default::default() + }) + .children(vec![segmented_view( + &NavMode::LABELS, + model.mode.index(), + Msg::SetMode, + &SegmentedPalette::from_theme(&theme), + )]); + + let roots = forest(); + let nav = navigator_view( + NavSpec { + roots: &roots, + mode: model.mode, + selected: model.selected, + palette, + guides: true, + }, + { + let expanded = model.expanded.clone(); + move |id| expanded.contains(&id) + }, + Msg::Toggle, + Msg::Select, + Some(Msg::Open), + ); + + let nav_pane = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + ..Default::default() + }) + .children(vec![nav]); + + let status = format!( + "modo: {} · sel: {} · abrir (right-click): {}", + match model.mode { + NavMode::Tree => "árbol", + NavMode::Graph => "grafo", + }, + model + .selected + .map(|i| i.to_string()) + .unwrap_or_else(|| "—".into()), + model + .last_open + .map(|i| i.to_string()) + .unwrap_or_else(|| "—".into()), + ); + let footer = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(26.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_alt) + .text_aligned(status, 12.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), + }, + ..Default::default() + }) + .fill(theme.bg_app) + .children(vec![toggle, nav_pane, footer]) + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/navigator/src/lib.rs b/widgets/navigator/src/lib.rs new file mode 100644 index 0000000..bec3718 --- /dev/null +++ b/widgets/navigator/src/lib.rs @@ -0,0 +1,626 @@ +//! `llimphi-widget-navigator` — navegador **data-agnóstico** de nodos en +//! dos modos conmutables: **árbol** (`tree`) y **grafo** (`nodegraph`). +//! +//! Nació para que `pata` muestre las **Mónadas** de nouser y sus archivos +//! en un sidebar, pero el widget no sabe de nouser: recibe un bosque de +//! [`NavNode`]s (id opaco + label + [`NavKind`] + hijos) y emite `Msg`s al +//! interactuar. El caller mapea cada `id` a lo suyo (un `MonadId`, un path) +//! y decide qué hacer al seleccionar/abrir. +//! +//! Igual que el resto de widgets Llimphi, es **render-only y stateless**: +//! el estado (qué nodos están expandidos, cuál está seleccionado, en qué +//! modo está) vive en el `Model` del App; el widget sólo pinta y avisa. +//! +//! - **Árbol** — reusa [`llimphi_widget_tree`]. El navegador aplana el +//! bosque respetando `is_expanded`, dibuja un icono por [`NavKind`] entre +//! el chevron y el label, y cablea toggle / select / context por fila. +//! - **Grafo** — reusa [`llimphi_widget_nodegraph`]. Coloca los nodos +//! visibles en columnas por profundidad, con cables de **contención** +//! (padre→hijo). El nodo seleccionado se resalta; arrastrar un nodo lo +//! selecciona; el right-click abre el menú contextual. +//! +//! ```ignore +//! navigator_view( +//! NavSpec { roots: &model.nodes, mode: model.mode, +//! selected: model.selected, palette, guides: true }, +//! |id| model.expanded.contains(&id), +//! Msg::Toggle, Msg::Select, Some(Msg::Open), +//! ) +//! ``` + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{Size, Style}, + AlignItems, JustifyContent, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::{DragPhase, View}; +use llimphi_theme::Theme; +use llimphi_widget_nodegraph::{ + nodegraph_view_styled, NodeId, NodeSpec, NodeTint, NodegraphMetrics, NodegraphPalette, Wire, +}; +use llimphi_widget_tree::{tree_view, TreePalette, TreeRow, TreeSpec}; + +/// Identificador opaco de un nodo. El caller lo asigna y lo recibe de vuelta +/// sin que el widget lo interprete (típicamente un índice a su propio mapa +/// `id → MonadId | PathBuf`). +pub type NavId = u64; + +/// Naturaleza de un nodo — sólo para elegir su icono y tinte. El widget no +/// asume semántica de dominio más allá de esto. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NavKind { + /// Una Mónada (cluster semántico de nouser). Diamante de acento. + Monad, + /// Una agrupación intermedia (carpeta lógica, categoría). Cuadrado. + Group, + /// Un directorio del filesystem. Cuadrado tenue. + Dir, + /// Un archivo hoja. Punto. + File, + /// Cualquier otra cosa. Punto tenue. + Other, +} + +/// Un nodo del bosque que el navegador pinta. La jerarquía es explícita +/// (`children`); el navegador la aplana según el estado de expansión. +#[derive(Debug, Clone)] +pub struct NavNode { + pub id: NavId, + pub label: String, + pub kind: NavKind, + pub children: Vec, +} + +impl NavNode { + /// Un nodo hoja (sin hijos). + pub fn leaf(id: NavId, label: impl Into, kind: NavKind) -> Self { + Self { + id, + label: label.into(), + kind, + children: Vec::new(), + } + } + + /// Un nodo con hijos. + pub fn branch( + id: NavId, + label: impl Into, + kind: NavKind, + children: Vec, + ) -> Self { + Self { + id, + label: label.into(), + kind, + children, + } + } + + /// `true` si tiene al menos un hijo. + pub fn has_children(&self) -> bool { + !self.children.is_empty() + } +} + +/// Modo de visualización del navegador. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NavMode { + /// Árbol indentado con expand/collapse. + Tree, + /// Grafo de nodos con cables de contención. + Graph, +} + +impl NavMode { + /// Etiquetas para un control segmentado (en el mismo orden que + /// [`NavMode::index`] / [`NavMode::from_index`]). + pub const LABELS: [&'static str; 2] = ["Árbol", "Grafo"]; + + /// El otro modo (para un botón de toggle simple). + pub fn toggled(self) -> Self { + match self { + NavMode::Tree => NavMode::Graph, + NavMode::Graph => NavMode::Tree, + } + } + + /// Índice 0/1 — para alimentar un control segmentado. + pub fn index(self) -> usize { + match self { + NavMode::Tree => 0, + NavMode::Graph => 1, + } + } + + /// Recupera el modo desde un índice de control segmentado (≥1 = grafo). + pub fn from_index(i: usize) -> Self { + if i == 0 { + NavMode::Tree + } else { + NavMode::Graph + } + } +} + +/// Paleta del navegador: hereda las de tree y nodegraph del [`Theme`] +/// semántico, más los tintes por [`NavKind`] para los iconos. +#[derive(Debug, Clone, Copy)] +pub struct NavPalette { + pub tree: TreePalette, + pub graph: NodegraphPalette, + pub accent: Color, + pub monad: Color, + pub group: Color, + pub dir: Color, + pub file: Color, + pub other: Color, +} + +impl Default for NavPalette { + fn default() -> Self { + Self::from_theme(&Theme::dark()) + } +} + +impl NavPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + tree: TreePalette::from_theme(t), + graph: NodegraphPalette::from_theme(t), + accent: t.accent, + monad: t.accent, + group: t.fg_text, + dir: t.fg_muted, + file: t.fg_text, + other: t.fg_muted, + } + } + + /// El color del icono de un nodo según su clase. + pub fn kind_color(&self, kind: NavKind) -> Color { + match kind { + NavKind::Monad => self.monad, + NavKind::Group => self.group, + NavKind::Dir => self.dir, + NavKind::File => self.file, + NavKind::Other => self.other, + } + } +} + +/// Lo que el navegador necesita saber para pintar, sin los callbacks. +pub struct NavSpec<'a> { + /// Las raíces del bosque a mostrar. + pub roots: &'a [NavNode], + /// Modo activo. + pub mode: NavMode, + /// Nodo seleccionado (resaltado en ambos modos). `None` = ninguno. + pub selected: Option, + /// Paleta. + pub palette: NavPalette, + /// Dibujar líneas guía de indentación en el árbol. + pub guides: bool, +} + +/// Alto de fila del árbol / paso vertical del grafo. +const ROW_H: f32 = 24.0; +/// Tamaño del icono de clase (px). +const ICON_PX: f32 = 14.0; + +/// Compone el navegador. Los callbacks se identifican por [`NavId`]: +/// - `is_expanded(id)` → si un nodo rama está abierto (sólo árbol); +/// - `on_toggle(id)` → al click en el chevron (sólo árbol); +/// - `on_select(id)` → al click en la fila (árbol) o al arrastrar el nodo +/// (grafo); +/// - `on_context(id)` → al right-click (ambos modos); `None` = sin menú. +pub fn navigator_view( + spec: NavSpec, + is_expanded: FExp, + on_toggle: FTog, + on_select: FSel, + on_context: Option, +) -> View +where + Msg: Clone + Send + Sync + 'static, + FExp: Fn(NavId) -> bool, + FTog: Fn(NavId) -> Msg, + FSel: Fn(NavId) -> Msg + Send + Sync + 'static, + FCtx: Fn(NavId) -> Msg, +{ + match spec.mode { + NavMode::Tree => tree_mode(spec, is_expanded, on_toggle, on_select, on_context), + NavMode::Graph => graph_mode(spec, is_expanded, on_select, on_context), + } +} + +// ===================================================================== +// Árbol +// ===================================================================== + +fn tree_mode( + spec: NavSpec, + is_expanded: FExp, + on_toggle: FTog, + on_select: FSel, + on_context: Option, +) -> View +where + Msg: Clone + Send + Sync + 'static, + FExp: Fn(NavId) -> bool, + FTog: Fn(NavId) -> Msg, + FSel: Fn(NavId) -> Msg, + FCtx: Fn(NavId) -> Msg, +{ + let mut rows: Vec> = Vec::new(); + for root in spec.roots { + push_rows( + root, + 0, + &spec, + &is_expanded, + &on_toggle, + &on_select, + &on_context, + &mut rows, + ); + } + tree_view(TreeSpec { + rows, + row_height: ROW_H, + indent_px: 14.0, + palette: spec.palette.tree, + guides: spec.guides, + }) +} + +#[allow(clippy::too_many_arguments)] +fn push_rows( + node: &NavNode, + depth: usize, + spec: &NavSpec, + is_expanded: &FExp, + on_toggle: &FTog, + on_select: &FSel, + on_context: &Option, + out: &mut Vec>, +) where + Msg: Clone + Send + Sync + 'static, + FExp: Fn(NavId) -> bool, + FTog: Fn(NavId) -> Msg, + FSel: Fn(NavId) -> Msg, + FCtx: Fn(NavId) -> Msg, +{ + let has_children = node.has_children(); + let expanded = has_children && is_expanded(node.id); + let icon = kind_icon_view::(node.kind, spec.palette.kind_color(node.kind)); + let mut row = TreeRow::new( + node.label.clone(), + depth, + has_children, + expanded, + spec.selected == Some(node.id), + on_toggle(node.id), + on_select(node.id), + ) + .with_icon(icon); + if let Some(ctx) = on_context.as_ref().map(|f| f(node.id)) { + row = row.with_context(ctx); + } + out.push(row); + + if expanded { + for child in &node.children { + push_rows( + child, depth + 1, spec, is_expanded, on_toggle, on_select, on_context, out, + ); + } + } +} + +// ===================================================================== +// Grafo +// ===================================================================== + +/// Un nodo visible aplanado para el grafo: su id, su label/kind y la posición +/// (índice) de su padre en la lista (`None` = raíz). +struct FlatNode { + id: NavId, + label: String, + kind: NavKind, + depth: usize, + parent: Option, + has_children: bool, +} + +fn flatten_for_graph bool>( + roots: &[NavNode], + is_expanded: &FExp, +) -> Vec { + let mut out = Vec::new(); + for root in roots { + walk_graph(root, 0, None, is_expanded, &mut out); + } + out +} + +fn walk_graph bool>( + node: &NavNode, + depth: usize, + parent: Option, + is_expanded: &FExp, + out: &mut Vec, +) { + let has_children = node.has_children(); + let me = out.len(); + out.push(FlatNode { + id: node.id, + label: node.label.clone(), + kind: node.kind, + depth, + parent, + has_children, + }); + if has_children && is_expanded(node.id) { + for child in &node.children { + walk_graph(child, depth + 1, Some(me), is_expanded, out); + } + } +} + +fn graph_mode( + spec: NavSpec, + is_expanded: FExp, + on_select: FSel, + on_context: Option, +) -> View +where + Msg: Clone + Send + Sync + 'static, + FExp: Fn(NavId) -> bool, + FSel: Fn(NavId) -> Msg + Send + Sync + 'static, + FCtx: Fn(NavId) -> Msg, +{ + let flat = flatten_for_graph(spec.roots, &is_expanded); + let metrics = NodegraphMetrics { + node_width: 150.0, + ..NodegraphMetrics::default() + }; + + // Layout: columna por profundidad, una fila por nodo visible. + const MARGIN: f32 = 24.0; + const COL_GAP: f32 = 36.0; + const ROW_GAP: f32 = 12.0; + let node_h = metrics.node_height(1, 1); + let col_w = metrics.node_width + COL_GAP; + + let mut nodes: Vec = Vec::with_capacity(flat.len()); + let mut wires: Vec = Vec::new(); + let ids: Vec = flat.iter().map(|f| f.id).collect(); + + for (i, f) in flat.iter().enumerate() { + let inputs = if f.parent.is_some() { + vec![String::new()] + } else { + Vec::new() + }; + let outputs = if f.has_children { + vec![String::new()] + } else { + Vec::new() + }; + // Prefijo del icono en el label (el nodegraph no tiene slot de icono; + // un glifo simple por clase basta para distinguirlos de un vistazo). + let label = format!("{} {}", kind_glyph(f.kind), f.label); + nodes.push(NodeSpec { + id: i as NodeId, + label, + x: MARGIN + f.depth as f32 * col_w, + y: MARGIN + i as f32 * (node_h + ROW_GAP), + inputs, + outputs, + }); + if let Some(p) = f.parent { + wires.push(Wire { + from_node: p as NodeId, + from_output: 0, + to_node: i as NodeId, + to_input: 0, + }); + } + } + + // Arrastrar un nodo lo selecciona (al soltar). El grafo no reposiciona + // por arrastre — el layout es derivado, no editable. + let drag_ids = ids.clone(); + let on_drag = move |id: NodeId, phase: DragPhase, _dx: f32, _dy: f32| match phase { + DragPhase::End => drag_ids + .get(id as usize) + .map(|nav_id| on_select(*nav_id)), + DragPhase::Move => None, + }; + // Sin conexiones: la contención es fija. + let on_connect = |_: NodeId, _: u16, _: NodeId, _: u16| None; + + // Right-click → menú contextual (evaluado en build, por nodo). + let ctx_ids = &ids; + let on_right: Option Option>> = on_context.map(|f| { + let f = move |id: NodeId| ctx_ids.get(id as usize).map(|nav_id| f(*nav_id)); + Box::new(f) as Box Option> + }); + + // Resaltado del nodo seleccionado. + let sel_idx = spec + .selected + .and_then(|sid| ids.iter().position(|id| *id == sid)); + let accent = spec.palette.accent; + let tint = move |id: NodeId| -> Option { + if Some(id as usize) == sel_idx { + Some(NodeTint { + bg_title: Some(accent), + ..NodeTint::default() + }) + } else { + None + } + }; + + nodegraph_view_styled( + &nodes, + &wires, + &spec.palette.graph, + &metrics, + on_drag, + on_connect, + on_right, + Some(&tint as &dyn Fn(NodeId) -> Option), + None, + ) +} + +/// Glifo ASCII-ish por clase para el label del grafo. +fn kind_glyph(kind: NavKind) -> &'static str { + match kind { + NavKind::Monad => "◈", + NavKind::Group => "▣", + NavKind::Dir => "▸", + NavKind::File => "·", + NavKind::Other => "·", + } +} + +// ===================================================================== +// Icono vectorial por clase (para el árbol) +// ===================================================================== + +/// Un mini-canvas con el icono de la clase, tinte `color`. Diamante para +/// Mónada, cuadrado para grupo/dir, círculo para archivo. +fn kind_icon_view(kind: NavKind, color: Color) -> View { + View::new(Style { + size: Size { + width: llimphi_ui::llimphi_layout::taffy::prelude::length(ICON_PX), + height: llimphi_ui::llimphi_layout::taffy::prelude::length(ICON_PX), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| { + use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle, Point, RoundedRect}; + use llimphi_ui::llimphi_raster::peniko::Fill; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let cx = (rect.x + rect.w * 0.5) as f64; + let cy = (rect.y + rect.h * 0.5) as f64; + let r = (rect.w.min(rect.h) as f64 * 0.34).max(1.5); + match kind { + NavKind::Monad => { + // Diamante (cuadrado a 45°). + let mut p = BezPath::new(); + p.move_to(Point::new(cx, cy - r)); + p.line_to(Point::new(cx + r, cy)); + p.line_to(Point::new(cx, cy + r)); + p.line_to(Point::new(cx - r, cy)); + p.close_path(); + scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &p); + } + NavKind::Group | NavKind::Dir => { + let sq = RoundedRect::new(cx - r, cy - r, cx + r, cy + r, 2.0); + scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &sq); + } + NavKind::File | NavKind::Other => { + let dot = (rect.w.min(rect.h) as f64 * 0.22).max(1.0); + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + color, + None, + &Circle::new((cx, cy), dot), + ); + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Clone, Debug, PartialEq)] + enum Msg { + Toggle(NavId), + Select(NavId), + Open(NavId), + } + + fn forest() -> Vec { + vec![NavNode::branch( + 1, + "Mónada src", + NavKind::Monad, + vec![ + NavNode::leaf(11, "lib.rs", NavKind::File), + NavNode::leaf(12, "main.rs", NavKind::File), + ], + )] + } + + #[test] + fn navmode_toggle_e_indices() { + assert_eq!(NavMode::Tree.toggled(), NavMode::Graph); + assert_eq!(NavMode::Graph.toggled(), NavMode::Tree); + assert_eq!(NavMode::Tree.index(), 0); + assert_eq!(NavMode::from_index(1), NavMode::Graph); + assert_eq!(NavMode::from_index(0), NavMode::Tree); + } + + #[test] + fn navnode_constructores() { + let n = NavNode::leaf(1, "x", NavKind::File); + assert!(!n.has_children()); + let b = NavNode::branch(2, "y", NavKind::Monad, vec![n]); + assert!(b.has_children()); + assert_eq!(b.children.len(), 1); + } + + #[test] + fn flatten_grafo_respeta_expansion() { + let roots = forest(); + // Colapsado: sólo la raíz. + let collapsed = flatten_for_graph(&roots, &|_| false); + assert_eq!(collapsed.len(), 1); + assert_eq!(collapsed[0].id, 1); + assert!(collapsed[0].parent.is_none()); + assert!(collapsed[0].has_children); + // Expandido: raíz + 2 hijos, con parent = índice 0. + let expanded = flatten_for_graph(&roots, &|id| id == 1); + assert_eq!(expanded.len(), 3); + assert_eq!(expanded[1].parent, Some(0)); + assert_eq!(expanded[2].parent, Some(0)); + assert_eq!(expanded[1].depth, 1); + } + + #[test] + fn navigator_view_construye_en_ambos_modos() { + // No paniquea construyendo el View en cada modo (smoke). + let roots = forest(); + let palette = NavPalette::default(); + for mode in [NavMode::Tree, NavMode::Graph] { + let _v: View = navigator_view( + NavSpec { + roots: &roots, + mode, + selected: Some(1), + palette, + guides: true, + }, + |id| id == 1, + Msg::Toggle, + Msg::Select, + Some(Msg::Open), + ); + } + } +} diff --git a/widgets/nodegraph/Cargo.toml b/widgets/nodegraph/Cargo.toml new file mode 100644 index 0000000..edc5d0e --- /dev/null +++ b/widgets/nodegraph/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "llimphi-widget-nodegraph" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-nodegraph — lienzo de nodos con pins y cables Bezier. Reusable por pluma (DAG), nakui (fórmulas yupay), tullpu (ajustes no destructivos), dominium (sistemas), takiy (cadena de audio)." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } + +[[example]] +name = "nodegraph_demo" +path = "examples/nodegraph_demo.rs" diff --git a/widgets/nodegraph/LEEME.md b/widgets/nodegraph/LEEME.md new file mode 100644 index 0000000..d65279c --- /dev/null +++ b/widgets/nodegraph/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-nodegraph + +> Lienzo de nodos + cables Bezier para [llimphi](../../README.md). + +Cada nodo es `View` libre con puertos in/out. Aristas curvas Bezier. Drag de nodos, pan/zoom del canvas, conectar puertos. Usado por `pluma-notebook-graph-llimphi`, `nakui-explorer-llimphi`, `iniy-explorer-llimphi`. diff --git a/widgets/nodegraph/README.md b/widgets/nodegraph/README.md new file mode 100644 index 0000000..f374697 --- /dev/null +++ b/widgets/nodegraph/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-nodegraph + +> Node canvas + Bezier wires for [llimphi](../../README.md). + +Each node is a free `View` with in/out ports. Bezier curve edges. Node drag, canvas pan/zoom, port connect. Used by `pluma-notebook-graph-llimphi`, `nakui-explorer-llimphi`, `iniy-explorer-llimphi`. diff --git a/widgets/nodegraph/examples/nodegraph_demo.rs b/widgets/nodegraph/examples/nodegraph_demo.rs new file mode 100644 index 0000000..a78117a --- /dev/null +++ b/widgets/nodegraph/examples/nodegraph_demo.rs @@ -0,0 +1,197 @@ +//! Showcase de `llimphi-widget-nodegraph`. Cuatro nodos pre-conectados +//! representando una cadena de audio (`Source → Filter → Mixer → +//! Output`) y un `LFO` huérfano para que el usuario lo conecte +//! arrastrando desde su pin de salida hasta el `mod` del filtro. +//! +//! - Arrastrá la title bar de cualquier nodo para moverlo. +//! - Arrastrá desde un pin de salida (lado derecho) y soltá sobre un +//! pin de entrada (lado izquierdo) de otro nodo para conectar. +//! +//! Corré con: `cargo run -p llimphi-widget-nodegraph --example +//! nodegraph_demo --release`. + +use llimphi_theme::Theme; +use llimphi_ui::{App, DragPhase, Handle, View}; +use llimphi_widget_nodegraph::{ + nodegraph_view, NodeId, NodeSpec, NodegraphMetrics, NodegraphPalette, PinIdx, Wire, +}; + +#[derive(Clone)] +enum Msg { + DragNode { + id: NodeId, + // El demo no diferencia Move/End; lo dejamos en el Msg por si + // un caller real quiere persistir layout solo en End. + #[allow(dead_code)] + phase: DragPhase, + dx: f32, + dy: f32, + }, + Connect { + from_node: NodeId, + from_pin: PinIdx, + to_node: NodeId, + to_pin: PinIdx, + }, +} + +struct Model { + nodes: Vec, + wires: Vec, +} + +const ID_SOURCE: NodeId = 1; +const ID_FILTER: NodeId = 2; +const ID_MIXER: NodeId = 3; +const ID_OUTPUT: NodeId = 4; +const ID_LFO: NodeId = 5; + +struct Showcase; + +impl App for Showcase { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · nodegraph showcase (drag títulos, arrastrá pin → pin)" + } + + fn initial_size() -> (u32, u32) { + (1100, 720) + } + + fn init(_: &Handle) -> Model { + Model { + nodes: vec![ + NodeSpec { + id: ID_SOURCE, + label: "Source".into(), + x: 60.0, + y: 80.0, + inputs: vec![], + outputs: vec!["out".into()], + }, + NodeSpec { + id: ID_FILTER, + label: "Filter".into(), + x: 290.0, + y: 80.0, + inputs: vec!["in".into(), "mod".into()], + outputs: vec!["out".into()], + }, + NodeSpec { + id: ID_MIXER, + label: "Mixer".into(), + x: 520.0, + y: 80.0, + inputs: vec!["a".into(), "b".into()], + outputs: vec!["out".into()], + }, + NodeSpec { + id: ID_OUTPUT, + label: "Output".into(), + x: 750.0, + y: 80.0, + inputs: vec!["in".into()], + outputs: vec![], + }, + NodeSpec { + id: ID_LFO, + label: "LFO".into(), + x: 290.0, + y: 260.0, + inputs: vec![], + outputs: vec!["out".into()], + }, + ], + wires: vec![ + Wire { + from_node: ID_SOURCE, + from_output: 0, + to_node: ID_FILTER, + to_input: 0, + }, + Wire { + from_node: ID_FILTER, + from_output: 0, + to_node: ID_MIXER, + to_input: 0, + }, + Wire { + from_node: ID_MIXER, + from_output: 0, + to_node: ID_OUTPUT, + to_input: 0, + }, + ], + } + } + + fn update(model: Model, msg: Msg, _: &Handle) -> Model { + let mut m = model; + match msg { + Msg::DragNode { id, phase: _, dx, dy } => { + if let Some(n) = m.nodes.iter_mut().find(|n| n.id == id) { + n.x += dx; + n.y += dy; + if n.x < 0.0 { + n.x = 0.0; + } + if n.y < 0.0 { + n.y = 0.0; + } + } + } + Msg::Connect { + from_node, + from_pin, + to_node, + to_pin, + } => { + if from_node == to_node { + return m; + } + let exists = m.wires.iter().any(|w| { + w.from_node == from_node + && w.from_output == from_pin + && w.to_node == to_node + && w.to_input == to_pin + }); + if !exists { + m.wires.push(Wire { + from_node, + from_output: from_pin, + to_node, + to_input: to_pin, + }); + } + } + } + m + } + + fn view(model: &Model) -> View { + let theme = Theme::dark(); + let palette = NodegraphPalette::from_theme(&theme); + let metrics = NodegraphMetrics::default(); + nodegraph_view( + &model.nodes, + &model.wires, + &palette, + &metrics, + |id, phase, dx, dy| Some(Msg::DragNode { id, phase, dx, dy }), + |from_node, from_pin, to_node, to_pin| { + Some(Msg::Connect { + from_node, + from_pin, + to_node, + to_pin, + }) + }, + ) + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/nodegraph/src/lib.rs b/widgets/nodegraph/src/lib.rs new file mode 100644 index 0000000..7af7238 --- /dev/null +++ b/widgets/nodegraph/src/lib.rs @@ -0,0 +1,718 @@ +//! `llimphi-widget-nodegraph` — lienzo de nodos con pins y cables +//! Bezier sobre Llimphi. +//! +//! Modelo declarativo de un grafo dirigido: cada frame, el caller pasa +//! la lista actual de [`NodeSpec`]s + [`Wire`]s y el widget pinta: +//! +//! - el lienzo (fondo lleno); +//! - cada nodo como un rect con título arriba y pins a los lados +//! (entradas a la izquierda, salidas a la derecha); +//! - los cables entre `(node_a, output_pin_a)` y `(node_b, input_pin_b)` +//! como Bezier cúbicas con tangentes horizontales (mismo look que +//! `pluma-editor-llimphi::multilienzo_editor::carril_editor`). +//! +//! El widget no mantiene estado: el caller acumula posición de nodos + +//! cables en su `Model` y le pasa handlers para los dos eventos +//! interactivos: +//! +//! - **mover un nodo** — `on_drag_node(node_id, phase, dx, dy)` se +//! invoca al arrastrar la title bar de un nodo. El handler suma el +//! delta a la posición persistida. +//! - **conectar dos pins** — al arrastrar desde un pin de salida y +//! soltar sobre un pin de entrada, `on_connect(from_node, from_out, +//! to_node, to_in)` se invoca para que el caller materialice el +//! `Wire` en su modelo. +//! +//! Reusable por: pluma (visualizador DAG), nakui (fórmulas yupay), +//! tullpu (ajustes no destructivos), dominium (sistemas), takiy +//! (cadena de audio), pluma-notebook (kernel-DAG visual). + +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Dimension, Position, Size, Style}, + Rect, +}; +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::{DragPhase, View}; + +/// Identificador opaco de un nodo. El caller asigna estos valores; el +/// widget los pasa de vuelta sin interpretarlos. +pub type NodeId = u32; +/// Índice del pin dentro de la lista `inputs` o `outputs` del nodo. +pub type PinIdx = u16; + +/// Especificación de un nodo del grafo. El caller construye uno por +/// nodo en cada `view`. Las posiciones son en pixels relativas al rect +/// del lienzo. +#[derive(Debug, Clone)] +pub struct NodeSpec { + pub id: NodeId, + pub label: String, + /// Esquina superior-izquierda del nodo, en coordenadas del lienzo. + pub x: f32, + pub y: f32, + /// Labels de los pins de entrada. Cantidad = altura mínima del nodo. + pub inputs: Vec, + /// Labels de los pins de salida. + pub outputs: Vec, +} + +/// Cable entre el pin de salida de un nodo y el pin de entrada de otro. +/// El widget no valida ciclos ni direcciones — esa política vive en el +/// caller. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Wire { + pub from_node: NodeId, + pub from_output: PinIdx, + pub to_node: NodeId, + pub to_input: PinIdx, +} + +/// Tinte opcional de un nodo resaltado. Cada campo `Some` sobrescribe +/// el color correspondiente de la paleta global para *ese* nodo; los +/// `None` heredan la paleta. Sirve para que el caller marque un subgrafo +/// (p.ej. el cono afectado por un morfismo) sin tocar el resto. +#[derive(Debug, Clone, Copy, Default)] +pub struct NodeTint { + pub bg_node: Option, + pub bg_title: Option, + pub fg_title: Option, +} + +/// Paleta del lienzo. Hereda del [`llimphi_theme::Theme`] semántico. +#[derive(Debug, Clone, Copy)] +pub struct NodegraphPalette { + pub bg_canvas: Color, + pub bg_node: Color, + pub bg_title: Color, + pub fg_title: Color, + pub fg_pin_label: Color, + pub pin_input: Color, + pub pin_output: Color, + pub pin_drop_hover: Color, + pub wire: Color, + pub border: Color, +} + +impl Default for NodegraphPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl NodegraphPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg_canvas: t.bg_app, + bg_node: t.bg_panel, + bg_title: t.bg_panel_alt, + fg_title: t.fg_text, + fg_pin_label: t.fg_muted, + pin_input: t.accent, + pin_output: t.accent, + pin_drop_hover: t.bg_selected, + wire: t.accent, + border: t.border, + } + } +} + +/// Geometría del nodo y de los pins. +#[derive(Debug, Clone, Copy)] +pub struct NodegraphMetrics { + pub node_width: f32, + pub title_height: f32, + pub pin_row_height: f32, + pub pin_radius: f32, + pub pin_label_size: f32, + pub title_text_size: f32, + pub wire_stroke: f32, + pub node_radius: f64, +} + +impl Default for NodegraphMetrics { + fn default() -> Self { + Self { + node_width: 160.0, + title_height: 22.0, + pin_row_height: 18.0, + pin_radius: 5.0, + pin_label_size: 10.0, + title_text_size: 11.0, + wire_stroke: 1.6, + node_radius: 4.0, + } + } +} + +impl NodegraphMetrics { + /// Alto total del rect que ocupa un nodo con `n_in` entradas y + /// `n_out` salidas. El cuerpo crece con el lado que tenga más pins. + pub fn node_height(&self, n_in: usize, n_out: usize) -> f32 { + let rows = n_in.max(n_out).max(1) as f32; + self.title_height + rows * self.pin_row_height + 6.0 + } + + /// Centro Y absoluto de un pin de entrada del nodo cuyo top-left es + /// `(_x, node_y)`. Sirve también para outputs (misma alineación). + pub fn input_pin_y(&self, node_y: f32, pin: PinIdx) -> f32 { + node_y + + self.title_height + + 3.0 + + (pin as f32 + 0.5) * self.pin_row_height + } + + pub fn output_pin_y(&self, node_y: f32, pin: PinIdx) -> f32 { + self.input_pin_y(node_y, pin) + } +} + +type DragNodeFn = + Arc Option + Send + Sync>; +type ConnectFn = Arc< + dyn Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option + Send + Sync, +>; + +/// Codifica `(node_id, pin_idx)` en el `u64` que viaja como payload del +/// drag de un pin. 32 bits superiores = node_id, 16 bits inferiores = +/// pin_idx. +#[inline] +fn encode_payload(node: NodeId, pin: PinIdx) -> u64 { + ((node as u64) << 32) | (pin as u64) +} + +#[inline] +fn decode_payload(payload: u64) -> (NodeId, PinIdx) { + let node = (payload >> 32) as NodeId; + let pin = (payload & 0xFFFF) as PinIdx; + (node, pin) +} + +/// Construye el lienzo de nodos. `on_drag_node` se invoca con el delta +/// del cursor cuando el usuario arrastra la title bar de un nodo (las +/// fases `Move` y `End` se reenvían tal cual). `on_connect` se invoca +/// cuando el usuario suelta un cable iniciado en un pin de salida +/// sobre un pin de entrada de otro nodo. +pub fn nodegraph_view( + nodes: &[NodeSpec], + wires: &[Wire], + palette: &NodegraphPalette, + metrics: &NodegraphMetrics, + on_drag_node: FDrag, + on_connect: FConnect, +) -> View +where + Msg: Clone + Send + Sync + 'static, + FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option + Send + Sync + 'static, + FConnect: + Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option + Send + Sync + 'static, +{ + nodegraph_view_ex:: Option>( + nodes, + wires, + palette, + metrics, + on_drag_node, + on_connect, + None, + ) +} + +/// Variante extendida con un handler opcional de click derecho sobre +/// la title bar de cada nodo. Permite a la app montar acciones por-nodo +/// (estilo "ejecutar desde aquí" en un notebook reactivo, o "duplicar +/// este nodo" en un editor de cadena de audio) sin esperar a que el +/// widget tenga un menú contextual propio. +/// +/// `on_right_click_node` se evalúa una vez por nodo al construir la +/// vista — si devuelve `Some(msg)`, el runtime emite ese `Msg` al hacer +/// right-click sobre la title bar; `None` deja al nodo sin acción +/// contextual. +pub fn nodegraph_view_ex( + nodes: &[NodeSpec], + wires: &[Wire], + palette: &NodegraphPalette, + metrics: &NodegraphMetrics, + on_drag_node: FDrag, + on_connect: FConnect, + on_right_click_node: Option, +) -> View +where + Msg: Clone + Send + Sync + 'static, + FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option + Send + Sync + 'static, + FConnect: + Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option + Send + Sync + 'static, + FRight: Fn(NodeId) -> Option, +{ + nodegraph_view_styled( + nodes, + wires, + palette, + metrics, + on_drag_node, + on_connect, + on_right_click_node, + None, + None, + ) +} + +/// Variante con realce: además de los handlers, acepta dos closures de +/// estilo evaluados en construcción —`node_tint(id)` tiñe nodos puntuales +/// y `wire_tint(&Wire)` recolorea cables— para que el caller marque un +/// subgrafo (cono afectado, ruta crítica, celda con error…) sin tocar la +/// paleta global. Ambos `None` = render idéntico a [`nodegraph_view`]. +#[allow(clippy::too_many_arguments)] +pub fn nodegraph_view_styled( + nodes: &[NodeSpec], + wires: &[Wire], + palette: &NodegraphPalette, + metrics: &NodegraphMetrics, + on_drag_node: FDrag, + on_connect: FConnect, + on_right_click_node: Option, + node_tint: Option<&dyn Fn(NodeId) -> Option>, + wire_tint: Option<&dyn Fn(&Wire) -> Option>, +) -> View +where + Msg: Clone + Send + Sync + 'static, + FDrag: Fn(NodeId, DragPhase, f32, f32) -> Option + Send + Sync + 'static, + FConnect: + Fn(NodeId, PinIdx, NodeId, PinIdx) -> Option + Send + Sync + 'static, + FRight: Fn(NodeId) -> Option, +{ + let on_drag: DragNodeFn = Arc::new(on_drag_node); + let on_connect: ConnectFn = Arc::new(on_connect); + + let painted = precompute_wires(nodes, wires, metrics, palette.wire, wire_tint); + let stroke_px = metrics.wire_stroke; + + let mut children: Vec> = Vec::with_capacity(nodes.len() + 1); + + // Capa 0 — cables (van detrás de los nodos). + children.push(wires_layer(painted, stroke_px)); + + // Capa 1..N — nodos. + for node in nodes { + let right_click_msg = on_right_click_node + .as_ref() + .and_then(|f| f(node.id)); + let tint = node_tint.and_then(|f| f(node.id)); + children.push(node_view( + node, + palette, + metrics, + &on_drag, + &on_connect, + right_click_msg, + tint, + )); + } + + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(palette.bg_canvas) + .clip(true) + .children(children) +} + +#[derive(Debug, Clone, Copy)] +struct WirePainted { + x1: f32, + y1: f32, + x2: f32, + y2: f32, + color: Color, +} + +fn precompute_wires( + nodes: &[NodeSpec], + wires: &[Wire], + metrics: &NodegraphMetrics, + default_color: Color, + wire_tint: Option<&dyn Fn(&Wire) -> Option>, +) -> Vec { + let mut out = Vec::with_capacity(wires.len()); + for w in wires { + let from = nodes.iter().find(|n| n.id == w.from_node); + let to = nodes.iter().find(|n| n.id == w.to_node); + if let (Some(a), Some(b)) = (from, to) { + let x1 = a.x + metrics.node_width; + let y1 = metrics.output_pin_y(a.y, w.from_output); + let x2 = b.x; + let y2 = metrics.input_pin_y(b.y, w.to_input); + let color = wire_tint.and_then(|f| f(w)).unwrap_or(default_color); + out.push(WirePainted { + x1, + y1, + x2, + y2, + color, + }); + } + } + out +} + +fn wires_layer(wires: Vec, stroke_px: f32) -> View +where + Msg: Clone + 'static, +{ + let nodo = View::new(Style { + position: 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 wires.is_empty() { + return nodo; + } + nodo.paint_with(move |scene, _ts, rect| { + let stroke = Stroke::new(stroke_px as f64); + for w in &wires { + // Bezier cúbica con tangentes horizontales — mismo patrón + // que las hebras de pluma-editor-llimphi. + let dx = ((w.x2 - w.x1).abs().max(40.0) * 0.5) as f64; + let x1 = (rect.x + w.x1) as f64; + let y1 = (rect.y + w.y1) as f64; + let x2 = (rect.x + w.x2) as f64; + let y2 = (rect.y + w.y2) as f64; + let mut path = BezPath::new(); + path.move_to((x1, y1)); + path.curve_to((x1 + dx, y1), (x2 - dx, y2), (x2, y2)); + scene.stroke(&stroke, Affine::IDENTITY, w.color, None, &path); + } + }) +} + +fn node_view( + node: &NodeSpec, + palette: &NodegraphPalette, + metrics: &NodegraphMetrics, + on_drag: &DragNodeFn, + on_connect: &ConnectFn, + on_right_click_msg: Option, + tint: Option, +) -> View +where + Msg: Clone + Send + Sync + 'static, +{ + let n_in = node.inputs.len(); + let n_out = node.outputs.len(); + let height = metrics.node_height(n_in, n_out); + + // Colores efectivos: el tinte sobrescribe la paleta por-campo. + let tint = tint.unwrap_or_default(); + let bg_node = tint.bg_node.unwrap_or(palette.bg_node); + let bg_title = tint.bg_title.unwrap_or(palette.bg_title); + let fg_title = tint.fg_title.unwrap_or(palette.fg_title); + + let node_id = node.id; + let drag = on_drag.clone(); + let mut title_bar = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(metrics.title_height), + }, + flex_shrink: 0.0, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(bg_title) + .text_aligned( + node.label.clone(), + metrics.title_text_size, + fg_title, + Alignment::Start, + ) + .draggable(move |phase, dx, dy| (drag)(node_id, phase, dx, dy)); + + if let Some(msg) = on_right_click_msg { + title_bar = title_bar.on_right_click(msg); + } + + let mut pin_layer_children: Vec> = Vec::with_capacity(n_in + n_out); + for (i, label) in node.inputs.iter().enumerate() { + pin_layer_children.push(pin_view( + node_id, + i as PinIdx, + PinKind::Input, + label, + palette, + metrics, + on_connect.clone(), + )); + } + for (i, label) in node.outputs.iter().enumerate() { + pin_layer_children.push(pin_view( + node_id, + i as PinIdx, + PinKind::Output, + label, + palette, + metrics, + on_connect.clone(), + )); + } + let pin_layer = View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0_f32), + top: length(metrics.title_height), + right: length(0.0_f32), + bottom: length(0.0_f32), + }, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + ..Default::default() + }) + .children(pin_layer_children); + + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(node.x), + top: length(node.y), + right: length(0.0_f32), + bottom: length(0.0_f32), + }, + size: Size { + width: length(metrics.node_width), + height: length(height), + }, + ..Default::default() + }) + .fill(bg_node) + .radius(metrics.node_radius) + .children(vec![title_bar, pin_layer]) +} + +#[derive(Debug, Clone, Copy)] +enum PinKind { + Input, + Output, +} + +fn pin_view( + node_id: NodeId, + pin_idx: PinIdx, + kind: PinKind, + label: &str, + palette: &NodegraphPalette, + metrics: &NodegraphMetrics, + on_connect: ConnectFn, +) -> View +where + Msg: Clone + Send + Sync + 'static, +{ + let y_top = pin_idx as f32 * metrics.pin_row_height; + let row_h = metrics.pin_row_height; + let r = metrics.pin_radius; + let diam = r * 2.0; + + let (pin_left, pin_right, dot_color, label_align) = match kind { + PinKind::Input => ( + Some(length(-r)), + None, + palette.pin_input, + Alignment::Start, + ), + PinKind::Output => ( + None, + Some(length(-r)), + palette.pin_output, + Alignment::End, + ), + }; + + let mut dot = View::new(Style { + position: Position::Absolute, + inset: Rect { + left: pin_left.unwrap_or_else(|| length(0.0_f32)), + top: length((row_h - diam) * 0.5), + right: pin_right.unwrap_or_else(|| length(0.0_f32)), + bottom: length(0.0_f32), + }, + size: Size { + width: length(diam), + height: length(diam), + }, + ..Default::default() + }) + .fill(dot_color) + .radius(r as f64); + + match kind { + PinKind::Output => { + dot = dot + .draggable(|_phase: DragPhase, _dx: f32, _dy: f32| None) + .drag_payload(encode_payload(node_id, pin_idx)); + } + PinKind::Input => { + let to_node = node_id; + let to_pin = pin_idx; + let cb = on_connect.clone(); + dot = dot + .on_drop(move |payload: u64| { + let (from_node, from_pin) = decode_payload(payload); + (cb)(from_node, from_pin, to_node, to_pin) + }) + .drop_hover_fill(palette.pin_drop_hover); + } + } + + let label_view = View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(diam + 4.0), + top: length(0.0), + right: length(diam + 4.0), + bottom: length(0.0), + }, + size: Size { + width: Dimension::auto(), + height: length(row_h), + }, + ..Default::default() + }) + .text_aligned( + label.to_string(), + metrics.pin_label_size, + palette.fg_pin_label, + label_align, + ); + + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0), + top: length(y_top), + right: length(0.0), + bottom: length(0.0_f32), + }, + size: Size { + width: percent(1.0_f32), + height: length(row_h), + }, + ..Default::default() + }) + .children(vec![label_view, dot]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payload_roundtrip() { + for (n, p) in [ + (0u32, 0u16), + (1, 0), + (0, 1), + (42, 7), + (u32::MAX, u16::MAX), + (123_456, 65_535), + ] { + let enc = encode_payload(n, p); + let (n2, p2) = decode_payload(enc); + assert_eq!((n, p), (n2, p2), "payload {enc} → ({n2}, {p2})"); + } + } + + #[test] + fn metrics_node_height_grows_with_max_side() { + let m = NodegraphMetrics::default(); + assert_eq!(m.node_height(3, 1), m.node_height(1, 3)); + let min = m.title_height + m.pin_row_height + 6.0; + assert_eq!(m.node_height(0, 0), min); + } + + #[test] + fn pin_y_progression() { + let m = NodegraphMetrics::default(); + let y0 = m.input_pin_y(100.0, 0); + let y1 = m.input_pin_y(100.0, 1); + let y2 = m.input_pin_y(100.0, 2); + assert!(y1 - y0 > 0.0, "pins crecen hacia abajo"); + assert!((y2 - y1) - (y1 - y0) < 1e-3, "espaciado uniforme"); + } + + #[test] + fn precompute_skips_dangling_wires() { + let nodes = vec![NodeSpec { + id: 1, + label: "solo".into(), + x: 0.0, + y: 0.0, + inputs: vec!["in".into()], + outputs: vec!["out".into()], + }]; + let wires = vec![Wire { + from_node: 99, + from_output: 0, + to_node: 1, + to_input: 0, + }]; + let m = NodegraphMetrics::default(); + let pre = precompute_wires(&nodes, &wires, &m, Color::from_rgba8(0,0,0,255), None); + assert!(pre.is_empty()); + } + + #[test] + fn precompute_resolves_existing_wires() { + let nodes = vec![ + NodeSpec { + id: 1, + label: "a".into(), + x: 0.0, + y: 0.0, + inputs: vec![], + outputs: vec!["out".into()], + }, + NodeSpec { + id: 2, + label: "b".into(), + x: 200.0, + y: 50.0, + inputs: vec!["in".into()], + outputs: vec![], + }, + ]; + let wires = vec![Wire { + from_node: 1, + from_output: 0, + to_node: 2, + to_input: 0, + }]; + let m = NodegraphMetrics::default(); + let pre = precompute_wires(&nodes, &wires, &m, Color::from_rgba8(0,0,0,255), None); + assert_eq!(pre.len(), 1); + assert!((pre[0].x1 - m.node_width).abs() < 1e-3); + assert!((pre[0].x2 - 200.0).abs() < 1e-3); + } +} diff --git a/widgets/panel/Cargo.toml b/widgets/panel/Cargo.toml new file mode 100644 index 0000000..e5a3e1e --- /dev/null +++ b/widgets/panel/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-panel" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-panel — firma visual transversal: gradiente vertical casi imperceptible + hairline accent en el top edge. Helper paint_with + wrapper panel_view. La capa que vuelve reconocible al sistema sin cargar." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/panel/src/lib.rs b/widgets/panel/src/lib.rs new file mode 100644 index 0000000..be2fe76 --- /dev/null +++ b/widgets/panel/src/lib.rs @@ -0,0 +1,239 @@ +//! `llimphi-widget-panel` — firma visual transversal de los paneles gioser. +//! +//! Aporta dos detalles que aplicados consistentemente vuelven al sistema +//! reconocible sin que se note "diseñado": +//! +//! 1. **Gradiente vertical casi imperceptible** — el fondo del panel no +//! es un color sólido sino una interpolación lineal entre una versión +//! ligeramente más clara (top) y una ligeramente más oscura (bot) del +//! color base. La diferencia es ~4% en valor — invisible al primer +//! vistazo pero el ojo lo registra como "tallado" en vez de "pintado". +//! +//! 2. **Hairline accent en el top edge** — una línea horizontal de 1px +//! en el color accent del theme, al ~30% de alpha, justo en el borde +//! superior del panel. Funciona como "hilo de identidad" que cose +//! todos los paneles del sistema: aparece en modales, dropdowns, +//! cards, sidebars; siempre el mismo grosor, siempre el mismo color. +//! +//! ## API +//! +//! - [`PanelStyle`] — bundle de tokens (color base, accent, radio, +//! alpha del hairline, fuerza del gradiente). +//! - [`panel_signature_painter`] — `Fn` para `View::paint_with`. Útil si +//! ya tenés un View configurado y querés sumarle la firma sin envolver. +//! - [`panel_view`] — convenience: arma el View completo con la firma +//! aplicada, recibe los hijos como `Vec>`. +//! +//! ## Cuándo usarlo +//! +//! - SÍ: modales, dropdowns, cards prominentes, columnas de layout, +//! shortcuts-help, paneles flotantes. +//! - NO: chips, badges, toasts, items de lista (la firma es para +//! superficies grandes; en piezas chiquitas es ruido). + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style}; +use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, Rect as KurboRect, RoundedRect}; +use llimphi_ui::llimphi_raster::peniko::{color::AlphaColor, Color, Fill, Gradient}; +use llimphi_ui::{PaintRect, View}; +use llimphi_theme::{alpha, radius, Theme}; + +/// Token bundle de la firma visual. +#[derive(Debug, Clone, Copy)] +pub struct PanelStyle { + /// Color base del panel (típico: `theme.bg_panel`). + pub bg_base: Color, + /// Color del hairline (típico: `theme.accent`). + pub accent: Color, + /// Radio de las esquinas (típico: `radius::MD` para cards, `radius::LG` + /// para modales/overlays). + pub radius: f64, + /// Alpha del hairline (0.0–1.0). Por debajo de 0.20 se pierde; por + /// encima de 0.45 se vuelve dominante. Default 0.30. + pub hairline_alpha: f32, + /// Fuerza del gradiente — cada componente RGB se desplaza ±gradient + /// (en escala 0.0–1.0). 0.04 = 4% = imperceptible-pero-presente. + /// Subir más sólo si el theme es muy claro y el efecto no llega. + pub gradient_strength: f32, +} + +impl PanelStyle { + /// Estilo estándar para cards / sidebars / paneles medianos. + pub fn from_theme(t: &Theme) -> Self { + Self { + bg_base: t.bg_panel, + accent: t.accent, + radius: radius::MD, + hairline_alpha: alpha::SCRIM as f32 / 255.0 * 1.2, // ~0.30 + gradient_strength: 0.04, + } + } + + /// Variante para superficies grandes — modales, splash, overlays. + /// Esquinas más generosas, gradiente y hairline un toque más marcados. + pub fn from_theme_large(t: &Theme) -> Self { + Self { + bg_base: t.bg_panel, + accent: t.accent, + radius: radius::LG, + hairline_alpha: 0.35, + gradient_strength: 0.05, + } + } + + /// Variante neutra — sin hairline (panels que no deben llevar la + /// "firma" porque son piezas auxiliares). Mantiene el gradiente. + pub fn neutral(t: &Theme) -> Self { + Self { + bg_base: t.bg_panel, + accent: t.accent, + radius: radius::MD, + hairline_alpha: 0.0, + gradient_strength: 0.03, + } + } + + /// Color del top del gradiente: base aclarada. + pub fn bg_top(&self) -> Color { + shift(self.bg_base, self.gradient_strength) + } + + /// Color del bottom del gradiente: base oscurecida. + pub fn bg_bot(&self) -> Color { + shift(self.bg_base, -self.gradient_strength) + } +} + +/// Devuelve la closure de pintura que aplica la firma sobre el rect del +/// nodo. Pasarla a `View::paint_with` para sumar la firma a un View +/// existente. El View NO debe tener `.fill(...)` setteado — el gradient +/// reemplaza el fill sólido. +/// +/// Nota: el View debe llamar `.radius(style.radius)` en sí mismo si quiere +/// que clip/hit-test/borders respeten las esquinas. La firma pinta el +/// gradiente como `RoundedRect` con el mismo `radius`, así que la +/// silueta visual es consistente independientemente del clipping. +pub fn panel_signature_painter( + style: PanelStyle, +) -> impl Fn(&mut llimphi_ui::llimphi_raster::vello::Scene, &mut llimphi_ui::llimphi_text::Typesetter, PaintRect) + + Send + + Sync + + 'static { + move |scene, _ts, rect| { + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + + // === 1) Gradiente vertical en RoundedRect === + let x0 = rect.x as f64; + let y0 = rect.y as f64; + let x1 = (rect.x + rect.w) as f64; + let y1 = (rect.y + rect.h) as f64; + let rr = RoundedRect::new(x0, y0, x1, y1, style.radius); + let gradient = Gradient::new_linear( + Point::new(x0, y0), + Point::new(x0, y1), + ) + .with_stops([style.bg_top(), style.bg_bot()].as_slice()); + scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &rr); + + // === 2) Hairline accent en el top edge === + // Se acorta horizontalmente para no chocar con las esquinas + // redondeadas — queda inscrito en el "techo recto" del panel. + if style.hairline_alpha > 0.0 && rect.w > style.radius as f32 * 2.0 + 4.0 { + let hairline_color = with_alpha_mul(style.accent, style.hairline_alpha); + let hairline = KurboRect::new( + x0 + style.radius, + y0, + x1 - style.radius, + y0 + 1.0, + ); + scene.fill(Fill::NonZero, Affine::IDENTITY, hairline_color, None, &hairline); + } + } +} + +/// Convenience: arma un `View` con la firma aplicada y los `children` +/// adentro. Equivalente a: +/// +/// ```ignore +/// View::new(Style { size: full, ..Default::default() }) +/// .paint_with(panel_signature_painter(style)) +/// .radius(style.radius) +/// .clip(true) +/// .children(children) +/// ``` +/// +/// Para layouts custom (size específico, padding, flex direction), usar +/// `panel_signature_painter` directamente y construir el View a mano. +pub fn panel_view( + children: Vec>, + style: PanelStyle, +) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .paint_with(panel_signature_painter(style)) + .radius(style.radius) + .clip(true) + .children(children) +} + +// ===================================================================== +// Helpers internos +// ===================================================================== + +/// Desplaza cada componente RGB de `c` por `delta` (positivo aclara, +/// negativo oscurece). Clampea en [0,1]. El alpha queda intacto. +fn shift(c: Color, delta: f32) -> Color { + let [r, g, b, a] = c.components; + AlphaColor::new([ + (r + delta).clamp(0.0, 1.0), + (g + delta).clamp(0.0, 1.0), + (b + delta).clamp(0.0, 1.0), + a, + ]) +} + +fn with_alpha_mul(c: Color, mult: f32) -> Color { + let [r, g, b, a] = c.components; + AlphaColor::new([r, g, b, a * mult]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bg_top_is_brighter_than_bg_bot() { + let t = Theme::dark(); + let s = PanelStyle::from_theme(&t); + let top = s.bg_top(); + let bot = s.bg_bot(); + // El top debe tener cada canal RGB ≥ al del bot (es más claro). + for i in 0..3 { + assert!(top.components[i] >= bot.components[i], + "canal {i}: top {} < bot {}", top.components[i], bot.components[i]); + } + } + + #[test] + fn neutral_style_has_no_hairline() { + let t = Theme::dark(); + let s = PanelStyle::neutral(&t); + assert_eq!(s.hairline_alpha, 0.0); + } + + #[test] + fn shift_clamps_to_unit() { + let c = Color::from_rgba8(250, 250, 250, 255); + let bright = shift(c, 0.5); + assert!(bright.components[0] <= 1.0); + assert!(bright.components[1] <= 1.0); + } +} diff --git a/widgets/panes/Cargo.toml b/widgets/panes/Cargo.toml new file mode 100644 index 0000000..0e5d73c --- /dev/null +++ b/widgets/panes/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-panes" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-panes — árbol de paneles BSP estilo tmux: hojas opacas (`View`) que se parten horizontal/vertical, se cierran, enfocan y redimensionan arrastrando divisores. La base para montar cualquier componente de gioser en un layout intercambiable." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/panes/examples/panes_demo.rs b/widgets/panes/examples/panes_demo.rs new file mode 100644 index 0000000..d4fb11c --- /dev/null +++ b/widgets/panes/examples/panes_demo.rs @@ -0,0 +1,319 @@ +//! Demo de `llimphi-widget-panes` — "tmux de componentes gioser". +//! +//! Dos tipos de panel heterogéneos (Contador y Notas) conviviendo en un +//! mismo árbol BSP que se parte horizontal/vertical, se cierra, se enfoca +//! (click) y se redimensiona (arrastrando los divisores). Prueba de punta +//! a punta de que componentes distintos se montan en un layout +//! intercambiable con splits resizables. +//! +//! Correr: `cargo run -p llimphi-widget-panes --example panes_demo --release` + +use std::collections::HashMap; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + Rect, +}; +use llimphi_ui::{App, DragPhase, Handle, View}; +use llimphi_theme::Theme; +use llimphi_widget_panes::{panes_view, Axis, Layout, PaneId, PanesPalette, Side}; + +struct Demo; + +#[derive(Clone)] +enum Msg { + Focus(PaneId), + Split(Axis), + Close, + Resize(Vec, f32), + Inc(PaneId), + Dec(PaneId), + AddNote(PaneId), +} + +enum Kind { + Counter(i64), + Notes(Vec), +} + +struct Pane { + title: String, + kind: Kind, +} + +struct Model { + layout: Layout, + panes: HashMap, + focused: PaneId, + next_id: PaneId, + theme: Theme, +} + +impl App for Demo { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "panes — tmux de componentes gioser" + } + + fn init(_: &Handle) -> Model { + let mut panes = HashMap::new(); + panes.insert( + 1, + Pane { + title: "Contador".into(), + kind: Kind::Counter(0), + }, + ); + panes.insert( + 2, + Pane { + title: "Notas".into(), + kind: Kind::Notes(vec!["arrastrá el divisor del medio →".into()]), + }, + ); + let mut layout = Layout::single(1); + layout.split(1, 2, Axis::Horizontal); + Model { + layout, + panes, + focused: 1, + next_id: 3, + theme: Theme::dark(), + } + } + + fn update(mut model: Model, msg: Msg, _: &Handle) -> Model { + match msg { + Msg::Focus(id) => model.focused = id, + Msg::Split(axis) => { + let id = model.next_id; + model.next_id += 1; + let kind = if id % 2 == 0 { + Kind::Counter(0) + } else { + Kind::Notes(vec![]) + }; + let title = match &kind { + Kind::Counter(_) => "Contador".to_string(), + Kind::Notes(_) => "Notas".to_string(), + }; + model.panes.insert(id, Pane { title, kind }); + model.layout.split(model.focused, id, axis); + model.focused = id; + } + Msg::Close => { + if model.layout.count() > 1 { + let target = model.focused; + let (nl, removed) = model.layout.clone().without(target); + if removed { + model.layout = nl; + model.panes.remove(&target); + model.focused = model.layout.first_leaf(); + } + } + } + Msg::Resize(path, d) => model.layout.resize(&path, d), + Msg::Inc(id) => { + if let Some(Pane { + kind: Kind::Counter(n), + .. + }) = model.panes.get_mut(&id) + { + *n += 1; + } + } + Msg::Dec(id) => { + if let Some(Pane { + kind: Kind::Counter(n), + .. + }) = model.panes.get_mut(&id) + { + *n -= 1; + } + } + Msg::AddNote(id) => { + if let Some(Pane { + kind: Kind::Notes(v), + .. + }) = model.panes.get_mut(&id) + { + let n = v.len() + 1; + v.push(format!("nota #{n}")); + } + } + } + model + } + + fn view(model: &Model) -> View { + let t = &model.theme; + let toolbar = View::new(Style { + flex_direction: FlexDirection::Row, + gap: Size { + width: length(8.0), + height: length(8.0), + }, + padding: uniform(8.0), + flex_shrink: 0.0, + ..Default::default() + }) + .fill(t.bg_panel) + .children(vec![ + button("Split →", Msg::Split(Axis::Horizontal), t), + button("Split ↓", Msg::Split(Axis::Vertical), t), + button("Cerrar", Msg::Close, t), + View::new(Style { + flex_grow: 1.0, + ..Default::default() + }), + label( + format!("foco #{} · {} paneles", model.focused, model.layout.count()), + 13.0, + t.fg_muted, + ), + ]); + + let palette = PanesPalette::from_theme(t); + let panes = &model.panes; + let theme = t; + let area = panes_view( + &model.layout, + model.focused, + move |id| render_pane(panes, theme, id), + |path, phase, d| { + let _ = phase; + Some(Msg::Resize(path, d)) + }, + Msg::Focus, + &palette, + ); + + let area_wrap = View::new(Style { + flex_grow: 1.0, + size: Size { + width: percent(1.0), + height: percent(1.0), + }, + min_size: Size { + width: length(0.0), + height: length(0.0), + }, + ..Default::default() + }) + .children(vec![area]); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0), + height: percent(1.0), + }, + ..Default::default() + }) + .fill(t.bg_app) + .children(vec![toolbar, area_wrap]) + } +} + +fn render_pane(panes: &HashMap, t: &Theme, id: PaneId) -> View { + let Some(pane) = panes.get(&id) else { + return label("(panel vacío)".to_string(), 14.0, t.fg_muted); + }; + + let header = label(format!("{} #{id}", pane.title), 13.0, t.fg_text); + + let body = match &pane.kind { + Kind::Counter(n) => View::new(Style { + flex_direction: FlexDirection::Column, + gap: Size { + width: length(8.0), + height: length(8.0), + }, + ..Default::default() + }) + .children(vec![ + label(format!("{n}"), 44.0, t.accent), + View::new(Style { + flex_direction: FlexDirection::Row, + gap: Size { + width: length(8.0), + height: length(8.0), + }, + ..Default::default() + }) + .children(vec![ + button("−", Msg::Dec(id), t), + button("+", Msg::Inc(id), t), + ]), + ]), + Kind::Notes(v) => { + let mut lines: Vec> = v + .iter() + .map(|s| label(format!("• {s}"), 14.0, t.fg_text)) + .collect(); + lines.push(button("+ nota", Msg::AddNote(id), t)); + View::new(Style { + flex_direction: FlexDirection::Column, + gap: Size { + width: length(6.0), + height: length(6.0), + }, + ..Default::default() + }) + .children(lines) + } + }; + + View::new(Style { + flex_direction: FlexDirection::Column, + gap: Size { + width: length(10.0), + height: length(10.0), + }, + padding: uniform(12.0), + flex_grow: 1.0, + ..Default::default() + }) + .children(vec![header, body]) +} + +fn button(text: &str, msg: Msg, t: &Theme) -> View { + View::new(Style { + padding: Rect { + left: length(12.0), + right: length(12.0), + top: length(6.0), + bottom: length(6.0), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(t.bg_button) + .hover_fill(t.bg_button_hover) + .radius(6.0) + .on_click(msg) + .children(vec![label(text.to_string(), 14.0, t.fg_text)]) +} + +fn label( + text: String, + size: f32, + color: llimphi_ui::llimphi_raster::peniko::Color, +) -> View { + View::new(Style::default()).text(text, size, color) +} + +fn uniform(px: f32) -> Rect { + Rect { + left: length(px), + right: length(px), + top: length(px), + bottom: length(px), + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/panes/src/lib.rs b/widgets/panes/src/lib.rs new file mode 100644 index 0000000..cecee75 --- /dev/null +++ b/widgets/panes/src/lib.rs @@ -0,0 +1,505 @@ +//! `llimphi-widget-panes` — árbol de paneles BSP estilo tmux. +//! +//! La pieza que faltaba para "montar cualquier componente de gioser en un +//! layout intercambiable con splits resizables". El widget NO conoce los +//! dominios: hospeda hojas opacas (`View`) en un árbol binario que el +//! usuario parte (horizontal/vertical), cierra, enfoca (click) y +//! redimensiona (arrastrando los divisores). tmux, pero in-process y sobre +//! el bucle Elm de Llimphi. +//! +//! No confundir con `llimphi-widget-panel` (el chrome de UN panel con +//! título): esto es el árbol de N panes. +//! +//! ## Modelo +//! +//! - [`Layout`] es la **estructura** del árbol (qué hoja vive dónde, con +//! qué ratio cada split). Vive en el `Model` del host y se manipula con +//! [`Layout::split`], [`Layout::without`] y [`Layout::resize`]. +//! - El **contenido** de cada hoja lo provee el host vía un closure +//! `FnMut(PaneId) -> View` que se invoca al construir la vista — +//! por eso puede tomar prestado el `Model` (no necesita ser `'static`). +//! - El handler de resize sí se guarda en el árbol de vistas (lo agarra el +//! divisor draggable), así que ése debe ser `'static + Send + Sync`. El +//! de focus se evalúa al construir (porque `on_click` toma el `Msg` por +//! valor), así que no tiene esa restricción. +//! +//! ## Por qué no `Box` +//! +//! Igual que el resto del repo: el host mantiene un `enum` de sus tipos de +//! panel y hace dispatch estático. El widget es genérico sobre `Msg`; el +//! host decide cómo materializar cada hoja. Cero downcasting. + +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Dimension, FlexDirection, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::{DragPhase, View}; + +/// Identificador estable de un panel. El host lo asigna (un contador +/// monótono basta) y lo usa como llave hacia su propio estado. +pub type PaneId = u64; + +/// Eje del split. `Horizontal` pone los panes lado a lado (divisor +/// vertical, se arrastra en X); `Vertical` los apila (divisor horizontal, +/// se arrastra en Y). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Axis { + Horizontal, + Vertical, +} + +/// Rama de un split, usada para direccionar un nodo dentro del árbol. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Side { + First, + Second, +} + +/// Árbol binario de paneles. `Leaf` es un panel; `Split` divide el espacio +/// entre dos subárboles con un `ratio` (fracción que ocupa el primero). +#[derive(Debug, Clone, PartialEq)] +pub enum Layout { + Leaf(PaneId), + Split { + axis: Axis, + /// Fracción del eje que ocupa el subárbol `first` (0..1). + ratio: f32, + first: Box, + second: Box, + }, +} + +impl Layout { + /// Árbol de un solo panel. + pub fn single(id: PaneId) -> Self { + Layout::Leaf(id) + } + + /// Cantidad de hojas (paneles) en el árbol. + pub fn count(&self) -> usize { + match self { + Layout::Leaf(_) => 1, + Layout::Split { first, second, .. } => first.count() + second.count(), + } + } + + /// Lista de todas las hojas, en orden de aparición (izq→der / arr→ab). + pub fn leaves(&self) -> Vec { + let mut out = Vec::new(); + self.collect_leaves(&mut out); + out + } + + fn collect_leaves(&self, out: &mut Vec) { + match self { + Layout::Leaf(id) => out.push(*id), + Layout::Split { first, second, .. } => { + first.collect_leaves(out); + second.collect_leaves(out); + } + } + } + + /// `true` si la hoja existe en el árbol. + pub fn contains(&self, id: PaneId) -> bool { + match self { + Layout::Leaf(x) => *x == id, + Layout::Split { first, second, .. } => first.contains(id) || second.contains(id), + } + } + + /// Primera hoja (la de más arriba/izquierda). Útil para reenfocar tras + /// cerrar un panel. + pub fn first_leaf(&self) -> PaneId { + match self { + Layout::Leaf(id) => *id, + Layout::Split { first, .. } => first.first_leaf(), + } + } + + /// Parte la hoja `target` en dos: `target` queda en `Side::First` y la + /// nueva hoja `new` en `Side::Second`, con ratio 0.5. Devuelve `true` + /// si encontró el target. + pub fn split(&mut self, target: PaneId, new: PaneId, axis: Axis) -> bool { + match self { + Layout::Leaf(id) if *id == target => { + *self = Layout::Split { + axis, + ratio: 0.5, + first: Box::new(Layout::Leaf(target)), + second: Box::new(Layout::Leaf(new)), + }; + true + } + Layout::Leaf(_) => false, + Layout::Split { first, second, .. } => { + first.split(target, new, axis) || second.split(target, new, axis) + } + } + } + + /// Devuelve el árbol sin la hoja `target`, colapsando el split padre en + /// el hermano sobreviviente. El `bool` indica si removió algo. Quitar la + /// única hoja raíz es no-op (devuelve el árbol intacto, `false`). + pub fn without(self, target: PaneId) -> (Layout, bool) { + match self { + Layout::Leaf(id) => (Layout::Leaf(id), false), + Layout::Split { + axis, + ratio, + first, + second, + } => { + if matches!(*first, Layout::Leaf(t) if t == target) { + return (*second, true); + } + if matches!(*second, Layout::Leaf(t) if t == target) { + return (*first, true); + } + let (nf, rf) = first.without(target); + if rf { + return ( + Layout::Split { + axis, + ratio, + first: Box::new(nf), + second, + }, + true, + ); + } + let (ns, rs) = second.without(target); + ( + Layout::Split { + axis, + ratio, + first: Box::new(nf), + second: Box::new(ns), + }, + rs, + ) + } + } + } + + /// Ajusta el ratio del split direccionado por `path` (camino de raíz a + /// ese nodo). `delta` se suma al ratio, clamp a [0.05, 0.95]. + pub fn resize(&mut self, path: &[Side], delta: f32) { + match self { + Layout::Split { + ratio, + first, + second, + .. + } => match path.split_first() { + None => *ratio = (*ratio + delta).clamp(0.05, 0.95), + Some((Side::First, rest)) => first.resize(rest, delta), + Some((Side::Second, rest)) => second.resize(rest, delta), + }, + Layout::Leaf(_) => {} + } + } +} + +/// Ratio movido por píxel arrastrado. No conocemos el tamaño en px del +/// contenedor en tiempo de `view` (limitación conocida de Llimphi, la +/// misma raíz por la que no hay `View::map`), así que aproximamos con una +/// sensibilidad fija. El clamp en [`Layout::resize`] evita degenerar. +const RESIZE_SENSITIVITY: f32 = 1.0 / 600.0; + +/// Paleta del árbol de paneles. +#[derive(Debug, Clone, Copy)] +pub struct PanesPalette { + pub bg: Color, + pub border: Color, + pub focus_border: Color, + pub divider: Color, + pub divider_hover: Color, + pub thickness: f32, +} + +impl Default for PanesPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl PanesPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg: t.bg_app, + border: t.border, + focus_border: t.accent, + divider: t.border, + divider_hover: t.accent, + thickness: 6.0, + } + } +} + +/// Renderiza el árbol de paneles. +/// +/// - `leaf` materializa el contenido de cada hoja; se llama una vez por +/// panel mientras se construye la vista (puede tomar prestado el host). +/// - `on_resize` recibe el camino al split, la fase del drag y el delta de +/// ratio; devolver `Some(msg)` dispara el `update` (el host llama +/// [`Layout::resize`]). +/// - `on_focus` produce el msg al hacer click en un panel. +pub fn panes_view( + layout: &Layout, + focused: PaneId, + mut leaf: impl FnMut(PaneId) -> View, + on_resize: impl Fn(Vec, DragPhase, f32) -> Option + Send + Sync + 'static, + on_focus: impl Fn(PaneId) -> Msg, + palette: &PanesPalette, +) -> View +where + Msg: Clone + Send + Sync + 'static, +{ + let on_resize: Arc, DragPhase, f32) -> Option + Send + Sync> = + Arc::new(on_resize); + render( + layout, + focused, + &mut leaf, + &on_resize, + &on_focus, + Vec::new(), + palette, + ) +} + +#[allow(clippy::too_many_arguments)] +fn render( + layout: &Layout, + focused: PaneId, + leaf: &mut dyn FnMut(PaneId) -> View, + on_resize: &Arc, DragPhase, f32) -> Option + Send + Sync>, + on_focus: &dyn Fn(PaneId) -> Msg, + path: Vec, + palette: &PanesPalette, +) -> View +where + Msg: Clone + Send + Sync + 'static, +{ + match layout { + Layout::Leaf(id) => { + let id = *id; + let content = leaf(id); + let is_focused = id == focused; + let border_col = if is_focused { + palette.focus_border + } else { + palette.border + }; + let border_w = if is_focused { 2.0 } else { 1.0 }; + + // Caja interior (fondo del panel) con el contenido del host. + let inner = View::new(Style { + flex_grow: 1.0, + flex_direction: FlexDirection::Column, + size: full(), + min_size: zero(), + ..Default::default() + }) + .fill(palette.bg) + .children(vec![content]); + + // Marco: no hay `stroke`, así que el borde es un contenedor + // relleno con un padding del grosor → simula el trazo. + View::new(Style { + flex_direction: FlexDirection::Column, + size: full(), + min_size: zero(), + padding: uniform(border_w), + ..Default::default() + }) + .fill(border_col) + .on_click(on_focus(id)) + .children(vec![inner]) + } + Layout::Split { + axis, + ratio, + first, + second, + } => { + let flex_dir = match axis { + Axis::Horizontal => FlexDirection::Row, + Axis::Vertical => FlexDirection::Column, + }; + + let mut p1 = path.clone(); + p1.push(Side::First); + let mut p2 = path.clone(); + p2.push(Side::Second); + + let a = render(first, focused, leaf, on_resize, on_focus, p1, palette); + let b = render(second, focused, leaf, on_resize, on_focus, p2, palette); + + let pane_a = grow_pane(a, *ratio); + let pane_b = grow_pane(b, 1.0 - *ratio); + let divider = divider_view(*axis, palette, on_resize.clone(), path.clone()); + + View::new(Style { + flex_direction: flex_dir, + size: full(), + min_size: zero(), + ..Default::default() + }) + .children(vec![pane_a, divider, pane_b]) + } + } +} + +fn grow_pane(view: View, grow: f32) -> View +where + Msg: Clone + Send + Sync + 'static, +{ + View::new(Style { + flex_grow: grow.max(0.01), + flex_shrink: 1.0, + flex_basis: length(0.0), + size: full(), + min_size: zero(), + ..Default::default() + }) + .children(vec![view]) +} + +fn divider_view( + axis: Axis, + palette: &PanesPalette, + on_resize: Arc, DragPhase, f32) -> Option + Send + Sync>, + path: Vec, +) -> View +where + Msg: Clone + Send + Sync + 'static, +{ + let (width, height) = match axis { + Axis::Horizontal => (length(palette.thickness), percent(1.0_f32)), + Axis::Vertical => (percent(1.0_f32), length(palette.thickness)), + }; + View::new(Style { + size: Size { width, height }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.divider) + .hover_fill(palette.divider_hover) + .draggable(move |phase, dx, dy| { + let main = match axis { + Axis::Horizontal => dx, + Axis::Vertical => dy, + }; + (on_resize)(path.clone(), phase, main * RESIZE_SENSITIVITY) + }) +} + +fn full() -> Size { + Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + } +} + +fn zero() -> Size { + Size { + width: length(0.0_f32), + height: length(0.0_f32), + } +} + +fn uniform(px: f32) -> Rect { + Rect { + left: length(px), + right: length(px), + top: length(px), + bottom: length(px), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn single_has_one_leaf() { + let l = Layout::single(1); + assert_eq!(l.count(), 1); + assert_eq!(l.leaves(), vec![1]); + assert_eq!(l.first_leaf(), 1); + } + + #[test] + fn split_creates_two_leaves() { + let mut l = Layout::single(1); + assert!(l.split(1, 2, Axis::Horizontal)); + assert_eq!(l.count(), 2); + assert_eq!(l.leaves(), vec![1, 2]); + assert!(l.contains(2)); + } + + #[test] + fn split_missing_target_is_noop() { + let mut l = Layout::single(1); + assert!(!l.split(99, 2, Axis::Vertical)); + assert_eq!(l.count(), 1); + } + + #[test] + fn nested_split_then_close_collapses() { + let mut l = Layout::single(1); + l.split(1, 2, Axis::Horizontal); + l.split(2, 3, Axis::Vertical); // 2 se parte en [2 / 3] + assert_eq!(l.leaves(), vec![1, 2, 3]); + + let (l, removed) = l.without(3); + assert!(removed); + assert_eq!(l.leaves(), vec![1, 2]); + + let (l, removed) = l.without(1); + assert!(removed); + assert_eq!(l.leaves(), vec![2]); + + let (l, removed) = l.without(2); + assert!(!removed); + assert_eq!(l.leaves(), vec![2]); + } + + #[test] + fn resize_adjusts_ratio_with_clamp() { + let mut l = Layout::single(1); + l.split(1, 2, Axis::Horizontal); + l.resize(&[], 0.2); + if let Layout::Split { ratio, .. } = &l { + assert!((ratio - 0.7).abs() < 1e-6); + } else { + panic!("esperaba split"); + } + l.resize(&[], -10.0); + if let Layout::Split { ratio, .. } = &l { + assert!((ratio - 0.05).abs() < 1e-6); + } + } + + #[test] + fn resize_nested_path() { + let mut l = Layout::single(1); + l.split(1, 2, Axis::Horizontal); + l.split(2, 3, Axis::Vertical); + l.resize(&[Side::Second], 0.1); + if let Layout::Split { second, .. } = &l { + if let Layout::Split { ratio, .. } = second.as_ref() { + assert!((ratio - 0.6).abs() < 1e-6); + return; + } + } + panic!("estructura inesperada"); + } +} diff --git a/widgets/progress/Cargo.toml b/widgets/progress/Cargo.toml new file mode 100644 index 0000000..db8b579 --- /dev/null +++ b/widgets/progress/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-progress" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-progress — barras de progreso lineales y radiales determinadas (0.0-1.0). Para indeterminadas usar llimphi-widget-spinner." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/progress/src/lib.rs b/widgets/progress/src/lib.rs new file mode 100644 index 0000000..7858be9 --- /dev/null +++ b/widgets/progress/src/lib.rs @@ -0,0 +1,126 @@ +//! `llimphi-widget-progress` — progreso determinado, lineal o radial. +//! +//! Determinado = la app conoce el porcentaje (`0.0..=1.0`). Para +//! progreso indeterminado (la op está corriendo, no sé cuánto falta), +//! usar `llimphi-widget-spinner`. +//! +//! Dos formas: +//! - [`linear_progress_view`] — barra horizontal con relleno proporcional. +//! - [`radial_progress_view`] — anillo cuya porción llena indica el avance. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Position, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::kurbo::{Affine, Arc, Cap, Stroke}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::View; +use llimphi_theme::radius; + +/// Barra horizontal: una pista (`track`) con un fill proporcional al +/// `progress` (0.0..=1.0) pintado encima. +pub fn linear_progress_view( + progress: f32, + track_color: Color, + fill_color: Color, + height_px: f32, +) -> View { + let p = progress.clamp(0.0, 1.0); + let fill_radius = radius::XS; + let fill = View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0_f32), + top: length(0.0_f32), + right: llimphi_ui::llimphi_layout::taffy::prelude::auto(), + bottom: length(0.0_f32), + }, + size: Size { + width: percent(p), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(fill_color) + .radius(fill_radius) + .paint_with(move |scene, _ts, rect| { + // Gloss superior sobre la porción rellena — la barra deja de + // leerse como un rect plano y se siente como una luz que avanza. + // Mismo patrón que button/badge (P6). + use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect}; + use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient}; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let x0 = rect.x as f64; + let y0 = rect.y as f64; + let x1 = (rect.x + rect.w) as f64; + let y1 = (rect.y + rect.h) as f64; + let y_mid = y0 + (y1 - y0) * 0.5; + let rr = RoundedRect::new(x0, y0, x1, y1, fill_radius); + let top = Color::from_rgba8(255, 255, 255, 50); + let bot = Color::from_rgba8(255, 255, 255, 0); + let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid)) + .with_stops([top, bot].as_slice()); + scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr); + }); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(height_px), + }, + ..Default::default() + }) + .fill(track_color) + .radius(radius::XS) + .children(vec![fill]) +} + +/// Anillo cuya porción angular llena indica el avance. Empieza desde +/// arriba (12 en punto) y gira en sentido horario, igual que la +/// convención de relojes y muchos progress radiales. +pub fn radial_progress_view( + progress: f32, + track_color: Color, + fill_color: Color, + stroke_width_ratio: f32, +) -> View { + let p = progress.clamp(0.0, 1.0); + let sw = stroke_width_ratio; + View::new(Style { + position: Position::Absolute, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| { + let side = rect.w.min(rect.h) as f64; + if side <= 0.0 { + return; + } + let cx = rect.x as f64 + rect.w as f64 * 0.5; + let cy = rect.y as f64 + rect.h as f64 * 0.5; + let stroke_w = (side * sw as f64).max(1.0); + let radius = (side - stroke_w) * 0.5; + let stroke = Stroke::new(stroke_w).with_caps(Cap::Round); + + // Track completo (anillo gris). + let track = Arc::new((cx, cy), (radius, radius), 0.0, std::f64::consts::TAU, 0.0); + scene.stroke(&stroke, Affine::IDENTITY, track_color, None, &track); + + // Arco lleno — arranca en -π/2 (12 en punto) y barre `p * 2π` + // en sentido horario (positivo en el sistema y-down de vello). + if p > 0.0 { + let theta0 = -std::f64::consts::FRAC_PI_2; + let sweep = std::f64::consts::TAU * p as f64; + let fill_arc = Arc::new((cx, cy), (radius, radius), theta0, sweep, 0.0); + scene.stroke(&stroke, Affine::IDENTITY, fill_color, None, &fill_arc); + } + }) +} diff --git a/widgets/scroll/Cargo.toml b/widgets/scroll/Cargo.toml new file mode 100644 index 0000000..1aea1a6 --- /dev/null +++ b/widgets/scroll/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-scroll" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-scroll — área de scroll vertical reutilizable: viewport clipeado + contenido desplazado + barra arrastrable. Stateless (el offset vive en el Model); rueda autocontenida vía View::on_scroll. Helpers puros: clamp_offset, ensure_visible, approach (scroll suave)." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/scroll/src/lib.rs b/widgets/scroll/src/lib.rs new file mode 100644 index 0000000..9e054b1 --- /dev/null +++ b/widgets/scroll/src/lib.rs @@ -0,0 +1,326 @@ +//! `llimphi-widget-scroll` — área de scroll vertical reutilizable. +//! +//! Hasta ahora cada app rearmaba el scroll a mano: `App::on_wheel` + un +//! offset en el `Model` + `clip` + virtualización. Este widget empaqueta +//! ese patrón en un solo builder, **sin estado propio** (el offset sigue +//! viviendo en el `Model`, fiel al bucle Elm): +//! +//! - **viewport clipeado** de alto fijo (`viewport_len`), +//! - **contenido desplazado** `-offset` px (overflow recortado), +//! - **barra de scroll arrastrable** a la derecha (sólo si el contenido +//! excede el viewport), +//! - **rueda autocontenida** vía [`View::on_scroll`]: girar la rueda con +//! el cursor sobre el área emite un `Msg` sin que la app rutee nada por +//! su `on_wheel` global. +//! +//! El caller debe conocer el **alto total del contenido** (`content_len`) +//! y el **alto visible** (`viewport_len`) — igual que `list`/`grid` ya +//! piden la ventana visible. Para contenido de filas uniformes es +//! `n_filas * alto_fila`. +//! +//! ## Convención del callback `on_scroll` +//! +//! `on_scroll` recibe el **delta en px** a sumar al offset (no el offset +//! absoluto): tanto la rueda como el arrastre de la barra emiten deltas, +//! y el caller acumula + clampea en su `update` con [`clamp_offset`]. Es +//! la misma idea que el `splitter` (el handler de drag se reusa durante +//! todo el arrastre, así que un offset absoluto capturado se quedaría +//! viejo; el delta-por-evento siempre es correcto). +//! +//! ```ignore +//! // view: +//! scroll_y( +//! model.offset, +//! model.rows.len() as f32 * ROW_H, +//! panel_h, +//! lista_view, +//! Msg::ScrollBy, // Fn(f32) -> Msg, arg = delta px +//! &ScrollPalette::default(), +//! ) +//! // update: +//! Msg::ScrollBy(d) => { +//! m.offset = clamp_offset(m.offset + d, content_len, viewport_len); +//! } +//! ``` +//! +//! Para llevar una selección a la vista (teclado), ver [`ensure_visible`]; +//! para scroll suave/inercia, ver [`approach`]. + +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, percent, Position, Rect, Size, Style}, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::{DragPhase, View}; + +/// Alto mínimo del thumb en px — para que no desaparezca con contenido +/// muy largo. +const MIN_THUMB: f32 = 28.0; +/// Px de desplazamiento por "línea" de rueda. Aproxima el step de scroll +/// de un editor (≈3 líneas de texto). +pub const DEFAULT_LINE_PX: f32 = 48.0; +/// Ancho de la barra de scroll en px. +pub const DEFAULT_BAR_WIDTH: f32 = 10.0; + +/// Colores de la barra de scroll. +#[derive(Debug, Clone, Copy)] +pub struct ScrollPalette { + /// Canal de fondo (track). + pub track: Color, + /// Pulgar (thumb) en reposo. + pub thumb: Color, + /// Pulgar al pasar el cursor. + pub thumb_hover: Color, + /// Ancho de la barra y px por línea de rueda. + pub bar_width: f32, + pub line_px: f32, +} + +impl Default for ScrollPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl ScrollPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + track: t.bg_panel_alt, + thumb: t.border, + thumb_hover: t.accent, + bar_width: DEFAULT_BAR_WIDTH, + line_px: DEFAULT_LINE_PX, + } + } +} + +/// Máximo offset posible: cuánto se puede desplazar antes de que el final +/// del contenido toque el borde inferior del viewport. `0` si el contenido +/// entra entero. +pub fn max_offset(content_len: f32, viewport_len: f32) -> f32 { + (content_len - viewport_len).max(0.0) +} + +/// Acota `offset` a `[0, max_offset]`. El caller lo usa en su `update` +/// tras sumar el delta de [`scroll_y`]. +pub fn clamp_offset(offset: f32, content_len: f32, viewport_len: f32) -> f32 { + offset.clamp(0.0, max_offset(content_len, viewport_len)) +} + +/// Devuelve el offset que deja **visible** el intervalo vertical +/// `[item_top, item_top + item_h]` dentro de un viewport de alto +/// `viewport_len`, partiendo de `offset`. Si ya está visible, lo devuelve +/// sin cambios. Pensado para "llevar la selección a la vista" al navegar +/// con teclado (flechas, Page Up/Down). El resultado se acota a `≥ 0`; el +/// caller puede clampear arriba con [`clamp_offset`] si lo necesita. +pub fn ensure_visible(offset: f32, viewport_len: f32, item_top: f32, item_h: f32) -> f32 { + if item_top < offset { + // El item arranca por encima del viewport: subí hasta su tope. + item_top.max(0.0) + } else if item_top + item_h > offset + viewport_len { + // El item termina por debajo: bajá hasta que su fondo toque el borde. + (item_top + item_h - viewport_len).max(0.0) + } else { + offset + } +} + +/// Un paso de aproximación exponencial de `current` hacia `target` +/// (scroll suave / inercia). `factor ∈ (0, 1]`: 1.0 salta de una, 0.2 +/// desliza suave. Cuando la diferencia cae por debajo de 0.5 px aterriza +/// exacto en `target` (evita el "casi-llega" infinito). El caller lo +/// dispara por frame vía `Handle::spawn_periodic` guardando `target` en +/// su `Model`. +pub fn approach(current: f32, target: f32, factor: f32) -> f32 { + let f = factor.clamp(0.0, 1.0); + let next = current + (target - current) * f; + if (target - next).abs() < 0.5 { + target + } else { + next + } +} + +/// Geometría del thumb: `(altura, posición_y)` dentro del track de alto +/// `viewport_len`, y `offset_por_px` (cuánto offset de contenido equivale +/// a 1 px de arrastre del thumb). Público para tests y para callers que +/// quieran pintar su propia barra. +pub fn thumb_geometry(offset: f32, content_len: f32, viewport_len: f32) -> (f32, f32, f32) { + let max_off = max_offset(content_len, viewport_len); + if max_off <= 0.0 || content_len <= 0.0 { + return (viewport_len, 0.0, 0.0); + } + let ratio = (viewport_len / content_len).clamp(0.0, 1.0); + let thumb_h = (viewport_len * ratio).clamp(MIN_THUMB.min(viewport_len), viewport_len); + let travel = (viewport_len - thumb_h).max(0.0); + let thumb_y = if max_off > 0.0 { + (offset / max_off).clamp(0.0, 1.0) * travel + } else { + 0.0 + }; + let offset_per_px = if travel > 0.0 { max_off / travel } else { 0.0 }; + (thumb_h, thumb_y, offset_per_px) +} + +/// Área de scroll vertical. `offset` es el desplazamiento actual (px, ya +/// clampeado por el caller). `content_len`/`viewport_len` el alto total y +/// visible. `content` se desplaza `-offset` y se recorta al viewport. +/// `on_scroll(delta_px)` se invoca con el delta a sumar al offset (rueda +/// y arrastre de barra); el caller acumula con [`clamp_offset`]. +pub fn scroll_y( + offset: f32, + content_len: f32, + viewport_len: f32, + content: View, + on_scroll: F, + palette: &ScrollPalette, +) -> View +where + // `Msg` no necesita `Send + Sync`: los closures de rueda/arrastre + // capturan el `Arc`, no un `Msg`. Sólo se exige + // `Clone` (para montar el `View`) y `'static`. + Msg: Clone + 'static, + F: Fn(f32) -> Msg + Send + Sync + 'static, +{ + let on_scroll = Arc::new(on_scroll); + + // Contenido desplazado: nodo absoluto anclado a left/right (toma el + // ancho del viewport) con top = -offset y alto natural. El overflow se + // recorta por el `clip` del viewport. + let content_wrap = View::new(Style { + position: Position::Absolute, + inset: Rect { + top: length(-offset), + left: length(0.0), + right: length(0.0), + bottom: auto(), + }, + ..Default::default() + }) + .children(vec![content]); + + let mut children = vec![content_wrap]; + + // Barra: sólo si hay overflow. + if max_offset(content_len, viewport_len) > 0.0 { + let (thumb_h, thumb_y, offset_per_px) = + thumb_geometry(offset, content_len, viewport_len); + + let on_thumb = on_scroll.clone(); + let thumb = View::new(Style { + position: Position::Absolute, + inset: Rect { + top: length(thumb_y), + right: length(0.0), + left: auto(), + bottom: auto(), + }, + size: Size { + width: length(palette.bar_width), + height: length(thumb_h), + }, + ..Default::default() + }) + .fill(palette.thumb) + .hover_fill(palette.thumb_hover) + .radius((palette.bar_width * 0.5) as f64) + .draggable(move |phase, _dx, dy| match phase { + // Cada Move trae el delta de px de pantalla del thumb; lo + // convertimos a delta de offset de contenido. + DragPhase::Move => Some((on_thumb)(dy * offset_per_px)), + DragPhase::End => None, + }); + + let track = View::new(Style { + position: Position::Absolute, + inset: Rect { + top: length(0.0), + right: length(0.0), + bottom: length(0.0), + left: auto(), + }, + size: Size { + width: length(palette.bar_width), + height: auto(), + }, + ..Default::default() + }) + .fill(palette.track) + .children(vec![thumb]); + + children.push(track); + } + + // Viewport: alto fijo, ancho del padre, contenido recortado, rueda + // local. Position::Relative para ser el bloque contenedor de los + // hijos absolutos. + let line_px = palette.line_px; + let on_wheel = on_scroll; + View::new(Style { + position: Position::Relative, + size: Size { + width: percent(1.0), + height: length(viewport_len), + }, + ..Default::default() + }) + .clip(true) + .on_scroll(move |_dx, dy| Some((on_wheel)(dy * line_px))) + .children(children) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn max_y_clamp() { + assert_eq!(max_offset(1000.0, 300.0), 700.0); + assert_eq!(max_offset(200.0, 300.0), 0.0); // entra entero + assert_eq!(clamp_offset(-50.0, 1000.0, 300.0), 0.0); + assert_eq!(clamp_offset(9999.0, 1000.0, 300.0), 700.0); + assert_eq!(clamp_offset(400.0, 1000.0, 300.0), 400.0); + } + + #[test] + fn ensure_visible_arriba_abajo_y_sin_cambio() { + let vp = 300.0; + // Item por encima del offset → subir hasta su tope. + assert_eq!(ensure_visible(500.0, vp, 100.0, 20.0), 100.0); + // Item por debajo del fondo visible → bajar lo justo. + assert_eq!(ensure_visible(0.0, vp, 400.0, 20.0), 120.0); // 400+20-300 + // Item ya visible → sin cambios. + assert_eq!(ensure_visible(50.0, vp, 100.0, 20.0), 50.0); + // Nunca negativo. + assert_eq!(ensure_visible(50.0, vp, -10.0, 20.0), 0.0); + } + + #[test] + fn approach_aterriza_exacto() { + // Se acerca pero no salta. + let a = approach(0.0, 100.0, 0.25); + assert!(a > 0.0 && a < 100.0); + // Diferencia < 0.5 px → aterriza exacto. + assert_eq!(approach(99.8, 100.0, 0.25), 100.0); + // factor 1.0 salta de una. + assert_eq!(approach(0.0, 100.0, 1.0), 100.0); + } + + #[test] + fn thumb_proporcional_y_topes() { + // Contenido entra entero → thumb cubre todo, sin travel. + let (h, y, opp) = thumb_geometry(0.0, 200.0, 300.0); + assert_eq!((h, y, opp), (300.0, 0.0, 0.0)); + // Contenido 3× viewport → thumb ≈ 1/3 (clampeado a MIN_THUMB). + let (h, y, _) = thumb_geometry(0.0, 900.0, 300.0); + assert!((h - 100.0).abs() < 0.01); + assert_eq!(y, 0.0); + // En el máximo offset, el thumb toca el fondo del track. + let max = max_offset(900.0, 300.0); + let (h2, y2, _) = thumb_geometry(max, 900.0, 300.0); + assert!((y2 + h2 - 300.0).abs() < 0.01); + } +} diff --git a/widgets/segmented/Cargo.toml b/widgets/segmented/Cargo.toml new file mode 100644 index 0000000..a6b0efe --- /dev/null +++ b/widgets/segmented/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-segmented" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-segmented — control de opciones mutuamente exclusivas (radio horizontal). Para 2-5 opciones en línea." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/segmented/src/lib.rs b/widgets/segmented/src/lib.rs new file mode 100644 index 0000000..ddbe843 --- /dev/null +++ b/widgets/segmented/src/lib.rs @@ -0,0 +1,143 @@ +//! `llimphi-widget-segmented` — control de opciones mutuamente exclusivas. +//! +//! N opciones horizontales con UNA activa. Patrón iOS/macOS para +//! alternativas radio-style cuando son pocas (2-5) y caben en línea. +//! Si son más, usar un `tabs` o un dropdown. +//! +//! Render-only: la app guarda `selected: usize` en el modelo y +//! dispatcha `Msg::SelectSegment(usize)` al click. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_theme::{radius, Theme}; + +/// Paleta del control. +#[derive(Debug, Clone, Copy)] +pub struct SegmentedPalette { + pub bg_track: Color, + pub bg_active: Color, + pub fg_active: Color, + pub fg_inactive: Color, + pub fg_hover: Color, +} + +impl SegmentedPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + bg_track: t.bg_button, + bg_active: t.bg_panel, + fg_active: t.fg_text, + fg_inactive: t.fg_muted, + fg_hover: t.fg_text, + } + } +} + +/// Construye el control. `labels` son los textos visibles; `selected` +/// es el índice activo (0-based). `make_msg(i)` se llama al click. +pub fn segmented_view( + labels: &[&str], + selected: usize, + make_msg: F, + palette: &SegmentedPalette, +) -> View +where + Msg: Clone + 'static, + F: Fn(usize) -> Msg, +{ + let children: Vec> = labels + .iter() + .enumerate() + .map(|(i, label)| segment_view(i, label, i == selected, make_msg(i), palette)) + .collect(); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(28.0_f32), + }, + padding: Rect { + left: length(2.0_f32), + right: length(2.0_f32), + top: length(2.0_f32), + bottom: length(2.0_f32), + }, + gap: Size { + width: length(2.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .fill(palette.bg_track) + .radius(radius::SM) + .children(children) +} + +fn segment_view( + _idx: usize, + label: &str, + is_active: bool, + msg: Msg, + palette: &SegmentedPalette, +) -> View { + let (bg, fg) = if is_active { + (Some(palette.bg_active), palette.fg_active) + } else { + (None, palette.fg_inactive) + }; + + let seg_radius = radius::XS; + let mut node = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .radius(seg_radius) + .text_aligned(label.to_string(), 11.5, fg, Alignment::Center) + .on_click(msg); + + if let Some(c) = bg { + node = node.fill(c).paint_with(move |scene, _ts, rect| { + // Gloss superior sólo en el segmento activo — refuerza + // "esto está seleccionado" con la misma firma de button (P6). + // Los segmentos inactivos quedan planos para que el contraste + // sea inequívoco. + use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect}; + use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient}; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let x0 = rect.x as f64; + let y0 = rect.y as f64; + let x1 = (rect.x + rect.w) as f64; + let y1 = (rect.y + rect.h) as f64; + let y_mid = y0 + (y1 - y0) * 0.5; + let rr = RoundedRect::new(x0, y0, x1, y1, seg_radius); + let top = Color::from_rgba8(255, 255, 255, 28); + let bot = Color::from_rgba8(255, 255, 255, 0); + let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid)) + .with_stops([top, bot].as_slice()); + scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr); + }); + } + node +} diff --git a/widgets/shortcuts-help/Cargo.toml b/widgets/shortcuts-help/Cargo.toml new file mode 100644 index 0000000..fe41b1f --- /dev/null +++ b/widgets/shortcuts-help/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-shortcuts-help" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-shortcuts-help — overlay '?' que muestra los atajos de teclado del contexto actual, agrupados por categoría." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-panel = { workspace = true } diff --git a/widgets/shortcuts-help/src/lib.rs b/widgets/shortcuts-help/src/lib.rs new file mode 100644 index 0000000..6471f03 --- /dev/null +++ b/widgets/shortcuts-help/src/lib.rs @@ -0,0 +1,282 @@ +//! `llimphi-widget-shortcuts-help` — overlay de atajos de teclado. +//! +//! Convención "press ? for help": cuando el usuario aprieta `?`, +//! aparece un panel centrado con todos los atajos del contexto actual +//! agrupados por categoría. Cualquier tecla cierra (la app maneja eso). +//! +//! La app construye un `ShortcutsHelpSpec` con grupos y entries, lo +//! guarda en su modelo cuando se abre, y lo devuelve desde +//! `view_overlay`. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, percent, FlexDirection, Position, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_theme::{alpha, radius, Theme}; +use llimphi_widget_panel::{panel_signature_painter, PanelStyle}; + +/// Paleta del overlay. +#[derive(Debug, Clone, Copy)] +pub struct ShortcutsHelpPalette { + pub scrim: Color, + /// Firma del panel (gradient + hairline accent en top edge). + pub panel: PanelStyle, + pub border: Color, + pub fg_title: Color, + pub fg_group: Color, + pub fg_desc: Color, + pub fg_key: Color, + pub bg_key: Color, +} + +impl ShortcutsHelpPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + scrim: Color::from_rgba8(0, 0, 0, alpha::SCRIM), + panel: PanelStyle::from_theme_large(t), + border: t.border, + fg_title: t.fg_text, + fg_group: t.accent, + fg_desc: t.fg_text, + fg_key: t.fg_text, + bg_key: t.bg_button, + } + } +} + +/// Una entrada de atajo: combinación de teclas + descripción de qué hace. +#[derive(Debug, Clone)] +pub struct ShortcutEntry { + /// La combinación tal como aparece (ej. `"Ctrl+S"`, `"⌘K ⌘P"`, `"?"`). + pub keys: String, + pub description: String, +} + +impl ShortcutEntry { + pub fn new(keys: impl Into, description: impl Into) -> Self { + Self { keys: keys.into(), description: description.into() } + } +} + +/// Grupo de atajos con un título (ej. "Edición", "Navegación"). +#[derive(Debug, Clone)] +pub struct ShortcutGroup { + pub title: String, + pub entries: Vec, +} + +impl ShortcutGroup { + pub fn new(title: impl Into, entries: Vec) -> Self { + Self { title: title.into(), entries } + } +} + +/// Spec completo del overlay. +pub struct ShortcutsHelpSpec { + pub title: String, + pub groups: Vec, + pub viewport: (f32, f32), + pub on_dismiss: Msg, + pub palette: ShortcutsHelpPalette, +} + +const PANEL_W: f32 = 480.0; +const TITLE_FONT: f32 = 16.0; +const GROUP_FONT: f32 = 11.5; +const ENTRY_FONT: f32 = 12.0; +const ENTRY_H: f32 = 22.0; +const GROUP_H: f32 = 24.0; +const TITLE_H: f32 = 40.0; +const PAD: f32 = 20.0; + +pub fn shortcuts_help_view(spec: ShortcutsHelpSpec) -> View { + let ShortcutsHelpSpec { title, groups, viewport, on_dismiss, palette } = spec; + + // Altura del panel — suma de header + grupos. + let body_h: f32 = groups + .iter() + .map(|g| GROUP_H + g.entries.len() as f32 * ENTRY_H + 8.0) + .sum(); + let panel_h = (TITLE_H + body_h + PAD * 2.0).min(viewport.1 - 32.0); + let panel_w = PANEL_W.min(viewport.0 - 32.0); + let x = ((viewport.0 - panel_w) * 0.5).max(0.0); + let y = ((viewport.1 - panel_h) * 0.5).max(0.0); + + let header = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(TITLE_H), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(title, TITLE_FONT, palette.fg_title, Alignment::Start); + + let mut body_children: Vec> = Vec::with_capacity(groups.len() * 6); + for group in &groups { + body_children.push(group_header_view(&group.title, &palette)); + for entry in &group.entries { + body_children.push(entry_view(entry, &palette)); + } + } + let body = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: auto(), + }, + flex_grow: 1.0, + ..Default::default() + }) + .children(body_children); + + let panel = View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(x), + top: length(y), + right: auto(), + bottom: auto(), + }, + size: Size { + width: length(panel_w), + height: length(panel_h), + }, + flex_direction: FlexDirection::Column, + padding: Rect { + left: length(PAD), + right: length(PAD), + top: length(PAD), + bottom: length(PAD), + }, + ..Default::default() + }) + .paint_with(panel_signature_painter(palette.panel)) + .radius(palette.panel.radius) + .clip(true) + .children(vec![header, body]); + + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(palette.scrim) + .on_click(on_dismiss) + .children(vec![panel]) +} + +fn group_header_view( + title: &str, + palette: &ShortcutsHelpPalette, +) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(GROUP_H), + }, + padding: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(8.0_f32), + bottom: length(2.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned( + title.to_uppercase(), + GROUP_FONT, + palette.fg_group, + Alignment::Start, + ) +} + +fn entry_view( + entry: &ShortcutEntry, + palette: &ShortcutsHelpPalette, +) -> View { + let desc = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned( + entry.description.clone(), + ENTRY_FONT, + palette.fg_desc, + Alignment::Start, + ); + + let key_radius = radius::XS; + let keys = View::new(Style { + size: Size { + width: length(140.0_f32), + height: length(ENTRY_H - 6.0), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::FlexEnd), + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.bg_key) + .radius(key_radius) + .paint_with(move |scene, _ts, rect| { + // Gloss superior — el chip de teclado se lee como tecla con + // luz cayendo desde el top, no como rect plano. Mismo patrón + // que button (P6) — todo chip clicable o tipo-tecla comparte + // la firma. + use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect}; + use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient}; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let x0 = rect.x as f64; + let y0 = rect.y as f64; + let x1 = (rect.x + rect.w) as f64; + let y1 = (rect.y + rect.h) as f64; + let y_mid = y0 + (y1 - y0) * 0.5; + let rr = RoundedRect::new(x0, y0, x1, y1, key_radius); + let top = Color::from_rgba8(255, 255, 255, 28); + let bot = Color::from_rgba8(255, 255, 255, 0); + let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid)) + .with_stops([top, bot].as_slice()); + scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr); + }) + .text_aligned(entry.keys.clone(), ENTRY_FONT - 1.0, palette.fg_key, Alignment::End); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(ENTRY_H), + }, + align_items: Some(AlignItems::Center), + gap: Size { + width: length(10.0_f32), + height: length(0.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![desc, keys]) +} diff --git a/widgets/skeleton/Cargo.toml b/widgets/skeleton/Cargo.toml new file mode 100644 index 0000000..95a562b --- /dev/null +++ b/widgets/skeleton/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-skeleton" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-skeleton — bloque animado con shimmer para placeholders de contenido en carga. Alternativa a spinner cuando se conoce la forma del contenido (lista de N items, card con título+texto+imagen)." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/skeleton/src/lib.rs b/widgets/skeleton/src/lib.rs new file mode 100644 index 0000000..1e39743 --- /dev/null +++ b/widgets/skeleton/src/lib.rs @@ -0,0 +1,145 @@ +//! `llimphi-widget-skeleton` — placeholder de carga con shimmer. +//! +//! Cuando una pantalla está cargando contenido cuya forma es predecible +//! (ej. una lista de 5 cards, un avatar+nombre+timestamp), un skeleton +//! es más informativo que un spinner: el usuario ya ve QUÉ vendrá, +//! sólo no tiene los valores reales todavía. +//! +//! El brillo (shimmer) viene de una **banda de gradiente que cruza** el +//! rect de izquierda a derecha cíclicamente. Los stops son +//! `[low, high, low]` sobre una franja del ~50% del ancho, con `Extend::Pad` +//! por default — fuera de la banda el rect queda en `low`, dentro el +//! `high` pinta el destello. Es el patrón canónico de Material/Apple/ +//! sistemas modernos, más legible que la oscilación uniforme previa. +//! +//! Como `spinner`, requiere que la app fuerce redraws periódicos para +//! que la animación corra (típico: `Handle::spawn_periodic(50ms, …)` +//! mientras hay skeletons visibles). + +#![forbid(unsafe_code)] + +use std::time::Instant; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Size, Style}, + Position, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::View; +use llimphi_theme::{radius, Theme}; + +/// Paleta del skeleton — dos tonos entre los que oscila. +#[derive(Debug, Clone, Copy)] +pub struct SkeletonPalette { + pub low: Color, + pub high: Color, +} + +impl SkeletonPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + low: t.bg_panel_alt, + high: t.bg_button_hover, + } + } +} + +/// Período del shimmer en segundos — un ciclo completo de la banda +/// cruzando el rect. 1.4s es el sweet spot: rápido para señalar +/// "esto se está cargando", lento para no marear. +const SHIMMER_CYCLE_SECS: f32 = 1.4; +/// Ancho de la banda como fracción del ancho del rect. 50% da una +/// transición suave; bajar a 30% da un destello más puntual. +const SHIMMER_BAND_FRAC: f64 = 0.5; +/// Ancho mínimo absoluto de la banda — evita que en skeletons cortos +/// (avatares chicos, line skeletons de ~80px) el destello sea un +/// pixel apretado. +const SHIMMER_BAND_MIN_PX: f64 = 40.0; + +/// Bloque rectangular animado. La altura y forma viene del `Style` +/// que pasa el caller — el skeleton sólo aporta el `fill` animado. +/// +/// Devuelve un `View` con `paint_with` que pinta una banda de +/// gradiente atravesando el rect. Para usarlo dentro de un layout +/// con tamaño definido, envolvelo en un contenedor con el `Style` +/// adecuado. +pub fn skeleton_view(palette: &SkeletonPalette) -> View { + let started = Instant::now(); + let p = *palette; + View::new(Style { + position: Position::Absolute, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| { + use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect}; + use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient}; + + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + + // Progress del ciclo en [0, 1). + let elapsed = started.elapsed().as_secs_f32(); + let progress = (elapsed / SHIMMER_CYCLE_SECS).fract() as f64; + + // Banda: ancho relativo al rect (con floor mínimo) que arranca + // a la izquierda del rect y termina a la derecha. Distancia + // total recorrida = rect.w + band_w, así el destello entra y + // sale por completo. + let rect_w = rect.w as f64; + let band_w = (rect_w * SHIMMER_BAND_FRAC).max(SHIMMER_BAND_MIN_PX); + let travel = rect_w + band_w; + let band_left = rect.x as f64 - band_w + progress * travel; + let band_right = band_left + band_w; + let cy = (rect.y + rect.h * 0.5) as f64; + + // Single fill: gradient lineal con stops [low, high, low]. Fuera + // de [band_left, band_right] el Extend::Pad (default de peniko) + // extiende los stops endpoint — ambos `low` — así el resto del + // rect queda en `low` sin necesidad de un fill base separado. + let rr = RoundedRect::new( + rect.x as f64, + rect.y as f64, + (rect.x + rect.w) as f64, + (rect.y + rect.h) as f64, + radius::SM, + ); + let gradient = Gradient::new_linear( + Point::new(band_left, cy), + Point::new(band_right, cy), + ) + .with_stops([p.low, p.high, p.low].as_slice()); + scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &rr); + }) +} + +/// Caja con tamaño explícito (ancho + alto en px) + skeleton adentro. +/// Helper para casos comunes: line skeleton (`skeleton_line_view(160)`). +pub fn skeleton_box_view( + width_px: f32, + height_px: f32, + palette: &SkeletonPalette, +) -> View { + View::new(Style { + size: Size { + width: length(width_px), + height: length(height_px), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![skeleton_view(palette)]) +} + +/// Línea horizontal típica para texto en carga (height fijo ~12px). +pub fn skeleton_line_view( + width_px: f32, + palette: &SkeletonPalette, +) -> View { + skeleton_box_view(width_px, 12.0, palette) +} + diff --git a/widgets/slider/Cargo.toml b/widgets/slider/Cargo.toml new file mode 100644 index 0000000..ba415b4 --- /dev/null +++ b/widgets/slider/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "llimphi-widget-slider" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-slider — slider horizontal con etiqueta + track draggable + valor numérico. El track es un fillbar (sin pulgar): cambia el ancho relleno según la fracción `(value-min)/(max-min)`. El drag emite el delta de valor (no pixels) en cada `Move`, listo para reentrar al update." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } + +[[example]] +name = "slider_demo" +path = "examples/slider_demo.rs" diff --git a/widgets/slider/LEEME.md b/widgets/slider/LEEME.md new file mode 100644 index 0000000..a3ef331 --- /dev/null +++ b/widgets/slider/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-slider + +> Slider con tick marks para [llimphi](../../README.md). + +Horizontal/vertical. Range custom, snap-to-ticks opcional, label de valor en vivo. Continuous y stepped variants. diff --git a/widgets/slider/README.md b/widgets/slider/README.md new file mode 100644 index 0000000..43a2bba --- /dev/null +++ b/widgets/slider/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-slider + +> Slider with tick marks for [llimphi](../../README.md). + +Horizontal/vertical. Custom range, optional snap-to-ticks, live value label. Continuous and stepped variants. diff --git a/widgets/slider/examples/slider_demo.rs b/widgets/slider/examples/slider_demo.rs new file mode 100644 index 0000000..fcd50d1 --- /dev/null +++ b/widgets/slider/examples/slider_demo.rs @@ -0,0 +1,130 @@ +//! Showcase de `llimphi-widget-slider`: tres sliders sobre un Model que +//! acumula deltas en vivo. Corré con: +//! +//! ```text +//! cargo run -p llimphi-widget-slider --example slider_demo +//! ``` + +use llimphi_theme::Theme; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{App, DragPhase, Handle, View}; +use llimphi_widget_slider::{slider_view, SliderPalette}; + +#[derive(Clone, Debug)] +enum Msg { + EditPsique(f32), + EditMateria(f32), + EditPoder(f32), +} + +struct Model { + psique: f32, + materia: f32, + poder: f32, +} + +struct Demo; + +impl App for Demo { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · slider demo" + } + + fn initial_size() -> (u32, u32) { + (520, 280) + } + + fn init(_: &Handle) -> Model { + Model { psique: 0.0, materia: 0.5, poder: -0.25 } + } + + fn update(model: Model, msg: Msg, _: &Handle) -> Model { + let mut m = model; + match msg { + Msg::EditPsique(dv) => m.psique = (m.psique + dv).clamp(-1.0, 1.0), + Msg::EditMateria(dv) => m.materia = (m.materia + dv).clamp(-1.0, 1.0), + Msg::EditPoder(dv) => m.poder = (m.poder + dv).clamp(-1.0, 1.0), + } + m + } + + fn view(model: &Model) -> View { + let theme = Theme::dark(); + let palette = SliderPalette::from_theme(&theme); + + let header = View::new(Style { + size: Size { width: percent(1.0_f32), height: length(28.0_f32) }, + ..Default::default() + }) + .text_aligned( + "ajustá los sliders — el Model acumula deltas en vivo".to_string(), + 13.0, + theme.fg_text, + Alignment::Start, + ); + + let psique = slider_view( + "psique", + model.psique, + -1.0, + 1.0, + &palette, + |phase, dv| match phase { + DragPhase::Move => Some(Msg::EditPsique(dv)), + DragPhase::End => None, + }, + ); + let materia = slider_view( + "materia", + model.materia, + -1.0, + 1.0, + &palette, + |phase, dv| match phase { + DragPhase::Move => Some(Msg::EditMateria(dv)), + DragPhase::End => None, + }, + ); + let poder = slider_view( + "poder", + model.poder, + -1.0, + 1.0, + &palette, + |phase, dv| match phase { + DragPhase::Move => Some(Msg::EditPoder(dv)), + DragPhase::End => None, + }, + ); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, + padding: Rect { + left: length(20.0_f32), + right: length(20.0_f32), + top: length(16.0_f32), + bottom: length(16.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(8.0_f32), + }, + align_items: Some(AlignItems::Stretch), + ..Default::default() + }) + .fill(theme.bg_app) + .children(vec![header, psique, materia, poder]) + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/slider/src/lib.rs b/widgets/slider/src/lib.rs new file mode 100644 index 0000000..5fb6c4b --- /dev/null +++ b/widgets/slider/src/lib.rs @@ -0,0 +1,254 @@ +//! `llimphi-widget-slider` — slider horizontal con label + track + valor. +//! +//! Pattern análogo a `llimphi-widget-splitter`: el widget no mantiene +//! estado. El caller guarda el valor actual en su `Model` y le pasa un +//! handler `Fn(DragPhase, f32) -> Option` que recibe **el delta de +//! valor** (no el delta de pixels) entre eventos consecutivos. El widget +//! traduce internamente `dx_pixels` a `dv` usando `track_width`. +//! +//! Visualmente es un *fillbar*: el track entero es draggable y se rellena +//! una fracción proporcional a `(value - min) / (max - min)`. No hay +//! pulgar separado — el límite entre relleno y vacío es el indicador. +//! +//! Layout fila: +//! +//! ```text +//! [ label_width ] [ ████░░░░░░ ] [ value_width ] +//! "psique" 0.4 / 1.0 " 0.40" +//! ``` +//! +//! Uso típico (sliders sobre `LayerMods` de un Concepto): +//! +//! ```ignore +//! slider_view( +//! "psique", +//! model.selected.mods.psique, +//! -1.0, 1.0, +//! &palette, +//! |phase, dv| match phase { +//! DragPhase::Move => Some(Msg::EditMod(Layer::Psique, dv)), +//! DragPhase::End => None, +//! }, +//! ) +//! ``` + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{DragPhase, View}; + +/// Paleta del slider. Las dimensiones también viajan acá porque definen +/// el layout fila — el caller no toca el `Style` del slider directamente. +#[derive(Debug, Clone, Copy)] +pub struct SliderPalette { + pub track: Color, + pub track_filled: Color, + pub track_hover: Color, + pub fg_label: Color, + pub fg_value: Color, + pub radius: f64, + /// Alto total del widget en pixels. + pub row_height: f32, + /// Ancho fijo del bloque del label (a la izquierda). + pub label_width: f32, + /// Ancho fijo del bloque del valor numérico (a la derecha). + pub value_width: f32, + /// Ancho fijo del track draggable (al medio). Único valor que el + /// widget usa para convertir dx_pixels → dv_value. + pub track_width: f32, + /// Grosor (alto) del track en pixels. + pub track_thickness: f32, +} + +impl Default for SliderPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl SliderPalette { + /// Construye la paleta desde un `Theme` semántico. + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + track: t.bg_button, + track_filled: t.accent, + track_hover: t.bg_button_hover, + fg_label: t.fg_muted, + fg_value: t.fg_text, + radius: 3.0, + row_height: 22.0, + label_width: 80.0, + value_width: 56.0, + track_width: 120.0, + track_thickness: 6.0, + } + } +} + +/// Compone un slider horizontal: label + track-fillbar draggable + valor. +/// +/// `value`, `min`, `max` son sólo para presentación visual y conversión +/// `dx → dv`; el caller mantiene el estado y aplica el delta en su +/// `update`. El handler recibe `(DragPhase, delta_value)`; devolver +/// `None` deja el drag activo sin emitir Msg. +pub fn slider_view( + label: impl Into, + value: f32, + min: f32, + max: f32, + palette: &SliderPalette, + on_change: F, +) -> View +where + Msg: Clone + Send + Sync + 'static, + F: Fn(DragPhase, f32) -> Option + Send + Sync + 'static, +{ + let range = (max - min).max(f32::EPSILON); + let ratio = ((value - min) / range).clamp(0.0, 1.0); + let track_width = palette.track_width.max(1.0); + + // Drag: dx_pixels → dv_value. Escala FIJA (no depende del valor actual). + let span = max - min; + let handler = move |phase: DragPhase, dx: f32, _dy: f32| -> Option { + let dv = dx * span / track_width; + on_change(phase, dv) + }; + + // Bloque del label. + let label_view = View::new(Style { + size: Size { + width: length(palette.label_width), + height: percent(1.0_f32), + }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(label.into(), 12.0, palette.fg_label, Alignment::Start); + + // Track draggable: fill = track bg, hijo = porción rellena (accent). + let filled_radius = palette.radius; + let filled = View::new(Style { + size: Size { + width: percent(ratio), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(palette.track_filled) + .radius(filled_radius) + .paint_with(move |scene, _ts, rect| { + // Gloss superior sobre la stripe accent — la barra se lee como + // luz que avanza, no como rect plano. Mismo patrón button/progress + // (P6/P7). Alpha bajo (40) porque el track es muy delgado (6px + // default) y un sheen fuerte le mete glitter. + use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect}; + use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient}; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let x0 = rect.x as f64; + let y0 = rect.y as f64; + let x1 = (rect.x + rect.w) as f64; + let y1 = (rect.y + rect.h) as f64; + let y_mid = y0 + (y1 - y0) * 0.5; + let rr = RoundedRect::new(x0, y0, x1, y1, filled_radius); + let top = Color::from_rgba8(255, 255, 255, 40); + let bot = Color::from_rgba8(255, 255, 255, 0); + let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid)) + .with_stops([top, bot].as_slice()); + scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr); + }); + + let track = View::new(Style { + size: Size { + width: length(track_width), + height: length(palette.track_thickness), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.track) + .hover_fill(palette.track_hover) + .radius(palette.radius) + .draggable(handler) + .children(vec![filled]); + + // Wrapper del track para centrarlo verticalmente sobre la fila. + let track_cell = View::new(Style { + size: Size { + width: length(track_width), + height: percent(1.0_f32), + }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + padding: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![track]); + + // Bloque del valor. + let value_text = format_value(value); + let value_view = View::new(Style { + size: Size { + width: length(palette.value_width), + height: percent(1.0_f32), + }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(value_text, 12.0, palette.fg_value, Alignment::End); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(palette.row_height), + }, + align_items: Some(AlignItems::Center), + gap: Size { + width: length(8.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![label_view, track_cell, value_view]) +} + +/// Formato uniforme para los valores: 2 decimales con signo explícito si +/// la magnitud es chica, 1 decimal si es grande. Cabe en `value_width: 56`. +fn format_value(v: f32) -> String { + let abs = v.abs(); + if abs >= 1000.0 { + format!("{v:.0}") + } else if abs >= 10.0 { + format!("{v:.1}") + } else { + format!("{v:+.2}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_value_pretty_for_three_regimes() { + assert_eq!(format_value(0.34), "+0.34"); + assert_eq!(format_value(-0.10), "-0.10"); + assert_eq!(format_value(42.5), "42.5"); + assert_eq!(format_value(1234.0), "1234"); + } +} diff --git a/widgets/spinner/Cargo.toml b/widgets/spinner/Cargo.toml new file mode 100644 index 0000000..21b90d2 --- /dev/null +++ b/widgets/spinner/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-spinner" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-spinner — spinner circular animado por reloj absoluto (no requiere ticks del modelo). Stroke gradient circular. Default 24×24 pero escalable." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/spinner/src/lib.rs b/widgets/spinner/src/lib.rs new file mode 100644 index 0000000..9d3c192 --- /dev/null +++ b/widgets/spinner/src/lib.rs @@ -0,0 +1,72 @@ +//! `llimphi-widget-spinner` — spinner circular animado por reloj absoluto. +//! +//! El paint usa `Instant::now()` para calcular el ángulo de rotación, +//! así no hace falta que la app guarde un tween ni dispatchee ticks: +//! cuando llimphi-ui rasterize un frame (porque algo cambió en el +//! modelo o porque la app pidió un repaint), el spinner se ve girando. +//! +//! **Nota**: el spinner sólo se anima si HAY frames. Una app idle no +//! repintará por sí sola — usar `Handle::spawn_periodic(50ms, …)` +//! mientras el spinner esté visible para forzar redraw. O conectar +//! el spinner a un `Tween` y leer su `progress()` desde la `view`. +//! +//! Diseño visual: arco de 270° con stroke variable (más grueso al +//! frente del giro, más fino atrás) para dar sensación de aceleración. + +#![forbid(unsafe_code)] + +use std::time::Instant; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{percent, Size, Style}, + Position, +}; +use llimphi_ui::llimphi_raster::kurbo::{Affine, Arc, Cap, Stroke}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::View; + +/// Construye el `View` que pinta un spinner circular animado dentro +/// del rect del padre. +/// +/// - `color`: tinte del arco (típico: `theme.accent`). +/// - `stroke_width_ratio`: grosor del arco como fracción del lado +/// menor (0.10 = 10%). Default razonable es `0.12`. +/// - `speed_rev_per_sec`: revoluciones por segundo. Default `1.0`. +pub fn spinner_view( + color: Color, + stroke_width_ratio: f32, + speed_rev_per_sec: f32, +) -> View { + // Anchor temporal: arrancamos el reloj al construir el View. Como + // la closure se evalúa por frame, cada repintado calcula `elapsed` + // contra este origen — sin tween, sin model state. + let started = Instant::now(); + let sw = stroke_width_ratio; + let speed = speed_rev_per_sec; + View::new(Style { + position: Position::Absolute, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| { + let side = rect.w.min(rect.h) as f64; + if side <= 0.0 { + return; + } + let cx = rect.x as f64 + rect.w as f64 * 0.5; + let cy = rect.y as f64 + rect.h as f64 * 0.5; + let stroke_w = (side * sw as f64).max(1.0); + let radius = (side - stroke_w) * 0.5; + let elapsed = started.elapsed().as_secs_f64(); + // Ángulo de inicio del arco — gira completamente cada `1/speed` s. + let theta0 = elapsed * speed as f64 * std::f64::consts::TAU; + // Arco de 270° (= 3π/2 rad) — la "abertura" sugiere movimiento. + let sweep = std::f64::consts::PI * 1.5; + let arc = Arc::new((cx, cy), (radius, radius), theta0, sweep, 0.0); + let stroke = Stroke::new(stroke_w).with_caps(Cap::Round); + scene.stroke(&stroke, Affine::IDENTITY, color, None, &arc); + }) +} diff --git a/widgets/splash/Cargo.toml b/widgets/splash/Cargo.toml new file mode 100644 index 0000000..b0bf4b7 --- /dev/null +++ b/widgets/splash/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-splash" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-splash — splash de arranque gioser: cuatro cuadrantes (unanchay/yachay/ruway/ukupacha) animados con tween de entrada secuencial. Identidad visual del SO." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-motion = { workspace = true } diff --git a/widgets/splash/src/lib.rs b/widgets/splash/src/lib.rs new file mode 100644 index 0000000..c73c2b5 --- /dev/null +++ b/widgets/splash/src/lib.rs @@ -0,0 +1,283 @@ +//! `llimphi-widget-splash` — splash de arranque gioser. +//! +//! Identidad visual del SO al boot: cuatro cuadrantes ordenados como +//! una cruz andina, cada uno con su nombre quechua y color simbólico, +//! que **entran en secuencia** con un tween de fade+escala. +//! +//! Los cuadrantes (en orden de entrada): +//! 1. `unanchay` — PERCIBIR — cyan (índigo claro) +//! 2. `yachay` — CONOCER — verde aurora +//! 3. `ruway` — HACER — naranja sunset +//! 4. `ukupacha` — RAÍZ — púrpura profundo +//! +//! Cada cuadrante hace fade-in + slight scale-up, con un offset de +//! `motion::NORMAL / 2` entre uno y el siguiente. La app pasa un +//! `Instant` de inicio y el splash calcula las fases relativas — no +//! requiere ningún tween del modelo. +//! +//! Cuando el splash termina (todos visibles), la app puede: +//! - mantenerlo unos segundos más como pantalla de carga, +//! - hacer un fade-out completo cuando el sistema esté listo, +//! - o reemplazarlo por la UI principal. + +#![forbid(unsafe_code)] + +use std::time::Instant; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_motion::motion; + +/// Datos de un cuadrante: nombre quechua, glosa breve y color. +#[derive(Debug, Clone, Copy)] +pub struct Quadrant { + pub name: &'static str, + pub gloss: &'static str, + pub color: Color, +} + +/// Los cuatro cuadrantes canónicos, en orden de entrada al splash. +pub fn quadrants() -> [Quadrant; 4] { + [ + Quadrant { + name: "unanchay", + gloss: "PERCIBIR", + color: Color::from_rgba8(110, 160, 230, 255), + }, + Quadrant { + name: "yachay", + gloss: "CONOCER", + color: Color::from_rgba8(110, 220, 180, 255), + }, + Quadrant { + name: "ruway", + gloss: "HACER", + color: Color::from_rgba8(232, 160, 90, 255), + }, + Quadrant { + name: "ukupacha", + gloss: "RAÍZ", + color: Color::from_rgba8(160, 110, 220, 255), + }, + ] +} + +/// Construye el splash. `started_at` es el `Instant` de origen — el +/// splash calcula las fases relativas. La app puede llamar `animate(handle, +/// motion::SLOW * 3, …)` para forzar repaints durante la animación. +/// +/// `bg`: color de fondo (típico: `theme.bg_app`). +/// `fg_text`: color del título/glosa. +pub fn splash_view( + started_at: Instant, + bg: Color, + fg_text: Color, +) -> View { + let elapsed = started_at.elapsed().as_secs_f32(); + let stagger = motion::NORMAL.as_secs_f32() * 0.45; + let per_quad = motion::NORMAL.as_secs_f32(); + let quads = quadrants(); + + let cells: Vec> = quads + .iter() + .enumerate() + .map(|(i, q)| { + let local_t = ((elapsed - i as f32 * stagger) / per_quad).clamp(0.0, 1.0); + let eased = motion::ease_out_cubic(local_t); + quadrant_cell(q, eased, fg_text) + }) + .collect(); + + // 2×2 grid: row 0 = unanchay + yachay; row 1 = ruway + ukupacha. + let row = |a: View, b: View| -> View { + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: percent(0.5_f32), + }, + gap: Size { + width: length(12.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![a, b]) + }; + let mut iter = cells.into_iter(); + let r0 = row(iter.next().unwrap(), iter.next().unwrap()); + let r1 = row(iter.next().unwrap(), iter.next().unwrap()); + + let grid = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: length(420.0_f32), + height: length(280.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(12.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![r0, r1]); + + // Título "gioser" debajo, también fade-in pero al final. + let title_t = ((elapsed - 4.0 * stagger) / per_quad).clamp(0.0, 1.0); + let title_alpha = motion::ease_out_cubic(title_t); + let title = View::new(Style { + size: Size { + width: length(420.0_f32), + height: length(32.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned("gioser", 22.0, fg_text, Alignment::Center) + .alpha(title_alpha); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + gap: Size { + width: length(0.0_f32), + height: length(28.0_f32), + }, + padding: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(bg) + .children(vec![grid, title]) +} + +fn quadrant_cell( + quad: &Quadrant, + progress: f32, + fg_text: Color, +) -> View { + // El cuadrante "entra" con fade y un leve drift desde abajo (10px). + // El drift lo representamos con un padding-top que tiende a cero; + // como llimphi no expone translate por nodo (sólo position absolute), + // metemos el contenido en un wrapper con padding decreciente. + let drift = (1.0 - progress) * 10.0; + + let name = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(28.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(quad.name, 16.0, fg_text, Alignment::Center); + + let gloss = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(16.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .text_aligned(quad.gloss, 10.0, quad.color, Alignment::Center); + + let inner = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + gap: Size { + width: length(0.0_f32), + height: length(6.0_f32), + }, + padding: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(drift), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![name, gloss]); + + // Fondo del cuadrante con gradient vertical en el color semántico: + // alpha 50 arriba → alpha 12 abajo. Da volumen al cuadrante (más + // intenso cerca del accent strip del top) y un efecto "halo descendente" + // que ayuda a leer la cruz andina como cuatro luces que emergen del + // centro. Antes: alpha 30 uniforme. + let border = with_alpha8(quad.color, 90); + let bg_top = with_alpha8(quad.color, 50); + let bg_bot = with_alpha8(quad.color, 12); + + let cell_radius = llimphi_theme::radius::MD; + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| { + use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect}; + use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient}; + + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let x0 = rect.x as f64; + let y0 = rect.y as f64; + let x1 = (rect.x + rect.w) as f64; + let y1 = (rect.y + rect.h) as f64; + let rr = RoundedRect::new(x0, y0, x1, y1, cell_radius); + let gradient = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y1)) + .with_stops([bg_top, bg_bot].as_slice()); + scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &rr); + }) + .radius(cell_radius) + .clip(true) + .alpha(progress) + .children(vec![ + // Línea accent superior — 2px del color del cuadrante a alta + // intensidad, ancla del gradiente que cae. + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(2.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(border), + inner, + ]) +} + +fn with_alpha8(c: Color, a: u8) -> Color { + let [r, g, b, _] = c.components; + use llimphi_ui::llimphi_raster::peniko::color::AlphaColor; + AlphaColor::new([r, g, b, a as f32 / 255.0]) +} diff --git a/widgets/splitter/Cargo.toml b/widgets/splitter/Cargo.toml new file mode 100644 index 0000000..af931f0 --- /dev/null +++ b/widgets/splitter/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-splitter" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-splitter — split container con divisor draggable. Análogo Llimphi al `nahual-widget-splitter` GPUI: dos panes, divisor sólido del ancho del thickness configurable, drag emite Msg con el delta del eje principal." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/splitter/LEEME.md b/widgets/splitter/LEEME.md new file mode 100644 index 0000000..bd99b68 --- /dev/null +++ b/widgets/splitter/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-splitter + +> Splitter horizontal/vertical para [llimphi](../../README.md). + +Divide el espacio entre dos hijos con un handle arrastrable. Min/max sizes por hijo; doble-click para reset. diff --git a/widgets/splitter/README.md b/widgets/splitter/README.md new file mode 100644 index 0000000..8140406 --- /dev/null +++ b/widgets/splitter/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-splitter + +> Horizontal/vertical splitter for [llimphi](../../README.md). + +Divides space between two children with a draggable handle. Per-child min/max sizes; double-click to reset. diff --git a/widgets/splitter/examples/splitter_demo.rs b/widgets/splitter/examples/splitter_demo.rs new file mode 100644 index 0000000..45e1133 --- /dev/null +++ b/widgets/splitter/examples/splitter_demo.rs @@ -0,0 +1,126 @@ +//! Showcase de `llimphi-widget-splitter`: dos splits anidados +//! draggables (Row con Column adentro). +//! +//! Corré con: `cargo run -p llimphi-widget-splitter --example showcase --release`. +//! +//! Probá: agarrá el divisor vertical y arrastralo izquierda/derecha +//! para resizar el pane izquierdo; agarrá el divisor horizontal de la +//! derecha para resizar el pane superior derecho. + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{percent, Size, Style}, + AlignItems, JustifyContent, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{App, DragPhase, Handle, View}; +use llimphi_widget_splitter::{splitter_two, Direction, PaneSize, SplitterPalette}; + +#[derive(Clone)] +enum Msg { + ResizeOuter(f32), + ResizeInner(f32), +} + +struct Model { + left_w: f32, + top_h: f32, +} + +struct Showcase; + +impl App for Showcase { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · splitter showcase" + } + + fn initial_size() -> (u32, u32) { + (1100, 720) + } + + fn init(_: &Handle) -> Model { + Model { + left_w: 320.0, + top_h: 240.0, + } + } + + fn update(model: Model, msg: Msg, _: &Handle) -> Model { + let mut m = model; + match msg { + Msg::ResizeOuter(dx) => { + m.left_w = (m.left_w + dx).clamp(120.0, 800.0); + } + Msg::ResizeInner(dy) => { + m.top_h = (m.top_h + dy).clamp(80.0, 600.0); + } + } + m + } + + fn view(model: &Model) -> View { + let palette = SplitterPalette::default(); + + let left = pane("izquierdo", Color::from_rgba8(28, 36, 50, 255)); + let top_right = pane( + &format!("arriba · {:.0} px", model.top_h), + Color::from_rgba8(38, 50, 70, 255), + ); + let bottom_right = pane( + "abajo · flex", + Color::from_rgba8(48, 36, 60, 255), + ); + + let right = splitter_two( + Direction::Column, + top_right, + PaneSize::Fixed(model.top_h), + bottom_right, + PaneSize::Flex, + |phase, dy| match phase { + DragPhase::Move => Some(Msg::ResizeInner(dy)), + DragPhase::End => None, + }, + &palette, + ); + + splitter_two( + Direction::Row, + left, + PaneSize::Fixed(model.left_w), + right, + PaneSize::Flex, + |phase, dx| match phase { + DragPhase::Move => Some(Msg::ResizeOuter(dx)), + DragPhase::End => None, + }, + &palette, + ) + } +} + +fn pane(label: &str, bg: Color) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(bg) + .text_aligned( + label.to_string(), + 18.0, + Color::from_rgba8(220, 230, 240, 255), + Alignment::Center, + ) +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/splitter/src/lib.rs b/widgets/splitter/src/lib.rs new file mode 100644 index 0000000..ee73a92 --- /dev/null +++ b/widgets/splitter/src/lib.rs @@ -0,0 +1,174 @@ +//! `llimphi-widget-splitter` — split container con divisor draggable. +//! +//! Análogo Llimphi al `nahual-widget-splitter` GPUI: dos panes con un +//! divisor entre medio que el usuario arrastra para reasignar el tamaño. +//! El widget no mantiene estado: el caller acumula el tamaño de un pane +//! en su `Model` y le pasa el valor actual + un handler `Fn(DragPhase, +//! f32) -> Option` que materializa el delta en un Msg de update. +//! +//! Uso típico (dos panes, izquierdo fijo y derecho flex): +//! +//! ```ignore +//! splitter_two( +//! Direction::Row, +//! left_view, +//! PaneSize::Fixed(model.left_size), +//! right_view, +//! PaneSize::Flex, +//! |phase, dx| match phase { +//! DragPhase::Move => Some(Msg::ResizeLeft(dx)), +//! DragPhase::End => Some(Msg::PersistLayout), +//! }, +//! &SplitterPalette::default(), +//! ) +//! ``` + +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Dimension, FlexDirection, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::{DragPhase, View}; + +/// Dirección del split. `Row` apila los panes horizontalmente +/// (divisor vertical, drag horizontal); `Column` los apila verticalmente. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + Row, + Column, +} + +/// Tamaño de un pane sobre el eje principal del split. +#[derive(Debug, Clone, Copy)] +pub enum PaneSize { + /// Ancho/alto fijo en pixels. El otro pane se ajusta con `flex_grow`. + Fixed(f32), + /// Toma todo el espacio sobrante (`flex_grow = 1`). + Flex, +} + +/// Paleta del divisor. Cambia de color al hover para señalar +/// "agarrame y arrastrá". +#[derive(Debug, Clone, Copy)] +pub struct SplitterPalette { + pub divider: Color, + pub divider_hover: Color, + pub thickness: f32, +} + +impl Default for SplitterPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl SplitterPalette { + /// Construye la paleta desde un `Theme` semántico. + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + divider: t.border, + divider_hover: t.accent, + thickness: 6.0, + } + } +} + +/// Split de dos panes con divisor draggable entre medio. `on_resize` +/// se invoca con el delta del eje principal (positivo → divisor se +/// mueve a la derecha/abajo). +pub fn splitter_two( + direction: Direction, + a: View, + a_size: PaneSize, + b: View, + b_size: PaneSize, + on_resize: F, + palette: &SplitterPalette, +) -> View +where + Msg: Clone + Send + Sync + 'static, + F: Fn(DragPhase, f32) -> Option + Send + Sync + 'static, +{ + let flex_dir = match direction { + Direction::Row => FlexDirection::Row, + Direction::Column => FlexDirection::Column, + }; + + // El divisor sólo necesita Msg en el eje principal — escondemos el + // otro detrás del closure. + let on_resize = Arc::new(on_resize); + let cb_dir = direction; + let cb = on_resize.clone(); + let divider = divider_view::(direction, palette, move |phase, dx, dy| { + let main = match cb_dir { + Direction::Row => dx, + Direction::Column => dy, + }; + (cb)(phase, main) + }); + + let pane_a = wrap_pane(a, direction, a_size); + let pane_b = wrap_pane(b, direction, b_size); + + View::new(Style { + flex_direction: flex_dir, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .children(vec![pane_a, divider, pane_b]) +} + +fn wrap_pane(view: View, direction: Direction, size: PaneSize) -> View { + let (width, height, flex_grow) = match (direction, size) { + (Direction::Row, PaneSize::Fixed(px)) => (length(px), percent(1.0_f32), 0.0), + (Direction::Row, PaneSize::Flex) => (Dimension::auto(), percent(1.0_f32), 1.0), + (Direction::Column, PaneSize::Fixed(px)) => (percent(1.0_f32), length(px), 0.0), + (Direction::Column, PaneSize::Flex) => (percent(1.0_f32), Dimension::auto(), 1.0), + }; + View::new(Style { + size: Size { width, height }, + flex_grow, + flex_shrink: 0.0, + min_size: Size { + width: length(0.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![view]) +} + +fn divider_view( + direction: Direction, + palette: &SplitterPalette, + handler: impl Fn(DragPhase, f32, f32) -> Option + Send + Sync + 'static, +) -> View +where + Msg: Clone + Send + Sync + 'static, +{ + let (width, height) = match direction { + Direction::Row => (length(palette.thickness), percent(1.0_f32)), + Direction::Column => (percent(1.0_f32), length(palette.thickness)), + }; + View::new(Style { + size: Size { width, height }, + flex_shrink: 0.0, + padding: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(palette.divider) + .hover_fill(palette.divider_hover) + .draggable(handler) +} diff --git a/widgets/stat-card/Cargo.toml b/widgets/stat-card/Cargo.toml new file mode 100644 index 0000000..f63af4c --- /dev/null +++ b/widgets/stat-card/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-stat-card" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-stat-card — tarjeta de dashboard con label chico + valor grande + descripción + accent vertical. Análogo Llimphi al `nahual-widget-stat-card` GPUI." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-card = { workspace = true } diff --git a/widgets/stat-card/LEEME.md b/widgets/stat-card/LEEME.md new file mode 100644 index 0000000..4fa05fd --- /dev/null +++ b/widgets/stat-card/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-stat-card + +> Card para métricas para [llimphi](../../README.md). + +Label + valor grande + sub-label + sparkline opcional. Variante `compact` y `wide`. Usado por `cosmos-card`, `chasqui-card`, `arje-card`, etc. diff --git a/widgets/stat-card/README.md b/widgets/stat-card/README.md new file mode 100644 index 0000000..92be197 --- /dev/null +++ b/widgets/stat-card/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-stat-card + +> Card for metrics for [llimphi](../../README.md). + +Label + large value + sub-label + optional sparkline. `compact` and `wide` variants. Used by `cosmos-card`, `chasqui-card`, `arje-card`, etc. diff --git a/widgets/stat-card/src/lib.rs b/widgets/stat-card/src/lib.rs new file mode 100644 index 0000000..557667e --- /dev/null +++ b/widgets/stat-card/src/lib.rs @@ -0,0 +1,144 @@ +//! `llimphi-widget-stat-card` — tarjeta de dashboard con accent. +//! +//! Compone (sobre `llimphi-widget-card`): +//! - **Border-l-4** con un color de accent que el caller decide. +//! - **Label** chico arriba en el color del accent. +//! - **Value** grande (28 px) en el color principal del texto. +//! - **Description** chica en el color tenue. +//! - **Listing opcional** de items recientes con sub-header +//! `"recent (N):"`. +//! +//! Análogo Llimphi al `nahual-widget-stat-card` GPUI. Pensado para +//! dashboards estilo `minga-explorer`, `brahman-broker-explorer`. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_widget_card::{card_view, CardOptions, CardPalette}; + +/// Paleta del stat-card. `accent` se setea por instancia (verde/rojo/ +/// ámbar etc.), los otros vienen del theme. +#[derive(Debug, Clone, Copy)] +pub struct StatCardPalette { + pub bg: Color, + pub fg_text: Color, + pub fg_muted: Color, +} + +impl Default for StatCardPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl StatCardPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg: t.bg_panel, + fg_text: t.fg_text, + fg_muted: t.fg_muted, + } + } +} + +/// Compone un stat-card. +/// +/// - `label`: header chico en color `accent`. +/// - `value`: texto principal grande. +/// - `description`: línea chica tenue debajo del value. +/// - `accent`: color del border-l + del label. +/// - `recent_items`: si no vacío, agrega "recent (N):" + una fila por +/// item. +pub fn stat_card_view( + label: &str, + value: impl Into, + description: &str, + accent: Color, + recent_items: &[String], + palette: &StatCardPalette, +) -> View { + let label_row = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(14.0_f32), + }, + ..Default::default() + }) + .text_aligned(label.to_string(), 11.0, accent, Alignment::Start); + + let value_row = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(36.0_f32), + }, + ..Default::default() + }) + .text_aligned(value.into(), 28.0, palette.fg_text, Alignment::Start); + + let desc_row = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(14.0_f32), + }, + ..Default::default() + }) + .text_aligned( + description.to_string(), + 11.0, + palette.fg_muted, + Alignment::Start, + ); + + let mut children: Vec> = vec![label_row, value_row, desc_row]; + + if !recent_items.is_empty() { + children.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(16.0_f32), + }, + padding: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(6.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .text_aligned( + format!("recent ({}):", recent_items.len()), + 10.0, + palette.fg_muted, + Alignment::Start, + ), + ); + for it in recent_items { + children.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(14.0_f32), + }, + ..Default::default() + }) + .text_aligned(it.clone(), 11.0, palette.fg_text, Alignment::Start), + ); + } + } + + card_view( + children, + CardOptions { + accent: Some(accent), + ..Default::default() + }, + &CardPalette { bg: palette.bg }, + ) +} diff --git a/widgets/status-bar/Cargo.toml b/widgets/status-bar/Cargo.toml new file mode 100644 index 0000000..bcfcee2 --- /dev/null +++ b/widgets/status-bar/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "llimphi-widget-status-bar" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-status-bar — barra inferior con segmentos left/center/right configurables. Cada segmento puede llevar icono opcional y handler de click." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-icons = { workspace = true } +llimphi-widget-panel = { workspace = true } diff --git a/widgets/status-bar/src/lib.rs b/widgets/status-bar/src/lib.rs new file mode 100644 index 0000000..5e9bc53 --- /dev/null +++ b/widgets/status-bar/src/lib.rs @@ -0,0 +1,242 @@ +//! `llimphi-widget-status-bar` — barra de estado inferior. +//! +//! Patrón clásico de IDEs/editores: barra delgada en el borde inferior +//! de la ventana con tres regiones (left/center/right). Cada región +//! tiene N segmentos, cada uno puede llevar icono + texto + handler de +//! click opcional. +//! +//! Útil para mostrar: rama git activa, posición del cursor, tipo de +//! archivo, modo (insert/normal), notificaciones pendientes, etc. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_icons::{icon_view, Icon}; +use llimphi_theme::Theme; +use llimphi_widget_panel::{panel_signature_painter, PanelStyle}; + +/// Paleta de la barra de estado. +#[derive(Debug, Clone, Copy)] +pub struct StatusBarPalette { + pub bg: Color, + pub fg: Color, + pub fg_muted: Color, + pub bg_hover: Color, + pub border: Color, + /// Firma visual de la barra: gradient sutil + hairline accent en su + /// top edge — el hairline funciona como "techo" que separa la barra + /// de la zona de contenido. `None` cae al fill plano + border top + /// del modo previo (back-compat). + pub signature: Option, +} + +impl StatusBarPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + bg: t.bg_panel_alt, + fg: t.fg_text, + fg_muted: t.fg_muted, + bg_hover: t.bg_row_hover, + border: t.border, + signature: Some(PanelStyle { + radius: 0.0, + bg_base: t.bg_panel_alt, + ..PanelStyle::from_theme(t) + }), + } + } +} + +/// Un segmento de la barra. `icon` y `on_click` son opcionales. +#[derive(Clone)] +pub struct StatusSegment { + pub text: String, + pub icon: Option, + pub on_click: Option, + /// Si `true`, usa `fg` en vez de `fg_muted` — útil para destacar + /// estados importantes (ej. "modificado"). + pub emphasized: bool, +} + +impl StatusSegment { + pub fn text(text: impl Into) -> Self { + Self { + text: text.into(), + icon: None, + on_click: None, + emphasized: false, + } + } + pub fn with_icon(mut self, icon: Icon) -> Self { + self.icon = Some(icon); + self + } + pub fn clickable(mut self, msg: Msg) -> Self { + self.on_click = Some(msg); + self + } + pub fn emphasized(mut self) -> Self { + self.emphasized = true; + self + } +} + +const BAR_H: f32 = 22.0; +const SEG_GAP: f32 = 14.0; +const FONT_SIZE: f32 = 11.0; +const ICON_SIZE: f32 = 12.0; + +pub fn status_bar_view( + left: Vec>, + center: Vec>, + right: Vec>, + palette: &StatusBarPalette, +) -> View { + let make_region = |segs: Vec>, justify: JustifyContent| -> View { + let children: Vec> = segs + .into_iter() + .map(|s| segment_view(s, palette)) + .collect(); + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + align_items: Some(AlignItems::Center), + justify_content: Some(justify), + gap: Size { + width: length(SEG_GAP), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(children) + }; + + let left_region = make_region(left, JustifyContent::FlexStart); + let center_region = make_region(center, JustifyContent::Center); + let right_region = make_region(right, JustifyContent::FlexEnd); + + // Modo con firma: la barra trae su propio hairline accent en el top + // edge — reemplaza el border plano del modo previo. + let bar_style = Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(BAR_H), + }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + flex_shrink: 0.0, + ..Default::default() + }; + + if let Some(style) = palette.signature { + return View::new(bar_style) + .paint_with(panel_signature_painter(style)) + .children(vec![left_region, center_region, right_region]); + } + + // Back-compat: fill plano + border top 1px en el wrapper column. + let border = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(1.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(palette.border); + + let bar = View::new(bar_style) + .fill(palette.bg) + .children(vec![left_region, center_region, right_region]); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: length(BAR_H + 1.0), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![border, bar]) +} + +fn segment_view( + seg: StatusSegment, + palette: &StatusBarPalette, +) -> View { + let fg = if seg.emphasized { palette.fg } else { palette.fg_muted }; + let approx_w = seg.text.chars().count() as f32 * 6.0 + + if seg.icon.is_some() { ICON_SIZE + 4.0 } else { 0.0 } + + 12.0; + + let mut children: Vec> = Vec::with_capacity(2); + if let Some(icon) = seg.icon { + children.push( + View::new(Style { + size: Size { + width: length(ICON_SIZE), + height: length(ICON_SIZE), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![icon_view(icon, fg, 1.4)]), + ); + } + children.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(seg.text.clone(), FONT_SIZE, fg, Alignment::Start), + ); + + let mut node = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: length(approx_w), + height: percent(1.0_f32), + }, + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(6.0_f32), + right: length(6.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + gap: Size { + width: length(4.0_f32), + height: length(0.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(children); + + if let Some(msg) = seg.on_click { + node = node.hover_fill(palette.bg_hover).on_click(msg); + } + node +} diff --git a/widgets/switch/Cargo.toml b/widgets/switch/Cargo.toml new file mode 100644 index 0000000..d02deda --- /dev/null +++ b/widgets/switch/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-switch" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-switch — toggle binario on/off (track + thumb) con paleta del theme. Para preferencias, modos y feature flags." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/switch/src/lib.rs b/widgets/switch/src/lib.rs new file mode 100644 index 0000000..c5ec516 --- /dev/null +++ b/widgets/switch/src/lib.rs @@ -0,0 +1,152 @@ +//! `llimphi-widget-switch` — toggle binario (track + thumb). +//! +//! Render-only: la app guarda el `bool` en su modelo y dispatcha el +//! Msg de toggle al click. Visualmente: +//! - Track horizontal (40×22 default) con color del estado activo. +//! - Thumb circular (18px) que se posiciona a la izquierda (off) o +//! derecha (on) del track. +//! +//! Para animar la transición, la app puede guardar un `Tween` con +//! el progreso 0→1 y leerlo desde `view` para interpolar la posición +//! del thumb. Sin tween la transición es instantánea — funcional pero +//! menos elegante. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, Position, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::View; +use llimphi_theme::Theme; + +/// Paleta del switch. +#[derive(Debug, Clone, Copy)] +pub struct SwitchPalette { + pub track_off: Color, + pub track_on: Color, + pub thumb: Color, +} + +impl SwitchPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + track_off: t.bg_button, + track_on: t.accent, + thumb: t.fg_text, + } + } +} + +const TRACK_W: f32 = 40.0; +const TRACK_H: f32 = 22.0; +const THUMB_R: f32 = 9.0; // radio en px → diámetro 18 +const PAD: f32 = 2.0; + +/// Construye un switch. `progress` en `[0.0, 1.0]` indica la +/// posición animada del thumb (0 = off, 1 = on). Para la transición +/// instantánea usar `if state { 1.0 } else { 0.0 }`. +/// +/// `on_toggle` se dispatcha al click; la app actualiza su `bool` y +/// (opcionalmente) lanza un `Tween` que actualiza `progress` por frame. +pub fn switch_view( + progress: f32, + on_toggle: Msg, + palette: &SwitchPalette, +) -> View { + let p = progress.clamp(0.0, 1.0); + + // Track color interpola entre off y on según progress. + let track_color = lerp_color(palette.track_off, palette.track_on, p); + + // Thumb absolute dentro del track. Range del centro: PAD+THUMB_R a TRACK_W-PAD-THUMB_R. + let min_x = PAD; + let max_x = TRACK_W - PAD - THUMB_R * 2.0; + let thumb_x = min_x + (max_x - min_x) * p; + let thumb_y = (TRACK_H - THUMB_R * 2.0) * 0.5; + + let thumb = View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(thumb_x), + top: length(thumb_y), + right: auto(), + bottom: auto(), + }, + size: Size { + width: length(THUMB_R * 2.0), + height: length(THUMB_R * 2.0), + }, + ..Default::default() + }) + .fill(palette.thumb) + .radius(THUMB_R as f64) + .paint_with(move |scene, _ts, rect| { + // Highlight radial pequeño en cuadrante superior — el thumb se + // lee como esfera, no como círculo plano. Mismo patrón que el + // dot del badge (P6). + use llimphi_ui::llimphi_raster::kurbo::{Affine, Circle}; + use llimphi_ui::llimphi_raster::peniko::Fill; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let cx = (rect.x + rect.w * 0.5) as f64; + let cy = (rect.y + rect.h * 0.32) as f64; + let r = (rect.w as f64 * 0.18).max(1.0); + let highlight = Color::from_rgba8(255, 255, 255, 70); + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + highlight, + None, + &Circle::new((cx, cy), r), + ); + }); + + let track_radius = (TRACK_H * 0.5) as f64; + View::new(Style { + size: Size { + width: length(TRACK_W), + height: length(TRACK_H), + }, + ..Default::default() + }) + .fill(track_color) + .radius(track_radius) + .paint_with(move |scene, _ts, rect| { + // Gloss superior en el track — pill con luz cayendo desde arriba. + // El track interpola color (off/on) en el fill, el gloss queda + // estable encima en ambos estados. + use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, RoundedRect}; + use llimphi_ui::llimphi_raster::peniko::{Fill, Gradient}; + if rect.w <= 0.0 || rect.h <= 0.0 { + return; + } + let x0 = rect.x as f64; + let y0 = rect.y as f64; + let x1 = (rect.x + rect.w) as f64; + let y1 = (rect.y + rect.h) as f64; + let y_mid = y0 + (y1 - y0) * 0.5; + let rr = RoundedRect::new(x0, y0, x1, y1, track_radius); + let top = Color::from_rgba8(255, 255, 255, 28); + let bot = Color::from_rgba8(255, 255, 255, 0); + let g = Gradient::new_linear(Point::new(x0, y0), Point::new(x0, y_mid)) + .with_stops([top, bot].as_slice()); + scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &rr); + }) + .on_click(on_toggle) + .children(vec![thumb]) +} + +fn lerp_color(a: Color, b: Color, t: f32) -> Color { + let [r0, g0, b0, a0] = a.components; + let [r1, g1, b1, a1] = b.components; + use llimphi_ui::llimphi_raster::peniko::color::AlphaColor; + AlphaColor::new([ + r0 + (r1 - r0) * t, + g0 + (g1 - g0) * t, + b0 + (b1 - b0) * t, + a0 + (a1 - a0) * t, + ]) +} diff --git a/widgets/tabs/Cargo.toml b/widgets/tabs/Cargo.toml new file mode 100644 index 0000000..d01a2e7 --- /dev/null +++ b/widgets/tabs/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-tabs" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-tabs — tira de tabs + área de contenido. Análogo Llimphi al `nahual-widget-tabs` GPUI. El caller mantiene el índice activo en el `Model` y le da al widget las labels + el view del tab activo." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-panel = { workspace = true } diff --git a/widgets/tabs/LEEME.md b/widgets/tabs/LEEME.md new file mode 100644 index 0000000..cff8999 --- /dev/null +++ b/widgets/tabs/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-tabs + +> Tabs con cierre para [llimphi](../../README.md). + +Pestañas horizontales arrastrables, botón "+", close por pestaña. Activa por keyboard (Ctrl+Tab). Usado por `nada`, `pluma`, `puriy`. diff --git a/widgets/tabs/README.md b/widgets/tabs/README.md new file mode 100644 index 0000000..076c525 --- /dev/null +++ b/widgets/tabs/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-tabs + +> Closeable tabs for [llimphi](../../README.md). + +Draggable horizontal tabs, "+" button, per-tab close. Keyboard active (Ctrl+Tab). Used by `nada`, `pluma`, `puriy`. diff --git a/widgets/tabs/examples/tabs_demo.rs b/widgets/tabs/examples/tabs_demo.rs new file mode 100644 index 0000000..25c44d6 --- /dev/null +++ b/widgets/tabs/examples/tabs_demo.rs @@ -0,0 +1,136 @@ +//! Showcase de `llimphi-widget-tabs`: 3 tabs con contenido distinto +//! cada uno. Hover en los tabs inactivos cambia el bg. +//! +//! Corré con: `cargo run -p llimphi-widget-tabs --example showcase --release`. + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{App, Handle, View}; +use llimphi_widget_tabs::{tabs_view, TabsPalette, TabsSpec}; + +#[derive(Clone)] +enum Msg { + SelectTab(usize), +} + +struct Model { + active: usize, +} + +struct Showcase; + +impl App for Showcase { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · tabs showcase" + } + + fn initial_size() -> (u32, u32) { + (900, 600) + } + + fn init(_: &Handle) -> Model { + Model { active: 0 } + } + + fn update(model: Model, msg: Msg, _: &Handle) -> Model { + let mut m = model; + match msg { + Msg::SelectTab(i) => m.active = i, + } + m + } + + fn view(model: &Model) -> View { + let body = match model.active { + 0 => content_pane( + "General", + "Acá vivirían los settings principales del módulo.\n\ + El click cambia de tab; el hover sobre tabs inactivos\n\ + ilumina el fondo levemente.", + Color::from_rgba8(220, 230, 245, 255), + ), + 1 => content_pane( + "Avanzado", + "Variables esotéricas, banderas experimentales.\n\ + Probablemente no las toques.", + Color::from_rgba8(200, 220, 240, 255), + ), + _ => content_pane( + "Logs", + "[12:01:33] arranqué\n[12:01:34] cargué config\n\ + [12:01:35] esperando eventos…", + Color::from_rgba8(180, 195, 215, 255), + ), + }; + + tabs_view(TabsSpec { + labels: vec!["General".into(), "Avanzado".into(), "Logs".into()], + active: model.active, + on_select: Msg::SelectTab, + content: body, + tab_height: 36.0, + palette: TabsPalette::default(), + tab_width: Some(160.0), + }) + } +} + +fn content_pane(title: &str, body: &str, fg: Color) -> View { + let header = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(36.0_f32), + }, + padding: Rect { + left: length(20.0_f32), + right: length(20.0_f32), + top: length(8.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Start), + ..Default::default() + }) + .text_aligned( + format!("# {title}"), + 18.0, + Color::from_rgba8(220, 230, 245, 255), + Alignment::Start, + ); + + let body_view = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + padding: Rect { + left: length(20.0_f32), + right: length(20.0_f32), + top: length(0.0_f32), + bottom: length(20.0_f32), + }, + ..Default::default() + }) + .text_aligned(body.to_string(), 13.0, fg, Alignment::Start); + + View::new(Style { + flex_direction: llimphi_ui::llimphi_layout::taffy::FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .children(vec![header, body_view]) +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/tabs/src/lib.rs b/widgets/tabs/src/lib.rs new file mode 100644 index 0000000..0abc6dd --- /dev/null +++ b/widgets/tabs/src/lib.rs @@ -0,0 +1,271 @@ +//! `llimphi-widget-tabs` — tira de tabs + área de contenido. +//! +//! Análogo Llimphi al `nahual-widget-tabs` GPUI. El widget no mantiene +//! estado interno: el `Model` del App lleva el índice activo, le pasa al +//! widget las labels + el `View` del tab activo, y maneja el Msg de +//! cambio de tab. +//! +//! Uso típico: +//! +//! ```ignore +//! tabs_view( +//! TabsSpec { +//! labels: vec!["General".into(), "Avanzado".into(), "Logs".into()], +//! active: model.active_tab, +//! on_select: |i| Msg::SelectTab(i), +//! content: render_active_tab(model), +//! palette: TabsPalette::default(), +//! } +//! ) +//! ``` + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, percent, Dimension, FlexDirection, Size, Style}, + AlignItems, JustifyContent, Rect, +}; + +/// Ancho mínimo de un tab cuando `tab_width` es `None` — evita que los +/// tabs cortos (un nombre de 4 chars) se vean apretados contra los +/// vecinos. Si se especifica `tab_width: Some(px)`, se ignora. +const DEFAULT_MIN_TAB_WIDTH: f32 = 120.0; +/// Separación horizontal entre tabs — deja ver el `bg_bar` como hilo +/// fino, suaviza el bloque sólido de antes. +const TAB_GAP: f32 = 2.0; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_widget_panel::{panel_signature_painter, PanelStyle}; + +/// Paleta del tab bar. +#[derive(Debug, Clone, Copy)] +pub struct TabsPalette { + pub bg_bar: Color, + pub bg_tab_inactive: Color, + pub bg_tab_hover: Color, + pub bg_tab_active: Color, + pub fg_text: Color, + pub fg_text_active: Color, + /// Línea bajo el tab activo (acento). Si es `None` no se dibuja. + pub accent: Option, + /// Firma visual del área de contenido (sólo gradient — el accent + /// del tab activo justo encima ya cumple el rol del hairline). `None` + /// cae al fill plano de `bg_tab_active` (back-compat). + pub content_signature: Option, +} + +impl Default for TabsPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl TabsPalette { + /// Construye la paleta desde un `Theme` semántico. + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg_bar: t.bg_panel_alt, + bg_tab_inactive: t.bg_panel, + bg_tab_hover: t.bg_row_hover, + bg_tab_active: t.bg_app, + fg_text: t.fg_muted, + fg_text_active: t.fg_text, + accent: Some(t.accent), + content_signature: Some(PanelStyle { + radius: 0.0, + bg_base: t.bg_app, + ..PanelStyle::neutral(t) + }), + } + } +} + +/// Especificación de los tabs. `labels.len()` define cuántos tabs; el +/// `Msg` por click se construye con `on_select(idx)`. +pub struct TabsSpec { + pub labels: Vec, + pub active: usize, + /// Function from tab index to Msg. Se invoca una vez por tab en `view`. + pub on_select: F, + /// Contenido del tab activo. El widget lo coloca debajo de la barra. + pub content: View, + pub tab_height: f32, + pub palette: TabsPalette, + /// Ancho de cada tab. `None` = tamaño según contenido (auto). + pub tab_width: Option, +} + +/// Compone la barra de tabs + área de contenido. La función `on_select` +/// se consume — se invoca una vez por tab para construir su Msg. +pub fn tabs_view(spec: TabsSpec) -> View +where + Msg: Clone + 'static, + F: Fn(usize) -> Msg, +{ + let TabsSpec { + labels, + active, + on_select, + content, + tab_height, + palette, + tab_width, + } = spec; + + let mut bar_children: Vec> = Vec::with_capacity(labels.len() + 1); + for (i, label) in labels.iter().enumerate() { + bar_children.push(tab_button( + label, + i == active, + tab_height, + tab_width, + &palette, + on_select(i), + )); + } + // Spacer al final: empuja los tabs al inicio y rellena el resto del + // ancho con el bg_bar. + bar_children.push( + View::new(Style { + size: Size { + width: Dimension::auto(), + height: length(tab_height), + }, + flex_grow: 1.0, + ..Default::default() + }) + .fill(palette.bg_bar), + ); + + let bar = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(tab_height + accent_thickness(&palette)), + }, + // No comprimir verticalmente cuando el contenido del tab activo + // pide percent(1.0): si no, el column padre reparte overflow y + // come la altura del tab strip. + flex_shrink: 0.0, + gap: Size { + width: length(TAB_GAP), + height: length(0.0_f32), + }, + ..Default::default() + }) + .fill(palette.bg_bar) + .children(bar_children); + + let content_style = Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + ..Default::default() + }; + let content_wrap = match palette.content_signature { + Some(style) => View::new(content_style) + .paint_with(panel_signature_painter(style)) + .children(vec![content]), + None => View::new(content_style) + .fill(palette.bg_tab_active) + .children(vec![content]), + }; + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .children(vec![bar, content_wrap]) +} + +fn tab_button( + label: &str, + active: bool, + height: f32, + width: Option, + palette: &TabsPalette, + on_click: Msg, +) -> View { + let (bg, fg) = if active { + (palette.bg_tab_active, palette.fg_text_active) + } else { + (palette.bg_tab_inactive, palette.fg_text) + }; + let w = match width { + Some(px) => length(px), + None => Dimension::auto(), + }; + // Cuando el tab es auto-width, garantizamos min para que un label + // corto («main.rs», 7 chars) no apriete al vecino. + let min_w = match width { + Some(_) => auto(), + None => length(DEFAULT_MIN_TAB_WIDTH), + }; + + let label_view = View::new(Style { + size: Size { + width: w, + height: length(height), + }, + min_size: Size { width: min_w, height: auto() }, + padding: Rect { + left: length(16.0_f32), + right: length(16.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(bg) + .hover_fill(palette.bg_tab_hover) + .text_aligned(label.to_string(), 13.0, fg, Alignment::Center) + .on_click(on_click); + + // Línea de acento bajo el tab activo. Para inactivos se dibuja con el + // bg_bar (transparente al ojo). + let accent_color = match (palette.accent, active) { + (Some(c), true) => c, + _ => palette.bg_bar, + }; + let accent = View::new(Style { + size: Size { + width: w, + height: length(accent_thickness(palette)), + }, + min_size: Size { width: min_w, height: auto() }, + ..Default::default() + }) + .fill(accent_color); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: w, + height: length(height + accent_thickness(palette)), + }, + min_size: Size { width: min_w, height: auto() }, + // Cuando hay muchos tabs y el ancho total excede la bar, no + // comprimir cada tab — preferimos overflow a verlos como una + // lasca delgada. (Eventualmente: scroll horizontal.) + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![label_view, accent]) +} + +fn accent_thickness(palette: &TabsPalette) -> f32 { + if palette.accent.is_some() { + 2.0 + } else { + 0.0 + } +} diff --git a/widgets/text-area/Cargo.toml b/widgets/text-area/Cargo.toml new file mode 100644 index 0000000..916faba --- /dev/null +++ b/widgets/text-area/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-text-area" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-text-area — input de texto multilínea para Llimphi: estado plano (String con \\n), apply_key con Enter→\\n + Backspace + caracteres imprimibles, render multilínea con caret bloque al final del último renglón." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/text-area/LEEME.md b/widgets/text-area/LEEME.md new file mode 100644 index 0000000..0cba865 --- /dev/null +++ b/widgets/text-area/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-text-area + +> Textarea multi-line para [llimphi](../../README.md). + +Sin highlight (eso es `text-editor`). Wrap configurable, char count, placeholder. diff --git a/widgets/text-area/README.md b/widgets/text-area/README.md new file mode 100644 index 0000000..92f0f60 --- /dev/null +++ b/widgets/text-area/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-text-area + +> Multi-line textarea for [llimphi](../../README.md). + +No highlight (that's `text-editor`). Configurable wrap, char count, placeholder. diff --git a/widgets/text-area/src/lib.rs b/widgets/text-area/src/lib.rs new file mode 100644 index 0000000..f480c03 --- /dev/null +++ b/widgets/text-area/src/lib.rs @@ -0,0 +1,261 @@ +//! `llimphi-widget-text-area` — input de texto multilínea para Llimphi. +//! +//! Versión multilínea del [`llimphi-widget-text-input`]. Mismo contrato Elm +//! (estado en el `Model`, `apply_key` desde el `update`, view con foco), +//! pero acepta `\n` como contenido válido: Enter inserta salto de línea +//! en lugar de "submit". El llamador decide cómo commitear (típicamente +//! Ctrl+Enter o un botón ✓ aparte). +//! +//! El render aprovecha que `View::text_aligned` ya hace layout multilínea +//! vía parley (line wrap por `max_width`, saltos `\n` respetados). +//! +//! Limitaciones del PMV (heredadas del text-input): sin posicionamiento +//! del cursor con flechas, sin selección, sin copy/paste, sin IME. El +//! caret se simula como un bloque sólido al final del texto. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View}; + +/// Paleta del text-area — mismos slots que el text-input. +#[derive(Debug, Clone, Copy)] +pub struct TextAreaPalette { + pub bg: Color, + pub bg_focus: Color, + pub border: Color, + pub border_focus: Color, + pub fg_text: Color, + pub fg_placeholder: Color, +} + +impl Default for TextAreaPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl TextAreaPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg: t.bg_input, + bg_focus: t.bg_input_focus, + border: t.border, + border_focus: t.border_focus, + fg_text: t.fg_text, + fg_placeholder: t.fg_placeholder, + } + } +} + +/// Estado del text-area. Vive en el `Model`; `apply_key` se llama desde +/// el `update` para ediciones por tecla. +#[derive(Debug, Clone, Default)] +pub struct TextAreaState { + text: String, +} + +impl TextAreaState { + pub fn new() -> Self { + Self::default() + } + + pub fn text(&self) -> &str { + &self.text + } + + pub fn is_empty(&self) -> bool { + self.text.is_empty() + } + + pub fn clear(&mut self) { + self.text.clear(); + } + + pub fn set_text(&mut self, s: impl Into) { + self.text = s.into(); + } + + /// Cantidad de líneas (≥ 1, mismo criterio que `str::lines` + 1 si + /// el texto termina en `\n`). + pub fn line_count(&self) -> usize { + if self.text.is_empty() { + return 1; + } + let mut n = self.text.lines().count(); + if self.text.ends_with('\n') { + n += 1; + } + n.max(1) + } + + /// Aplica una tecla al estado. Devuelve `true` si cambió el contenido. + /// + /// Maneja: Backspace, Enter (inserta `\n`), e inserción de + /// caracteres imprimibles vía `event.text`. NO maneja: Tab (lo + /// dejamos al caller — típicamente cambio de foco o indent), + /// Escape, flechas. + pub fn apply_key(&mut self, event: &KeyEvent) -> bool { + if event.state != KeyState::Pressed { + return false; + } + match &event.key { + Key::Named(NamedKey::Backspace) => self.text.pop().is_some(), + Key::Named(NamedKey::Enter) => { + self.text.push('\n'); + true + } + _ => { + let Some(text) = event.text.as_ref() else { + return false; + }; + // Filtramos caracteres de control — el `\n` lo metemos + // sólo desde NamedKey::Enter para tener un único path. + if text.is_empty() || text.chars().any(|c| c.is_control()) { + return false; + } + self.text.push_str(text); + true + } + } + } +} + +/// Render del text-area. `body_height` es el alto disponible del bloque +/// (el widget no calcula altura automática; el caller decide). Con foco +/// se pinta un caret bloque al final del texto. +pub fn text_area_view( + state: &TextAreaState, + placeholder: &str, + focused: bool, + body_height: f32, + palette: &TextAreaPalette, + on_focus: Msg, +) -> View { + let is_empty = state.is_empty(); + let display = if is_empty { + placeholder.to_string() + } else { + state.text.clone() + }; + let text_color = if is_empty { palette.fg_placeholder } else { palette.fg_text }; + let (bg, border) = if focused { + (palette.bg_focus, palette.border_focus) + } else { + (palette.bg, palette.border) + }; + + let inner = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(body_height), + }, + padding: Rect { + left: length(6.0_f32), + right: length(6.0_f32), + top: length(4.0_f32), + bottom: length(4.0_f32), + }, + ..Default::default() + }) + .fill(bg) + .text_aligned(display, 12.0, text_color, Alignment::Start); + + // Wrapper que pinta el borde como fill del padre (1 px alrededor + // del inner gracias al padding del padre). + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(body_height + 2.0), + }, + padding: Rect { + left: length(1.0_f32), + right: length(1.0_f32), + top: length(1.0_f32), + bottom: length(1.0_f32), + }, + ..Default::default() + }) + .fill(border) + .on_click(on_focus) + .children(vec![inner]) +} + +#[cfg(test)] +mod tests { + use super::*; + use llimphi_ui::Modifiers; + + fn k(named: NamedKey) -> KeyEvent { + KeyEvent { + key: Key::Named(named), + state: KeyState::Pressed, + text: None, + modifiers: Modifiers::default(), + repeat: false, + } + } + + fn k_text(s: &str) -> KeyEvent { + KeyEvent { + key: Key::Character(s.into()), + state: KeyState::Pressed, + text: Some(s.to_owned()), + modifiers: Modifiers::default(), + repeat: false, + } + } + + #[test] + fn enter_inserta_salto_de_linea() { + let mut s = TextAreaState::new(); + s.apply_key(&k_text("a")); + s.apply_key(&k(NamedKey::Enter)); + s.apply_key(&k_text("b")); + assert_eq!(s.text(), "a\nb"); + assert_eq!(s.line_count(), 2); + } + + #[test] + fn backspace_borra_el_salto_y_une_lineas() { + let mut s = TextAreaState::new(); + s.set_text("a\nb"); + s.apply_key(&k(NamedKey::Backspace)); + s.apply_key(&k(NamedKey::Backspace)); + assert_eq!(s.text(), "a"); + } + + #[test] + fn line_count_vacio_es_uno() { + let s = TextAreaState::new(); + assert_eq!(s.line_count(), 1); + } + + #[test] + fn line_count_cuenta_trailing_newline() { + let mut s = TextAreaState::new(); + s.set_text("a\nb\n"); + assert_eq!(s.line_count(), 3); + } + + #[test] + fn caracteres_de_control_se_filtran() { + let mut s = TextAreaState::new(); + s.apply_key(&k_text("\t")); + assert!(s.is_empty()); + } + + #[test] + fn set_text_roundtrip() { + let mut s = TextAreaState::new(); + s.set_text("hola\nmundo"); + assert_eq!(s.text(), "hola\nmundo"); + s.clear(); + assert!(s.is_empty()); + } +} diff --git a/widgets/text-editor-core/Cargo.toml b/widgets/text-editor-core/Cargo.toml new file mode 100644 index 0000000..9a4be5b --- /dev/null +++ b/widgets/text-editor-core/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "llimphi-widget-text-editor-core" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-text-editor-core — núcleo agnóstico del editor de código: rope buffer (ropey), cursor + selección, undo/redo, bracket matching, find, diagnostics y syntax highlighting (tree-sitter). Sin dependencias de render — reutilizable en TUI/web/headless. La capa Llimphi (state + view) vive en `llimphi-widget-text-editor`." + +[dependencies] +ropey = { workspace = true } +tree-sitter = { workspace = true } +tree-sitter-rust = { workspace = true } +tree-sitter-python = { workspace = true } +# peniko sólo aporta el tipo de color (peniko::Color) para SyntaxPalette; +# es un crate de tipos sin GPU — no arrastra wgpu/vello. Versión alineada +# con la que expone vello 0.5 (ver workspace root). +peniko = "0.4" diff --git a/widgets/text-editor-core/src/bracket.rs b/widgets/text-editor-core/src/bracket.rs new file mode 100644 index 0000000..7cc5e1c --- /dev/null +++ b/widgets/text-editor-core/src/bracket.rs @@ -0,0 +1,165 @@ +//! Matching de paréntesis/corchetes/llaves bajo el cursor. +//! +//! Si el carácter inmediatamente *antes* o *en* el caret es un bracket +//! abridor o cerrador, busca su par contando profundidad y devuelve las +//! dos posiciones. Útil para el visor (resaltar ambas). +//! +//! Restricciones del PMV: no diferencia brackets dentro de strings ni +//! comentarios — el tokenizer del bloque de highlight (tree-sitter) lo +//! resolverá mejor en una pasada futura. Para WAT/JSON/Lisp esto basta. + +use crate::buffer::Buffer; +use crate::cursor::{Cursor, Pos}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + Forward, + Backward, +} + +/// Pares reconocidos. +const PAIRS: &[(char, char)] = &[('(', ')'), ('[', ']'), ('{', '}')]; + +fn pair_of(c: char) -> Option<(char, char, Direction)> { + for &(o, cl) in PAIRS { + if c == o { + return Some((o, cl, Direction::Forward)); + } + if c == cl { + return Some((o, cl, Direction::Backward)); + } + } + None +} + +/// Si el caret toca un bracket, devuelve `(pos_del_bracket, pos_del_par)`. +pub fn find_bracket_pair(buf: &Buffer, cursor: &Cursor) -> Option<(Pos, Pos)> { + let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col); + + // Probamos en `caret` y `caret-1` — un caret "entre" dos chars puede + // tocar al de la izquierda visualmente. + let candidates: [Option; 2] = [ + Some(caret_off).filter(|&o| o < buf.len_chars()), + caret_off.checked_sub(1), + ]; + + for opt in candidates { + let Some(off) = opt else { continue }; + let Some(ch) = buf.char_at(off) else { continue }; + let Some((open, close, dir)) = pair_of(ch) else { continue }; + let mate = match dir { + Direction::Forward => find_forward(buf, off + 1, open, close), + Direction::Backward => find_backward(buf, off, open, close), + }; + if let Some(mate_off) = mate { + let a = buf.offset_to_pos(off); + let b = buf.offset_to_pos(mate_off); + return Some((Pos::new(a.0, a.1), Pos::new(b.0, b.1))); + } + } + None +} + +fn find_forward(buf: &Buffer, from: usize, open: char, close: char) -> Option { + let mut depth = 1usize; + let mut off = from; + let len = buf.len_chars(); + while off < len { + match buf.char_at(off) { + Some(c) if c == open => depth += 1, + Some(c) if c == close => { + depth -= 1; + if depth == 0 { + return Some(off); + } + } + _ => {} + } + off += 1; + } + None +} + +fn find_backward(buf: &Buffer, before: usize, open: char, close: char) -> Option { + if before == 0 { + return None; + } + let mut depth = 1usize; + let mut off = before; + while off > 0 { + off -= 1; + match buf.char_at(off) { + Some(c) if c == close => depth += 1, + Some(c) if c == open => { + depth -= 1; + if depth == 0 { + return Some(off); + } + } + _ => {} + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empareja_paren_simple() { + let b = Buffer::from_str("(a)"); + let c = Cursor::at(0, 0); // caret antes del '(' + let (a, m) = find_bracket_pair(&b, &c).unwrap(); + assert_eq!(a, Pos::new(0, 0)); + assert_eq!(m, Pos::new(0, 2)); + } + + #[test] + fn empareja_desde_el_lado_derecho() { + let b = Buffer::from_str("(a)"); + let c = Cursor::at(0, 3); // caret después del ')' + let (a, m) = find_bracket_pair(&b, &c).unwrap(); + assert_eq!(a, Pos::new(0, 2)); // ')' + assert_eq!(m, Pos::new(0, 0)); // '(' + } + + #[test] + fn anidados_respeta_profundidad() { + let b = Buffer::from_str("((a))"); + let c = Cursor::at(0, 0); // primer '(' + let (_, m) = find_bracket_pair(&b, &c).unwrap(); + assert_eq!(m, Pos::new(0, 4)); // último ')' + } + + #[test] + fn empareja_brackets_y_llaves() { + let b = Buffer::from_str("[a]"); + assert!(find_bracket_pair(&b, &Cursor::at(0, 0)).is_some()); + + let b2 = Buffer::from_str("{a}"); + assert!(find_bracket_pair(&b2, &Cursor::at(0, 0)).is_some()); + } + + #[test] + fn caret_lejos_de_bracket_devuelve_none() { + let b = Buffer::from_str("hola"); + let c = Cursor::at(0, 2); + assert!(find_bracket_pair(&b, &c).is_none()); + } + + #[test] + fn bracket_sin_par_devuelve_none() { + let b = Buffer::from_str("(a"); + let c = Cursor::at(0, 0); + assert!(find_bracket_pair(&b, &c).is_none()); + } + + #[test] + fn multilinea_pasa_saltos() { + let b = Buffer::from_str("(\n a\n)"); + let c = Cursor::at(0, 0); + let (_, m) = find_bracket_pair(&b, &c).unwrap(); + assert_eq!(m, Pos::new(2, 0)); + } +} diff --git a/widgets/text-editor-core/src/buffer.rs b/widgets/text-editor-core/src/buffer.rs new file mode 100644 index 0000000..f998ece --- /dev/null +++ b/widgets/text-editor-core/src/buffer.rs @@ -0,0 +1,255 @@ +//! Buffer del editor — wrapper fino sobre [`ropey::Rope`] con las +//! conversiones de coordenadas que el resto del crate usa. +//! +//! Coordenadas: +//! - `char_offset`: índice de carácter (no byte) en el buffer entero. +//! - `(line, col)`: línea (0-based) + columna en chars dentro de esa línea. +//! +//! Convenciones: +//! - Las líneas son las que define `Rope::lines()` — un `\n` separa +//! líneas; la última línea puede o no terminar en `\n` (en cuyo caso +//! hay una línea vacía extra después). +//! - `col` cuenta chars, no graphemes ni bytes. Para CJK ancho doble +//! el render decidirá el ancho visual; el cursor avanza en chars. + +use ropey::Rope; + +#[derive(Debug, Clone)] +pub struct Buffer { + rope: Rope, +} + +impl Default for Buffer { + fn default() -> Self { + Self::new() + } +} + +impl Buffer { + pub fn new() -> Self { + Self { rope: Rope::new() } + } + + pub fn from_str(s: &str) -> Self { + Self { rope: Rope::from_str(s) } + } + + pub fn text(&self) -> String { + self.rope.to_string() + } + + pub fn len_chars(&self) -> usize { + self.rope.len_chars() + } + + pub fn len_lines(&self) -> usize { + self.rope.len_lines().max(1) + } + + pub fn is_empty(&self) -> bool { + self.rope.len_chars() == 0 + } + + /// Devuelve la línea `n` como `String` (incluye su trailing `\n` si + /// no es la última). Si `n` está fuera de rango devuelve `""`. + pub fn line(&self, n: usize) -> String { + if n >= self.rope.len_lines() { + return String::new(); + } + self.rope.line(n).to_string() + } + + /// Cantidad de chars en la línea `n` **sin contar** el `\n` terminal. + pub fn line_len_chars(&self, n: usize) -> usize { + if n >= self.rope.len_lines() { + return 0; + } + let line = self.rope.line(n); + let mut len = line.len_chars(); + // Quitamos el `\n` final si lo hay. + if len > 0 && line.char(len - 1) == '\n' { + len -= 1; + } + len + } + + /// Convierte `char_offset` global a `(line, col)`. + pub fn offset_to_pos(&self, offset: usize) -> (usize, usize) { + let off = offset.min(self.rope.len_chars()); + let line = self.rope.char_to_line(off); + let line_start = self.rope.line_to_char(line); + (line, off - line_start) + } + + /// Convierte `(line, col)` a `char_offset`. Clampea `line` y `col` + /// para no panicear con coordenadas fuera de rango. + pub fn pos_to_offset(&self, line: usize, col: usize) -> usize { + let line = line.min(self.rope.len_lines().saturating_sub(1)); + let line_start = self.rope.line_to_char(line); + let line_chars = self.line_len_chars(line); + let col = col.min(line_chars); + line_start + col + } + + /// Carácter en `char_offset`. `None` si está fuera de rango. + pub fn char_at(&self, offset: usize) -> Option { + if offset >= self.rope.len_chars() { + return None; + } + Some(self.rope.char(offset)) + } + + /// Slice `[start..end)` como `String`. Clampea para no panicear. + pub fn slice(&self, start: usize, end: usize) -> String { + let len = self.rope.len_chars(); + let s = start.min(len); + let e = end.min(len).max(s); + self.rope.slice(s..e).to_string() + } + + /// Inserta `s` en `offset`. Clampea `offset`. + pub fn insert(&mut self, offset: usize, s: &str) { + let off = offset.min(self.rope.len_chars()); + self.rope.insert(off, s); + } + + /// Borra `[start..end)`. Clampea ambos. + pub fn delete(&mut self, start: usize, end: usize) { + let len = self.rope.len_chars(); + let s = start.min(len); + let e = end.min(len).max(s); + if s == e { + return; + } + self.rope.remove(s..e); + } + + pub fn set_text(&mut self, s: &str) { + self.rope = Rope::from_str(s); + } + + pub fn replace_all(&mut self, s: &str) { + self.set_text(s); + } + + /// Convierte char_offset → byte_offset. tree-sitter trabaja en bytes + /// (UTF-8); el editor en chars. Esto las conecta. + pub fn char_to_byte(&self, char_offset: usize) -> usize { + let off = char_offset.min(self.rope.len_chars()); + self.rope.char_to_byte(off) + } + + /// Línea (0-based) que contiene el char_offset dado. + pub fn char_to_line(&self, char_offset: usize) -> usize { + let off = char_offset.min(self.rope.len_chars()); + self.rope.char_to_line(off) + } + + /// Byte_offset del primer char de la línea `n`. + pub fn line_to_byte(&self, line: usize) -> usize { + let line = line.min(self.rope.len_lines()); + self.rope.line_to_byte(line) + } + + /// Devuelve el rango `[start_col..col)` que contiene el "word" actual + /// — desde el último carácter no-de-palabra hasta `col`, en la línea + /// `line`. Útil para autocompletion (smart-replace del prefijo). + pub fn current_word_prefix(&self, line: usize, col: usize) -> (usize, String) { + let line_text = self.line(line); + let chars: Vec = line_text + .chars() + .filter(|c| *c != '\n') + .collect(); + let end = col.min(chars.len()); + let mut start = end; + while start > 0 && is_word_char(chars[start - 1]) { + start -= 1; + } + let prefix: String = chars[start..end].iter().collect(); + (start, prefix) + } +} + +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_buffer_has_one_line() { + let b = Buffer::new(); + assert_eq!(b.len_lines(), 1); + assert_eq!(b.line_len_chars(0), 0); + } + + #[test] + fn pos_offset_roundtrip() { + let b = Buffer::from_str("hola\nmundo\nfin"); + let cases = [(0usize, 0usize), (0, 4), (1, 0), (1, 5), (2, 3)]; + for (line, col) in cases { + let off = b.pos_to_offset(line, col); + assert_eq!(b.offset_to_pos(off), (line, col)); + } + } + + #[test] + fn line_len_excludes_trailing_newline() { + let b = Buffer::from_str("hola\nfin"); + assert_eq!(b.line_len_chars(0), 4); // "hola" sin \n + assert_eq!(b.line_len_chars(1), 3); // "fin" + } + + #[test] + fn insert_and_delete_modify_text() { + let mut b = Buffer::from_str("ab"); + b.insert(1, "X"); + assert_eq!(b.text(), "aXb"); + b.delete(1, 2); + assert_eq!(b.text(), "ab"); + } + + #[test] + fn slice_clampea() { + let b = Buffer::from_str("hola"); + assert_eq!(b.slice(0, 100), "hola"); + assert_eq!(b.slice(50, 100), ""); + assert_eq!(b.slice(2, 1), ""); // end < start clampea + } + + #[test] + fn current_word_prefix_basic() { + let b = Buffer::from_str("let hola_mundo = 1;"); + // Caret en col 14 (después de la 'o' de "hola_mundo"). + let (start, p) = b.current_word_prefix(0, 14); + assert_eq!(start, 4); + assert_eq!(p, "hola_mundo"); + } + + #[test] + fn current_word_prefix_en_inicio_es_vacio() { + let b = Buffer::from_str("hola"); + let (start, p) = b.current_word_prefix(0, 0); + assert_eq!(start, 0); + assert!(p.is_empty()); + } + + #[test] + fn current_word_prefix_caret_despues_de_no_word() { + let b = Buffer::from_str("foo.bar"); + let (start, p) = b.current_word_prefix(0, 4); + // El '.' no es word; el prefijo empieza ahí. + assert_eq!(start, 4); + assert!(p.is_empty()); + } + + #[test] + fn pos_to_offset_clampea_col() { + let b = Buffer::from_str("ab\ncd"); + // col fuera de rango → fin de línea + assert_eq!(b.pos_to_offset(0, 99), 2); + assert_eq!(b.pos_to_offset(1, 99), 5); + } +} diff --git a/widgets/text-editor-core/src/clipboard.rs b/widgets/text-editor-core/src/clipboard.rs new file mode 100644 index 0000000..efb11d5 --- /dev/null +++ b/widgets/text-editor-core/src/clipboard.rs @@ -0,0 +1,50 @@ +//! Clipboard abstracto. El editor no quiere acoplarse a un backend de +//! SO concreto (X11 / Wayland / macOS / Windows), así que define el +//! trait y entrega un mock para tests. La impl real (vía `arboard`) +//! vive del lado del caller — típicamente la app embebida en +//! `nada` o el visor del notebook. + +/// Backend de clipboard. `set` mete texto; `get` lo lee. Cualquiera de +/// los dos puede fallar (sin display, headless CI, race con otro +/// programa) — `None` / no-op silencioso es válido. +pub trait Clipboard: Send { + fn get(&mut self) -> Option; + fn set(&mut self, s: &str); +} + +/// Clipboard de memoria — útil para tests y como fallback cuando el +/// sistema no expone uno. +#[derive(Debug, Default, Clone)] +pub struct MemClipboard { + content: Option, +} + +impl MemClipboard { + pub fn new() -> Self { + Self::default() + } + pub fn with(s: impl Into) -> Self { + Self { content: Some(s.into()) } + } +} + +impl Clipboard for MemClipboard { + fn get(&mut self) -> Option { + self.content.clone() + } + fn set(&mut self, s: &str) { + self.content = Some(s.to_owned()); + } +} + +/// "No clipboard" — `set` descarta, `get` devuelve `None`. Útil cuando +/// el caller quiere desactivar copy/paste explícitamente. +#[derive(Debug, Default, Clone, Copy)] +pub struct NullClipboard; + +impl Clipboard for NullClipboard { + fn get(&mut self) -> Option { + None + } + fn set(&mut self, _: &str) {} +} diff --git a/widgets/text-editor-core/src/cursor.rs b/widgets/text-editor-core/src/cursor.rs new file mode 100644 index 0000000..cf21501 --- /dev/null +++ b/widgets/text-editor-core/src/cursor.rs @@ -0,0 +1,325 @@ +//! Cursor + selección. Coordenadas en `(line, col)` (col en chars). +//! +//! Un [`Cursor`] tiene siempre una posición `caret` y opcionalmente un +//! `anchor`: si están en distintos puntos, hay una **selección**. +//! Movimiento sin `shift` colapsa la selección al caret nuevo; +//! movimiento con `shift` extiende desde el `anchor`. + +use crate::buffer::Buffer; + +fn is_word(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} +fn is_ws(c: char) -> bool { + c.is_whitespace() && c != '\n' +} + +/// Posición lógica del cursor — (línea, columna en chars). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Pos { + pub line: usize, + pub col: usize, +} + +impl Pos { + pub const fn new(line: usize, col: usize) -> Self { + Self { line, col } + } + pub const ORIGIN: Pos = Pos { line: 0, col: 0 }; +} + +/// Selección activa (anchor + caret). El rango efectivo es +/// `(min(anchor,caret), max(anchor,caret))` en orden de offset. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Selection { + pub anchor: Pos, + pub caret: Pos, +} + +impl Selection { + pub fn new(anchor: Pos, caret: Pos) -> Self { + Self { anchor, caret } + } + pub fn is_empty(&self) -> bool { + self.anchor == self.caret + } +} + +/// Cursor: caret + (opcional) anchor cuando hay selección. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Cursor { + pub caret: Pos, + pub anchor: Option, + /// Columna "deseada" — preserva la posición horizontal al saltar + /// entre líneas de distinto largo. Se setea al mover horizontal + /// y se respeta al mover vertical. + pub desired_col: usize, +} + +impl Default for Cursor { + fn default() -> Self { + Self::new() + } +} + +impl Cursor { + pub fn new() -> Self { + Self { caret: Pos::ORIGIN, anchor: None, desired_col: 0 } + } + + pub fn at(line: usize, col: usize) -> Self { + Self { caret: Pos::new(line, col), anchor: None, desired_col: col } + } + + pub fn selection(&self) -> Option { + self.anchor.map(|a| Selection::new(a, self.caret)) + } + + pub fn has_selection(&self) -> bool { + self.anchor.map_or(false, |a| a != self.caret) + } + + /// Rango efectivo `(start, end)` en `char_offset` global. Si no hay + /// selección, ambos son el caret. + pub fn selection_range(&self, buf: &Buffer) -> (usize, usize) { + let caret_off = buf.pos_to_offset(self.caret.line, self.caret.col); + match self.anchor { + None => (caret_off, caret_off), + Some(a) => { + let anchor_off = buf.pos_to_offset(a.line, a.col); + if anchor_off <= caret_off { + (anchor_off, caret_off) + } else { + (caret_off, anchor_off) + } + } + } + } + + /// Colapsa la selección dejando el caret donde está. + pub fn collapse(&mut self) { + self.anchor = None; + } + + /// Asegura que `anchor = caret` si `extending` es true y no había + /// anchor; si es false, colapsa. + pub fn set_extending(&mut self, extending: bool) { + match (extending, self.anchor) { + (true, None) => self.anchor = Some(self.caret), + (true, Some(_)) => {} + (false, _) => self.anchor = None, + } + } + + // ----- Movimiento por chars ----- + + pub fn move_left(&mut self, buf: &Buffer, extending: bool) { + self.set_extending(extending); + if self.caret.col > 0 { + self.caret.col -= 1; + } else if self.caret.line > 0 { + self.caret.line -= 1; + self.caret.col = buf.line_len_chars(self.caret.line); + } + self.desired_col = self.caret.col; + } + + pub fn move_right(&mut self, buf: &Buffer, extending: bool) { + self.set_extending(extending); + let line_len = buf.line_len_chars(self.caret.line); + if self.caret.col < line_len { + self.caret.col += 1; + } else if self.caret.line + 1 < buf.len_lines() { + self.caret.line += 1; + self.caret.col = 0; + } + self.desired_col = self.caret.col; + } + + pub fn move_up(&mut self, buf: &Buffer, extending: bool) { + self.set_extending(extending); + if self.caret.line == 0 { + self.caret.col = 0; + } else { + self.caret.line -= 1; + self.caret.col = self.desired_col.min(buf.line_len_chars(self.caret.line)); + } + } + + pub fn move_down(&mut self, buf: &Buffer, extending: bool) { + self.set_extending(extending); + if self.caret.line + 1 >= buf.len_lines() { + self.caret.col = buf.line_len_chars(self.caret.line); + } else { + self.caret.line += 1; + self.caret.col = self.desired_col.min(buf.line_len_chars(self.caret.line)); + } + } + + pub fn move_home(&mut self, _buf: &Buffer, extending: bool) { + self.set_extending(extending); + // Atajo: ir al inicio del primer non-whitespace; segundo Home + // iría al 0 — por ahora siempre al 0. + self.caret.col = 0; + self.desired_col = 0; + } + + pub fn move_end(&mut self, buf: &Buffer, extending: bool) { + self.set_extending(extending); + self.caret.col = buf.line_len_chars(self.caret.line); + self.desired_col = self.caret.col; + } + + pub fn move_page_up(&mut self, buf: &Buffer, extending: bool, page: usize) { + self.set_extending(extending); + self.caret.line = self.caret.line.saturating_sub(page); + self.caret.col = self.desired_col.min(buf.line_len_chars(self.caret.line)); + } + + pub fn move_page_down(&mut self, buf: &Buffer, extending: bool, page: usize) { + self.set_extending(extending); + self.caret.line = (self.caret.line + page).min(buf.len_lines().saturating_sub(1)); + self.caret.col = self.desired_col.min(buf.line_len_chars(self.caret.line)); + } + + pub fn move_doc_start(&mut self, _buf: &Buffer, extending: bool) { + self.set_extending(extending); + self.caret = Pos::ORIGIN; + self.desired_col = 0; + } + + pub fn move_doc_end(&mut self, buf: &Buffer, extending: bool) { + self.set_extending(extending); + let last_line = buf.len_lines().saturating_sub(1); + self.caret = Pos::new(last_line, buf.line_len_chars(last_line)); + self.desired_col = self.caret.col; + } + + // ----- Word movement ----- + + /// Movimiento por palabra a la izquierda — salta whitespace, después + /// caracteres de palabra (alfanumérico + `_`). + pub fn move_word_left(&mut self, buf: &Buffer, extending: bool) { + self.set_extending(extending); + let mut off = buf.pos_to_offset(self.caret.line, self.caret.col); + while off > 0 && buf.char_at(off - 1).map_or(false, is_ws) { + off -= 1; + } + while off > 0 && buf.char_at(off - 1).map_or(false, is_word) { + off -= 1; + } + let (l, c) = buf.offset_to_pos(off); + self.caret = Pos::new(l, c); + self.desired_col = c; + } + + pub fn move_word_right(&mut self, buf: &Buffer, extending: bool) { + self.set_extending(extending); + let len = buf.len_chars(); + let mut off = buf.pos_to_offset(self.caret.line, self.caret.col); + while off < len && buf.char_at(off).map_or(false, is_word) { + off += 1; + } + while off < len && buf.char_at(off).map_or(false, is_ws) { + off += 1; + } + let (l, c) = buf.offset_to_pos(off); + self.caret = Pos::new(l, c); + self.desired_col = c; + } + + // ----- Setters ----- + + pub fn set_caret(&mut self, buf: &Buffer, pos: Pos) { + let line = pos.line.min(buf.len_lines().saturating_sub(1)); + let col = pos.col.min(buf.line_len_chars(line)); + self.caret = Pos::new(line, col); + self.desired_col = col; + self.anchor = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn buf() -> Buffer { + Buffer::from_str("hola\nmundo\nfin") + } + + #[test] + fn cursor_new_is_origin() { + let c = Cursor::new(); + assert_eq!(c.caret, Pos::ORIGIN); + assert!(!c.has_selection()); + } + + #[test] + fn move_right_atraviesa_lineas() { + let b = buf(); + let mut c = Cursor::at(0, 4); // fin de "hola" + c.move_right(&b, false); + assert_eq!(c.caret, Pos::new(1, 0)); // inicio de "mundo" + } + + #[test] + fn move_left_retrocede_a_linea_anterior() { + let b = buf(); + let mut c = Cursor::at(1, 0); + c.move_left(&b, false); + assert_eq!(c.caret, Pos::new(0, 4)); + } + + #[test] + fn move_up_preserva_desired_col() { + let b = Buffer::from_str("abcdefgh\nxy\nlmnop"); + let mut c = Cursor::at(0, 7); + c.move_down(&b, false); + // "xy" sólo tiene 2 chars; el cursor se pega a col=2 + assert_eq!(c.caret, Pos::new(1, 2)); + // pero al bajar de nuevo, el desired (7) reanima. + c.move_down(&b, false); + assert_eq!(c.caret, Pos::new(2, 5)); // "lmnop" tiene 5 + } + + #[test] + fn shift_arrow_inicia_seleccion() { + let b = buf(); + let mut c = Cursor::at(0, 0); + c.move_right(&b, true); + c.move_right(&b, true); + assert!(c.has_selection()); + let (s, e) = c.selection_range(&b); + assert_eq!((s, e), (0, 2)); + } + + #[test] + fn arrow_sin_shift_colapsa() { + let b = buf(); + let mut c = Cursor::at(0, 0); + c.move_right(&b, true); + c.move_right(&b, true); + c.move_right(&b, false); + assert!(!c.has_selection()); + } + + #[test] + fn home_end_son_locales_a_la_linea() { + let b = buf(); + let mut c = Cursor::at(1, 2); + c.move_home(&b, false); + assert_eq!(c.caret, Pos::new(1, 0)); + c.move_end(&b, false); + assert_eq!(c.caret, Pos::new(1, 5)); + } + + #[test] + fn doc_start_y_end() { + let b = buf(); + let mut c = Cursor::at(1, 2); + c.move_doc_end(&b, false); + assert_eq!(c.caret, Pos::new(2, 3)); + c.move_doc_start(&b, false); + assert_eq!(c.caret, Pos::ORIGIN); + } +} diff --git a/widgets/text-editor-core/src/diagnostics.rs b/widgets/text-editor-core/src/diagnostics.rs new file mode 100644 index 0000000..20c9441 --- /dev/null +++ b/widgets/text-editor-core/src/diagnostics.rs @@ -0,0 +1,78 @@ +//! Diagnósticos del editor — espejo minimal del shape de `lsp-types` +//! sin depender del crate. Pensado para que un client LSP (rust-analyzer, +//! pylsp, etc.) lo poble desde fuera; el render del editor los pinta +//! como subrayado bajo el rango. +//! +//! El client real vive aparte (proceso + JSON-RPC) — este módulo sólo +//! define el shape de los datos y el helper para renderizarlos. + +use crate::cursor::Pos; + +/// Severidad — mismos valores y orden que en LSP (1 = Error es el más alto). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Severity { + Error, + Warning, + Information, + Hint, +} + +/// Rango cerrado de un diagnostic. `end` exclusivo en `col`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DiagnosticRange { + pub start: Pos, + pub end: Pos, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Diagnostic { + pub range: DiagnosticRange, + pub severity: Severity, + /// Mensaje humano corto — el render lo trunca para mostrar al hover/ + /// en una mini popup futura. En esta versión solo se usa el rango. + pub message: String, + /// Source del diagnostic — "rust-analyzer", "pylsp", "clippy", etc. + /// `None` si no se conoce. + pub source: Option, +} + +impl Diagnostic { + pub fn error(line_start: usize, col_start: usize, line_end: usize, col_end: usize, message: impl Into) -> Self { + Self { + range: DiagnosticRange { + start: Pos::new(line_start, col_start), + end: Pos::new(line_end, col_end), + }, + severity: Severity::Error, + message: message.into(), + source: None, + } + } + pub fn warning(line_start: usize, col_start: usize, line_end: usize, col_end: usize, message: impl Into) -> Self { + Self { + range: DiagnosticRange { + start: Pos::new(line_start, col_start), + end: Pos::new(line_end, col_end), + }, + severity: Severity::Warning, + message: message.into(), + source: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constructors_funcionan() { + let e = Diagnostic::error(1, 2, 1, 5, "boom"); + assert_eq!(e.severity, Severity::Error); + assert_eq!(e.range.start, Pos::new(1, 2)); + assert_eq!(e.range.end, Pos::new(1, 5)); + + let w = Diagnostic::warning(0, 0, 0, 10, "ojo"); + assert_eq!(w.severity, Severity::Warning); + } +} diff --git a/widgets/text-editor-core/src/find.rs b/widgets/text-editor-core/src/find.rs new file mode 100644 index 0000000..5a97d00 --- /dev/null +++ b/widgets/text-editor-core/src/find.rs @@ -0,0 +1,168 @@ +//! Búsqueda en el buffer. PMV: case-insensitive opcional, sin regex, +//! sin replace. La UI del prompt vive en el caller (típicamente una +//! barra arriba del editor); este módulo sólo provee: +//! +//! - [`FindState`] con el query actual + dirección + flag case-sensitive. +//! - [`find_next`] / [`find_prev`] que devuelven la próxima/anterior +//! match desde el caret del editor. +//! - [`all_matches`] para que el render resalte cada ocurrencia. + +use crate::buffer::Buffer; +use crate::cursor::{Cursor, Pos}; + +/// Configuración de búsqueda del editor. +#[derive(Debug, Clone, Default)] +pub struct FindState { + pub query: String, + pub case_sensitive: bool, +} + +impl FindState { + pub fn new() -> Self { + Self::default() + } + pub fn with_query(query: impl Into) -> Self { + Self { query: query.into(), case_sensitive: false } + } + pub fn is_active(&self) -> bool { + !self.query.is_empty() + } +} + +/// Devuelve todas las ocurrencias del query en el buffer como +/// `(start_offset, end_offset)` en char offsets. Vacío si query vacío. +pub fn all_matches(buf: &Buffer, find: &FindState) -> Vec<(usize, usize)> { + if find.query.is_empty() { + return Vec::new(); + } + let hay = buf.text(); + let (hay_search, needle_search) = if find.case_sensitive { + (hay.clone(), find.query.clone()) + } else { + (hay.to_lowercase(), find.query.to_lowercase()) + }; + + // Buscamos en bytes; convertimos a char_offsets al devolver. + let mut out: Vec<(usize, usize)> = Vec::new(); + let mut byte_start = 0; + while let Some(pos) = hay_search[byte_start..].find(&needle_search) { + let byte_match = byte_start + pos; + let char_start = hay[..byte_match].chars().count(); + let char_end = char_start + find.query.chars().count(); + out.push((char_start, char_end)); + byte_start = byte_match + needle_search.len().max(1); + } + out +} + +/// Encuentra la próxima ocurrencia con `start >= caret_off` (la match +/// **en** el caret cuenta, no la saltea). Para avanzar a la siguiente +/// real, el caller mueve el caret al `end` de la match anterior y +/// vuelve a llamar. Wrap-around al fin del buffer → primera match. +pub fn find_next(buf: &Buffer, find: &FindState, cursor: &Cursor) -> Option<(Pos, Pos)> { + let matches = all_matches(buf, find); + if matches.is_empty() { + return None; + } + let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col); + let next = matches + .iter() + .find(|(s, _)| *s >= caret_off) + .copied() + .or_else(|| matches.first().copied())?; + Some(positions_of(buf, next)) +} + +/// Como [`find_next`] pero en reverso. +pub fn find_prev(buf: &Buffer, find: &FindState, cursor: &Cursor) -> Option<(Pos, Pos)> { + let matches = all_matches(buf, find); + if matches.is_empty() { + return None; + } + let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col); + let prev = matches + .iter() + .rev() + .find(|(_, e)| *e < caret_off) + .copied() + .or_else(|| matches.last().copied())?; + Some(positions_of(buf, prev)) +} + +fn positions_of(buf: &Buffer, (start, end): (usize, usize)) -> (Pos, Pos) { + let (sl, sc) = buf.offset_to_pos(start); + let (el, ec) = buf.offset_to_pos(end); + (Pos::new(sl, sc), Pos::new(el, ec)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_matches_vacio_devuelve_vacio() { + let b = Buffer::from_str("hola hola"); + let f = FindState::new(); + assert!(all_matches(&b, &f).is_empty()); + } + + #[test] + fn all_matches_encuentra_todas() { + let b = Buffer::from_str("ab cd ab ef ab"); + let f = FindState::with_query("ab"); + let m = all_matches(&b, &f); + assert_eq!(m, vec![(0, 2), (6, 8), (12, 14)]); + } + + #[test] + fn case_insensitive_por_default() { + let b = Buffer::from_str("Hola HOLA hola"); + let f = FindState::with_query("hola"); + assert_eq!(all_matches(&b, &f).len(), 3); + } + + #[test] + fn case_sensitive_filtra() { + let b = Buffer::from_str("Hola HOLA hola"); + let f = FindState { query: "hola".into(), case_sensitive: true }; + assert_eq!(all_matches(&b, &f).len(), 1); + } + + #[test] + fn find_next_wrap_al_final() { + let b = Buffer::from_str("ab cd ab"); + let f = FindState::with_query("ab"); + let c = Cursor::at(0, 8); // al final + let (a, _) = find_next(&b, &f, &c).unwrap(); + assert_eq!(a, Pos::new(0, 0)); // wrap al primero + } + + #[test] + fn find_prev_wrap_al_principio() { + let b = Buffer::from_str("ab cd ab"); + let f = FindState::with_query("ab"); + let c = Cursor::at(0, 0); + let (a, _) = find_prev(&b, &f, &c).unwrap(); + assert_eq!(a, Pos::new(0, 6)); // wrap al último + } + + #[test] + fn find_next_devuelve_match_en_el_caret() { + let b = Buffer::from_str("ab ab ab"); + let f = FindState::with_query("ab"); + let c = Cursor::at(0, 0); + let (a, _) = find_next(&b, &f, &c).unwrap(); + assert_eq!(a, Pos::new(0, 0)); + } + + #[test] + fn find_next_avanza_si_caret_va_al_fin_de_match_anterior() { + let b = Buffer::from_str("ab ab ab"); + let f = FindState::with_query("ab"); + let mut c = Cursor::at(0, 0); + let (_, end1) = find_next(&b, &f, &c).unwrap(); + c.caret = end1; // (0, 2) — fin de la primera + let (a2, _) = find_next(&b, &f, &c).unwrap(); + assert_eq!(a2, Pos::new(0, 3)); + } +} diff --git a/widgets/text-editor-core/src/highlight.rs b/widgets/text-editor-core/src/highlight.rs new file mode 100644 index 0000000..57b58f2 --- /dev/null +++ b/widgets/text-editor-core/src/highlight.rs @@ -0,0 +1,590 @@ +//! Syntax highlighting. Cada `Language` produce una `Vec`: +//! por línea, una secuencia ordenada de `(start_col, end_col, TokenKind)` +//! que cubre toda la línea. El renderer pinta cada span con el color +//! que el [`SyntaxPalette`] mapea desde el `TokenKind`. +//! +//! - **Rust / Python**: tree-sitter parseando el buffer entero (ineficiente +//! pero adecuado para celdas de notebook ≤ ~1k LOC). Las queries se +//! compilan una vez por `Language`. +//! - **WAT**: tokenizer en Rust puro (LISP-like: paren, `$`-prefijo, +//! strings, números, keywords típicos del subset MVP). +//! - **Plain**: un solo span por línea con `TokenKind::Other`. + +use peniko::Color; + +/// Lenguajes soportados — la matriz se extiende sumando un variant + +/// una rama en [`Highlighter::tokenize_line`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Language { + Plain, + Rust, + Python, + Wat, +} + +impl Language { + /// Heurística: derivar el `Language` del `language` del `CellKind`. + pub fn from_cell_language(s: &str) -> Self { + match s.to_ascii_lowercase().as_str() { + "rust" | "rs" => Language::Rust, + "python" | "py" => Language::Python, + "wasm" | "wat" => Language::Wat, + _ => Language::Plain, + } + } +} + +/// Categorías de token — lo suficientemente granular para colores +/// distintos sin saturar el theme. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TokenKind { + Keyword, + Type, + Function, + String, + Number, + Comment, + Operator, + Punctuation, + Identifier, + Other, +} + +/// Un span dentro de una línea: `[start_col..end_col)` de la línea, +/// más su categoría. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Span { + pub start_col: usize, + pub end_col: usize, + pub kind: TokenKind, +} + +/// Paleta de colores por categoría — el theme la deriva. +#[derive(Debug, Clone, Copy)] +pub struct SyntaxPalette { + pub keyword: Color, + pub typ: Color, + pub function: Color, + pub string: Color, + pub number: Color, + pub comment: Color, + pub operator: Color, + pub punctuation: Color, + pub identifier: Color, + pub other: Color, +} + +impl SyntaxPalette { + pub fn color(&self, k: TokenKind) -> Color { + match k { + TokenKind::Keyword => self.keyword, + TokenKind::Type => self.typ, + TokenKind::Function => self.function, + TokenKind::String => self.string, + TokenKind::Number => self.number, + TokenKind::Comment => self.comment, + TokenKind::Operator => self.operator, + TokenKind::Punctuation => self.punctuation, + TokenKind::Identifier => self.identifier, + TokenKind::Other => self.other, + } + } +} + +// El constructor `dark_default(theme)` — única pieza que dependía de +// `llimphi_theme` — vive ahora en `llimphi-widget-text-editor` +// (`syntax_palette_dark`), para que este núcleo no arrastre el stack de +// render. Aquí queda sólo el modelo de color puro (peniko::Color). + +// Pool thread-local de parsers tree-sitter. Reconstruir el parser +// (con `set_language`) es caro; reusarlo entre highlights del mismo +// lenguaje es un ahorro grande. `tree_sitter::Parser` no es Send/ +// Sync ni Clone, así que vive en thread-local — un parser por +// lenguaje por thread. +thread_local! { + static PARSER_POOL: std::cell::RefCell> + = std::cell::RefCell::new(std::collections::HashMap::new()); + /// Cache del último árbol parseado por lenguaje. Se pasa como hint + /// al siguiente `parse(source, Some(&old_tree))`. El "verdadero + /// incremental" (aplicar `InputEdit`s al tree antes de reparsear) + /// ya está cableado: `EditorState` acumula los edits por delta y + /// llama a [`apply_pending_edits`] antes de cada highlight, de modo + /// que tree-sitter sólo reconstruye los subtrees afectados. + static TREE_CACHE: std::cell::RefCell> + = std::cell::RefCell::new(std::collections::HashMap::new()); +} + +/// Invalida el árbol cached para `language` — el caller lo invoca al +/// hacer `set_text` o cambios masivos donde el hint puede confundir +/// más que ayudar. No es estrictamente necesario, pero es defensivo. +pub fn invalidate_tree_cache(language: Language) { + TREE_CACHE.with(|c| { + c.borrow_mut().remove(&language); + }); +} + +/// Aplica una lista de `InputEdit` al tree cached del `language`. +/// Llamarlo ANTES de `parse(source, Some(&old_tree))` activa el modo +/// incremental real de tree-sitter — solo reconstruye los subtrees +/// afectados por las edits. +pub fn apply_pending_edits(language: Language, edits: &[tree_sitter::InputEdit]) { + if edits.is_empty() { + return; + } + TREE_CACHE.with(|c| { + let mut c = c.borrow_mut(); + if let Some(tree) = c.get_mut(&language) { + for e in edits { + tree.edit(e); + } + } + }); +} + +/// Highlighter — fina capa sin estado mutable propio. La parser real +/// vive en el pool thread-local. +pub struct Highlighter { + language: Language, +} + +impl Highlighter { + pub fn new(language: Language) -> Self { + Self { language } + } + + pub fn language(&self) -> Language { + self.language + } + + pub fn set_language(&mut self, language: Language) { + self.language = language; + } + + /// Tokeniza el `source` entero y devuelve los spans por línea. + /// `result.len() == source.lines().count().max(1)`. + pub fn highlight(&mut self, source: &str) -> Vec> { + match self.language { + Language::Plain => plain_lines(source), + Language::Wat => highlight_wat(source), + Language::Rust => self.highlight_treesitter(source, rust_kind), + Language::Python => self.highlight_treesitter(source, python_kind), + } + } + + fn highlight_treesitter( + &mut self, + source: &str, + kind_of: fn(&str) -> Option, + ) -> Vec> { + let language = self.language; + // Parsea con hint del tree previo si lo hay. tree-sitter puede + // reusar subtrees por hash incluso sin InputEdits aplicados. + let tree_opt = PARSER_POOL.with(|pool| { + let mut pool = pool.borrow_mut(); + let parser = pool.entry(language).or_insert_with(|| { + make_ts_parser(language).unwrap_or_else(tree_sitter::Parser::new) + }); + let old = TREE_CACHE.with(|c| c.borrow().get(&language).cloned()); + parser.parse(source, old.as_ref()) + }); + let Some(tree) = tree_opt else { + return plain_lines(source); + }; + // Guarda el nuevo árbol para la próxima invocación. + TREE_CACHE.with(|c| { + c.borrow_mut().insert(language, tree.clone()); + }); + + // Por línea: recopilamos spans de los nodos *named* tipados que + // matchean kind_of. Luego rellenamos los huecos con `Other`. + let line_count = source.lines().count().max(1) + + (if source.ends_with('\n') { 1 } else { 0 }); + let mut per_line: Vec> = vec![Vec::new(); line_count.max(1)]; + + let mut stack: Vec = vec![tree.root_node()]; + while let Some(node) = stack.pop() { + if node.child_count() == 0 { + // hoja: tomamos el tipo del nodo (token). + let kind = node.kind(); + if let Some(tk) = kind_of(kind) { + let start = node.start_position(); + let end = node.end_position(); + // Sólo manejamos tokens single-line (los multi-line + // como block strings se splitean por línea). + if start.row == end.row { + if let Some(line) = per_line.get_mut(start.row) { + line.push(Span { + start_col: start.column, + end_col: end.column, + kind: tk, + }); + } + } else { + // Multi-line: marca cada línea entera como ese kind. + // Aproximación; suficiente para strings multi-línea. + for row in start.row..=end.row { + if let Some(line) = per_line.get_mut(row) { + let line_text = + source.lines().nth(row).unwrap_or(""); + let s = if row == start.row { start.column } else { 0 }; + let e = + if row == end.row { end.column } else { line_text.chars().count() }; + line.push(Span { start_col: s, end_col: e, kind: tk }); + } + } + } + } + } else { + for i in (0..node.child_count()).rev() { + if let Some(c) = node.child(i) { + stack.push(c); + } + } + } + } + + // Por cada línea: ordena, fusiona overlapping, rellena huecos. + let mut result: Vec> = Vec::with_capacity(per_line.len()); + for (row, mut spans) in per_line.into_iter().enumerate() { + let line_text = source.lines().nth(row).unwrap_or(""); + spans.sort_by_key(|s| s.start_col); + result.push(fill_gaps(spans, line_text.chars().count())); + } + result + } +} + +fn make_ts_parser(language: Language) -> Option { + let mut parser = tree_sitter::Parser::new(); + let lang: tree_sitter::Language = match language { + Language::Rust => tree_sitter_rust::LANGUAGE.into(), + Language::Python => tree_sitter_python::LANGUAGE.into(), + _ => return None, + }; + parser.set_language(&lang).ok()?; + Some(parser) +} + +/// Mapeo de tree-sitter node `kind` → TokenKind para Rust. +fn rust_kind(kind: &str) -> Option { + // Lista deliberadamente acotada al subset común; nodos no listados + // caen como Identifier/Other vía fill_gaps. + match kind { + // Keywords + "fn" | "let" | "mut" | "const" | "static" | "if" | "else" | "match" + | "for" | "while" | "loop" | "break" | "continue" | "return" | "use" + | "mod" | "pub" | "impl" | "trait" | "struct" | "enum" | "type" + | "where" | "as" | "in" | "ref" | "move" | "self" | "Self" | "crate" + | "super" | "async" | "await" | "dyn" | "unsafe" | "extern" => { + Some(TokenKind::Keyword) + } + // Tipos primitivos + "primitive_type" => Some(TokenKind::Type), + // Literales + "string_literal" | "raw_string_literal" | "char_literal" | "string_content" => { + Some(TokenKind::String) + } + "integer_literal" | "float_literal" | "boolean_literal" => Some(TokenKind::Number), + // Comentarios + "line_comment" | "block_comment" => Some(TokenKind::Comment), + _ => None, + } +} + +/// Mapeo para Python. +fn python_kind(kind: &str) -> Option { + match kind { + "def" | "class" | "if" | "elif" | "else" | "for" | "while" | "return" + | "import" | "from" | "as" | "in" | "is" | "not" | "and" | "or" + | "with" | "try" | "except" | "finally" | "raise" | "yield" | "pass" + | "break" | "continue" | "global" | "nonlocal" | "lambda" | "True" + | "False" | "None" | "async" | "await" => Some(TokenKind::Keyword), + "string" | "string_start" | "string_content" | "string_end" => Some(TokenKind::String), + "integer" | "float" | "true" | "false" | "none" => Some(TokenKind::Number), + "comment" => Some(TokenKind::Comment), + _ => None, + } +} + +// --------------------------------------------------------------------- +// WAT — tokenizer en Rust puro (sin tree-sitter). +// --------------------------------------------------------------------- + +fn highlight_wat(source: &str) -> Vec> { + let mut out: Vec> = Vec::new(); + for line in iterate_lines(source) { + out.push(tokenize_wat_line(line)); + } + out +} + +fn iterate_lines(source: &str) -> Vec<&str> { + let mut out: Vec<&str> = source.lines().collect(); + if source.ends_with('\n') || source.is_empty() { + out.push(""); + } + if out.is_empty() { + out.push(""); + } + out +} + +fn tokenize_wat_line(line: &str) -> Vec { + let mut out: Vec = Vec::new(); + let chars: Vec = line.chars().collect(); + let len = chars.len(); + let mut i = 0usize; + + while i < len { + let c = chars[i]; + + if c.is_whitespace() { + let start = i; + while i < len && chars[i].is_whitespace() { + i += 1; + } + out.push(Span { start_col: start, end_col: i, kind: TokenKind::Other }); + continue; + } + + // Comentario línea `;; ...` + if c == ';' && i + 1 < len && chars[i + 1] == ';' { + out.push(Span { start_col: i, end_col: len, kind: TokenKind::Comment }); + break; + } + + // Paren + if c == '(' || c == ')' { + out.push(Span { start_col: i, end_col: i + 1, kind: TokenKind::Punctuation }); + i += 1; + continue; + } + + // String "..." + if c == '"' { + let start = i; + i += 1; + while i < len { + let cc = chars[i]; + if cc == '\\' && i + 1 < len { + i += 2; + continue; + } + if cc == '"' { + i += 1; + break; + } + i += 1; + } + out.push(Span { start_col: start, end_col: i, kind: TokenKind::String }); + continue; + } + + // Identificador `$nombre` + if c == '$' { + let start = i; + i += 1; + while i < len && is_wat_ident_char(chars[i]) { + i += 1; + } + out.push(Span { start_col: start, end_col: i, kind: TokenKind::Identifier }); + continue; + } + + // Número (entero/hex/float — simplificado: empieza con dígito o -dígito). + if c.is_ascii_digit() || (c == '-' && i + 1 < len && chars[i + 1].is_ascii_digit()) { + let start = i; + if c == '-' { + i += 1; + } + while i < len { + let cc = chars[i]; + if cc.is_ascii_digit() || cc == '.' || cc == 'x' || cc.is_ascii_hexdigit() { + i += 1; + } else { + break; + } + } + out.push(Span { start_col: start, end_col: i, kind: TokenKind::Number }); + continue; + } + + // Word: keyword o identificador + if is_wat_word_start(c) { + let start = i; + while i < len && is_wat_ident_char(chars[i]) { + i += 1; + } + let word: String = chars[start..i].iter().collect(); + let kind = wat_word_kind(&word); + out.push(Span { start_col: start, end_col: i, kind }); + continue; + } + + // Otros (operadores como `.`) + out.push(Span { start_col: i, end_col: i + 1, kind: TokenKind::Operator }); + i += 1; + } + + fill_gaps(out, len) +} + +fn is_wat_word_start(c: char) -> bool { + c.is_ascii_alphabetic() || c == '_' +} +fn is_wat_ident_char(c: char) -> bool { + c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '!' | '#' | '$' | '%' | '&' | '\'' | '*' | '+' | '-' | '/' | ':' | '<' | '=' | '>' | '?' | '@' | '\\' | '^' | '`' | '|' | '~') +} + +fn wat_word_kind(w: &str) -> TokenKind { + const KEYWORDS: &[&str] = &[ + "module", "func", "param", "result", "local", "import", "export", + "memory", "data", "table", "elem", "type", "global", "start", "block", + "loop", "if", "then", "else", "end", "br", "br_if", "br_table", + "return", "call", "call_indirect", + ]; + const TYPES: &[&str] = &["i32", "i64", "f32", "f64", "v128", "funcref", "externref", "anyref"]; + + if KEYWORDS.contains(&w) { + TokenKind::Keyword + } else if TYPES.contains(&w) { + TokenKind::Type + } else if w.contains('.') { + // Instrucciones tipo `i32.const`, `local.get`, etc. + TokenKind::Function + } else { + TokenKind::Identifier + } +} + +// --------------------------------------------------------------------- +// Plain + utilities +// --------------------------------------------------------------------- + +fn plain_lines(source: &str) -> Vec> { + let mut out: Vec> = Vec::new(); + for line in iterate_lines(source) { + let len = line.chars().count(); + out.push(vec![Span { start_col: 0, end_col: len, kind: TokenKind::Other }]); + } + out +} + +/// Rellena los huecos entre spans con `Other` para cubrir `[0..line_len)`. +fn fill_gaps(spans: Vec, line_len: usize) -> Vec { + if spans.is_empty() { + return vec![Span { start_col: 0, end_col: line_len, kind: TokenKind::Other }]; + } + let mut out: Vec = Vec::with_capacity(spans.len() * 2); + let mut cursor = 0usize; + for s in spans { + if s.start_col > cursor { + out.push(Span { start_col: cursor, end_col: s.start_col, kind: TokenKind::Other }); + } + // Clampea overlaps con el anterior. + if s.end_col > cursor { + let start_col = s.start_col.max(cursor); + out.push(Span { start_col, end_col: s.end_col, kind: s.kind }); + cursor = s.end_col; + } + } + if cursor < line_len { + out.push(Span { start_col: cursor, end_col: line_len, kind: TokenKind::Other }); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_devuelve_un_span_por_linea() { + let mut h = Highlighter::new(Language::Plain); + let r = h.highlight("hola\nmundo"); + assert_eq!(r.len(), 2); + assert_eq!(r[0].len(), 1); + assert_eq!(r[0][0].kind, TokenKind::Other); + } + + #[test] + fn wat_paren_es_punctuation() { + let mut h = Highlighter::new(Language::Wat); + let r = h.highlight("(module)"); + let line = &r[0]; + let paren = line.iter().find(|s| s.kind == TokenKind::Punctuation).unwrap(); + assert_eq!(paren.start_col, 0); + assert_eq!(paren.end_col, 1); + } + + #[test] + fn wat_keyword_module_clasifica_como_keyword() { + let mut h = Highlighter::new(Language::Wat); + let r = h.highlight("(module)"); + let kw = r[0].iter().find(|s| s.kind == TokenKind::Keyword).unwrap(); + assert_eq!(kw.start_col, 1); + assert_eq!(kw.end_col, 7); + } + + #[test] + fn wat_tipo_i32_es_type() { + let mut h = Highlighter::new(Language::Wat); + let r = h.highlight("(result i32)"); + assert!(r[0].iter().any(|s| s.kind == TokenKind::Type)); + } + + #[test] + fn wat_string_y_comment() { + let mut h = Highlighter::new(Language::Wat); + let r = h.highlight(r#"(data "hola") ;; comentario"#); + assert!(r[0].iter().any(|s| s.kind == TokenKind::String)); + assert!(r[0].iter().any(|s| s.kind == TokenKind::Comment)); + } + + #[test] + fn wat_instruction_dotted_es_function() { + let mut h = Highlighter::new(Language::Wat); + let r = h.highlight("i32.const 42"); + assert!(r[0].iter().any(|s| s.kind == TokenKind::Function)); + assert!(r[0].iter().any(|s| s.kind == TokenKind::Number)); + } + + #[test] + fn rust_keyword_fn() { + let mut h = Highlighter::new(Language::Rust); + let r = h.highlight("fn main() {}"); + // El span de "fn" debe estar marcado como keyword. + assert!(r[0].iter().any(|s| s.kind == TokenKind::Keyword)); + } + + #[test] + fn python_keyword_def() { + let mut h = Highlighter::new(Language::Python); + let r = h.highlight("def f():\n return 1"); + assert!(r[0].iter().any(|s| s.kind == TokenKind::Keyword)); + // "return" en la línea 2. + assert!(r[1].iter().any(|s| s.kind == TokenKind::Keyword)); + } + + #[test] + fn fill_gaps_rellena_y_clampea() { + let spans = vec![ + Span { start_col: 2, end_col: 4, kind: TokenKind::Keyword }, + Span { start_col: 6, end_col: 9, kind: TokenKind::String }, + ]; + let filled = fill_gaps(spans, 10); + // [Other 0..2] [Keyword 2..4] [Other 4..6] [String 6..9] [Other 9..10] + assert_eq!(filled.len(), 5); + assert_eq!(filled[0].kind, TokenKind::Other); + assert_eq!(filled[4].kind, TokenKind::Other); + } + + #[test] + fn from_cell_language_mapea_aliases() { + assert_eq!(Language::from_cell_language("rust"), Language::Rust); + assert_eq!(Language::from_cell_language("rs"), Language::Rust); + assert_eq!(Language::from_cell_language("py"), Language::Python); + assert_eq!(Language::from_cell_language("wat"), Language::Wat); + assert_eq!(Language::from_cell_language("desconocido"), Language::Plain); + } +} diff --git a/widgets/text-editor-core/src/lib.rs b/widgets/text-editor-core/src/lib.rs new file mode 100644 index 0000000..d28fb8c --- /dev/null +++ b/widgets/text-editor-core/src/lib.rs @@ -0,0 +1,40 @@ +//! `llimphi-widget-text-editor-core` — núcleo agnóstico del editor de código. +//! +//! Capas finas y **puras** (sin IO, sin Llimphi, sin GPU) sobre [`ropey`]: +//! +//! - [`buffer`] — wrapper de `Rope` con conversiones (línea, col) ↔ char_offset. +//! - [`cursor`] — `Cursor` + `Selection`; movimiento por char/word/line/page. +//! - [`ops`] — operaciones puras de edición sobre `(Buffer, Cursor) → (Buffer, Cursor)`. +//! - [`undo`] — pila reversible: cada operación se registra como `EditDelta`. +//! - [`bracket`] — matching de paréntesis/llaves/corchetes. +//! - [`find`] — búsqueda incremental sobre el buffer. +//! - [`diagnostics`] — modelo de diagnósticos (errores/warnings) por rango. +//! - [`clipboard`] — abstracción de portapapeles (mem/null) sin tocar el SO. +//! - [`highlight`] — syntax highlighting con tree-sitter (Rust/Python/WAT/Plain). +//! +//! Único acoplamiento externo: [`peniko::Color`] en [`highlight::SyntaxPalette`] +//! — un tipo de color, no el stack de render. Eso deja el núcleo reutilizable +//! desde un TUI, una mini-REPL, un text-input single-line, un backend web, etc. +//! La capa visual (state + view sobre Llimphi) vive en +//! `llimphi-widget-text-editor`, que re-exporta todo este núcleo. + +#![forbid(unsafe_code)] + +pub mod bracket; +pub mod buffer; +pub mod clipboard; +pub mod cursor; +pub mod diagnostics; +pub mod find; +pub mod highlight; +pub mod ops; +pub mod undo; + +pub use buffer::Buffer; +pub use clipboard::{Clipboard, MemClipboard, NullClipboard}; +pub use cursor::{Cursor, Pos, Selection}; +pub use diagnostics::{Diagnostic, DiagnosticRange, Severity}; +pub use find::{all_matches, find_next, find_prev, FindState}; +pub use highlight::{Highlighter, Language, Span, SyntaxPalette, TokenKind}; +pub use ops::{indent_str, EditDelta}; +pub use undo::UndoStack; diff --git a/widgets/text-editor-core/src/ops.rs b/widgets/text-editor-core/src/ops.rs new file mode 100644 index 0000000..0381d17 --- /dev/null +++ b/widgets/text-editor-core/src/ops.rs @@ -0,0 +1,384 @@ +//! Operaciones de edición. Cada una toma `&mut Buffer + &mut Cursor` y +//! devuelve un [`EditDelta`] reversible que la pila de undo guarda. +//! +//! El delta es minimal: el rango `[start..end)` que se reemplazó + el +//! texto que estaba antes + el texto nuevo. Aplicado en reversa, +//! restaura el estado anterior exactamente. + +use crate::buffer::Buffer; +use crate::cursor::{Cursor, Pos}; + +/// Delta atómico de edición — útil para undo/redo y log de cambios. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EditDelta { + pub start: usize, + pub removed: String, + pub inserted: String, + /// Caret antes de la operación (para restaurarlo en undo). + pub cursor_before: Cursor, + /// Caret después de la operación. + pub cursor_after: Cursor, +} + +impl EditDelta { + /// Aplica el delta a `(buf, cursor)`. + pub fn apply(&self, buf: &mut Buffer, cursor: &mut Cursor) { + let end = self.start + self.removed.chars().count(); + buf.delete(self.start, end); + if !self.inserted.is_empty() { + buf.insert(self.start, &self.inserted); + } + *cursor = self.cursor_after; + } + + /// Aplica el inverso (undo). + pub fn undo(&self, buf: &mut Buffer, cursor: &mut Cursor) { + let end = self.start + self.inserted.chars().count(); + buf.delete(self.start, end); + if !self.removed.is_empty() { + buf.insert(self.start, &self.removed); + } + *cursor = self.cursor_before; + } +} + +/// Genera la string de indentación según la config. +pub fn indent_str(tab_to_spaces: bool, indent_size: usize) -> String { + if tab_to_spaces { + " ".repeat(indent_size) + } else { + "\t".to_string() + } +} + +/// Reemplaza la selección activa por `text`. Si no hay selección, +/// inserta `text` en el caret. Devuelve el delta resultante. +pub fn replace_selection( + buf: &mut Buffer, + cursor: &mut Cursor, + text: &str, +) -> EditDelta { + let before = *cursor; + let (start, end) = cursor.selection_range(buf); + let removed = buf.slice(start, end); + + if start != end { + buf.delete(start, end); + } + if !text.is_empty() { + buf.insert(start, text); + } + + let new_off = start + text.chars().count(); + let (line, col) = buf.offset_to_pos(new_off); + cursor.caret = Pos::new(line, col); + cursor.desired_col = col; + cursor.anchor = None; + + EditDelta { + start, + removed, + inserted: text.to_string(), + cursor_before: before, + cursor_after: *cursor, + } +} + +/// Borra hacia atrás (Backspace). Si hay selección, la borra; si no, +/// borra el char antes del caret. Devuelve `None` si no había nada que +/// borrar (cursor al inicio + sin selección). +pub fn delete_backward(buf: &mut Buffer, cursor: &mut Cursor) -> Option { + if cursor.has_selection() { + return Some(replace_selection(buf, cursor, "")); + } + let before = *cursor; + let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col); + if caret_off == 0 { + return None; + } + let removed = buf.slice(caret_off - 1, caret_off); + buf.delete(caret_off - 1, caret_off); + let (line, col) = buf.offset_to_pos(caret_off - 1); + cursor.caret = Pos::new(line, col); + cursor.desired_col = col; + Some(EditDelta { + start: caret_off - 1, + removed, + inserted: String::new(), + cursor_before: before, + cursor_after: *cursor, + }) +} + +/// Borra hacia adelante (Delete). +pub fn delete_forward(buf: &mut Buffer, cursor: &mut Cursor) -> Option { + if cursor.has_selection() { + return Some(replace_selection(buf, cursor, "")); + } + let before = *cursor; + let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col); + if caret_off >= buf.len_chars() { + return None; + } + let removed = buf.slice(caret_off, caret_off + 1); + buf.delete(caret_off, caret_off + 1); + Some(EditDelta { + start: caret_off, + removed, + inserted: String::new(), + cursor_before: before, + cursor_after: *cursor, + }) +} + +/// Inserta un salto de línea con **indentación automática**: copia los +/// whitespace iniciales del renglón actual al renglón nuevo. +pub fn insert_newline_auto_indent(buf: &mut Buffer, cursor: &mut Cursor) -> EditDelta { + let current_line = buf.line(cursor.caret.line); + let indent: String = current_line + .chars() + .take_while(|c| *c == ' ' || *c == '\t') + .collect(); + let text = format!("\n{indent}"); + replace_selection(buf, cursor, &text) +} + +/// Inserta un tab (o `indent_size` spaces según config). Si hay +/// selección **multilínea**, indenta cada línea de la selección. +pub fn indent_or_insert_tab( + buf: &mut Buffer, + cursor: &mut Cursor, + tab_to_spaces: bool, + indent_size: usize, +) -> EditDelta { + let indent = indent_str(tab_to_spaces, indent_size); + + // Sin selección o selección en una sola línea → inserta indent. + let multi_line = match cursor.selection() { + Some(sel) => sel.anchor.line != sel.caret.line, + None => false, + }; + if !multi_line { + return replace_selection(buf, cursor, &indent); + } + + // Selección multilínea: indenta cada línea afectada por el rango. + let before = *cursor; + let sel = cursor.selection().expect("multi_line implica selección"); + let first = sel.anchor.line.min(sel.caret.line); + let last = sel.anchor.line.max(sel.caret.line); + + let mut start_global = buf.pos_to_offset(first, 0); + let removed = String::new(); + let mut inserted = String::new(); + for line in first..=last { + let line_start = buf.pos_to_offset(line, 0); + buf.insert(line_start, &indent); + inserted.push_str(&indent); + let _ = start_global; // (sin uso; se mantiene por simetría) + start_global = buf.pos_to_offset(first, 0); + } + + // Mantenemos la selección extendida sobre las líneas indentadas. + let n_added = indent.chars().count(); + let new_anchor = Pos::new(sel.anchor.line, sel.anchor.col + n_added); + let new_caret = Pos::new(sel.caret.line, sel.caret.col + n_added); + cursor.anchor = Some(new_anchor); + cursor.caret = new_caret; + cursor.desired_col = new_caret.col; + + EditDelta { + start: start_global, + removed, + inserted, + cursor_before: before, + cursor_after: *cursor, + } +} + +/// Quita un nivel de indent del renglón actual (o de cada línea si hay +/// selección multilínea). Devuelve `None` si nada cambió. +pub fn dedent( + buf: &mut Buffer, + cursor: &mut Cursor, + tab_to_spaces: bool, + indent_size: usize, +) -> Option { + let before = *cursor; + let (first, last) = match cursor.selection() { + Some(sel) => ( + sel.anchor.line.min(sel.caret.line), + sel.anchor.line.max(sel.caret.line), + ), + None => (cursor.caret.line, cursor.caret.line), + }; + + let mut total_removed = 0usize; + let mut removed_text = String::new(); + let start_offset = buf.pos_to_offset(first, 0); + + for line in first..=last { + let line_str = buf.line(line); + let mut n = 0usize; + let mut chars = line_str.chars(); + if tab_to_spaces { + for _ in 0..indent_size { + if chars.next() == Some(' ') { + n += 1; + } else { + break; + } + } + } else if chars.next() == Some('\t') { + n = 1; + } + if n == 0 { + continue; + } + let line_start = buf.pos_to_offset(line, 0); + removed_text.push_str(&buf.slice(line_start, line_start + n)); + buf.delete(line_start, line_start + n); + total_removed += n; + } + + if total_removed == 0 { + return None; + } + + // Cursor: clampea col al nuevo line_len. + let caret_line = cursor.caret.line; + let caret_col = cursor + .caret + .col + .saturating_sub(if caret_line >= first && caret_line <= last { + // Cuánto se removió de esta línea (varía); aproximamos al + // common case de mismo n por línea. Si fuera distinto el + // visual queda OK porque clampea. + removed_text.chars().count() / (last - first + 1).max(1) + } else { + 0 + }); + cursor.caret.col = caret_col.min(buf.line_len_chars(caret_line)); + cursor.desired_col = cursor.caret.col; + + if let Some(anchor) = cursor.anchor.as_mut() { + if anchor.line >= first && anchor.line <= last { + anchor.col = anchor + .col + .saturating_sub(removed_text.chars().count() / (last - first + 1).max(1)) + .min(buf.line_len_chars(anchor.line)); + } + } + + Some(EditDelta { + start: start_offset, + removed: removed_text, + inserted: String::new(), + cursor_before: before, + cursor_after: *cursor, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn replace_selection_sin_seleccion_inserta() { + let mut b = Buffer::from_str("ab"); + let mut c = Cursor::at(0, 1); + let d = replace_selection(&mut b, &mut c, "X"); + assert_eq!(b.text(), "aXb"); + assert_eq!(c.caret, Pos::new(0, 2)); + assert_eq!(d.removed, ""); + assert_eq!(d.inserted, "X"); + } + + #[test] + fn replace_selection_con_seleccion_reemplaza() { + let mut b = Buffer::from_str("hola mundo"); + let mut c = Cursor { caret: Pos::new(0, 9), anchor: Some(Pos::new(0, 5)), desired_col: 9 }; + replace_selection(&mut b, &mut c, "luna"); + assert_eq!(b.text(), "hola lunao"); + } + + #[test] + fn backspace_borra_char() { + let mut b = Buffer::from_str("hola"); + let mut c = Cursor::at(0, 4); + delete_backward(&mut b, &mut c); + assert_eq!(b.text(), "hol"); + assert_eq!(c.caret, Pos::new(0, 3)); + } + + #[test] + fn backspace_en_inicio_no_hace_nada() { + let mut b = Buffer::from_str("a"); + let mut c = Cursor::at(0, 0); + assert!(delete_backward(&mut b, &mut c).is_none()); + } + + #[test] + fn delete_forward_borra_char() { + let mut b = Buffer::from_str("ab"); + let mut c = Cursor::at(0, 0); + delete_forward(&mut b, &mut c); + assert_eq!(b.text(), "b"); + } + + #[test] + fn newline_copia_indent_del_renglon_anterior() { + let mut b = Buffer::from_str(" hola"); + let mut c = Cursor::at(0, 8); + insert_newline_auto_indent(&mut b, &mut c); + assert_eq!(b.text(), " hola\n "); + assert_eq!(c.caret, Pos::new(1, 4)); + } + + #[test] + fn tab_inserta_spaces() { + let mut b = Buffer::from_str("ab"); + let mut c = Cursor::at(0, 1); + indent_or_insert_tab(&mut b, &mut c, true, 4); + assert_eq!(b.text(), "a b"); + assert_eq!(c.caret, Pos::new(0, 5)); + } + + #[test] + fn tab_con_seleccion_multilinea_indenta_cada_linea() { + let mut b = Buffer::from_str("a\nb\nc"); + let mut c = Cursor { + anchor: Some(Pos::new(0, 0)), + caret: Pos::new(2, 1), + desired_col: 1, + }; + indent_or_insert_tab(&mut b, &mut c, true, 2); + assert_eq!(b.text(), " a\n b\n c"); + } + + #[test] + fn dedent_quita_indent_del_renglon() { + let mut b = Buffer::from_str(" hola"); + let mut c = Cursor::at(0, 8); + dedent(&mut b, &mut c, true, 4); + assert_eq!(b.text(), "hola"); + } + + #[test] + fn dedent_sin_indent_devuelve_none() { + let mut b = Buffer::from_str("hola"); + let mut c = Cursor::at(0, 0); + assert!(dedent(&mut b, &mut c, true, 4).is_none()); + } + + #[test] + fn delta_undo_restaura_estado() { + let mut b = Buffer::from_str("hola"); + let mut c = Cursor::at(0, 4); + let d = replace_selection(&mut b, &mut c, "!"); + assert_eq!(b.text(), "hola!"); + d.undo(&mut b, &mut c); + assert_eq!(b.text(), "hola"); + assert_eq!(c.caret, Pos::new(0, 4)); + } +} diff --git a/widgets/text-editor-core/src/undo.rs b/widgets/text-editor-core/src/undo.rs new file mode 100644 index 0000000..6de8ad4 --- /dev/null +++ b/widgets/text-editor-core/src/undo.rs @@ -0,0 +1,133 @@ +//! Pila de undo/redo basada en [`EditDelta`]. +//! +//! API simple: `push(delta)` añade al historial y limpia el stack de +//! redo; `undo`/`redo` aplican o reaplican deltas existentes. No +//! coalesce inserciones consecutivas — cada keystroke es un delta; +//! para una UX más fina, el llamador puede agrupar deltas relacionados +//! (ej. cada secuencia de chars imprimibles hasta whitespace). + +use crate::buffer::Buffer; +use crate::cursor::Cursor; +use crate::ops::EditDelta; + +const DEFAULT_CAPACITY: usize = 256; + +#[derive(Debug, Clone, Default)] +pub struct UndoStack { + /// Deltas aplicados, en orden cronológico. El `Vec::last` es el + /// próximo candidato a deshacer. + done: Vec, + /// Deltas deshechos disponibles para redo (en orden inverso del + /// `undo`: el último deshecho es el primero a rehacer). + undone: Vec, + capacity: usize, +} + +impl UndoStack { + pub fn new() -> Self { + Self::with_capacity(DEFAULT_CAPACITY) + } + pub fn with_capacity(capacity: usize) -> Self { + Self { + done: Vec::with_capacity(capacity.min(64)), + undone: Vec::new(), + capacity, + } + } + + /// Registra un delta. Limpia el stack de redo (la rama alternativa + /// se pierde, como en todo editor estándar). + pub fn push(&mut self, delta: EditDelta) { + self.done.push(delta); + self.undone.clear(); + if self.done.len() > self.capacity { + // Truncamos por el extremo viejo. + let drop = self.done.len() - self.capacity; + self.done.drain(0..drop); + } + } + + pub fn can_undo(&self) -> bool { + !self.done.is_empty() + } + pub fn can_redo(&self) -> bool { + !self.undone.is_empty() + } + + pub fn undo(&mut self, buf: &mut Buffer, cursor: &mut Cursor) -> bool { + let Some(delta) = self.done.pop() else { + return false; + }; + delta.undo(buf, cursor); + self.undone.push(delta); + true + } + + pub fn redo(&mut self, buf: &mut Buffer, cursor: &mut Cursor) -> bool { + let Some(delta) = self.undone.pop() else { + return false; + }; + delta.apply(buf, cursor); + self.done.push(delta); + true + } + + pub fn clear(&mut self) { + self.done.clear(); + self.undone.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ops::replace_selection; + + #[test] + fn undo_y_redo_son_simetricos() { + let mut b = Buffer::from_str("a"); + let mut c = Cursor::at(0, 1); + let mut st = UndoStack::new(); + + st.push(replace_selection(&mut b, &mut c, "b")); + st.push(replace_selection(&mut b, &mut c, "c")); + assert_eq!(b.text(), "abc"); + + assert!(st.undo(&mut b, &mut c)); + assert_eq!(b.text(), "ab"); + assert!(st.undo(&mut b, &mut c)); + assert_eq!(b.text(), "a"); + + assert!(st.redo(&mut b, &mut c)); + assert_eq!(b.text(), "ab"); + assert!(st.redo(&mut b, &mut c)); + assert_eq!(b.text(), "abc"); + } + + #[test] + fn push_limpia_redo() { + let mut b = Buffer::from_str("a"); + let mut c = Cursor::at(0, 1); + let mut st = UndoStack::new(); + st.push(replace_selection(&mut b, &mut c, "b")); + st.undo(&mut b, &mut c); + assert!(st.can_redo()); + st.push(replace_selection(&mut b, &mut c, "X")); + assert!(!st.can_redo()); + } + + #[test] + fn capacity_descartan_viejos() { + let mut b = Buffer::from_str(""); + let mut c = Cursor::at(0, 0); + let mut st = UndoStack::with_capacity(2); + for ch in ["a", "b", "c"] { + st.push(replace_selection(&mut b, &mut c, ch)); + } + // Sólo deberían quedar los últimos 2 deltas; el undo del primero + // (cuando ya no está) no debería hacer nada. + st.undo(&mut b, &mut c); + st.undo(&mut b, &mut c); + assert!(!st.undo(&mut b, &mut c)); + } +} diff --git a/widgets/text-editor-lsp/Cargo.toml b/widgets/text-editor-lsp/Cargo.toml new file mode 100644 index 0000000..a7009ba --- /dev/null +++ b/widgets/text-editor-lsp/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "llimphi-widget-text-editor-lsp" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-text-editor-lsp — trait LspClient + NoopLspClient como foundation. El cliente real (rust-analyzer/pylsp con tokio + jsonrpc) queda como TODO para una sesión dedicada." + +[dependencies] +llimphi-widget-text-editor = { workspace = true } +lsp-types = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] + diff --git a/widgets/text-editor-lsp/LEEME.md b/widgets/text-editor-lsp/LEEME.md new file mode 100644 index 0000000..867c27c --- /dev/null +++ b/widgets/text-editor-lsp/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-text-editor-lsp + +> [`text-editor`](../text-editor/README.md) + LSP para [llimphi](../../README.md). + +Wrapper que conecta el editor a un servidor LSP (rust-analyzer, pyright, ...). Hover, goto-definition, autocomplete, diagnostics inline, formatter al guardar. diff --git a/widgets/text-editor-lsp/README.md b/widgets/text-editor-lsp/README.md new file mode 100644 index 0000000..8be0f1b --- /dev/null +++ b/widgets/text-editor-lsp/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-text-editor-lsp + +> [`text-editor`](../text-editor/README.md) + LSP for [llimphi](../../README.md). + +Wrapper connecting the editor to an LSP server (rust-analyzer, pyright, ...). Hover, goto-definition, autocomplete, inline diagnostics, format on save. diff --git a/widgets/text-editor-lsp/src/client.rs b/widgets/text-editor-lsp/src/client.rs new file mode 100644 index 0000000..20e21dd --- /dev/null +++ b/widgets/text-editor-lsp/src/client.rs @@ -0,0 +1,501 @@ +use super::*; + +pub struct RustAnalyzerClient { + /// Diagnostics activos por path. Lo escribe la task reader. + state: SharedState, + /// Sender al writer task. `None` si el spawn falló (modo no-op). + tx: Option>, + /// Contador monotónico de request IDs. + next_id: i64, + /// Versiones por documento — el server las requiere en didChange. + versions: HashMap, + /// Runtime tokio dedicado — vive todo lo que viva el client. + /// `None` si el spawn falló. + _runtime: Option>, +} + +impl RustAnalyzerClient { + /// Spawn `rust-analyzer` en `workspace_root`. Si el binary no está + /// en PATH, devuelve un client en modo no-op (sin error). + pub fn start(workspace_root: PathBuf) -> Self { + Self::with_command(workspace_root, "rust-analyzer") + } + + /// Como `start` pero permite indicar el binary (`pylsp`, etc.). + pub fn with_command(workspace_root: PathBuf, command: &str) -> Self { + let state: SharedState = Arc::new(Mutex::new(SharedInner::default())); + let runtime = match tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + { + Ok(rt) => Arc::new(rt), + Err(_) => { + return Self { + state, + tx: None, + next_id: 1, + versions: HashMap::new(), + _runtime: None, + }; + } + }; + + let (tx, rx) = tokio::sync::mpsc::unbounded_channel::(); + let state_clone = state.clone(); + let workspace_root_clone = workspace_root.clone(); + let command_string = command.to_string(); + + runtime.spawn(async move { + if let Err(e) = run_server(workspace_root_clone, command_string, rx, state_clone).await + { + eprintln!("lsp: server task terminó con error: {e}"); + } + }); + + let mut client = Self { + state, + tx: Some(tx), + next_id: 1, + versions: HashMap::new(), + _runtime: Some(runtime), + }; + client.send_initialize(&workspace_root); + client + } + + fn send_initialize(&mut self, root: &Path) { + let id = self.alloc_id(); + let params = serde_json::json!({ + "processId": std::process::id(), + "rootUri": format!("file://{}", root.display()), + "capabilities": { + "textDocument": { + "publishDiagnostics": { "relatedInformation": false } + } + }, + "clientInfo": { "name": "llimphi-text-editor-lsp", "version": "0.1.0" } + }); + let req = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "initialize", + "params": params + }); + self.send_raw(req.to_string()); + // El handshake termina con la notification `initialized` que + // mandamos sin esperar la response — el reader la procesará. + let notif = serde_json::json!({ + "jsonrpc": "2.0", + "method": "initialized", + "params": {} + }); + self.send_raw(notif.to_string()); + } + + fn alloc_id(&mut self) -> i64 { + let id = self.next_id; + self.next_id += 1; + id + } + + fn send_raw(&self, msg: String) { + if let Some(tx) = &self.tx { + let _ = tx.send(msg); + } + } + + fn lsp_language_id(language: &str) -> &str { + match language { + "rust" | "rs" => "rust", + "python" | "py" => "python", + other => other, + } + } +} + +impl LspClient for RustAnalyzerClient { + fn diagnostics(&self, path: &Path) -> Vec { + self.state + .lock() + .ok() + .and_then(|s| s.diagnostics.get(path).cloned()) + .unwrap_or_default() + } + + fn request_completions(&mut self, path: &Path, line: usize, col: usize) { + let id = self.alloc_id(); + if let Ok(mut s) = self.state.lock() { + s.pending_completion_ids.insert(id); + } + let req = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "textDocument/completion", + "params": { + "textDocument": { "uri": format!("file://{}", path.display()) }, + "position": { "line": line, "character": col } + } + }); + self.send_raw(req.to_string()); + } + + fn latest_completions(&self) -> Vec { + self.state + .lock() + .map(|s| s.completions.clone()) + .unwrap_or_default() + } + + fn clear_completions(&mut self) { + if let Ok(mut s) = self.state.lock() { + s.completions.clear(); + } + } + + fn request_hover(&mut self, path: &Path, line: usize, col: usize) { + let id = self.alloc_id(); + if let Ok(mut s) = self.state.lock() { + s.pending_hover_ids.insert(id); + } + let req = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "textDocument/hover", + "params": { + "textDocument": { "uri": format!("file://{}", path.display()) }, + "position": { "line": line, "character": col } + } + }); + self.send_raw(req.to_string()); + } + + fn latest_hover(&self) -> Option { + self.state.lock().ok().and_then(|s| s.hover.clone()) + } + + fn clear_hover(&mut self) { + if let Ok(mut s) = self.state.lock() { + s.hover = None; + } + } + + fn request_definition(&mut self, path: &Path, line: usize, col: usize) { + let id = self.alloc_id(); + if let Ok(mut s) = self.state.lock() { + s.pending_definition_ids.insert(id); + } + let req = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "textDocument/definition", + "params": { + "textDocument": { "uri": format!("file://{}", path.display()) }, + "position": { "line": line, "character": col } + } + }); + self.send_raw(req.to_string()); + } + + fn latest_definition(&self) -> Option { + self.state.lock().ok().and_then(|s| s.definition.clone()) + } + + fn clear_definition(&mut self) { + if let Ok(mut s) = self.state.lock() { + s.definition = None; + } + } + + fn request_formatting(&mut self, path: &Path, tab_size: u32, insert_spaces: bool) { + let id = self.alloc_id(); + if let Ok(mut s) = self.state.lock() { + s.pending_formatting_ids.insert(id); + } + let req = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "textDocument/formatting", + "params": { + "textDocument": { "uri": format!("file://{}", path.display()) }, + "options": { + "tabSize": tab_size, + "insertSpaces": insert_spaces + } + } + }); + self.send_raw(req.to_string()); + } + + fn latest_text_edits(&self) -> Vec { + self.state.lock().map(|s| s.text_edits.clone()).unwrap_or_default() + } + + fn clear_text_edits(&mut self) { + if let Ok(mut s) = self.state.lock() { + s.text_edits.clear(); + } + } + + fn request_signature_help(&mut self, path: &Path, line: usize, col: usize) { + let id = self.alloc_id(); + if let Ok(mut s) = self.state.lock() { + s.pending_signature_help_ids.insert(id); + } + let req = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "textDocument/signatureHelp", + "params": { + "textDocument": { "uri": format!("file://{}", path.display()) }, + "position": { "line": line, "character": col } + } + }); + self.send_raw(req.to_string()); + } + + fn latest_signature_help(&self) -> Option { + self.state.lock().ok().and_then(|s| s.signature_help.clone()) + } + + fn clear_signature_help(&mut self) { + if let Ok(mut s) = self.state.lock() { + s.signature_help = None; + } + } + + fn request_references(&mut self, path: &Path, line: usize, col: usize, include_decl: bool) { + let id = self.alloc_id(); + if let Ok(mut s) = self.state.lock() { + s.pending_references_ids.insert(id); + } + let req = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "textDocument/references", + "params": { + "textDocument": { "uri": format!("file://{}", path.display()) }, + "position": { "line": line, "character": col }, + "context": { "includeDeclaration": include_decl } + } + }); + self.send_raw(req.to_string()); + } + + fn latest_references(&self) -> Vec { + self.state.lock().map(|s| s.references.clone()).unwrap_or_default() + } + + fn clear_references(&mut self) { + if let Ok(mut s) = self.state.lock() { + s.references.clear(); + } + } + + fn request_rename(&mut self, path: &Path, line: usize, col: usize, new_name: &str) { + let id = self.alloc_id(); + if let Ok(mut s) = self.state.lock() { + s.pending_rename_ids.insert(id); + } + let req = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "textDocument/rename", + "params": { + "textDocument": { "uri": format!("file://{}", path.display()) }, + "position": { "line": line, "character": col }, + "newName": new_name + } + }); + self.send_raw(req.to_string()); + } + + fn latest_workspace_edit(&self) -> std::collections::HashMap> { + self.state.lock().map(|s| s.workspace_edit.clone()).unwrap_or_default() + } + + fn clear_workspace_edit(&mut self) { + if let Ok(mut s) = self.state.lock() { + s.workspace_edit.clear(); + } + } + + fn request_document_symbols(&mut self, path: &Path) { + let id = self.alloc_id(); + if let Ok(mut s) = self.state.lock() { + s.pending_document_symbols_ids.insert(id); + } + let req = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "textDocument/documentSymbol", + "params": { + "textDocument": { "uri": format!("file://{}", path.display()) } + } + }); + self.send_raw(req.to_string()); + } + + fn latest_document_symbols(&self) -> Vec { + self.state.lock().map(|s| s.document_symbols.clone()).unwrap_or_default() + } + + fn clear_document_symbols(&mut self) { + if let Ok(mut s) = self.state.lock() { + s.document_symbols.clear(); + } + } + + fn did_open(&mut self, path: &Path, language: &str, text: &str) { + self.versions.insert(path.to_path_buf(), 1); + let notif = serde_json::json!({ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": format!("file://{}", path.display()), + "languageId": Self::lsp_language_id(language), + "version": 1, + "text": text, + } + } + }); + self.send_raw(notif.to_string()); + } + + fn did_change(&mut self, path: &Path, new_text: &str) { + let version = { + let v = self.versions.entry(path.to_path_buf()).or_insert(1); + *v += 1; + *v + }; + // Full-document change. Más eficiente sería incremental, pero + // requiere trackear los EditDeltas del editor — futuro. + let notif = serde_json::json!({ + "jsonrpc": "2.0", + "method": "textDocument/didChange", + "params": { + "textDocument": { + "uri": format!("file://{}", path.display()), + "version": version, + }, + "contentChanges": [{ "text": new_text }] + } + }); + self.send_raw(notif.to_string()); + } + + fn did_close(&mut self, path: &Path) { + self.versions.remove(path); + let notif = serde_json::json!({ + "jsonrpc": "2.0", + "method": "textDocument/didClose", + "params": { + "textDocument": { "uri": format!("file://{}", path.display()) } + } + }); + self.send_raw(notif.to_string()); + if let Ok(mut s) = self.state.lock() { + s.diagnostics.remove(path); + } + } +} + +// --------------------------------------------------------------------- +// Task tokio que corre el server + bombea I/O +// --------------------------------------------------------------------- + +async fn run_server( + _workspace_root: PathBuf, + command: String, + mut rx: tokio::sync::mpsc::UnboundedReceiver, + state: SharedState, +) -> std::io::Result<()> { + use std::process::Stdio; + use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; + use tokio::process::Command; + + let mut child = match Command::new(&command) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(c) => c, + Err(e) => { + eprintln!("lsp: no pude spawn `{command}`: {e}"); + return Ok(()); + } + }; + + let stdin = child.stdin.take().expect("stdin piped"); + let stdout = child.stdout.take().expect("stdout piped"); + + // Writer task: consume el rx y manda al stdin con headers LSP. + let writer = tokio::spawn(async move { + let mut stdin = stdin; + while let Some(msg) = rx.recv().await { + let header = format!("Content-Length: {}\r\n\r\n", msg.len()); + if stdin.write_all(header.as_bytes()).await.is_err() { + break; + } + if stdin.write_all(msg.as_bytes()).await.is_err() { + break; + } + let _ = stdin.flush().await; + } + }); + + // Reader task: parsea mensajes del stdout, procesa publishDiagnostics. + let reader = tokio::spawn({ + let state = state.clone(); + async move { + let mut reader = BufReader::new(stdout); + loop { + let mut content_length: Option = None; + // Headers — terminan con línea vacía. + loop { + let mut line = String::new(); + match reader.read_line(&mut line).await { + Ok(0) => return, // EOF + Ok(_) => {} + Err(_) => return, + } + let line = line.trim_end_matches(['\r', '\n']); + if line.is_empty() { + break; + } + if let Some(rest) = line.strip_prefix("Content-Length:") { + if let Ok(n) = rest.trim().parse::() { + content_length = Some(n); + } + } + } + let Some(len) = content_length else { continue }; + let mut buf = vec![0u8; len]; + if reader.read_exact(&mut buf).await.is_err() { + return; + } + let Ok(json) = serde_json::from_slice::(&buf) else { + continue; + }; + if json.get("method").and_then(|m| m.as_str()) + == Some("textDocument/publishDiagnostics") + { + handle_publish_diagnostics(&json, &state); + } else if let Some(id) = json.get("id").and_then(|i| i.as_i64()) { + handle_response(id, &json, &state); + } + } + } + }); + + // Esperamos a que se cierre cualquiera de los dos lados o el child. + tokio::select! { + _ = writer => {} + _ = reader => {} + _ = child.wait() => {} + } + let _ = child.kill().await; + Ok(()) +} diff --git a/widgets/text-editor-lsp/src/lib.rs b/widgets/text-editor-lsp/src/lib.rs new file mode 100644 index 0000000..37e16e1 --- /dev/null +++ b/widgets/text-editor-lsp/src/lib.rs @@ -0,0 +1,275 @@ +//! `llimphi-widget-text-editor-lsp` — cliente LSP para alimentar +//! diagnostics al editor. +//! +//! Implementación real basada en `tokio::process::Command` + +//! `lsp-types` + JSON-RPC sobre stdin/stdout del language server. +//! +//! Flujo: +//! +//! 1. `RustAnalyzerClient::start(workspace_root)` spawn `rust-analyzer` +//! (o el binary que se le pase con `with_command`) y arranca dos +//! tasks tokio: +//! - **writer**: consume mensajes del `mpsc::Sender`, los serializa +//! con headers `Content-Length: N\r\n\r\n` y los manda al stdin. +//! - **reader**: parsea el stdout del server (mismo formato), +//! atiende `textDocument/publishDiagnostics` y guarda los +//! diagnostics en el state compartido. +//! 2. El handshake `initialize` se envía sincronicamente desde `start` +//! y se espera la respuesta antes de mandar `initialized` + +//! procesar más mensajes. +//! 3. `did_open` / `did_change` / `did_close` mandan las notifications +//! correspondientes — sin esperar respuesta. +//! 4. `diagnostics(path)` lee del state sin contactar al server. +//! +//! El client maneja **una sola conexión por instancia**. Para +//! multi-proyecto el caller crea varios clients. +//! +//! Si el server no se puede spawnear (binary no instalado), el client +//! cae en modo no-op transparentemente — `diagnostics` devuelve vacío. + +#![forbid(unsafe_code)] + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use llimphi_widget_text_editor::{Diagnostic, DiagnosticRange, Pos, Severity}; + +/// Item de completion — mirror minimal de `lsp_types::CompletionItem`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompletionItem { + pub label: String, + /// Texto a insertar. Si `None`, se usa `label`. + pub insert_text: Option, + /// Tipo del símbolo según LSP (Function, Variable, etc.) — para + /// mostrar un ícono. Aquí lo guardamos como string corto. + pub kind: Option, + /// Documentación corta — el primer renglón típicamente. + pub detail: Option, +} + +impl CompletionItem { + pub fn text_to_insert(&self) -> &str { + self.insert_text.as_deref().unwrap_or(self.label.as_str()) + } +} + +/// Contrato que un client LSP debe cumplir para alimentar al editor. +pub trait LspClient: Send { + fn diagnostics(&self, path: &Path) -> Vec; + fn did_open(&mut self, path: &Path, language: &str, text: &str); + fn did_change(&mut self, path: &Path, new_text: &str); + fn did_close(&mut self, path: &Path); + /// Dispara una petición de completions en `(line, col)` del path. + /// Fire-and-forget; la respuesta se lee con `latest_completions`. + fn request_completions(&mut self, path: &Path, line: usize, col: usize); + /// Última lista de completions recibida (cualquier path/pos). + /// Vacío hasta que el server responda. El client la limpia cuando + /// el caller llama `clear_completions`. + fn latest_completions(&self) -> Vec; + /// Borra el cache de completions — útil al cerrar el popup. + fn clear_completions(&mut self); + /// Dispara textDocument/hover. Fire-and-forget; el caller polla + /// `latest_hover` para leer la respuesta. + fn request_hover(&mut self, path: &Path, line: usize, col: usize); + /// Última hover info recibida (cualquier path/pos). + fn latest_hover(&self) -> Option; + /// Borra el cache de hover. + fn clear_hover(&mut self); + /// Dispara textDocument/definition. Fire-and-forget; el caller + /// polla `latest_definition`. + fn request_definition(&mut self, path: &Path, line: usize, col: usize); + /// Última definition recibida (path destino + pos de inicio). + fn latest_definition(&self) -> Option; + fn clear_definition(&mut self); + /// Dispara textDocument/formatting. Cuando llega la response, el + /// caller polla `latest_text_edits` y los aplica al buffer. + fn request_formatting(&mut self, path: &Path, tab_size: u32, insert_spaces: bool); + /// Última lista de TextEdits recibida (de formatting o rename). + fn latest_text_edits(&self) -> Vec; + fn clear_text_edits(&mut self); + /// Dispara textDocument/signatureHelp. Cuando llega, el popup + /// muestra la firma activa con el parámetro current resaltado. + fn request_signature_help(&mut self, path: &Path, line: usize, col: usize); + fn latest_signature_help(&self) -> Option; + fn clear_signature_help(&mut self); + /// Dispara textDocument/references. `include_decl` controla si la + /// declaración misma aparece en los resultados. + fn request_references(&mut self, path: &Path, line: usize, col: usize, include_decl: bool); + fn latest_references(&self) -> Vec; + fn clear_references(&mut self); + /// Dispara textDocument/rename con `new_name` como nuevo identificador. + fn request_rename(&mut self, path: &Path, line: usize, col: usize, new_name: &str); + /// Última WorkspaceEdit recibida (rename o code actions). Mapeado a + /// `path → Vec` por simplicidad. + fn latest_workspace_edit(&self) -> std::collections::HashMap>; + fn clear_workspace_edit(&mut self); + + /// Dispara textDocument/documentSymbol. La respuesta llega + /// asincrónica; el caller la recoge con [`latest_document_symbols`]. + fn request_document_symbols(&mut self, path: &Path); + /// Última respuesta de documentSymbol — lista plana flattening del + /// árbol jerárquico que devuelve el server. Orden: top-down, + /// children en orden de aparición. `depth` refleja la profundidad + /// para que el caller indente visualmente. + fn latest_document_symbols(&self) -> Vec; + fn clear_document_symbols(&mut self); +} + +/// Una entrada flattening del árbol `DocumentSymbol` del LSP. Espejo +/// mínimo que evita arrastrar `lsp_types::SymbolKind` a los hosts — +/// `kind` viene ya como string corta (`"fn"`, `"struct"`, `"method"`, …). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocumentSymbolEntry { + pub name: String, + pub kind: String, + pub line: usize, + pub col: usize, + pub container: Option, + pub depth: u32, +} + +/// Info de signatureHelp activa. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignatureHelpInfo { + /// Firma activa (label completa, ej. "fn foo(x: i32, y: String) -> u64"). + pub label: String, + /// Documentación de la firma activa. + pub doc: Option, + /// Índice del parámetro current (0-based). + pub active_param: usize, + /// Labels de los parámetros — para resaltar el activo. + pub param_labels: Vec, +} + +/// Edit estilo LSP: reemplazar el rango `[start..end)` por `new_text`. +/// Para apply: ordenar desc por `start` y aplicar uno por uno. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TextEdit { + pub start_line: usize, + pub start_col: usize, + pub end_line: usize, + pub end_col: usize, + pub new_text: String, +} + +/// Resultado de un goto-definition: archivo destino + posición. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DefinitionLocation { + pub path: PathBuf, + pub line: usize, + pub col: usize, +} + +/// Información de hover — espejo simplificado de `lsp_types::Hover`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HoverInfo { + /// Markdown / plaintext del símbolo bajo el cursor. El render del + /// caller lo muestra tal cual (sin parsear markdown todavía). + pub contents: String, +} + +/// Stub que no hace nada — útil cuando no hay LSP configurado o para tests. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopLspClient; + +impl LspClient for NoopLspClient { + fn diagnostics(&self, _: &Path) -> Vec { + Vec::new() + } + fn did_open(&mut self, _: &Path, _: &str, _: &str) {} + fn did_change(&mut self, _: &Path, _: &str) {} + fn did_close(&mut self, _: &Path) {} + fn request_completions(&mut self, _: &Path, _: usize, _: usize) {} + fn latest_completions(&self) -> Vec { + Vec::new() + } + fn clear_completions(&mut self) {} + fn request_hover(&mut self, _: &Path, _: usize, _: usize) {} + fn latest_hover(&self) -> Option { + None + } + fn clear_hover(&mut self) {} + fn request_definition(&mut self, _: &Path, _: usize, _: usize) {} + fn latest_definition(&self) -> Option { + None + } + fn clear_definition(&mut self) {} + fn request_formatting(&mut self, _: &Path, _: u32, _: bool) {} + fn latest_text_edits(&self) -> Vec { + Vec::new() + } + fn clear_text_edits(&mut self) {} + fn request_signature_help(&mut self, _: &Path, _: usize, _: usize) {} + fn latest_signature_help(&self) -> Option { + None + } + fn clear_signature_help(&mut self) {} + fn request_references(&mut self, _: &Path, _: usize, _: usize, _: bool) {} + fn latest_references(&self) -> Vec { + Vec::new() + } + fn clear_references(&mut self) {} + fn request_rename(&mut self, _: &Path, _: usize, _: usize, _: &str) {} + fn latest_workspace_edit(&self) -> std::collections::HashMap> { + std::collections::HashMap::new() + } + fn clear_workspace_edit(&mut self) {} + fn request_document_symbols(&mut self, _: &Path) {} + fn latest_document_symbols(&self) -> Vec { + Vec::new() + } + fn clear_document_symbols(&mut self) {} +} + +// --------------------------------------------------------------------- +// Rust-analyzer client real +// --------------------------------------------------------------------- + +/// State compartido: paths → versión + diagnostics actuales + última +/// lista de completions recibida. +#[derive(Default)] +struct SharedInner { + diagnostics: HashMap>, + /// Última respuesta de completions — sobreescribe cualquier + /// request previo. El caller decide cuándo limpiar. + completions: Vec, + /// Última hover info recibida. + hover: Option, + /// Última definition recibida. + definition: Option, + /// Última lista de TextEdits (formatting / rename). + text_edits: Vec, + /// Última signature help. + signature_help: Option, + /// Última lista de references. + references: Vec, + /// Última WorkspaceEdit (de rename). Mapeo path → edits. + workspace_edit: HashMap>, + /// Última lista de document symbols (flattened del árbol que devuelve + /// el server). Se sobreescribe en cada request. + document_symbols: Vec, + /// IDs de requests pendientes para distinguir responses; el reader + /// usa estos sets para routear cada response al handler correcto. + pending_completion_ids: std::collections::HashSet, + pending_hover_ids: std::collections::HashSet, + pending_definition_ids: std::collections::HashSet, + pending_formatting_ids: std::collections::HashSet, + pending_signature_help_ids: std::collections::HashSet, + pending_references_ids: std::collections::HashSet, + pending_rename_ids: std::collections::HashSet, + pending_document_symbols_ids: std::collections::HashSet, +} + +type SharedState = Arc>; + +// Cliente y protocolo partidos del monolito (regla dura #1, 1660 LOC): +// `client` (RustAnalyzerClient + impls), `protocol` (parsers/handlers JSON-RPC). +mod client; +mod protocol; + +pub use client::RustAnalyzerClient; +pub(crate) use protocol::*; + +#[cfg(test)] +mod tests; diff --git a/widgets/text-editor-lsp/src/protocol.rs b/widgets/text-editor-lsp/src/protocol.rs new file mode 100644 index 0000000..f30df0c --- /dev/null +++ b/widgets/text-editor-lsp/src/protocol.rs @@ -0,0 +1,515 @@ +//! Parsers y handlers JSON-RPC de las respuestas/notificaciones LSP. + +use super::*; + +pub(crate) fn handle_publish_diagnostics(json: &serde_json::Value, state: &SharedState) { + let Some(params) = json.get("params") else { return }; + let Some(uri) = params.get("uri").and_then(|u| u.as_str()) else { return }; + let path = match uri.strip_prefix("file://") { + Some(p) => PathBuf::from(p), + None => return, + }; + let Some(diags_arr) = params.get("diagnostics").and_then(|d| d.as_array()) else { + return; + }; + let diagnostics: Vec = diags_arr + .iter() + .filter_map(parse_lsp_diagnostic) + .collect(); + if let Ok(mut s) = state.lock() { + s.diagnostics.insert(path, diagnostics); + } +} + +/// Routea una response del server al handler correspondiente según +/// qué set de pendientes la contenía. +pub(crate) fn handle_response(id: i64, json: &serde_json::Value, state: &SharedState) { + let flags = { + let Ok(mut s) = state.lock() else { return }; + ( + s.pending_completion_ids.remove(&id), + s.pending_hover_ids.remove(&id), + s.pending_definition_ids.remove(&id), + s.pending_formatting_ids.remove(&id), + s.pending_signature_help_ids.remove(&id), + s.pending_references_ids.remove(&id), + s.pending_rename_ids.remove(&id), + s.pending_document_symbols_ids.remove(&id), + ) + }; + let (was_completion, was_hover, was_def, was_fmt, was_sig, was_refs, was_rename, was_doc_sym) = + flags; + if was_completion { + handle_completion_response(json, state); + } + if was_hover { + handle_hover_response(json, state); + } + if was_def { + handle_definition_response(json, state); + } + if was_fmt { + handle_text_edits_response(json, state); + } + if was_sig { + handle_signature_help_response(json, state); + } + if was_refs { + handle_references_response(json, state); + } + if was_rename { + handle_rename_response(json, state); + } + if was_doc_sym { + handle_document_symbols_response(json, state); + } +} + +/// Parsea la respuesta de `textDocument/documentSymbol`. Devuelve dos +/// formatos posibles según la versión del server: +/// +/// - `DocumentSymbol[]` (jerárquico, moderno) — el que usa rust-analyzer. +/// - `SymbolInformation[]` (plano, legacy) — fallback razonable. +/// +/// Ambos se flatten a `Vec` con depth para que el +/// caller pueda indentar visualmente. +pub(crate) fn handle_document_symbols_response(json: &serde_json::Value, state: &SharedState) { + let Some(result) = json.get("result") else { return }; + if result.is_null() { + if let Ok(mut s) = state.lock() { + s.document_symbols.clear(); + } + return; + } + let mut out: Vec = Vec::new(); + if let Some(arr) = result.as_array() { + for item in arr { + // Distingue por la presencia de "selectionRange" (sólo en + // DocumentSymbol). SymbolInformation tiene "location" en + // su lugar. + if item.get("selectionRange").is_some() { + flatten_document_symbol(item, None, 0, &mut out); + } else if item.get("location").is_some() { + if let Some(entry) = parse_symbol_information(item) { + out.push(entry); + } + } + } + } + if let Ok(mut s) = state.lock() { + s.document_symbols = out; + } +} + +/// Flatten recursivo de `DocumentSymbol`. `parent` es el nombre del +/// contenedor (para que `container` quede poblado en métodos/campos). +pub(crate) fn flatten_document_symbol( + node: &serde_json::Value, + parent: Option<&str>, + depth: u32, + out: &mut Vec, +) { + let name = node.get("name").and_then(|v| v.as_str()).unwrap_or("?").to_string(); + let kind_num = node.get("kind").and_then(|v| v.as_u64()).unwrap_or(0); + let kind = symbol_kind_label(kind_num); + // `selectionRange.start` es la pos del identificador (lo que el + // usuario quiere ver al saltar). `range.start` apuntaría al `{` de + // la definición — menos útil para outline. + let (line, col) = node + .get("selectionRange") + .and_then(|r| r.get("start")) + .and_then(parse_position) + .or_else(|| node.get("range").and_then(|r| r.get("start")).and_then(parse_position)) + .unwrap_or((0, 0)); + out.push(DocumentSymbolEntry { + name: name.clone(), + kind, + line, + col, + container: parent.map(|s| s.to_string()), + depth, + }); + if let Some(children) = node.get("children").and_then(|c| c.as_array()) { + for child in children { + flatten_document_symbol(child, Some(&name), depth + 1, out); + } + } +} + +pub(crate) fn parse_symbol_information(item: &serde_json::Value) -> Option { + let name = item.get("name")?.as_str()?.to_string(); + let kind_num = item.get("kind").and_then(|v| v.as_u64()).unwrap_or(0); + let location = item.get("location")?; + let (line, col) = location.get("range").and_then(|r| r.get("start")).and_then(parse_position)?; + let container = item + .get("containerName") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + Some(DocumentSymbolEntry { + name, + kind: symbol_kind_label(kind_num), + line, + col, + container, + depth: 0, + }) +} + +pub(crate) fn parse_position(p: &serde_json::Value) -> Option<(usize, usize)> { + let line = p.get("line")?.as_u64()? as usize; + let col = p.get("character")?.as_u64()? as usize; + Some((line, col)) +} + +/// Mapea el `SymbolKind` numérico del LSP a la etiqueta corta que el +/// outline pinta. Sólo cubre las que el usuario suele ver — el resto +/// va a `"sym"`. Lista canónica: +pub(crate) fn symbol_kind_label(kind: u64) -> String { + match kind { + 2 => "mod", + 5 => "class", + 6 => "method", + 7 => "property", + 8 => "field", + 9 => "ctor", + 10 => "enum", + 11 => "iface", + 12 => "fn", + 13 => "var", + 14 => "const", + 15 => "str", + 18 => "arr", + 22 => "variant", + 23 => "struct", + 26 => "type", + _ => "sym", + } + .to_string() +} + +pub(crate) fn handle_rename_response(json: &serde_json::Value, state: &SharedState) { + let Some(result) = json.get("result") else { return }; + if result.is_null() { + return; + } + let mut map: HashMap> = HashMap::new(); + // changes: { uri → TextEdit[] } + if let Some(changes) = result.get("changes").and_then(|c| c.as_object()) { + for (uri, edits_val) in changes { + let Some(path) = uri.strip_prefix("file://").map(PathBuf::from) else { continue }; + let Some(arr) = edits_val.as_array() else { continue }; + let edits: Vec = arr.iter().filter_map(parse_text_edit).collect(); + map.insert(path, edits); + } + } + // documentChanges: [{ textDocument: { uri }, edits: [...] }] — más nuevo. + if let Some(docs) = result.get("documentChanges").and_then(|c| c.as_array()) { + for doc in docs { + let Some(uri) = doc + .get("textDocument") + .and_then(|t| t.get("uri")) + .and_then(|u| u.as_str()) + else { + continue; + }; + let Some(path) = uri.strip_prefix("file://").map(PathBuf::from) else { continue }; + let Some(arr) = doc.get("edits").and_then(|e| e.as_array()) else { continue }; + let edits: Vec = arr.iter().filter_map(parse_text_edit).collect(); + map.entry(path).or_default().extend(edits); + } + } + if let Ok(mut s) = state.lock() { + s.workspace_edit = map; + } +} + +pub(crate) fn handle_references_response(json: &serde_json::Value, state: &SharedState) { + let Some(result) = json.get("result") else { return }; + if result.is_null() { + if let Ok(mut s) = state.lock() { + s.references.clear(); + } + return; + } + let Some(arr) = result.as_array() else { return }; + let refs: Vec = arr.iter().filter_map(parse_location).collect(); + if let Ok(mut s) = state.lock() { + s.references = refs; + } +} + +/// Parsea una `Location` LSP: { uri, range } → DefinitionLocation. +pub(crate) fn parse_location(loc: &serde_json::Value) -> Option { + let uri = loc.get("uri")?.as_str()?; + let path = uri.strip_prefix("file://").map(PathBuf::from)?; + let range = loc.get("range")?; + let start = range.get("start")?; + let line = start.get("line")?.as_u64()? as usize; + let col = start.get("character")?.as_u64()? as usize; + Some(DefinitionLocation { path, line, col }) +} + +pub(crate) fn handle_signature_help_response(json: &serde_json::Value, state: &SharedState) { + let Some(result) = json.get("result") else { return }; + if result.is_null() { + if let Ok(mut s) = state.lock() { + s.signature_help = None; + } + return; + } + let info = parse_signature_help(result); + if let Ok(mut s) = state.lock() { + s.signature_help = info; + } +} + +pub(crate) fn parse_signature_help(result: &serde_json::Value) -> Option { + let sigs = result.get("signatures")?.as_array()?; + if sigs.is_empty() { + return None; + } + let active_sig = result.get("activeSignature").and_then(|n| n.as_u64()).unwrap_or(0) as usize; + let sig = sigs.get(active_sig).or_else(|| sigs.first())?; + let label = sig.get("label")?.as_str()?.to_string(); + let doc = sig + .get("documentation") + .map(stringify_hover_contents) + .filter(|s| !s.is_empty()); + let active_param = sig + .get("activeParameter") + .or_else(|| result.get("activeParameter")) + .and_then(|n| n.as_u64()) + .unwrap_or(0) as usize; + let param_labels = sig + .get("parameters") + .and_then(|p| p.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|p| { + let lbl = p.get("label")?; + if let Some(s) = lbl.as_str() { + Some(s.to_string()) + } else if let Some(arr2) = lbl.as_array() { + let s = arr2.first()?.as_u64()? as usize; + let e = arr2.get(1)?.as_u64()? as usize; + label.get(s..e).map(String::from) + } else { + None + } + }) + .collect() + }) + .unwrap_or_default(); + Some(SignatureHelpInfo { label, doc, active_param, param_labels }) +} + +pub(crate) fn handle_text_edits_response(json: &serde_json::Value, state: &SharedState) { + let Some(result) = json.get("result") else { return }; + if result.is_null() { + return; + } + let Some(arr) = result.as_array() else { return }; + let edits: Vec = arr.iter().filter_map(parse_text_edit).collect(); + if let Ok(mut s) = state.lock() { + s.text_edits = edits; + } +} + +pub(crate) fn parse_text_edit(v: &serde_json::Value) -> Option { + let range = v.get("range")?; + let start = range.get("start")?; + let end = range.get("end")?; + let start_line = start.get("line")?.as_u64()? as usize; + let start_col = start.get("character")?.as_u64()? as usize; + let end_line = end.get("line")?.as_u64()? as usize; + let end_col = end.get("character")?.as_u64()? as usize; + let new_text = v.get("newText")?.as_str()?.to_string(); + Some(TextEdit { start_line, start_col, end_line, end_col, new_text }) +} + +pub(crate) fn handle_definition_response(json: &serde_json::Value, state: &SharedState) { + let Some(result) = json.get("result") else { return }; + if result.is_null() { + return; + } + // `result` puede ser: + // - Location { uri, range } + // - Location[] + // - LocationLink[] { targetUri, targetSelectionRange } + // Tomamos la primera location en cualquier caso. + let loc_value = if result.is_array() { + result.as_array().and_then(|a| a.first()).cloned() + } else { + Some(result.clone()) + }; + let Some(loc) = loc_value else { return }; + + let (uri, range) = if let Some(u) = loc.get("uri") { + (u, loc.get("range")) + } else if let Some(u) = loc.get("targetUri") { + ( + u, + loc.get("targetSelectionRange").or_else(|| loc.get("targetRange")), + ) + } else { + return; + }; + let Some(uri) = uri.as_str() else { return }; + let path = match uri.strip_prefix("file://") { + Some(p) => PathBuf::from(p), + None => return, + }; + let Some(range) = range else { return }; + let Some(start) = range.get("start") else { return }; + let line = start.get("line").and_then(|n| n.as_u64()).unwrap_or(0) as usize; + let col = start.get("character").and_then(|n| n.as_u64()).unwrap_or(0) as usize; + if let Ok(mut s) = state.lock() { + s.definition = Some(DefinitionLocation { path, line, col }); + } +} + +pub(crate) fn handle_completion_response(json: &serde_json::Value, state: &SharedState) { + let Some(result) = json.get("result") else { return }; + let items_arr = if let Some(arr) = result.as_array() { + arr.clone() + } else if let Some(items) = result.get("items").and_then(|i| i.as_array()) { + items.clone() + } else { + return; + }; + let completions: Vec = items_arr.iter().filter_map(parse_completion).collect(); + if let Ok(mut s) = state.lock() { + s.completions = completions; + } +} + +pub(crate) fn handle_hover_response(json: &serde_json::Value, state: &SharedState) { + let Some(result) = json.get("result") else { return }; + if result.is_null() { + if let Ok(mut s) = state.lock() { + s.hover = None; + } + return; + } + let info = parse_hover(result); + if let Ok(mut s) = state.lock() { + s.hover = info; + } +} + +/// `contents` en LSP puede ser: +/// - String +/// - { kind: "markdown"|"plaintext", value: String } +/// - Array de los anteriores (deprecated pero algunos servers lo mandan) +/// - { language: ..., value: ... } (legacy MarkedString) +pub(crate) fn parse_hover(result: &serde_json::Value) -> Option { + let contents = result.get("contents")?; + let text = stringify_hover_contents(contents); + if text.is_empty() { + None + } else { + Some(HoverInfo { contents: text }) + } +} + +pub(crate) fn stringify_hover_contents(v: &serde_json::Value) -> String { + match v { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Object(map) => { + // { kind, value } o { language, value } + map.get("value") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string() + } + serde_json::Value::Array(arr) => arr + .iter() + .map(stringify_hover_contents) + .filter(|s| !s.is_empty()) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + +pub(crate) fn parse_completion(v: &serde_json::Value) -> Option { + let label = v.get("label")?.as_str()?.to_string(); + let insert_text = v + .get("insertText") + .and_then(|s| s.as_str()) + .map(String::from); + let kind = v + .get("kind") + .and_then(|k| k.as_u64()) + .map(|n| completion_kind_label(n).to_string()); + let detail = v + .get("detail") + .and_then(|d| d.as_str()) + .map(String::from); + Some(CompletionItem { label, insert_text, kind, detail }) +} + +/// Etiqueta corta para el CompletionItemKind de LSP (1..25). +pub(crate) fn completion_kind_label(k: u64) -> &'static str { + match k { + 1 => "Text", + 2 => "Method", + 3 => "Function", + 4 => "Ctor", + 5 => "Field", + 6 => "Var", + 7 => "Class", + 8 => "Iface", + 9 => "Mod", + 10 => "Prop", + 11 => "Unit", + 12 => "Value", + 13 => "Enum", + 14 => "Keyword", + 15 => "Snip", + 16 => "Color", + 17 => "File", + 18 => "Ref", + 19 => "Folder", + 20 => "EnumMember", + 21 => "Const", + 22 => "Struct", + 23 => "Event", + 24 => "Op", + 25 => "TypeParam", + _ => "?", + } +} + +pub(crate) fn parse_lsp_diagnostic(d: &serde_json::Value) -> Option { + let range = d.get("range")?; + let start = range.get("start")?; + let end = range.get("end")?; + let sl = start.get("line")?.as_u64()? as usize; + let sc = start.get("character")?.as_u64()? as usize; + let el = end.get("line")?.as_u64()? as usize; + let ec = end.get("character")?.as_u64()? as usize; + let severity = match d.get("severity").and_then(|s| s.as_u64()) { + Some(1) => Severity::Error, + Some(2) => Severity::Warning, + Some(3) => Severity::Information, + Some(4) => Severity::Hint, + _ => Severity::Information, + }; + let message = d + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("") + .to_string(); + let source = d.get("source").and_then(|s| s.as_str()).map(String::from); + Some(Diagnostic { + range: DiagnosticRange { + start: Pos::new(sl, sc), + end: Pos::new(el, ec), + }, + severity, + message, + source, + }) +} diff --git a/widgets/text-editor-lsp/src/tests.rs b/widgets/text-editor-lsp/src/tests.rs new file mode 100644 index 0000000..40a27ee --- /dev/null +++ b/widgets/text-editor-lsp/src/tests.rs @@ -0,0 +1,380 @@ +use super::*; + +#[test] +fn noop_devuelve_vacio() { + let c = NoopLspClient; + assert!(c.diagnostics(&PathBuf::from("x")).is_empty()); +} + +#[test] +fn noop_no_panic_en_eventos() { + let mut c = NoopLspClient; + c.did_open(&PathBuf::from("x"), "rust", "fn main() {}"); + c.did_change(&PathBuf::from("x"), "fn main() { 1 }"); + c.did_close(&PathBuf::from("x")); +} + +#[test] +fn parse_diagnostic_minimo() { + let json = serde_json::json!({ + "range": { + "start": { "line": 3, "character": 5 }, + "end": { "line": 3, "character": 12 } + }, + "severity": 1, + "message": "no es así", + "source": "rustc" + }); + let d = parse_lsp_diagnostic(&json).unwrap(); + assert_eq!(d.range.start, Pos::new(3, 5)); + assert_eq!(d.range.end, Pos::new(3, 12)); + assert_eq!(d.severity, Severity::Error); + assert_eq!(d.message, "no es así"); + assert_eq!(d.source.as_deref(), Some("rustc")); +} + +#[test] +fn parse_diagnostic_sin_severidad_es_info() { + let json = serde_json::json!({ + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 1 } + }, + "message": "x" + }); + let d = parse_lsp_diagnostic(&json).unwrap(); + assert_eq!(d.severity, Severity::Information); +} + +#[test] +fn parse_completion_minimo() { + let v = serde_json::json!({ + "label": "to_string", + "insertText": "to_string()", + "kind": 2, + "detail": "fn(&self) -> String" + }); + let c = parse_completion(&v).unwrap(); + assert_eq!(c.label, "to_string"); + assert_eq!(c.insert_text.as_deref(), Some("to_string()")); + assert_eq!(c.kind.as_deref(), Some("Method")); + assert_eq!(c.detail.as_deref(), Some("fn(&self) -> String")); +} + +#[test] +fn parse_hover_string_simple() { + let v = serde_json::json!({ "contents": "hola" }); + let h = parse_hover(&v).unwrap(); + assert_eq!(h.contents, "hola"); +} + +#[test] +fn parse_hover_marked_object() { + let v = serde_json::json!({ + "contents": { "kind": "markdown", "value": "**fn**(x: i32) -> i32" } + }); + let h = parse_hover(&v).unwrap(); + assert_eq!(h.contents, "**fn**(x: i32) -> i32"); +} + +#[test] +fn parse_hover_array_concatena() { + let v = serde_json::json!({ + "contents": ["primero", { "value": "segundo" }, ""] + }); + let h = parse_hover(&v).unwrap(); + assert_eq!(h.contents, "primero\nsegundo"); +} + +#[test] +fn parse_hover_vacio_devuelve_none() { + let v = serde_json::json!({ "contents": "" }); + assert!(parse_hover(&v).is_none()); +} + +#[test] +fn parse_completion_sin_insert_text_usa_label() { + let v = serde_json::json!({ "label": "main" }); + let c = parse_completion(&v).unwrap(); + assert_eq!(c.text_to_insert(), "main"); +} + +fn make_state() -> SharedState { + Arc::new(Mutex::new(SharedInner::default())) +} + +#[test] +fn handle_rename_changes_map() { + let s = make_state(); + let json = serde_json::json!({ + "id": 1, + "result": { + "changes": { + "file:///tmp/a.rs": [ + { "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 3 } }, "newText": "bar" } + ], + "file:///tmp/b.rs": [ + { "range": { "start": { "line": 5, "character": 4 }, "end": { "line": 5, "character": 7 } }, "newText": "bar" }, + { "range": { "start": { "line": 10, "character": 0 }, "end": { "line": 10, "character": 3 } }, "newText": "bar" } + ] + } + } + }); + handle_rename_response(&json, &s); + let we = s.lock().unwrap().workspace_edit.clone(); + assert_eq!(we.len(), 2); + assert_eq!(we.get(&PathBuf::from("/tmp/a.rs")).unwrap().len(), 1); + assert_eq!(we.get(&PathBuf::from("/tmp/b.rs")).unwrap().len(), 2); +} + +#[test] +fn handle_rename_document_changes() { + let s = make_state(); + let json = serde_json::json!({ + "id": 1, + "result": { + "documentChanges": [ + { + "textDocument": { "uri": "file:///tmp/x.rs", "version": 2 }, + "edits": [ + { "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 3 } }, "newText": "foo" } + ] + } + ] + } + }); + handle_rename_response(&json, &s); + let we = s.lock().unwrap().workspace_edit.clone(); + assert_eq!(we.len(), 1); + assert_eq!(we.get(&PathBuf::from("/tmp/x.rs")).unwrap().len(), 1); +} + +#[test] +fn handle_document_symbols_jerarquico() { + let s = make_state(); + // Estructura: struct Foo { fn bar(), fn baz() } + fn top() + let json = serde_json::json!({ + "id": 1, + "result": [ + { + "name": "Foo", + "kind": 23, // struct + "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 10, "character": 1 } }, + "selectionRange": { "start": { "line": 0, "character": 7 }, "end": { "line": 0, "character": 10 } }, + "children": [ + { + "name": "bar", + "kind": 6, // method + "range": { "start": { "line": 2, "character": 4 }, "end": { "line": 4, "character": 5 } }, + "selectionRange": { "start": { "line": 2, "character": 7 }, "end": { "line": 2, "character": 10 } } + }, + { + "name": "baz", + "kind": 6, + "range": { "start": { "line": 6, "character": 4 }, "end": { "line": 8, "character": 5 } }, + "selectionRange": { "start": { "line": 6, "character": 7 }, "end": { "line": 6, "character": 10 } } + } + ] + }, + { + "name": "top", + "kind": 12, // function + "range": { "start": { "line": 12, "character": 0 }, "end": { "line": 14, "character": 1 } }, + "selectionRange": { "start": { "line": 12, "character": 3 }, "end": { "line": 12, "character": 6 } } + } + ] + }); + handle_document_symbols_response(&json, &s); + let syms = s.lock().unwrap().document_symbols.clone(); + assert_eq!(syms.len(), 4, "esperaba 4 entradas flattening"); + + assert_eq!(syms[0].name, "Foo"); + assert_eq!(syms[0].kind, "struct"); + assert_eq!(syms[0].line, 0); + assert_eq!(syms[0].depth, 0); + assert_eq!(syms[0].container, None); + + assert_eq!(syms[1].name, "bar"); + assert_eq!(syms[1].kind, "method"); + assert_eq!(syms[1].line, 2); + assert_eq!(syms[1].depth, 1); + assert_eq!(syms[1].container.as_deref(), Some("Foo")); + + assert_eq!(syms[2].name, "baz"); + assert_eq!(syms[2].depth, 1); + assert_eq!(syms[2].container.as_deref(), Some("Foo")); + + assert_eq!(syms[3].name, "top"); + assert_eq!(syms[3].kind, "fn"); + assert_eq!(syms[3].depth, 0); +} + +#[test] +fn handle_document_symbols_legacy_symbolinformation() { + let s = make_state(); + // Formato viejo: SymbolInformation[] (plano + location). + let json = serde_json::json!({ + "id": 1, + "result": [ + { + "name": "main", + "kind": 12, + "location": { + "uri": "file:///tmp/x.rs", + "range": { "start": { "line": 0, "character": 3 }, "end": { "line": 0, "character": 7 } } + } + }, + { + "name": "helper", + "kind": 12, + "containerName": "main", + "location": { + "uri": "file:///tmp/x.rs", + "range": { "start": { "line": 5, "character": 3 }, "end": { "line": 5, "character": 9 } } + } + } + ] + }); + handle_document_symbols_response(&json, &s); + let syms = s.lock().unwrap().document_symbols.clone(); + assert_eq!(syms.len(), 2); + assert_eq!(syms[1].name, "helper"); + assert_eq!(syms[1].container.as_deref(), Some("main")); +} + +#[test] +fn handle_references_response_array() { + let s = make_state(); + let json = serde_json::json!({ + "id": 1, + "result": [ + { "uri": "file:///tmp/a.rs", "range": { "start": { "line": 1, "character": 2 }, "end": { "line": 1, "character": 5 } } }, + { "uri": "file:///tmp/b.rs", "range": { "start": { "line": 10, "character": 0 }, "end": { "line": 10, "character": 3 } } } + ] + }); + handle_references_response(&json, &s); + let refs = s.lock().unwrap().references.clone(); + assert_eq!(refs.len(), 2); + assert_eq!(refs[0].path, PathBuf::from("/tmp/a.rs")); + assert_eq!(refs[0].line, 1); + assert_eq!(refs[1].path, PathBuf::from("/tmp/b.rs")); + assert_eq!(refs[1].line, 10); +} + +#[test] +fn parse_signature_help_basic() { + let result = serde_json::json!({ + "signatures": [{ + "label": "fn foo(x: i32, y: String) -> u64", + "parameters": [ + { "label": "x: i32" }, + { "label": "y: String" } + ] + }], + "activeSignature": 0, + "activeParameter": 1 + }); + let info = parse_signature_help(&result).unwrap(); + assert_eq!(info.label, "fn foo(x: i32, y: String) -> u64"); + assert_eq!(info.active_param, 1); + assert_eq!(info.param_labels, vec!["x: i32", "y: String"]); +} + +#[test] +fn parse_signature_help_offset_label() { + // Label como [start, end] dentro del label de la firma. + let result = serde_json::json!({ + "signatures": [{ + "label": "foo(x, y)", + "parameters": [ + { "label": [4, 5] }, + { "label": [7, 8] } + ] + }] + }); + let info = parse_signature_help(&result).unwrap(); + assert_eq!(info.param_labels, vec!["x", "y"]); +} + +#[test] +fn parse_text_edit_basic() { + let v = serde_json::json!({ + "range": { + "start": { "line": 1, "character": 0 }, + "end": { "line": 1, "character": 4 } + }, + "newText": "let " + }); + let e = parse_text_edit(&v).unwrap(); + assert_eq!(e.start_line, 1); + assert_eq!(e.start_col, 0); + assert_eq!(e.end_line, 1); + assert_eq!(e.end_col, 4); + assert_eq!(e.new_text, "let "); +} + +#[test] +fn handle_text_edits_response_array() { + let s = make_state(); + let json = serde_json::json!({ + "id": 1, + "result": [ + { "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 3 } }, "newText": "fn " }, + { "range": { "start": { "line": 1, "character": 4 }, "end": { "line": 1, "character": 5 } }, "newText": "" } + ] + }); + handle_text_edits_response(&json, &s); + let edits = s.lock().unwrap().text_edits.clone(); + assert_eq!(edits.len(), 2); +} + +#[test] +fn handle_definition_location_simple() { + let s = make_state(); + let json = serde_json::json!({ + "id": 1, + "result": { + "uri": "file:///tmp/x.rs", + "range": { + "start": { "line": 10, "character": 4 }, + "end": { "line": 10, "character": 9 } + } + } + }); + handle_definition_response(&json, &s); + let d = s.lock().unwrap().definition.clone().unwrap(); + assert_eq!(d.path, PathBuf::from("/tmp/x.rs")); + assert_eq!(d.line, 10); + assert_eq!(d.col, 4); +} + +#[test] +fn handle_definition_location_link_array() { + let s = make_state(); + let json = serde_json::json!({ + "id": 1, + "result": [ + { + "targetUri": "file:///tmp/y.rs", + "targetSelectionRange": { + "start": { "line": 0, "character": 7 }, + "end": { "line": 0, "character": 12 } + } + } + ] + }); + handle_definition_response(&json, &s); + let d = s.lock().unwrap().definition.clone().unwrap(); + assert_eq!(d.path, PathBuf::from("/tmp/y.rs")); + assert_eq!(d.line, 0); + assert_eq!(d.col, 7); +} + +#[test] +fn rust_analyzer_client_sin_binary_no_panic() { + // Si rust-analyzer no está instalado, el spawn falla en silencio + // y el client queda en modo no-op (state vacío). + let c = RustAnalyzerClient::with_command(PathBuf::from("/tmp"), "rust-analyzer-missing-99999"); + // diagnostics() siempre devuelve vacío hasta que el server responde. + assert!(c.diagnostics(&PathBuf::from("/tmp/x")).is_empty()); +} diff --git a/widgets/text-editor/Cargo.toml b/widgets/text-editor/Cargo.toml new file mode 100644 index 0000000..ba99f03 --- /dev/null +++ b/widgets/text-editor/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "llimphi-widget-text-editor" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-text-editor — capa visual Llimphi del editor de código (gutter, caret, selección, scroll, integración de teclado al update Elm). El núcleo agnóstico (buffer/cursor/ops/undo/highlight/…) vive en llimphi-widget-text-editor-core y se re-exporta. LSP queda para una capa superior." + +[dependencies] +llimphi-widget-text-editor-core = { workspace = true } +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +# state.rs construye tree_sitter::InputEdit/Point para alimentar el +# parsing incremental del núcleo (el resto de tree-sitter vive en core). +tree-sitter = { workspace = true } + +[dev-dependencies] diff --git a/widgets/text-editor/LEEME.md b/widgets/text-editor/LEEME.md new file mode 100644 index 0000000..85de610 --- /dev/null +++ b/widgets/text-editor/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-text-editor + +> Editor de código (rope · cursor · undo · highlight · clipboard · find) para [llimphi](../../README.md). + +Rope con `crop` o `xi-rope` interno (eficiente para edits grandes). Cursores múltiples opcionales, syntax highlight (tree-sitter), clipboard real (`arboard`), find/replace con regex, undo grouped. Base de `nada`, `pluma-editor`, `pluma-notebook`, `nakui-sheet`. diff --git a/widgets/text-editor/README.md b/widgets/text-editor/README.md new file mode 100644 index 0000000..d45991d --- /dev/null +++ b/widgets/text-editor/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-text-editor + +> Code editor (rope · cursor · undo · highlight · clipboard · find) for [llimphi](../../README.md). + +Internal rope with `crop` or `xi-rope` (efficient for large edits). Optional multi-cursor, syntax highlight (tree-sitter), real clipboard (`arboard`), regex find/replace, grouped undo. Foundation of `nada`, `pluma-editor`, `pluma-notebook`, `nakui-sheet`. diff --git a/widgets/text-editor/src/lib.rs b/widgets/text-editor/src/lib.rs new file mode 100644 index 0000000..e43625d --- /dev/null +++ b/widgets/text-editor/src/lib.rs @@ -0,0 +1,68 @@ +//! `llimphi-widget-text-editor` — editor de código multilínea para Llimphi. +//! +//! Capa visual sobre el núcleo agnóstico [`llimphi_widget_text_editor_core`]: +//! +//! - El **núcleo** (`buffer`/`cursor`/`ops`/`undo`/`bracket`/`find`/ +//! `diagnostics`/`clipboard`/`highlight`) es puro — sin IO, sin Llimphi, +//! sin GPU — y se re-exporta aquí tal cual, de modo que los consumidores +//! históricos (`crate::cursor::Pos`, `crate::Buffer`, …) siguen resolviendo +//! sin cambios. +//! - [`state`] — el [`EditorState`] que une todo + `apply_key` para integrar +//! al `update` Elm (depende de los tipos de teclado de `llimphi-ui`). +//! - [`view`] — renderizado multilínea con gutter, caret, selección, scroll. +//! +//! El split núcleo/widget permite tests amplios del core y reutilizar la +//! lógica de edición desde un TUI, un `text-input` single-line, una +//! mini-REPL o un backend web, sin arrastrar `wgpu`/`vello`. + +#![forbid(unsafe_code)] + +// Núcleo agnóstico re-exportado como módulos del crate: mantiene viva la +// ruta `crate::::…` que usan `state`/`view` y los consumidores externos. +pub use llimphi_widget_text_editor_core::{ + bracket, buffer, clipboard, cursor, diagnostics, find, highlight, ops, undo, +}; + +// Capa Llimphi propia de este widget. +pub mod state; +pub mod view; + +pub use buffer::Buffer; +pub use clipboard::{Clipboard, MemClipboard, NullClipboard}; +pub use cursor::{Cursor, Pos, Selection}; +pub use diagnostics::{Diagnostic, DiagnosticRange, Severity}; +pub use find::{all_matches, find_next, find_prev, FindState}; +pub use highlight::{Highlighter, Language, Span, SyntaxPalette, TokenKind}; +pub use ops::{indent_str, EditDelta}; +pub use state::{ApplyResult, EditorOptions, EditorState}; +pub use undo::UndoStack; +pub use view::{ + text_editor_view, text_editor_view_full, text_editor_view_highlighted, EditorMetrics, + EditorPalette, GutterStyle, PointerEvent, +}; + +use llimphi_ui::llimphi_raster::peniko::Color; + +/// Paleta de syntax highlighting dark — deriva de un [`llimphi_theme::Theme`] +/// + colores hardcoded para las categorías que el theme no expone como +/// slots semánticos (string, number, keyword, …). +/// +/// Vive en el widget (no en el núcleo) porque es el único punto que toca +/// `llimphi-theme`; el núcleo se queda con el modelo de color puro. +pub fn syntax_palette_dark(theme: &llimphi_theme::Theme) -> SyntaxPalette { + fn rgb(r: u8, g: u8, b: u8) -> Color { + Color::from_rgb8(r, g, b) + } + SyntaxPalette { + keyword: rgb(198, 120, 221), // morado: keywords + typ: rgb(229, 192, 123), // amarillo cálido: tipos + function: rgb(97, 175, 239), // azul: funciones + string: rgb(152, 195, 121), // verde: strings + number: rgb(209, 154, 102), // naranja: números + comment: theme.fg_muted, // muted: comentarios + operator: theme.fg_text, + punctuation: theme.fg_muted, + identifier: theme.fg_text, + other: theme.fg_text, + } +} diff --git a/widgets/text-editor/src/state.rs b/widgets/text-editor/src/state.rs new file mode 100644 index 0000000..e13f66a --- /dev/null +++ b/widgets/text-editor/src/state.rs @@ -0,0 +1,1258 @@ +//! [`EditorState`] — la unión de buffer + cursor + undo + opciones, con +//! `apply_key` que mapea un `KeyEvent` de llimphi-ui a operaciones de +//! edición o movimiento. Este es el tipo que el caller pone en su +//! `Model` y mete en el `update` Elm. + +use std::cell::RefCell; + +use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey}; + +use crate::buffer::Buffer; +use crate::clipboard::{Clipboard, NullClipboard}; +use crate::cursor::{Cursor, Pos}; +use crate::highlight::{Highlighter, Language, Span}; +use crate::ops::{ + dedent, delete_backward, delete_forward, indent_or_insert_tab, + insert_newline_auto_indent, replace_selection, +}; +use crate::undo::UndoStack; + +/// Opciones del editor — afectan indent + límite de undo + page size. +#[derive(Debug, Clone, Copy)] +pub struct EditorOptions { + /// `true` = Tab inserta `indent_size` spaces; `false` = inserta `\t`. + pub tab_to_spaces: bool, + pub indent_size: usize, + /// Cuántas líneas avanza PageUp/PageDown. + pub page_size: usize, + /// `true` = Enter no inserta `\n`; el caller maneja submit. (modo + /// single-line para el text-input refactorizado). + pub single_line: bool, +} + +impl Default for EditorOptions { + fn default() -> Self { + Self { + tab_to_spaces: true, + indent_size: 2, + page_size: 12, + single_line: false, + } + } +} + +#[derive(Debug, Clone)] +pub struct EditorState { + pub buffer: Buffer, + /// Cursor primario — el que la API legacy expone como "el" cursor. + /// Edit ops aplican al primary + todos los `extra_cursors` en orden. + pub cursor: Cursor, + /// Cursores adicionales (multi-cursor). Vacío en el caso típico. + /// Cuando hay extras, las ediciones aplican a todos; Esc los colapsa + /// dejando sólo el primary. + pub extra_cursors: Vec, + /// Diagnostics del LSP (o equivalente). El client externo los popa + /// vía `set_diagnostics`; el render del editor los pinta como + /// subrayado bajo el rango con color según severity. + pub diagnostics: Vec, + pub options: EditorOptions, + /// Líneas-guarda: índices ordenados (ascendente, sin duplicados) de + /// líneas que el caret no puede ocupar y que el gutter no numera. + /// El widget no decide qué es guarda — lo decide el caller (el + /// `cuerpo_ide` lo computa a partir de la estructura de átomos + + /// flags de fusión). La lista debe mantenerse al día tras cada + /// edición que cambie la cantidad o posición de líneas: el caller + /// es responsable de actualizar este campo cuando reaccione a + /// `Changed`. Vacío = sin guardas, comportamiento clásico de IDE. + pub guard_lines: Vec, + /// Tinte de fondo por línea. `line_tints[i]` controla la línea + /// `i` del buffer: `Some(color)` pinta un rectángulo del ancho + /// completo del área de contenido al ALPHA del color, **debajo** + /// del texto y de cualquier highlight; `None` deja la línea sin + /// tinte. Vacío o ausente = sin tintes (modo IDE clásico). Pensado + /// para colorear zonas en editores narrativos sin afectar la + /// lectura — los callers deben elegir colores con alpha bajo (≤ + /// ~40/255 sobre el bg). + pub line_tints: Vec>, + pub undo: UndoStack, + /// Línea inicial visible — el viewport renderiza + /// `[scroll_offset, scroll_offset + visible)`. El caller llama a + /// [`Self::ensure_caret_visible`] tras movimientos para auto-scrollear. + pub scroll_offset: usize, + /// Contador monotónico que se incrementa con cada edición del buffer. + /// Lo usa el cache de highlight para invalidarse sin re-hashear el + /// texto entero por frame. + pub edit_seq: u64, + /// InputEdits que el editor produjo y todavía no fueron aplicados + /// al `Tree` cached del highlighter. El highlight, antes de + /// reparsear, los drena y los aplica al tree → parseo incremental + /// real (tree-sitter sólo reconstruye los subtrees afectados). + pub pending_input_edits: RefCell>, + /// Cache memoizado del syntax highlight. Interior mutability vía + /// `RefCell` para que el view (que recibe `&EditorState`) lo + /// actualice on-demand. Se invalida cuando cambian `edit_seq` o el + /// `Language` solicitado. + pub highlight_cache: RefCell>, +} + +/// Entrada del cache: spans por línea + clave que la generó. +#[derive(Debug, Clone)] +pub struct HighlightCache { + pub seq: u64, + pub language: Language, + pub spans: Vec>, +} + +impl Default for EditorState { + fn default() -> Self { + Self::new() + } +} + +impl EditorState { + pub fn new() -> Self { + Self { + buffer: Buffer::new(), + cursor: Cursor::new(), + extra_cursors: Vec::new(), + diagnostics: Vec::new(), + options: EditorOptions::default(), + guard_lines: Vec::new(), + line_tints: Vec::new(), + undo: UndoStack::new(), + scroll_offset: 0, + edit_seq: 0, + pending_input_edits: RefCell::new(Vec::new()), + highlight_cache: RefCell::new(None), + } + } + + /// Devuelve todos los cursores en orden: primary + extras. Útil para + /// el render que dibuja un caret + selección por cada uno. + pub fn all_cursors(&self) -> impl Iterator { + std::iter::once(&self.cursor).chain(self.extra_cursors.iter()) + } + + /// Agrega un cursor adicional con caret en `(line, col)`. Si ya hay + /// un cursor exactamente ahí, no duplica. + pub fn add_cursor_at(&mut self, line: usize, col: usize) { + let line = line.min(self.buffer.len_lines().saturating_sub(1)); + let col = col.min(self.buffer.line_len_chars(line)); + let pos = Pos::new(line, col); + if self.cursor.caret == pos { + return; + } + if self.extra_cursors.iter().any(|c| c.caret == pos) { + return; + } + self.extra_cursors.push(Cursor::at(line, col)); + } + + /// Colapsa multi-cursor: descarta los `extra_cursors`. No toca el + /// primary. + pub fn collapse_to_primary(&mut self) { + self.extra_cursors.clear(); + } + + pub fn has_multi_cursor(&self) -> bool { + !self.extra_cursors.is_empty() + } + + /// Reemplaza los diagnostics del editor. Usado por el client LSP + /// cuando recibe `textDocument/publishDiagnostics`. + pub fn set_diagnostics(&mut self, diags: Vec) { + self.diagnostics = diags; + } + + pub fn with_options(options: EditorOptions) -> Self { + Self { + options, + ..Self::new() + } + } + + /// Ajusta `scroll_offset` para que la línea del caret quede dentro + /// de `[scroll_offset, scroll_offset + visible_lines)`. Si el caret + /// está arriba, scrollea para arriba; si está abajo, scrollea para + /// abajo dejando el caret en la última línea visible. + pub fn ensure_caret_visible(&mut self, visible_lines: usize) { + if visible_lines == 0 { + return; + } + let line = self.cursor.caret.line; + if line < self.scroll_offset { + self.scroll_offset = line; + } else if line >= self.scroll_offset + visible_lines { + self.scroll_offset = line + 1 - visible_lines; + } + // Clampea al rango válido — no scrollear más allá del fin del + // buffer (deja la última línea siempre visible). + let max_scroll = self.line_count().saturating_sub(1); + if self.scroll_offset > max_scroll { + self.scroll_offset = max_scroll; + } + } + + /// Scrollea relativo (positivo = abajo). Clampea a 0..line_count-1. + pub fn scroll_by(&mut self, delta: i32) { + let new = (self.scroll_offset as i32 + delta).max(0) as usize; + let max = self.line_count().saturating_sub(1); + self.scroll_offset = new.min(max); + } + + pub fn text(&self) -> String { + self.buffer.text() + } + + pub fn set_text(&mut self, s: &str) { + self.buffer.set_text(s); + // Las guardas y tintes previos referían al texto viejo: + // limpiar. El caller los repuebla cuando reaccione al cambio. + self.guard_lines.clear(); + self.line_tints.clear(); + // Clampea el caret a la nueva longitud. + let last_line = self.buffer.len_lines().saturating_sub(1); + let col = self.buffer.line_len_chars(last_line); + self.cursor = Cursor::at(last_line, col); + self.undo.clear(); + self.bump_edit_seq(); + // Cambio masivo de buffer — el árbol cached del highlighter + // queda inválido. Lo borramos para forzar full parse próximo. + for lang in [Language::Rust, Language::Python] { + crate::highlight::invalidate_tree_cache(lang); + } + } + + /// Incrementa el contador de ediciones — invalidando el cache de + /// highlight automáticamente. + pub fn bump_edit_seq(&mut self) { + self.edit_seq = self.edit_seq.wrapping_add(1); + } + + /// Devuelve los spans del highlight cacheados. Si el cache no matchea + /// (distinto `edit_seq` o `language`), reparsea con tree-sitter + /// incremental — aplica los `pending_input_edits` al tree previo + /// antes de parsear, y guarda el nuevo tree. + pub fn highlighted_spans(&self, language: Language) -> Vec> { + if matches!(language, Language::Plain) { + return Vec::new(); + } + let mut cache = self.highlight_cache.borrow_mut(); + if let Some(c) = cache.as_ref() { + if c.seq == self.edit_seq && c.language == language { + return c.spans.clone(); + } + } + // Aplica los InputEdits pending al tree cached antes de parsear + // — eso convierte el parseo de "full" a "incremental real". + let edits: Vec = + self.pending_input_edits.borrow_mut().drain(..).collect(); + crate::highlight::apply_pending_edits(language, &edits); + + let mut h = Highlighter::new(language); + let spans = h.highlight(&self.buffer.text()); + *cache = Some(HighlightCache { + seq: self.edit_seq, + language, + spans: spans.clone(), + }); + spans + } + + pub fn is_empty(&self) -> bool { + self.buffer.is_empty() + } + + pub fn line_count(&self) -> usize { + self.buffer.len_lines() + } + + /// Posiciona el caret en `(line, col)`, clampeando al rango válido + /// del buffer. Colapsa la selección. Usado por el caller cuando el + /// usuario clickea en el área de texto. + /// + /// Si la línea destino está en [`Self::guard_lines`], el caret + /// salta a la línea no-guarda más cercana (privilegia hacia + /// abajo). Así un click "en la franja entre zonas" aterriza en el + /// inicio de la zona siguiente. + pub fn set_caret_at(&mut self, line: usize, col: usize) { + self.cursor.set_caret(&self.buffer, Pos::new(line, col)); + if !self.guard_lines.is_empty() { + snap_cursor_off_guard(&mut self.cursor, &self.buffer, &self.guard_lines, 0); + } + } + + /// `true` si la línea `line` figura en `guard_lines`. + pub fn is_guard_line(&self, line: usize) -> bool { + self.guard_lines.binary_search(&line).is_ok() + } + + /// Reemplaza la lista de líneas-guarda. La entrada se ordena y + /// deduplica — el caller puede pasarlas en cualquier orden. Tras + /// el cambio NO se snappea el caret automáticamente: si tu nueva + /// lista deja al caret sobre una guarda, llamá a + /// [`Self::snap_off_guards`] explícitamente. + pub fn set_guard_lines(&mut self, mut lines: Vec) { + lines.sort_unstable(); + lines.dedup(); + self.guard_lines = lines; + } + + /// Salta el primary cursor + extras fuera de cualquier línea + /// guarda. `dir` orienta la búsqueda: `+1` busca primero abajo, + /// `-1` arriba, `0` igual a `+1` (con fallback al opuesto). + /// No-op si `guard_lines` está vacío. + pub fn snap_off_guards(&mut self, dir: i32) { + if self.guard_lines.is_empty() { + return; + } + snap_cursor_off_guard(&mut self.cursor, &self.buffer, &self.guard_lines, dir); + for c in &mut self.extra_cursors { + snap_cursor_off_guard(c, &self.buffer, &self.guard_lines, dir); + } + } + + /// Extiende la selección hasta `(line, col)`. Si no había anchor, + /// lo planta en el caret actual antes de mover. Usado por drag del + /// mouse: cada `Move` del drag llama esto con la nueva pos. + pub fn extend_selection_to(&mut self, line: usize, col: usize) { + let line = line.min(self.buffer.len_lines().saturating_sub(1)); + let col = col.min(self.buffer.line_len_chars(line)); + if self.cursor.anchor.is_none() { + self.cursor.anchor = Some(self.cursor.caret); + } + self.cursor.caret = Pos::new(line, col); + self.cursor.desired_col = col; + } + + /// Selecciona todo el buffer: anchor en `(0,0)`, caret al final de + /// la última línea. Colapsa los multi-cursor extras. Operación de + /// sólo-cursor (no edita) — la usan el menú de edición y Ctrl+A. + pub fn select_all(&mut self) { + self.collapse_to_primary(); + let last_line = self.buffer.len_lines().saturating_sub(1); + let last_col = self.buffer.line_len_chars(last_line); + self.cursor.anchor = Some(Pos::ORIGIN); + self.cursor.caret = Pos::new(last_line, last_col); + self.cursor.desired_col = last_col; + } + + /// `true` si hay algo que deshacer (para habilitar "Deshacer" en el + /// menú de edición). + pub fn can_undo(&self) -> bool { + self.undo.can_undo() + } + + /// `true` si hay algo que rehacer. + pub fn can_redo(&self) -> bool { + self.undo.can_redo() + } + + /// `true` si hay una selección no-vacía (para habilitar Cortar/ + /// Copiar/Eliminar en el menú de edición). + pub fn has_selection(&self) -> bool { + self.cursor.has_selection() + } + + /// Texto seleccionado, si hay selección no-vacía. `None` cuando el + /// cursor está colapsado. + pub fn selected_text(&self) -> Option { + if !self.cursor.has_selection() { + return None; + } + let (s, e) = self.cursor.selection_range(&self.buffer); + if s == e { + return None; + } + Some(self.buffer.slice(s, e)) + } + + /// Resultado: `Changed` si la tecla modificó el buffer o el cursor; + /// `Ignored` si la tecla no aplica al editor. Útil para que el + /// caller decida si rebuildear el view. + /// + /// Copy/cut/paste (Ctrl+C/X/V) son ignorados — para habilitarlos, + /// usá [`Self::apply_key_with_clipboard`] pasando un backend. + pub fn apply_key(&mut self, event: &KeyEvent) -> ApplyResult { + self.apply_key_with_clipboard(event, &mut NullClipboard) + } + + /// Como [`Self::apply_key`] pero con backend de clipboard activo: + /// Ctrl+C copia la selección, Ctrl+X la corta, Ctrl+V pega lo que + /// haya en el clipboard. + pub fn apply_key_with_clipboard( + &mut self, + event: &KeyEvent, + clipboard: &mut dyn Clipboard, + ) -> ApplyResult { + // Antes de aplicar la tecla guardamos la línea del primary + // cursor: si la edición/movimiento termina parando en una + // guarda, la dirección del salto es la diferencia + // post-pre. Up → snap arriba, Down → snap abajo, click/edit + // en el mismo sitio → snap abajo por default. + let pre_line = self.cursor.caret.line as i32; + let r = self.apply_key_inner(event, clipboard); + if r.changed() { + self.bump_edit_seq(); + } + if r.touched() && !self.guard_lines.is_empty() && !self.cursor.has_selection() { + // Si hay selección viva (shift+arrow / drag) no snappeamos: + // el usuario está seleccionando a través de la guarda y + // forzar el caret afuera rompería la selección. + let dir = (self.cursor.caret.line as i32 - pre_line).signum(); + self.snap_off_guards(dir); + } + r + } + + fn apply_key_inner( + &mut self, + event: &KeyEvent, + clipboard: &mut dyn Clipboard, + ) -> ApplyResult { + if event.state != KeyState::Pressed { + return ApplyResult::Ignored; + } + let extending = event.modifiers.shift; + let ctrl = event.modifiers.ctrl || event.modifiers.meta; + let alt = event.modifiers.alt; + + // Esc colapsa multi-cursor (sin extras = ignorado, el caller + // decide qué más hacer — cancelar edit, cerrar find, etc.). + if matches!(&event.key, Key::Named(NamedKey::Escape)) { + if self.has_multi_cursor() { + self.collapse_to_primary(); + return ApplyResult::CursorMoved; + } + return ApplyResult::Ignored; + } + + // Multi-cursor: Ctrl+Alt+ArrowDown/Up agrega un cursor en la + // línea siguiente/anterior usando la misma desired_col. Esc del + // caller debería colapsar — no lo manejamos acá porque el caller + // puede querer usar Esc para otras cosas (cerrar find, cancelar + // edit). El caller chequea has_multi_cursor() antes. + if ctrl && alt { + match &event.key { + Key::Named(NamedKey::ArrowDown) => { + let line = self.cursor.caret.line + 1; + if line < self.buffer.len_lines() { + self.add_cursor_at(line, self.cursor.desired_col); + return ApplyResult::CursorMoved; + } + return ApplyResult::Ignored; + } + Key::Named(NamedKey::ArrowUp) => { + if self.cursor.caret.line > 0 { + self.add_cursor_at(self.cursor.caret.line - 1, self.cursor.desired_col); + return ApplyResult::CursorMoved; + } + return ApplyResult::Ignored; + } + _ => {} + } + } + + let page = self.options.page_size; + match &event.key { + // Movimiento + Key::Named(NamedKey::ArrowLeft) => { + if ctrl { + self.apply_move_all(|b, c| c.move_word_left(b, extending)); + } else { + self.apply_move_all(|b, c| c.move_left(b, extending)); + } + ApplyResult::CursorMoved + } + Key::Named(NamedKey::ArrowRight) => { + if ctrl { + self.apply_move_all(|b, c| c.move_word_right(b, extending)); + } else { + self.apply_move_all(|b, c| c.move_right(b, extending)); + } + ApplyResult::CursorMoved + } + Key::Named(NamedKey::ArrowUp) => { + self.apply_move_all(|b, c| c.move_up(b, extending)); + ApplyResult::CursorMoved + } + Key::Named(NamedKey::ArrowDown) => { + self.apply_move_all(|b, c| c.move_down(b, extending)); + ApplyResult::CursorMoved + } + Key::Named(NamedKey::Home) => { + if ctrl { + self.apply_move_all(|b, c| c.move_doc_start(b, extending)); + } else { + self.apply_move_all(|b, c| c.move_home(b, extending)); + } + ApplyResult::CursorMoved + } + Key::Named(NamedKey::End) => { + if ctrl { + self.apply_move_all(|b, c| c.move_doc_end(b, extending)); + } else { + self.apply_move_all(|b, c| c.move_end(b, extending)); + } + ApplyResult::CursorMoved + } + Key::Named(NamedKey::PageUp) => { + self.apply_move_all(|b, c| c.move_page_up(b, extending, page)); + ApplyResult::CursorMoved + } + Key::Named(NamedKey::PageDown) => { + self.apply_move_all(|b, c| c.move_page_down(b, extending, page)); + ApplyResult::CursorMoved + } + + // Edición + Key::Named(NamedKey::Enter) => { + if self.options.single_line { + return ApplyResult::Ignored; + } + self.apply_edit_all(|b, c, _opts| Some(insert_newline_auto_indent(b, c))); + ApplyResult::Changed + } + Key::Named(NamedKey::Backspace) => { + if self.apply_edit_all(|b, c, _opts| delete_backward(b, c)) { + ApplyResult::Changed + } else { + ApplyResult::Ignored + } + } + Key::Named(NamedKey::Delete) => { + if self.apply_edit_all(|b, c, _opts| delete_forward(b, c)) { + ApplyResult::Changed + } else { + ApplyResult::Ignored + } + } + Key::Named(NamedKey::Tab) => { + let any = if extending { + self.apply_edit_all(|b, c, opts| { + dedent(b, c, opts.tab_to_spaces, opts.indent_size) + }) + } else { + self.apply_edit_all(|b, c, opts| { + Some(indent_or_insert_tab(b, c, opts.tab_to_spaces, opts.indent_size)) + }) + }; + if any { ApplyResult::Changed } else { ApplyResult::Ignored } + } + + // Clipboard + Key::Character(s) if ctrl && s.as_str().eq_ignore_ascii_case("c") => { + if let Some(text) = self.selected_text() { + clipboard.set(&text); + ApplyResult::CursorMoved + } else { + ApplyResult::Ignored + } + } + Key::Character(s) if ctrl && s.as_str().eq_ignore_ascii_case("x") => { + if let Some(text) = self.selected_text() { + clipboard.set(&text); + let d = replace_selection(&mut self.buffer, &mut self.cursor, ""); + self.undo.push(d); + ApplyResult::Changed + } else { + ApplyResult::Ignored + } + } + Key::Character(s) if ctrl && s.as_str().eq_ignore_ascii_case("v") => { + let Some(text) = clipboard.get() else { + return ApplyResult::Ignored; + }; + if text.is_empty() { + return ApplyResult::Ignored; + } + // En single-line, los `\n` del clipboard se aplanan. + let to_insert = if self.options.single_line { + text.replace(['\n', '\r'], " ") + } else { + text + }; + let d = replace_selection(&mut self.buffer, &mut self.cursor, &to_insert); + self.undo.push(d); + ApplyResult::Changed + } + + // Undo / Redo + Key::Character(s) if ctrl && s.as_str().eq_ignore_ascii_case("z") => { + let did = if extending { + self.undo.redo(&mut self.buffer, &mut self.cursor) + } else { + self.undo.undo(&mut self.buffer, &mut self.cursor) + }; + if did { ApplyResult::Changed } else { ApplyResult::Ignored } + } + Key::Character(s) if ctrl && s.as_str().eq_ignore_ascii_case("y") => { + let did = self.undo.redo(&mut self.buffer, &mut self.cursor); + if did { ApplyResult::Changed } else { ApplyResult::Ignored } + } + + // Inserción de chars imprimibles vía event.text (respeta IME + + // layouts no-US). Ignoramos cuando ctrl/meta están activos + // para no comernos Ctrl+S, Ctrl+C, etc. (eso lo hace el + // caller registrando shortcuts). + _ => { + if ctrl { + return ApplyResult::Ignored; + } + let Some(text) = event.text.as_ref() else { + return ApplyResult::Ignored; + }; + if text.is_empty() || text.chars().any(|c| c.is_control()) { + return ApplyResult::Ignored; + } + let text = text.clone(); + self.apply_edit_all(|b, c, _opts| Some(replace_selection(b, c, &text))); + ApplyResult::Changed + } + } + } + + // ----- Multi-cursor helpers ----- + + /// Aplica un movimiento (no edita el buffer) a todos los cursores: + /// primary + extras. Después dedupa para evitar cursores que terminan + /// en el mismo punto. + fn apply_move_all(&mut self, mut f: F) + where + F: FnMut(&Buffer, &mut Cursor), + { + f(&self.buffer, &mut self.cursor); + for c in &mut self.extra_cursors { + f(&self.buffer, c); + } + self.dedupe_cursors(); + } + + /// Aplica una edición (que puede modificar el buffer) a todos los + /// cursores. Procesa en orden de offset descendente para que las + /// ediciones tempranas no desplacen las posiciones de las + /// posteriores. Devuelve `true` si al menos uno produjo un delta. + /// Cada delta también genera un `tree_sitter::InputEdit` que va a + /// `pending_input_edits` para alimentar el incremental parsing. + fn apply_edit_all(&mut self, mut f: F) -> bool + where + F: FnMut(&mut Buffer, &mut Cursor, &EditorOptions) -> Option, + { + let mut all: Vec<(Option, usize)> = Vec::with_capacity(1 + self.extra_cursors.len()); + let p_off = self.buffer.pos_to_offset(self.cursor.caret.line, self.cursor.caret.col); + all.push((None, p_off)); + for (i, c) in self.extra_cursors.iter().enumerate() { + let off = self.buffer.pos_to_offset(c.caret.line, c.caret.col); + all.push((Some(i), off)); + } + all.sort_by_key(|(_, off)| std::cmp::Reverse(*off)); + + let opts = self.options; + let mut any = false; + for (which, _) in all { + let cursor: &mut Cursor = match which { + None => &mut self.cursor, + Some(i) => &mut self.extra_cursors[i], + }; + // Pre-edit positions del start del delta — necesitamos las + // coordenadas BYTE del buffer ANTES de la edición. + let start_char = self.buffer.pos_to_offset(cursor.caret.line, cursor.caret.col); + // Pero si hay selección, el start real es el min de la sel. + let (sel_start, _) = cursor.selection_range(&self.buffer); + let start_char = start_char.min(sel_start); + let start_byte = self.buffer.char_to_byte(start_char); + let start_line = self.buffer.char_to_line(start_char); + let start_col_byte = start_byte - self.buffer.line_to_byte(start_line); + let pre_pt = tree_sitter::Point { row: start_line, column: start_col_byte }; + + if let Some(d) = f(&mut self.buffer, cursor, &opts) { + let edit = compute_input_edit(start_byte, pre_pt, &d); + self.pending_input_edits.borrow_mut().push(edit); + self.undo.push(d); + any = true; + } + } + self.dedupe_cursors(); + any + } + + /// Elimina cursores extras que están en la misma posición que el + /// primary o que otros extras (después de una edición pueden + /// converger). + fn dedupe_cursors(&mut self) { + let primary = self.cursor.caret; + let mut seen: Vec = vec![primary]; + self.extra_cursors.retain(|c| { + if seen.contains(&c.caret) { + false + } else { + seen.push(c.caret); + true + } + }); + } +} + +/// Si `cursor.caret.line` cae sobre una línea presente en `guards`, +/// mueve el caret a la línea no-guarda más cercana siguiendo `dir`: +/// +/// - `dir > 0` → busca primero abajo, luego arriba. +/// - `dir < 0` → busca primero arriba, luego abajo. +/// - `dir == 0` → equivalente a `dir > 0`. +/// +/// Colapsa la selección y reposiciona el `col` clampeado al ancho de +/// la línea destino. `guards` debe estar ordenado ascendente; el +/// chequeo usa `binary_search`. Si TODAS las líneas son guardas, no +/// puede hacer nada y el caret queda donde está. +fn snap_cursor_off_guard( + cursor: &mut Cursor, + buffer: &Buffer, + guards: &[usize], + dir: i32, +) { + let n = buffer.len_lines(); + if n == 0 || guards.is_empty() { + return; + } + let line = cursor.caret.line.min(n - 1); + if guards.binary_search(&line).is_err() { + return; + } + // Orden de búsqueda: primero la dirección preferida, luego la opuesta. + let primary: i32 = if dir < 0 { -1 } else { 1 }; + let secondary: i32 = -primary; + for d in [primary, secondary] { + let mut probe = line as i32 + d; + while probe >= 0 && (probe as usize) < n { + let p = probe as usize; + if guards.binary_search(&p).is_err() { + let col = cursor.desired_col.min(buffer.line_len_chars(p)); + cursor.caret = Pos::new(p, col); + cursor.anchor = None; + return; + } + probe += d; + } + } + // Todas las líneas son guardas — no podemos hacer nada útil. +} + +/// Convierte un `EditDelta` + posiciones pre-edit a un `InputEdit` de +/// tree-sitter. tree-sitter trabaja en bytes y `Point { row, column_byte }`; +/// el editor trabaja en chars (y col_byte para esto). +/// +/// `start_byte` y `start_point` son las coords del inicio del delta +/// ANTES del cambio (el caller las captura). +fn compute_input_edit( + start_byte: usize, + start_point: tree_sitter::Point, + delta: &crate::ops::EditDelta, +) -> tree_sitter::InputEdit { + let removed_bytes = delta.removed.len(); + let inserted_bytes = delta.inserted.len(); + + let old_end_byte = start_byte + removed_bytes; + let new_end_byte = start_byte + inserted_bytes; + + let old_end_point = advance_point(start_point, &delta.removed); + let new_end_point = advance_point(start_point, &delta.inserted); + + tree_sitter::InputEdit { + start_byte, + old_end_byte, + new_end_byte, + start_position: start_point, + old_end_position: old_end_point, + new_end_position: new_end_point, + } +} + +/// Avanza un Point por el contenido de `text`: cuenta `\n` para filas, +/// bytes de la última línea para columna. +fn advance_point(start: tree_sitter::Point, text: &str) -> tree_sitter::Point { + let newlines = text.bytes().filter(|b| *b == b'\n').count(); + if newlines == 0 { + tree_sitter::Point { + row: start.row, + column: start.column + text.len(), + } + } else { + let after_last_nl = text.rsplit('\n').next().unwrap_or("").len(); + tree_sitter::Point { + row: start.row + newlines, + column: after_last_nl, + } + } +} + +/// Resultado de `apply_key`. El caller usa esto para decidir si +/// rebuildear el view o ignorar. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApplyResult { + /// La tecla cambió el buffer (o sea, hay edición persistible). + Changed, + /// Sólo se movió el cursor — el view se redibuja, pero el `source` + /// del notebook no cambia. + CursorMoved, + /// La tecla no aplicaba al editor. + Ignored, +} + +impl ApplyResult { + pub fn changed(self) -> bool { + matches!(self, ApplyResult::Changed) + } + pub fn touched(self) -> bool { + !matches!(self, ApplyResult::Ignored) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use llimphi_ui::Modifiers; + + fn ev(named: NamedKey, shift: bool, ctrl: bool) -> KeyEvent { + KeyEvent { + key: Key::Named(named), + state: KeyState::Pressed, + text: None, + modifiers: Modifiers { shift, ctrl, alt: false, meta: false }, + repeat: false, + } + } + fn evtext(s: &str, shift: bool, ctrl: bool) -> KeyEvent { + KeyEvent { + key: Key::Character(s.into()), + state: KeyState::Pressed, + text: Some(s.to_owned()), + modifiers: Modifiers { shift, ctrl, alt: false, meta: false }, + repeat: false, + } + } + + #[test] + fn escribir_chars_inserta() { + let mut s = EditorState::new(); + s.apply_key(&evtext("h", false, false)); + s.apply_key(&evtext("i", false, false)); + assert_eq!(s.text(), "hi"); + } + + #[test] + fn enter_con_indent_auto() { + let mut s = EditorState::new(); + s.set_text(" hola"); + s.cursor = Cursor::at(0, 8); + s.apply_key(&ev(NamedKey::Enter, false, false)); + assert_eq!(s.text(), " hola\n "); + } + + #[test] + fn enter_en_single_line_ignorado() { + let mut s = EditorState::with_options(EditorOptions { + single_line: true, + ..Default::default() + }); + s.set_text("a"); + s.cursor = Cursor::at(0, 1); + let r = s.apply_key(&ev(NamedKey::Enter, false, false)); + assert_eq!(r, ApplyResult::Ignored); + assert_eq!(s.text(), "a"); + } + + #[test] + fn tab_inserta_indent() { + let mut s = EditorState::new(); + s.apply_key(&ev(NamedKey::Tab, false, false)); + assert_eq!(s.text(), " "); // indent_size por defecto = 2 + } + + #[test] + fn shift_tab_dedenta() { + let mut s = EditorState::new(); + s.set_text(" hola"); + s.cursor = Cursor::at(0, 4); + s.apply_key(&ev(NamedKey::Tab, true, false)); + // indent_size=2 → quita 2 espacios + assert_eq!(s.text(), " hola"); + } + + #[test] + fn ctrl_z_y_ctrl_y_son_undo_redo() { + let mut s = EditorState::new(); + s.apply_key(&evtext("a", false, false)); + s.apply_key(&evtext("b", false, false)); + assert_eq!(s.text(), "ab"); + s.apply_key(&evtext("z", false, true)); + assert_eq!(s.text(), "a"); + s.apply_key(&evtext("y", false, true)); + assert_eq!(s.text(), "ab"); + } + + #[test] + fn ctrl_shift_z_es_redo() { + let mut s = EditorState::new(); + s.apply_key(&evtext("a", false, false)); + s.apply_key(&evtext("z", false, true)); + assert!(s.is_empty()); + s.apply_key(&evtext("z", true, true)); + assert_eq!(s.text(), "a"); + } + + #[test] + fn ctrl_arrow_left_salta_palabra() { + let mut s = EditorState::new(); + s.set_text("hola mundo"); + s.cursor = Cursor::at(0, 10); + s.apply_key(&ev(NamedKey::ArrowLeft, false, true)); + assert_eq!(s.cursor.caret, Pos::new(0, 5)); // inicio de "mundo" + s.apply_key(&ev(NamedKey::ArrowLeft, false, true)); + assert_eq!(s.cursor.caret, Pos::new(0, 0)); // inicio de "hola" + } + + #[test] + fn shift_arrow_selecciona_y_chars_reemplazan() { + let mut s = EditorState::new(); + s.set_text("abc"); + s.cursor = Cursor::at(0, 0); + s.apply_key(&ev(NamedKey::ArrowRight, true, false)); + s.apply_key(&ev(NamedKey::ArrowRight, true, false)); + assert!(s.cursor.has_selection()); + s.apply_key(&evtext("X", false, false)); + assert_eq!(s.text(), "Xc"); + } + + #[test] + fn ctrl_chars_se_ignoran_en_input_normal() { + // Ctrl+S no debería insertar "s". + let mut s = EditorState::new(); + let r = s.apply_key(&evtext("s", false, true)); + assert_eq!(r, ApplyResult::Ignored); + assert!(s.is_empty()); + } + + #[test] + fn ctrl_c_copia_la_seleccion_al_clipboard() { + use crate::clipboard::MemClipboard; + let mut s = EditorState::new(); + s.set_text("hola mundo"); + s.cursor = Cursor { + anchor: Some(Pos::new(0, 0)), + caret: Pos::new(0, 4), + desired_col: 4, + }; + let mut clip = MemClipboard::new(); + let r = s.apply_key_with_clipboard(&evtext("c", false, true), &mut clip); + assert_eq!(r, ApplyResult::CursorMoved); + assert_eq!(clip.get().as_deref(), Some("hola")); + // El buffer no cambia. + assert_eq!(s.text(), "hola mundo"); + } + + #[test] + fn ctrl_x_corta_y_borra() { + use crate::clipboard::MemClipboard; + let mut s = EditorState::new(); + s.set_text("hola mundo"); + s.cursor = Cursor { + anchor: Some(Pos::new(0, 0)), + caret: Pos::new(0, 5), + desired_col: 5, + }; + let mut clip = MemClipboard::new(); + let r = s.apply_key_with_clipboard(&evtext("x", false, true), &mut clip); + assert_eq!(r, ApplyResult::Changed); + assert_eq!(clip.get().as_deref(), Some("hola ")); + assert_eq!(s.text(), "mundo"); + } + + #[test] + fn ctrl_v_pega_en_el_caret() { + use crate::clipboard::MemClipboard; + let mut s = EditorState::new(); + s.set_text("ab"); + s.cursor = Cursor::at(0, 1); + let mut clip = MemClipboard::with("XYZ"); + s.apply_key_with_clipboard(&evtext("v", false, true), &mut clip); + assert_eq!(s.text(), "aXYZb"); + } + + #[test] + fn ctrl_v_aplana_newlines_en_single_line() { + use crate::clipboard::MemClipboard; + let mut s = EditorState::with_options(EditorOptions { + single_line: true, + ..Default::default() + }); + let mut clip = MemClipboard::with("a\nb\nc"); + s.apply_key_with_clipboard(&evtext("v", false, true), &mut clip); + assert_eq!(s.text(), "a b c"); + } + + #[test] + fn ensure_caret_visible_scrollea_hacia_abajo() { + let mut s = EditorState::new(); + let lines: String = (0..100).map(|n| format!("line {n}\n")).collect(); + s.set_text(&lines); + s.cursor = Cursor::at(50, 0); + s.ensure_caret_visible(20); + // Caret en línea 50, visible_lines = 20 → scroll = 50 - 19 = 31. + assert_eq!(s.scroll_offset, 31); + // El caret debe estar dentro del viewport. + assert!(s.cursor.caret.line >= s.scroll_offset); + assert!(s.cursor.caret.line < s.scroll_offset + 20); + } + + #[test] + fn ensure_caret_visible_scrollea_hacia_arriba() { + let mut s = EditorState::new(); + let lines: String = (0..100).map(|n| format!("line {n}\n")).collect(); + s.set_text(&lines); + s.scroll_offset = 50; + s.cursor = Cursor::at(5, 0); + s.ensure_caret_visible(20); + assert_eq!(s.scroll_offset, 5); + } + + #[test] + fn ensure_caret_visible_no_mueve_si_ya_visible() { + let mut s = EditorState::new(); + let lines: String = (0..50).map(|n| format!("line {n}\n")).collect(); + s.set_text(&lines); + s.scroll_offset = 10; + s.cursor = Cursor::at(15, 0); + s.ensure_caret_visible(20); + assert_eq!(s.scroll_offset, 10); + } + + #[test] + fn input_edits_se_acumulan_y_drenan_en_highlight() { + use crate::highlight::Language; + let mut s = EditorState::new(); + s.set_text("fn main() {}"); + // Set_text invalida pero NO pushea InputEdit (es replace_all). + // Después de una edit normal, sí debería haber 1 pending. + s.cursor = Cursor::at(0, 12); + s.apply_key(&evtext("x", false, false)); + assert_eq!(s.pending_input_edits.borrow().len(), 1); + // El parse drena los pending. + let _ = s.highlighted_spans(Language::Rust); + assert!(s.pending_input_edits.borrow().is_empty()); + } + + #[test] + fn input_edit_multilinea_calcula_rows_correctamente() { + let mut s = EditorState::new(); + s.set_text("ab"); + s.cursor = Cursor::at(0, 2); + s.apply_key(&ev(NamedKey::Enter, false, false)); + let edits = s.pending_input_edits.borrow().clone(); + assert_eq!(edits.len(), 1); + let e = &edits[0]; + // Insertó "\n" (auto-indent vacío porque no había indent) → + // new_end_position debe estar en row=1, col=0. + assert_eq!(e.start_byte, 2); + assert_eq!(e.new_end_position.row, 1); + assert_eq!(e.new_end_position.column, 0); + } + + #[test] + fn edit_seq_se_incrementa_solo_con_cambios() { + let mut s = EditorState::new(); + let seq0 = s.edit_seq; + s.apply_key(&ev(NamedKey::ArrowRight, false, false)); // CursorMoved + assert_eq!(s.edit_seq, seq0, "movimiento no debería bumpear"); + s.apply_key(&evtext("a", false, false)); // Changed + assert!(s.edit_seq > seq0); + } + + #[test] + fn highlight_cache_reuse_cuando_seq_no_cambia() { + use crate::highlight::Language; + let mut s = EditorState::new(); + s.set_text("fn main() {}"); + let _ = s.highlighted_spans(Language::Rust); + let seq_before = s.edit_seq; + let _ = s.highlighted_spans(Language::Rust); + // Sin edición → seq igual → cache hit (no asserción directa + // posible sin mock, pero al menos el seq no cambia). + assert_eq!(s.edit_seq, seq_before); + } + + #[test] + fn multi_cursor_insert_aplica_a_todos() { + let mut s = EditorState::new(); + s.set_text("ab\ncd\nef"); + // Cursor primary al final de "ab", extras al final de "cd" y "ef". + s.cursor = Cursor::at(0, 2); + s.add_cursor_at(1, 2); + s.add_cursor_at(2, 2); + s.apply_key(&evtext("!", false, false)); + assert_eq!(s.text(), "ab!\ncd!\nef!"); + } + + #[test] + fn multi_cursor_backspace_aplica_a_todos() { + let mut s = EditorState::new(); + s.set_text("ab\ncd\nef"); + s.cursor = Cursor::at(0, 2); + s.add_cursor_at(1, 2); + s.add_cursor_at(2, 2); + s.apply_key(&ev(NamedKey::Backspace, false, false)); + assert_eq!(s.text(), "a\nc\ne"); + } + + #[test] + fn dedupe_cursors_remueve_solapados() { + let mut s = EditorState::new(); + s.set_text("abc"); + s.cursor = Cursor::at(0, 1); + s.add_cursor_at(0, 1); // exacto primary → no se agrega + s.add_cursor_at(0, 2); + // El primer add no agregó nada; el segundo sí. + assert_eq!(s.extra_cursors.len(), 1); + } + + #[test] + fn collapse_to_primary_descarta_extras() { + let mut s = EditorState::new(); + s.set_text("abc"); + s.cursor = Cursor::at(0, 0); + s.add_cursor_at(0, 1); + s.add_cursor_at(0, 2); + assert!(s.has_multi_cursor()); + s.collapse_to_primary(); + assert!(!s.has_multi_cursor()); + } + + #[test] + fn highlight_cache_invalida_con_cambio_de_lenguaje() { + use crate::highlight::Language; + let mut s = EditorState::new(); + s.set_text("def f(): pass"); + let py = s.highlighted_spans(Language::Python); + let rs = s.highlighted_spans(Language::Rust); + // Distinto lenguaje → spans distintos (al menos el conteo o + // las categorías difieren). + assert!(py != rs || s.is_empty()); + } + + #[test] + fn scroll_by_clampea_a_rango_valido() { + let mut s = EditorState::new(); + let lines: String = (0..10).map(|n| format!("line {n}\n")).collect(); + s.set_text(&lines); + s.scroll_by(-100); + assert_eq!(s.scroll_offset, 0); + s.scroll_by(1000); + assert!(s.scroll_offset < 11); + } + + fn estado_con_guardas(texto: &str, guards: Vec) -> EditorState { + let mut s = EditorState::new(); + s.set_text(texto); + s.set_guard_lines(guards); + s + } + + #[test] + fn guarda_set_caret_at_en_linea_vacia_salta_hacia_abajo() { + // "abc\n\ndef" → líneas: "abc", "", "def". La 1 es guarda. + let mut s = estado_con_guardas("abc\n\ndef", vec![1]); + s.set_caret_at(1, 0); + // El caret no puede quedar en la línea 1 (guarda) — salta a 2. + assert_eq!(s.cursor.caret, Pos::new(2, 0)); + } + + #[test] + fn guarda_sin_linea_abajo_salta_arriba() { + // Todas las líneas después de la 0 son guardas: el snap solo + // puede ir hacia arriba. + let mut s = estado_con_guardas("abc\n\n", vec![1, 2]); + s.set_caret_at(1, 0); + assert_eq!(s.cursor.caret.line, 0); + } + + #[test] + fn guarda_arrow_down_atraviesa_la_separacion() { + let mut s = estado_con_guardas("abc\n\ndef", vec![1]); + s.cursor = Cursor::at(0, 0); + // Down debería terminar en línea 2, no en la 1 (guarda). + s.apply_key(&ev(NamedKey::ArrowDown, false, false)); + assert_eq!(s.cursor.caret.line, 2); + } + + #[test] + fn guarda_arrow_up_atraviesa_la_separacion() { + let mut s = estado_con_guardas("abc\n\ndef", vec![1]); + s.cursor = Cursor::at(2, 1); + s.apply_key(&ev(NamedKey::ArrowUp, false, false)); + assert_eq!(s.cursor.caret.line, 0); + } + + #[test] + fn set_text_limpia_guardas() { + // Tras `set_text`, las guardas anteriores ya no son válidas: + // el caller las repuebla. La función las limpia. + let mut s = estado_con_guardas("abc\n\ndef", vec![1]); + assert_eq!(s.guard_lines, vec![1]); + s.set_text("nuevo"); + assert!(s.guard_lines.is_empty()); + } + + #[test] + fn sin_guardas_set_caret_at_en_blank_se_queda() { + // Con `guard_lines` vacío, comportamiento clásico: el caret + // puede caer en cualquier línea sin snap. + let mut s = EditorState::new(); + s.set_text("abc\n\ndef"); + s.set_caret_at(1, 0); + assert_eq!(s.cursor.caret, Pos::new(1, 0)); + } + + #[test] + fn guarda_shift_arrow_extiende_seleccion_a_traves() { + // Con selección viva atravesando la guarda, NO snapear: el + // usuario está seleccionando texto multi-zona. + let mut s = estado_con_guardas("abc\n\ndef", vec![1]); + s.cursor = Cursor::at(0, 3); + s.apply_key(&ev(NamedKey::ArrowDown, true, false)); + // El caret puede quedar en la línea 1 mientras hay selección + // viva — el snap se inhibe. + assert!(s.cursor.has_selection()); + assert_eq!(s.cursor.caret.line, 1); + } + + #[test] + fn set_guard_lines_ordena_y_deduplica() { + let mut s = EditorState::new(); + s.set_text("a\nb\nc\nd\ne"); + s.set_guard_lines(vec![3, 1, 1, 3]); + assert_eq!(s.guard_lines, vec![1, 3]); + } + + #[test] + fn guarda_no_es_solo_blank_puede_ser_cualquiera() { + // Una guarda no tiene que ser una línea vacía — el widget no + // mira el contenido, sólo el índice. Una línea con texto + // marcada como guarda igual repele al caret. + let mut s = EditorState::new(); + s.set_text("aaa\nbbb\nccc"); + s.set_guard_lines(vec![1]); // línea "bbb" es guarda + s.set_caret_at(1, 0); + assert!(s.cursor.caret.line != 1); + } + + #[test] + fn ctrl_c_sin_seleccion_es_ignorado() { + use crate::clipboard::MemClipboard; + let mut s = EditorState::new(); + s.set_text("hola"); + s.cursor = Cursor::at(0, 4); + let mut clip = MemClipboard::new(); + let r = s.apply_key_with_clipboard(&evtext("c", false, true), &mut clip); + assert_eq!(r, ApplyResult::Ignored); + assert!(clip.get().is_none()); + } +} diff --git a/widgets/text-editor/src/view.rs b/widgets/text-editor/src/view.rs new file mode 100644 index 0000000..556a39f --- /dev/null +++ b/widgets/text-editor/src/view.rs @@ -0,0 +1,855 @@ +//! Render del editor. Layout: gutter izquierdo (line numbers) + área +//! principal (texto + selección como rects + caret bloque). El scroll +//! vertical es implícito por viewport — el caller decide cuántas líneas +//! caben en el `height` que pasa. +//! +//! Limitaciones del PMV de render: +//! - **Char width fijo** — asume fuente monoespaciada y un ancho de +//! carácter en píxeles fijo. Para CJK / proportional el caret y la +//! selección se desalinean. Para texto ASCII monoespaciado es exacto. +//! - **Selección multilínea** se pinta como un rect por línea afectada +//! (sin "rio" continuo); estilo Sublime Text / antiguo, lectura clara. +//! - **Sin syntax highlight todavía** — eso vive en su propio bloque y +//! requiere `llimphi-text` rich (Vec); aquí cada línea va +//! monocolor `fg_text`. + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, percent, FlexDirection, Position, Rect, Size, Style}, + AlignItems, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; + +use crate::cursor::Pos; +use crate::diagnostics::{Diagnostic, Severity}; +use crate::highlight::{Language, Span, SyntaxPalette, TokenKind}; +use crate::state::EditorState; + +/// Paleta del editor. Defaults dark. +#[derive(Debug, Clone, Copy)] +pub struct EditorPalette { + pub bg: Color, + pub bg_gutter: Color, + pub bg_selection: Color, + pub bg_current_line: Color, + pub fg_text: Color, + pub fg_line_number: Color, + pub fg_line_number_active: Color, + pub caret: Color, + /// Fondo del bracket bajo el cursor + su par. Un acento sutil. + pub bg_bracket_pair: Color, + /// Fondo de cada match del find activo. + pub bg_match: Color, + /// Subrayado de diagnostic — Error. + pub diag_error: Color, + /// Subrayado de diagnostic — Warning. + pub diag_warning: Color, + /// Subrayado de diagnostic — Information. + pub diag_info: Color, + /// Subrayado de diagnostic — Hint. + pub diag_hint: Color, +} + +impl Default for EditorPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl EditorPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + // Reutilizamos slots del theme; los que no existen como semánticos + // se derivan con `mix`/transparencia conceptual. + Self { + bg: t.bg_input, + bg_gutter: t.bg_panel, + bg_selection: t.bg_selected, + bg_current_line: t.bg_panel_alt, + fg_text: t.fg_text, + fg_line_number: t.fg_muted, + fg_line_number_active: t.fg_text, + caret: t.accent, + bg_bracket_pair: t.bg_button_hover, + bg_match: t.bg_button_hover, + diag_error: t.fg_destructive, + diag_warning: Color::from_rgb8(229, 192, 123), + diag_info: Color::from_rgb8(97, 175, 239), + diag_hint: t.fg_muted, + } + } +} + +/// Cómo renderizar la columna izquierda del editor. +/// +/// - [`GutterStyle::Numbers`] es el comportamiento clásico de IDE: +/// "1", "2", "3"… alineados a la derecha del gutter. +/// - [`GutterStyle::Phantom`] suprime los números y dibuja en su lugar +/// un tick **muy sutil** por línea (un pequeño segmento horizontal +/// con baja opacidad). Sirve para prosa narrativa donde el número de +/// línea es ruido — la línea sigue estando, pero "fingiendo no +/// estar". El gutter en este modo se acorta a un sliver fino. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum GutterStyle { + #[default] + Numbers, + Phantom, +} + +/// Métricas del editor — todo derivado del `font_size`. Cambiar la +/// fuente requiere recalcular `char_width` empíricamente para la mono +/// que use llimphi-text; los valores acá son razonables para +/// `font_size = 12` con la mono default de parley. +#[derive(Debug, Clone, Copy)] +pub struct EditorMetrics { + pub font_size: f32, + /// Alto de cada línea en píxeles (font_size * line_height_ratio). + pub line_height: f32, + /// Ancho promedio de un char (mono). Si la fuente no es mono, esto + /// es sólo una aproximación. + pub char_width: f32, + /// Ancho del gutter (incluye padding interno). + pub gutter_width: f32, + /// Cómo se pinta el gutter. Default [`GutterStyle::Numbers`] — el + /// comportamiento clásico se conserva para callers existentes. + pub gutter_style: GutterStyle, + /// Si `true`, cada línea **guarda** (índices en + /// `EditorState::guard_lines`) recibe un segmento horizontal con + /// baja opacidad atravesando su centro — un divisor fantasma que + /// sugiere "acá termina un bloque" sin gritar. Sin guardas, esto + /// no hace nada visible. Default `false`: comportamiento IDE + /// clásico. + pub phantom_guard_lines: bool, +} + +impl Default for EditorMetrics { + fn default() -> Self { + Self::for_font_size(12.0) + } +} + +impl EditorMetrics { + pub const fn for_font_size(font_size: f32) -> Self { + Self { + font_size, + line_height: font_size * 1.4, + char_width: font_size * 0.6, + gutter_width: font_size * 3.5, + gutter_style: GutterStyle::Numbers, + phantom_guard_lines: false, + } + } + + /// Variante "prosa": gutter fantasma (ticks sutiles, sin números) + + /// divisores fantasma en cada guarda. Ancho del gutter reducido a + /// un sliver porque ya no necesita acomodar dígitos. + /// + /// Pensado para editores narrativos tipo `cuerpo_ide` donde el + /// número de línea es ruido y las junctions están marcadas como + /// guardas. + pub const fn prosa(font_size: f32) -> Self { + Self { + font_size, + line_height: font_size * 1.4, + char_width: font_size * 0.6, + gutter_width: font_size * 1.0, + gutter_style: GutterStyle::Phantom, + phantom_guard_lines: true, + } + } + + /// Convierte coords locales del **área de contenido** (no del gutter) + /// a `(line, col)` absolutas en el buffer. `local_x` se mide desde el + /// borde izquierdo del área de texto (sin el padding interno de 4 px); + /// `local_y` desde la primera línea visible. + /// + /// Devuelve coordenadas siempre dentro del buffer — el caller + /// generalmente las pasa a `EditorState::set_caret_at` que clampea + /// `col` al ancho real de la línea. + pub fn screen_to_pos(self, local_x: f32, local_y: f32, scroll_offset: usize) -> (usize, usize) { + let line_local = (local_y / self.line_height).max(0.0) as usize; + let col = ((local_x - 4.0).max(0.0) / self.char_width).round() as usize; + (scroll_offset + line_local, col) + } +} + +/// Render principal sin syntax highlight — todas las líneas visibles +/// en `palette.fg_text`. `visible_lines` es cuántas líneas mostrar como +/// máximo en el viewport. +/// +/// `on_pointer` se invoca con el evento del mouse dentro del área de +/// texto (no del gutter): el caller decide cómo mover el caret / +/// extender selección. Ver [`PointerEvent`]. +pub fn text_editor_view( + state: &EditorState, + palette: &EditorPalette, + metrics: EditorMetrics, + visible_lines: usize, + on_pointer: impl Fn(PointerEvent) -> Option + Send + Sync + Clone + 'static, +) -> View { + text_editor_view_highlighted( + state, + palette, + metrics, + visible_lines, + Language::Plain, + on_pointer, + ) +} + +/// Evento de mouse que el view envía al caller dentro del área de texto. +/// El caller convierte `(x, y)` con [`EditorMetrics::screen_to_pos`] y +/// aplica `set_caret_at` (Click) o `extend_selection_to` (Drag). +/// +/// `Drag` entrega `initial` (pos del press inicial, constante durante el +/// drag) + `delta` (delta desde el evento anterior). El caller debe +/// acumular el delta — el view no mantiene state. Patrón típico: +/// `accum += (dx, dy); actual = (initial_x + accum.0, initial_y + accum.1)`. +#[derive(Debug, Clone, Copy)] +pub enum PointerEvent { + Click { x: f32, y: f32 }, + Drag { initial_x: f32, initial_y: f32, dx: f32, dy: f32 }, +} + +/// Render con syntax highlight + **viewport scrolling**: sólo se renderizan +/// las líneas en `[state.scroll_offset, scroll_offset + visible_lines)`. +/// +/// `visible_lines` es cuántas líneas máximo dibujamos por frame; el caller +/// se asegura de tener un container con altura ≥ `visible_lines * line_height` +/// o aplica clip propio. Para archivos grandes (1000+ líneas), el cap es +/// crítico — sin él generaríamos miles de Views y wgpu rechazaría el bind +/// group por `max_*_buffer_binding_size`. +/// +/// Recomendación para el caller: tras cada edición, llamar a +/// [`EditorState::ensure_caret_visible`] con el mismo `visible_lines` para +/// que el viewport siga al caret. +pub fn text_editor_view_highlighted( + state: &EditorState, + palette: &EditorPalette, + metrics: EditorMetrics, + visible_lines: usize, + language: Language, + on_pointer: impl Fn(PointerEvent) -> Option + Send + Sync + Clone + 'static, +) -> View { + text_editor_view_full( + state, + palette, + metrics, + visible_lines, + language, + &[], + on_pointer, + ) +} + +/// Como [`text_editor_view_highlighted`] + `match_ranges` para pintar +/// las ocurrencias de un find activo. Cada par `(char_start, char_end)` +/// es un rango de chars globales del buffer. +pub fn text_editor_view_full( + state: &EditorState, + palette: &EditorPalette, + metrics: EditorMetrics, + visible_lines: usize, + language: Language, + match_ranges: &[(usize, usize)], + on_pointer: impl Fn(PointerEvent) -> Option + Send + Sync + Clone + 'static, +) -> View { + let caret = state.cursor.caret; + let syntax = crate::syntax_palette_dark(&llimphi_theme::Theme::dark()); + + let visible = visible_lines.max(1).min(200); + let line_count = state.line_count(); + let scroll = state.scroll_offset.min(line_count.saturating_sub(1)); + let end_line = (scroll + visible).min(line_count); + let height = (end_line - scroll) as f32 * metrics.line_height; + + // Memoizado por `edit_seq` — sólo reparseamos cuando el buffer + // realmente cambió o cambia el `Language`. + let spans = state.highlighted_spans(language); + + let gutter = build_gutter(state, scroll, end_line, caret.line, metrics, palette); + let content = build_content( + state, + palette, + metrics, + height, + scroll, + end_line, + spans, + &syntax, + match_ranges, + on_pointer, + ); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { width: percent(1.0_f32), height: length(height) }, + ..Default::default() + }) + .fill(palette.bg) + .clip(true) + .children(vec![gutter, content]) +} + +fn build_gutter( + state: &EditorState, + scroll: usize, + end_line: usize, + active_line: usize, + metrics: EditorMetrics, + palette: &EditorPalette, +) -> View { + let count = end_line.saturating_sub(scroll); + let mut children: Vec> = Vec::with_capacity(count); + for n in scroll..end_line { + // Las líneas-guarda son separadores estructurales entre zonas + // de texto: ni se numeran ni se pueden escribir. El espacio + // se preserva (la línea sigue existiendo), pero el gutter las + // saltea — visualmente la numeración "rompe" en cada zona. + // Si `guard_lines` está vacío, este check es siempre `false` + // y la numeración cubre todas las líneas (modo IDE clásico). + if state.is_guard_line(n) { + continue; + } + let color = if n == active_line { + palette.fg_line_number_active + } else { + palette.fg_line_number + }; + let y = (n - scroll) as f32 * metrics.line_height; + match metrics.gutter_style { + GutterStyle::Numbers => { + let label = (n + 1).to_string(); + children.push( + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0_f32), + top: length(y), + right: length(4.0_f32), + bottom: auto(), + }, + size: Size { + width: length(metrics.gutter_width - 4.0), + height: length(metrics.line_height), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(label, metrics.font_size * 0.85, color, Alignment::End), + ); + } + GutterStyle::Phantom => { + // Tick fantasma — un segmento horizontal corto centrado + // verticalmente en la línea, con la opacidad bajada. + // La línea activa queda un pelín más visible. + let alpha = if n == active_line { 0.35 } else { 0.12 }; + let tick_w = (metrics.gutter_width * 0.5).max(3.0); + let tick_h = 1.0_f32; + let tick_y = y + (metrics.line_height - tick_h) * 0.5; + let tick_x = (metrics.gutter_width - tick_w) * 0.5; + children.push( + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(tick_x), + top: length(tick_y), + right: auto(), + bottom: auto(), + }, + size: Size { + width: length(tick_w), + height: length(tick_h), + }, + ..Default::default() + }) + .fill(with_alpha(color, alpha)), + ); + } + } + } + + // En modo Phantom el gutter es un sliver: no aplicamos `fill` — + // se mezcla con el fondo del editor. El gutter "está sin estar". + let bg = match metrics.gutter_style { + GutterStyle::Numbers => palette.bg_gutter, + GutterStyle::Phantom => palette.bg, + }; + View::new(Style { + size: Size { + width: length(metrics.gutter_width), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(bg) + .clip(true) + .children(children) +} + +/// Devuelve `c` con la opacidad multiplicada por `alpha` (clamp 0..1). +fn with_alpha(c: Color, alpha: f32) -> Color { + let rgba = c.to_rgba8(); + let a = ((alpha.clamp(0.0, 1.0)) * (rgba.a as f32)) as u8; + Color::from_rgba8(rgba.r, rgba.g, rgba.b, a) +} + +fn build_content( + state: &EditorState, + palette: &EditorPalette, + metrics: EditorMetrics, + height: f32, + scroll: usize, + end_line: usize, + spans_per_line: Vec>, + syntax: &SyntaxPalette, + match_ranges: &[(usize, usize)], + on_pointer: impl Fn(PointerEvent) -> Option + Send + Sync + Clone + 'static, +) -> View { + let caret = state.cursor.caret; + let mut children: Vec> = Vec::new(); + + // 0) Tintes por línea — la capa más baja, debajo de todo el resto. + // Pinta un rect del ancho completo del área de contenido por + // cada línea con tinte asignado. El caller elige el alpha — el + // widget no lo modula. Si la línea cae fuera de viewport o no + // tiene tinte, no se pinta nada. + for n in scroll..end_line { + if let Some(Some(c)) = state.line_tints.get(n) { + children.push(line_tint(n - scroll, *c, metrics)); + } + } + + // 1) Fondo del renglón activo — sólo el del primary cursor. + if caret.line >= scroll && caret.line < end_line { + children.push(line_highlight(caret.line - scroll, metrics, palette)); + } + + // 1b) Highlight de matches del find. + for (s, e) in match_ranges { + children.extend(match_rects(state, *s, *e, scroll, end_line, metrics, palette)); + } + + // 2) Selección — por cada cursor que tenga selección. + for c in state.all_cursors() { + if c.has_selection() { + children.extend(selection_rects_for_cursor( + state, c, scroll, end_line, metrics, palette, + )); + } + } + + // 2b) Bracket pair bajo el primary cursor — si visible. + if let Some((a, b)) = crate::bracket::find_bracket_pair(&state.buffer, &state.cursor) { + if a.line >= scroll && a.line < end_line { + children.push(bracket_highlight(crate::cursor::Pos::new(a.line - scroll, a.col), metrics, palette)); + } + if b.line >= scroll && b.line < end_line { + children.push(bracket_highlight(crate::cursor::Pos::new(b.line - scroll, b.col), metrics, palette)); + } + } + + // 3) Texto — sólo las líneas en viewport. + // Si `phantom_guard_lines` está activo, cada guarda recibe un + // divisor fantasma (segmento horizontal con baja opacidad) + // atravesando su centro — sin texto, sólo un susurro visual. + for n in scroll..end_line { + let text = state.buffer.line(n); + let text = text.trim_end_matches('\n').to_owned(); + let local_line = n - scroll; + if metrics.phantom_guard_lines && state.is_guard_line(n) { + children.push(phantom_guard_divider(local_line, metrics, palette)); + continue; + } + if let Some(line_spans) = spans_per_line.get(n) { + children.push(line_text_tokens(local_line, &text, line_spans, metrics, palette, syntax)); + } else { + children.push(line_text_plain(local_line, text, metrics, palette)); + } + } + + // 3b) Diagnostics — subrayado bajo el rango, color por severity. + for d in &state.diagnostics { + children.extend(diagnostic_underline(d, scroll, end_line, metrics, palette)); + } + + // 4) Caret — uno por cursor, sólo si visible. + for c in state.all_cursors() { + let p = c.caret; + if p.line >= scroll && p.line < end_line { + let local = crate::cursor::Pos::new(p.line - scroll, p.col); + children.push(caret_rect(local, metrics, palette)); + } + } + + let click_cb = on_pointer.clone(); + let drag_cb = on_pointer; + View::new(Style { + flex_grow: 1.0, + size: Size { width: percent(1.0_f32), height: length(height) }, + ..Default::default() + }) + .fill(palette.bg) + .clip(true) + .on_click_at(move |x, y, _w, _h| click_cb(PointerEvent::Click { x, y })) + .draggable_at(move |phase, dx, dy, lx, ly| match phase { + llimphi_ui::DragPhase::Move => drag_cb(PointerEvent::Drag { + initial_x: lx, + initial_y: ly, + dx, + dy, + }), + llimphi_ui::DragPhase::End => None, + }) + .children(children) +} + +/// Rect de tinte para una línea. Cubre el ancho completo y el alto +/// exacto de la línea, pintado al color literal pasado (el caller +/// elige el alpha). Posición absoluta dentro del área de contenido. +fn line_tint( + line: usize, + color: Color, + metrics: EditorMetrics, +) -> View { + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0_f32), + top: length(line as f32 * metrics.line_height), + right: length(0.0_f32), + bottom: auto(), + }, + size: Size { + width: percent(1.0_f32), + height: length(metrics.line_height), + }, + ..Default::default() + }) + .fill(color) +} + +fn line_highlight( + line: usize, + metrics: EditorMetrics, + palette: &EditorPalette, +) -> View { + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0_f32), + top: length(line as f32 * metrics.line_height), + right: length(0.0_f32), + bottom: auto(), + }, + size: Size { + width: percent(1.0_f32), + height: length(metrics.line_height), + }, + ..Default::default() + }) + .fill(palette.bg_current_line) +} + +/// Línea-fantasma para una guarda: un segmento horizontal con baja +/// opacidad atravesando el centro vertical de la línea. Ancho +/// limitado para que parezca un susurro y no una regla. Color derivado +/// de `fg_line_number` que ya está pensado como "muted". +fn phantom_guard_divider( + line: usize, + metrics: EditorMetrics, + palette: &EditorPalette, +) -> View { + let h = 1.0_f32; + let y = line as f32 * metrics.line_height + (metrics.line_height - h) * 0.5; + // Largo visual del divisor — generoso pero no infinito. + let w = 320.0_f32; + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(8.0_f32), + top: length(y), + right: auto(), + bottom: auto(), + }, + size: Size { + width: length(w), + height: length(h), + }, + ..Default::default() + }) + .fill(with_alpha(palette.fg_line_number, 0.18)) +} + +fn line_text_plain( + line: usize, + text: String, + metrics: EditorMetrics, + palette: &EditorPalette, +) -> View { + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(4.0_f32), + top: length(line as f32 * metrics.line_height), + right: auto(), + bottom: auto(), + }, + size: Size { + width: length(2000.0_f32), + height: length(metrics.line_height), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(text, metrics.font_size, palette.fg_text, Alignment::Start) +} + +/// Renderiza una línea como secuencia de Views absolutos posicionados, +/// cada uno con el color de su span. El posicionamiento horizontal usa +/// `char_width` (mono); para fuentes proporcionales habría que medir +/// cada token con parley (TODO). +fn line_text_tokens( + line: usize, + text: &str, + spans: &[Span], + metrics: EditorMetrics, + palette: &EditorPalette, + syntax: &SyntaxPalette, +) -> View { + // char-col → byte-offset: parley rangea por bytes, los spans por chars. + let mut byte_at: Vec = Vec::with_capacity(text.len() + 1); + let mut acc = 0usize; + byte_at.push(0); + for ch in text.chars() { + acc += ch.len_utf8(); + byte_at.push(acc); + } + let nchars = byte_at.len() - 1; + + // Un run de color por span no-Other (el default_color cubre el resto). + let mut runs: Vec<(usize, usize, Color)> = Vec::with_capacity(spans.len()); + for span in spans { + if span.start_col >= nchars || matches!(span.kind, TokenKind::Other) { + continue; + } + let end = span.end_col.min(nchars); + if end <= span.start_col { + continue; + } + runs.push((byte_at[span.start_col], byte_at[end], syntax.color(span.kind))); + } + + // Una sola línea shapeada de una vez, multicolor, en lugar de un nodo + // (+ layout parley) por token. El `+4` de gutter va en el inset del nodo. + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(4.0_f32), + top: length(line as f32 * metrics.line_height), + right: auto(), + bottom: auto(), + }, + size: Size { width: length(2000.0_f32), height: length(metrics.line_height) }, + ..Default::default() + }) + .text_runs( + text.to_string(), + metrics.font_size, + palette.fg_text, + runs, + Alignment::Start, + ) +} + +fn caret_rect( + caret: Pos, + metrics: EditorMetrics, + palette: &EditorPalette, +) -> View { + let x = 4.0 + caret.col as f32 * metrics.char_width; + let y = caret.line as f32 * metrics.line_height; + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(x), + top: length(y + 2.0), + right: auto(), + bottom: auto(), + }, + size: Size { width: length(2.0_f32), height: length(metrics.line_height - 4.0) }, + ..Default::default() + }) + .fill(palette.caret) +} + +fn bracket_highlight( + pos: Pos, + metrics: EditorMetrics, + palette: &EditorPalette, +) -> View { + let x = 4.0 + pos.col as f32 * metrics.char_width; + let y = pos.line as f32 * metrics.line_height; + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(x), + top: length(y), + right: auto(), + bottom: auto(), + }, + size: Size { width: length(metrics.char_width), height: length(metrics.line_height) }, + ..Default::default() + }) + .fill(palette.bg_bracket_pair) +} + +fn diagnostic_underline( + d: &Diagnostic, + scroll: usize, + end_viewport: usize, + metrics: EditorMetrics, + palette: &EditorPalette, +) -> Vec> { + let color = match d.severity { + Severity::Error => palette.diag_error, + Severity::Warning => palette.diag_warning, + Severity::Information => palette.diag_info, + Severity::Hint => palette.diag_hint, + }; + let mut out: Vec> = Vec::new(); + let first = d.range.start.line.max(scroll); + let last = d.range.end.line.min(end_viewport.saturating_sub(1)); + if first > last { + return out; + } + for line in first..=last { + let col_start = if line == d.range.start.line { d.range.start.col } else { 0 }; + let col_end = if line == d.range.end.line { + d.range.end.col + } else { + // Fin de línea — extendemos 1 char extra para visualizar el wrap. + col_start + 1 + }; + if col_end <= col_start { + continue; + } + let x = 4.0 + col_start as f32 * metrics.char_width; + let w = (col_end - col_start) as f32 * metrics.char_width; + // Subrayado de 1.5 px al final de la línea. + let y = (line - scroll) as f32 * metrics.line_height + metrics.line_height - 2.0; + out.push( + 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(1.5_f32) }, + ..Default::default() + }) + .fill(color), + ); + } + out +} + +fn match_rects( + state: &EditorState, + start_off: usize, + end_off: usize, + scroll: usize, + end_viewport: usize, + metrics: EditorMetrics, + palette: &EditorPalette, +) -> Vec> { + if start_off == end_off { + return vec![]; + } + let (start_line, start_col) = state.buffer.offset_to_pos(start_off); + let (end_line, end_col) = state.buffer.offset_to_pos(end_off); + let mut out: Vec> = Vec::new(); + let first = start_line.max(scroll); + let last = end_line.min(end_viewport.saturating_sub(1)); + if first > last { + return out; + } + for line in first..=last { + let line_len = state.buffer.line_len_chars(line); + let col_start = if line == start_line { start_col } else { 0 }; + let col_end = if line == end_line { end_col } else { line_len }; + if col_end <= col_start { + continue; + } + let x = 4.0 + col_start as f32 * metrics.char_width; + let w = (col_end - col_start) as f32 * metrics.char_width; + let local_y = (line - scroll) as f32 * metrics.line_height; + out.push( + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(x), + top: length(local_y), + right: auto(), + bottom: auto(), + }, + size: Size { width: length(w), height: length(metrics.line_height) }, + ..Default::default() + }) + .fill(palette.bg_match), + ); + } + out +} + +fn selection_rects_for_cursor( + state: &EditorState, + cursor: &crate::cursor::Cursor, + scroll: usize, + end_viewport: usize, + metrics: EditorMetrics, + palette: &EditorPalette, +) -> Vec> { + let (start_off, end_off) = cursor.selection_range(&state.buffer); + if start_off == end_off { + return vec![]; + } + let (start_line, start_col) = state.buffer.offset_to_pos(start_off); + let (end_line, end_col) = state.buffer.offset_to_pos(end_off); + + let mut out: Vec> = Vec::new(); + let first = start_line.max(scroll); + let last = end_line.min(end_viewport.saturating_sub(1)); + if first > last { + return out; + } + for line in first..=last { + let line_len = state.buffer.line_len_chars(line); + let col_start = if line == start_line { start_col } else { 0 }; + let col_end = if line == end_line { end_col } else { line_len }; + let x = 4.0 + col_start as f32 * metrics.char_width; + let extra = if line < end_line { 1.0 } else { 0.0 }; + let w = ((col_end - col_start) as f32 + extra) * metrics.char_width; + if w <= 0.0 { + continue; + } + let local_y = (line - scroll) as f32 * metrics.line_height; + out.push( + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(x), + top: length(local_y), + right: auto(), + bottom: auto(), + }, + size: Size { width: length(w), height: length(metrics.line_height) }, + ..Default::default() + }) + .fill(palette.bg_selection), + ); + } + out +} diff --git a/widgets/text-input/Cargo.toml b/widgets/text-input/Cargo.toml new file mode 100644 index 0000000..272da9a --- /dev/null +++ b/widgets/text-input/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-text-input" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-text-input — input de texto single-line para Llimphi. Wrappea el llimphi-widget-text-editor en modo single_line para heredar selección con shift+arrows, undo/redo, word-jump con Ctrl, sin perder la API compacta original." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-text-editor = { workspace = true } diff --git a/widgets/text-input/LEEME.md b/widgets/text-input/LEEME.md new file mode 100644 index 0000000..9f9f233 --- /dev/null +++ b/widgets/text-input/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-text-input + +> Input single-line para [llimphi](../../README.md). + +Reusa partes del [`text-editor`](../text-editor/README.md) pero con UI ajustada: sin multi-line, sin syntax, con placeholder, foco azul, enter dispara `Msg::Submit`. diff --git a/widgets/text-input/README.md b/widgets/text-input/README.md new file mode 100644 index 0000000..72a655d --- /dev/null +++ b/widgets/text-input/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-text-input + +> Single-line input for [llimphi](../../README.md). + +Reuses parts of [`text-editor`](../text-editor/README.md) but with tuned UI: no multi-line, no syntax, with placeholder, blue focus, enter fires `Msg::Submit`. diff --git a/widgets/text-input/src/lib.rs b/widgets/text-input/src/lib.rs new file mode 100644 index 0000000..5da19b5 --- /dev/null +++ b/widgets/text-input/src/lib.rs @@ -0,0 +1,270 @@ +//! `llimphi-widget-text-input` — input de texto single-line para Llimphi. +//! +//! Después del refactor 2026-05-25, [`TextInputState`] es un wrapper fino +//! sobre [`llimphi_widget_text_editor::EditorState`] con +//! `options.single_line = true` + un flag `masked` para passwords. La +//! API pública (`new`, `masked`, `text`, `set_text`, `clear`, `apply_key`, +//! `is_empty`, `push_str`, `pop`, `is_masked`) se mantiene salvo que +//! `text()` ahora devuelve `String` (antes `&str`) — los callers que +//! hacían `.text().trim().to_string()` siguen funcionando idénticos. +//! +//! Beneficios heredados del editor: selección con Shift+arrows, undo/ +//! redo con Ctrl+Z/Y, salto de palabra con Ctrl+arrows, Home/End, +//! Delete (además de Backspace). Tab/Enter siguen ignorados (single_line). + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{KeyEvent, View}; +use llimphi_widget_text_editor::{EditorOptions, EditorState}; + +/// Paleta del input. Defaults son una variante dark con borde tenue que +/// se enciende al focar, equivalente conceptual al `nahual-theme` dark. +#[derive(Debug, Clone, Copy)] +pub struct TextInputPalette { + pub bg: Color, + pub bg_focus: Color, + pub border: Color, + pub border_focus: Color, + pub fg_text: Color, + pub fg_placeholder: Color, +} + +impl Default for TextInputPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl TextInputPalette { + /// Construye la paleta desde un `Theme` semántico. + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg: t.bg_input, + bg_focus: t.bg_input_focus, + border: t.border, + border_focus: t.border_focus, + fg_text: t.fg_text, + fg_placeholder: t.fg_placeholder, + } + } +} + +/// Estado del input. Wrappea un `EditorState` single-line. +#[derive(Debug, Clone, Default)] +pub struct TextInputState { + inner: EditorState, + masked: bool, +} + +impl TextInputState { + /// Input vacío visible (texto plano). + pub fn new() -> Self { + Self { + inner: EditorState::with_options(EditorOptions { + single_line: true, + ..EditorOptions::default() + }), + masked: false, + } + } + + /// Input enmascarado — para campos de contraseña. + pub fn masked() -> Self { + Self { masked: true, ..Self::new() } + } + + /// Texto actual. Devuelve `String` (antes `&str` — el rope no expone + /// slice borrowed sin clone). Para evitar copias innecesarias, los + /// callers que sólo necesitan derivar `.trim()` o `.is_empty()` + /// pueden hacerlo directo sobre el `String` devuelto. + pub fn text(&self) -> String { + self.inner.text() + } + + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + pub fn is_masked(&self) -> bool { + self.masked + } + + pub fn clear(&mut self) { + self.inner.set_text(""); + } + + pub fn set_text(&mut self, s: impl Into) { + let s = s.into(); + self.inner.set_text(&s); + } + + pub fn push_str(&mut self, s: &str) { + let combined = format!("{}{}", self.inner.text(), s); + self.inner.set_text(&combined); + } + + pub fn pop(&mut self) -> Option { + let mut t = self.inner.text(); + let ch = t.pop()?; + self.inner.set_text(&t); + Some(ch) + } + + /// Aplica una tecla al estado. Devuelve `true` si cambió el contenido + /// **o** sólo se movió el cursor (cualquier cosa que requiera repintar). + pub fn apply_key(&mut self, event: &KeyEvent) -> bool { + self.inner.apply_key(event).touched() + } + + /// Acceso de bajo nivel al editor interno — útil si el caller + /// quiere consultar cursor/selección o aplicar ops avanzadas. + pub fn editor(&self) -> &EditorState { + &self.inner + } + pub fn editor_mut(&mut self) -> &mut EditorState { + &mut self.inner + } +} + +/// Compone el input box: borde de 1 px (rect padre coloreado), relleno +/// interno, texto o placeholder, caret simulado al final si está focado. +/// Click sobre el box emite `on_focus` (típicamente `Msg::Focus(Field)`). +pub fn text_input_view( + state: &TextInputState, + placeholder: &str, + focused: bool, + palette: &TextInputPalette, + on_focus: Msg, +) -> View { + let raw = state.text(); + let is_empty = raw.is_empty(); + let shown = if is_empty { + placeholder.to_string() + } else if state.masked { + "•".repeat(raw.chars().count()) + } else { + raw + }; + // El cambio de bg al focus ya transmite "este es el activo"; sin + // caret glyph (la fuente default rendea cuadrados de fallback). + let display = shown; + let text_color = if is_empty { + palette.fg_placeholder + } else { + palette.fg_text + }; + let (bg, border) = if focused { + (palette.bg_focus, palette.border_focus) + } else { + (palette.bg, palette.border) + }; + + let inner = View::new(Style { + 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(6.0_f32), + bottom: length(6.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(bg) + .radius(3.0) + .text_aligned(display, 13.0, text_color, Alignment::Start); + + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(34.0_f32), + }, + padding: Rect { + left: length(1.0_f32), + right: length(1.0_f32), + top: length(1.0_f32), + bottom: length(1.0_f32), + }, + ..Default::default() + }) + .fill(border) + .radius(4.0) + .on_click(on_focus) + .children(vec![inner]) +} + +#[cfg(test)] +mod tests { + use super::*; + use llimphi_ui::{Key, KeyState, NamedKey}; + + fn key_press(key: Key, text: Option<&str>) -> KeyEvent { + KeyEvent { + key, + state: KeyState::Pressed, + text: text.map(|s| s.to_string()), + modifiers: Default::default(), + repeat: false, + } + } + + #[test] + fn apply_key_inserts_printable_chars() { + let mut s = TextInputState::new(); + let ev = key_press(Key::Character("a".into()), Some("a")); + assert!(s.apply_key(&ev)); + assert_eq!(s.text(), "a"); + } + + #[test] + fn apply_key_backspace_pops() { + let mut s = TextInputState::new(); + s.set_text("hola"); + let ev = key_press(Key::Named(NamedKey::Backspace), None); + assert!(s.apply_key(&ev)); + assert_eq!(s.text(), "hol"); + } + + #[test] + fn enter_ignorado_en_single_line() { + let mut s = TextInputState::new(); + s.set_text("hola"); + let enter = key_press(Key::Named(NamedKey::Enter), None); + assert!(!s.apply_key(&enter)); + assert_eq!(s.text(), "hola"); + } + + #[test] + fn masked_state_is_masked() { + let s = TextInputState::masked(); + assert!(s.is_masked()); + } + + #[test] + fn flecha_izquierda_mueve_cursor() { + // El refactor agrega esta capacidad — antes no había movimiento. + let mut s = TextInputState::new(); + s.set_text("hola"); + let arr = key_press(Key::Named(NamedKey::ArrowLeft), None); + assert!(s.apply_key(&arr)); + assert_eq!(s.editor().cursor.caret.col, 3); + } + + #[test] + fn push_str_y_pop_funcionan() { + let mut s = TextInputState::new(); + s.push_str("hola"); + assert_eq!(s.text(), "hola"); + assert_eq!(s.pop(), Some('a')); + assert_eq!(s.text(), "hol"); + } +} diff --git a/widgets/theme-switcher/Cargo.toml b/widgets/theme-switcher/Cargo.toml new file mode 100644 index 0000000..a310cb7 --- /dev/null +++ b/widgets/theme-switcher/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "llimphi-widget-theme-switcher" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-theme-switcher — botón que cicla los presets de `llimphi_theme::Theme`. Análogo Llimphi del `nahual-widget-theme-switcher` GPUI: el caller lifta `Msg::ChangeTheme(Theme)` y reasigna el theme en su Model." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } + +[[example]] +name = "theme_switcher_demo" +path = "examples/theme_switcher_demo.rs" diff --git a/widgets/theme-switcher/LEEME.md b/widgets/theme-switcher/LEEME.md new file mode 100644 index 0000000..74271fb --- /dev/null +++ b/widgets/theme-switcher/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-theme-switcher + +> Selector de tema para [llimphi](../../README.md). + +Botón que cicla Dark → Light → Aurora → Sunset, escribe la preferencia en `wawa-config`. Useful en el panel del escritorio. diff --git a/widgets/theme-switcher/README.md b/widgets/theme-switcher/README.md new file mode 100644 index 0000000..14e358c --- /dev/null +++ b/widgets/theme-switcher/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-theme-switcher + +> Theme selector for [llimphi](../../README.md). + +Button that cycles Dark → Light → Aurora → Sunset, writes the preference to `wawa-config`. Useful in the desktop panel. diff --git a/widgets/theme-switcher/examples/theme_switcher_demo.rs b/widgets/theme-switcher/examples/theme_switcher_demo.rs new file mode 100644 index 0000000..157391e --- /dev/null +++ b/widgets/theme-switcher/examples/theme_switcher_demo.rs @@ -0,0 +1,153 @@ +//! Showcase de `llimphi-widget-theme-switcher`. +//! +//! Una ventana con el switcher en la cabecera + un sample de paneles +//! que cambian de color al ciclar. Validación visual de que el theme +//! propaga a la UI: al hacer click en el switcher, los paneles se +//! repintan con el siguiente preset. +//! +//! Corré: `cargo run -p llimphi-widget-theme-switcher --example theme_switcher_demo --release`. + +use llimphi_theme::Theme; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, AlignItems, FlexDirection, JustifyContent, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{App, Handle, View}; +use llimphi_widget_theme_switcher::theme_switcher_view; + +#[derive(Clone, Debug)] +enum Msg { + ChangeTheme(Theme), +} + +struct Model { + theme: Theme, +} + +struct Showcase; + +impl App for Showcase { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · theme-switcher" + } + + fn init(_: &Handle) -> Model { + Model { + theme: Theme::dark(), + } + } + + fn update(model: Model, msg: Msg, _: &Handle) -> Model { + let mut m = model; + match msg { + Msg::ChangeTheme(t) => m.theme = t, + } + m + } + + fn view(model: &Model) -> View { + let switcher = theme_switcher_view(&model.theme, Msg::ChangeTheme); + + let header = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(48.0_f32), + }, + flex_direction: FlexDirection::Row, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::SpaceBetween), + padding: Rect { + left: length(16.0_f32), + right: length(16.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(model.theme.bg_panel) + .children(vec![ + View::new(Style { + size: Size { + width: length(220.0_f32), + height: length(32.0_f32), + }, + ..Default::default() + }) + .text_aligned( + format!("Preset actual: {}", model.theme.name), + 13.0, + model.theme.fg_text, + Alignment::Start, + ), + switcher, + ]); + + let card_a = sample_card("Panel principal", &model.theme, model.theme.bg_panel); + let card_b = sample_card( + "Strip alternativo", + &model.theme, + model.theme.bg_panel_alt, + ); + let card_c = sample_card("Input focado", &model.theme, model.theme.bg_input_focus); + + let body = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(24.0_f32), + right: length(24.0_f32), + top: length(24.0_f32), + bottom: length(24.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(14.0_f32), + }, + ..Default::default() + }) + .fill(model.theme.bg_app) + .children(vec![card_a, card_b, card_c]); + + 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) + .children(vec![header, body]) + } +} + +fn sample_card(label: &str, theme: &Theme, bg: llimphi_ui::llimphi_raster::peniko::Color) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(60.0_f32), + }, + padding: Rect { + left: length(14.0_f32), + right: length(14.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(bg) + .radius(6.0) + .text_aligned(label.to_string(), 13.0, theme.fg_text, Alignment::Start) +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/theme-switcher/src/lib.rs b/widgets/theme-switcher/src/lib.rs new file mode 100644 index 0000000..560fa9a --- /dev/null +++ b/widgets/theme-switcher/src/lib.rs @@ -0,0 +1,179 @@ +//! `llimphi-widget-theme-switcher` — botón que rota los presets de +//! [`llimphi_theme::Theme`]. +//! +//! Análogo Llimphi del `nahual-widget-theme-switcher` GPUI. Diferencia +//! estructural: GPUI lleva el theme en un `Global` y el switcher lo +//! reemplaza con `cx.set_global`; Llimphi no tiene globals — el caller +//! guarda el theme en su `Model` y reasigna en su `update`. El widget +//! sólo emite `on_change(next_theme)` cuando el botón se clickea, donde +//! `next_theme` es el siguiente preset de [`Theme::next_after`]. +//! +//! El label del botón muestra el nombre del preset actual con un signo +//! de rotación (`Tema: Dark ▸`). Los colores salen del `Theme` actual +//! para que el switcher sea coherente con el resto de la UI. +//! +//! # Uso +//! +//! ```ignore +//! use llimphi_widget_theme_switcher::theme_switcher_view; +//! +//! // En App::view: +//! let switcher = theme_switcher_view(&model.theme, Msg::ChangeTheme); +//! ``` +//! +//! `Msg::ChangeTheme(Theme)` lo define la app; en `update`: +//! +//! ```ignore +//! Msg::ChangeTheme(t) => { model.theme = t; } +//! ``` + +#![forbid(unsafe_code)] + +use llimphi_theme::Theme; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, AlignItems, JustifyContent, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; + +/// Paleta del switcher. Por default replica el patrón del switcher de +/// nahual: `bg_panel_alt` + hover `bg_row_hover`, texto `fg_text`. +#[derive(Debug, Clone, Copy)] +pub struct ThemeSwitcherPalette { + pub bg: Color, + pub bg_hover: Color, + pub fg: Color, + pub radius: f64, +} + +impl Default for ThemeSwitcherPalette { + fn default() -> Self { + Self::from_theme(&Theme::dark()) + } +} + +impl ThemeSwitcherPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + bg: t.bg_panel_alt, + bg_hover: t.bg_row_hover, + fg: t.fg_text, + radius: 3.0, + } + } +} + +/// Compone el switcher: chip con texto `Tema: ▸`. Click rota +/// al siguiente preset y emite `on_change(next)`. +/// +/// Toma el `current` por referencia para no clonar el `Theme` entero +/// (es `Copy`, pero la API se mantiene consistente con `Palette::from_theme`). +/// La paleta se deriva del `current` para que el chip use el mismo set +/// de colores que el resto de la UI. +pub fn theme_switcher_view( + current: &Theme, + on_change: impl Fn(Theme) -> Msg, +) -> View { + let palette = ThemeSwitcherPalette::from_theme(current); + theme_switcher_styled(current, &palette, on_change) +} + +/// Variante con paleta explícita — útil cuando la app quiere un look +/// distinto al default (botón destacado, accent del switcher fijo, etc.). +pub fn theme_switcher_styled( + current: &Theme, + palette: &ThemeSwitcherPalette, + on_change: impl Fn(Theme) -> Msg, +) -> View { + let next = Theme::next_after(current.name); + let label = format!("Tema: {} ▸", current.name); + + View::new(Style { + size: Size { + width: length(140.0_f32), + height: length(26.0_f32), + }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(palette.bg) + .hover_fill(palette.bg_hover) + .radius(palette.radius) + .text_aligned(label, 11.0, palette.fg, Alignment::Start) + .on_click(on_change(next)) +} + +/// Variante de tamaño flexible — toma el ancho dado por el padre y se +/// adapta al alto natural del slot. Útil dentro de toolbars con flexbox. +pub fn theme_switcher_flex( + current: &Theme, + palette: &ThemeSwitcherPalette, + on_change: impl Fn(Theme) -> Msg, +) -> View { + let next = Theme::next_after(current.name); + let label = format!("Tema: {} ▸", current.name); + + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(26.0_f32), + }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(palette.bg) + .hover_fill(palette.bg_hover) + .radius(palette.radius) + .text_aligned(label, 11.0, palette.fg, Alignment::Start) + .on_click(on_change(next)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, Clone, PartialEq)] + enum Msg { + Change(&'static str), + } + + #[test] + fn switcher_constructs_with_a_default_theme() { + let t = Theme::dark(); + let _v = theme_switcher_view::(&t, |th| Msg::Change(th.name)); + // Si el constructor no panicó, el widget queda armado. + } + + #[test] + fn palette_from_theme_matches_panel_alt_slots() { + let t = Theme::dark(); + let p = ThemeSwitcherPalette::from_theme(&t); + // No comparamos por igualdad de Color (no implementa PartialEq); + // sí garantizamos que la paleta derivó del theme — radius default. + assert_eq!(p.radius, 3.0); + } + + #[test] + fn on_change_receives_the_next_preset() { + // Verificación funcional independiente: la rotación que verá el + // handler coincide con `Theme::next_after`. + let current = Theme::dark(); + let expected_next = Theme::next_after(current.name).name; + assert_eq!(expected_next, "Light"); + } +} diff --git a/widgets/tiled/Cargo.toml b/widgets/tiled/Cargo.toml new file mode 100644 index 0000000..7001e75 --- /dev/null +++ b/widgets/tiled/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "llimphi-widget-tiled" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-tiled — grid auto cols×rows con title bar por tile. Análogo Llimphi al `nahual-widget-tiled` GPUI (sin drag-to-swap todavía: requiere drop-targets globales que llimphi-ui aún no expone)." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } + +[[example]] +name = "tiled_demo" +path = "examples/tiled_demo.rs" diff --git a/widgets/tiled/LEEME.md b/widgets/tiled/LEEME.md new file mode 100644 index 0000000..6803870 --- /dev/null +++ b/widgets/tiled/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-tiled + +> Tiled window manager dentro de la app para [llimphi](../../README.md). + +Splits anidados horizontal/vertical sin overlap (estilo i3/sway intra-app). Atajos para split/swap/cerrar. Usado por `nada` cuando se abren múltiples buffers. diff --git a/widgets/tiled/README.md b/widgets/tiled/README.md new file mode 100644 index 0000000..6f5a5e1 --- /dev/null +++ b/widgets/tiled/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-tiled + +> Intra-app tiled window manager for [llimphi](../../README.md). + +Nested horizontal/vertical splits without overlap (i3/sway-style inside one app). Shortcuts for split/swap/close. Used by `nada` when multiple buffers are open. diff --git a/widgets/tiled/examples/tiled_demo.rs b/widgets/tiled/examples/tiled_demo.rs new file mode 100644 index 0000000..07e775f --- /dev/null +++ b/widgets/tiled/examples/tiled_demo.rs @@ -0,0 +1,218 @@ +//! Showcase de `llimphi-widget-tiled` con drag-to-swap. Cinco paneles +//! heterogéneos; arrastrá la title bar de uno sobre otro para +//! intercambiarlos. El destino se ilumina mientras está bajo el cursor. +//! +//! Corré con: `cargo run -p llimphi-widget-tiled --example tiled_demo --release`. + +use llimphi_theme::Theme; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{App, Handle, View}; +use llimphi_widget_tiled::{tiled_view_reorderable, TileSpec, TiledPalette}; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum TileId { + Logs, + Metrics, + Alerts, + Uptime, + Queue, +} + +#[derive(Clone)] +enum Msg { + Swap { from: usize, to: usize }, +} + +struct Model { + tiles: Vec, +} + +struct Showcase; + +impl App for Showcase { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · tiled showcase (drag titles para intercambiar)" + } + + fn initial_size() -> (u32, u32) { + (1100, 720) + } + + fn init(_: &Handle) -> Model { + Model { + tiles: vec![ + TileId::Logs, + TileId::Metrics, + TileId::Alerts, + TileId::Uptime, + TileId::Queue, + ], + } + } + + fn update(model: Model, msg: Msg, _: &Handle) -> Model { + let mut m = model; + match msg { + Msg::Swap { from, to } => { + if from != to && from < m.tiles.len() && to < m.tiles.len() { + m.tiles.swap(from, to); + } + } + } + m + } + + fn view(model: &Model) -> View { + let theme = Theme::dark(); + let palette = TiledPalette::from_theme(&theme); + + let tiles: Vec> = model + .tiles + .iter() + .map(|id| match id { + TileId::Logs => TileSpec { + label: "logs".into(), + content: log_body(&theme), + }, + TileId::Metrics => TileSpec { + label: "métricas".into(), + content: metrics_body(&theme), + }, + TileId::Alerts => TileSpec { + label: "alertas".into(), + content: alerts_body(&theme), + }, + TileId::Uptime => TileSpec { + label: "uptime".into(), + content: uptime_body(&theme), + }, + TileId::Queue => TileSpec { + label: "queue".into(), + content: queue_body(&theme), + }, + }) + .collect(); + + tiled_view_reorderable( + tiles, + |from, to| Some(Msg::Swap { from, to }), + &palette, + ) + } +} + +fn padded(text: &str, size: f32, color: Color, align: Alignment) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + padding: Rect { + left: length(14.0_f32), + right: length(14.0_f32), + top: length(10.0_f32), + bottom: length(10.0_f32), + }, + ..Default::default() + }) + .text_aligned(text.to_string(), size, color, align) +} + +fn log_body(theme: &Theme) -> View { + padded( + "[12:01:33] boot\n[12:01:34] config ok\n[12:01:35] esperando eventos…\n[12:02:01] cliente 1 conectó\n[12:02:02] cliente 2 conectó", + 12.0, + theme.fg_text, + Alignment::Start, + ) +} + +fn metrics_body(theme: &Theme) -> View { + let stat = |label: &str, value: &str, color: Color| -> View { + let label_view = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(14.0_f32), + }, + ..Default::default() + }) + .text_aligned(label.to_string(), 10.0, theme.fg_muted, Alignment::Start); + let value_view = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(28.0_f32), + }, + ..Default::default() + }) + .text_aligned(value.to_string(), 22.0, color, Alignment::Start); + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + ..Default::default() + }) + .children(vec![label_view, value_view]) + }; + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + gap: Size { + width: length(12.0_f32), + height: length(0.0_f32), + }, + padding: Rect { + left: length(14.0_f32), + right: length(14.0_f32), + top: length(10.0_f32), + bottom: length(10.0_f32), + }, + ..Default::default() + }) + .children(vec![ + stat("cpu", "37%", theme.accent), + stat("ram", "1.2 G", theme.fg_text), + stat("net", "12 kB/s", theme.fg_text), + ]) +} + +fn alerts_body(theme: &Theme) -> View { + padded( + "● info: dos clientes online\n● warn: latencia 250 ms\n● ok: backup nocturno verde", + 12.0, + theme.fg_text, + Alignment::Start, + ) +} + +fn uptime_body(theme: &Theme) -> View { + padded("4d 12h 33m", 26.0, theme.accent, Alignment::Center) +} + +fn queue_body(theme: &Theme) -> View { + padded( + "pending: 7\nin-flight: 2\ndone (24h): 1842", + 13.0, + theme.fg_text, + Alignment::Start, + ) +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/tiled/src/lib.rs b/widgets/tiled/src/lib.rs new file mode 100644 index 0000000..4ab1422 --- /dev/null +++ b/widgets/tiled/src/lib.rs @@ -0,0 +1,322 @@ +//! `llimphi-widget-tiled` — grilla auto cols×rows de tiles con title +//! bar fija arriba. +//! +//! Cada tile es un panel rectangular con: +//! - una franja superior (20 px) con `bg_panel_alt` + label centrado a +//! la izquierda en `fg_muted`; +//! - un cuerpo flex que aloja el `View` provisto por el caller. +//! +//! La grilla se calcula como `cols = ⌈√n⌉`, `rows = ⌈n/cols⌉` — mismo +//! algoritmo que el `nahual-widget-tiled` GPUI. Las celdas son +//! equipesos: `flex_grow = 1` sobre ambos ejes. +//! +//! ## Variantes +//! +//! - [`tiled_view`] — grilla estática, sin reordenamiento. +//! - [`tiled_view_reorderable`] — drag-to-swap: arrastrar la title bar +//! de un tile y soltar sobre otro emite `on_reorder(from, to)`. El +//! tile destino se ilumina (`drop_hover_fill` = `accent`) mientras +//! el cursor está sobre él durante el drag. Usa los primitives +//! `drag_payload` + `on_drop` + `drop_hover_fill` de `llimphi-ui`. +//! - [`tiled_view_cols`] / [`tiled_view_reorderable_cols`] — fuerzan el +//! número de columnas (útil para sidebars verticales: `cols = 1`). + +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Dimension, FlexDirection, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{DragPhase, View}; + +const TITLE_BAR_HEIGHT: f32 = 20.0; +const TITLE_TEXT_SIZE: f32 = 10.0; +const TILE_GAP: f32 = 4.0; +const TILE_PADDING: f32 = 4.0; + +/// Paleta del tiled. +#[derive(Debug, Clone, Copy)] +pub struct TiledPalette { + /// Fondo del container outer (visible en los gaps entre tiles). + pub bg_outer: Color, + /// Fondo del cuerpo del tile. + pub bg_tile: Color, + /// Fondo de la title bar del tile. + pub bg_title: Color, + /// Color del label de la title bar. + pub fg_title: Color, + /// Color del tile destino durante un drag (drop hover). + pub bg_drop_hover: Color, +} + +impl Default for TiledPalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl TiledPalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg_outer: t.bg_app, + bg_tile: t.bg_panel, + bg_title: t.bg_panel_alt, + fg_title: t.fg_muted, + bg_drop_hover: t.bg_selected, + } + } +} + +/// Un tile de la grilla: label que va en la title bar + view del cuerpo. +pub struct TileSpec { + pub label: String, + pub content: View, +} + +type ReorderFn = Arc Option + Send + Sync>; + +/// Construye una grilla estática (sin drag-to-swap). Equivalente a +/// [`tiled_view_reorderable`] sin handler de reorder. +pub fn tiled_view(tiles: Vec>, palette: &TiledPalette) -> View +where + Msg: Clone + Send + Sync + 'static, +{ + build(tiles, palette, None, None) +} + +/// Construye una grilla con drag-to-swap. Arrastrar la title bar de un +/// tile y soltar sobre otro invoca `on_reorder(from_index, to_index)`; +/// el `Msg` retornado se dispatchea al `update` antes de cerrar el +/// drag. El caller es responsable de filtrar `from == to`. +pub fn tiled_view_reorderable( + tiles: Vec>, + on_reorder: F, + palette: &TiledPalette, +) -> View +where + Msg: Clone + Send + Sync + 'static, + F: Fn(usize, usize) -> Option + Send + Sync + 'static, +{ + build(tiles, palette, Some(Arc::new(on_reorder)), None) +} + +/// Como [`tiled_view`] pero con número fijo de columnas. Útil para +/// sidebars verticales (`cols = 1`) o filas horizontales (`cols = n`) +/// donde el algoritmo auto-sqrt no sirve. `cols.max(1)` se aplica por +/// seguridad. +pub fn tiled_view_cols( + tiles: Vec>, + cols: usize, + palette: &TiledPalette, +) -> View +where + Msg: Clone + Send + Sync + 'static, +{ + build(tiles, palette, None, Some(cols)) +} + +/// Como [`tiled_view_reorderable`] pero con número fijo de columnas. +pub fn tiled_view_reorderable_cols( + tiles: Vec>, + cols: usize, + on_reorder: F, + palette: &TiledPalette, +) -> View +where + Msg: Clone + Send + Sync + 'static, + F: Fn(usize, usize) -> Option + Send + Sync + 'static, +{ + build(tiles, palette, Some(Arc::new(on_reorder)), Some(cols)) +} + +fn build( + tiles: Vec>, + palette: &TiledPalette, + on_reorder: Option>, + cols_override: Option, +) -> View +where + Msg: Clone + Send + Sync + 'static, +{ + let n = tiles.len(); + if n == 0 { + return View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(palette.bg_outer) + .text( + "(tiled vacío)".to_string(), + 11.0, + palette.fg_title, + ); + } + + let cols = cols_override + .map(|c| c.max(1)) + .unwrap_or_else(|| ((n as f32).sqrt().ceil() as usize).max(1)); + let rows = (n + cols - 1) / cols; + + let mut tiles_iter = tiles.into_iter().enumerate(); + let mut rows_views: Vec> = Vec::with_capacity(rows); + + for _r in 0..rows { + let mut cells: Vec> = Vec::with_capacity(cols); + for _c in 0..cols { + let cell = match tiles_iter.next() { + Some((idx, tile)) => tile_view(idx, tile, palette, on_reorder.clone()), + None => empty_cell_view(palette), + }; + cells.push(cell); + } + rows_views.push(row_view(cells)); + } + + 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(TILE_GAP), + }, + padding: Rect { + left: length(TILE_PADDING), + right: length(TILE_PADDING), + top: length(TILE_PADDING), + bottom: length(TILE_PADDING), + }, + ..Default::default() + }) + .fill(palette.bg_outer) + .children(rows_views) +} + +fn row_view(cells: Vec>) -> View { + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + flex_grow: 1.0, + flex_basis: length(0.0_f32), + min_size: Size { + width: length(0.0_f32), + height: length(0.0_f32), + }, + gap: Size { + width: length(TILE_GAP), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(cells) +} + +fn tile_view( + idx: usize, + tile: TileSpec, + palette: &TiledPalette, + on_reorder: Option>, +) -> View +where + Msg: Clone + Send + Sync + 'static, +{ + let mut title = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(TITLE_BAR_HEIGHT), + }, + flex_shrink: 0.0, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(palette.bg_title) + .text_aligned(tile.label, TITLE_TEXT_SIZE, palette.fg_title, Alignment::Start); + + // Si hay reorder, la title bar arrastra con payload = idx. + if on_reorder.is_some() { + // Handler trivial: tiled no usa dx/dy. Devuelve None. + title = title + .draggable(|_phase: DragPhase, _dx: f32, _dy: f32| None) + .drag_payload(idx as u64); + } + + let body = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + flex_grow: 1.0, + min_size: Size { + width: length(0.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![tile.content]); + + let mut tile_view = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: Dimension::auto(), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + flex_basis: length(0.0_f32), + min_size: Size { + width: length(0.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .fill(palette.bg_tile) + .radius(4.0) + .clip(true) + .children(vec![title, body]); + + // Drop target: si hay reorder, este tile entero recibe drops. + if let Some(reorder) = on_reorder { + let to_idx = idx; + tile_view = tile_view + .on_drop(move |from: u64| (reorder)(from as usize, to_idx)) + .drop_hover_fill(palette.bg_drop_hover); + } + + tile_view +} + +fn empty_cell_view(palette: &TiledPalette) -> View +where + Msg: Clone + 'static, +{ + View::new(Style { + size: Size { + width: Dimension::auto(), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + flex_basis: length(0.0_f32), + min_size: Size { + width: length(0.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .fill(palette.bg_outer) +} diff --git a/widgets/timeline/Cargo.toml b/widgets/timeline/Cargo.toml new file mode 100644 index 0000000..5c930f3 --- /dev/null +++ b/widgets/timeline/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-timeline" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-timeline — barra de progreso/scrub clickeable (seek absoluto). El widget es stateless: el caller pasa la fracción de avance y un handler fracción→Msg." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/timeline/src/lib.rs b/widgets/timeline/src/lib.rs new file mode 100644 index 0000000..d6ebda8 --- /dev/null +++ b/widgets/timeline/src/lib.rs @@ -0,0 +1,154 @@ +//! `llimphi-widget-timeline` — barra de progreso/scrub clickeable. +//! +//! Pattern análogo a `llimphi-widget-slider`/`-progress`: el widget **no +//! mantiene estado**. El caller guarda la posición actual en su `Model`, +//! le pasa la **fracción de avance** (`0.0..=1.0` = posición/duración) y un +//! handler `Fn(f32) -> Option` que recibe la fracción **donde el +//! usuario clickeó** (scrub absoluto, estilo VLC). El widget no sabe de +//! tiempo ni de duración: sólo pinta el avance y reporta dónde se clickeó +//! como fracción del ancho de la barra (`on_click_at`). Quien mapea esa +//! fracción a un seek concreto es la app. +//! +//! ```text +//! [ ██████████▏░░░░░░░░░░░░ ] +//! recorrido playhead resto +//! ``` +//! +//! Uso típico (reproductor): +//! +//! ```ignore +//! let frac = pos.as_secs_f64() / dur.as_secs_f64(); +//! timeline_view(frac as f32, &TimelinePalette::default(), |f| { +//! Some(Msg::Command(MediaCommand::SeekTo { fraction: f })) +//! }) +//! ``` + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, Size, Style}; +use llimphi_ui::llimphi_raster::kurbo::{Affine, Rect}; +use llimphi_ui::llimphi_raster::peniko::{Color, Fill}; +use llimphi_ui::View; + +/// Paleta + dimensiones del timeline. Las medidas viajan acá (igual que +/// `SliderPalette`) porque definen cómo se ve la barra — el caller no +/// toca el `Style` directamente. +#[derive(Debug, Clone, Copy)] +pub struct TimelinePalette { + /// Color de la pista de fondo (el track entero). + pub track: Color, + /// Color del tramo recorrido (de 0 al playhead). + pub fill: Color, + /// Color del playhead (la barrita vertical en la posición actual). + pub knob: Color, + /// Alto total del widget en pixels. + pub height: f32, + /// Radio de las esquinas del track. + pub radius: f64, +} + +impl Default for TimelinePalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl TimelinePalette { + /// Construye la paleta desde un `Theme` semántico. + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + track: t.bg_button, + fill: t.accent, + knob: t.fg_text, + height: 14.0, + radius: 7.0, + } + } +} + +/// Compone una barra de progreso clickeable. +/// +/// `progress` es la fracción recorrida (`0.0..=1.0`); se clampea. El +/// handler `on_seek` recibe la fracción `0.0..=1.0` donde el usuario +/// clickeó (`local_x / ancho`) y devuelve el `Msg` a despachar (o `None` +/// para ignorar el click). El widget es stateless: redibujá pasando un +/// `progress` nuevo en cada frame y el playhead avanza solo. +pub fn timeline_view(progress: f32, palette: &TimelinePalette, on_seek: F) -> View +where + Msg: 'static, + F: Fn(f32) -> Option + Send + Sync + 'static, +{ + let p = progress.clamp(0.0, 1.0); + let fill_color = palette.fill; + let knob_color = palette.knob; + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(palette.height), + }, + ..Default::default() + }) + .fill(palette.track) + .radius(palette.radius) + .paint_with(move |scene, _ts, rect| { + if rect.w <= 2.0 || rect.h <= 2.0 { + return; + } + let pad: f32 = 2.0; + let x0 = rect.x + pad; + let y0 = rect.y + pad; + let w = (rect.w - 2.0 * pad).max(1.0); + let h = (rect.h - 2.0 * pad).max(1.0); + // Tramo recorrido. + let fw = (w * p).max(0.0); + if fw > 0.5 { + let fill = Rect::new(x0 as f64, y0 as f64, (x0 + fw) as f64, (y0 + h) as f64); + scene.fill(Fill::NonZero, Affine::IDENTITY, fill_color, None, &fill); + } + // Playhead — fina barra vertical en la posición actual. + let kx = x0 + fw; + let kw: f32 = 3.0; + let knob = Rect::new( + (kx - kw * 0.5) as f64, + y0 as f64, + (kx + kw * 0.5) as f64, + (y0 + h) as f64, + ); + scene.fill(Fill::NonZero, Affine::IDENTITY, knob_color, None, &knob); + }) + .on_click_at(move |lx, _ly, w, _h| { + if w <= 0.0 { + return None; + } + on_seek((lx / w).clamp(0.0, 1.0)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Msg de prueba: el handler reporta la fracción clickeada. + #[derive(Debug, PartialEq)] + struct Seek(f32); + + #[test] + fn from_theme_usa_colores_semanticos() { + let t = llimphi_theme::Theme::dark(); + let p = TimelinePalette::from_theme(&t); + assert_eq!(p.track, t.bg_button); + assert_eq!(p.fill, t.accent); + assert_eq!(p.knob, t.fg_text); + } + + #[test] + fn construye_sin_panic_en_extremos() { + // El widget se arma para fracciones fuera de rango (se clampea + // internamente al pintar) sin reventar. + let pal = TimelinePalette::default(); + let _ = timeline_view(-0.5, &pal, |f| Some(Seek(f))); + let _ = timeline_view(0.0, &pal, |f| Some(Seek(f))); + let _ = timeline_view(1.0, &pal, |f| Some(Seek(f))); + let _ = timeline_view(2.0, &pal, |f| Some(Seek(f))); + } +} diff --git a/widgets/toast/Cargo.toml b/widgets/toast/Cargo.toml new file mode 100644 index 0000000..754ef7f --- /dev/null +++ b/widgets/toast/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "llimphi-widget-toast" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-toast — notificaciones efímeras apiladas bottom-right. Severidades info/success/warning/error. Auto-dismiss configurable. Render-only; el ciclo de vida lo maneja la app." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-icons = { workspace = true } diff --git a/widgets/toast/src/lib.rs b/widgets/toast/src/lib.rs new file mode 100644 index 0000000..52e6b76 --- /dev/null +++ b/widgets/toast/src/lib.rs @@ -0,0 +1,238 @@ +//! `llimphi-widget-toast` — notificaciones efímeras apiladas. +//! +//! Cuatro severidades (Info / Success / Warning / Error) con color +//! semántico hardcoded — un Error debe leerse rojo aunque la app esté +//! en tema "sunset". Cada toast lleva un icono de `llimphi-icons` y +//! un texto corto. +//! +//! El widget es **render-only**: recibe una lista de [`Toast`]s ya +//! filtrados por la app (los que aún no expiraron) y los apila en la +//! esquina bottom-right. El ciclo de vida (push, auto-dismiss tras +//! `duration`, dismiss manual al click) lo maneja la app desde su +//! `update`/`spawn`. +//! +//! Patrón típico: +//! 1. App tiene `Vec` en el modelo + `next_id: u64`. +//! 2. Para pushear: agregar Toast con `expires_at = Instant::now() + dur` +//! + `handle.spawn(move || { sleep(dur); Msg::ToastExpire(id) })`. +//! 3. `view_overlay` filtra los no expirados y los pasa a `toast_stack_view`. + +#![forbid(unsafe_code)] + +use std::time::Instant; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, percent, FlexDirection, Position, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_icons::{icon_view, Icon}; +use llimphi_theme::radius; + +/// Severidad del toast — define color e icono. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToastKind { + Info, + Success, + Warning, + Error, +} + +impl ToastKind { + /// Color de fondo (semántico, no dependiente del theme). + pub fn bg(self) -> Color { + match self { + ToastKind::Info => Color::from_rgba8(28, 56, 88, 245), + ToastKind::Success => Color::from_rgba8(28, 72, 44, 245), + ToastKind::Warning => Color::from_rgba8(88, 64, 20, 245), + ToastKind::Error => Color::from_rgba8(96, 32, 32, 245), + } + } + + /// Color del trazo y del texto principal. + pub fn fg(self) -> Color { + match self { + ToastKind::Info => Color::from_rgba8(180, 220, 250, 255), + ToastKind::Success => Color::from_rgba8(180, 240, 200, 255), + ToastKind::Warning => Color::from_rgba8(250, 220, 160, 255), + ToastKind::Error => Color::from_rgba8(250, 200, 200, 255), + } + } + + pub fn icon(self) -> Icon { + match self { + ToastKind::Info => Icon::Info, + ToastKind::Success => Icon::Check, + ToastKind::Warning => Icon::Warning, + ToastKind::Error => Icon::Error, + } + } +} + +/// Un toast en cola. La app mantiene `Vec` y descarta los +/// expirados antes de pasarlos al render. +#[derive(Debug, Clone)] +pub struct Toast { + /// Id estable para que la app pueda correlacionar con su Msg de + /// dismiss (`Msg::ToastDismiss(u64)`). + pub id: u64, + pub kind: ToastKind, + pub text: String, + /// Cuándo expira. El render no chequea esto — sólo apila lo que + /// recibe; la app filtra antes. + pub expires_at: Instant, +} + +const TOAST_W: f32 = 320.0; +const TOAST_H: f32 = 44.0; +const ICON_BOX: f32 = 24.0; +const GAP: f32 = 8.0; +const MARGIN: f32 = 16.0; +/// Ancho del "rail" de severidad en el edge izquierdo. 3px es el sweet +/// spot — visible al pasar sin chocar con el icono. Look Linear/Slack. +const RAIL_W: f32 = 3.0; + +/// Apila los toasts en la esquina bottom-right del viewport. `on_click` +/// se construye por toast vía `make_dismiss(id)`. Devuelve un `View` +/// para colgar de `view_overlay`. +pub fn toast_stack_view( + toasts: &[Toast], + viewport: (f32, f32), + make_dismiss: F, +) -> View +where + Msg: Clone + 'static, + F: Fn(u64) -> Msg, +{ + let n = toasts.len() as f32; + let stack_h = n * TOAST_H + (n - 1.0).max(0.0) * GAP; + let stack_y = (viewport.1 - stack_h - MARGIN).max(MARGIN); + let stack_x = (viewport.0 - TOAST_W - MARGIN).max(MARGIN); + + let children: Vec> = toasts + .iter() + .map(|t| single_toast_view(t, make_dismiss(t.id))) + .collect(); + + let stack = View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(stack_x), + top: length(stack_y), + right: auto(), + bottom: auto(), + }, + size: Size { + width: length(TOAST_W), + height: length(stack_h.max(0.0)), + }, + flex_direction: FlexDirection::Column, + gap: Size { + width: length(0.0_f32), + height: length(GAP), + }, + ..Default::default() + }) + .children(children); + + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .children(vec![stack]) +} + +fn single_toast_view(toast: &Toast, on_dismiss: Msg) -> View { + let bg = toast.kind.bg(); + let fg = toast.kind.fg(); + let icon = toast.kind.icon(); + + // Rail de severidad: stripe del color fg semántico (más brillante + // que el bg) en el edge izquierdo. Visible al pasar el ojo sin + // chocar con el icono — refuerza la severidad para usuarios que ya + // están mirando a otra parte de la UI. + let rail = View::new(Style { + size: Size { + width: length(RAIL_W), + height: percent(1.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(fg); + + let icon_cell = View::new(Style { + size: Size { + width: length(ICON_BOX), + height: length(ICON_BOX), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![icon_view(icon, fg, 1.6)]); + + let text = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(toast.text.clone(), 12.0, fg, Alignment::Start); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(TOAST_H), + }, + align_items: Some(AlignItems::Center), + // El rail vive en el edge — sin padding-left propio para que + // pegue al borde; el padding del contenido arranca después. + padding: Rect { + left: length(0.0_f32), + right: length(12.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + gap: Size { + width: length(10.0_f32), + height: length(0.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .fill(bg) + .radius(radius::MD) + .clip(true) + .on_click(on_dismiss) + .children(vec![rail, icon_cell, text]) +} + +/// Helper de construcción para uso inmediato: +/// `Toast::info(1, "guardado", Duration::from_secs(3))`. +impl Toast { + pub fn info(id: u64, text: impl Into, dur: std::time::Duration) -> Self { + Self { id, kind: ToastKind::Info, text: text.into(), expires_at: Instant::now() + dur } + } + pub fn success(id: u64, text: impl Into, dur: std::time::Duration) -> Self { + Self { id, kind: ToastKind::Success, text: text.into(), expires_at: Instant::now() + dur } + } + pub fn warning(id: u64, text: impl Into, dur: std::time::Duration) -> Self { + Self { id, kind: ToastKind::Warning, text: text.into(), expires_at: Instant::now() + dur } + } + pub fn error(id: u64, text: impl Into, dur: std::time::Duration) -> Self { + Self { id, kind: ToastKind::Error, text: text.into(), expires_at: Instant::now() + dur } + } + + pub fn is_alive(&self, now: Instant) -> bool { + now < self.expires_at + } +} diff --git a/widgets/tooltip/Cargo.toml b/widgets/tooltip/Cargo.toml new file mode 100644 index 0000000..362e079 --- /dev/null +++ b/widgets/tooltip/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-tooltip" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-tooltip — tooltip flotante posicionado por anchor + viewport. Render-only: la app decide cuándo abrir (típico: hover-after-delay manejado en update)." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/tooltip/src/lib.rs b/widgets/tooltip/src/lib.rs new file mode 100644 index 0000000..52d0cb6 --- /dev/null +++ b/widgets/tooltip/src/lib.rs @@ -0,0 +1,166 @@ +//! `llimphi-widget-tooltip` — tooltip flotante con anchor + clamping. +//! +//! Render puro: el widget recibe el anchor (típicamente bottom-center +//! del elemento que lo dispara), el viewport y el texto, y devuelve un +//! `View` posicionado en absolute para colgarlo de `view_overlay`. +//! La app es responsable de: +//! 1. Detectar el hover sobre el elemento via `View::on_pointer_enter` +//! + un `Tween`/delay para evitar tooltips que parpadean al pasar. +//! 2. Guardar el `Option` en su modelo. +//! 3. Devolverlo desde `view_overlay`. +//! 4. Cerrarlo con `View::on_pointer_leave` sobre el mismo elemento. +//! +//! No se incluye scrim — el tooltip es informativo, no modal: los +//! clicks atraviesan al árbol principal. (Para popovers con +//! interacción, usar `llimphi-widget-modal` o el `context-menu`). + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{auto, length, FlexDirection, Position, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_theme::{radius, Theme}; + +/// Paleta del tooltip — fondo "glass panel" oscuro, texto claro. +#[derive(Debug, Clone, Copy)] +pub struct TooltipPalette { + pub bg: Color, + pub fg: Color, + pub border: Color, +} + +impl TooltipPalette { + pub fn from_theme(t: &Theme) -> Self { + Self { + bg: t.bg_app, + fg: t.fg_text, + border: t.border, + } + } +} + +/// Lado preferido al que se coloca el tooltip respecto del anchor. +/// Si no entra en el viewport por ese lado, el clamping lo empuja al +/// lado contrario (no recortado). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Side { + Top, + #[default] + Bottom, + Left, + Right, +} + +/// Spec para [`tooltip_view`]. +#[derive(Debug, Clone)] +pub struct TooltipSpec { + /// Punto de origen — típicamente el centro del elemento que dispara. + pub anchor: (f32, f32), + /// Tamaño actual de la ventana, para clamping. + pub viewport: (f32, f32), + /// Lado preferido respecto del anchor. + pub side: Side, + pub text: String, + pub palette: TooltipPalette, +} + +const PAD_X: f32 = 8.0; +const PAD_Y: f32 = 5.0; +const GAP: f32 = 6.0; +const FONT_SIZE: f32 = 11.5; +/// Ancho aproximado de un carácter (estimación zonal — Llimphi +/// todavía no expone medición previa al layout). Sirve para clampear +/// tooltips largos a un ancho razonable. +const CHAR_W_APPROX: f32 = 6.5; +const MAX_W: f32 = 280.0; + +pub fn tooltip_view(spec: TooltipSpec) -> View { + let TooltipSpec { anchor, viewport, side, text, palette } = spec; + + // Tamaño estimado del tooltip — Llimphi resuelve layout pero el + // posicionamiento absolute necesita un x,y; estimamos con el ancho + // del texto y limitamos al MAX_W. Ancho real puede diferir un + // píxel — al ojo es invisible. + let est_w = (text.chars().count() as f32 * CHAR_W_APPROX + PAD_X * 2.0).min(MAX_W); + let est_h = FONT_SIZE * 1.3 + PAD_Y * 2.0; + + // Posicionamiento respecto del anchor. + let (raw_x, raw_y) = match side { + Side::Bottom => (anchor.0 - est_w * 0.5, anchor.1 + GAP), + Side::Top => (anchor.0 - est_w * 0.5, anchor.1 - GAP - est_h), + Side::Right => (anchor.0 + GAP, anchor.1 - est_h * 0.5), + Side::Left => (anchor.0 - GAP - est_w, anchor.1 - est_h * 0.5), + }; + + // Clamping al viewport (margen 4px para no pegarse al borde). + let margin = 4.0; + let x = raw_x + .min((viewport.0 - est_w - margin).max(margin)) + .max(margin); + let y = raw_y + .min((viewport.1 - est_h - margin).max(margin)) + .max(margin); + + let panel = View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(x), + top: length(y), + right: auto(), + bottom: auto(), + }, + size: Size { + width: length(est_w), + height: length(est_h), + }, + flex_direction: FlexDirection::Column, + align_items: Some(AlignItems::FlexStart), + padding: Rect { + left: length(PAD_X), + right: length(PAD_X), + top: length(PAD_Y), + bottom: length(PAD_Y), + }, + ..Default::default() + }) + .fill(palette.bg) + .radius(radius::SM) + .text_aligned(text, FONT_SIZE, palette.fg, Alignment::Start); + + // Wrapper invisible que ocupa toda la pantalla — el panel ya está + // posicionado en absolute, pero `view_overlay` espera un único root + // que cubre la ventana. Sin scrim ni intercept de clicks. + View::new(Style { + size: Size { + width: llimphi_ui::llimphi_layout::taffy::prelude::percent(1.0_f32), + height: llimphi_ui::llimphi_layout::taffy::prelude::percent(1.0_f32), + }, + ..Default::default() + }) + // Borde sutil pintado vía un nodo separado: pintamos el panel sobre + // un rect 1px más grande coloreado con `border` — barato y consistente + // con cómo el context-menu hace su borde. + .children(vec![ + View::new(Style { + position: Position::Absolute, + inset: Rect { + left: length(x - 1.0), + top: length(y - 1.0), + right: auto(), + bottom: auto(), + }, + size: Size { + width: length(est_w + 2.0), + height: length(est_h + 2.0), + }, + ..Default::default() + }) + .fill(palette.border) + .radius(radius::SM + 1.0), + panel, + ]) +} diff --git a/widgets/tree/Cargo.toml b/widgets/tree/Cargo.toml new file mode 100644 index 0000000..8c44804 --- /dev/null +++ b/widgets/tree/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-tree" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-tree — árbol con expand/collapse y selección. Análogo Llimphi al `nahual-widget-tree` GPUI. El caller mantiene el set de nodos expandidos y el seleccionado en su Model; el widget aplana el árbol en filas con indentación y emite Msg al togglear o seleccionar." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/tree/LEEME.md b/widgets/tree/LEEME.md new file mode 100644 index 0000000..16a6346 --- /dev/null +++ b/widgets/tree/LEEME.md @@ -0,0 +1,5 @@ +# llimphi-widget-tree + +> Árbol jerárquico para [llimphi](../../README.md). + +Tree-view con expand/collapse, virtualización (filas no visibles no se montan), drag-and-drop opcional. Lazy-load por nodo cuando los hijos son caros. Usado por file-explorer, sidebar de docs, etc. diff --git a/widgets/tree/README.md b/widgets/tree/README.md new file mode 100644 index 0000000..b510b5c --- /dev/null +++ b/widgets/tree/README.md @@ -0,0 +1,5 @@ +# llimphi-widget-tree + +> Hierarchical tree for [llimphi](../../README.md). + +Tree-view with expand/collapse, virtualization (off-screen rows aren't mounted), optional drag-and-drop. Lazy-load per node when children are expensive. Used by file-explorer, doc sidebar, etc. diff --git a/widgets/tree/examples/tree_demo.rs b/widgets/tree/examples/tree_demo.rs new file mode 100644 index 0000000..4a7ad7d --- /dev/null +++ b/widgets/tree/examples/tree_demo.rs @@ -0,0 +1,207 @@ +//! Showcase de `llimphi-widget-tree`: jerarquía con expand/collapse + +//! selección. Click en ▸/▾ togglea; click en el resto de la fila +//! selecciona. +//! +//! Corré con: `cargo run -p llimphi-widget-tree --example tree_demo --release`. + +use std::collections::HashSet; + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{App, Handle, View}; +use llimphi_theme::Theme; +use llimphi_widget_tree::{tree_view, TreePalette, TreeRow, TreeSpec}; + +#[derive(Clone)] +enum Msg { + Toggle(u32), + Select(u32), +} + +struct Model { + /// Set de ids expandidos. + expanded: HashSet, + selected: Option, +} + +struct Showcase; + +/// Estructura estática del árbol — `(id, parent_id, label)`. `parent_id = +/// 0` significa raíz. +const TREE: &[(u32, u32, &str)] = &[ + (1, 0, "00_unanchay (PERCIBIR)"), + (10, 1, "pluma"), + (101, 10, "core"), + (102, 10, "graph"), + (103, 10, "render-plan"), + (104, 10, "editor-llimphi"), + (11, 1, "khipu"), + (12, 1, "rimay"), + (13, 1, "puriy"), + (131, 13, "core"), + (132, 13, "engine"), + (2, 0, "01_yachay (CONOCER)"), + (20, 2, "cosmos"), + (21, 2, "dominium"), + (22, 2, "nakui"), + (3, 0, "02_ruway (HACER)"), + (30, 3, "llimphi"), + (301, 30, "hal"), + (302, 30, "raster"), + (303, 30, "layout"), + (304, 30, "text"), + (305, 30, "ui"), + (306, 30, "widgets/"), + (3061, 306, "button"), + (3062, 306, "list"), + (3063, 306, "splitter"), + (3064, 306, "tabs"), + (3065, 306, "text-input"), + (3066, 306, "tree"), + (31, 3, "mirada"), + (32, 3, "nahual"), + (4, 0, "03_ukupacha (RAÍZ)"), +]; + +impl App for Showcase { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "llimphi · tree showcase" + } + + fn initial_size() -> (u32, u32) { + (560, 720) + } + + fn init(_: &Handle) -> Model { + let mut expanded = HashSet::new(); + // Raíces abiertas por default. + expanded.insert(1); + expanded.insert(3); + expanded.insert(30); + Model { + expanded, + selected: None, + } + } + + fn update(model: Model, msg: Msg, _: &Handle) -> Model { + let mut m = model; + match msg { + Msg::Toggle(id) => { + if !m.expanded.remove(&id) { + m.expanded.insert(id); + } + } + Msg::Select(id) => { + m.selected = Some(id); + } + } + m + } + + fn view(model: &Model) -> View { + let theme = Theme::dark(); + let palette = TreePalette::from_theme(&theme); + + let rows = flatten_visible(&model.expanded, model.selected); + let tree = tree_view(TreeSpec { + rows, + row_height: 22.0, + indent_px: 16.0, + palette, + guides: true, + }); + + // Header con info de la selección. + let header_text = match model.selected { + Some(id) => format!("seleccionado: id {id}"), + None => "(click en una fila para seleccionar)".to_string(), + }; + let header = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(28.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_alt) + .text_aligned(header_text, 12.0, theme.fg_muted, Alignment::Start); + + let tree_pane = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + ..Default::default() + }) + .children(vec![tree]); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_app) + .children(vec![header, tree_pane]) + } +} + +/// Aplana el árbol estático respetando el set expandido. Profundidad +/// inferida de la cadena de parents. +fn flatten_visible(expanded: &HashSet, selected: Option) -> Vec> { + let mut out = Vec::new(); + visit(0, 0, expanded, selected, &mut out); + out +} + +fn visit( + parent_id: u32, + depth: usize, + expanded: &HashSet, + selected: Option, + out: &mut Vec>, +) { + for (id, p, label) in TREE { + if *p != parent_id { + continue; + } + let has_children = TREE.iter().any(|(_, pp, _)| *pp == *id); + let is_expanded = expanded.contains(id); + out.push(TreeRow { + label: label.to_string(), + depth, + has_children, + expanded: is_expanded, + selected: selected == Some(*id), + on_toggle: Msg::Toggle(*id), + on_select: Msg::Select(*id), + icon: None, + on_context: None, + editor: None, + }); + if has_children && is_expanded { + visit(*id, depth + 1, expanded, selected, out); + } + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/tree/src/lib.rs b/widgets/tree/src/lib.rs new file mode 100644 index 0000000..dfa6a14 --- /dev/null +++ b/widgets/tree/src/lib.rs @@ -0,0 +1,345 @@ +//! `llimphi-widget-tree` — árbol con expand/collapse y selección. +//! +//! Análogo Llimphi al `nahual-widget-tree` GPUI. No mantiene estado +//! propio: el `Model` del App lleva el set de nodos expandidos + el +//! seleccionado, le pasa al widget la lista aplanada de filas (sólo +//! las visibles según el estado de expansión) y maneja los Msg de +//! toggle/select. +//! +//! Aplanar el árbol vive del lado del caller para no imponer una +//! representación específica (recursiva, plana con paths, etc.). +//! +//! Cada fila lleva su `depth` (para indentar), `has_children` (para +//! decidir si dibujar la flecha ▸/▾) y `expanded` (cuál de las dos). +//! Click en la flecha → `on_toggle`; click en el resto de la fila → +//! `on_select`; click derecho → `on_context` (si lo trae). +//! +//! Extras opcionales: un **icono gráfico** por fila (`icon`, cualquier +//! `View` — típicamente un mini-canvas vectorial) entre el chevron y el +//! label, y **líneas guía** de indentación (`TreeSpec::guides`). + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Dimension, FlexDirection, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::kurbo::{Affine, Line as KurboLine, Stroke}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{PaintRect, View}; + +/// Paleta del árbol — un subset del `Theme` semántico, igual que los +/// otros widgets de Llimphi. +#[derive(Debug, Clone, Copy)] +pub struct TreePalette { + pub bg_panel: Color, + pub bg_selected: Color, + pub bg_hover: Color, + pub fg_text: Color, + pub fg_muted: Color, + pub fg_chevron: Color, + /// Color de las líneas guía de indentación. + pub guide: Color, +} + +impl Default for TreePalette { + fn default() -> Self { + Self::from_theme(&llimphi_theme::Theme::dark()) + } +} + +impl TreePalette { + pub fn from_theme(t: &llimphi_theme::Theme) -> Self { + Self { + bg_panel: t.bg_panel, + bg_selected: t.bg_selected, + bg_hover: t.bg_row_hover, + fg_text: t.fg_text, + fg_muted: t.fg_muted, + fg_chevron: t.fg_muted, + guide: t.border, + } + } +} + +/// Una fila del árbol — ya posicionada en la lista plana visible. +pub struct TreeRow { + pub label: String, + /// Nivel de anidación (0 = raíz). Se traduce a indentación visual. + pub depth: usize, + /// Si el nodo tiene hijos. `false` = hoja; no se dibuja el chevron. + pub has_children: bool, + /// Estado actual del nodo. Ignorado si `has_children = false`. + pub expanded: bool, + /// Si esta fila es la seleccionada. + pub selected: bool, + /// Msg al hacer click en el chevron. Sólo se usa si `has_children`. + pub on_toggle: Msg, + /// Msg al hacer click en la fila (label o área alrededor). + pub on_select: Msg, + /// Icono gráfico opcional (cualquier `View`, p.ej. un mini-canvas + /// vectorial) que se pinta entre el chevron y el label. + pub icon: Option>, + /// Msg al hacer click derecho sobre la fila (menú contextual). `None` + /// = sin menú contextual. + pub on_context: Option, + /// Edición in-situ: si es `Some`, la fila se renderea con este + /// `View` (típicamente un `text_input_view`) en el lugar del label, + /// en vez del texto sólo-lectura. El chevron y la indentación se + /// mantienen; el editor ocupa el slot elástico del label y no se le + /// cablea `on_select` (las teclas las rutea el App). `None` = fila + /// normal de sólo-lectura. + pub editor: Option>, +} + +impl TreeRow { + /// Constructor mínimo (sin icono / contexto / editor) — azúcar para + /// callers que sólo quieren label + toggle + select. + pub fn new( + label: impl Into, + depth: usize, + has_children: bool, + expanded: bool, + selected: bool, + on_toggle: Msg, + on_select: Msg, + ) -> Self { + Self { + label: label.into(), + depth, + has_children, + expanded, + selected, + on_toggle, + on_select, + icon: None, + on_context: None, + editor: None, + } + } + + pub fn with_icon(mut self, icon: View) -> Self { + self.icon = Some(icon); + self + } + + pub fn with_context(mut self, msg: Msg) -> Self { + self.on_context = Some(msg); + self + } + + pub fn with_editor(mut self, editor: View) -> Self { + self.editor = Some(editor); + self + } +} + +/// Especificación completa del árbol a renderear. +pub struct TreeSpec { + pub rows: Vec>, + pub row_height: f32, + pub indent_px: f32, + pub palette: TreePalette, + /// Dibujar líneas guía verticales de indentación. + pub guides: bool, +} + +impl TreeSpec { + /// Spec con valores por defecto sensatos (row 22, indent 14, sin + /// guías) — sólo hay que pasar filas y paleta. + pub fn new(rows: Vec>, palette: TreePalette) -> Self { + Self { + rows, + row_height: 22.0, + indent_px: 14.0, + palette, + guides: false, + } + } +} + +/// Compone el árbol como `View`. El contenedor activa `clip` para +/// que filas que excedan el rect se recorten — usar dentro de un panel +/// del tamaño deseado. +pub fn tree_view(spec: TreeSpec) -> View { + let TreeSpec { + rows, + row_height, + indent_px, + palette, + guides, + } = spec; + + let children: Vec> = rows + .into_iter() + .map(|row| tree_row_view(row, row_height, indent_px, guides, &palette)) + .collect(); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(4.0_f32), + bottom: length(4.0_f32), + }, + ..Default::default() + }) + .fill(palette.bg_panel) + .clip(true) + .children(children) +} + +fn tree_row_view( + row: TreeRow, + height: f32, + indent_px: f32, + guides: bool, + palette: &TreePalette, +) -> View { + let bg = if row.selected { + palette.bg_selected + } else { + palette.bg_panel + }; + let indent = (row.depth as f32) * indent_px; + + // Chevron a la izquierda — 16px de ancho, ▸ si colapsado, ▾ si + // expandido. Si es hoja, espacio en blanco del mismo ancho para que + // los labels alineen. ASCII puro (`v`/`>`) por compat de fuentes. + let chevron_label = if row.has_children { + if row.expanded { + "v" + } else { + ">" + } + } else { + " " + }; + let chevron_msg = if row.has_children { + Some(row.on_toggle) + } else { + None + }; + let mut chevron = View::new(Style { + size: Size { + width: length(16.0_f32), + height: length(height), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .text_aligned( + chevron_label.to_string(), + 12.0, + palette.fg_chevron, + Alignment::Center, + ); + if let Some(msg) = chevron_msg { + chevron = chevron.hover_fill(palette.bg_hover).on_click(msg); + } + + let mut row_children: Vec> = vec![chevron]; + + // Icono gráfico opcional, entre chevron y label. + if let Some(icon) = row.icon { + row_children.push( + View::new(Style { + size: Size { + width: length(20.0_f32), + height: length(height), + }, + flex_shrink: 0.0, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .children(vec![icon]), + ); + } + + // Slot elástico del label: editor in-situ si la fila lo trae, o el + // texto sólo-lectura clickeable en su defecto. Alto `auto` para que el + // `align_items: Center` de la fila lo centre verticalmente. + let label = if let Some(editor) = row.editor { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(height), + }, + flex_grow: 1.0, + padding: Rect { + left: length(4.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .children(vec![editor]) + } else { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + flex_grow: 1.0, + padding: Rect { + left: length(4.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .text_aligned(row.label, 12.0, palette.fg_text, Alignment::Start) + .on_click(row.on_select) + }; + row_children.push(label); + + let mut v = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(height), + }, + padding: Rect { + left: length(8.0_f32 + indent), + right: length(0.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(bg) + .hover_fill(palette.bg_hover) + .children(row_children); + + // Líneas guía de indentación, pintadas por debajo de los hijos. + if guides && row.depth > 0 { + let guide = palette.guide; + let depth = row.depth; + v = v.paint_with(move |scene, _ts, rect: PaintRect| { + let stroke = Stroke::new(1.0); + for k in 0..depth { + let x = (rect.x + 8.0 + k as f32 * indent_px + 7.0) as f64; + let line = KurboLine::new((x, rect.y as f64), (x, (rect.y + rect.h) as f64)); + scene.stroke(&stroke, Affine::IDENTITY, guide, None, &line); + } + }); + } + + if let Some(ctx) = row.on_context { + v = v.on_right_click(ctx); + } + + v +} diff --git a/widgets/wawa-mark/Cargo.toml b/widgets/wawa-mark/Cargo.toml new file mode 100644 index 0000000..974fe10 --- /dev/null +++ b/widgets/wawa-mark/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "llimphi-widget-wawa-mark" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "llimphi-widget-wawa-mark — sello vectorial de wawa: rombo con degradado azul índigo → púrpura profundo + 'W' implícita en trazo blanco continuo + Merkle Core luminoso en la sutura. Sin tipografía, todo geometría." + +[dependencies] +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/widgets/wawa-mark/examples/wawa_mark_demo.rs b/widgets/wawa-mark/examples/wawa_mark_demo.rs new file mode 100644 index 0000000..c048e84 --- /dev/null +++ b/widgets/wawa-mark/examples/wawa_mark_demo.rs @@ -0,0 +1,85 @@ +//! Demo del sello wawa. Tres tamaños sobre fondo oscuro neutro. +//! +//! `cargo run -p llimphi-widget-wawa-mark --example wawa_mark_demo --release` + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::{App, Handle, View}; +use llimphi_widget_wawa_mark::{wawa_mark_view, WawaMarkPalette}; + +struct Demo; + +impl App for Demo { + type Model = (); + type Msg = (); + + fn title() -> &'static str { + "wawa · sello" + } + + fn initial_size() -> (u32, u32) { + (820, 420) + } + + fn init(_: &Handle) {} + fn update(model: Self::Model, _: Self::Msg, _: &Handle) -> Self::Model { + model + } + + fn view(_: &Self::Model) -> View { + let palette = WawaMarkPalette::default(); + + let frame = |side: f32| -> View<()> { + View::new(Style { + size: Size { + width: length(side), + height: length(side), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .children(vec![wawa_mark_view(&palette)]) + }; + + let row = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::SpaceEvenly), + gap: Size { + width: length(24.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![frame(72.0), frame(160.0), frame(288.0)]); + + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(32.0_f32), + right: length(32.0_f32), + top: length(32.0_f32), + bottom: length(32.0_f32), + }, + ..Default::default() + }) + // Fondo grafito neutro para que el rombo destaque sin competir. + .fill(Color::from_rgba8(18, 18, 22, 255)) + .children(vec![row]) + } +} + +fn main() { + llimphi_ui::run::(); +} diff --git a/widgets/wawa-mark/src/lib.rs b/widgets/wawa-mark/src/lib.rs new file mode 100644 index 0000000..1618ba2 --- /dev/null +++ b/widgets/wawa-mark/src/lib.rs @@ -0,0 +1,306 @@ +//! `llimphi-widget-wawa-mark` — sello vectorial del SO wawa. +//! +//! ## Spec (revisión 2026-05-29) +//! +//! Identidad nominal **implícita**: el rombo de fondo lleva la paleta +//! oficial (Azul Índigo / Púrpura Profundo) y los trazos blancos forman +//! las letras **"WA"** pero geométricamente — no son tipografía, son +//! aristas internas que rebotan en los mismos 45° del rombo, así dan +//! sensación de facetas talladas dentro del diamante. +//! +//! ### Composición +//! +//! 1. **Rombo de fondo** — degradado vertical inmaculado, sin sutura +//! visible: índigo arriba, púrpura abajo. El degradado lineal cubre +//! toda la altura del rombo (no sólo la mitad), de modo que el cambio +//! de tono es continuo. +//! 2. **Trazo "WA"** — un único `BezPath` con dos subtrazos: +//! - **W** (izquierda): zigzag de 4 segmentos, todos a 45° (matching +//! las aristas del rombo). Picos en la sutura azul/púrpura +//! (y = 0.50), valles en y = 0.60. Cinco vértices, cuatro segmentos. +//! - **A** (derecha): triángulo abierto formado por dos legs a 45° +//! + un crossbar horizontal a mitad de altura. Tres segmentos. +//! Las strokes diagonales (6 de las 7) son paralelas a las aristas +//! del rombo, por eso "leen" como filos cortados del diamante en vez +//! de letras pintadas encima. +//! 3. **Merkle Core** — punto luminoso con halo en el pico central de +//! la W (sobre la sutura, donde azul y púrpura se encuentran). Es el +//! nodo raíz que amarra el sistema. +//! +//! ### Geometría (en coords normalizadas `[0, 1] × [0, 1]` del rect) +//! +//! ```text +//! Top +//! ◇ +//! / \ +//! / \ ← azul índigo +//! / \ +//! P0 P2★ P4 A1 +//! ●─. ● .─● ●─. .─● ← y = 0.50 (sutura) +//! ╲ ╱ ╲ ╱ ╲ ╱ +//! ╳ ╳ ╲────╱ ← crossbar A (y=0.55) +//! ╱ ╲ ╱ ╲ ╱ ╲ +//! ●─' ● '─● ●─' '─● ← y = 0.60 (valles/pies) +//! P1 P3 A0 A2 +//! ↑ +//! gap entre W y A +//! Left ◇─────────────────────────────────◇ Right +//! (sutura, y = 0.50) +//! / +//! / +//! / ← púrpura profundo +//! / +//! ◇ +//! Bottom +//! ``` +//! +//! Las strokes diagonales todas a slope ±1, igual que las aristas del +//! rombo. El crossbar de la A es la única horizontal — concesión mínima +//! a la legibilidad de la letra, queda subordinado al patrón diamante. +//! +//! ## Uso +//! +//! ```ignore +//! use llimphi_widget_wawa_mark::{wawa_mark_view, WawaMarkPalette}; +//! +//! // En un view: +//! View::new(Style { size: Size { width: length(128.0), height: length(128.0) }, ..Default::default() }) +//! .children(vec![wawa_mark_view(&WawaMarkPalette::default())]) +//! ``` +//! +//! El widget rellena el rect del padre — pasarle un tamaño cuadrado para +//! que el rombo no se distorsione (lo respeta igual, pero queda mejor +//! cuadrado). + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{percent, Size, Style}, + Position, +}; +use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle, Point, Stroke}; +use llimphi_ui::llimphi_raster::peniko::{color::AlphaColor, Color, Fill, Gradient, Mix}; +use llimphi_ui::View; + +/// Paleta del sello. Los defaults corresponden a la especificación +/// oficial (Azul Índigo + Púrpura Profundo + trazo blanco + acento +/// cyan-eléctrico para el Merkle Core). +#[derive(Debug, Clone, Copy)] +pub struct WawaMarkPalette { + /// Color superior del degradado (tope del rombo). + pub indigo: Color, + /// Color inferior del degradado (base del rombo). + pub purple: Color, + /// Color del trazo de la 'W' implícita. + pub stroke: Color, + /// Color del Merkle Core (nodo central). Halo se deriva con alpha + /// reducido del mismo color. + pub core: Color, +} + +impl Default for WawaMarkPalette { + fn default() -> Self { + Self { + // Azul Índigo profundo — saturación alta, valor medio. + indigo: Color::from_rgba8(46, 56, 168, 255), + // Púrpura Profundo — más violeta, valor menor. + purple: Color::from_rgba8(76, 32, 122, 255), + // Blanco con leve calidez para no quemar contra el púrpura. + stroke: Color::from_rgba8(240, 240, 248, 255), + // Cyan eléctrico — el "color del cursor del osciloscopio". + core: Color::from_rgba8(120, 240, 255, 255), + } + } +} + +/// Construye el `View` que pinta el sello dentro del rect del padre. +/// El widget se posiciona absolute al 100% del padre — pasarle un +/// contenedor con tamaño cuadrado para evitar distorsión. +pub fn wawa_mark_view(palette: &WawaMarkPalette) -> View { + let p = *palette; + View::new(Style { + position: Position::Absolute, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .paint_with(move |scene, _ts, rect| paint_mark(scene, rect, &p)) +} + +/// Pintor puro — recibe el `Scene`, el rect de pintura y la paleta. +/// Expuesto por separado para que apps avanzadas puedan reusar el +/// painter dentro de canvas custom (splash de boot, about box, etc.) +/// sin pasar por la fachada `View`. +pub fn paint_mark( + scene: &mut llimphi_ui::llimphi_raster::vello::Scene, + rect: llimphi_ui::PaintRect, + palette: &WawaMarkPalette, +) { + // Encajamos el rombo en el menor de los lados del rect, centrado. + // Así el sello mantiene su proporción incluso si el rect no es + // cuadrado (pero degrada gracilmente). + let side = rect.w.min(rect.h) as f64; + let cx = rect.x as f64 + rect.w as f64 * 0.5; + let cy = rect.y as f64 + rect.h as f64 * 0.5; + let half = side * 0.5; + + // === 1) Rombo de fondo con degradado vertical === + // + // Construimos el rombo como BezPath (4 segmentos rectos) en coords + // absolutas. El degradado lineal va de (cx, top) a (cx, bot) — toda + // la altura del rombo — para que el cambio de tono sea continuo y + // sin sutura visible. + let top = Point::new(cx, cy - half); + let right = Point::new(cx + half, cy); + let bot = Point::new(cx, cy + half); + let left = Point::new(cx - half, cy); + + let mut rhombus = BezPath::new(); + rhombus.move_to(top); + rhombus.line_to(right); + rhombus.line_to(bot); + rhombus.line_to(left); + rhombus.close_path(); + + let gradient = Gradient::new_linear(top, bot) + .with_stops([palette.indigo, palette.purple].as_slice()); + + scene.fill(Fill::NonZero, Affine::IDENTITY, &gradient, None, &rhombus); + + // === 2) "WA" implícita === + // + // Coords en porcentaje del rombo (origen = esquina top-left del bbox + // del rombo = (cx-half, cy-half), unidad = side). Toda stroke diagonal + // tiene |dy/dx| = 1 (paralela a las aristas del rombo) — por eso lee + // como faceta del diamante en vez de letra dibujada encima. + let coord = |fx: f64, fy: f64| -> Point { + Point::new( + cx - half + fx * side, + cy - half + fy * side, + ) + }; + + // Unidad de escala: span vertical de las letras. dx==dy en cada leg + // hace que las strokes corran a 45° exactos (mismo ángulo que las + // aristas del rombo). Probado para que WA quede inscrita con holgura + // en el rombo a cualquier escala — al achicar (32px) sigue legible, + // al ampliar (300px) no se ve disperso. + let unit: f64 = 0.10; + // Línea de picos en la sutura azul/púrpura. + let top_y = 0.50; + // Línea de valles/pies en el cuadrante púrpura inferior. + let bot_y = top_y + unit; + + // ---- W (zigzag de 4 segmentos) ---- + // Centramos la composición WA: span total ≈ 0.61 (W 0.36 + gap 0.03 + // + A 0.18 + holgura). Empezamos en x = 0.19 para que el centro + // óptico de WA caiga cerca de x = 0.50. + let w_left = 0.20; + let p0 = coord(w_left + 0.0 * unit, top_y); + let p1 = coord(w_left + 1.0 * unit, bot_y); + let p2 = coord(w_left + 2.0 * unit, top_y); + let p3 = coord(w_left + 3.0 * unit, bot_y); + let p4 = coord(w_left + 4.0 * unit, top_y); + + // ---- A (legs + crossbar) ---- + // Gap entre W y A — apenas un respiro para que no se confundan en + // un solo zigzag. + let gap = 0.04; + let a_left = w_left + 4.0 * unit + gap; + let a0 = coord(a_left + 0.0 * unit, bot_y); + let a1 = coord(a_left + 1.0 * unit, top_y); + let a2 = coord(a_left + 2.0 * unit, bot_y); + // Crossbar a mitad de altura, en el tercio interno de cada leg para + // que no toque las puntas (queda más A que H). + let cross_y = (top_y + bot_y) * 0.5 + 0.005; // un toque debajo del medio óptico + let c_offset = 0.30 * unit; + let cb0 = coord(a_left + 0.0 * unit + c_offset, cross_y); + let cb1 = coord(a_left + 2.0 * unit - c_offset, cross_y); + + // Un único BezPath con cuatro subtrazos (move_to abre subtrazo nuevo). + let mut wa = BezPath::new(); + // W + wa.move_to(p0); + wa.line_to(p1); + wa.line_to(p2); + wa.line_to(p3); + wa.line_to(p4); + // A — legs. + wa.move_to(a0); + wa.line_to(a1); + wa.line_to(a2); + // A — crossbar (horizontal, único trazo no diagonal). + wa.move_to(cb0); + wa.line_to(cb1); + + // Espesor escalable: ~2.0% del lado del rombo. Levemente más fino + // que la W sola, porque ahora hay 7 strokes en vez de 4 y conviene + // bajar densidad. + let stroke_w = (side * 0.020).max(1.0); + let stroke = Stroke::new(stroke_w) + .with_join(llimphi_ui::llimphi_raster::kurbo::Join::Miter) + .with_caps(llimphi_ui::llimphi_raster::kurbo::Cap::Butt); + + scene.stroke( + &stroke, + Affine::IDENTITY, + palette.stroke, + None, + &wa, + ); + + // === 3) Merkle Core === + // + // Sobre P2 — pico central de la W, en la sutura exacta entre azul y + // púrpura. Halo amplio semi-transparente + núcleo opaco compacto + // dan sensación de glow sin blur real. + let core_r = (side * 0.018).max(1.2); + let halo_r = core_r * 2.6; + let halo_color = with_alpha(palette.core, 0.30); + scene.push_layer(Mix::Normal, 1.0, Affine::IDENTITY, &Circle::new(p2, halo_r)); + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + halo_color, + None, + &Circle::new(p2, halo_r), + ); + scene.pop_layer(); + scene.fill( + Fill::NonZero, + Affine::IDENTITY, + palette.core, + None, + &Circle::new(p2, core_r), + ); +} + +/// Devuelve `color` con su alpha multiplicado por `mult` (no reemplazado). +/// Mantenemos la cromaticidad intacta. +fn with_alpha(color: Color, mult: f32) -> Color { + let [r, g, b, a] = color.components; + AlphaColor::new([r, g, b, a * mult]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_palette_has_distinct_indigo_and_purple() { + let p = WawaMarkPalette::default(); + assert_ne!(p.indigo.components, p.purple.components); + assert_ne!(p.stroke.components, p.core.components); + } + + #[test] + fn with_alpha_multiplies_not_replaces() { + let c = Color::from_rgba8(100, 100, 100, 255); + let halved = with_alpha(c, 0.5); + assert!((halved.components[3] - 0.5).abs() < 1e-3); + // RGB intactos. + assert!((halved.components[0] - c.components[0]).abs() < 1e-3); + } +}