tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Definitions

⎇ e74800d9dab0bd8fcc82d36f776628ad45cedfd3 llimphi-ui/src/lib.rs Rust · 604 lines · 24.8 KB History Who touched what raw

feat: llimphi standalone — framework UI soberano extraído del monorepo e65e9cc62 · Sergio ·

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
15use llimphi_hal::winit::application::ApplicationHandler;
16use llimphi_hal::winit::dpi::{LogicalSize, PhysicalPosition};
17use llimphi_hal::winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
18use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy};
19use llimphi_hal::winit::keyboard::ModifiersState;
20use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId};
21use llimphi_hal::{Hal, Surface, WinitSurface};
22
23pub use llimphi_hal::winit::keyboard::{Key, NamedKey};
24use llimphi_layout::{ComputedLayout, LayoutTree};
25use llimphi_raster::peniko::color::palette;
26use llimphi_raster::{vello, Renderer};
27
28pub use llimphi_hal;
29pub use llimphi_layout;
30pub use llimphi_raster;
31pub use llimphi_text;
32
33// El compositor declarativo (View, mount, paint, hit-test, tipos de
34// handler) se re-exporta entero: `llimphi_ui::View`, `llimphi_ui::DragFn`,
35// etc. siguen resolviendo igual que antes del split.
36pub use llimphi_compositor;
37pub use llimphi_compositor::*;
38
39/// Aplicación Elm: estado inmutable, transición pura, vista pura.
40///
41/// `init` y `update` reciben un [`Handle`] que permite hablar con el runtime
42/// desde dentro de la transición (cerrar la ventana, lanzar trabajo en otro
43/// hilo y reentrar con un Msg al terminar). Mantener la transición pura del
44/// modelo sigue siendo el contrato — `Handle` sólo escala efectos.
45pub trait App: 'static {
46    type Model: 'static;
47    type Msg: Clone + Send + 'static;
48
49    fn init(handle: &Handle<Self::Msg>) -> Self::Model;
50    fn update(model: Self::Model, msg: Self::Msg, handle: &Handle<Self::Msg>) -> Self::Model;
51    fn view(model: &Self::Model) -> View<Self::Msg>;
52
53    /// Maneja una pulsación de tecla. Devuelve `Some(Msg)` para disparar
54    /// una transición; `None` (default) ignora la tecla.
55    fn on_key(_model: &Self::Model, _event: &KeyEvent) -> Option<Self::Msg> {
56        None
57    }
58
59    /// El foco cambió: el runtime movió el foco a `id` (`None` = nada
60    /// enfocado). Pasa al pulsar Tab/Shift+Tab (recorre los nodos
61    /// `View::focusable` en orden de árbol, envolviendo) o al clickear un
62    /// nodo enfocable. La app guarda `id` en su `Model` para (a) pintar el
63    /// focus-ring (`if model.focus == Some(id) { … }` en `view`) y (b)
64    /// rutear el teclado al campo activo desde `on_key`. Devolver
65    /// `Some(Msg)` dispara una transición; `None` (default) ignora.
66    ///
67    /// El foco lo administra el runtime (única fuente de verdad), así que
68    /// Tab y click-to-focus quedan consistentes sin que la app los cablee.
69    fn on_focus(_model: &Self::Model, _id: Option<u64>) -> Option<Self::Msg> {
70        None
71    }
72
73    /// ¿Habilitar IME (input method editor) en esta ventana? Default
74    /// `false`. Con IME activo, el texto compuesto (CJK, acentos muertos,
75    /// emoji picker) llega por [`App::on_ime`] como `Commit`, **no** por
76    /// `KeyEvent.text` — por eso es opt-in: las apps que sólo leen
77    /// `on_key` siguen funcionando igual. Las que editan texto
78    /// (`text-input`, `text-editor`) la activan e implementan `on_ime`.
79    fn ime_allowed() -> bool {
80        false
81    }
82
83    /// Maneja un evento de IME (sólo llega si [`App::ime_allowed`] es
84    /// `true`). El flujo típico: `Enabled` → uno o más `Preedit` (texto en
85    /// composición, a pintar subrayado en el caret) → `Commit(texto)` (el
86    /// texto final, a insertar como si se hubiera tecleado) o `Disabled`.
87    /// El `Preedit` no es definitivo: cada uno reemplaza al anterior, y un
88    /// `Commit` o `Preedit` vacío lo cierra. Devolver `Some(Msg)` dispara
89    /// una transición.
90    fn on_ime(_model: &Self::Model, _event: &ImeEvent) -> Option<Self::Msg> {
91        None
92    }
93
94    /// Área del caret en **píxeles físicos** `(x, y, w, h)` para posicionar
95    /// la ventana de candidatos del IME (CJK) junto al cursor de texto. El
96    /// runtime la consulta por frame cuando [`App::ime_allowed`] es `true`.
97    /// `None` (default) deja que el sistema la ubique por defecto.
98    fn ime_cursor_area(_model: &Self::Model) -> Option<(f32, f32, f32, f32)> {
99        None
100    }
101
102    /// Maneja una rueda del mouse. `delta` está normalizado a "líneas"
103    /// (positivo arriba/izquierda, negativo abajo/derecha). En backends
104    /// que reportan píxeles, llimphi-ui divide por 20 para aproximar.
105    fn on_wheel(
106        _model: &Self::Model,
107        _delta: WheelDelta,
108        _cursor: (f32, f32),
109        _modifiers: Modifiers,
110    ) -> Option<Self::Msg> {
111        None
112    }
113
114    /// Capa de overlay opcional. Si devuelve `Some(view)`, el runtime
115    /// la pinta encima del árbol principal y los clicks/hover se
116    /// rutean exclusivamente a ella (el árbol de fondo queda "bajo
117    /// vidrio" hasta que se cierre el overlay). Pensado para menús
118    /// contextuales, diálogos modales, popovers — el patrón usual es
119    /// envolver los items en un scrim a pantalla completa con
120    /// `on_click = DismissOverlay` para que los clicks afuera lo
121    /// cierren.
122    ///
123    /// La transición entre "con overlay" y "sin overlay" la maneja la
124    /// app vía su Model: cuando el state diga "menu abierto",
125    /// `view_overlay` devuelve `Some`; cuando se cierre, `None`.
126    fn view_overlay(_model: &Self::Model) -> Option<View<Self::Msg>> {
127        None
128    }
129
130    /// Maneja un drop de archivo desde el sistema operativo (drag&drop
131    /// desde el file manager hacia la ventana). El runtime invoca este
132    /// callback una vez por archivo soltado — si el usuario suelta varios,
133    /// llega un evento por path. Devolver `Some(Msg)` dispara un update;
134    /// `None` (default) ignora el drop.
135    ///
136    /// Backend: mapea directamente `winit::WindowEvent::DroppedFile(PathBuf)`.
137    /// La posición del drop no se reporta porque winit no la expone hasta
138    /// que el compositor la propague — en Wayland depende del extension
139    /// `data_device_manager`, en X11 viene en el ClientMessage XDND.
140    fn on_file_drop(_model: &Self::Model, _path: std::path::PathBuf) -> Option<Self::Msg> {
141        None
142    }
143
144    /// Maneja un redimensionado de la ventana. `width`/`height` son el
145    /// nuevo tamaño en **píxeles físicos** (lo que reporta
146    /// `winit::WindowEvent::Resized` y lo que recibe la surface). El
147    /// runtime ya reconfiguró la surface y pedirá redraw; este callback
148    /// es para que la app reaccione al nuevo viewport (recalcular layout
149    /// dependiente del tamaño, emitir un evento `resize`, etc.).
150    /// Devolver `Some(Msg)` dispara un update; `None` (default) lo ignora.
151    fn on_resize(_model: &Self::Model, _width: u32, _height: u32) -> Option<Self::Msg> {
152        None
153    }
154
155    /// Maneja un cambio del factor de escala de la ventana (`scale_factor`
156    /// de winit: 1.0 en pantallas normales, 2.0 en HiDPI/Retina, fraccional
157    /// con escalado del compositor). El runtime lo invoca una vez al arrancar
158    /// (con el factor inicial de la ventana, tras `init`) y luego en cada
159    /// `WindowEvent::ScaleFactorChanged` (mover la ventana entre monitores,
160    /// cambiar el escalado del sistema). Es lo que permite, p. ej., que
161    /// `window.devicePixelRatio` refleje el DPI real. Devolver `Some(Msg)`
162    /// dispara un update; `None` (default) lo ignora.
163    fn on_scale_factor(_model: &Self::Model, _scale: f64) -> Option<Self::Msg> {
164        None
165    }
166
167    /// Título de la ventana (sólo se lee al arrancar). Es el título inicial;
168    /// para uno que cambie en runtime, ver [`App::window_title`].
169    fn title() -> &'static str {
170        "llimphi"
171    }
172
173    /// Título **dinámico** de la ventana, derivado del modelo. El runtime lo
174    /// consulta tras cada render y, si cambió, lo aplica con `Window::set_title`
175    /// — así el título de la barra del SO puede reflejar el estado (p. ej. el
176    /// medio que se reproduce). `None` (default) deja el título fijo de
177    /// [`App::title`]; una app que no lo implemente no paga nada.
178    fn window_title(_model: &Self::Model) -> Option<String> {
179        None
180    }
181
182    /// Vista de una ventana OS **secundaria** identificada por `key` (la que
183    /// se pasó a [`Handle::open_window`]). El runtime la pinta en su propia
184    /// ventana y rutea sus eventos al mismo [`App::update`] — comparte modelo
185    /// con la primaria. `None` (default, o para una key desconocida) deja la
186    /// ventana en blanco. Las secundarias NO tienen capa de overlay
187    /// ([`App::view_overlay`] es sólo de la primaria); para diálogos dentro de
188    /// una secundaria, componerlos en su propio `secondary_view`.
189    fn secondary_view(_model: &Self::Model, _key: u64) -> Option<View<Self::Msg>> {
190        None
191    }
192
193    /// Título dinámico de una ventana secundaria (análogo a
194    /// [`App::window_title`] para la primaria). `None` deja el título con el
195    /// que se abrió.
196    fn secondary_title(_model: &Self::Model, _key: u64) -> Option<String> {
197        None
198    }
199
200    /// El usuario cerró una ventana secundaria con el botón del SO. El runtime
201    /// ya la destruyó; este callback es para que la app sincronice su modelo
202    /// (p. ej. marcar el panel como cerrado). Devolver `Some(Msg)` dispara un
203    /// `update`; `None` (default) no hace nada.
204    fn on_secondary_close(_model: &Self::Model, _key: u64) -> Option<Self::Msg> {
205        None
206    }
207
208    /// Identificador de aplicación. En Wayland se mapea al `app_id` del
209    /// xdg-toplevel (lo que el compositor usa para reconocer la ventana,
210    /// p. ej. `carmen.greeter`). `None` deja que el sistema asigne uno.
211    fn app_id() -> Option<&'static str> {
212        None
213    }
214
215    /// Tamaño lógico inicial de la ventana, en píxeles. El usuario puede
216    /// redimensionar después; sólo se lee al arrancar.
217    fn initial_size() -> (u32, u32) {
218        (960, 540)
219    }
220}
221
222/// Mensaje interno del event loop. `Msg` lo dispara la app desde un hilo de
223/// fondo vía [`Handle::dispatch`] o [`Handle::spawn`]; `Quit` cierra la
224/// ventana y termina el proceso.
225pub enum UserEvent<Msg> {
226    Msg(Msg),
227    Quit,
228    /// Pide abrir una ventana OS **secundaria** con la `key` dada (la app la
229    /// usa para distinguir cuál es en [`App::secondary_view`]). Idempotente:
230    /// si ya existe una con esa key, se enfoca en vez de duplicar. La crea el
231    /// event loop (que tiene el `ActiveEventLoop`); por eso va por mensaje.
232    OpenWindow {
233        key: u64,
234        title: String,
235        width: u32,
236        height: u32,
237    },
238    /// Pide cerrar la ventana secundaria con esa `key`. No afecta a la primaria.
239    CloseWindow { key: u64 },
240}
241
242/// Asa al runtime de Llimphi. Clonable y enviable entre hilos: la usás para
243/// pedir cerrar la ventana o para lanzar trabajo (PAM, IO, etc.) que al
244/// terminar reentra con un Msg al `update`.
245///
246/// Tests pueden construir un handle "muerto" con [`Handle::for_test`]: los
247/// `dispatch`/`quit`/`spawn` siguen siendo seguros de llamar pero los
248/// `Msg` que generan no van a ningún lado (no hay event loop detrás).
249pub struct Handle<Msg: Send + 'static> {
250    inner: HandleInner<Msg>,
251}
252
253enum HandleInner<Msg: Send + 'static> {
254    Real(EventLoopProxy<UserEvent<Msg>>),
255    /// Handle de tests: drop silencioso de todos los dispatches. Permite
256    /// llamar funciones que toman `&Handle<Msg>` sin levantar un event
257    /// loop real (que en CI sin display tiraría).
258    Test,
259}
260
261impl<Msg: Send + 'static> Clone for Handle<Msg> {
262    fn clone(&self) -> Self {
263        Self {
264            inner: match &self.inner {
265                HandleInner::Real(p) => HandleInner::Real(p.clone()),
266                HandleInner::Test => HandleInner::Test,
267            },
268        }
269    }
270}
271
272impl<Msg: Send + 'static> Handle<Msg> {
273    /// Construye un handle desactivado para tests — todos los dispatch
274    /// se descartan silenciosamente. Útil para probar funciones que toman
275    /// `&Handle<Msg>` sin levantar un event loop real (que en CI sin
276    /// display tiraría).
277    pub fn for_test() -> Self {
278        Self {
279            inner: HandleInner::Test,
280        }
281    }
282
283    /// Cierra la ventana y termina el bucle. La transición en curso (si la
284    /// hay) se completa antes de salir.
285    pub fn quit(&self) {
286        match &self.inner {
287            HandleInner::Real(p) => {
288                let _ = p.send_event(UserEvent::Quit);
289            }
290            HandleInner::Test => {}
291        }
292    }
293
294    /// Abre una ventana OS **secundaria** (ver [`App::secondary_view`]). La
295    /// `key` la elige la app para reconocerla luego; abrir con una key que ya
296    /// existe sólo la enfoca (no duplica). El contenido lo pinta
297    /// `App::secondary_view(model, key)` y los eventos (click/tecla/…) reentran
298    /// al mismo `update`, así que la ventana comparte el modelo con la primaria.
299    /// Cerrala con [`Self::close_window`] o con el botón del SO.
300    pub fn open_window(&self, key: u64, title: impl Into<String>, width: u32, height: u32) {
301        if let HandleInner::Real(p) = &self.inner {
302            let _ = p.send_event(UserEvent::OpenWindow {
303                key,
304                title: title.into(),
305                width,
306                height,
307            });
308        }
309    }
310
311    /// Cierra la ventana secundaria con esa `key` (no-op si no existe). La
312    /// ventana primaria nunca se cierra por acá — para eso está [`Self::quit`].
313    pub fn close_window(&self, key: u64) {
314        if let HandleInner::Real(p) = &self.inner {
315            let _ = p.send_event(UserEvent::CloseWindow { key });
316        }
317    }
318
319    /// Encola un Msg para procesarse en el próximo turno del bucle. Útil
320    /// para que un callback externo reentre al update.
321    pub fn dispatch(&self, msg: Msg) {
322        match &self.inner {
323            HandleInner::Real(p) => {
324                let _ = p.send_event(UserEvent::Msg(msg));
325            }
326            HandleInner::Test => {}
327        }
328    }
329
330    /// Lanza una closure en un hilo aparte; cuando devuelve `Msg`, el
331    /// runtime la entrega al `update` en el hilo de UI. Pensado para
332    /// trabajo bloqueante (PAM tarda ~2 s ante un fallo, p. ej.).
333    pub fn spawn<F>(&self, f: F)
334    where
335        F: FnOnce() -> Msg + Send + 'static,
336    {
337        match &self.inner {
338            HandleInner::Real(p) => {
339                let proxy = p.clone();
340                std::thread::spawn(move || {
341                    let msg = f();
342                    let _ = proxy.send_event(UserEvent::Msg(msg));
343                });
344            }
345            HandleInner::Test => {
346                // Corremos la closure igual (para no perder side-effects de
347                // tests que dependan de su side) pero el msg se descarta.
348                std::thread::spawn(move || {
349                    let _ = f();
350                });
351            }
352        }
353    }
354
355    /// Lanza un loop periódico en un hilo aparte: cada `period` invoca
356    /// `f()` y dispatcha el `Msg` resultante al `update`. El thread
357    /// queda corriendo hasta que el event loop se cierra (en ese
358    /// punto el `send_event` falla silenciosamente y el thread spinea
359    /// hasta el exit del proceso, costo despreciable).
360    ///
361    /// Útil para ticks de simulación (~11 Hz en dominium), polling de
362    /// hardware, o cualquier feed que necesite Msgs a intervalos
363    /// regulares. Si `f` necesita state, capturalo en la closure por
364    /// move; la closure se ejecuta en un thread aparte así que el
365    /// state capturado debe ser `Send`.
366    pub fn spawn_periodic<F>(&self, period: std::time::Duration, f: F)
367    where
368        F: Fn() -> Msg + Send + 'static,
369    {
370        match &self.inner {
371            HandleInner::Real(p) => {
372                let proxy = p.clone();
373                std::thread::spawn(move || loop {
374                    std::thread::sleep(period);
375                    if proxy.send_event(UserEvent::Msg(f())).is_err() {
376                        // Event loop cerrado — el thread puede morir.
377                        break;
378                    }
379                });
380            }
381            HandleInner::Test => {
382                // Un thread vivo eternamente sin sumidero ni manera de
383                // pararlo sería un leak — en for_test simplemente no
384                // arrancamos el loop. Los tests que necesiten verificar
385                // periodic behaviour deben usar el callback directo.
386                let _ = f;
387            }
388        }
389    }
390}
391
392/// Evento de teclado normalizado.
393#[derive(Debug, Clone)]
394pub struct KeyEvent {
395    pub key: Key,
396    pub state: KeyState,
397    /// Texto resultante (con modifiers e IME aplicados). Útil para inserción
398    /// directa; `None` para teclas que no producen texto (flechas, etc.).
399    pub text: Option<String>,
400    pub modifiers: Modifiers,
401    pub repeat: bool,
402}
403
404#[derive(Debug, Clone, Copy, PartialEq, Eq)]
405pub enum KeyState {
406    Pressed,
407    Released,
408}
409
410/// Evento de IME normalizado (espeja `winit::event::Ime`). Ver
411/// [`App::on_ime`] para el flujo Enabled → Preedit* → Commit/Disabled.
412#[derive(Debug, Clone, PartialEq, Eq)]
413pub enum ImeEvent {
414    /// El IME se activó para esta ventana.
415    Enabled,
416    /// Texto en composición (aún no confirmado). `cursor` es el rango
417    /// `(inicio, fin)` en bytes a resaltar dentro de `text`, si el IME lo
418    /// reporta. Cada `Preedit` reemplaza al anterior; uno con `text`
419    /// vacío cierra la preedición sin confirmar.
420    Preedit {
421        text: String,
422        cursor: Option<(usize, usize)>,
423    },
424    /// Texto confirmado: insertarlo como si se hubiera tecleado.
425    Commit(String),
426    /// El IME se desactivó (perder foco, cambiar de método).
427    Disabled,
428}
429
430#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
431pub struct Modifiers {
432    pub shift: bool,
433    pub ctrl: bool,
434    pub alt: bool,
435    pub meta: bool,
436}
437
438/// Delta de rueda en "líneas" lógicas (normalizado a través de backends).
439/// Convención CSS: positivo = scroll **hacia abajo** (contenido sube).
440/// `x` similar para scroll horizontal (touchpads, ratones de 2 ejes).
441#[derive(Debug, Clone, Copy, Default)]
442pub struct WheelDelta {
443    pub x: f32,
444    pub y: f32,
445}
446
447impl From<ModifiersState> for Modifiers {
448    fn from(m: ModifiersState) -> Self {
449        Self {
450            shift: m.shift_key(),
451            ctrl: m.control_key(),
452            alt: m.alt_key(),
453            meta: m.super_key(),
454        }
455    }
456}
457
458// --- Runtime winit. El event loop (impl ApplicationHandler) vive en
459// `eventloop` y accede los campos privados de estos structs vía
460// `use super::*`. La composición declarativa (View, mount, paint,
461// hit-test) la trae el re-export de `llimphi_compositor`. ---
462mod eventloop;
463
464struct Runtime<A: App> {
465    handle: Handle<A::Msg>,
466    state: Option<RuntimeState<A>>,
467    /// Ventanas OS secundarias abiertas (opt-in vía [`Handle::open_window`]).
468    /// Comparten el `Hal`/`Renderer` y el modelo de la primaria (`state`);
469    /// cada una lleva su propia surface + caches de interacción. Vacío en la
470    /// inmensa mayoría de las apps (monoventana) — coste cero.
471    secondaries: Vec<SecondaryState<A>>,
472}
473
474/// Estado por **ventana secundaria**. Espeja los campos de interacción de
475/// [`RuntimeState`] pero SIN modelo (vive en la primaria), sin overlay y sin
476/// `Hal`/`Renderer` propios (los toma prestados de la primaria al pintar).
477struct SecondaryState<A: App> {
478    /// La key con la que la app la abrió (la pasa a `secondary_view`).
479    key: u64,
480    window: Arc<Window>,
481    surface: WinitSurface,
482    scene: vello::Scene,
483    typesetter: llimphi_text::Typesetter,
484    layout: LayoutTree,
485    cursor: PhysicalPosition<f64>,
486    modifiers: Modifiers,
487    last_render: Option<SecRenderCache<A::Msg>>,
488    hovered: Option<usize>,
489    drag: Option<DragState<A::Msg>>,
490    last_title: Option<String>,
491}
492
493/// Cache de render de una ventana secundaria (como [`RenderCache`] pero sin
494/// capa de overlay). Sólo guarda el árbol montado + layout para hit-testear el
495/// próximo click/hover; el `hover_idx` actual vive en `SecondaryState::hovered`.
496struct SecRenderCache<Msg> {
497    mounted: Mounted<Msg>,
498    computed: ComputedLayout,
499}
500
501struct RuntimeState<A: App> {
502    window: Arc<Window>,
503    hal: Hal,
504    surface: WinitSurface,
505    renderer: Renderer,
506    scene: vello::Scene,
507    /// Compositor de la capa de overlay sobre contenido `gpu_paint` (video).
508    /// Sólo entra en juego cuando el árbol principal tiene painters gpu y hay
509    /// un overlay activo; resuelve el z-order (menús por encima del video).
510    overlay_compositor: llimphi_hal::OverlayCompositor,
511    model: Option<A::Model>,
512    cursor: PhysicalPosition<f64>,
513    modifiers: Modifiers,
514    typesetter: llimphi_text::Typesetter,
515    /// Árboles de layout reusados entre frames: `clear()` + `mount` en
516    /// vez de re-allocar el slotmap de taffy en cada redraw. Uno para el
517    /// árbol principal, otro para el overlay (sus `NodeId` no deben
518    /// colisionar dentro del mismo frame).
519    layout: LayoutTree,
520    overlay_layout: LayoutTree,
521    /// Último frame renderizado: árbol montado + rects absolutos +
522    /// nodo con hover. Lo consume el handler de click para hit-testear
523    /// sin reconstruir `view` + layout, y CursorMoved para detectar si
524    /// el hover cambió y disparar redraw.
525    last_render: Option<RenderCache<A::Msg>>,
526    /// Nodo hovereado **persistente** entre frames, actualizado SÓLO en
527    /// `CursorMoved`. Es contra esto que se detecta el `on_pointer_enter`
528    /// (no contra `last_render.hover_idx`, que el render recomputa cada
529    /// cuadro): en una app que re-renderiza sin parar (visores `paint_with`)
530    /// el render "se comería" la transición de hover antes de que el handler
531    /// del mouse la detecte, y el hover-switch de menús no funcionaría.
532    hovered: Option<usize>,
533    /// Drag activo. Mantiene su propio handler clonado del MountedNode
534    /// — así el drag sobrevive aunque el cache se invalide entre
535    /// eventos.
536    drag: Option<DragState<A::Msg>>,
537    /// Foco actual (id de un nodo `View::focusable`). El runtime es la
538    /// única fuente de verdad: lo mueve con Tab/Shift+Tab y click-to-focus
539    /// y lo notifica vía `App::on_focus`. `None` = nada enfocado.
540    focused: Option<u64>,
541    /// Último título dinámico aplicado a la ventana (ver [`App::window_title`]).
542    /// Evita llamar `set_title` en cada frame cuando no cambió.
543    last_title: Option<String>,
544}
545
546struct RenderCache<Msg> {
547    mounted: Mounted<Msg>,
548    computed: ComputedLayout,
549    /// Índice del nodo en hover en el frame ya pintado. `None` si el
550    /// cursor no toca ningún `hover_fill`.
551    hover_idx: Option<usize>,
552    /// Índice del drop target hovereado en el frame ya pintado. Solo
553    /// se setea durante un drag activo con `payload` declarado.
554    drop_hover_idx: Option<usize>,
555    /// Capa de overlay (menú contextual, modal). Cuando está presente,
556    /// hover/click/right-click se rutean a ella exclusivamente — el
557    /// árbol principal queda "bajo vidrio" hasta que la app cierre el
558    /// overlay devolviendo `None` desde [`App::view_overlay`].
559    overlay: Option<OverlayCache<Msg>>,
560}
561
562struct OverlayCache<Msg> {
563    mounted: Mounted<Msg>,
564    computed: ComputedLayout,
565    hover_idx: Option<usize>,
566}
567
568/// Dos sabores de handler de drag activo: el simple `(phase, dx, dy)`
569/// o la variante que conserva la posición local del press original
570/// `(phase, dx, dy, lx0, ly0)`. El runtime elige uno al iniciar el drag.
571enum DragHandlerKind<Msg> {
572    Delta(DragFn<Msg>),
573    DeltaAt(DragAtFn<Msg>, f32, f32),
574}
575
576struct DragState<Msg> {
577    handler: DragHandlerKind<Msg>,
578    /// Cursor en el último evento (Press o CursorMoved). El delta del
579    /// próximo Move se calcula contra este, no contra el inicio del
580    /// drag — el caller acumula los deltas en su modelo si los necesita.
581    last_cursor: PhysicalPosition<f64>,
582    /// Payload `u64` que viaja con el drag. `None` si el draggable
583    /// origen no declaró ninguno (drag de resize/scroll/etc.). Los drop
584    /// targets sólo reaccionan cuando hay payload.
585    payload: Option<u64>,
586}
587
588/// Punto de entrada: corre el bucle Elm hasta que el usuario cierre la
589/// ventana (o la app llame [`Handle::quit`]).
590pub fn run<A: App>() {
591    let event_loop = EventLoop::<UserEvent<A::Msg>>::with_user_event()
592        .build()
593        .expect("event loop");
594    event_loop.set_control_flow(ControlFlow::Wait);
595    let handle = Handle {
596        inner: HandleInner::Real(event_loop.create_proxy()),
597    };
598    let mut runtime: Runtime<A> = Runtime {
599        handle,
600        state: None,
601        secondaries: Vec::new(),
602    };
603    event_loop.run_app(&mut runtime).expect("run app");
604}