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