Skip to main content

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}