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// eventloop/mod.rs — Bucle Elm sobre winit: núcleo del runtime llimphi-ui.
2//
3// Este módulo actúa como organizador: declara los submódulos por responsabilidad
4// e implementa los tres puntos de entrada del `ApplicationHandler` de winit
5// (`resumed`, `user_event`, `about_to_wait`). El handler de `window_event`
6// delega a `input` (primaria) o `secondary` según el `WindowId`.
7//
8// Submódulos:
9// - helpers — funciones puras (hit-test helpers, selección, layout builders)
10// - input — manejo de todos los WindowEvent de la ventana primaria
11// - redraw — ciclo mount → layout → paint → GPU → present
12// - secondary — gestión de ventanas OS secundarias (opt-in)
13// - a11y_rt — integración AccessKit en tiempo de ejecución
14
15mod a11y_rt;
16mod helpers;
17mod input;
18mod perf;
19/// El vigía del hilo de UI: cronometra `update`/`init` y avisa si bloquean.
20/// Cierra el agujero que `auditar-hilo-ui.py` declara suyo (no ve cómputo de
21/// CPU). Ver `COMPUTO-FUERA-DEL-HILO-UI.md`.
22mod vigia;
23mod redraw;
24mod secondary;
25
26use super::*;
27
28/// Traza un hito de arranque con su epoch-ms si `LLIMPHI_TIMING` está puesto.
29/// Sirve para perfilar el camino frío hasta el primer frame (init de wgpu/Hal,
30/// surface, renderer) sin afectar a nadie en condiciones normales. Off por
31/// defecto: una sola lectura de env, cero costo si no está.
32pub(crate) fn timing_hito(hito: &str) {
33 // Web: SIEMPRE a la consola del navegador. Los hitos son un puñado y sólo
34 // de arranque, y en una página no hay dónde poner `LLIMPHI_TIMING` — sin
35 // esto el arranque en web es una caja negra (`eprintln!` va a /dev/null y
36 // `SystemTime::now()` ni siquiera existe en wasm32).
37 #[cfg(target_arch = "wasm32")]
38 {
39 web_sys::console::log_1(&format!("[llimphi-timing] {hito}").into());
40 return;
41 }
42 #[cfg(not(target_arch = "wasm32"))]
43 if std::env::var_os("LLIMPHI_TIMING").is_some() {
44 let ms = std::time::SystemTime::now()
45 .duration_since(std::time::UNIX_EPOCH)
46 .map(|d| d.as_millis())
47 .unwrap_or(0);
48 llimphi_hal::avisar!("[llimphi-timing] {hito} epoch_ms={ms}");
49 }
50}
51
52/// Reporta un error del camino de dibujo por donde se lo pueda LEER.
53///
54/// En nativo es un `eprintln!` como siempre. **En wasm `eprintln!` escribe en
55/// un `/dev/null`**: no hay stderr en una página, así que un fallo del
56/// rasterizador desaparecía sin dejar rastro y el síntoma llegaba disfrazado
57/// de «el canvas no pinta». Costó media tarde de diagnóstico creyendo que el
58/// bug estaba en el modo alfa del swapchain, cuando el render venía fallando
59/// y avisando a nadie.
60pub(crate) fn aviso_error(msg: &str) {
61 #[cfg(target_arch = "wasm32")]
62 web_sys::console::error_1(&format!("llimphi-ui · {msg}").into());
63 #[cfg(not(target_arch = "wasm32"))]
64 llimphi_hal::avisar!("{msg}");
65}
66
67/// Avisa a winit que estamos por presentar. **Es lo que engancha la app al
68/// ritmo del compositor**, y hay que llamarlo justo antes de cada `present`.
69///
70/// En Wayland, winit sólo pide un `wl_surface.frame` si la app llama a esto
71/// (`window/mod.rs::pre_present_notify` → `request_frame_callback`). Y su
72/// bucle (`event_loop/mod.rs:486`) suprime el `RedrawRequested` mientras haya
73/// un callback pedido sin llegar. Sin esta llamada, `frame_callback_state()`
74/// nunca vale `Requested`, el gate **nunca** se activa y la app repinta contra
75/// su propio temporizador **ignorando por completo al compositor**: sigue
76/// gastando GPU en otro escritorio, tapada o con la pantalla apagada.
77///
78/// Hallado 2026-07-21 midiendo `media-app`: oculto en otro escritorio rendía
79/// los mismos 4 fps y el mismo 10% de un core que visible. El gate de frames
80/// de `mirada` no tenía efecto porque **ningún cliente llimphi escuchaba**.
81///
82/// No bloquea ni cambia el modo de presentación (seguimos en `Mailbox`, ver
83/// `choose_present_mode`): sólo adjunta el callback para que winit pueda
84/// pacear. Y no pierde repintados — winit consulta el gate **antes** de
85/// consumir el flag de `request_redraw`, así que un redraw suprimido queda
86/// pendiente y sale en cuanto llega el callback.
87///
88/// Escotilla: `LLIMPHI_FRAME_CALLBACK=0` vuelve al comportamiento viejo (libre,
89/// sin pacing) sin recompilar. Está porque esto afecta a TODAS las apps del
90/// escritorio y el modo de falla —una ventana visible que no repinta— se
91/// arregla más rápido con una variable de entorno que con un build.
92pub(crate) fn notificar_present(window: &llimphi_hal::winit::window::Window) {
93 use std::sync::OnceLock;
94 static ACTIVO: OnceLock<bool> = OnceLock::new();
95 if *ACTIVO.get_or_init(|| {
96 !matches!(
97 std::env::var("LLIMPHI_FRAME_CALLBACK").as_deref(),
98 Ok("0") | Ok("no") | Ok("off")
99 )
100 }) {
101 window.pre_present_notify();
102 }
103}
104
105/// ¿Está habilitado el **modo vsync** ([`crate::App::wants_vsync_frames`])?
106///
107/// Escotilla `LLIMPHI_VSYNC_FRAMES=0`: vuelve al comportamiento viejo (cada app
108/// se marca su propio ritmo con `spawn_periodic`) sin recompilar. Existe por lo
109/// mismo que la de `notificar_present`: esto cambia el reloj de cuadros de
110/// cualquier app que lo pida, y sirve para el A/B honesto de antes/después.
111pub(crate) fn modo_vsync_activo() -> bool {
112 use std::sync::OnceLock;
113 static ACTIVO: OnceLock<bool> = OnceLock::new();
114 *ACTIVO.get_or_init(|| {
115 !matches!(
116 std::env::var("LLIMPHI_VSYNC_FRAMES").as_deref(),
117 Ok("0") | Ok("no") | Ok("off")
118 )
119 })
120}
121
122/// Marca el PRIMER present (cualquiera de los dos caminos) — el frame que de
123/// verdad llega a la pantalla. `Once` para que sólo dispare una vez.
124pub(crate) fn timing_primer_present() {
125 use std::sync::Once;
126 static UNA_VEZ: Once = Once::new();
127 UNA_VEZ.call_once(|| timing_hito("primer-present"));
128}
129
130/// Aplica la **pantalla completa inicial** justo después del primer present.
131///
132/// Existe porque pedirla antes no llega, por dos guardas distintas y del mismo
133/// tipo (una en cada lado de la frontera), las dos verificadas en metal el
134/// 2026-08-25 con el launcher:
135///
136/// * Desde `App::init` con [`crate::Handle::set_fullscreen`]: el runtime
137/// todavía está construyendo su estado y el handler descarta el evento
138/// (`if let Some(state) = self.state`).
139/// * Como atributo de la ventana (`WindowAttributes::with_fullscreen`, que es
140/// lo correcto según xdg-shell: `set_fullscreen` antes del primer commit
141/// debe honrarse en el configure inicial): el compositor `mirada` busca la
142/// ventana en su lista en `fullscreen_request` y, si todavía no está mapeada,
143/// **tira el pedido sin decir nada**. Eso es un defecto del compositor y está
144/// anotado aparte — afecta a cualquier app que quiera arrancar en pantalla
145/// completa, no sólo a ésta.
146///
147/// Después del primer present la ventana ya está mapeada de los dos lados, así
148/// que el pedido llega. El atributo se sigue mandando: en un compositor que lo
149/// honre, esto es un no-op idempotente.
150pub(crate) fn aplicar_fullscreen_inicial<A: App>(window: &Window) {
151 use std::sync::Once;
152 static UNA_VEZ: Once = Once::new();
153 if !A::initial_fullscreen() {
154 return;
155 }
156 UNA_VEZ.call_once(|| {
157 window.set_fullscreen(Some(llimphi_hal::winit::window::Fullscreen::Borderless(
158 None,
159 )));
160 });
161}
162
163pub(crate) fn build_window_attributes<A: App>() -> WindowAttributes {
164 let (w, h) = A::initial_size();
165 let attrs = WindowAttributes::default()
166 .with_title(A::title())
167 .with_inner_size(LogicalSize::new(w, h))
168 // Sin esto winit declara la superficie ENTERA opaca (marca una
169 // `wl_region` de 0,0..i32::MAX), y el alfa del fondo de la app queda
170 // mentido: se ve un color plano donde debería verse lo de atrás.
171 .with_transparent(A::transparent());
172 // Pantalla completa **al crear**, no por pedido: `Handle::set_fullscreen`
173 // desde `init` llega cuando el runtime no tiene ventana todavía y su
174 // handler lo tira sin decir nada.
175 let attrs = if A::initial_fullscreen() {
176 attrs.with_fullscreen(Some(llimphi_hal::winit::window::Fullscreen::Borderless(
177 None,
178 )))
179 } else {
180 attrs
181 };
182 // En Linux, `with_name` del trait de Wayland mapea al `app_id` del
183 // xdg-toplevel — lo que el compositor (`mirada-compositor`) usa para
184 // reconocer ventanas especiales (greeter, launcher…).
185 #[cfg(all(target_os = "linux", not(target_os = "android")))]
186 {
187 use llimphi_hal::winit::platform::wayland::WindowAttributesExtWayland;
188 if let Some(id) = A::app_id() {
189 return attrs.with_name(id, "");
190 }
191 // Sin `app_id` declarado NO se mandaba nada, y entonces el compositor
192 // rellenaba con un genérico —mirada pone «cliente»— que **163 de las
193 // 178 apps Llimphi compartían**. Un identificador que comparten 163
194 // apps no identifica: obligó a desempatarlas por el título (hay
195 // workarounds así en `mirada-compositor/handlers.rs` para distinguir
196 // Brave de Zen), rompió las reglas de ventana, y dejó al plano de
197 // control sin forma de nombrar una ventana que no fuera su texto.
198 //
199 // El nombre del ejecutable es estable, único y ya es lo que el
200 // operador escribiría. Declarar `app_id()` sigue ganando: esto es el
201 // piso, no el techo.
202 if let Some(id) = app_id_del_ejecutable() {
203 return attrs.with_name(id, "");
204 }
205 }
206 attrs
207}
208
209/// El nombre del ejecutable, para usarlo como `app_id` de último recurso.
210///
211/// `None` sólo si el sistema no sabe decir cuál es su propio ejecutable o si el
212/// nombre no es UTF-8 — casos en los que mandar basura sería peor que dejar que
213/// el compositor ponga su genérico.
214#[cfg(all(target_os = "linux", not(target_os = "android")))]
215fn app_id_del_ejecutable() -> Option<String> {
216 let exe = std::env::current_exe().ok()?;
217 let nombre = exe.file_name()?.to_str()?.trim();
218 (!nombre.is_empty()).then(|| nombre.to_string())
219}
220
221/// Empuja al adapter AccessKit el árbol del último frame pintado. Llamar tras
222/// guardar `state.last_render`. `update_if_active` no construye el árbol si no
223/// hay tecnología asistiva activa (coste cero en ese caso). Pública sólo
224/// dentro del crate; las tests no la necesitan.
225fn push_a11y_tree<A: App>(state: &mut RuntimeState<A>) {
226 let Some(cache) = state.last_render.as_ref() else {
227 return;
228 };
229 // El foco que tenemos es un id opaco u64 (`focusable`); el árbol AccessKit
230 // necesita el índice del MountedNode. Resolvemos buscando.
231 let focused_idx = state.focused.and_then(|fid| {
232 cache
233 .mounted
234 .nodes
235 .iter()
236 .position(|n| n.focusable == Some(fid))
237 });
238 let app_name = A::window_title(state.model.as_ref().expect("model"))
239 .unwrap_or_else(|| String::from("Llimphi"));
240 let tree_id = state.a11y_tree_id;
241 state.a11y_adapter.update_if_active(|| {
242 crate::a11y::build_tree(&cache.mounted, &cache.computed, focused_idx, &app_name, tree_id)
243 });
244}
245
246impl<A: App> Runtime<A> {
247 /// El cuerpo de `resumed`: crea la ventana y arranca el stack GPU. En
248 /// escritorio y Android termina instalando el estado en la misma llamada;
249 /// en web sale antes y lo completa `instalar_gpu_pendiente` (ver ahí).
250 fn arrancar(&mut self, event_loop: &ActiveEventLoop) {
251 if self.state.is_some() {
252 return;
253 }
254 timing_hito("resumed:entrada");
255 #[cfg(not(target_arch = "wasm32"))]
256 let attrs = build_window_attributes::<A>();
257 // Web: la ventana ES un `<canvas>`. Si `run_web` recibió uno del
258 // documento, winit se monta sobre ÉSE (la página decide dónde va y con
259 // qué CSS); si no, winit crea el suyo y lo cuelga del `<body>`.
260 #[cfg(target_arch = "wasm32")]
261 let attrs = {
262 use llimphi_hal::winit::platform::web::WindowAttributesExtWebSys;
263 let base = build_window_attributes::<A>();
264 match self.canvas.clone() {
265 Some(c) => base.with_canvas(Some(c)),
266 None => base.with_append(true),
267 }
268 };
269 let window = event_loop.create_window(attrs).expect("create window");
270 let window = Arc::new(window);
271 // IME opt-in: sólo se habilita si la app lo pide (ver `App::ime_allowed`).
272 // Con IME activo el texto compuesto llega por `WindowEvent::Ime`.
273 if A::ime_allowed() {
274 window.set_ime_allowed(true);
275 }
276 // Adapter AccessKit: lo creamos ANTES del primer redraw, conectado al
277 // EventLoopProxy del runtime. El adapter emitirá `accesskit_winit::Event`
278 // (Initial tree requested, ActionRequested, deactivated) — nuestro
279 // `From<accesskit_winit::Event> for UserEvent<Msg>` los rutea como
280 // `UserEvent::A11y(...)` para que entren por el mismo `user_event`.
281 let a11y_proxy: EventLoopProxy<UserEvent<A::Msg>> =
282 match &self.handle.inner {
283 HandleInner::Real(p) => p.clone(),
284 HandleInner::Test => unreachable!("resumed sin event loop real"),
285 HandleInner::Lifted(_) => unreachable!("el runtime nunca corre con un handle lifteado"),
286 };
287 let a11y_adapter =
288 accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, a11y_proxy);
289 // **ROOT, no un uuid por proceso.** `accesskit_consumer::Tree::new`
290 // PANIQUEA si el `TreeUpdate` con el que se inicializa no trae
291 // `TreeId::ROOT` («Cannot initialize with a subtree»): los uuid propios
292 // son para SUBárboles injertados, no para el árbol de una ventana.
293 // Cada adapter tiene su propio consumer, así que cada ventana es raíz
294 // de lo suyo. Con un v4 acá, la app se caía en el instante en que un
295 // lector de pantalla se activaba — y como el adapter sólo publica
296 // cuando `org.a11y.Status.ScreenReaderEnabled` es `true`, la caída no
297 // aparecía nunca en una sesión normal.
298 let a11y_tree_id = accesskit::TreeId::ROOT;
299 timing_hito("resumed:antes-de-Hal");
300 // Web: el stack GPU se pide ASÍNCRONO y `resumed` termina sin estado.
301 // No es una preferencia de estilo — `request_adapter`/`request_device`
302 // son promesas de JS y el main thread del navegador no se puede
303 // bloquear (`pollster::block_on` paniquea ahí). El futuro deja el
304 // resultado en `gpu_pendiente` y pide un redraw; `instalar_gpu_pendiente`
305 // lo recoge en el próximo evento y termina de armar el `RuntimeState`.
306 // Hasta entonces cada handler sale por su `let Some(state) … else`, que
307 // ya existía para el hueco equivalente de Android entre `resumed` y la
308 // NativeWindow válida.
309 #[cfg(target_arch = "wasm32")]
310 {
311 let pendiente = self.gpu_pendiente.clone();
312 let w = window.clone();
313 wasm_bindgen_futures::spawn_local(async move {
314 match Hal::new(None).await {
315 Ok(hal) => match WinitSurface::new(&hal, w.clone()) {
316 Ok(surface) => {
317 timing_hito("web:futuro-ok");
318 *pendiente.borrow_mut() = Some((w.clone(), hal, surface));
319 // Despierta al event loop: sin esto el runtime
320 // queda dormido en `Wait` y nadie recoge el stack.
321 w.request_redraw();
322 }
323 Err(e) => web_sys::console::error_1(
324 &format!("llimphi-ui · surface web: {e}").into(),
325 ),
326 },
327 Err(e) => web_sys::console::error_1(
328 &format!(
329 "llimphi-ui · no hay GPU en este navegador ({e}). \
330 Hace falta WebGPU."
331 )
332 .into(),
333 ),
334 }
335 });
336 // El adapter a11y y el tree id se rearman en `instalar_gpu_pendiente`
337 // (que también tiene el `&ActiveEventLoop`); acá se descartan.
338 let _ = (a11y_adapter, a11y_tree_id);
339 return;
340 }
341 // Desktop: adapter primero, surface después (la secuencia clásica).
342 #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
343 let (hal, surface) = {
344 // ⚠ CON display, y no es un adorno: sin él wgpu abre el display EGL por la
345 // plataforma surfaceless, que no tiene configs de ventana ⇒ la surface no es
346 // presentable ⇒ `caps.formats` sale VACÍA y la línea de abajo panica con un
347 // «index out of bounds» que no nombra nada de esto. No se ve en una máquina con
348 // Vulkan (PRIMARY no necesita el display) y se ve SIEMPRE en una sin él. La
349 // ventana ya existe acá — se creó para hacerle la surface. Ver `Hal::new_con_display`.
350 let hal =
351 pollster::block_on(Hal::new_con_display(None, window.clone())).expect("hal");
352 timing_hito("resumed:Hal-listo");
353 let surface = WinitSurface::new(&hal, window.clone()).expect("surface");
354 (hal, surface)
355 };
356 // Android: surface-first obligatorio. La surface debe existir ANTES
357 // del request_adapter(compatible_surface=Some(...)) y no puede
358 // recrearse sobre la misma ANativeWindow (ERROR_NATIVE_WINDOW_IN_USE_KHR).
359 // `recreate_for_window` ya implementa exactamente ese orden.
360 #[cfg(target_os = "android")]
361 let (hal, surface) =
362 Hal::recreate_for_window(window.clone()).expect("hal+surface android");
363 #[cfg(not(target_arch = "wasm32"))]
364 self.instalar_estado(window, hal, surface, a11y_adapter, a11y_tree_id);
365 }
366}
367
368impl<A: App> Runtime<A> {
369 /// Termina de armar el [`RuntimeState`] una vez que el stack GPU existe.
370 /// Está separado de `resumed` porque en web el `Hal` llega un tick después
371 /// (promesa de JS), y todo esto es idéntico en los tres targets.
372 fn instalar_estado(
373 &mut self,
374 window: Arc<Window>,
375 hal: Hal,
376 surface: WinitSurface,
377 a11y_adapter: accesskit_winit::Adapter,
378 a11y_tree_id: accesskit::TreeId,
379 ) {
380 let renderer = Renderer::new(&hal).expect("renderer");
381 timing_hito("resumed:renderer-listo");
382 let overlay_compositor = llimphi_hal::OverlayCompositor::new(&hal.device);
383 let blur_compositor = llimphi_hal::BlurCompositor::new(&hal.device);
384 let color_filter_compositor = llimphi_hal::ColorFilterCompositor::new(&hal.device);
385 let typesetter = llimphi_text::Typesetter::new();
386 // El puente para soltar archivos: bajo Wayland winit no emite
387 // `DroppedFile`, así que la escucha la monta `file_drop` sobre el mismo
388 // `wl_display` y nos devuelve las rutas por `UserEvent::FileDrop`. En
389 // los demás backends es un no-op. Va acá, con la superficie ya creada,
390 // porque el puente necesita su puntero para no aceptar drops ajenos.
391 // El asa vuelve para guardarla en el estado: es lo que apaga el hilo
392 // cuando la ventana se va (ver `RuntimeState::puente`).
393 let puente = match &self.handle.inner {
394 HandleInner::Real(p) => crate::file_drop::arrancar(&window, p.clone()),
395 _ => None,
396 };
397 window.request_redraw();
398 self.state = Some(RuntimeState {
399 puente,
400 window,
401 hal,
402 surface,
403 renderer,
404 scene: Escena::new(),
405 over_scratch: None,
406 over_huella: None,
407 over_tramos: [0; 6],
408 over_coords: Vec::new(),
409 escena_huella: None,
410 overlay_compositor,
411 blur_compositor,
412 color_filter_compositor,
413 // Primer arranque → `A::init`; re-entrada tras un `suspended`
414 // (Android background→foreground) → el modelo parkeado sigue vivo.
415 model: Some(match self.parked_model.take() {
416 Some(m) => m,
417 None => vigia::aplicar_init::<A>(&self.handle),
418 }),
419 cursor: PhysicalPosition::new(0.0, 0.0),
420 // Una ventana nace en ventana; si el compositor la abre a pantalla
421 // completa (una regla de `rules.ron`), el primer `Resized` lo
422 // detecta y la app se entera por `on_fullscreen`.
423 fullscreen: false,
424 cursor_arrastre: None,
425 arrastre_entregado: false,
426 modifiers: Modifiers::default(),
427 typesetter,
428 layout: LayoutTree::new(),
429 overlay_layout: LayoutTree::new(),
430 last_render: None,
431 mount_pool: None,
432 layout_pool: None,
433 hovered: None,
434 drag: None,
435 focused: None,
436 last_title: None,
437 anim_registry: llimphi_compositor::AnimRegistry::new(),
438 size_anim_registry: llimphi_compositor::SizeAnimRegistry::new(),
439 hero_registry: llimphi_compositor::HeroRegistry::new(),
440 ripple_registry: llimphi_compositor::RippleRegistry::new(),
441 repaint_cache: llimphi_compositor::RepaintCache::new(),
442 detector_boundaries: perf::boundary_hint()
443 .map(|_| llimphi_compositor::DetectorBoundaries::new()),
444 retained_layout: None,
445 memo_registry: llimphi_compositor::MemoRegistry::new(),
446 last_tap: None,
447 pending_long_press: None,
448 pending_click: None,
449 pending_right_click: None,
450 pending_wheel: None,
451 held_key: None,
452 winit_repeats: false,
453 retained: None,
454 selection: None,
455 a11y_adapter,
456 a11y_tree_id,
457 needs_device_rebuild: false,
458 acquire_fallidos: 0,
459 });
460 // Sincroniza el factor de escala inicial (el de la ventana recién
461 // creada) ANTES del primer render: así una app que dependa del DPI
462 // (p. ej. `devicePixelRatio` en puriy) ya lo tiene correcto en su
463 // primera pasada, sin esperar a un ScaleFactorChanged.
464 if let Some(state) = self.state.as_mut() {
465 let scale = state.window.scale_factor();
466 if let Some(msg) = A::on_scale_factor(state.model.as_ref().expect("model"), scale) {
467 let model = state.model.take().expect("model");
468 state.model = Some(vigia::aplicar_update::<A>(model, msg, &self.handle));
469 state.invalidar_render();
470 }
471 }
472 }
473
474 /// Web: recoge el stack GPU que dejó el futuro de `resumed` y termina de
475 /// instalar el estado. Se llama al principio de cada evento de ventana; el
476 /// caso normal (ya instalado, o nada pendiente) es un `borrow` + `is_none`.
477 #[cfg(target_arch = "wasm32")]
478 fn instalar_gpu_pendiente(&mut self, event_loop: &ActiveEventLoop) {
479 if self.state.is_some() {
480 return;
481 }
482 let Some((window, hal, surface)) = self.gpu_pendiente.borrow_mut().take() else {
483 return;
484 };
485 timing_hito("web:Hal-listo");
486 let a11y_proxy: EventLoopProxy<UserEvent<A::Msg>> = match &self.handle.inner {
487 HandleInner::Real(p) => p.clone(),
488 HandleInner::Test => unreachable!("runtime web sin event loop real"),
489 HandleInner::Lifted(_) => unreachable!("el runtime nunca corre con un handle lifteado"),
490 };
491 let a11y_adapter =
492 accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, a11y_proxy);
493 // ROOT por lo mismo que en `arrancar` — ver el comentario de allá.
494 let a11y_tree_id = accesskit::TreeId::ROOT;
495 self.instalar_estado(window, hal, surface, a11y_adapter, a11y_tree_id);
496 }
497}
498
499impl<A: App> ApplicationHandler<UserEvent<A::Msg>> for Runtime<A> {
500 fn resumed(&mut self, event_loop: &ActiveEventLoop) {
501 self.arrancar(event_loop);
502 }
503
504 fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent<A::Msg>) {
505 // Corre ENTRE cuadros (los ticks periódicos de una app llegan por acá,
506 // como `Msg` del canal), así que su costo va al tramo «otros» del
507 // período. Sin esto, el hueco `entre` no se puede repartir.
508 let _t = perf::CronometroOtros::nuevo();
509 match event {
510 UserEvent::Quit => event_loop.exit(),
511 UserEvent::Dormir(respuesta) => {
512 let ruta = self
513 .state
514 .as_mut()
515 .and_then(|st| st.model.as_mut())
516 .and_then(crate::dormir::dormir_modelo::<A>);
517 let durmio = ruta.is_some();
518 let _ = respuesta.send(ruta);
519 // Se va sola: lo que había ya está guardado. `exiting` corre
520 // igual, así que `al_salir` sigue volcando lo suyo.
521 if durmio {
522 event_loop.exit();
523 }
524 }
525 UserEvent::FileDrop(path) => {
526 // Wayland: el drop no viene de winit sino del hilo de
527 // `file_drop`. De acá para abajo es exactamente el mismo camino
528 // que `WindowEvent::DroppedFile`.
529 self.aplicar_file_drop(path);
530 }
531 UserEvent::Msg(msg) => {
532 // Un Msg del canal (Handle::dispatch, ticks periódicos, trabajo
533 // de fondo) muta el modelo compartido y repinta TODAS las
534 // ventanas — así un cambio se refleja tanto en la primaria como
535 // en las secundarias (config) sin importar de dónde vino.
536 self.dispatch_model(msg);
537 }
538 UserEvent::OpenWindow { key, title, width, height } => {
539 self.open_secondary(event_loop, key, title, width, height);
540 }
541 UserEvent::CloseWindow { key } => {
542 if let Some(pos) = self.secondaries.iter().position(|s| s.key == key) {
543 // Drop de la SecondaryState → se destruye la ventana/surface.
544 self.secondaries.remove(pos);
545 }
546 }
547 UserEvent::SetMinimized(min) => {
548 if let Some(state) = self.state.as_ref() {
549 state.window.set_minimized(min);
550 }
551 }
552 UserEvent::SetFullscreen(on) => {
553 if let Some(state) = self.state.as_ref() {
554 // `Borderless(None)` = el monitor donde ya está la ventana,
555 // sin cambiar el modo de video (lo que quieren los
556 // reproductores). El resize lo trae `WindowEvent::Resized`
557 // por el camino de siempre: no hay que tocar la surface acá.
558 state.window.set_fullscreen(on.then_some(
559 llimphi_hal::winit::window::Fullscreen::Borderless(None),
560 ));
561 }
562 }
563 UserEvent::SetContentType(kind) => {
564 if let Some(state) = self.state.as_ref() {
565 // Va sobre la `wl_surface` de winit por una conexión ajena
566 // montada sobre el mismo display — ver `crate::content_type`.
567 crate::content_type::aplicar(&state.window, kind);
568 }
569 }
570 UserEvent::A11y(ev) => {
571 self.handle_a11y_event(ev);
572 }
573 }
574 }
575
576 /// Android/iOS/web: la app pasó a background y la NativeWindow deja de
577 /// ser válida — TODO el estado GPU (surface, device, texturas, renderer)
578 /// debe soltarse. El modelo de la app se parquea y `resumed` lo retoma
579 /// al volver, recreando el stack gráfico desde cero contra la ventana
580 /// nueva. En desktop winit nunca llama esto.
581 fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
582 if let Some(mut st) = self.state.take() {
583 self.parked_model = st.model.take();
584 }
585 // Las secundarias también cuelgan de la NativeWindow/device viejos.
586 self.secondaries.clear();
587 self.active_touch = None;
588 }
589
590 fn window_event(
591 &mut self,
592 event_loop: &ActiveEventLoop,
593 _id: WindowId,
594 event: WindowEvent,
595 ) {
596 // Todo evento de ventana que NO sea el repintado corre ENTRE cuadros:
597 // punteros, teclas, resize, foco. Va al tramo «otros» del período. El
598 // `RedrawRequested` se excluye porque su costo ya lo miden `acquire` y
599 // `total`, y contarlo acá lo sumaría dos veces.
600 let _t = (!matches!(event, WindowEvent::RedrawRequested))
601 .then(perf::CronometroOtros::nuevo);
602 // Web: el stack GPU llega un tick después de `resumed` (promesa de JS).
603 // El primer evento tras completarse lo instala — típicamente el
604 // `RedrawRequested` que el propio futuro pidió al terminar.
605 #[cfg(target_arch = "wasm32")]
606 {
607 if self.state.is_none() {
608 timing_hito(&format!("web:evento-sin-estado {event:?}"));
609 }
610 self.instalar_gpu_pendiente(event_loop);
611 }
612 // ¿El evento es de una ventana secundaria? Lo atiende su handler
613 // dedicado (path aparte: la primaria queda 100% intacta).
614 if let Some(idx) = self.secondaries.iter().position(|s| s.window.id() == _id) {
615 self.handle_secondary_event(idx, event);
616 return;
617 }
618 // Touch → puntero: en móvil los eventos llegan como `Touch`, no como
619 // CursorMoved/MouseInput. Traducimos el PRIMER dedo a la secuencia de
620 // mouse equivalente (move+press / move / move+release) y lo ruteamos
621 // por el mismo handler primario — tap=click, arrastre=drag, sin
622 // duplicar ninguna lógica de hit-test/gestos. Dedos extra se ignoran
623 // hasta que el activo suelte (multi-touch real queda para después).
624 if let WindowEvent::Touch(t) = &event {
625 use llimphi_hal::winit::event::{ElementState, MouseButton, TouchPhase};
626 let es_activo = match self.active_touch {
627 Some(id) => id == t.id,
628 None => t.phase == TouchPhase::Started,
629 };
630 if !es_activo {
631 return;
632 }
633 let device_id = t.device_id;
634 let position = t.location;
635 let moved = WindowEvent::CursorMoved { device_id, position };
636 match t.phase {
637 TouchPhase::Started => {
638 self.active_touch = Some(t.id);
639 self.handle_primary_window_event(event_loop, moved);
640 self.handle_primary_window_event(
641 event_loop,
642 WindowEvent::MouseInput {
643 device_id,
644 state: ElementState::Pressed,
645 button: MouseButton::Left,
646 },
647 );
648 }
649 TouchPhase::Moved => {
650 self.handle_primary_window_event(event_loop, moved);
651 }
652 TouchPhase::Ended | TouchPhase::Cancelled => {
653 self.active_touch = None;
654 // Un tap con foco de texto re-afirma el soft keyboard en
655 // el próximo frame (el usuario pudo bajarlo con Atrás y
656 // el estado del IME no es legible).
657 #[cfg(target_os = "android")]
658 {
659 self.soft_input_reassert = true;
660 }
661 self.handle_primary_window_event(event_loop, moved);
662 self.handle_primary_window_event(
663 event_loop,
664 WindowEvent::MouseInput {
665 device_id,
666 state: ElementState::Released,
667 button: MouseButton::Left,
668 },
669 );
670 }
671 }
672 return;
673 }
674 self.handle_primary_window_event(event_loop, event);
675 }
676
677 /// Se ejecuta tras procesar los eventos de cada vuelta, justo antes de que
678 /// el loop se duerma. Es donde vence el **long-press**: si hay uno armado y
679 /// ya pasó su `deadline` (el botón siguió apretado y quieto), se dispara su
680 /// `Msg`. Mientras quede uno pendiente, ponemos `WaitUntil(deadline)` para
681 /// que winit nos despierte a tiempo (con `ControlFlow::Wait` el loop dormiría
682 /// indefinidamente sin un evento que lo despierte). Sin long-press armado,
683 /// volvemos a `Wait` (no dejar un `WaitUntil` viejo: con un deadline pasado
684 /// el loop spinearía). Las animaciones implícitas no usan el control flow
685 /// (piden frames con `request_redraw`), así que esto no las afecta.
686 /// **El bucle se va.** winit garantiza que después de esto no vuelve, así
687 /// que es la última oportunidad de la app para volcar lo que sólo está en
688 /// memoria. Se le pasa el modelo vivo por referencia mutable: lo que se
689 /// lance por el `Handle` acá no lo despacha nadie.
690 fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
691 let Some(state) = self.state.as_mut() else {
692 return;
693 };
694 if let Some(model) = state.model.as_mut() {
695 A::al_salir(model);
696 }
697 crate::dormir::soltar();
698 }
699
700 fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
701 let _t = perf::CronometroOtros::nuevo();
702 let Some(state) = self.state.as_mut() else {
703 return;
704 };
705 let now = crate::Instant::now();
706 // 1. Auto-repeat de tecla: si la sostenida venció, re-emitíla (repeat=true)
707 // y reprograma el próximo disparo al intervalo. Sólo si winit no repite él.
708 //
709 // **Pero no en la misma vuelta en que vence.** Esto corre después del
710 // repintado, que es de la app y puede durar lo que quiera: un cuadro
711 // lento devuelve el control con el `release` sin leer en la cola de la
712 // plataforma mientras el reloj venció. Medido con
713 // `examples/melaza_teclas.rs`: con `view()` durmiendo 3 s, un toque de
714 // tecla salía como press + **repeat inventada** + release, y la
715 // repetición no aparecía en la sonda de winit (`LLIMPHI_DEBUG_KEY=1`)
716 // porque no era de winit: era de acá.
717 //
718 // Así que al vencer se pide una vuelta más (ver `HeldKey::confirmada`)
719 // y se dispara en la siguiente, cuando la cola ya se leyó.
720 let mut confirmar_repeat = false;
721 if !state.winit_repeats && state.held_key.as_ref().is_some_and(|h| now >= h.next) {
722 if !state.held_key.as_ref().expect("held").confirmada {
723 state.held_key.as_mut().expect("held").confirmada = true;
724 confirmar_repeat = true;
725 } else {
726 let winit_ev = state.held_key.as_ref().expect("held").event.clone();
727 {
728 let h = state.held_key.as_mut().expect("held");
729 h.next = now + KEY_REPEAT_INTERVAL;
730 h.confirmada = false;
731 }
732 let ev = crate::KeyEvent {
733 key: crate::tecla_de_winit(&winit_ev.logical_key),
734 state: crate::KeyState::Pressed,
735 text: winit_ev.text.as_ref().map(|t| t.to_string()),
736 modifiers: state.modifiers,
737 repeat: true,
738 };
739 if let Some(msg) = A::on_key(state.model.as_ref().expect("model"), &ev) {
740 let model = state.model.take().expect("model");
741 state.model = Some(vigia::aplicar_update::<A>(model, msg, &self.handle));
742 state.invalidar_render();
743 state.window.request_redraw();
744 }
745 }
746 }
747 // 2. Long-press: si venció, dispará su `Msg` (lógica existente).
748 if state.pending_long_press.as_ref().is_some_and(|p| now >= p.deadline) {
749 let handler = state.pending_long_press.take().expect("pending").handler;
750 if let Some(msg) = handler.invoke() {
751 let model = state.model.take().expect("model");
752 state.model = Some(vigia::aplicar_update::<A>(model, msg, &self.handle));
753 state.invalidar_render();
754 state.window.request_redraw();
755 }
756 }
757 // 2.bis. Click RETENIDO por un doble-tap exclusivo: si ya se soltó el
758 // botón y venció la ventana del doble, el gesto era un click simple y
759 // recién ahora se sabe. Ver `PendingClick::retener_hasta`.
760 if state
761 .pending_click
762 .as_ref()
763 .is_some_and(|p| p.soltado && p.retener_hasta.is_some_and(|t| now >= t))
764 {
765 let pending = state.pending_click.take().expect("pending");
766 let model = state.model.take().expect("model");
767 state.model = Some(vigia::aplicar_update::<A>(model, pending.msg, &self.handle));
768 state.invalidar_render();
769 state.window.request_redraw();
770 }
771 // 3. Dormir hasta el deadline pendiente MÁS CERCANO (repeat o long-press);
772 // sin ninguno, `Wait` (hasta el próximo evento).
773 // Con una repetición a confirmar, la vuelta tiene que ser YA: `Poll` hace
774 // una pasada sin dormir, y esa pasada lee la cola de la plataforma — que
775 // es todo lo que hace falta para saber si el `release` estaba esperando.
776 if confirmar_repeat {
777 event_loop.set_control_flow(ControlFlow::Poll);
778 return;
779 }
780 let lp = state.pending_long_press.as_ref().map(|p| p.deadline);
781 // El click retenido también es un vencimiento: sin esto la ventana se
782 // duerme en `Wait` y el click sale recién con el próximo evento ajeno.
783 let pc = state
784 .pending_click
785 .as_ref()
786 .filter(|p| p.soltado)
787 .and_then(|p| p.retener_hasta);
788 let hk = (!state.winit_repeats)
789 .then(|| state.held_key.as_ref().map(|h| h.next))
790 .flatten();
791 match [lp, hk, pc].into_iter().flatten().min() {
792 Some(d) => event_loop.set_control_flow(ControlFlow::WaitUntil(d)),
793 None => event_loop.set_control_flow(ControlFlow::Wait),
794 }
795 }
796}
797
798#[cfg(all(test, target_os = "linux", not(target_os = "android")))]
799mod pruebas_de_identidad {
800 use super::*;
801
802 /// El piso de identidad: sin `app_id()` declarado, el toplevel igual sale
803 /// con un nombre propio. Antes salía sin ninguno y el compositor lo metía
804 /// en la misma bolsa que a otras 162 apps.
805 #[test]
806 fn sin_app_id_declarado_igual_hay_un_nombre_propio() {
807 let id = app_id_del_ejecutable().expect("el ejecutable tiene nombre");
808 assert!(!id.is_empty());
809 assert!(!id.contains('/'), "es el nombre, no la ruta: {id}");
810 // Y es el de ESTE binario, no un literal compartido.
811 let esperado = std::env::current_exe().unwrap().file_name().unwrap().to_string_lossy().to_string();
812 assert_eq!(id, esperado);
813 assert_ne!(id, "cliente", "el genérico es justo lo que esto viene a evitar");
814 }
815}