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

⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1 llimphi-ui/src/lib.rs Rust · 1811 lines · 86.7 KB History Who touched what raw

chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos b3f12e136 · 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::atomic::{AtomicBool, Ordering};
14use std::sync::Arc;
15
16/// El reloj monotónico del runtime. **En cualquier target nativo esto ES
17/// `std::time::Instant`** — `web_time` lo re-exporta tal cual, sin envoltorio
18/// ni coste. En wasm32 se apoya en `performance.now()`, porque allí
19/// `std::time::Instant::now()` no está implementado y panica en runtime.
20///
21/// Todo el crate lo usa (`crate::Instant`) en vez de `std::time::Instant`: los
22/// deadlines de key-repeat y long-press terminan en
23/// `ControlFlow::WaitUntil`, que winit tipa justamente con este `Instant`
24/// cuando compila para web. Un `std::time::Instant` ahí no compila — y aunque
25/// compilara, `now()` reventaría en el navegador.
26pub use web_time::Instant;
27
28pub mod a11y;
29/// Contador de asignaciones de heap para el perfilador (`LLIMPHI_PERF`). Ver
30/// [`alloc`] para las dos formas de instalarlo (feature `perf-alloc` o
31/// `#[global_allocator]` a mano).
32pub mod alloc;
33/// Censo del reparto `View`/`ViewRare` contra el árbol de una app real (Bloque
34/// 21, palanca 2b). Headless: ni ventana ni GPU. Ver [`censo::censo_app`].
35pub mod censo;
36pub use censo::censo_app;
37
38/// La pista `wp_content_type_v1` (ver [`Handle::set_content_type`]).
39mod content_type;
40/// El puente `wl_data_device`: soltar archivos bajo Wayland, que winit no
41/// implementa en ese backend. Ver el encabezado del módulo.
42mod file_drop;
43/// El sentido de la rueda (ratón / touchpad) de la sesión. Ver el encabezado
44/// del módulo: quién lo aplica y por qué no pueden ser dos.
45pub mod rueda;
46pub use rueda::sembrar_politica_rueda;
47
48use llimphi_hal::winit::application::ApplicationHandler;
49use llimphi_hal::winit::dpi::{LogicalSize, PhysicalPosition};
50use llimphi_hal::winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
51use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy};
52use llimphi_hal::winit::keyboard::ModifiersState;
53use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId};
54use llimphi_hal::{Hal, Surface, WinitSurface};
55
56// `Key`/`NamedKey` ya NO son los de winit: son los del modelo de input del
57// compositor (W4.7) — mismos nombres y variantes que el subconjunto que el
58// repo usaba, así que ningún `match` cambia. Llegan por el glob
59// `pub use llimphi_compositor::*` de abajo; la conversión desde winit vive en
60// `eventloop::input_winit`.
61use llimphi_layout::{ComputedLayout, LayoutTree};
62use llimphi_raster::peniko::color::palette;
63use llimphi_raster::{vello, Escena, Renderer};
64
65pub use llimphi_hal;
66pub use llimphi_layout;
67pub use llimphi_raster;
68pub use llimphi_text;
69
70// El compositor declarativo (View, mount, paint, hit-test, tipos de
71// handler) se re-exporta entero: `llimphi_ui::View`, `llimphi_ui::DragFn`,
72// etc. siguen resolviendo igual que antes del split.
73pub use llimphi_compositor;
74pub use llimphi_compositor::*;
75
76/// Sustituto de `llimphi_compositor::paint_gpu` cuando se rasteriza con
77/// `hybrid`. **Siempre dice que no pintó nada**, y eso es la verdad.
78///
79/// El hook de GPU directa (`View::gpu_paint_with`) deja que un nodo emita sus
80/// propias pasadas de wgpu ENCIMA de lo que ya rasterizó vello, sobre la misma
81/// textura intermedia. Vive en la feature `gpu` del compositor, que implica el
82/// backend vello — no por capricho de features, sino porque el hook está
83/// definido en términos de una `vello::Scene` ya pintada, que en este perfil no
84/// existe: acá el compositor graba una `Escena` y el rasterizado ocurre después.
85///
86/// Que sea un `fn` que devuelve `false` en vez de tres `#[cfg]` en los sitios de
87/// llamada es a propósito: el orden de las pasadas del fotograma —base, gpu,
88/// over, overlay— queda escrito UNA sola vez y se lee igual con los dos
89/// backends. Con la bandera en `false`, las ramas que dependen del pase GPU se
90/// saltean solas.
91///
92/// Consecuencia para quien elija este perfil: las apps que usan `gpu_paint_with`
93/// —el motor voxel, dominium— no pueden compilarse con `hybrid`. Está dicho
94/// también en el `Cargo.toml`, donde se elige.
95#[cfg(feature = "hybrid")]
96pub(crate) fn paint_gpu<Msg>(
97    _mounted: &Mounted<Msg>,
98    _computed: &llimphi_layout::ComputedLayout,
99    _device: &llimphi_hal::wgpu::Device,
100    _queue: &llimphi_hal::wgpu::Queue,
101    _encoder: &mut llimphi_hal::wgpu::CommandEncoder,
102    _view: &llimphi_hal::wgpu::TextureView,
103    _viewport: (u32, u32),
104) -> bool {
105    false
106}
107
108/// Aplicación Elm: estado inmutable, transición pura, vista pura.
109///
110/// `init` y `update` reciben un [`Handle`] que permite hablar con el runtime
111/// desde dentro de la transición (cerrar la ventana, lanzar trabajo en otro
112/// hilo y reentrar con un Msg al terminar). Mantener la transición pura del
113/// modelo sigue siendo el contrato — `Handle` sólo escala efectos.
114pub trait App: 'static {
115    type Model: 'static;
116    type Msg: Clone + Send + 'static;
117
118    fn init(handle: &Handle<Self::Msg>) -> Self::Model;
119    fn update(model: Self::Model, msg: Self::Msg, handle: &Handle<Self::Msg>) -> Self::Model;
120    fn view(model: &Self::Model) -> View<Self::Msg>;
121
122    /// Maneja una pulsación de tecla. Devuelve `Some(Msg)` para disparar
123    /// una transición; `None` (default) ignora la tecla.
124    fn on_key(_model: &Self::Model, _event: &KeyEvent) -> Option<Self::Msg> {
125        None
126    }
127
128    /// El foco cambió: el runtime movió el foco a `id` (`None` = nada
129    /// enfocado). Pasa al pulsar Tab/Shift+Tab (recorre los nodos
130    /// `View::focusable` en orden de árbol, envolviendo) o al clickear un
131    /// nodo enfocable. La app guarda `id` en su `Model` para (a) pintar el
132    /// focus-ring (`if model.focus == Some(id) { … }` en `view`) y (b)
133    /// rutear el teclado al campo activo desde `on_key`. Devolver
134    /// `Some(Msg)` dispara una transición; `None` (default) ignora.
135    ///
136    /// El foco lo administra el runtime (única fuente de verdad), así que
137    /// Tab y click-to-focus quedan consistentes sin que la app los cablee.
138    fn on_focus(_model: &Self::Model, _id: Option<u64>) -> Option<Self::Msg> {
139        None
140    }
141
142    /// ¿Habilitar IME (input method editor) en esta ventana? Default
143    /// `false`. Con IME activo, el texto compuesto (CJK, acentos muertos,
144    /// emoji picker) llega por [`App::on_ime`] como `Commit`, **no** por
145    /// `KeyEvent.text` — por eso es opt-in: las apps que sólo leen
146    /// `on_key` siguen funcionando igual. Las que editan texto
147    /// (`text-input`, `text-editor`) la activan e implementan `on_ime`.
148    fn ime_allowed() -> bool {
149        false
150    }
151
152    /// Maneja un evento de IME (sólo llega si [`App::ime_allowed`] es
153    /// `true`). El flujo típico: `Enabled` → uno o más `Preedit` (texto en
154    /// composición, a pintar subrayado en el caret) → `Commit(texto)` (el
155    /// texto final, a insertar como si se hubiera tecleado) o `Disabled`.
156    /// El `Preedit` no es definitivo: cada uno reemplaza al anterior, y un
157    /// `Commit` o `Preedit` vacío lo cierra. Devolver `Some(Msg)` dispara
158    /// una transición.
159    fn on_ime(_model: &Self::Model, _event: &ImeEvent) -> Option<Self::Msg> {
160        None
161    }
162
163    /// Área del caret en **píxeles físicos** `(x, y, w, h)` para posicionar
164    /// la ventana de candidatos del IME (CJK) junto al cursor de texto. El
165    /// runtime la consulta por frame cuando [`App::ime_allowed`] es `true`.
166    /// `None` (default) deja que el sistema la ubique por defecto.
167    fn ime_cursor_area(_model: &Self::Model) -> Option<(f32, f32, f32, f32)> {
168        None
169    }
170
171    /// ¿En qué **monitor** quiere estar la superficie? Devuelve el nombre del
172    /// conector (`"DP-1"`, `"eDP-1"`), o `None` (default) para dejar que el
173    /// compositor elija — que es lo que pasaba siempre.
174    ///
175    /// Sólo lo mira el backend **layer-shell** ([`llimphi_layer`](https://docs.rs/llimphi-layer)),
176    /// y lo consulta por cuadro: cuando el nombre cambia, re-crea la
177    /// layer-surface anclada a esa salida. Es la única forma de mudar de
178    /// monitor en wlr-layer-shell — la salida se elige al **crear** la
179    /// superficie y después no se puede cambiar. El proceso, el modelo y el
180    /// dispositivo GPU sobreviven; lo único que se rehace es la superficie.
181    ///
182    /// Lo usa el teclado en pantalla (`mirada-teclado`) para seguir al monitor
183    /// con el foco: un teclado que se queda en el monitor de al lado mientras
184    /// tipeás en éste no sirve para nada.
185    fn output_deseado(_model: &Self::Model) -> Option<String> {
186        None
187    }
188
189    /// ¿Hay un campo de texto con foco? El runtime **Android** lo consulta
190    /// en cada frame para mostrar/ocultar el teclado en pantalla (soft
191    /// keyboard) siguiendo al foco — sin esto, en un teléfono no hay forma
192    /// de tipear. Lo típico es delegar al mismo estado que rutea el
193    /// teclado: `model.focused_input().is_some()`. Desktop lo ignora (ahí
194    /// el teclado es físico; la composición va por [`App::ime_allowed`]).
195    /// Default `false`: una app sin edición de texto jamás muestra teclado.
196    fn wants_text_input(_model: &Self::Model) -> bool {
197        false
198    }
199
200    /// ¿La app quiere **un cuadro por cada refresco del compositor**? Default
201    /// `false`: el runtime sólo repinta ante eventos, animaciones o cuando la
202    /// app lo pide con un `Msg`.
203    ///
204    /// Devolver `true` engancha el bucle al ritmo de la pantalla: tras cada
205    /// `present` el runtime vuelve a pedir el cuadro siguiente, y como
206    /// [`notificar_present`](crate) ya adjunta el `wl_surface.frame`, winit
207    /// retiene ese repintado hasta que llega el callback del compositor. El
208    /// resultado es una cadencia **en fase con el vblank**, sin temporizador
209    /// propio.
210    ///
211    /// **El porqué** (medido en `media-app`, 2026-08-04). Un reproductor que se
212    /// marca su propio ritmo con `spawn_periodic` de 33 ms tiene DOS relojes: el
213    /// suyo y el del compositor, que es quien de verdad decide cuándo sale el
214    /// cuadro. El período efectivo termina siendo el del temporizador
215    /// **redondeado hacia arriba** al próximo callback, así que contra vblanks de
216    /// 16.67 ms alterna 33/50/66 ms — nunca los dos refrescos por cuadro que un
217    /// video de 30 fps necesita en un panel de 60 Hz. Se ve como micro-tirones
218    /// permanentes, y bajo carga (un `cargo build`) el redondeo se va a 55-75 ms
219    /// y los fps se desploman a 13-18 con el trabajo por cuadro casi intacto: lo
220    /// que se cae es la cadencia, no el rendimiento.
221    ///
222    /// Quien lo active **debe decidir por cuadro qué mostrar** a partir del
223    /// tiempo transcurrido (el `dt` real entre repintados), no asumir que cada
224    /// cuadro del compositor es un cuadro de contenido. Y debe devolver `false`
225    /// cuando no hay nada vivo que mostrar (en pausa, sin medio): si no, la
226    /// ventana repinta a 60 Hz para siempre.
227    fn wants_vsync_frames(_model: &Self::Model) -> bool {
228        false
229    }
230
231    /// ¿Lo que van a pintar los `gpu_painter` de este cuadro es **distinto** de
232    /// lo que pintaron en el anterior? Default `true` — o sea, el
233    /// comportamiento de siempre.
234    ///
235    /// Es la contracara de [`Self::wants_vsync_frames`]. Una app que pide
236    /// cuadros al ritmo del compositor los recibe a 60 Hz, pero su contenido
237    /// casi nunca cambia tan seguido: un reproductor con un video de 24 fps
238    /// redibuja lo mismo en **3 de cada 5 cuadros**. Devolviendo `false` en
239    /// esos, el runtime saltea el cuadro entero —ni vello, ni painters GPU, ni
240    /// composite— y **presenta la intermedia tal como quedó**, que ya tiene el
241    /// resultado correcto. El ritmo no se toca: se sigue presentando en cada
242    /// vsync, así que no hay riesgo de perder el enganche con el compositor.
243    ///
244    /// El runtime **no confía sólo en esto**: además exige que la escena vello
245    /// sea idéntica a la del cuadro anterior (por huella de su encoding), que no
246    /// haya overlay, arrastre ni post-pasadas de blur/filtro, y que el viewport
247    /// no haya cambiado. O sea que un `false` de más no puede congelar la UI —
248    /// a lo sumo desperdicia la oportunidad de ahorrar.
249    ///
250    /// Al implementarlo, **sé conservador**: devolvé `true` ante la duda. El
251    /// criterio correcto no es «¿ya pasó el período de cuadro?» sino «¿va a
252    /// pasar antes del próximo vsync?» — si no, el cuadro nuevo llega tarde.
253    fn gpu_content_changed(_model: &Self::Model) -> bool {
254        true
255    }
256
257    /// Maneja una rueda del mouse. `delta` está normalizado a "líneas"
258    /// (positivo arriba/izquierda, negativo abajo/derecha). En backends
259    /// que reportan píxeles, llimphi-ui divide por 20 para aproximar.
260    fn on_wheel(
261        _model: &Self::Model,
262        _delta: WheelDelta,
263        _cursor: (f32, f32),
264        _modifiers: Modifiers,
265    ) -> Option<Self::Msg> {
266        None
267    }
268
269    /// Capa de overlay opcional. Si devuelve `Some(view)`, el runtime
270    /// la pinta encima del árbol principal y los clicks/hover se
271    /// rutean exclusivamente a ella (el árbol de fondo queda "bajo
272    /// vidrio" hasta que se cierre el overlay). Pensado para menús
273    /// contextuales, diálogos modales, popovers — el patrón usual es
274    /// envolver los items en un scrim a pantalla completa con
275    /// `on_click = DismissOverlay` para que los clicks afuera lo
276    /// cierren.
277    ///
278    /// La transición entre "con overlay" y "sin overlay" la maneja la
279    /// app vía su Model: cuando el state diga "menu abierto",
280    /// `view_overlay` devuelve `Some`; cuando se cierre, `None`.
281    fn view_overlay(_model: &Self::Model) -> Option<View<Self::Msg>> {
282        None
283    }
284
285    /// Maneja un drop de archivo desde el sistema operativo (drag&drop
286    /// desde el file manager hacia la ventana). El runtime invoca este
287    /// callback una vez por archivo soltado — si el usuario suelta varios,
288    /// llega un evento por path. Devolver `Some(Msg)` dispara un update;
289    /// `None` (default) ignora el drop.
290    ///
291    /// Backend: mapea directamente `winit::WindowEvent::DroppedFile(PathBuf)`.
292    /// La posición del drop no se reporta porque winit no la expone hasta
293    /// que el compositor la propague — en Wayland depende del extension
294    /// `data_device_manager`, en X11 viene en el ClientMessage XDND.
295    fn on_file_drop(_model: &Self::Model, _path: std::path::PathBuf) -> Option<Self::Msg> {
296        None
297    }
298
299    /// Maneja un redimensionado de la ventana. `width`/`height` son el
300    /// nuevo tamaño en **píxeles físicos** (lo que reporta
301    /// `winit::WindowEvent::Resized` y lo que recibe la surface). El
302    /// runtime ya reconfiguró la surface y pedirá redraw; este callback
303    /// es para que la app reaccione al nuevo viewport (recalcular layout
304    /// dependiente del tamaño, emitir un evento `resize`, etc.).
305    /// Devolver `Some(Msg)` dispara un update; `None` (default) lo ignora.
306    fn on_resize(_model: &Self::Model, _width: u32, _height: u32) -> Option<Self::Msg> {
307        None
308    }
309
310    /// Maneja un cambio del factor de escala de la ventana (`scale_factor`
311    /// de winit: 1.0 en pantallas normales, 2.0 en HiDPI/Retina, fraccional
312    /// con escalado del compositor). El runtime lo invoca una vez al arrancar
313    /// (con el factor inicial de la ventana, tras `init`) y luego en cada
314    /// `WindowEvent::ScaleFactorChanged` (mover la ventana entre monitores,
315    /// cambiar el escalado del sistema). Es lo que permite, p. ej., que
316    /// `window.devicePixelRatio` refleje el DPI real. Devolver `Some(Msg)`
317    /// dispara un update; `None` (default) lo ignora.
318    fn on_scale_factor(_model: &Self::Model, _scale: f64) -> Option<Self::Msg> {
319        None
320    }
321
322    /// La **ventana** ganó (`true`) o perdió (`false`) el foco del sistema —
323    /// el `WindowEvent::Focused` de winit. Distinto de [`App::on_focus`], que
324    /// es el foco de un nodo enfocable *dentro* de la app (navegación Tab).
325    /// Útil para apps que cambian de forma según tengan o no el foco (p. ej.
326    /// shuma replegándose a una barra al perder el foco). Devolver `Some(Msg)`
327    /// dispara un update; `None` (default) lo ignora.
328    fn on_window_focus(_model: &Self::Model, _focused: bool) -> Option<Self::Msg> {
329        None
330    }
331
332    /// El estado de los **modificadores** cambió (Shift/Ctrl/Alt/Super pulsados
333    /// o soltados) — el `WindowEvent::ModifiersChanged` de winit.
334    ///
335    /// Existe para que la app pueda saber qué modificadores están puestos
336    /// **cuando llega un click**: los handlers de click llevan un `Msg` plano y
337    /// no los reciben (agregarlos obligaría a versionar el callback de cada
338    /// widget con filas). El patrón es guardarlos en el modelo acá y
339    /// consultarlos en el `update` del click — así se implementan el
340    /// Shift+click (rango) y el Ctrl+click (selección aditiva) de cualquier
341    /// lista. Devolver `Some(Msg)` dispara un update; `None` (default) lo
342    /// ignora. Conviene devolver `None` cuando el cambio no le importa a la
343    /// app: si no, cada roce de Shift repinta.
344    fn on_modifiers(_model: &Self::Model, _mods: Modifiers) -> Option<Self::Msg> {
345        None
346    }
347
348    /// El cursor se movió sobre la ventana — `(x, y)` en píxeles físicos, el
349    /// `WindowEvent::CursorMoved` crudo. Se invoca en **cada** movimiento, sin
350    /// depender del hit-test de nodos ni de que haya un frame cacheado: es la
351    /// señal fiable de "hay actividad de mouse" para cosas como revelar una
352    /// barra estilo OSC (mpv) o resetear un temporizador de inactividad. Para
353    /// reaccionar a la posición *sobre un nodo concreto* usa
354    /// `View::on_pointer_move_at` en su lugar. Devolver `Some(Msg)` dispara un
355    /// update; `None` (default) lo ignora.
356    fn on_cursor_moved(_model: &Self::Model, _x: f32, _y: f32) -> Option<Self::Msg> {
357        None
358    }
359
360    /// El stack GPU se **reconstruyó tras una pérdida de dispositivo**
361    /// ([`llimphi_hal::SurfaceError::DeviceLost`]): el runtime ya rehízo
362    /// instance/adapter/device/surface/renderer/compositores contra un `Hal`
363    /// nuevo. Este gancho avisa a la app para que reconstruya SUS propios
364    /// recursos GPU —los que el runtime no puede alcanzar porque los posee la
365    /// app: pipelines directos (`GpuPipelines`), texturas de video
366    /// (`ExternalSurface`), renderers `llimphi-3d`, atlas propios— contra el
367    /// `hal` nuevo. Las apps de **UI pura** (sólo vello) no tienen recursos
368    /// propios en GPU: el no-op por defecto les basta y no pagan nada. Se llama
369    /// con el modelo mutable y el `Hal` recién creado, antes del siguiente
370    /// redraw. Ver [`Hal::recreate_for_window`].
371    fn on_gpu_reset(_model: &mut Self::Model, _hal: &Hal) {}
372
373    /// Título de la ventana (sólo se lee al arrancar). Es el título inicial;
374    /// para uno que cambie en runtime, ver [`App::window_title`].
375    fn title() -> &'static str {
376        "llimphi"
377    }
378
379    /// Título **dinámico** de la ventana, derivado del modelo. El runtime lo
380    /// consulta tras cada render y, si cambió, lo aplica con `Window::set_title`
381    /// — así el título de la barra del SO puede reflejar el estado (p. ej. el
382    /// medio que se reproduce). `None` (default) deja el título fijo de
383    /// [`App::title`]; una app que no lo implemente no paga nada.
384    fn window_title(_model: &Self::Model) -> Option<String> {
385        None
386    }
387
388    /// Vista de una ventana OS **secundaria** identificada por `key` (la que
389    /// se pasó a [`Handle::open_window`]). El runtime la pinta en su propia
390    /// ventana y rutea sus eventos al mismo [`App::update`] — comparte modelo
391    /// con la primaria. `None` (default, o para una key desconocida) deja la
392    /// ventana en blanco. Las secundarias NO tienen capa de overlay
393    /// ([`App::view_overlay`] es sólo de la primaria); para diálogos dentro de
394    /// una secundaria, componerlos en su propio `secondary_view`.
395    fn secondary_view(_model: &Self::Model, _key: u64) -> Option<View<Self::Msg>> {
396        None
397    }
398
399    /// Título dinámico de una ventana secundaria (análogo a
400    /// [`App::window_title`] para la primaria). `None` deja el título con el
401    /// que se abrió.
402    fn secondary_title(_model: &Self::Model, _key: u64) -> Option<String> {
403        None
404    }
405
406    /// El usuario cerró una ventana secundaria con el botón del SO. El runtime
407    /// ya la destruyó; este callback es para que la app sincronice su modelo
408    /// (p. ej. marcar el panel como cerrado). Devolver `Some(Msg)` dispara un
409    /// `update`; `None` (default) no hace nada.
410    fn on_secondary_close(_model: &Self::Model, _key: u64) -> Option<Self::Msg> {
411        None
412    }
413
414    /// Identificador de aplicación. En Wayland se mapea al `app_id` del
415    /// xdg-toplevel (lo que el compositor usa para reconocer la ventana,
416    /// p. ej. `mirada.greeter`). `None` deja que el sistema asigne uno.
417    fn app_id() -> Option<&'static str> {
418        None
419    }
420
421    /// Tamaño lógico inicial de la ventana, en píxeles. El usuario puede
422    /// redimensionar después; sólo se lee al arrancar.
423    fn initial_size() -> (u32, u32) {
424        (960, 540)
425    }
426
427    /// ¿La ventana nace en **pantalla completa sin bordes**?
428    ///
429    /// Existe porque pedirlo desde `init` con [`Handle::set_fullscreen`] **no
430    /// funciona**: en ese momento el runtime todavía está construyendo su
431    /// estado, y el handler del evento lo descarta en silencio
432    /// (`if let Some(state) = self.state`). Verificado en metal el 2026-08-25
433    /// con el launcher, que salía con barra de título igual.
434    ///
435    /// Esto en cambio se resuelve al crear la ventana, así que no hay carrera:
436    /// es un atributo, no un pedido. Lo quiere un overlay (un runner, un HUD);
437    /// una app normal lo deja en `false` y el usuario decide.
438    fn initial_fullscreen() -> bool {
439        false
440    }
441
442    /// ¿La ventana **compone con alfa**? `false` (el default) le declara al
443    /// compositor una **región opaca** que cubre toda la superficie: el
444    /// compositor queda autorizado a no pintar nada de lo que hay detrás, y
445    /// cualquier alfa que la app ponga en su fondo es una mentira que se ve
446    /// como un color plano.
447    ///
448    /// Devolver `true` es lo que necesita un **overlay** —un runner, un HUD, un
449    /// diálogo que oscurece lo de atrás sin taparlo— y lo que necesita una
450    /// terminal translúcida. Cuesta: sin región opaca el compositor no puede
451    /// mandar la superficie a un plano de scanout y la compone por GL en cada
452    /// cuadro (ver [[mirada-scanout-fullscreen-vs-redondeo]]), así que no es
453    /// gratis para una app que llena su ventana con contenido sólido.
454    fn transparent() -> bool {
455        false
456    }
457}
458
459/// Mensaje interno del event loop. `Msg` lo dispara la app desde un hilo de
460/// fondo vía [`Handle::dispatch`] o [`Handle::spawn`]; `Quit` cierra la
461/// ventana y termina el proceso.
462pub enum UserEvent<Msg> {
463    Msg(Msg),
464    Quit,
465    /// Pide abrir una ventana OS **secundaria** con la `key` dada (la app la
466    /// usa para distinguir cuál es en [`App::secondary_view`]). Idempotente:
467    /// si ya existe una con esa key, se enfoca en vez de duplicar. La crea el
468    /// event loop (que tiene el `ActiveEventLoop`); por eso va por mensaje.
469    OpenWindow {
470        key: u64,
471        title: String,
472        width: u32,
473        height: u32,
474    },
475    /// Pide cerrar la ventana secundaria con esa `key`. No afecta a la primaria.
476    CloseWindow { key: u64 },
477    /// Minimiza (o restaura) la ventana **primaria**. Útil para apps que deben
478    /// quitarse de en medio un instante — p. ej. una captura de pantalla que no
479    /// quiere salir en su propia toma. Es una *petición* al compositor.
480    SetMinimized(bool),
481    /// Pone (o saca) la ventana **primaria** en pantalla completa, sin bordes,
482    /// sobre el monitor donde está. Es la pieza que le faltaba a todo
483    /// reproductor/visor de la suite (V1 de `media/PARIDAD.md`).
484    SetFullscreen(bool),
485    /// Declara **qué** muestra la ventana primaria (`wp_content_type_v1`).
486    /// Ver [`Handle::set_content_type`].
487    SetContentType(ContentType),
488    /// Evento del adapter AccessKit: el lector de pantalla solicitó el árbol
489    /// inicial, pidió ejecutar una acción (focus, click, etc.) o se desactivó.
490    /// El adapter usa el `EventLoopProxy` para enviarlos al hilo del runtime.
491    A11y(accesskit_winit::Event),
492    /// Alguien soltó un archivo sobre la ventana **bajo Wayland**. Llega desde
493    /// el hilo de [`file_drop`], no desde winit, porque su backend Wayland no
494    /// emite `DroppedFile`; el runtime lo trata igual que a ese evento.
495    FileDrop(std::path::PathBuf),
496}
497
498/// Qué muestra una ventana, para que el compositor ajuste su política. Es la
499/// pista `wp_content_type_v1`; ver [`Handle::set_content_type`].
500#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
501pub enum ContentType {
502    /// Sin declarar — el trato neutro, y el default.
503    #[default]
504    None,
505    /// Imagen fija: no tiene ritmo de refresco propio.
506    Photo,
507    /// Video: cadencia propia que el compositor no debe entorpecer.
508    Video,
509    /// Juego: como video, y además sensible a la latencia.
510    Game,
511}
512
513/// Permite que `accesskit_winit::Adapter::with_event_loop_proxy` mande sus
514/// eventos sobre nuestro `EventLoopProxy<UserEvent<Msg>>` sin que el caller
515/// los rutee a mano.
516impl<Msg> From<accesskit_winit::Event> for UserEvent<Msg> {
517    fn from(e: accesskit_winit::Event) -> Self {
518        UserEvent::A11y(e)
519    }
520}
521
522/// Asa al runtime de Llimphi. Clonable y enviable entre hilos: la usas para
523/// pedir cerrar la ventana o para lanzar trabajo (PAM, IO, etc.) que al
524/// terminar reentra con un Msg al `update`.
525///
526/// Tests pueden construir un handle "muerto" con [`Handle::for_test`]: los
527/// `dispatch`/`quit`/`spawn` siguen siendo seguros de llamar pero los
528/// `Msg` que generan no van a ningún lado (no hay event loop detrás).
529pub struct Handle<Msg: Send + 'static> {
530    inner: HandleInner<Msg>,
531    /// Bandera de salida para los runners que **no** son el event loop de
532    /// winit. `quit()` cierra por `UserEvent::Quit`, que sólo entiende ese
533    /// loop; un runner propio (el de `llimphi-layer`, por ejemplo) no lo ve y
534    /// la app se queda abierta para siempre. Con esto el runner arma su handle
535    /// con [`Handle::con_salida`] y mira la bandera por vuelta de bucle.
536    salir: Option<Arc<AtomicBool>>,
537}
538
539enum HandleInner<Msg: Send + 'static> {
540    Real(EventLoopProxy<UserEvent<Msg>>),
541    /// Handle de tests: drop silencioso de todos los dispatches. Permite
542    /// llamar funciones que toman `&Handle<Msg>` sin levantar un event
543    /// loop real (que en CI sin display tiraría).
544    Test,
545    /// Handle **lifteado**: reenvía cada `Msg` (de un sub-app hospedado) al
546    /// handle del host aplicándole una función de elevación `Sub -> Host`. Lo
547    /// crea [`Handle::lift`]; permite que el `update` de un app embebido use
548    /// `dispatch`/`spawn`/`spawn_periodic` con su propio `Msg` y que el
549    /// resultado llegue al loop del host. No maneja ventanas (open/close/quit
550    /// son no-op): esas son del host, no del hospedado.
551    Lifted(Arc<dyn Fn(Msg) + Send + Sync>),
552}
553
554impl<Msg: Send + 'static> Clone for Handle<Msg> {
555    fn clone(&self) -> Self {
556        Self {
557            inner: match &self.inner {
558                HandleInner::Real(p) => HandleInner::Real(p.clone()),
559                HandleInner::Test => HandleInner::Test,
560                HandleInner::Lifted(f) => HandleInner::Lifted(f.clone()),
561            },
562            salir: self.salir.clone(),
563        }
564    }
565}
566
567impl<Msg: Send + 'static> Handle<Msg> {
568    /// Construye un handle desactivado para tests — todos los dispatch
569    /// se descartan silenciosamente. Útil para probar funciones que toman
570    /// `&Handle<Msg>` sin levantar un event loop real (que en CI sin
571    /// display tiraría).
572    pub fn for_test() -> Self {
573        Self {
574            inner: HandleInner::Test,
575            salir: None,
576        }
577    }
578
579    /// Handle de tests que **entrega** cada `Msg` a `sink` en vez de tirarlo.
580    /// Es lo que necesita un test para ejercitar el camino asíncrono real de la
581    /// app: `spawn` corre su closure en un hilo (como en producción) y el `Msg`
582    /// resultante llega al `sink` — típicamente el extremo de un canal que el
583    /// test drena y realimenta a `update`. Con [`Self::for_test`] ese `Msg` se
584    /// descarta, así que un test que necesitaba el efecto tenía que volver a
585    /// correr el trabajo a mano: dos ejecuciones del mismo job y carrera entre
586    /// ellas.
587    ///
588    /// El `sink` se llama desde el hilo del `spawn`, de ahí el `Send + Sync`
589    /// (un `mpsc::Sender` no es `Sync`: envolverlo en `Mutex` alcanza).
590    pub fn for_test_with<F>(sink: F) -> Self
591    where
592        F: Fn(Msg) + Send + Sync + 'static,
593    {
594        Self {
595            inner: HandleInner::Lifted(Arc::new(sink)),
596            salir: None,
597        }
598    }
599
600    /// Le cuelga a este handle la **bandera de salida** que mira un runner que
601    /// no es el event loop de winit (el de `llimphi-layer`, p. ej.). Sin esto,
602    /// una app que se cierra a sí misma con [`Self::quit`] anda como ventana y
603    /// se queda colgada como capa, porque `UserEvent::Quit` no tiene quién lo
604    /// reciba. La bandera viaja a los clones y a los sub-handles de `lift`.
605    pub fn con_salida(mut self, salir: Arc<AtomicBool>) -> Self {
606        self.salir = Some(salir);
607        self
608    }
609
610    /// Cierra la ventana y termina el bucle. La transición en curso (si la
611    /// hay) se completa antes de salir.
612    pub fn quit(&self) {
613        // Primero la bandera: es el único camino que ven los runners propios.
614        if let Some(f) = &self.salir {
615            f.store(true, Ordering::SeqCst);
616        }
617        match &self.inner {
618            HandleInner::Real(p) => {
619                let _ = p.send_event(UserEvent::Quit);
620            }
621            HandleInner::Test => {}
622            // Un app hospedado no cierra el loop del host.
623            HandleInner::Lifted(_) => {}
624        }
625    }
626
627    /// Deriva un handle para un **sub-app hospedado**: el `update`/efectos del
628    /// sub-app usan su propio `Sub` msg, y `lift` los eleva al `Msg` del host
629    /// antes de despacharlos a este loop. Es la pieza que permite embeber un
630    /// App entero en otro (junto con [`crate::View::map`] para su `view`) sin
631    /// reescribirlo a patrón módulo. El sub-handle es `Clone + Send` como
632    /// cualquier handle. `open_window`/`close_window`/`quit` quedan no-op en él
633    /// (esas son del host).
634    pub fn lift<Sub, F>(&self, lift: F) -> Handle<Sub>
635    where
636        Sub: Send + 'static,
637        F: Fn(Sub) -> Msg + Send + Sync + 'static,
638    {
639        let parent = self.clone();
640        let parent_salir = self.salir.clone();
641        Handle {
642            inner: HandleInner::Lifted(Arc::new(move |sub: Sub| parent.dispatch(lift(sub)))),
643            // La bandera SÍ viaja al sub-handle: un app hospedado no cierra el
644            // loop de winit del host, pero si el host es un runner propio que
645            // sale por bandera, el hospedado tiene que poder pedirlo.
646            salir: parent_salir,
647        }
648    }
649
650    /// Abre una ventana OS **secundaria** (ver [`App::secondary_view`]). La
651    /// `key` la elige la app para reconocerla luego; abrir con una key que ya
652    /// existe sólo la enfoca (no duplica). El contenido lo pinta
653    /// `App::secondary_view(model, key)` y los eventos (click/tecla/…) reentran
654    /// al mismo `update`, así que la ventana comparte el modelo con la primaria.
655    /// Cerrala con [`Self::close_window`] o con el botón del SO.
656    pub fn open_window(&self, key: u64, title: impl Into<String>, width: u32, height: u32) {
657        if let HandleInner::Real(p) = &self.inner {
658            let _ = p.send_event(UserEvent::OpenWindow {
659                key,
660                title: title.into(),
661                width,
662                height,
663            });
664        }
665    }
666
667    /// Cierra la ventana secundaria con esa `key` (no-op si no existe). La
668    /// ventana primaria nunca se cierra por aquí — para eso está [`Self::quit`].
669    pub fn close_window(&self, key: u64) {
670        if let HandleInner::Real(p) = &self.inner {
671            let _ = p.send_event(UserEvent::CloseWindow { key });
672        }
673    }
674
675    /// Minimiza (`true`) o restaura (`false`) la ventana primaria. Es una
676    /// petición al compositor (en Wayland, `xdg_toplevel.set_minimized`); honrarla
677    /// depende del WM. Pensado para apartarse un instante (captura de pantalla).
678    pub fn set_minimized(&self, minimized: bool) {
679        if let HandleInner::Real(p) = &self.inner {
680            let _ = p.send_event(UserEvent::SetMinimized(minimized));
681        }
682    }
683
684    /// Pone (`true`) o saca (`false`) la ventana **primaria** de pantalla
685    /// completa, sin bordes, en el monitor donde ya está (`Fullscreen::
686    /// Borderless(None)` de winit) — el modo que usan los reproductores, no el
687    /// exclusivo con cambio de modo de video.
688    ///
689    /// Es una *petición*: en Wayland la resuelve el compositor. El runtime no
690    /// lleva la cuenta del estado — el estado es de la app (un `bool` en su
691    /// modelo), que es quien decide cuándo entrar y salir. Si el usuario sale
692    /// de pantalla completa por el WM (F11 del compositor), la app no se
693    /// entera; pedir `set_fullscreen(false)` de nuevo es inocuo.
694    pub fn set_fullscreen(&self, fullscreen: bool) {
695        if let HandleInner::Real(p) = &self.inner {
696            let _ = p.send_event(UserEvent::SetFullscreen(fullscreen));
697        }
698    }
699
700    /// Declara **qué** está mostrando la ventana primaria (`wp_content_type_v1`).
701    ///
702    /// No cambia nada de cómo pinta la app: es una pista para que el compositor
703    /// ajuste su política. mirada la usa para cederle la pantalla al contenido
704    /// con ritmo propio —deja de animar el fondo de marca, que le cuesta una
705    /// recomposición completa por latido— y para decidir VRR y tearing.
706    ///
707    /// Un reproductor declara [`ContentType::Video`] al arrancar la
708    /// reproducción y vuelve a [`ContentType::None`] al pausar o cerrar el
709    /// medio; declarar el mismo valor dos veces es gratis. Fuera de Wayland (o
710    /// con un compositor que no exponga el protocolo) es un no-op silencioso.
711    ///
712    /// La pista viaja con el **próximo cuadro** que pinte la app: es estado de
713    /// superficie doble-buffereado y el compositor lo lee en el `commit`.
714    pub fn set_content_type(&self, kind: ContentType) {
715        if let HandleInner::Real(p) = &self.inner {
716            let _ = p.send_event(UserEvent::SetContentType(kind));
717        }
718    }
719
720    /// Encola un Msg para procesarse en el próximo turno del bucle. Útil
721    /// para que un callback externo reentre al update.
722    pub fn dispatch(&self, msg: Msg) {
723        match &self.inner {
724            HandleInner::Real(p) => {
725                let _ = p.send_event(UserEvent::Msg(msg));
726            }
727            HandleInner::Test => {}
728            HandleInner::Lifted(f) => f(msg),
729        }
730    }
731
732    /// Lanza una closure en un hilo aparte; cuando devuelve `Msg`, el
733    /// runtime la entrega al `update` en el hilo de UI. Pensado para
734    /// trabajo bloqueante (PAM tarda ~2 s ante un fallo, p. ej.).
735    pub fn spawn<F>(&self, f: F)
736    where
737        F: FnOnce() -> Msg + Send + 'static,
738    {
739        match &self.inner {
740            HandleInner::Real(p) => {
741                let proxy = p.clone();
742                std::thread::spawn(move || {
743                    let msg = f();
744                    let _ = proxy.send_event(UserEvent::Msg(msg));
745                });
746            }
747            HandleInner::Test => {
748                // Corremos la closure igual (para no perder side-effects de
749                // tests que dependan de su side) pero el msg se descarta.
750                std::thread::spawn(move || {
751                    let _ = f();
752                });
753            }
754            HandleInner::Lifted(lift) => {
755                // Tarea one-shot del sub-app: corre en su hilo y el resultado
756                // se eleva al host vía la closure de lift.
757                let lift = lift.clone();
758                std::thread::spawn(move || {
759                    lift(f());
760                });
761            }
762        }
763    }
764
765    /// Lanza un loop periódico en un hilo aparte: cada `period` invoca
766    /// `f()` y dispatcha el `Msg` resultante al `update`. El thread
767    /// queda corriendo hasta que el event loop se cierra (en ese
768    /// punto el `send_event` falla silenciosamente y el thread spinea
769    /// hasta el exit del proceso, costo despreciable).
770    ///
771    /// Útil para ticks de simulación (~11 Hz en dominium), polling de
772    /// hardware, o cualquier feed que necesite Msgs a intervalos
773    /// regulares. Si `f` necesita state, capturalo en la closure por
774    /// move; la closure se ejecuta en un thread aparte así que el
775    /// state capturado debe ser `Send`.
776    pub fn spawn_periodic<F>(&self, period: std::time::Duration, f: F)
777    where
778        F: Fn() -> Msg + Send + 'static,
779    {
780        match &self.inner {
781            HandleInner::Real(p) => {
782                let proxy = p.clone();
783                std::thread::spawn(move || loop {
784                    std::thread::sleep(period);
785                    if proxy.send_event(UserEvent::Msg(f())).is_err() {
786                        // Event loop cerrado — el thread puede morir.
787                        break;
788                    }
789                });
790            }
791            HandleInner::Test => {
792                // Un thread vivo eternamente sin sumidero ni manera de
793                // pararlo sería un leak — en for_test simplemente no
794                // arrancamos el loop. Los tests que necesiten verificar
795                // periodic behaviour deben usar el callback directo.
796                let _ = f;
797            }
798            HandleInner::Lifted(lift) => {
799                // Mismo loop que `Real` pero elevando al host. Si el loop del
800                // host se cerró, la closure de lift termina en un dispatch
801                // no-op (spinea hasta el exit, costo despreciable — igual que
802                // `Real`); aceptable para un ticker de animación/feed.
803                let lift = lift.clone();
804                std::thread::spawn(move || loop {
805                    std::thread::sleep(period);
806                    lift(f());
807                });
808            }
809        }
810    }
811
812    /// Igual que [`spawn_periodic`](Self::spawn_periodic) pero con **periodo
813    /// variable**: antes de cada espera se llama `period()` para decidir cuánto
814    /// dormir hasta el próximo `f()`. Pensado para feeds cuya cadencia depende
815    /// del estado — p. ej. un reproductor que tiquea a ~30 Hz en play pero baja
816    /// a unos pocos Hz en pausa (cada Msg fuerza un repintado completo, así que
817    /// bajar la cadencia en pausa recorta la CPU en reposo sin tocar el core).
818    pub fn spawn_periodic_dyn<P, F>(&self, period: P, f: F)
819    where
820        P: Fn() -> std::time::Duration + Send + 'static,
821        F: Fn() -> Msg + Send + 'static,
822    {
823        match &self.inner {
824            HandleInner::Real(p) => {
825                let proxy = p.clone();
826                std::thread::spawn(move || loop {
827                    std::thread::sleep(period());
828                    if proxy.send_event(UserEvent::Msg(f())).is_err() {
829                        break;
830                    }
831                });
832            }
833            HandleInner::Test => {
834                let _ = (period, f);
835            }
836            HandleInner::Lifted(lift) => {
837                let lift = lift.clone();
838                std::thread::spawn(move || loop {
839                    std::thread::sleep(period());
840                    lift(f());
841                });
842            }
843        }
844    }
845}
846
847// Los tipos de entrada (`KeyEvent`, `KeyState`, `ImeEvent`, `Modifiers`,
848// `WheelDelta`) se MUDARON a `llimphi_compositor::input` (W4.7): eran datos
849// puros del modelo, sólo estaban mal vecindados. Llegan por el glob del
850// compositor — mismos nombres, ningún consumidor cambia.
851
852/// Convierte los modificadores de winit al modelo del compositor.
853///
854/// Función y no `impl From`: con `Modifiers` viviendo en el compositor y
855/// `ModifiersState` en winit, el impl violaría las reglas de huérfanos — y
856/// además la conversión ES de la frontera, no del tipo.
857pub(crate) fn modificadores_de_winit(m: ModifiersState) -> Modifiers {
858    Modifiers {
859        shift: m.shift_key(),
860        ctrl: m.control_key(),
861        alt: m.alt_key(),
862        meta: m.super_key(),
863    }
864}
865
866/// Convierte una tecla de winit al modelo del compositor.
867///
868/// Total por construcción: lo que el modelo no nombra cae a
869/// `NamedKey::Unidentified`, que todo `match` existente absorbe en su `_` —
870/// exactamente lo que ya pasaba con las variantes exóticas de winit.
871pub(crate) fn tecla_de_winit(k: &llimphi_hal::winit::keyboard::Key) -> Key {
872    use llimphi_hal::winit::keyboard::{Key as WKey, NamedKey as WNamed};
873    match k {
874        WKey::Character(s) => Key::Character(s.as_str().into()),
875        WKey::Named(n) => Key::Named(match n {
876            WNamed::Enter => NamedKey::Enter,
877            WNamed::Escape => NamedKey::Escape,
878            WNamed::Backspace => NamedKey::Backspace,
879            WNamed::Tab => NamedKey::Tab,
880            WNamed::Space => NamedKey::Space,
881            WNamed::Delete => NamedKey::Delete,
882            WNamed::Insert => NamedKey::Insert,
883            WNamed::Home => NamedKey::Home,
884            WNamed::End => NamedKey::End,
885            WNamed::PageUp => NamedKey::PageUp,
886            WNamed::PageDown => NamedKey::PageDown,
887            WNamed::ArrowUp => NamedKey::ArrowUp,
888            WNamed::ArrowDown => NamedKey::ArrowDown,
889            WNamed::ArrowLeft => NamedKey::ArrowLeft,
890            WNamed::ArrowRight => NamedKey::ArrowRight,
891            WNamed::Shift => NamedKey::Shift,
892            WNamed::Control => NamedKey::Control,
893            WNamed::Alt => NamedKey::Alt,
894            WNamed::Meta => NamedKey::Meta,
895            WNamed::CapsLock => NamedKey::CapsLock,
896            WNamed::F1 => NamedKey::F1,
897            WNamed::F2 => NamedKey::F2,
898            WNamed::F3 => NamedKey::F3,
899            WNamed::F4 => NamedKey::F4,
900            WNamed::F5 => NamedKey::F5,
901            WNamed::F6 => NamedKey::F6,
902            WNamed::F7 => NamedKey::F7,
903            WNamed::F8 => NamedKey::F8,
904            WNamed::F9 => NamedKey::F9,
905            WNamed::F10 => NamedKey::F10,
906            WNamed::F11 => NamedKey::F11,
907            WNamed::F12 => NamedKey::F12,
908            _ => NamedKey::Unidentified,
909        }),
910        // El repo no consume `Dead` ni `Unidentified` (verificado): llegan como
911        // tecla sin nombre y el brazo `_` de los consumidores las absorbe.
912        _ => Key::Named(NamedKey::Unidentified),
913    }
914}
915
916// --- Runtime winit. El event loop (impl ApplicationHandler) vive en
917// `eventloop` y accede los campos privados de estos structs vía
918// `use super::*`. La composición declarativa (View, mount, paint,
919// hit-test) la trae el re-export de `llimphi_compositor`. ---
920mod eventloop;
921
922struct Runtime<A: App> {
923    handle: Handle<A::Msg>,
924    state: Option<RuntimeState<A>>,
925    /// Ventanas OS secundarias abiertas (opt-in vía [`Handle::open_window`]).
926    /// Comparten el `Hal`/`Renderer` y el modelo de la primaria (`state`);
927    /// cada una lleva su propia surface + caches de interacción. Vacío en la
928    /// inmensa mayoría de las apps (monoventana) — coste cero.
929    secondaries: Vec<SecondaryState<A>>,
930    /// Modelo preservado a través de un `suspended` (Android/iOS/web): al
931    /// pasar a background la NativeWindow se invalida y hay que soltar TODO
932    /// el estado GPU (`RuntimeState`), pero el modelo de la app debe
933    /// sobrevivir. `resumed` lo retoma en vez de llamar `A::init` de nuevo.
934    /// En desktop winit nunca emite `suspended` — queda `None` siempre.
935    parked_model: Option<A::Model>,
936    /// Dedo activo (id del touch) cuando los eventos llegan por
937    /// `WindowEvent::Touch` en vez de mouse. Sólo se sigue UN dedo: el
938    /// primero que baja; los demás se ignoran hasta que suelte.
939    active_touch: Option<u64>,
940    /// Android: el handle de `android-activity`, para mostrar/ocultar el
941    /// teclado en pantalla (`show_soft_input`/`hide_soft_input`). `None`
942    /// si el runtime no nació de [`run_android`] (tests, example desktop).
943    #[cfg(target_os = "android")]
944    android_app: Option<llimphi_hal::winit::platform::android::activity::AndroidApp>,
945    /// Android: si el runtime cree que el soft keyboard está visible.
946    /// Aproximación optimista — Android no deja LEER el estado del IME,
947    /// así que un Atrás del usuario nos desincroniza; `soft_input_reassert`
948    /// lo cura en el próximo tap.
949    #[cfg(target_os = "android")]
950    soft_input_visible: bool,
951    /// Android: forzar un `show_soft_input` en el próximo frame aunque
952    /// `wants_text_input` no haya cambiado (un tap con foco de texto
953    /// re-afirma el teclado que el usuario pudo haber bajado con Atrás).
954    #[cfg(target_os = "android")]
955    soft_input_reassert: bool,
956    /// Web: el `<canvas>` del documento sobre el que montar la ventana, si
957    /// [`run_web`] recibió uno. `None` → winit crea el suyo y lo cuelga del
958    /// `<body>`.
959    #[cfg(target_arch = "wasm32")]
960    canvas: Option<web_sys::HtmlCanvasElement>,
961    /// Web: buzón donde el futuro lanzado por `resumed` deja el stack GPU
962    /// cuando las promesas de `requestAdapter`/`requestDevice` resuelven.
963    ///
964    /// `Rc<RefCell<…>>` y no el `EventLoopProxy` a propósito: en wasm los
965    /// tipos de wgpu **no son `Send`**, así que un `UserEvent` que cargue el
966    /// `Hal` no compila. Como el navegador es monohilo, un `Rc` compartido con
967    /// el futuro es exactamente lo que corresponde. Sólo existe en wasm — en
968    /// escritorio el `Hal` se construye dentro de `resumed` y este campo no
969    /// está.
970    #[cfg(target_arch = "wasm32")]
971    gpu_pendiente: std::rc::Rc<
972        std::cell::RefCell<Option<(Arc<Window>, Hal, WinitSurface)>>,
973    >,
974}
975
976/// Estado por **ventana secundaria**. Espeja los campos de interacción de
977/// [`RuntimeState`] pero SIN modelo (vive en la primaria), sin overlay y sin
978/// `Hal`/`Renderer` propios (los toma prestados de la primaria al pintar).
979struct SecondaryState<A: App> {
980    /// La key con la que la app la abrió (la pasa a `secondary_view`).
981    key: u64,
982    window: Arc<Window>,
983    surface: WinitSurface,
984    scene: Escena,
985    typesetter: llimphi_text::Typesetter,
986    layout: LayoutTree,
987    cursor: PhysicalPosition<f64>,
988    modifiers: Modifiers,
989    last_render: Option<SecRenderCache<A::Msg>>,
990    hovered: Option<usize>,
991    drag: Option<DragState<A::Msg>>,
992    last_title: Option<String>,
993}
994
995/// Cache de render de una ventana secundaria (como [`RenderCache`] pero sin
996/// capa de overlay). Sólo guarda el árbol montado + layout para hit-testear el
997/// próximo click/hover; el `hover_idx` actual vive en `SecondaryState::hovered`.
998struct SecRenderCache<Msg> {
999    mounted: Mounted<Msg>,
1000    computed: ComputedLayout,
1001}
1002
1003struct RuntimeState<A: App> {
1004    window: Arc<Window>,
1005    hal: Hal,
1006    surface: WinitSurface,
1007    renderer: Renderer,
1008    scene: Escena,
1009    /// Compositor de la capa de overlay sobre contenido `gpu_paint` (video).
1010    /// Sólo entra en juego cuando el árbol principal tiene painters gpu y hay
1011    /// un overlay activo; resuelve el z-order (menús por encima del video).
1012    overlay_compositor: llimphi_hal::OverlayCompositor,
1013    /// Scratch donde se rasteriza la capa `over` (ver `eventloop::redraw`).
1014    /// **Persistente**: antes se creaba una textura del tamaño del frame —8 MB
1015    /// en 1080p— en *cada* cuadro, aunque la capa fuera una barra de progreso
1016    /// de 5 px de alto. Ahora se dimensiona al bounding box de lo que se pinta,
1017    /// snapeado a una grilla, y sólo se reasigna cuando ese tamaño cambia de
1018    /// verdad. `(textura, view, ancho, alto)`.
1019    over_scratch: Option<(
1020        llimphi_hal::wgpu::Texture,
1021        llimphi_hal::wgpu::TextureView,
1022        u32,
1023        u32,
1024    )>,
1025    /// Huella de la escena que está **dibujada** en `over_scratch` ahora mismo.
1026    /// Si la escena over de este cuadro da la misma, la scratch ya tiene el
1027    /// resultado y la rasterización de vello se saltea entera — que es el tramo
1028    /// caro (1,3–1,7 ms de ~5 ms de cuadro en el reproductor). `None` = no hay
1029    /// nada confiable dibujado, o el backend no sabe dar huella (perfil
1030    /// `hybrid`): se rasteriza siempre, como antes.
1031    over_huella: Option<u64>,
1032    /// Huella por tramo del encoding over del cuadro anterior. **Sólo
1033    /// diagnóstico** (`LLIMPHI_DEBUG_OVER=1`): sirve para decir *cuál* de las
1034    /// corrientes de vello cambió cuando el caché falla.
1035    over_tramos: [u64; 6],
1036    /// Coordenadas del flujo de paths del cuadro anterior. **Sólo diagnóstico**
1037    /// (`LLIMPHI_DEBUG_OVER=1`), igual que [`Self::over_tramos`].
1038    over_coords: Vec<f32>,
1039    /// Huella de la escena vello principal del cuadro anterior, con el viewport
1040    /// en que se pintó. Si coincide y la app dice que sus painters GPU tampoco
1041    /// cambiaron, el cuadro entero se saltea y se presenta la intermedia como
1042    /// quedó. El viewport va adentro porque un resize **recrea** la intermedia:
1043    /// sin eso, el primer cuadro después de agrandar la ventana presentaría una
1044    /// textura en blanco. Ver `App::gpu_content_changed`.
1045    escena_huella: Option<(u64, u32, u32)>,
1046    /// Backdrop blur post-pasada: para cada nodo con `.backdrop_blur(sigma)`,
1047    /// el runtime aplica un Gauss separable (H+V) sobre la intermediate
1048    /// restringido al rect del nodo, **después** de la rasterización vello y
1049    /// **antes** de los `gpu_painter`. El compositor mantiene su scratch
1050    /// interno; coste cero cuando no hay nodos blur.
1051    blur_compositor: llimphi_hal::BlurCompositor,
1052    /// Post-pasada de **matriz de color** (`filter: brightness/grayscale/…`),
1053    /// restringida al rect del nodo, en el mismo punto que `blur_compositor`.
1054    /// Mantiene su scratch interno; coste cero sin filtros de color. Fase
1055    /// 7.1233.
1056    color_filter_compositor: llimphi_hal::ColorFilterCompositor,
1057    model: Option<A::Model>,
1058    cursor: PhysicalPosition<f64>,
1059    modifiers: Modifiers,
1060    typesetter: llimphi_text::Typesetter,
1061    /// Árboles de layout reusados entre frames: `clear()` + `mount` en
1062    /// vez de re-allocar el slotmap de taffy en cada redraw. Uno para el
1063    /// árbol principal, otro para el overlay (sus `NodeId` no deben
1064    /// colisionar dentro del mismo frame).
1065    layout: LayoutTree,
1066    overlay_layout: LayoutTree,
1067    /// Estado del layout retenido (Bloque 24). `mount_incremental` lo lee para
1068    /// reusar los `NodeId` de taffy del frame anterior cuando la forma del árbol
1069    /// no cambió, y lo re-emite para el próximo. `None` = primer frame (o tras un
1070    /// cambio de forma, se rehace). Opaco: sólo se guarda y se pasa de vuelta.
1071    retained_layout: Option<llimphi_compositor::RetainedLayout>,
1072    /// Registro de subárboles memoizados (`View::memo`, Bloque 25). Vive entre
1073    /// frames: por key retiene el `Mounted` + `LayoutTree` aislado del subárbol,
1074    /// y lo reusa (sin reconstruir el `View`) mientras `deps` y el tamaño del
1075    /// slot no cambien. Sin nodos `memo`, `splice_memos` es no-op (coste ~0).
1076    memo_registry: llimphi_compositor::MemoRegistry<A::Msg>,
1077    /// Último frame renderizado: árbol montado + rects absolutos +
1078    /// nodo con hover. Lo consume el handler de click para hit-testear
1079    /// sin reconstruir `view` + layout, y CursorMoved para detectar si
1080    /// el hover cambió y disparar redraw.
1081    last_render: Option<RenderCache<A::Msg>>,
1082    /// Buffers del árbol montado **rescatados** de un `last_render` invalidado,
1083    /// para que el próximo mount escriba encima en vez de pedirle al allocator
1084    /// otro bloque del mismo porte (Bloque 21, palanca 1).
1085    ///
1086    /// Existe porque invalidar la caché de render y tirar la memoria son dos
1087    /// cosas distintas, y hasta 2026-07-27 eran la misma: cualquier `update` de
1088    /// la app hace `last_render = None`, así que el `Mounted` moría antes de
1089    /// llegar al mount siguiente y el reciclado no se activaba **nunca** en una
1090    /// app real (sólo en redraws espurios, que ya los cubre el `cache_hit`). El
1091    /// contador de asignaciones lo delató: los bytes por frame salieron
1092    /// idénticos hasta el byte antes y después de implementarlo.
1093    ///
1094    /// Lo llena [`RuntimeState::invalidar_render`]; lo vacía el mount.
1095    mount_pool: Option<Mounted<A::Msg>>,
1096    /// El gemelo de [`RuntimeState::mount_pool`] para el **layout**: el
1097    /// `ComputedLayout` del frame anterior, rescatado en vez de dropeado, para
1098    /// que la etapa de layout escriba su mapa de rects encima del bloque que ya
1099    /// está tomado.
1100    ///
1101    /// Sin esto, cada frame construye un `HashMap` de un rect por nodo **desde
1102    /// cero**, y un `HashMap` que crece duplica la tabla y re-inserta todo: es
1103    /// el «crece a saltos» del hilo abierto del Bloque 21. Medido a 200k nodos:
1104    /// 14 MB por frame que dejan de ir y venir del allocator.
1105    layout_pool: Option<ComputedLayout>,
1106    /// Nodo hovereado **persistente** entre frames, actualizado SÓLO en
1107    /// `CursorMoved`. Es contra esto que se detecta el `on_pointer_enter`
1108    /// (no contra `last_render.hover_idx`, que el render recomputa cada
1109    /// cuadro): en una app que re-renderiza sin parar (visores `paint_with`)
1110    /// el render "se comería" la transición de hover antes de que el handler
1111    /// del mouse la detecte, y el hover-switch de menús no funcionaría.
1112    hovered: Option<usize>,
1113    /// Drag activo. Mantiene su propio handler clonado del MountedNode
1114    /// — así el drag sobrevive aunque el cache se invalide entre
1115    /// eventos.
1116    drag: Option<DragState<A::Msg>>,
1117    /// Foco actual (id de un nodo `View::focusable`). El runtime es la
1118    /// única fuente de verdad: lo mueve con Tab/Shift+Tab y click-to-focus
1119    /// y lo notifica vía `App::on_focus`. `None` = nada enfocado.
1120    focused: Option<u64>,
1121    /// Último título dinámico aplicado a la ventana (ver [`App::window_title`]).
1122    /// Evita llamar `set_title` en cada frame cuando no cambió.
1123    last_title: Option<String>,
1124    /// Registro de animaciones implícitas (`View::animated`), vivo entre
1125    /// frames. En cada redraw reconcilia el árbol y, si alguna sigue en curso,
1126    /// el runtime pide otro frame (ticker autodetenido). Ver
1127    /// [`llimphi_compositor::AnimRegistry`].
1128    anim_registry: llimphi_compositor::AnimRegistry,
1129    /// Registro de animaciones implícitas de **tamaño**
1130    /// (`View::animated_size`, Flutter `AnimatedSize`), vivo entre frames.
1131    /// A diferencia de [`Self::anim_registry`] que reconcilia props de
1132    /// paint DESPUÉS del layout, este reconcilia `style.size`
1133    /// **antes** del mount/compute, así siblings/hijos reflowean suave.
1134    /// Ver [`llimphi_compositor::SizeAnimRegistry`].
1135    size_anim_registry: llimphi_compositor::SizeAnimRegistry,
1136    /// Registro de **heroes / shared-element transitions** (`View::hero`),
1137    /// vivo entre frames. Detecta cambio de rect de una misma `key` entre
1138    /// frames y escribe `transform` para "volar" del rect anterior al actual.
1139    /// Ver [`llimphi_compositor::HeroRegistry`].
1140    hero_registry: llimphi_compositor::HeroRegistry,
1141    /// Adapter [AccessKit](https://accesskit.dev) — empuja un árbol de
1142    /// accesibilidad al SO en cada paint para alimentar lectores de pantalla.
1143    /// Sólo se inicializa si el SO tiene una tecnología asistiva activa; el
1144    /// `update_if_active` evita construir el árbol cuando nadie escucha.
1145    a11y_adapter: accesskit_winit::Adapter,
1146    /// Identidad estable del árbol de accesibilidad entre `TreeUpdate`s. Se
1147    /// genera una vez al crear el runtime y se reutiliza en cada update — los
1148    /// lectores la usan para distinguir nuestra ventana de otras del SO.
1149    a11y_tree_id: accesskit::TreeId,
1150    /// Registro de **ripples/InkWell** (`View::ripple`), vivo entre frames. El
1151    /// press dispara una salpicadura; cada redraw la pinta sobre el contenido y,
1152    /// mientras alguna siga viva, pide otro frame (ticker autodetenido). Ver
1153    /// [`llimphi_compositor::RippleRegistry`].
1154    ripple_registry: llimphi_compositor::RippleRegistry,
1155    /// Caché de rasterización de los `View::repaint_boundary` (Bloque 23). Vivo
1156    /// entre frames: cada subárbol-boundary paint-puro y estático se rasteriza
1157    /// una vez y se reusa (append trasladado) mientras su contenido no cambie.
1158    /// Sin boundaries en el árbol, `paint_cached` delega en `paint` (coste ~0).
1159    repaint_cache: llimphi_compositor::RepaintCache,
1160    /// Detector de subárboles quietos (`LLIMPHI_BOUNDARY_HINT`). Diagnóstico:
1161    /// sólo se construye si la env está puesta, y apagado no cuesta ni una
1162    /// asignación. Dice **dónde** poner un `repaint_boundary`, que es el dato
1163    /// que faltaba para que el Bloque 23 saliera de los examples del motor.
1164    detector_boundaries: Option<llimphi_compositor::DetectorBoundaries>,
1165    /// Último tap (press izquierdo) sobre un nodo con `on_double_tap`: instante
1166    /// + posición. El próximo press que caiga cerca y a tiempo dispara el
1167    /// doble-tap. `None` cuando no hay un primer tap pendiente.
1168    last_tap: Option<(crate::Instant, PhysicalPosition<f64>)>,
1169    /// Long-press armado (ver [`PendingLongPress`]). El runtime lo vence por
1170    /// tiempo en `about_to_wait` y lo cancela en movimiento/release.
1171    pending_long_press: Option<PendingLongPress<A::Msg>>,
1172    /// `on_click` plano armado (ver [`PendingClick`]). Se dispara en el release
1173    /// y se cancela si el cursor se aleja del origen más que [`CLICK_MOVE_CANCEL`].
1174    pending_click: Option<PendingClick<A::Msg>>,
1175    /// Rueda **diferida**: delta acumulado de ruedas que llegaron con
1176    /// `last_render` invalidado (un update recién corrió). Se procesa al final
1177    /// del próximo redraw contra el árbol fresco — sin esto, una app con ticks
1178    /// frecuentes invalida la caché antes de casi cada rueda y el scroll por
1179    /// wheel queda muerto (chain=0 sistemático).
1180    pending_wheel: Option<WheelDelta>,
1181    /// Tecla sostenida para el **auto-repeat sintético**: winit no siempre genera
1182    /// los eventos repetidos en Wayland (deja `KeyEvent.repeat` pero no re-emite),
1183    /// así que el runtime los sintetiza — `about_to_wait` re-dispara la tecla al
1184    /// rate del compositor. El release o pisar otra tecla la limpia. `None` si no
1185    /// hay ninguna sostenida (o si winit ya repite, ver [`Self::winit_repeats`]).
1186    held_key: Option<HeldKey>,
1187    /// `true` si winit YA entrega eventos con `repeat = true` (los sintetiza él).
1188    /// Al ver el primero apagamos la síntesis propia para no duplicar la tecla.
1189    winit_repeats: bool,
1190    /// **Retención de frame entero**. Tras un paint exitoso, guardamos las
1191    /// dimensiones del viewport y los flags de animación del frame. Si en el
1192    /// próximo `RedrawRequested` ningún sitio invalidó `last_render` (la
1193    /// invariante existente del runtime), el modelo + view + layout son
1194    /// idénticos al frame anterior: no hace falta rehacer mount/layout/paint,
1195    /// alcanza con re-presentar `state.scene` tal cual quedó. Mata redraws
1196    /// espurios (expose del compositor, refocus, ticker en el último frame de
1197    /// una anim ya asentada). Si el frame retenido estaba animando o ripplando,
1198    /// el ticker NECESITA avanzarlo → no hay retención (cache miss). Tampoco
1199    /// hay retención con overlay o drag activos (camino conservador). Ver el
1200    /// hit-check en `RedrawRequested`.
1201    retained: Option<RetainedScene>,
1202    /// Selección de texto activa fuera del editor (drag para resaltar, Ctrl/Cmd+C
1203    /// para copiar). `None` = nada seleccionado. Ver [`TextSelection`].
1204    selection: Option<TextSelection>,
1205    /// El `acquire()` reportó [`llimphi_hal::SurfaceError::DeviceLost`]: el
1206    /// device quedó irrecuperable y hay que rehacer todo el stack GPU. El redraw
1207    /// no puede hacerlo solo (debe rehacer también las surfaces de las ventanas
1208    /// secundarias, que viven en `Runtime`, no en `RuntimeState`), así que sólo
1209    /// **marca** aquí y el dispatcher de `Runtime` lo atiende tras el redraw
1210    /// llamando `rebuild_gpu_after_device_loss`. Ver esa función.
1211    needs_device_rebuild: bool,
1212    /// Cuántos `acquire()` seguidos fallaron con un error **que no es**
1213    /// `DeviceLost`. Se pone en cero en cuanto uno sale bien.
1214    ///
1215    /// Existe porque el camino de recuperación era un bucle sin fondo: fallaba
1216    /// el acquire, se pedía otro redraw, volvía a fallar, para siempre y en
1217    /// silencio (el error ni se logueaba). En metal eso quemó un teléfono
1218    /// entero a 3.000 vueltas por segundo. Pasado [`ACQUIRE_FALLOS_MAX`] se
1219    /// escala a rebuild del stack GPU, que es lo que de verdad puede arreglarlo.
1220    acquire_fallidos: u32,
1221}
1222
1223/// Fallos de `acquire()` seguidos tras los cuales se rehace el stack GPU en vez
1224/// de seguir reintentando. Ocho es holgado para un hipo transitorio del
1225/// compositor (un par de frames) y cortito para una falla real.
1226pub(crate) const ACQUIRE_FALLOS_MAX: u32 = 8;
1227
1228impl<A: App> RuntimeState<A> {
1229    /// Invalida la caché de render **quedándose con los buffers**.
1230    ///
1231    /// Sustituye a `state.last_render = None` en todos lados. La diferencia:
1232    /// aquel tiraba, junto con la caché de hit-test, el `Vec<MountedNode>` del
1233    /// frame — que a 200k nodos son 432 MB que el frame siguiente volvía a
1234    /// pedirle al allocator. Acá el árbol montado se mueve a
1235    /// [`RuntimeState::mount_pool`] y el próximo mount escribe encima.
1236    ///
1237    /// La caché queda igual de inválida que antes: nadie puede hit-testear
1238    /// contra `mount_pool` (no es alcanzable desde los handlers) y el mount lo
1239    /// primero que hace es `clear()`.
1240    fn invalidar_render(&mut self) {
1241        if let Some(rc) = self.last_render.take() {
1242            self.mount_pool = Some(rc.mounted);
1243            self.layout_pool = Some(rc.computed);
1244        }
1245    }
1246}
1247
1248/// Metadata del frame retenido — qué pintó la `state.scene` para validar que
1249/// re-presentarla sin re-pintar es seguro.
1250#[derive(Clone, Copy)]
1251struct RetainedScene {
1252    w: u32,
1253    h: u32,
1254    animating: bool,
1255    rippling: bool,
1256    has_overlay: bool,
1257    /// El frame tenía painters GPU directos (`gpu_paint_with`) o post-pasadas
1258    /// (backdrop blur / filter). Esas primitivas se dibujan en vivo sobre la
1259    /// intermedia cada frame y **no** quedan en `state.scene` (la escena vello
1260    /// retenida). Re-presentar la scene tal cual las perdería → no se retiene
1261    /// (un expose del compositor debe re-correr el paint GPU). Sin esto, p.ej.
1262    /// la esfera 3D de cosmos desaparecía al tapar/destapar la ventana.
1263    has_gpu_paint: bool,
1264}
1265
1266/// Selección de texto activa fuera del editor (ver [`crate::View::selectable`]).
1267/// Anclada a la `key` estable del nodo (no a su `NodeId`, que cambia cada
1268/// frame); el runtime reconstruye el `parley::Layout` del nodo bajo esa key
1269/// para extender la selección al arrastrar y para pintar el resaltado.
1270#[derive(Clone, Copy)]
1271struct TextSelection {
1272    /// Key estable del nodo seleccionable (`text_select_key`).
1273    key: u64,
1274    /// Rango seleccionado, en coordenadas de bytes del `parley::Layout`.
1275    sel: llimphi_text::parley::Selection,
1276    /// `true` mientras el botón izquierdo sigue apretado (arrastrando).
1277    dragging: bool,
1278}
1279
1280struct RenderCache<Msg> {
1281    mounted: Mounted<Msg>,
1282    computed: ComputedLayout,
1283    // El hover del frame pintado NO vive acá: vive en `RuntimeState::hovered`,
1284    // que persiste entre cuadros y es contra lo que se detecta el
1285    // `on_pointer_enter`. Hubo un `hover_idx` en esta caché que se escribía cada
1286    // frame y no lo leía nadie — se fue en 2026-08-04.
1287    /// Índice del drop target hovereado en el frame ya pintado. Solo
1288    /// se setea durante un drag activo con `payload` declarado.
1289    drop_hover_idx: Option<usize>,
1290    /// Capa de overlay (menú contextual, modal). Cuando está presente,
1291    /// hover/click/right-click se rutean a ella exclusivamente — el
1292    /// árbol principal queda "bajo vidrio" hasta que la app cierre el
1293    /// overlay devolviendo `None` desde [`App::view_overlay`].
1294    overlay: Option<OverlayCache<Msg>>,
1295}
1296
1297struct OverlayCache<Msg> {
1298    mounted: Mounted<Msg>,
1299    computed: ComputedLayout,
1300    hover_idx: Option<usize>,
1301}
1302
1303/// Tres sabores de handler de drag activo: el simple `(phase, dx, dy)`;
1304/// la variante que conserva la posición local del press original
1305/// `(phase, dx, dy, lx0, ly0)`; o el handler **con velocidad** que recibe
1306/// también `(vx, vy)` al `DragPhase::End` (medida sobre los últimos
1307/// [`VELOCITY_WINDOW`] de movimiento). El runtime elige uno al iniciar el
1308/// drag — un nodo es uno u otro.
1309enum DragHandlerKind<Msg> {
1310    Delta(DragFn<Msg>),
1311    DeltaAt(DragAtFn<Msg>, f32, f32),
1312    Velocity(DragVelocityFn<Msg>),
1313}
1314
1315/// Un handler de gesto "tipo click" (doble-tap / long-press) ya **resuelto**
1316/// contra el nodo: o un `Msg` directo, o un handler posicional con la posición
1317/// local `(lx, ly, w, h)` ya calculada. Se captura en el press para poder
1318/// dispararlo más tarde (long-press, que vence por tiempo) sin volver a tocar
1319/// el árbol.
1320enum GestureResolved<Msg> {
1321    Direct(Msg),
1322    At(ClickAtFn<Msg>, f32, f32, f32, f32),
1323}
1324
1325impl<Msg: Clone> GestureResolved<Msg> {
1326    /// Materializa el `Msg` (clona el directo o invoca el handler posicional).
1327    fn invoke(&self) -> Option<Msg> {
1328        match self {
1329            GestureResolved::Direct(m) => Some(m.clone()),
1330            GestureResolved::At(h, lx, ly, w, ht) => h(*lx, *ly, *w, *ht),
1331        }
1332    }
1333}
1334
1335/// Long-press **armado**: el press cayó sobre un nodo con `on_long_press`. El
1336/// runtime lo dispara cuando pasa `deadline` (en `about_to_wait`), salvo que
1337/// antes el cursor se aleje de `origin` (pasó a drag) o se suelte el botón —
1338/// en ambos casos se cancela. Es la parte de "arena" del gesto: el árbitro es
1339/// el tiempo + el movimiento.
1340struct PendingLongPress<Msg> {
1341    deadline: crate::Instant,
1342    origin: PhysicalPosition<f64>,
1343    handler: GestureResolved<Msg>,
1344}
1345
1346/// `on_click` **armado**: el press cayó sobre un nodo con `on_click` plano (sin
1347/// drag ni `on_click_at`). El click NO se dispara en el press — semántica de
1348/// escritorio: se dispara al **soltar** (`handle_left_release`), salvo que antes
1349/// el cursor se aleje de `origin` más que [`CLICK_MOVE_CANCEL`] (el gesto pasó a
1350/// arrastre/barrido y se cancela). Esto evita el disparo prematuro en mousedown
1351/// y tolera arrastres minúsculos accidentales sobre el botón.
1352struct PendingClick<Msg> {
1353    msg: Msg,
1354    origin: PhysicalPosition<f64>,
1355    /// Con **doble-tap exclusivo** (`View::double_tap_exclusive`) el click no
1356    /// sale ni al soltar: espera hasta acá por si el gesto todavía se convierte
1357    /// en doble. Si el segundo press llega, este pendiente se **descarta**; si
1358    /// vence, se dispara desde `about_to_wait`. `None` = click normal, sale al
1359    /// soltar.
1360    retener_hasta: Option<crate::Instant>,
1361    /// El botón ya se soltó. Sólo importa con `retener_hasta`: un click retenido
1362    /// se dispara al vencer **y** con el botón arriba — mientras siga apretado
1363    /// no hubo click todavía.
1364    soltado: bool,
1365}
1366
1367/// Tecla sostenida para el auto-repeat sintético: el evento winit a re-emitir
1368/// (con `repeat = true`) y el próximo instante de disparo.
1369struct HeldKey {
1370    event: llimphi_hal::winit::event::KeyEvent,
1371    next: crate::Instant,
1372    /// ¿Ya dimos una vuelta del bucle *después* de que venciera `next`?
1373    ///
1374    /// `about_to_wait` corre **después de despachar la cola de la plataforma,
1375    /// pero también después del repintado** — y el repintado es de la app, o
1376    /// sea que puede durar lo que quiera. Un cuadro lento devuelve el control
1377    /// con el `release` todavía sin leer mientras el reloj de la repetición
1378    /// venció: ahí la tecla sostenida no prueba que siga abajo, prueba que
1379    /// todavía no la leímos, y disparar es **inventar** una tecla.
1380    ///
1381    /// Así que al vencer no se dispara: se marca esto y se pide una vuelta
1382    /// inmediata (`ControlFlow::Poll`). Esa vuelta lee la cola. Si el release
1383    /// estaba ahí, `held_key` desaparece y no hay nada que disparar; si
1384    /// sobrevivió, la tecla seguía abajo en el momento de esa lectura y la
1385    /// repetición es legítima. Sin umbrales: la pregunta no es «¿cuánto tardó
1386    /// el cuadro?» sino «¿leímos la cola desde que venció?».
1387    confirmada: bool,
1388}
1389
1390/// Cuánto hay que MANTENER una tecla antes de que empiece a repetir. Espeja el
1391/// estándar del compositor (mirada usa 600 ms).
1392const KEY_REPEAT_DELAY: std::time::Duration = std::time::Duration::from_millis(600);
1393/// Intervalo entre repeticiones una vez arrancado (25 Hz → 40 ms).
1394const KEY_REPEAT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(40);
1395
1396/// Umbral de duración para que un press se convierta en long-press.
1397const LONG_PRESS_DELAY: std::time::Duration = std::time::Duration::from_millis(500);
1398/// Si el cursor se aleja más que esto (px físicos) del origen del press, deja
1399/// de ser long-press (pasó a drag/scroll) y se cancela.
1400const LONG_PRESS_MOVE_CANCEL: f64 = 8.0;
1401/// Si el cursor se aleja más que esto (px físicos) del origen del press, el
1402/// `on_click` armado deja de contar como click (el usuario quiso arrastrar /
1403/// barrer, no cliquear) y se cancela. Por debajo del umbral, un arrastre
1404/// minúsculo accidental **sigue valiendo como click** al soltar.
1405pub const CLICK_MOVE_CANCEL: f64 = 6.0;
1406/// Ventana temporal máxima entre los dos taps de un doble-tap.
1407pub const DOUBLE_TAP_WINDOW: std::time::Duration = std::time::Duration::from_millis(400);
1408/// Distancia máxima (px físicos) entre los dos taps de un doble-tap.
1409const DOUBLE_TAP_DIST: f64 = 16.0;
1410
1411/// ¿El press actual (`now`, `pos`) completa un doble-tap con el tap previo
1412/// `last`? Verdadero si hubo un tap previo dentro de [`DOUBLE_TAP_WINDOW`] y a
1413/// menos de [`DOUBLE_TAP_DIST`]. Función pura (testeable sin event loop).
1414fn double_tap_qualifies(
1415    last: Option<(crate::Instant, PhysicalPosition<f64>)>,
1416    now: crate::Instant,
1417    pos: PhysicalPosition<f64>,
1418) -> bool {
1419    last.is_some_and(|(t, p)| doble_tap_califica(now.duration_since(t), p.x - pos.x, p.y - pos.y))
1420}
1421
1422/// ¿Dos taps separados por `dt` y `(dx, dy)` píxeles cuentan como **uno doble**?
1423///
1424/// La regla vive acá, pública y **una sola vez**, porque hay DOS bucles que
1425/// despachan gestos: el de ventana (winit, este crate) y el de layer (Wayland,
1426/// `llimphi-layer`, que es por donde salen el cajón y las barras). Hasta el
1427/// 25-ago el doble-tap existía sólo en el primero, así que `on_double_tap` era
1428/// un gesto que andaba en la mitad de la suite **sin decir en cuál**: puesto en
1429/// una fila del árbol del IDE, en la ventana abría el archivo y en el cajón no
1430/// hacía nada. Dos copias de estos dos números serían la misma trampa más
1431/// tarde, cuando una se ajuste y la otra no.
1432#[must_use]
1433pub fn doble_tap_califica(dt: std::time::Duration, dx: f64, dy: f64) -> bool {
1434    dt <= DOUBLE_TAP_WINDOW && (dx * dx + dy * dy).sqrt() <= DOUBLE_TAP_DIST
1435}
1436
1437struct DragState<Msg> {
1438    handler: DragHandlerKind<Msg>,
1439    /// Cursor en el último evento (Press o CursorMoved). El delta del
1440    /// próximo Move se calcula contra este, no contra el inicio del
1441    /// drag — el caller acumula los deltas en su modelo si los necesita.
1442    last_cursor: PhysicalPosition<f64>,
1443    /// Payload `u64` que viaja con el drag. `None` si el draggable
1444    /// origen no declaró ninguno (drag de resize/scroll/etc.). Los drop
1445    /// targets sólo reaccionan cuando hay payload.
1446    payload: Option<u64>,
1447    /// Buffer móvil de (timestamp, dx, dy) por cada `CursorMoved` durante
1448    /// el drag, recortado a [`VELOCITY_MAX_SAMPLES`]. Sólo se usa cuando el
1449    /// handler es [`DragHandlerKind::Velocity`] — en los otros sabores
1450    /// queda vacío. Al `DragPhase::End` el runtime computa la velocidad
1451    /// sobre la ventana [`VELOCITY_WINDOW`].
1452    samples: std::collections::VecDeque<(crate::Instant, f64, f64)>,
1453}
1454
1455/// Ventana temporal sobre la que se mide la velocidad de un drag al
1456/// soltarlo. Movimientos más viejos no cuentan — sólo importa el último
1457/// flick. ~100 ms es el valor que usa Android para fling.
1458const VELOCITY_WINDOW: std::time::Duration = std::time::Duration::from_millis(100);
1459/// Tope superior de muestras retenidas en el buffer móvil de velocidad —
1460/// con eventos típicos de 60–120 Hz, ocho muestras cubren la ventana
1461/// holgadamente. Más allá es ruido y costo.
1462const VELOCITY_MAX_SAMPLES: usize = 8;
1463
1464/// Velocidad (px/s) calculada sobre los últimos [`VELOCITY_WINDOW`] de
1465/// movimiento. Toma sólo las muestras dentro de la ventana, suma los
1466/// deltas y divide por el tiempo transcurrido desde la primera muestra
1467/// retenida hasta `now`. Función pura para testear sin event loop.
1468fn compute_drag_velocity(
1469    samples: &std::collections::VecDeque<(crate::Instant, f64, f64)>,
1470    now: crate::Instant,
1471) -> (f32, f32) {
1472    if samples.is_empty() {
1473        return (0.0, 0.0);
1474    }
1475    let cutoff = now.checked_sub(VELOCITY_WINDOW).unwrap_or(now);
1476    let recent: Vec<&(crate::Instant, f64, f64)> =
1477        samples.iter().filter(|(t, _, _)| *t >= cutoff).collect();
1478    if recent.is_empty() {
1479        return (0.0, 0.0);
1480    }
1481    let t0 = recent[0].0;
1482    let dt = now.duration_since(t0).as_secs_f32();
1483    if dt < 0.001 {
1484        return (0.0, 0.0);
1485    }
1486    let sum_dx: f64 = recent.iter().map(|(_, dx, _)| *dx).sum();
1487    let sum_dy: f64 = recent.iter().map(|(_, _, dy)| *dy).sum();
1488    ((sum_dx as f32) / dt, (sum_dy as f32) / dt)
1489}
1490
1491/// Punto de entrada: corre el bucle Elm hasta que el usuario cierre la
1492/// ventana (o la app llame [`Handle::quit`]).
1493///
1494/// No existe en wasm: `run_app` bloquea hasta que la ventana se cierra, y en
1495/// el navegador eso no tiene forma de expresarse (ni `process::exit` sentido).
1496/// El equivalente web es [`run_web`], que cede el runtime al event loop de la
1497/// página y retorna.
1498#[cfg(not(target_arch = "wasm32"))]
1499pub fn run<A: App>() {
1500    eventloop::timing_hito("run:entrada");
1501    // El sentido de la rueda (ratón / touchpad) ANTES del primer evento: si se
1502    // sembrara más tarde, las primeras ruedas de la app irían al revés.
1503    rueda::sembrar_politica_rueda();
1504    // Crear el event loop puede fallar cuando NO hay compositor/display
1505    // accesible (p. ej. el socket Wayland no es alcanzable por este usuario, o
1506    // el compositor se cayó): winit devuelve `NoCompositor`. Antes esto
1507    // `.expect()`eaba → panic → exit 101, y bajo un supervisor de respawn eso
1508    // es un crash-loop de panics. Salimos limpio (código 3) con un mensaje
1509    // claro: no hay display, no es un bug de la app.
1510    let event_loop = match EventLoop::<UserEvent<A::Msg>>::with_user_event().build() {
1511        Ok(el) => el,
1512        Err(e) => {
1513            eprintln!(
1514                "llimphi-ui · no pude crear el event loop ({e}): no hay compositor/display \
1515                 accesible. Salgo sin panichear."
1516            );
1517            std::process::exit(3);
1518        }
1519    };
1520    event_loop.set_control_flow(ControlFlow::Wait);
1521    let handle = Handle {
1522        inner: HandleInner::Real(event_loop.create_proxy()),
1523        salir: None,
1524    };
1525    let mut runtime: Runtime<A> = Runtime {
1526        handle,
1527        state: None,
1528        secondaries: Vec::new(),
1529        parked_model: None,
1530        active_touch: None,
1531        #[cfg(target_os = "android")]
1532        android_app: None,
1533        #[cfg(target_os = "android")]
1534        soft_input_visible: false,
1535        #[cfg(target_os = "android")]
1536        soft_input_reassert: false,
1537    };
1538    if let Err(e) = event_loop.run_app(&mut runtime) {
1539        eprintln!("llimphi-ui · el event loop terminó con error: {e}");
1540        std::process::exit(3);
1541    }
1542}
1543
1544/// Punto de entrada **Android**: como [`run`], pero construye el event loop
1545/// sobre el `AndroidApp` que `android-activity` entrega en `android_main`.
1546/// El ciclo de vida móvil difiere del desktop en dos cosas que este runtime
1547/// ya absorbe: la ventana/surface se crea recién en `resumed` (lazy, igual
1548/// que aquí) y se DESTRUYE en `suspended` (el modelo se parquea y el estado
1549/// GPU se suelta — ver `Runtime::parked_model`).
1550///
1551/// El crate final (la app) debe activar la feature
1552/// `winit/android-native-activity` y exportar:
1553///
1554/// ```ignore
1555/// #[no_mangle]
1556/// fn android_main(app: AndroidApp) {
1557///     llimphi_ui::run_android::<MiApp>(app);
1558/// }
1559/// ```
1560#[cfg(target_os = "android")]
1561pub use llimphi_hal::winit::platform::android::activity::AndroidApp;
1562
1563#[cfg(target_os = "android")]
1564pub fn run_android<A: App>(
1565    android_app: llimphi_hal::winit::platform::android::activity::AndroidApp,
1566) {
1567    use llimphi_hal::winit::platform::android::EventLoopBuilderExtAndroid;
1568    eventloop::timing_hito("run_android:entrada");
1569    let event_loop = match EventLoop::<UserEvent<A::Msg>>::with_user_event()
1570        .with_android_app(android_app.clone())
1571        .build()
1572    {
1573        Ok(el) => el,
1574        Err(e) => {
1575            log::error!("llimphi-ui · no pude crear el event loop android: {e}");
1576            return;
1577        }
1578    };
1579    event_loop.set_control_flow(ControlFlow::Wait);
1580    let handle = Handle {
1581        inner: HandleInner::Real(event_loop.create_proxy()),
1582        salir: None,
1583    };
1584    let mut runtime: Runtime<A> = Runtime {
1585        handle,
1586        state: None,
1587        secondaries: Vec::new(),
1588        parked_model: None,
1589        active_touch: None,
1590        #[cfg(target_os = "android")]
1591        android_app: Some(android_app),
1592        #[cfg(target_os = "android")]
1593        soft_input_visible: false,
1594        #[cfg(target_os = "android")]
1595        soft_input_reassert: false,
1596    };
1597    if let Err(e) = event_loop.run_app(&mut runtime) {
1598        log::error!("llimphi-ui · el event loop terminó con error: {e}");
1599    }
1600}
1601
1602/// Punto de entrada **web**: corre el bucle Elm sobre un `<canvas>` del
1603/// documento. `canvas_id` es el `id` de un `<canvas>` ya presente en la
1604/// página; con `None`, winit crea uno y lo cuelga del `<body>`.
1605///
1606/// Tres diferencias con [`run`], todas impuestas por el navegador:
1607///
1608/// 1. **No retorna.** `spawn_app` cede el control al event loop de la página y
1609///    devuelve en el acto; el bucle sigue vivo por `requestAnimationFrame`. Un
1610///    `run_app` que bloquee no existe en web, así que todo lo que la app
1611///    quiera hacer "después" tiene que ir dentro del `update`.
1612/// 2. **El stack GPU llega tarde.** `requestAdapter`/`requestDevice` son
1613///    promesas; `resumed` las lanza y sale sin estado, y el primer evento
1614///    posterior lo instala. Entre medio la app no pinta — el mismo hueco que
1615///    Android ya tenía entre `resumed` y la NativeWindow válida.
1616/// 3. **Hace falta WebGPU.** Sin él no hay storage textures y el renderer de
1617///    vello no arranca; el error sale por `console.error`. Ver `PORTING.md`
1618///    §wasm32 para el camino WebGL2 (`vello_hybrid`), todavía abierto.
1619///
1620/// El crate final se empaqueta con `wasm-bindgen-cli` y expone algo así:
1621///
1622/// ```ignore
1623/// #[wasm_bindgen(start)]
1624/// pub fn arrancar() {
1625///     llimphi_ui::run_web::<MiApp>(Some("llimphi"));
1626/// }
1627/// ```
1628#[cfg(target_arch = "wasm32")]
1629pub fn run_web<A: App + 'static>(canvas_id: Option<&str>) {
1630    use llimphi_hal::winit::platform::web::EventLoopExtWebSys;
1631    use wasm_bindgen::JsCast;
1632
1633    // Los panics del runtime, a la consola del navegador con stack. Sin esto
1634    // un panic en wasm es un «unreachable executed» sin ninguna pista.
1635    std::panic::set_hook(Box::new(|info| {
1636        web_sys::console::error_1(&format!("llimphi-ui · panic: {info}").into());
1637    }));
1638
1639    let canvas = canvas_id.and_then(|id| {
1640        let encontrado = web_sys::window()
1641            .and_then(|w| w.document())
1642            .and_then(|d| d.get_element_by_id(id))
1643            .and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok());
1644        if encontrado.is_none() {
1645            // Aviso explícito y seguimos: winit creará su propio canvas. Callar
1646            // acá haría que la app apareciera en un lugar inesperado de la
1647            // página sin decir por qué.
1648            web_sys::console::warn_1(
1649                &format!(
1650                    "llimphi-ui · no encontré un <canvas id=\"{id}\">; \
1651                     winit va a crear el suyo y colgarlo del <body>."
1652                )
1653                .into(),
1654            );
1655        }
1656        encontrado
1657    });
1658
1659    let event_loop = match EventLoop::<UserEvent<A::Msg>>::with_user_event().build() {
1660        Ok(el) => el,
1661        Err(e) => {
1662            web_sys::console::error_1(
1663                &format!("llimphi-ui · no pude crear el event loop web: {e}").into(),
1664            );
1665            return;
1666        }
1667    };
1668    event_loop.set_control_flow(ControlFlow::Wait);
1669    let handle = Handle {
1670        inner: HandleInner::Real(event_loop.create_proxy()),
1671        salir: None,
1672    };
1673    let runtime: Runtime<A> = Runtime {
1674        handle,
1675        state: None,
1676        secondaries: Vec::new(),
1677        parked_model: None,
1678        active_touch: None,
1679        canvas,
1680        gpu_pendiente: std::rc::Rc::new(std::cell::RefCell::new(None)),
1681    };
1682    // `spawn_app` en vez de `run_app`: en web el runtime se CEDE al event loop
1683    // de la página (de ahí el `'static`) y esta función retorna enseguida.
1684    event_loop.spawn_app(runtime);
1685}
1686
1687#[cfg(test)]
1688mod tests {
1689    use super::*;
1690    // `Instant` viene por `super::*` (el reloj portable del crate); de std
1691    // sólo hace falta `Duration`, que no cambia entre targets.
1692    use std::time::Duration;
1693
1694    /// La regla del doble-tap es **una sola** para los tres bucles que
1695    /// despachan gestos (ventana, `llimphi-layer`, pata). Este test fija los
1696    /// dos números; que estén acá y públicos es lo que impide que el próximo
1697    /// bucle se invente los suyos y el gesto se sienta distinto según dónde
1698    /// corra la app.
1699    #[test]
1700    fn el_doble_tap_pide_cerca_y_a_tiempo() {
1701        assert!(doble_tap_califica(Duration::from_millis(120), 2.0, 2.0), "dos taps normales");
1702        assert!(!doble_tap_califica(Duration::from_millis(900), 0.0, 0.0), "tarde no cuenta");
1703        assert!(!doble_tap_califica(Duration::from_millis(50), 40.0, 0.0), "lejos tampoco");
1704        // Los bordes, explícitos: la ventana INCLUYE y la distancia también.
1705        assert!(doble_tap_califica(DOUBLE_TAP_WINDOW, 0.0, 0.0));
1706        assert!(doble_tap_califica(Duration::ZERO, DOUBLE_TAP_DIST, 0.0));
1707        assert!(!doble_tap_califica(DOUBLE_TAP_WINDOW + Duration::from_millis(1), 0.0, 0.0));
1708        // Y la distancia es euclídea, no por eje: 12+12 en diagonal pasa de 16.
1709        assert!(!doble_tap_califica(Duration::from_millis(10), 12.0, 12.0));
1710    }
1711
1712    #[test]
1713    fn lift_aplica_la_funcion_de_elevacion() {
1714        use std::sync::{Arc, Mutex};
1715        // `lift` aplica la función Sub->Host síncronamente en `dispatch` (el
1716        // dispatch al padre Test es no-op, pero la elevación corre): así
1717        // observamos que el msg del sub-app se transforma para el host.
1718        let seen = Arc::new(Mutex::new(Vec::<i32>::new()));
1719        let parent: Handle<i32> = Handle::for_test();
1720        let sub: Handle<String> = {
1721            let seen = seen.clone();
1722            parent.lift(move |s: String| {
1723                let n = s.len() as i32;
1724                seen.lock().unwrap().push(n);
1725                n
1726            })
1727        };
1728        sub.dispatch("hola".to_string());
1729        let _ = sub.clone(); // es Clone como cualquier handle
1730        assert_eq!(*seen.lock().unwrap(), vec![4]);
1731    }
1732
1733    #[test]
1734    fn velocidad_de_drag_promedia_dentro_de_la_ventana() {
1735        use std::collections::VecDeque;
1736        let now = Instant::now();
1737        // Cuatro muestras dentro de la ventana (últimos 80 ms): 4 px en x cada
1738        // 20 ms ⇒ 16 px en 80 ms ⇒ 200 px/s.
1739        let mut samples: VecDeque<(Instant, f64, f64)> = VecDeque::new();
1740        samples.push_back((now - Duration::from_millis(80), 4.0, 0.0));
1741        samples.push_back((now - Duration::from_millis(60), 4.0, 0.0));
1742        samples.push_back((now - Duration::from_millis(40), 4.0, 0.0));
1743        samples.push_back((now - Duration::from_millis(20), 4.0, 0.0));
1744        let (vx, vy) = compute_drag_velocity(&samples, now);
1745        assert!((vx - 200.0).abs() < 1.0, "vx={vx}");
1746        assert!(vy.abs() < 1e-3);
1747        // Buffer vacío → (0,0).
1748        let empty: VecDeque<(Instant, f64, f64)> = VecDeque::new();
1749        assert_eq!(compute_drag_velocity(&empty, now), (0.0, 0.0));
1750        // Muestras todas más viejas que VELOCITY_WINDOW → (0,0) (no hay
1751        // movimiento reciente para fling).
1752        let mut old: VecDeque<(Instant, f64, f64)> = VecDeque::new();
1753        old.push_back((now - Duration::from_millis(500), 10.0, 10.0));
1754        assert_eq!(compute_drag_velocity(&old, now), (0.0, 0.0));
1755        // Eje y positivo (scroll vertical típico): 5 px cada 25 ms ⇒ 200 px/s.
1756        let mut vy_samples: VecDeque<(Instant, f64, f64)> = VecDeque::new();
1757        vy_samples.push_back((now - Duration::from_millis(75), 0.0, 5.0));
1758        vy_samples.push_back((now - Duration::from_millis(50), 0.0, 5.0));
1759        vy_samples.push_back((now - Duration::from_millis(25), 0.0, 5.0));
1760        let (_, vy) = compute_drag_velocity(&vy_samples, now);
1761        assert!((vy - 200.0).abs() < 1.0, "vy={vy}");
1762    }
1763
1764    #[test]
1765    fn double_tap_ventana_y_distancia() {
1766        let t0 = Instant::now();
1767        let p = PhysicalPosition::new(100.0, 100.0);
1768        // Sin tap previo → nunca califica.
1769        assert!(!double_tap_qualifies(None, t0, p));
1770        // Segundo tap a tiempo (100 ms < 400) y cerca (3px < 16) → califica.
1771        let near = PhysicalPosition::new(102.0, 102.0);
1772        assert!(double_tap_qualifies(
1773            Some((t0, p)),
1774            t0 + Duration::from_millis(100),
1775            near
1776        ));
1777        // A tiempo pero lejos (>16px) → no.
1778        let far = PhysicalPosition::new(140.0, 100.0);
1779        assert!(!double_tap_qualifies(
1780            Some((t0, p)),
1781            t0 + Duration::from_millis(100),
1782            far
1783        ));
1784        // Cerca pero tarde (>400 ms) → no.
1785        assert!(!double_tap_qualifies(
1786            Some((t0, p)),
1787            t0 + Duration::from_millis(600),
1788            near
1789        ));
1790    }
1791}
1792
1793/// **El `Handle` es un despachador**, y por eso `llimphi-motion` ya no necesita
1794/// conocerlo.
1795///
1796/// Un `Handle` es dos cosas pegadas —la capacidad de mandar un `Msg` y el bucle
1797/// de escritorio que lo hospeda— y todo lo que quería la primera se llevaba la
1798/// segunda de arriba: winit, wgpu y el backend `vello`, que es excluyente con el
1799/// de una app enjaulada. Con este impl, quien sólo despacha pide
1800/// `llimphi_despacho::Despachador` y sirve de los dos lados de la frontera.
1801impl<Msg: Send + 'static> llimphi_despacho::Despachador<Msg> for Handle<Msg> {
1802    fn despachar(&self, msg: Msg) {
1803        self.dispatch(msg)
1804    }
1805
1806    /// En el escritorio sí hay hilos, así que el trabajo va a uno de verdad en
1807    /// vez de correr inline como el default del trait.
1808    fn lanzar(&self, trabajo: Box<dyn FnOnce() -> Msg + Send + 'static>) {
1809        self.spawn(trabajo)
1810    }
1811}