// eventloop/mod.rs — Bucle Elm sobre winit: núcleo del runtime llimphi-ui. // // Este módulo actúa como organizador: declara los submódulos por responsabilidad // e implementa los tres puntos de entrada del `ApplicationHandler` de winit // (`resumed`, `user_event`, `about_to_wait`). El handler de `window_event` // delega a `input` (primaria) o `secondary` según el `WindowId`. // // Submódulos: // - helpers — funciones puras (hit-test helpers, selección, layout builders) // - input — manejo de todos los WindowEvent de la ventana primaria // - redraw — ciclo mount → layout → paint → GPU → present // - secondary — gestión de ventanas OS secundarias (opt-in) // - a11y_rt — integración AccessKit en tiempo de ejecución mod a11y_rt; mod helpers; mod input; mod perf; /// El vigía del hilo de UI: cronometra `update`/`init` y avisa si bloquean. /// Cierra el agujero que `auditar-hilo-ui.py` declara suyo (no ve cómputo de /// CPU). Ver `COMPUTO-FUERA-DEL-HILO-UI.md`. mod vigia; mod redraw; mod secondary; use super::*; /// Traza un hito de arranque con su epoch-ms si `LLIMPHI_TIMING` está puesto. /// Sirve para perfilar el camino frío hasta el primer frame (init de wgpu/Hal, /// surface, renderer) sin afectar a nadie en condiciones normales. Off por /// defecto: una sola lectura de env, cero costo si no está. pub(crate) fn timing_hito(hito: &str) { // Web: SIEMPRE a la consola del navegador. Los hitos son un puñado y sólo // de arranque, y en una página no hay dónde poner `LLIMPHI_TIMING` — sin // esto el arranque en web es una caja negra (`eprintln!` va a /dev/null y // `SystemTime::now()` ni siquiera existe en wasm32). #[cfg(target_arch = "wasm32")] { web_sys::console::log_1(&format!("[llimphi-timing] {hito}").into()); return; } #[cfg(not(target_arch = "wasm32"))] if std::env::var_os("LLIMPHI_TIMING").is_some() { let ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis()) .unwrap_or(0); llimphi_hal::avisar!("[llimphi-timing] {hito} epoch_ms={ms}"); } } /// Reporta un error del camino de dibujo por donde se lo pueda LEER. /// /// En nativo es un `eprintln!` como siempre. **En wasm `eprintln!` escribe en /// un `/dev/null`**: no hay stderr en una página, así que un fallo del /// rasterizador desaparecía sin dejar rastro y el síntoma llegaba disfrazado /// de «el canvas no pinta». Costó media tarde de diagnóstico creyendo que el /// bug estaba en el modo alfa del swapchain, cuando el render venía fallando /// y avisando a nadie. pub(crate) fn aviso_error(msg: &str) { #[cfg(target_arch = "wasm32")] web_sys::console::error_1(&format!("llimphi-ui · {msg}").into()); #[cfg(not(target_arch = "wasm32"))] llimphi_hal::avisar!("{msg}"); } /// Avisa a winit que estamos por presentar. **Es lo que engancha la app al /// ritmo del compositor**, y hay que llamarlo justo antes de cada `present`. /// /// En Wayland, winit sólo pide un `wl_surface.frame` si la app llama a esto /// (`window/mod.rs::pre_present_notify` → `request_frame_callback`). Y su /// bucle (`event_loop/mod.rs:486`) suprime el `RedrawRequested` mientras haya /// un callback pedido sin llegar. Sin esta llamada, `frame_callback_state()` /// nunca vale `Requested`, el gate **nunca** se activa y la app repinta contra /// su propio temporizador **ignorando por completo al compositor**: sigue /// gastando GPU en otro escritorio, tapada o con la pantalla apagada. /// /// Hallado 2026-07-21 midiendo `media-app`: oculto en otro escritorio rendía /// los mismos 4 fps y el mismo 10% de un core que visible. El gate de frames /// de `mirada` no tenía efecto porque **ningún cliente llimphi escuchaba**. /// /// No bloquea ni cambia el modo de presentación (seguimos en `Mailbox`, ver /// `choose_present_mode`): sólo adjunta el callback para que winit pueda /// pacear. Y no pierde repintados — winit consulta el gate **antes** de /// consumir el flag de `request_redraw`, así que un redraw suprimido queda /// pendiente y sale en cuanto llega el callback. /// /// Escotilla: `LLIMPHI_FRAME_CALLBACK=0` vuelve al comportamiento viejo (libre, /// sin pacing) sin recompilar. Está porque esto afecta a TODAS las apps del /// escritorio y el modo de falla —una ventana visible que no repinta— se /// arregla más rápido con una variable de entorno que con un build. pub(crate) fn notificar_present(window: &llimphi_hal::winit::window::Window) { use std::sync::OnceLock; static ACTIVO: OnceLock = OnceLock::new(); if *ACTIVO.get_or_init(|| { !matches!( std::env::var("LLIMPHI_FRAME_CALLBACK").as_deref(), Ok("0") | Ok("no") | Ok("off") ) }) { window.pre_present_notify(); } } /// ¿Está habilitado el **modo vsync** ([`crate::App::wants_vsync_frames`])? /// /// Escotilla `LLIMPHI_VSYNC_FRAMES=0`: vuelve al comportamiento viejo (cada app /// se marca su propio ritmo con `spawn_periodic`) sin recompilar. Existe por lo /// mismo que la de `notificar_present`: esto cambia el reloj de cuadros de /// cualquier app que lo pida, y sirve para el A/B honesto de antes/después. pub(crate) fn modo_vsync_activo() -> bool { use std::sync::OnceLock; static ACTIVO: OnceLock = OnceLock::new(); *ACTIVO.get_or_init(|| { !matches!( std::env::var("LLIMPHI_VSYNC_FRAMES").as_deref(), Ok("0") | Ok("no") | Ok("off") ) }) } /// Marca el PRIMER present (cualquiera de los dos caminos) — el frame que de /// verdad llega a la pantalla. `Once` para que sólo dispare una vez. pub(crate) fn timing_primer_present() { use std::sync::Once; static UNA_VEZ: Once = Once::new(); UNA_VEZ.call_once(|| timing_hito("primer-present")); } /// Aplica la **pantalla completa inicial** justo después del primer present. /// /// Existe porque pedirla antes no llega, por dos guardas distintas y del mismo /// tipo (una en cada lado de la frontera), las dos verificadas en metal el /// 2026-08-25 con el launcher: /// /// * Desde `App::init` con [`crate::Handle::set_fullscreen`]: el runtime /// todavía está construyendo su estado y el handler descarta el evento /// (`if let Some(state) = self.state`). /// * Como atributo de la ventana (`WindowAttributes::with_fullscreen`, que es /// lo correcto según xdg-shell: `set_fullscreen` antes del primer commit /// debe honrarse en el configure inicial): el compositor `mirada` busca la /// ventana en su lista en `fullscreen_request` y, si todavía no está mapeada, /// **tira el pedido sin decir nada**. Eso es un defecto del compositor y está /// anotado aparte — afecta a cualquier app que quiera arrancar en pantalla /// completa, no sólo a ésta. /// /// Después del primer present la ventana ya está mapeada de los dos lados, así /// que el pedido llega. El atributo se sigue mandando: en un compositor que lo /// honre, esto es un no-op idempotente. pub(crate) fn aplicar_fullscreen_inicial(window: &Window) { use std::sync::Once; static UNA_VEZ: Once = Once::new(); if !A::initial_fullscreen() { return; } UNA_VEZ.call_once(|| { window.set_fullscreen(Some(llimphi_hal::winit::window::Fullscreen::Borderless( None, ))); }); } pub(crate) fn build_window_attributes() -> WindowAttributes { let (w, h) = A::initial_size(); let attrs = WindowAttributes::default() .with_title(A::title()) .with_inner_size(LogicalSize::new(w, h)) // Sin esto winit declara la superficie ENTERA opaca (marca una // `wl_region` de 0,0..i32::MAX), y el alfa del fondo de la app queda // mentido: se ve un color plano donde debería verse lo de atrás. .with_transparent(A::transparent()); // Pantalla completa **al crear**, no por pedido: `Handle::set_fullscreen` // desde `init` llega cuando el runtime no tiene ventana todavía y su // handler lo tira sin decir nada. let attrs = if A::initial_fullscreen() { attrs.with_fullscreen(Some(llimphi_hal::winit::window::Fullscreen::Borderless( None, ))) } else { attrs }; // En Linux, `with_name` del trait de Wayland mapea al `app_id` del // xdg-toplevel — lo que el compositor (`mirada-compositor`) usa para // reconocer ventanas especiales (greeter, launcher…). #[cfg(all(target_os = "linux", not(target_os = "android")))] { use llimphi_hal::winit::platform::wayland::WindowAttributesExtWayland; if let Some(id) = A::app_id() { return attrs.with_name(id, ""); } // Sin `app_id` declarado NO se mandaba nada, y entonces el compositor // rellenaba con un genérico —mirada pone «cliente»— que **163 de las // 178 apps Llimphi compartían**. Un identificador que comparten 163 // apps no identifica: obligó a desempatarlas por el título (hay // workarounds así en `mirada-compositor/handlers.rs` para distinguir // Brave de Zen), rompió las reglas de ventana, y dejó al plano de // control sin forma de nombrar una ventana que no fuera su texto. // // El nombre del ejecutable es estable, único y ya es lo que el // operador escribiría. Declarar `app_id()` sigue ganando: esto es el // piso, no el techo. if let Some(id) = app_id_del_ejecutable() { return attrs.with_name(id, ""); } } attrs } /// El nombre del ejecutable, para usarlo como `app_id` de último recurso. /// /// `None` sólo si el sistema no sabe decir cuál es su propio ejecutable o si el /// nombre no es UTF-8 — casos en los que mandar basura sería peor que dejar que /// el compositor ponga su genérico. #[cfg(all(target_os = "linux", not(target_os = "android")))] fn app_id_del_ejecutable() -> Option { let exe = std::env::current_exe().ok()?; let nombre = exe.file_name()?.to_str()?.trim(); (!nombre.is_empty()).then(|| nombre.to_string()) } /// Empuja al adapter AccessKit el árbol del último frame pintado. Llamar tras /// guardar `state.last_render`. `update_if_active` no construye el árbol si no /// hay tecnología asistiva activa (coste cero en ese caso). Pública sólo /// dentro del crate; las tests no la necesitan. fn push_a11y_tree(state: &mut RuntimeState) { let Some(cache) = state.last_render.as_ref() else { return; }; // El foco que tenemos es un id opaco u64 (`focusable`); el árbol AccessKit // necesita el índice del MountedNode. Resolvemos buscando. let focused_idx = state.focused.and_then(|fid| { cache .mounted .nodes .iter() .position(|n| n.focusable == Some(fid)) }); let app_name = A::window_title(state.model.as_ref().expect("model")) .unwrap_or_else(|| String::from("Llimphi")); let tree_id = state.a11y_tree_id; state.a11y_adapter.update_if_active(|| { crate::a11y::build_tree(&cache.mounted, &cache.computed, focused_idx, &app_name, tree_id) }); } impl Runtime { /// El cuerpo de `resumed`: crea la ventana y arranca el stack GPU. En /// escritorio y Android termina instalando el estado en la misma llamada; /// en web sale antes y lo completa `instalar_gpu_pendiente` (ver ahí). fn arrancar(&mut self, event_loop: &ActiveEventLoop) { if self.state.is_some() { return; } timing_hito("resumed:entrada"); #[cfg(not(target_arch = "wasm32"))] let attrs = build_window_attributes::(); // Web: la ventana ES un ``. Si `run_web` recibió uno del // documento, winit se monta sobre ÉSE (la página decide dónde va y con // qué CSS); si no, winit crea el suyo y lo cuelga del ``. #[cfg(target_arch = "wasm32")] let attrs = { use llimphi_hal::winit::platform::web::WindowAttributesExtWebSys; let base = build_window_attributes::(); match self.canvas.clone() { Some(c) => base.with_canvas(Some(c)), None => base.with_append(true), } }; let window = event_loop.create_window(attrs).expect("create window"); let window = Arc::new(window); // IME opt-in: sólo se habilita si la app lo pide (ver `App::ime_allowed`). // Con IME activo el texto compuesto llega por `WindowEvent::Ime`. if A::ime_allowed() { window.set_ime_allowed(true); } // Adapter AccessKit: lo creamos ANTES del primer redraw, conectado al // EventLoopProxy del runtime. El adapter emitirá `accesskit_winit::Event` // (Initial tree requested, ActionRequested, deactivated) — nuestro // `From for UserEvent` los rutea como // `UserEvent::A11y(...)` para que entren por el mismo `user_event`. let a11y_proxy: EventLoopProxy> = match &self.handle.inner { HandleInner::Real(p) => p.clone(), HandleInner::Test => unreachable!("resumed sin event loop real"), HandleInner::Lifted(_) => unreachable!("el runtime nunca corre con un handle lifteado"), }; let a11y_adapter = accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, a11y_proxy); // **ROOT, no un uuid por proceso.** `accesskit_consumer::Tree::new` // PANIQUEA si el `TreeUpdate` con el que se inicializa no trae // `TreeId::ROOT` («Cannot initialize with a subtree»): los uuid propios // son para SUBárboles injertados, no para el árbol de una ventana. // Cada adapter tiene su propio consumer, así que cada ventana es raíz // de lo suyo. Con un v4 acá, la app se caía en el instante en que un // lector de pantalla se activaba — y como el adapter sólo publica // cuando `org.a11y.Status.ScreenReaderEnabled` es `true`, la caída no // aparecía nunca en una sesión normal. let a11y_tree_id = accesskit::TreeId::ROOT; timing_hito("resumed:antes-de-Hal"); // Web: el stack GPU se pide ASÍNCRONO y `resumed` termina sin estado. // No es una preferencia de estilo — `request_adapter`/`request_device` // son promesas de JS y el main thread del navegador no se puede // bloquear (`pollster::block_on` paniquea ahí). El futuro deja el // resultado en `gpu_pendiente` y pide un redraw; `instalar_gpu_pendiente` // lo recoge en el próximo evento y termina de armar el `RuntimeState`. // Hasta entonces cada handler sale por su `let Some(state) … else`, que // ya existía para el hueco equivalente de Android entre `resumed` y la // NativeWindow válida. #[cfg(target_arch = "wasm32")] { let pendiente = self.gpu_pendiente.clone(); let w = window.clone(); wasm_bindgen_futures::spawn_local(async move { match Hal::new(None).await { Ok(hal) => match WinitSurface::new(&hal, w.clone()) { Ok(surface) => { timing_hito("web:futuro-ok"); *pendiente.borrow_mut() = Some((w.clone(), hal, surface)); // Despierta al event loop: sin esto el runtime // queda dormido en `Wait` y nadie recoge el stack. w.request_redraw(); } Err(e) => web_sys::console::error_1( &format!("llimphi-ui · surface web: {e}").into(), ), }, Err(e) => web_sys::console::error_1( &format!( "llimphi-ui · no hay GPU en este navegador ({e}). \ Hace falta WebGPU." ) .into(), ), } }); // El adapter a11y y el tree id se rearman en `instalar_gpu_pendiente` // (que también tiene el `&ActiveEventLoop`); acá se descartan. let _ = (a11y_adapter, a11y_tree_id); return; } // Desktop: adapter primero, surface después (la secuencia clásica). #[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))] let (hal, surface) = { // ⚠ CON display, y no es un adorno: sin él wgpu abre el display EGL por la // plataforma surfaceless, que no tiene configs de ventana ⇒ la surface no es // presentable ⇒ `caps.formats` sale VACÍA y la línea de abajo panica con un // «index out of bounds» que no nombra nada de esto. No se ve en una máquina con // Vulkan (PRIMARY no necesita el display) y se ve SIEMPRE en una sin él. La // ventana ya existe acá — se creó para hacerle la surface. Ver `Hal::new_con_display`. let hal = pollster::block_on(Hal::new_con_display(None, window.clone())).expect("hal"); timing_hito("resumed:Hal-listo"); let surface = WinitSurface::new(&hal, window.clone()).expect("surface"); (hal, surface) }; // Android: surface-first obligatorio. La surface debe existir ANTES // del request_adapter(compatible_surface=Some(...)) y no puede // recrearse sobre la misma ANativeWindow (ERROR_NATIVE_WINDOW_IN_USE_KHR). // `recreate_for_window` ya implementa exactamente ese orden. #[cfg(target_os = "android")] let (hal, surface) = Hal::recreate_for_window(window.clone()).expect("hal+surface android"); #[cfg(not(target_arch = "wasm32"))] self.instalar_estado(window, hal, surface, a11y_adapter, a11y_tree_id); } } impl Runtime { /// Termina de armar el [`RuntimeState`] una vez que el stack GPU existe. /// Está separado de `resumed` porque en web el `Hal` llega un tick después /// (promesa de JS), y todo esto es idéntico en los tres targets. fn instalar_estado( &mut self, window: Arc, hal: Hal, surface: WinitSurface, a11y_adapter: accesskit_winit::Adapter, a11y_tree_id: accesskit::TreeId, ) { let renderer = Renderer::new(&hal).expect("renderer"); timing_hito("resumed:renderer-listo"); let overlay_compositor = llimphi_hal::OverlayCompositor::new(&hal.device); let blur_compositor = llimphi_hal::BlurCompositor::new(&hal.device); let color_filter_compositor = llimphi_hal::ColorFilterCompositor::new(&hal.device); let typesetter = llimphi_text::Typesetter::new(); // El puente para soltar archivos: bajo Wayland winit no emite // `DroppedFile`, así que la escucha la monta `file_drop` sobre el mismo // `wl_display` y nos devuelve las rutas por `UserEvent::FileDrop`. En // los demás backends es un no-op. Va acá, con la superficie ya creada, // porque el puente necesita su puntero para no aceptar drops ajenos. // El asa vuelve para guardarla en el estado: es lo que apaga el hilo // cuando la ventana se va (ver `RuntimeState::puente`). let puente = match &self.handle.inner { HandleInner::Real(p) => crate::file_drop::arrancar(&window, p.clone()), _ => None, }; window.request_redraw(); self.state = Some(RuntimeState { puente, window, hal, surface, renderer, scene: Escena::new(), over_scratch: None, over_huella: None, over_tramos: [0; 6], over_coords: Vec::new(), escena_huella: None, overlay_compositor, blur_compositor, color_filter_compositor, // Primer arranque → `A::init`; re-entrada tras un `suspended` // (Android background→foreground) → el modelo parkeado sigue vivo. model: Some(match self.parked_model.take() { Some(m) => m, None => vigia::aplicar_init::(&self.handle), }), cursor: PhysicalPosition::new(0.0, 0.0), // Una ventana nace en ventana; si el compositor la abre a pantalla // completa (una regla de `rules.ron`), el primer `Resized` lo // detecta y la app se entera por `on_fullscreen`. fullscreen: false, cursor_arrastre: None, arrastre_entregado: false, modifiers: Modifiers::default(), typesetter, layout: LayoutTree::new(), overlay_layout: LayoutTree::new(), last_render: None, mount_pool: None, layout_pool: None, hovered: None, drag: None, focused: None, last_title: None, anim_registry: llimphi_compositor::AnimRegistry::new(), size_anim_registry: llimphi_compositor::SizeAnimRegistry::new(), hero_registry: llimphi_compositor::HeroRegistry::new(), ripple_registry: llimphi_compositor::RippleRegistry::new(), repaint_cache: llimphi_compositor::RepaintCache::new(), detector_boundaries: perf::boundary_hint() .map(|_| llimphi_compositor::DetectorBoundaries::new()), retained_layout: None, memo_registry: llimphi_compositor::MemoRegistry::new(), last_tap: None, pending_long_press: None, pending_click: None, pending_right_click: None, pending_wheel: None, held_key: None, winit_repeats: false, retained: None, selection: None, a11y_adapter, a11y_tree_id, needs_device_rebuild: false, acquire_fallidos: 0, }); // Sincroniza el factor de escala inicial (el de la ventana recién // creada) ANTES del primer render: así una app que dependa del DPI // (p. ej. `devicePixelRatio` en puriy) ya lo tiene correcto en su // primera pasada, sin esperar a un ScaleFactorChanged. if let Some(state) = self.state.as_mut() { let scale = state.window.scale_factor(); if let Some(msg) = A::on_scale_factor(state.model.as_ref().expect("model"), scale) { let model = state.model.take().expect("model"); state.model = Some(vigia::aplicar_update::(model, msg, &self.handle)); state.invalidar_render(); } } } /// Web: recoge el stack GPU que dejó el futuro de `resumed` y termina de /// instalar el estado. Se llama al principio de cada evento de ventana; el /// caso normal (ya instalado, o nada pendiente) es un `borrow` + `is_none`. #[cfg(target_arch = "wasm32")] fn instalar_gpu_pendiente(&mut self, event_loop: &ActiveEventLoop) { if self.state.is_some() { return; } let Some((window, hal, surface)) = self.gpu_pendiente.borrow_mut().take() else { return; }; timing_hito("web:Hal-listo"); let a11y_proxy: EventLoopProxy> = match &self.handle.inner { HandleInner::Real(p) => p.clone(), HandleInner::Test => unreachable!("runtime web sin event loop real"), HandleInner::Lifted(_) => unreachable!("el runtime nunca corre con un handle lifteado"), }; let a11y_adapter = accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, a11y_proxy); // ROOT por lo mismo que en `arrancar` — ver el comentario de allá. let a11y_tree_id = accesskit::TreeId::ROOT; self.instalar_estado(window, hal, surface, a11y_adapter, a11y_tree_id); } } impl ApplicationHandler> for Runtime { fn resumed(&mut self, event_loop: &ActiveEventLoop) { self.arrancar(event_loop); } fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { // Corre ENTRE cuadros (los ticks periódicos de una app llegan por acá, // como `Msg` del canal), así que su costo va al tramo «otros» del // período. Sin esto, el hueco `entre` no se puede repartir. let _t = perf::CronometroOtros::nuevo(); match event { UserEvent::Quit => event_loop.exit(), UserEvent::Dormir(respuesta) => { let ruta = self .state .as_mut() .and_then(|st| st.model.as_mut()) .and_then(crate::dormir::dormir_modelo::); let durmio = ruta.is_some(); let _ = respuesta.send(ruta); // Se va sola: lo que había ya está guardado. `exiting` corre // igual, así que `al_salir` sigue volcando lo suyo. if durmio { event_loop.exit(); } } UserEvent::FileDrop(path) => { // Wayland: el drop no viene de winit sino del hilo de // `file_drop`. De acá para abajo es exactamente el mismo camino // que `WindowEvent::DroppedFile`. self.aplicar_file_drop(path); } UserEvent::Msg(msg) => { // Un Msg del canal (Handle::dispatch, ticks periódicos, trabajo // de fondo) muta el modelo compartido y repinta TODAS las // ventanas — así un cambio se refleja tanto en la primaria como // en las secundarias (config) sin importar de dónde vino. self.dispatch_model(msg); } UserEvent::OpenWindow { key, title, width, height } => { self.open_secondary(event_loop, key, title, width, height); } UserEvent::CloseWindow { key } => { if let Some(pos) = self.secondaries.iter().position(|s| s.key == key) { // Drop de la SecondaryState → se destruye la ventana/surface. self.secondaries.remove(pos); } } UserEvent::SetMinimized(min) => { if let Some(state) = self.state.as_ref() { state.window.set_minimized(min); } } UserEvent::SetFullscreen(on) => { if let Some(state) = self.state.as_ref() { // `Borderless(None)` = el monitor donde ya está la ventana, // sin cambiar el modo de video (lo que quieren los // reproductores). El resize lo trae `WindowEvent::Resized` // por el camino de siempre: no hay que tocar la surface acá. state.window.set_fullscreen(on.then_some( llimphi_hal::winit::window::Fullscreen::Borderless(None), )); } } UserEvent::SetContentType(kind) => { if let Some(state) = self.state.as_ref() { // Va sobre la `wl_surface` de winit por una conexión ajena // montada sobre el mismo display — ver `crate::content_type`. crate::content_type::aplicar(&state.window, kind); } } UserEvent::A11y(ev) => { self.handle_a11y_event(ev); } } } /// Android/iOS/web: la app pasó a background y la NativeWindow deja de /// ser válida — TODO el estado GPU (surface, device, texturas, renderer) /// debe soltarse. El modelo de la app se parquea y `resumed` lo retoma /// al volver, recreando el stack gráfico desde cero contra la ventana /// nueva. En desktop winit nunca llama esto. fn suspended(&mut self, _event_loop: &ActiveEventLoop) { if let Some(mut st) = self.state.take() { self.parked_model = st.model.take(); } // Las secundarias también cuelgan de la NativeWindow/device viejos. self.secondaries.clear(); self.active_touch = None; } fn window_event( &mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent, ) { // Todo evento de ventana que NO sea el repintado corre ENTRE cuadros: // punteros, teclas, resize, foco. Va al tramo «otros» del período. El // `RedrawRequested` se excluye porque su costo ya lo miden `acquire` y // `total`, y contarlo acá lo sumaría dos veces. let _t = (!matches!(event, WindowEvent::RedrawRequested)) .then(perf::CronometroOtros::nuevo); // Web: el stack GPU llega un tick después de `resumed` (promesa de JS). // El primer evento tras completarse lo instala — típicamente el // `RedrawRequested` que el propio futuro pidió al terminar. #[cfg(target_arch = "wasm32")] { if self.state.is_none() { timing_hito(&format!("web:evento-sin-estado {event:?}")); } self.instalar_gpu_pendiente(event_loop); } // ¿El evento es de una ventana secundaria? Lo atiende su handler // dedicado (path aparte: la primaria queda 100% intacta). if let Some(idx) = self.secondaries.iter().position(|s| s.window.id() == _id) { self.handle_secondary_event(idx, event); return; } // Touch → puntero: en móvil los eventos llegan como `Touch`, no como // CursorMoved/MouseInput. Traducimos el PRIMER dedo a la secuencia de // mouse equivalente (move+press / move / move+release) y lo ruteamos // por el mismo handler primario — tap=click, arrastre=drag, sin // duplicar ninguna lógica de hit-test/gestos. Dedos extra se ignoran // hasta que el activo suelte (multi-touch real queda para después). if let WindowEvent::Touch(t) = &event { use llimphi_hal::winit::event::{ElementState, MouseButton, TouchPhase}; let es_activo = match self.active_touch { Some(id) => id == t.id, None => t.phase == TouchPhase::Started, }; if !es_activo { return; } let device_id = t.device_id; let position = t.location; let moved = WindowEvent::CursorMoved { device_id, position }; match t.phase { TouchPhase::Started => { self.active_touch = Some(t.id); self.handle_primary_window_event(event_loop, moved); self.handle_primary_window_event( event_loop, WindowEvent::MouseInput { device_id, state: ElementState::Pressed, button: MouseButton::Left, }, ); } TouchPhase::Moved => { self.handle_primary_window_event(event_loop, moved); } TouchPhase::Ended | TouchPhase::Cancelled => { self.active_touch = None; // Un tap con foco de texto re-afirma el soft keyboard en // el próximo frame (el usuario pudo bajarlo con Atrás y // el estado del IME no es legible). #[cfg(target_os = "android")] { self.soft_input_reassert = true; } self.handle_primary_window_event(event_loop, moved); self.handle_primary_window_event( event_loop, WindowEvent::MouseInput { device_id, state: ElementState::Released, button: MouseButton::Left, }, ); } } return; } self.handle_primary_window_event(event_loop, event); } /// Se ejecuta tras procesar los eventos de cada vuelta, justo antes de que /// el loop se duerma. Es donde vence el **long-press**: si hay uno armado y /// ya pasó su `deadline` (el botón siguió apretado y quieto), se dispara su /// `Msg`. Mientras quede uno pendiente, ponemos `WaitUntil(deadline)` para /// que winit nos despierte a tiempo (con `ControlFlow::Wait` el loop dormiría /// indefinidamente sin un evento que lo despierte). Sin long-press armado, /// volvemos a `Wait` (no dejar un `WaitUntil` viejo: con un deadline pasado /// el loop spinearía). Las animaciones implícitas no usan el control flow /// (piden frames con `request_redraw`), así que esto no las afecta. /// **El bucle se va.** winit garantiza que después de esto no vuelve, así /// que es la última oportunidad de la app para volcar lo que sólo está en /// memoria. Se le pasa el modelo vivo por referencia mutable: lo que se /// lance por el `Handle` acá no lo despacha nadie. fn exiting(&mut self, _event_loop: &ActiveEventLoop) { let Some(state) = self.state.as_mut() else { return; }; if let Some(model) = state.model.as_mut() { A::al_salir(model); } crate::dormir::soltar(); } fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { let _t = perf::CronometroOtros::nuevo(); let Some(state) = self.state.as_mut() else { return; }; let now = crate::Instant::now(); // 1. Auto-repeat de tecla: si la sostenida venció, re-emitíla (repeat=true) // y reprograma el próximo disparo al intervalo. Sólo si winit no repite él. // // **Pero no en la misma vuelta en que vence.** Esto corre después del // repintado, que es de la app y puede durar lo que quiera: un cuadro // lento devuelve el control con el `release` sin leer en la cola de la // plataforma mientras el reloj venció. Medido con // `examples/melaza_teclas.rs`: con `view()` durmiendo 3 s, un toque de // tecla salía como press + **repeat inventada** + release, y la // repetición no aparecía en la sonda de winit (`LLIMPHI_DEBUG_KEY=1`) // porque no era de winit: era de acá. // // Así que al vencer se pide una vuelta más (ver `HeldKey::confirmada`) // y se dispara en la siguiente, cuando la cola ya se leyó. let mut confirmar_repeat = false; if !state.winit_repeats && state.held_key.as_ref().is_some_and(|h| now >= h.next) { if !state.held_key.as_ref().expect("held").confirmada { state.held_key.as_mut().expect("held").confirmada = true; confirmar_repeat = true; } else { let winit_ev = state.held_key.as_ref().expect("held").event.clone(); { let h = state.held_key.as_mut().expect("held"); h.next = now + KEY_REPEAT_INTERVAL; h.confirmada = false; } let ev = crate::KeyEvent { key: crate::tecla_de_winit(&winit_ev.logical_key), state: crate::KeyState::Pressed, text: winit_ev.text.as_ref().map(|t| t.to_string()), modifiers: state.modifiers, repeat: true, }; if let Some(msg) = A::on_key(state.model.as_ref().expect("model"), &ev) { let model = state.model.take().expect("model"); state.model = Some(vigia::aplicar_update::(model, msg, &self.handle)); state.invalidar_render(); state.window.request_redraw(); } } } // 2. Long-press: si venció, dispará su `Msg` (lógica existente). if state.pending_long_press.as_ref().is_some_and(|p| now >= p.deadline) { let handler = state.pending_long_press.take().expect("pending").handler; if let Some(msg) = handler.invoke() { let model = state.model.take().expect("model"); state.model = Some(vigia::aplicar_update::(model, msg, &self.handle)); state.invalidar_render(); state.window.request_redraw(); } } // 2.bis. Click RETENIDO por un doble-tap exclusivo: si ya se soltó el // botón y venció la ventana del doble, el gesto era un click simple y // recién ahora se sabe. Ver `PendingClick::retener_hasta`. if state .pending_click .as_ref() .is_some_and(|p| p.soltado && p.retener_hasta.is_some_and(|t| now >= t)) { let pending = state.pending_click.take().expect("pending"); let model = state.model.take().expect("model"); state.model = Some(vigia::aplicar_update::(model, pending.msg, &self.handle)); state.invalidar_render(); state.window.request_redraw(); } // 3. Dormir hasta el deadline pendiente MÁS CERCANO (repeat o long-press); // sin ninguno, `Wait` (hasta el próximo evento). // Con una repetición a confirmar, la vuelta tiene que ser YA: `Poll` hace // una pasada sin dormir, y esa pasada lee la cola de la plataforma — que // es todo lo que hace falta para saber si el `release` estaba esperando. if confirmar_repeat { event_loop.set_control_flow(ControlFlow::Poll); return; } let lp = state.pending_long_press.as_ref().map(|p| p.deadline); // El click retenido también es un vencimiento: sin esto la ventana se // duerme en `Wait` y el click sale recién con el próximo evento ajeno. let pc = state .pending_click .as_ref() .filter(|p| p.soltado) .and_then(|p| p.retener_hasta); let hk = (!state.winit_repeats) .then(|| state.held_key.as_ref().map(|h| h.next)) .flatten(); match [lp, hk, pc].into_iter().flatten().min() { Some(d) => event_loop.set_control_flow(ControlFlow::WaitUntil(d)), None => event_loop.set_control_flow(ControlFlow::Wait), } } } #[cfg(all(test, target_os = "linux", not(target_os = "android")))] mod pruebas_de_identidad { use super::*; /// El piso de identidad: sin `app_id()` declarado, el toplevel igual sale /// con un nombre propio. Antes salía sin ninguno y el compositor lo metía /// en la misma bolsa que a otras 162 apps. #[test] fn sin_app_id_declarado_igual_hay_un_nombre_propio() { let id = app_id_del_ejecutable().expect("el ejecutable tiene nombre"); assert!(!id.is_empty()); assert!(!id.contains('/'), "es el nombre, no la ruta: {id}"); // Y es el de ESTE binario, no un literal compartido. let esperado = std::env::current_exe().unwrap().file_name().unwrap().to_string_lossy().to_string(); assert_eq!(id, esperado); assert_ne!(id, "cliente", "el genérico es justo lo que esto viene a evitar"); } }