llimphi_ui/lib.rs
1//! llimphi-ui — Runtime Elm sobre winit.
2//!
3//! Maneja el bucle `input → update(model, msg) → view(model) → layout →
4//! raster → present` sobre una ventana winit + GPU (`llimphi-hal` +
5//! `llimphi-raster`). La parte declarativa y winit-agnóstica (el árbol
6//! `View<Msg>`, `mount`, `paint`, hit-test) vive en `llimphi-compositor` y
7//! se re-exporta tal cual, así los consumidores siguen escribiendo
8//! `llimphi_ui::View` sin enterarse del split.
9//!
10//! El estado del [`App`] es inmutable: cada evento produce un `Model`
11//! nuevo. La vista (`view`) es una función pura `&Model -> View<Msg>`.
12
13use std::sync::Arc;
14
15pub mod a11y;
16
17use llimphi_hal::winit::application::ApplicationHandler;
18use llimphi_hal::winit::dpi::{LogicalSize, PhysicalPosition};
19use llimphi_hal::winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
20use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy};
21use llimphi_hal::winit::keyboard::ModifiersState;
22use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId};
23use llimphi_hal::{Hal, Surface, WinitSurface};
24
25pub use llimphi_hal::winit::keyboard::{Key, NamedKey};
26use llimphi_layout::{ComputedLayout, LayoutTree};
27use llimphi_raster::peniko::color::palette;
28use llimphi_raster::{vello, Renderer};
29
30pub use llimphi_hal;
31pub use llimphi_layout;
32pub use llimphi_raster;
33pub use llimphi_text;
34
35// El compositor declarativo (View, mount, paint, hit-test, tipos de
36// handler) se re-exporta entero: `llimphi_ui::View`, `llimphi_ui::DragFn`,
37// etc. siguen resolviendo igual que antes del split.
38pub use llimphi_compositor;
39pub use llimphi_compositor::*;
40
41/// Aplicación Elm: estado inmutable, transición pura, vista pura.
42///
43/// `init` y `update` reciben un [`Handle`] que permite hablar con el runtime
44/// desde dentro de la transición (cerrar la ventana, lanzar trabajo en otro
45/// hilo y reentrar con un Msg al terminar). Mantener la transición pura del
46/// modelo sigue siendo el contrato — `Handle` sólo escala efectos.
47pub trait App: 'static {
48 type Model: 'static;
49 type Msg: Clone + Send + 'static;
50
51 fn init(handle: &Handle<Self::Msg>) -> Self::Model;
52 fn update(model: Self::Model, msg: Self::Msg, handle: &Handle<Self::Msg>) -> Self::Model;
53 fn view(model: &Self::Model) -> View<Self::Msg>;
54
55 /// Maneja una pulsación de tecla. Devuelve `Some(Msg)` para disparar
56 /// una transición; `None` (default) ignora la tecla.
57 fn on_key(_model: &Self::Model, _event: &KeyEvent) -> Option<Self::Msg> {
58 None
59 }
60
61 /// El foco cambió: el runtime movió el foco a `id` (`None` = nada
62 /// enfocado). Pasa al pulsar Tab/Shift+Tab (recorre los nodos
63 /// `View::focusable` en orden de árbol, envolviendo) o al clickear un
64 /// nodo enfocable. La app guarda `id` en su `Model` para (a) pintar el
65 /// focus-ring (`if model.focus == Some(id) { … }` en `view`) y (b)
66 /// rutear el teclado al campo activo desde `on_key`. Devolver
67 /// `Some(Msg)` dispara una transición; `None` (default) ignora.
68 ///
69 /// El foco lo administra el runtime (única fuente de verdad), así que
70 /// Tab y click-to-focus quedan consistentes sin que la app los cablee.
71 fn on_focus(_model: &Self::Model, _id: Option<u64>) -> Option<Self::Msg> {
72 None
73 }
74
75 /// ¿Habilitar IME (input method editor) en esta ventana? Default
76 /// `false`. Con IME activo, el texto compuesto (CJK, acentos muertos,
77 /// emoji picker) llega por [`App::on_ime`] como `Commit`, **no** por
78 /// `KeyEvent.text` — por eso es opt-in: las apps que sólo leen
79 /// `on_key` siguen funcionando igual. Las que editan texto
80 /// (`text-input`, `text-editor`) la activan e implementan `on_ime`.
81 fn ime_allowed() -> bool {
82 false
83 }
84
85 /// Maneja un evento de IME (sólo llega si [`App::ime_allowed`] es
86 /// `true`). El flujo típico: `Enabled` → uno o más `Preedit` (texto en
87 /// composición, a pintar subrayado en el caret) → `Commit(texto)` (el
88 /// texto final, a insertar como si se hubiera tecleado) o `Disabled`.
89 /// El `Preedit` no es definitivo: cada uno reemplaza al anterior, y un
90 /// `Commit` o `Preedit` vacío lo cierra. Devolver `Some(Msg)` dispara
91 /// una transición.
92 fn on_ime(_model: &Self::Model, _event: &ImeEvent) -> Option<Self::Msg> {
93 None
94 }
95
96 /// Área del caret en **píxeles físicos** `(x, y, w, h)` para posicionar
97 /// la ventana de candidatos del IME (CJK) junto al cursor de texto. El
98 /// runtime la consulta por frame cuando [`App::ime_allowed`] es `true`.
99 /// `None` (default) deja que el sistema la ubique por defecto.
100 fn ime_cursor_area(_model: &Self::Model) -> Option<(f32, f32, f32, f32)> {
101 None
102 }
103
104 /// Maneja una rueda del mouse. `delta` está normalizado a "líneas"
105 /// (positivo arriba/izquierda, negativo abajo/derecha). En backends
106 /// que reportan píxeles, llimphi-ui divide por 20 para aproximar.
107 fn on_wheel(
108 _model: &Self::Model,
109 _delta: WheelDelta,
110 _cursor: (f32, f32),
111 _modifiers: Modifiers,
112 ) -> Option<Self::Msg> {
113 None
114 }
115
116 /// Capa de overlay opcional. Si devuelve `Some(view)`, el runtime
117 /// la pinta encima del árbol principal y los clicks/hover se
118 /// rutean exclusivamente a ella (el árbol de fondo queda "bajo
119 /// vidrio" hasta que se cierre el overlay). Pensado para menús
120 /// contextuales, diálogos modales, popovers — el patrón usual es
121 /// envolver los items en un scrim a pantalla completa con
122 /// `on_click = DismissOverlay` para que los clicks afuera lo
123 /// cierren.
124 ///
125 /// La transición entre "con overlay" y "sin overlay" la maneja la
126 /// app vía su Model: cuando el state diga "menu abierto",
127 /// `view_overlay` devuelve `Some`; cuando se cierre, `None`.
128 fn view_overlay(_model: &Self::Model) -> Option<View<Self::Msg>> {
129 None
130 }
131
132 /// Maneja un drop de archivo desde el sistema operativo (drag&drop
133 /// desde el file manager hacia la ventana). El runtime invoca este
134 /// callback una vez por archivo soltado — si el usuario suelta varios,
135 /// llega un evento por path. Devolver `Some(Msg)` dispara un update;
136 /// `None` (default) ignora el drop.
137 ///
138 /// Backend: mapea directamente `winit::WindowEvent::DroppedFile(PathBuf)`.
139 /// La posición del drop no se reporta porque winit no la expone hasta
140 /// que el compositor la propague — en Wayland depende del extension
141 /// `data_device_manager`, en X11 viene en el ClientMessage XDND.
142 fn on_file_drop(_model: &Self::Model, _path: std::path::PathBuf) -> Option<Self::Msg> {
143 None
144 }
145
146 /// Maneja un redimensionado de la ventana. `width`/`height` son el
147 /// nuevo tamaño en **píxeles físicos** (lo que reporta
148 /// `winit::WindowEvent::Resized` y lo que recibe la surface). El
149 /// runtime ya reconfiguró la surface y pedirá redraw; este callback
150 /// es para que la app reaccione al nuevo viewport (recalcular layout
151 /// dependiente del tamaño, emitir un evento `resize`, etc.).
152 /// Devolver `Some(Msg)` dispara un update; `None` (default) lo ignora.
153 fn on_resize(_model: &Self::Model, _width: u32, _height: u32) -> Option<Self::Msg> {
154 None
155 }
156
157 /// Maneja un cambio del factor de escala de la ventana (`scale_factor`
158 /// de winit: 1.0 en pantallas normales, 2.0 en HiDPI/Retina, fraccional
159 /// con escalado del compositor). El runtime lo invoca una vez al arrancar
160 /// (con el factor inicial de la ventana, tras `init`) y luego en cada
161 /// `WindowEvent::ScaleFactorChanged` (mover la ventana entre monitores,
162 /// cambiar el escalado del sistema). Es lo que permite, p. ej., que
163 /// `window.devicePixelRatio` refleje el DPI real. Devolver `Some(Msg)`
164 /// dispara un update; `None` (default) lo ignora.
165 fn on_scale_factor(_model: &Self::Model, _scale: f64) -> Option<Self::Msg> {
166 None
167 }
168
169 /// Título de la ventana (sólo se lee al arrancar). Es el título inicial;
170 /// para uno que cambie en runtime, ver [`App::window_title`].
171 fn title() -> &'static str {
172 "llimphi"
173 }
174
175 /// Título **dinámico** de la ventana, derivado del modelo. El runtime lo
176 /// consulta tras cada render y, si cambió, lo aplica con `Window::set_title`
177 /// — así el título de la barra del SO puede reflejar el estado (p. ej. el
178 /// medio que se reproduce). `None` (default) deja el título fijo de
179 /// [`App::title`]; una app que no lo implemente no paga nada.
180 fn window_title(_model: &Self::Model) -> Option<String> {
181 None
182 }
183
184 /// Vista de una ventana OS **secundaria** identificada por `key` (la que
185 /// se pasó a [`Handle::open_window`]). El runtime la pinta en su propia
186 /// ventana y rutea sus eventos al mismo [`App::update`] — comparte modelo
187 /// con la primaria. `None` (default, o para una key desconocida) deja la
188 /// ventana en blanco. Las secundarias NO tienen capa de overlay
189 /// ([`App::view_overlay`] es sólo de la primaria); para diálogos dentro de
190 /// una secundaria, componerlos en su propio `secondary_view`.
191 fn secondary_view(_model: &Self::Model, _key: u64) -> Option<View<Self::Msg>> {
192 None
193 }
194
195 /// Título dinámico de una ventana secundaria (análogo a
196 /// [`App::window_title`] para la primaria). `None` deja el título con el
197 /// que se abrió.
198 fn secondary_title(_model: &Self::Model, _key: u64) -> Option<String> {
199 None
200 }
201
202 /// El usuario cerró una ventana secundaria con el botón del SO. El runtime
203 /// ya la destruyó; este callback es para que la app sincronice su modelo
204 /// (p. ej. marcar el panel como cerrado). Devolver `Some(Msg)` dispara un
205 /// `update`; `None` (default) no hace nada.
206 fn on_secondary_close(_model: &Self::Model, _key: u64) -> Option<Self::Msg> {
207 None
208 }
209
210 /// Identificador de aplicación. En Wayland se mapea al `app_id` del
211 /// xdg-toplevel (lo que el compositor usa para reconocer la ventana,
212 /// p. ej. `mirada.greeter`). `None` deja que el sistema asigne uno.
213 fn app_id() -> Option<&'static str> {
214 None
215 }
216
217 /// Tamaño lógico inicial de la ventana, en píxeles. El usuario puede
218 /// redimensionar después; sólo se lee al arrancar.
219 fn initial_size() -> (u32, u32) {
220 (960, 540)
221 }
222}
223
224/// Mensaje interno del event loop. `Msg` lo dispara la app desde un hilo de
225/// fondo vía [`Handle::dispatch`] o [`Handle::spawn`]; `Quit` cierra la
226/// ventana y termina el proceso.
227pub enum UserEvent<Msg> {
228 Msg(Msg),
229 Quit,
230 /// Pide abrir una ventana OS **secundaria** con la `key` dada (la app la
231 /// usa para distinguir cuál es en [`App::secondary_view`]). Idempotente:
232 /// si ya existe una con esa key, se enfoca en vez de duplicar. La crea el
233 /// event loop (que tiene el `ActiveEventLoop`); por eso va por mensaje.
234 OpenWindow {
235 key: u64,
236 title: String,
237 width: u32,
238 height: u32,
239 },
240 /// Pide cerrar la ventana secundaria con esa `key`. No afecta a la primaria.
241 CloseWindow { key: u64 },
242 /// Evento del adapter AccessKit: el lector de pantalla solicitó el árbol
243 /// inicial, pidió ejecutar una acción (focus, click, etc.) o se desactivó.
244 /// El adapter usa el `EventLoopProxy` para enviarlos al hilo del runtime.
245 A11y(accesskit_winit::Event),
246}
247
248/// Permite que `accesskit_winit::Adapter::with_event_loop_proxy` mande sus
249/// eventos sobre nuestro `EventLoopProxy<UserEvent<Msg>>` sin que el caller
250/// los rutee a mano.
251impl<Msg> From<accesskit_winit::Event> for UserEvent<Msg> {
252 fn from(e: accesskit_winit::Event) -> Self {
253 UserEvent::A11y(e)
254 }
255}
256
257/// Asa al runtime de Llimphi. Clonable y enviable entre hilos: la usás para
258/// pedir cerrar la ventana o para lanzar trabajo (PAM, IO, etc.) que al
259/// terminar reentra con un Msg al `update`.
260///
261/// Tests pueden construir un handle "muerto" con [`Handle::for_test`]: los
262/// `dispatch`/`quit`/`spawn` siguen siendo seguros de llamar pero los
263/// `Msg` que generan no van a ningún lado (no hay event loop detrás).
264pub struct Handle<Msg: Send + 'static> {
265 inner: HandleInner<Msg>,
266}
267
268enum HandleInner<Msg: Send + 'static> {
269 Real(EventLoopProxy<UserEvent<Msg>>),
270 /// Handle de tests: drop silencioso de todos los dispatches. Permite
271 /// llamar funciones que toman `&Handle<Msg>` sin levantar un event
272 /// loop real (que en CI sin display tiraría).
273 Test,
274 /// Handle **lifteado**: reenvía cada `Msg` (de un sub-app hospedado) al
275 /// handle del host aplicándole una función de elevación `Sub -> Host`. Lo
276 /// crea [`Handle::lift`]; permite que el `update` de un app embebido use
277 /// `dispatch`/`spawn`/`spawn_periodic` con su propio `Msg` y que el
278 /// resultado llegue al loop del host. No maneja ventanas (open/close/quit
279 /// son no-op): esas son del host, no del hospedado.
280 Lifted(Arc<dyn Fn(Msg) + Send + Sync>),
281}
282
283impl<Msg: Send + 'static> Clone for Handle<Msg> {
284 fn clone(&self) -> Self {
285 Self {
286 inner: match &self.inner {
287 HandleInner::Real(p) => HandleInner::Real(p.clone()),
288 HandleInner::Test => HandleInner::Test,
289 HandleInner::Lifted(f) => HandleInner::Lifted(f.clone()),
290 },
291 }
292 }
293}
294
295impl<Msg: Send + 'static> Handle<Msg> {
296 /// Construye un handle desactivado para tests — todos los dispatch
297 /// se descartan silenciosamente. Útil para probar funciones que toman
298 /// `&Handle<Msg>` sin levantar un event loop real (que en CI sin
299 /// display tiraría).
300 pub fn for_test() -> Self {
301 Self {
302 inner: HandleInner::Test,
303 }
304 }
305
306 /// Cierra la ventana y termina el bucle. La transición en curso (si la
307 /// hay) se completa antes de salir.
308 pub fn quit(&self) {
309 match &self.inner {
310 HandleInner::Real(p) => {
311 let _ = p.send_event(UserEvent::Quit);
312 }
313 HandleInner::Test => {}
314 // Un app hospedado no cierra el loop del host.
315 HandleInner::Lifted(_) => {}
316 }
317 }
318
319 /// Deriva un handle para un **sub-app hospedado**: el `update`/efectos del
320 /// sub-app usan su propio `Sub` msg, y `lift` los eleva al `Msg` del host
321 /// antes de despacharlos a este loop. Es la pieza que permite embeber un
322 /// App entero en otro (junto con [`crate::View::map`] para su `view`) sin
323 /// reescribirlo a patrón módulo. El sub-handle es `Clone + Send` como
324 /// cualquier handle. `open_window`/`close_window`/`quit` quedan no-op en él
325 /// (esas son del host).
326 pub fn lift<Sub, F>(&self, lift: F) -> Handle<Sub>
327 where
328 Sub: Send + 'static,
329 F: Fn(Sub) -> Msg + Send + Sync + 'static,
330 {
331 let parent = self.clone();
332 Handle {
333 inner: HandleInner::Lifted(Arc::new(move |sub: Sub| parent.dispatch(lift(sub)))),
334 }
335 }
336
337 /// Abre una ventana OS **secundaria** (ver [`App::secondary_view`]). La
338 /// `key` la elige la app para reconocerla luego; abrir con una key que ya
339 /// existe sólo la enfoca (no duplica). El contenido lo pinta
340 /// `App::secondary_view(model, key)` y los eventos (click/tecla/…) reentran
341 /// al mismo `update`, así que la ventana comparte el modelo con la primaria.
342 /// Cerrala con [`Self::close_window`] o con el botón del SO.
343 pub fn open_window(&self, key: u64, title: impl Into<String>, width: u32, height: u32) {
344 if let HandleInner::Real(p) = &self.inner {
345 let _ = p.send_event(UserEvent::OpenWindow {
346 key,
347 title: title.into(),
348 width,
349 height,
350 });
351 }
352 }
353
354 /// Cierra la ventana secundaria con esa `key` (no-op si no existe). La
355 /// ventana primaria nunca se cierra por acá — para eso está [`Self::quit`].
356 pub fn close_window(&self, key: u64) {
357 if let HandleInner::Real(p) = &self.inner {
358 let _ = p.send_event(UserEvent::CloseWindow { key });
359 }
360 }
361
362 /// Encola un Msg para procesarse en el próximo turno del bucle. Útil
363 /// para que un callback externo reentre al update.
364 pub fn dispatch(&self, msg: Msg) {
365 match &self.inner {
366 HandleInner::Real(p) => {
367 let _ = p.send_event(UserEvent::Msg(msg));
368 }
369 HandleInner::Test => {}
370 HandleInner::Lifted(f) => f(msg),
371 }
372 }
373
374 /// Lanza una closure en un hilo aparte; cuando devuelve `Msg`, el
375 /// runtime la entrega al `update` en el hilo de UI. Pensado para
376 /// trabajo bloqueante (PAM tarda ~2 s ante un fallo, p. ej.).
377 pub fn spawn<F>(&self, f: F)
378 where
379 F: FnOnce() -> Msg + Send + 'static,
380 {
381 match &self.inner {
382 HandleInner::Real(p) => {
383 let proxy = p.clone();
384 std::thread::spawn(move || {
385 let msg = f();
386 let _ = proxy.send_event(UserEvent::Msg(msg));
387 });
388 }
389 HandleInner::Test => {
390 // Corremos la closure igual (para no perder side-effects de
391 // tests que dependan de su side) pero el msg se descarta.
392 std::thread::spawn(move || {
393 let _ = f();
394 });
395 }
396 HandleInner::Lifted(lift) => {
397 // Tarea one-shot del sub-app: corre en su hilo y el resultado
398 // se eleva al host vía la closure de lift.
399 let lift = lift.clone();
400 std::thread::spawn(move || {
401 lift(f());
402 });
403 }
404 }
405 }
406
407 /// Lanza un loop periódico en un hilo aparte: cada `period` invoca
408 /// `f()` y dispatcha el `Msg` resultante al `update`. El thread
409 /// queda corriendo hasta que el event loop se cierra (en ese
410 /// punto el `send_event` falla silenciosamente y el thread spinea
411 /// hasta el exit del proceso, costo despreciable).
412 ///
413 /// Útil para ticks de simulación (~11 Hz en dominium), polling de
414 /// hardware, o cualquier feed que necesite Msgs a intervalos
415 /// regulares. Si `f` necesita state, capturalo en la closure por
416 /// move; la closure se ejecuta en un thread aparte así que el
417 /// state capturado debe ser `Send`.
418 pub fn spawn_periodic<F>(&self, period: std::time::Duration, f: F)
419 where
420 F: Fn() -> Msg + Send + 'static,
421 {
422 match &self.inner {
423 HandleInner::Real(p) => {
424 let proxy = p.clone();
425 std::thread::spawn(move || loop {
426 std::thread::sleep(period);
427 if proxy.send_event(UserEvent::Msg(f())).is_err() {
428 // Event loop cerrado — el thread puede morir.
429 break;
430 }
431 });
432 }
433 HandleInner::Test => {
434 // Un thread vivo eternamente sin sumidero ni manera de
435 // pararlo sería un leak — en for_test simplemente no
436 // arrancamos el loop. Los tests que necesiten verificar
437 // periodic behaviour deben usar el callback directo.
438 let _ = f;
439 }
440 HandleInner::Lifted(lift) => {
441 // Mismo loop que `Real` pero elevando al host. Si el loop del
442 // host se cerró, la closure de lift termina en un dispatch
443 // no-op (spinea hasta el exit, costo despreciable — igual que
444 // `Real`); aceptable para un ticker de animación/feed.
445 let lift = lift.clone();
446 std::thread::spawn(move || loop {
447 std::thread::sleep(period);
448 lift(f());
449 });
450 }
451 }
452 }
453}
454
455/// Evento de teclado normalizado.
456#[derive(Debug, Clone)]
457pub struct KeyEvent {
458 pub key: Key,
459 pub state: KeyState,
460 /// Texto resultante (con modifiers e IME aplicados). Útil para inserción
461 /// directa; `None` para teclas que no producen texto (flechas, etc.).
462 pub text: Option<String>,
463 pub modifiers: Modifiers,
464 pub repeat: bool,
465}
466
467#[derive(Debug, Clone, Copy, PartialEq, Eq)]
468pub enum KeyState {
469 Pressed,
470 Released,
471}
472
473/// Evento de IME normalizado (espeja `winit::event::Ime`). Ver
474/// [`App::on_ime`] para el flujo Enabled → Preedit* → Commit/Disabled.
475#[derive(Debug, Clone, PartialEq, Eq)]
476pub enum ImeEvent {
477 /// El IME se activó para esta ventana.
478 Enabled,
479 /// Texto en composición (aún no confirmado). `cursor` es el rango
480 /// `(inicio, fin)` en bytes a resaltar dentro de `text`, si el IME lo
481 /// reporta. Cada `Preedit` reemplaza al anterior; uno con `text`
482 /// vacío cierra la preedición sin confirmar.
483 Preedit {
484 text: String,
485 cursor: Option<(usize, usize)>,
486 },
487 /// Texto confirmado: insertarlo como si se hubiera tecleado.
488 Commit(String),
489 /// El IME se desactivó (perder foco, cambiar de método).
490 Disabled,
491}
492
493#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
494pub struct Modifiers {
495 pub shift: bool,
496 pub ctrl: bool,
497 pub alt: bool,
498 pub meta: bool,
499}
500
501/// Delta de rueda en "líneas" lógicas (normalizado a través de backends).
502/// Convención CSS: positivo = scroll **hacia abajo** (contenido sube).
503/// `x` similar para scroll horizontal (touchpads, ratones de 2 ejes).
504#[derive(Debug, Clone, Copy, Default)]
505pub struct WheelDelta {
506 pub x: f32,
507 pub y: f32,
508}
509
510impl From<ModifiersState> for Modifiers {
511 fn from(m: ModifiersState) -> Self {
512 Self {
513 shift: m.shift_key(),
514 ctrl: m.control_key(),
515 alt: m.alt_key(),
516 meta: m.super_key(),
517 }
518 }
519}
520
521// --- Runtime winit. El event loop (impl ApplicationHandler) vive en
522// `eventloop` y accede los campos privados de estos structs vía
523// `use super::*`. La composición declarativa (View, mount, paint,
524// hit-test) la trae el re-export de `llimphi_compositor`. ---
525mod eventloop;
526
527struct Runtime<A: App> {
528 handle: Handle<A::Msg>,
529 state: Option<RuntimeState<A>>,
530 /// Ventanas OS secundarias abiertas (opt-in vía [`Handle::open_window`]).
531 /// Comparten el `Hal`/`Renderer` y el modelo de la primaria (`state`);
532 /// cada una lleva su propia surface + caches de interacción. Vacío en la
533 /// inmensa mayoría de las apps (monoventana) — coste cero.
534 secondaries: Vec<SecondaryState<A>>,
535}
536
537/// Estado por **ventana secundaria**. Espeja los campos de interacción de
538/// [`RuntimeState`] pero SIN modelo (vive en la primaria), sin overlay y sin
539/// `Hal`/`Renderer` propios (los toma prestados de la primaria al pintar).
540struct SecondaryState<A: App> {
541 /// La key con la que la app la abrió (la pasa a `secondary_view`).
542 key: u64,
543 window: Arc<Window>,
544 surface: WinitSurface,
545 scene: vello::Scene,
546 typesetter: llimphi_text::Typesetter,
547 layout: LayoutTree,
548 cursor: PhysicalPosition<f64>,
549 modifiers: Modifiers,
550 last_render: Option<SecRenderCache<A::Msg>>,
551 hovered: Option<usize>,
552 drag: Option<DragState<A::Msg>>,
553 last_title: Option<String>,
554}
555
556/// Cache de render de una ventana secundaria (como [`RenderCache`] pero sin
557/// capa de overlay). Sólo guarda el árbol montado + layout para hit-testear el
558/// próximo click/hover; el `hover_idx` actual vive en `SecondaryState::hovered`.
559struct SecRenderCache<Msg> {
560 mounted: Mounted<Msg>,
561 computed: ComputedLayout,
562}
563
564struct RuntimeState<A: App> {
565 window: Arc<Window>,
566 hal: Hal,
567 surface: WinitSurface,
568 renderer: Renderer,
569 scene: vello::Scene,
570 /// Compositor de la capa de overlay sobre contenido `gpu_paint` (video).
571 /// Sólo entra en juego cuando el árbol principal tiene painters gpu y hay
572 /// un overlay activo; resuelve el z-order (menús por encima del video).
573 overlay_compositor: llimphi_hal::OverlayCompositor,
574 /// Backdrop blur post-pasada: para cada nodo con `.backdrop_blur(sigma)`,
575 /// el runtime aplica un Gauss separable (H+V) sobre la intermediate
576 /// restringido al rect del nodo, **después** de la rasterización vello y
577 /// **antes** de los `gpu_painter`. El compositor mantiene su scratch
578 /// interno; coste cero cuando no hay nodos blur.
579 blur_compositor: llimphi_hal::BlurCompositor,
580 /// Post-pasada de **matriz de color** (`filter: brightness/grayscale/…`),
581 /// restringida al rect del nodo, en el mismo punto que `blur_compositor`.
582 /// Mantiene su scratch interno; coste cero sin filtros de color. Fase
583 /// 7.1233.
584 color_filter_compositor: llimphi_hal::ColorFilterCompositor,
585 model: Option<A::Model>,
586 cursor: PhysicalPosition<f64>,
587 modifiers: Modifiers,
588 typesetter: llimphi_text::Typesetter,
589 /// Árboles de layout reusados entre frames: `clear()` + `mount` en
590 /// vez de re-allocar el slotmap de taffy en cada redraw. Uno para el
591 /// árbol principal, otro para el overlay (sus `NodeId` no deben
592 /// colisionar dentro del mismo frame).
593 layout: LayoutTree,
594 overlay_layout: LayoutTree,
595 /// Último frame renderizado: árbol montado + rects absolutos +
596 /// nodo con hover. Lo consume el handler de click para hit-testear
597 /// sin reconstruir `view` + layout, y CursorMoved para detectar si
598 /// el hover cambió y disparar redraw.
599 last_render: Option<RenderCache<A::Msg>>,
600 /// Nodo hovereado **persistente** entre frames, actualizado SÓLO en
601 /// `CursorMoved`. Es contra esto que se detecta el `on_pointer_enter`
602 /// (no contra `last_render.hover_idx`, que el render recomputa cada
603 /// cuadro): en una app que re-renderiza sin parar (visores `paint_with`)
604 /// el render "se comería" la transición de hover antes de que el handler
605 /// del mouse la detecte, y el hover-switch de menús no funcionaría.
606 hovered: Option<usize>,
607 /// Drag activo. Mantiene su propio handler clonado del MountedNode
608 /// — así el drag sobrevive aunque el cache se invalide entre
609 /// eventos.
610 drag: Option<DragState<A::Msg>>,
611 /// Foco actual (id de un nodo `View::focusable`). El runtime es la
612 /// única fuente de verdad: lo mueve con Tab/Shift+Tab y click-to-focus
613 /// y lo notifica vía `App::on_focus`. `None` = nada enfocado.
614 focused: Option<u64>,
615 /// Último título dinámico aplicado a la ventana (ver [`App::window_title`]).
616 /// Evita llamar `set_title` en cada frame cuando no cambió.
617 last_title: Option<String>,
618 /// Registro de animaciones implícitas (`View::animated`), vivo entre
619 /// frames. En cada redraw reconcilia el árbol y, si alguna sigue en curso,
620 /// el runtime pide otro frame (ticker autodetenido). Ver
621 /// [`llimphi_compositor::AnimRegistry`].
622 anim_registry: llimphi_compositor::AnimRegistry,
623 /// Registro de animaciones implícitas de **tamaño**
624 /// (`View::animated_size`, Flutter `AnimatedSize`), vivo entre frames.
625 /// A diferencia de [`Self::anim_registry`] que reconcilia props de
626 /// paint DESPUÉS del layout, este reconcilia `style.size`
627 /// **antes** del mount/compute, así siblings/hijos reflowean suave.
628 /// Ver [`llimphi_compositor::SizeAnimRegistry`].
629 size_anim_registry: llimphi_compositor::SizeAnimRegistry,
630 /// Registro de **heroes / shared-element transitions** (`View::hero`),
631 /// vivo entre frames. Detecta cambio de rect de una misma `key` entre
632 /// frames y escribe `transform` para "volar" del rect anterior al actual.
633 /// Ver [`llimphi_compositor::HeroRegistry`].
634 hero_registry: llimphi_compositor::HeroRegistry,
635 /// Adapter [AccessKit](https://accesskit.dev) — empuja un árbol de
636 /// accesibilidad al SO en cada paint para alimentar lectores de pantalla.
637 /// Sólo se inicializa si el SO tiene una tecnología asistiva activa; el
638 /// `update_if_active` evita construir el árbol cuando nadie escucha.
639 a11y_adapter: accesskit_winit::Adapter,
640 /// Identidad estable del árbol de accesibilidad entre `TreeUpdate`s. Se
641 /// genera una vez al crear el runtime y se reutiliza en cada update — los
642 /// lectores la usan para distinguir nuestra ventana de otras del SO.
643 a11y_tree_id: accesskit::TreeId,
644 /// Registro de **ripples/InkWell** (`View::ripple`), vivo entre frames. El
645 /// press dispara una salpicadura; cada redraw la pinta sobre el contenido y,
646 /// mientras alguna siga viva, pide otro frame (ticker autodetenido). Ver
647 /// [`llimphi_compositor::RippleRegistry`].
648 ripple_registry: llimphi_compositor::RippleRegistry,
649 /// Último tap (press izquierdo) sobre un nodo con `on_double_tap`: instante
650 /// + posición. El próximo press que caiga cerca y a tiempo dispara el
651 /// doble-tap. `None` cuando no hay un primer tap pendiente.
652 last_tap: Option<(std::time::Instant, PhysicalPosition<f64>)>,
653 /// Long-press armado (ver [`PendingLongPress`]). El runtime lo vence por
654 /// tiempo en `about_to_wait` y lo cancela en movimiento/release.
655 pending_long_press: Option<PendingLongPress<A::Msg>>,
656 /// **Retención de frame entero**. Tras un paint exitoso, guardamos las
657 /// dimensiones del viewport y los flags de animación del frame. Si en el
658 /// próximo `RedrawRequested` ningún sitio invalidó `last_render` (la
659 /// invariante existente del runtime), el modelo + view + layout son
660 /// idénticos al frame anterior: no hace falta rehacer mount/layout/paint,
661 /// alcanza con re-presentar `state.scene` tal cual quedó. Mata redraws
662 /// espurios (expose del compositor, refocus, ticker en el último frame de
663 /// una anim ya asentada). Si el frame retenido estaba animando o ripplando,
664 /// el ticker NECESITA avanzarlo → no hay retención (cache miss). Tampoco
665 /// hay retención con overlay o drag activos (camino conservador). Ver el
666 /// hit-check en `RedrawRequested`.
667 retained: Option<RetainedScene>,
668 /// Selección de texto activa fuera del editor (drag para resaltar, Ctrl/Cmd+C
669 /// para copiar). `None` = nada seleccionado. Ver [`TextSelection`].
670 selection: Option<TextSelection>,
671}
672
673/// Metadata del frame retenido — qué pintó la `state.scene` para validar que
674/// re-presentarla sin re-pintar es seguro.
675#[derive(Clone, Copy)]
676struct RetainedScene {
677 w: u32,
678 h: u32,
679 animating: bool,
680 rippling: bool,
681 has_overlay: bool,
682}
683
684/// Selección de texto activa fuera del editor (ver [`crate::View::selectable`]).
685/// Anclada a la `key` estable del nodo (no a su `NodeId`, que cambia cada
686/// frame); el runtime reconstruye el `parley::Layout` del nodo bajo esa key
687/// para extender la selección al arrastrar y para pintar el resaltado.
688#[derive(Clone, Copy)]
689struct TextSelection {
690 /// Key estable del nodo seleccionable (`text_select_key`).
691 key: u64,
692 /// Rango seleccionado, en coordenadas de bytes del `parley::Layout`.
693 sel: llimphi_text::parley::Selection,
694 /// `true` mientras el botón izquierdo sigue apretado (arrastrando).
695 dragging: bool,
696}
697
698struct RenderCache<Msg> {
699 mounted: Mounted<Msg>,
700 computed: ComputedLayout,
701 /// Índice del nodo en hover en el frame ya pintado. `None` si el
702 /// cursor no toca ningún `hover_fill`.
703 hover_idx: Option<usize>,
704 /// Índice del drop target hovereado en el frame ya pintado. Solo
705 /// se setea durante un drag activo con `payload` declarado.
706 drop_hover_idx: Option<usize>,
707 /// Capa de overlay (menú contextual, modal). Cuando está presente,
708 /// hover/click/right-click se rutean a ella exclusivamente — el
709 /// árbol principal queda "bajo vidrio" hasta que la app cierre el
710 /// overlay devolviendo `None` desde [`App::view_overlay`].
711 overlay: Option<OverlayCache<Msg>>,
712}
713
714struct OverlayCache<Msg> {
715 mounted: Mounted<Msg>,
716 computed: ComputedLayout,
717 hover_idx: Option<usize>,
718}
719
720/// Tres sabores de handler de drag activo: el simple `(phase, dx, dy)`;
721/// la variante que conserva la posición local del press original
722/// `(phase, dx, dy, lx0, ly0)`; o el handler **con velocidad** que recibe
723/// también `(vx, vy)` al `DragPhase::End` (medida sobre los últimos
724/// [`VELOCITY_WINDOW`] de movimiento). El runtime elige uno al iniciar el
725/// drag — un nodo es uno u otro.
726enum DragHandlerKind<Msg> {
727 Delta(DragFn<Msg>),
728 DeltaAt(DragAtFn<Msg>, f32, f32),
729 Velocity(DragVelocityFn<Msg>),
730}
731
732/// Un handler de gesto "tipo click" (doble-tap / long-press) ya **resuelto**
733/// contra el nodo: o un `Msg` directo, o un handler posicional con la posición
734/// local `(lx, ly, w, h)` ya calculada. Se captura en el press para poder
735/// dispararlo más tarde (long-press, que vence por tiempo) sin volver a tocar
736/// el árbol.
737enum GestureResolved<Msg> {
738 Direct(Msg),
739 At(ClickAtFn<Msg>, f32, f32, f32, f32),
740}
741
742impl<Msg: Clone> GestureResolved<Msg> {
743 /// Materializa el `Msg` (clona el directo o invoca el handler posicional).
744 fn invoke(&self) -> Option<Msg> {
745 match self {
746 GestureResolved::Direct(m) => Some(m.clone()),
747 GestureResolved::At(h, lx, ly, w, ht) => h(*lx, *ly, *w, *ht),
748 }
749 }
750}
751
752/// Long-press **armado**: el press cayó sobre un nodo con `on_long_press`. El
753/// runtime lo dispara cuando pasa `deadline` (en `about_to_wait`), salvo que
754/// antes el cursor se aleje de `origin` (pasó a drag) o se suelte el botón —
755/// en ambos casos se cancela. Es la parte de "arena" del gesto: el árbitro es
756/// el tiempo + el movimiento.
757struct PendingLongPress<Msg> {
758 deadline: std::time::Instant,
759 origin: PhysicalPosition<f64>,
760 handler: GestureResolved<Msg>,
761}
762
763/// Umbral de duración para que un press se convierta en long-press.
764const LONG_PRESS_DELAY: std::time::Duration = std::time::Duration::from_millis(500);
765/// Si el cursor se aleja más que esto (px físicos) del origen del press, deja
766/// de ser long-press (pasó a drag/scroll) y se cancela.
767const LONG_PRESS_MOVE_CANCEL: f64 = 8.0;
768/// Ventana temporal máxima entre los dos taps de un doble-tap.
769const DOUBLE_TAP_WINDOW: std::time::Duration = std::time::Duration::from_millis(400);
770/// Distancia máxima (px físicos) entre los dos taps de un doble-tap.
771const DOUBLE_TAP_DIST: f64 = 16.0;
772
773/// ¿El press actual (`now`, `pos`) completa un doble-tap con el tap previo
774/// `last`? Verdadero si hubo un tap previo dentro de [`DOUBLE_TAP_WINDOW`] y a
775/// menos de [`DOUBLE_TAP_DIST`]. Función pura (testeable sin event loop).
776fn double_tap_qualifies(
777 last: Option<(std::time::Instant, PhysicalPosition<f64>)>,
778 now: std::time::Instant,
779 pos: PhysicalPosition<f64>,
780) -> bool {
781 last.is_some_and(|(t, p)| {
782 now.duration_since(t) <= DOUBLE_TAP_WINDOW
783 && ((p.x - pos.x).powi(2) + (p.y - pos.y).powi(2)).sqrt() <= DOUBLE_TAP_DIST
784 })
785}
786
787struct DragState<Msg> {
788 handler: DragHandlerKind<Msg>,
789 /// Cursor en el último evento (Press o CursorMoved). El delta del
790 /// próximo Move se calcula contra este, no contra el inicio del
791 /// drag — el caller acumula los deltas en su modelo si los necesita.
792 last_cursor: PhysicalPosition<f64>,
793 /// Payload `u64` que viaja con el drag. `None` si el draggable
794 /// origen no declaró ninguno (drag de resize/scroll/etc.). Los drop
795 /// targets sólo reaccionan cuando hay payload.
796 payload: Option<u64>,
797 /// Buffer móvil de (timestamp, dx, dy) por cada `CursorMoved` durante
798 /// el drag, recortado a [`VELOCITY_MAX_SAMPLES`]. Sólo se usa cuando el
799 /// handler es [`DragHandlerKind::Velocity`] — en los otros sabores
800 /// queda vacío. Al `DragPhase::End` el runtime computa la velocidad
801 /// sobre la ventana [`VELOCITY_WINDOW`].
802 samples: std::collections::VecDeque<(std::time::Instant, f64, f64)>,
803}
804
805/// Ventana temporal sobre la que se mide la velocidad de un drag al
806/// soltarlo. Movimientos más viejos no cuentan — sólo importa el último
807/// flick. ~100 ms es el valor que usa Android para fling.
808const VELOCITY_WINDOW: std::time::Duration = std::time::Duration::from_millis(100);
809/// Tope superior de muestras retenidas en el buffer móvil de velocidad —
810/// con eventos típicos de 60–120 Hz, ocho muestras cubren la ventana
811/// holgadamente. Más allá es ruido y costo.
812const VELOCITY_MAX_SAMPLES: usize = 8;
813
814/// Velocidad (px/s) calculada sobre los últimos [`VELOCITY_WINDOW`] de
815/// movimiento. Toma sólo las muestras dentro de la ventana, suma los
816/// deltas y divide por el tiempo transcurrido desde la primera muestra
817/// retenida hasta `now`. Función pura para testear sin event loop.
818fn compute_drag_velocity(
819 samples: &std::collections::VecDeque<(std::time::Instant, f64, f64)>,
820 now: std::time::Instant,
821) -> (f32, f32) {
822 if samples.is_empty() {
823 return (0.0, 0.0);
824 }
825 let cutoff = now.checked_sub(VELOCITY_WINDOW).unwrap_or(now);
826 let recent: Vec<&(std::time::Instant, f64, f64)> =
827 samples.iter().filter(|(t, _, _)| *t >= cutoff).collect();
828 if recent.is_empty() {
829 return (0.0, 0.0);
830 }
831 let t0 = recent[0].0;
832 let dt = now.duration_since(t0).as_secs_f32();
833 if dt < 0.001 {
834 return (0.0, 0.0);
835 }
836 let sum_dx: f64 = recent.iter().map(|(_, dx, _)| *dx).sum();
837 let sum_dy: f64 = recent.iter().map(|(_, _, dy)| *dy).sum();
838 ((sum_dx as f32) / dt, (sum_dy as f32) / dt)
839}
840
841/// Punto de entrada: corre el bucle Elm hasta que el usuario cierre la
842/// ventana (o la app llame [`Handle::quit`]).
843pub fn run<A: App>() {
844 let event_loop = EventLoop::<UserEvent<A::Msg>>::with_user_event()
845 .build()
846 .expect("event loop");
847 event_loop.set_control_flow(ControlFlow::Wait);
848 let handle = Handle {
849 inner: HandleInner::Real(event_loop.create_proxy()),
850 };
851 let mut runtime: Runtime<A> = Runtime {
852 handle,
853 state: None,
854 secondaries: Vec::new(),
855 };
856 event_loop.run_app(&mut runtime).expect("run app");
857}
858
859#[cfg(test)]
860mod tests {
861 use super::*;
862 use std::time::{Duration, Instant};
863
864 #[test]
865 fn lift_aplica_la_funcion_de_elevacion() {
866 use std::sync::{Arc, Mutex};
867 // `lift` aplica la función Sub->Host síncronamente en `dispatch` (el
868 // dispatch al padre Test es no-op, pero la elevación corre): así
869 // observamos que el msg del sub-app se transforma para el host.
870 let seen = Arc::new(Mutex::new(Vec::<i32>::new()));
871 let parent: Handle<i32> = Handle::for_test();
872 let sub: Handle<String> = {
873 let seen = seen.clone();
874 parent.lift(move |s: String| {
875 let n = s.len() as i32;
876 seen.lock().unwrap().push(n);
877 n
878 })
879 };
880 sub.dispatch("hola".to_string());
881 let _ = sub.clone(); // es Clone como cualquier handle
882 assert_eq!(*seen.lock().unwrap(), vec![4]);
883 }
884
885 #[test]
886 fn velocidad_de_drag_promedia_dentro_de_la_ventana() {
887 use std::collections::VecDeque;
888 let now = Instant::now();
889 // Cuatro muestras dentro de la ventana (últimos 80 ms): 4 px en x cada
890 // 20 ms ⇒ 16 px en 80 ms ⇒ 200 px/s.
891 let mut samples: VecDeque<(Instant, f64, f64)> = VecDeque::new();
892 samples.push_back((now - Duration::from_millis(80), 4.0, 0.0));
893 samples.push_back((now - Duration::from_millis(60), 4.0, 0.0));
894 samples.push_back((now - Duration::from_millis(40), 4.0, 0.0));
895 samples.push_back((now - Duration::from_millis(20), 4.0, 0.0));
896 let (vx, vy) = compute_drag_velocity(&samples, now);
897 assert!((vx - 200.0).abs() < 1.0, "vx={vx}");
898 assert!(vy.abs() < 1e-3);
899 // Buffer vacío → (0,0).
900 let empty: VecDeque<(Instant, f64, f64)> = VecDeque::new();
901 assert_eq!(compute_drag_velocity(&empty, now), (0.0, 0.0));
902 // Muestras todas más viejas que VELOCITY_WINDOW → (0,0) (no hay
903 // movimiento reciente para fling).
904 let mut old: VecDeque<(Instant, f64, f64)> = VecDeque::new();
905 old.push_back((now - Duration::from_millis(500), 10.0, 10.0));
906 assert_eq!(compute_drag_velocity(&old, now), (0.0, 0.0));
907 // Eje y positivo (scroll vertical típico): 5 px cada 25 ms ⇒ 200 px/s.
908 let mut vy_samples: VecDeque<(Instant, f64, f64)> = VecDeque::new();
909 vy_samples.push_back((now - Duration::from_millis(75), 0.0, 5.0));
910 vy_samples.push_back((now - Duration::from_millis(50), 0.0, 5.0));
911 vy_samples.push_back((now - Duration::from_millis(25), 0.0, 5.0));
912 let (_, vy) = compute_drag_velocity(&vy_samples, now);
913 assert!((vy - 200.0).abs() < 1.0, "vy={vy}");
914 }
915
916 #[test]
917 fn double_tap_ventana_y_distancia() {
918 let t0 = Instant::now();
919 let p = PhysicalPosition::new(100.0, 100.0);
920 // Sin tap previo → nunca califica.
921 assert!(!double_tap_qualifies(None, t0, p));
922 // Segundo tap a tiempo (100 ms < 400) y cerca (3px < 16) → califica.
923 let near = PhysicalPosition::new(102.0, 102.0);
924 assert!(double_tap_qualifies(
925 Some((t0, p)),
926 t0 + Duration::from_millis(100),
927 near
928 ));
929 // A tiempo pero lejos (>16px) → no.
930 let far = PhysicalPosition::new(140.0, 100.0);
931 assert!(!double_tap_qualifies(
932 Some((t0, p)),
933 t0 + Duration::from_millis(100),
934 far
935 ));
936 // Cerca pero tarde (>400 ms) → no.
937 assert!(!double_tap_qualifies(
938 Some((t0, p)),
939 t0 + Duration::from_millis(600),
940 near
941 ));
942 }
943}