tawasuyu

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Definiciones

⎇ main llimphi-ui/src/eventloop/mod.rs Rust · 815 líneas · 39.0 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

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}