tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
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}