// input.rs — Manejo de todos los eventos de entrada de la ventana primaria. // Cubre: teclado, mouse (click/drag/rueda/gestos), IME, drop de archivos, // resize, DPI. Cada variante de `WindowEvent` queda en su sección. use super::super::*; use super::helpers::*; /// Resultado de un hit-test de click izquierdo. Tupla interna para extraer /// handlers del cache antes de mutar el modelo (suelta el borrow). pub(super) type HitInfo = ( Option>, Option>, Option>, Option, Option, Option>, Option<(f32, f32, f32, f32)>, ); /// Extrae el `HitInfo` de click izquierdo para un árbol montado dado. pub(super) fn lookup_click_hit( m: &Mounted, c: &ComputedLayout, cx: f32, cy: f32, ) -> Option> { hit_test_click(m, c, cx, cy).map(|i| { let node = &m.nodes[i]; let rect = c.get(node.id).map(|r| (r.x, r.y, r.w, r.h)); ( node.drag.clone(), node.drag_at.clone(), node.drag_velocity.clone(), node.drag_payload, node.on_click.clone(), node.on_click_at.clone(), rect, ) }) } /// La **carga** que el nodo bajo `(cx, cy)` declaró para su arrastre /// ([`llimphi_compositor::View::drag_carga`]). Es un hit-test aparte y no un /// campo más del [`HitInfo`] a propósito: la tupla se destructura en cinco /// lugares y agregarle un octavo hueco por un dato que sólo mira el pintado /// del fantasma es peor negocio que un segundo `hit_test_click` en el press /// —que ocurre una vez por gesto, no por cuadro—. fn carga_hit( cache: &RenderCache, cx: f32, cy: f32, ) -> Option> { let (m, c) = match cache.overlay.as_ref() { Some(ov) => (&ov.mounted, &ov.computed), None => (&cache.mounted, &cache.computed), }; hit_test_click(m, c, cx, cy).and_then(|i| m.nodes[i].drag_carga.clone()) } /// ¿Diag de click prendido? (`MEDIA_CLICK_DIAG=1`). Cacheado: un `env::var` /// una sola vez, no por click. fn click_diag_enabled() -> bool { use std::sync::OnceLock; static D: OnceLock = OnceLock::new(); *D.get_or_init(|| std::env::var("MEDIA_CLICK_DIAG").is_ok()) } /// Reconstruye un `RenderCache` fresco desde el modelo vigente (mount + layout /// del árbol principal y del overlay, **sin pintar**) y lo deja en /// `state.last_render`. Se llama al tope de cada handler de press cuando el /// cache llegó invalidado (`None`). /// /// Por qué: `last_render` se anula en CADA `Msg` (el tick de video incluido) y /// sólo vuelve a `Some` tras un redraw completo. Con video tickeando + redraw /// throttled, la ventana en que `last_render` está `None` es enorme, y un click /// que cae ahí **se saltaba todos los hit-tests** de `handle_*_press` (sólo el /// `on_click` plano tenía fallback; `on_click_screen`/foco/selección/gestos no) /// → click no-op silencioso: el engranaje de config de media no abría, F2 sí. /// Reconstruir acá hace que TODOS los caminos de hit-test vean el árbol del /// modelo vigente, sin depender de que exista un frame recién pintado. /// /// Anula `state.retained`: montamos el árbol pero NO lo pintamos, así que la /// invariante de retención ("`last_render` Some ⇒ nada visual cambió desde el /// paint") ya no vale — el próximo redraw debe repintar completo el modelo /// vigente en vez de re-presentar el frame viejo. pub(super) fn build_hit_cache(state: &mut crate::RuntimeState) { let (w, h) = state.surface.size(); let vp = (w as f32, h as f32); // Mismo resolve de LayoutBuilder que el redraw, para que el hit-test vea // los hijos producidos por builders diferidos. let view = resolve_layout_builders::( state.model.as_ref().expect("model"), vp, &mut state.typesetter, ); let overlay_view = super::vigia::aplicar_view_overlay::(state.model.as_ref().expect("model")); // `LayoutTree` efímero propio: no tocamos `state.layout`/`retained_layout` // (los usa el redraw retenido) para no ensuciar su reuso incremental. let mut layout = LayoutTree::new(); // Reciclado de buffers (Bloque 21): esta función corre justamente cuando // `last_render` está invalidado, o sea cuando el pool tiene el árbol del // frame anterior esperando. Sin `prev` no hay reuso de NodeIds (el // `LayoutTree` es efímero, arriba), pero sí se reusa el bloque de memoria. let (mounted, _, _): (Mounted, _, _) = llimphi_compositor::mount_incremental_reciclando( &mut layout, view, None, state.mount_pool.take(), ); let computed = { let ts = &mut state.typesetter; let tmap = &mounted.text_measures; layout .compute_with_measure(mounted.root, vp, |nid, known, avail| match tmap.get(&nid) { Some(tm) => measure_text_node(ts, tm, known, avail), None => llimphi_layout::taffy::Size::ZERO, }) .expect("layout") }; let overlay = overlay_view.map(|ov| { let mut olay = LayoutTree::new(); let omounted: Mounted = mount(&mut olay, ov); let ocomputed = { let ts = &mut state.typesetter; let tmap = &omounted.text_measures; olay.compute_with_measure(omounted.root, vp, |nid, known, avail| { match tmap.get(&nid) { Some(tm) => measure_text_node(ts, tm, known, avail), None => llimphi_layout::taffy::Size::ZERO, } }) .expect("layout overlay") }; let ohover = hit_test_hover(&omounted, &ocomputed, state.cursor.x as f32, state.cursor.y as f32); crate::OverlayCache { mounted: omounted, computed: ocomputed, hover_idx: ohover } }); state.last_render = Some(crate::RenderCache { mounted, computed, drop_hover_idx: None, overlay, }); // Montamos sin pintar → el frame retenido ya no representa el modelo // vigente. Invalidar la retención fuerza un redraw completo (correcto). state.retained = None; } impl Runtime { /// Android: muestra/oculta el teclado en pantalla siguiendo a /// [`App::wants_text_input`]. Se llama en cada `RedrawRequested` (todo /// update pide redraw, así que el foco nunca queda atrás). El estado /// del IME no se puede leer, sólo empujar: se cachea la última orden y /// se re-afirma tras un tap (`soft_input_reassert`) por si el usuario /// lo bajó con Atrás. #[cfg(target_os = "android")] pub(super) fn sync_soft_input(&mut self) { let wants = self .state .as_ref() .and_then(|s| s.model.as_ref()) .map(A::wants_text_input) .unwrap_or(false); let force = std::mem::take(&mut self.soft_input_reassert); if wants == self.soft_input_visible && !(wants && force) { return; } self.soft_input_visible = wants; if let Some(app) = &self.android_app { if wants { app.show_soft_input(true); } else { app.hide_soft_input(true); } } } /// Maneja un `WindowEvent` de la ventana primaria. La delegación a /// secciones internas sigue el orden de frecuencia (más común primero). pub(super) fn handle_primary_window_event( &mut self, event_loop: &llimphi_hal::winit::event_loop::ActiveEventLoop, event: llimphi_hal::winit::event::WindowEvent, ) { use llimphi_hal::winit::event::WindowEvent; let Some(state) = self.state.as_mut() else { return; }; // Cada window_event debe pasar primero por el adapter AccessKit para // que las tecnologías asistivas se enteren del estado real de la // ventana (focus_change del SO, cursor moves, etc.). El adapter // no consume el evento — lo despacha aparte vía el EventLoopProxy. state.a11y_adapter.process_event(&state.window, &event); match event { WindowEvent::CloseRequested => event_loop.exit(), WindowEvent::Resized(size) => { state.surface.resize(size.width, size.height); // La app puede reaccionar al nuevo viewport (emitir un // evento `resize`, recalcular layout, etc.). El update se // corre tras reconfigurar la surface; el cache se invalida // para repintar con el tamaño nuevo. if let Some(msg) = A::on_resize(state.model.as_ref().expect("model"), size.width, size.height) { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, &self.handle)); state.invalidar_render(); } // **Pantalla completa: el aviso llega acá.** En Wayland, // `window.fullscreen()` lee el ÚLTIMO `configure` del // compositor, así que refleja también lo que el escritorio // decidió por su cuenta (el `F11` del WM), no sólo lo que pidió // la app. El cambio de estado viene siempre con un `Resized` —la // ventana pasa de su celda al monitor entero—, así que se compara // acá y no en cada cuadro (`fullscreen()` toma un lock y consulta // el monitor: no es gratis). let ahora_fs = state.window.fullscreen().is_some(); if ahora_fs != state.fullscreen { state.fullscreen = ahora_fs; if let Some(msg) = A::on_fullscreen(state.model.as_ref().expect("model"), ahora_fs) { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, &self.handle)); state.invalidar_render(); } } state.window.request_redraw(); } WindowEvent::ScaleFactorChanged { scale_factor, .. } => { // El DPI de la ventana cambió (movida a otro monitor, escalado // del sistema). winit envía un Resized aparte para el nuevo // tamaño físico; aquí sólo propagamos el factor. if let Some(msg) = A::on_scale_factor(state.model.as_ref().expect("model"), scale_factor) { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, &self.handle)); state.invalidar_render(); } state.window.request_redraw(); } WindowEvent::CursorMoved { position, .. } => { self.handle_cursor_moved(position); } WindowEvent::Focused(focused) => { // Perder el foco con un drag activo casi siempre significa un // release perdido (soltado fuera de la ventana / grab que el // compositor no entregó). Cerrarlo aquí evita el drag colgado // que se come todos los CursorMoved para siempre. if !focused { if let Some(drag) = state.drag.take() { let end_msg = match &drag.handler { DragHandlerKind::Delta(h) => h(DragPhase::End, 0.0, 0.0), DragHandlerKind::DeltaAt(h, lx0, ly0) => { h(DragPhase::End, 0.0, 0.0, *lx0, *ly0) } DragHandlerKind::Velocity(h) => { h(DragPhase::End, 0.0, 0.0, 0.0, 0.0) } }; if let Some(msg) = end_msg { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, &self.handle)); } state.invalidar_render(); state.window.request_redraw(); } } // El foco de la ventana entró/salió. La app puede replegarse a // una barra, pausar animaciones, etc. (distinto del foco de nodo // que maneja `on_focus`). if let Some(msg) = A::on_window_focus(state.model.as_ref().expect("model"), focused) { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, &self.handle)); state.invalidar_render(); state.window.request_redraw(); } } WindowEvent::ModifiersChanged(mods) => { let Some(state) = self.state.as_mut() else { return }; state.modifiers = crate::modificadores_de_winit(mods.state()); // La app puede querer saberlo (Shift+click / Ctrl+click de una // lista: el click no lleva los modificadores, así que se // guardan en el modelo). `None` = no le importa, sin repintar. if let Some(msg) = A::on_modifiers(state.model.as_ref().expect("model"), state.modifiers) { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, &self.handle)); state.invalidar_render(); state.window.request_redraw(); } } WindowEvent::Ime(ime) if A::ime_allowed() => { self.handle_ime(ime); } WindowEvent::KeyboardInput { event, .. } => { self.handle_keyboard_input(event); } WindowEvent::DroppedFile(path) => { // Un evento por archivo (winit los entrega serializados); si // el usuario suelta varios, el bucle re-entra y aplicamos // updates en orden. self.aplicar_file_drop(path); } WindowEvent::MouseWheel { delta, .. } => { self.handle_mouse_wheel(delta); } WindowEvent::PinchGesture { delta, phase, .. } => { self.handle_pinch_gesture(delta, phase); } WindowEvent::RotationGesture { delta, phase, .. } => { self.handle_rotation_gesture(delta, phase); } WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => { self.handle_left_press(); } WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Middle, .. } => { self.handle_middle_press(); } WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Right, .. } => { self.handle_right_press(); } WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left, .. } => { self.handle_left_release(); } WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Right, .. } => { self.handle_right_release(); } WindowEvent::RedrawRequested => { // Android: el teclado en pantalla sigue al foco de texto de // la app (App::wants_text_input), sincronizado por frame. #[cfg(target_os = "android")] self.sync_soft_input(); super::redraw::handle_redraw::( self.state.as_mut().expect("state en redraw"), &self.handle, ); // El redraw pudo detectar un DeviceLost (Iris Xe reset de output, // cambio de VT). No lo puede resolver solo — rehacer el device // toca también las surfaces secundarias, que viven en `Runtime`. if self .state .as_ref() .is_some_and(|s| s.needs_device_rebuild) { self.rebuild_gpu_after_device_loss(); } } _ => {} } } /// Reconstruye **todo el stack GPU** tras un [`llimphi_hal::SurfaceError::DeviceLost`]: /// nuevo `Hal` (instance+adapter+device+queue) + `WinitSurface` primaria, /// renderer vello y los tres compositores; avisa a la app /// ([`App::on_gpu_reset`]) para que rehaga sus recursos propios; rehace las /// surfaces de todas las ventanas secundarias contra el device nuevo (su /// `WinitSurface` cacheaba un clone del device muerto); invalida las caches /// de frame y pide redraw. Es la recuperación real que reemplaza el bandaid /// de forzar backend GL: sirve para CUALQUIER GPU/driver que pierda el device /// (Iris Xe reset de output, TDR de Windows, cambio de VT, suspend/resume), /// no sólo esta máquina. Si el rebuild falla (GPU aún no lista), deja la /// marca y reintenta en el próximo frame. /// /// **Web:** no hay recuperación todavía — ver la variante `wasm32` justo /// debajo. #[cfg(not(target_arch = "wasm32"))] pub(super) fn rebuild_gpu_after_device_loss(&mut self) { let Some(state) = self.state.as_mut() else { return }; state.needs_device_rebuild = false; let window = state.window.clone(); // **Soltar la VkSurface vieja ANTES de crear la nueva.** Van contra la // MISMA ventana, y en Android eso es ilegal mientras la vieja viva: // `vkCreateAndroidSurfaceKHR` falla y wgpu-hal lo trata como fatal → // abort (medido en metal el 2026-07-28; el rebuild moría ahí sin decir // por qué). Antes se reemplazaba `state.surface` recién al final, o sea // la vieja seguía viva durante la creación. Entre esta línea y la // instalación de la nueva no se piden frames: estamos dentro del // handler, no hay redraw en el medio. state.surface.soltar_vk_surface(); let (hal, surface) = match llimphi_hal::Hal::recreate_for_window(window) { Ok(pair) => pair, Err(e) => { llimphi_hal::avisar!( "llimphi-ui: rebuild de device tras DeviceLost falló: {e}; reintento al próximo frame" ); state.needs_device_rebuild = true; state.window.request_redraw(); return; } }; let renderer = match llimphi_raster::Renderer::new(&hal) { Ok(r) => r, Err(e) => { llimphi_hal::avisar!( "llimphi-ui: renderer vello no reinicializó tras DeviceLost: {e}; reintento al próximo frame" ); state.needs_device_rebuild = true; state.window.request_redraw(); return; } }; state.overlay_compositor = llimphi_hal::OverlayCompositor::new(&hal.device); state.blur_compositor = llimphi_hal::BlurCompositor::new(&hal.device); state.color_filter_compositor = llimphi_hal::ColorFilterCompositor::new(&hal.device); state.surface = surface; state.renderer = renderer; state.hal = hal; // La app rehace SUS recursos GPU (video/voxel/pipelines directos) contra // el device nuevo. UI pura (vello) = no-op por defecto. if let Some(model) = state.model.as_mut() { A::on_gpu_reset(model, &state.hal); } // Invalidar toda cache de frame: el contenido se re-pinta desde cero. state.invalidar_render(); state.retained = None; state.window.request_redraw(); // Rehacer las surfaces secundarias contra el device nuevo (campos // disjuntos: `self.state` en shared, `self.secondaries` en mut). let new_hal = &self.state.as_ref().expect("state recién puesto").hal; for sec in &mut self.secondaries { match llimphi_hal::WinitSurface::new(new_hal, sec.window.clone()) { Ok(s) => { sec.surface = s; sec.last_render = None; sec.window.request_redraw(); } Err(e) => llimphi_hal::avisar!( "llimphi-ui: no se pudo rehacer la surface de una ventana secundaria tras DeviceLost: {e}" ), } } llimphi_hal::avisar!( "llimphi-ui: stack GPU reconstruido tras DeviceLost (device+surface+renderer+compositores+{} secundaria/s).", self.secondaries.len() ); } /// Web: **todavía no hay recuperación de device**. `Hal::recreate_for_window` /// no existe en wasm porque bloquea con `pollster`, y rehacerlo asíncrono /// (como el arranque) es trabajo aparte. Acá se limpia la marca y se avisa /// una vez: dejarla puesta pondría a reintentar en cada /// `requestAnimationFrame` sin arreglar nada. /// /// En el navegador un device perdido es raro y su remedio natural es /// recargar la página, así que la degradación es honesta. #[cfg(target_arch = "wasm32")] pub(super) fn rebuild_gpu_after_device_loss(&mut self) { if let Some(state) = self.state.as_mut() { state.needs_device_rebuild = false; } web_sys::console::error_1( &"llimphi-ui · se perdió el device GPU; en web todavía no hay recuperación — recargá la página." .into(), ); } // ── Cursor moved ───────────────────────────────────────────────────────── fn handle_cursor_moved( &mut self, position: llimphi_hal::winit::dpi::PhysicalPosition, ) { let Some(state) = self.state.as_mut() else { return }; let prev_cursor = state.cursor; state.cursor = position; // Señal cruda de actividad de mouse: se dispara SIEMPRE, sin depender // del hit-test de nodos ni de que haya un frame cacheado (a diferencia // de `on_pointer_move_at`, que requiere `last_render` válido y puede // caer en el hueco donde un Tick lo invalidó). Es lo que un OSC estilo // mpv necesita para revelarse de forma fiable al mover el mouse. if let Some(msg) = A::on_cursor_moved( state.model.as_ref().expect("model"), position.x as f32, position.y as f32, ) { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, &self.handle)); state.invalidar_render(); state.window.request_redraw(); } // Selección de texto en curso: extender el foco al punto actual. if let Some(tsel) = state.selection.filter(|s| s.dragging) { let info = state .last_render .as_ref() .and_then(|c| selectable_by_key(c, tsel.key)); if let Some((spec, (rx, ry, rw, _rh))) = info { let layout = build_selectable_layout(&mut state.typesetter, &spec, rw); let lx = position.x as f32 - rx; let ly = position.y as f32 - ry; let new_sel = tsel.sel.extend_to_point(&layout, lx, ly); state.selection = Some(TextSelection { sel: new_sel, ..tsel }); state.invalidar_render(); state.window.request_redraw(); } } // Long-press armado: si el cursor se alejó del origen del press // más que el umbral, el gesto pasó a drag/scroll → cancelar. if let Some(p) = state.pending_long_press.as_ref() { let dx = position.x - p.origin.x; let dy = position.y - p.origin.y; if (dx * dx + dy * dy).sqrt() > LONG_PRESS_MOVE_CANCEL { state.pending_long_press = None; } } // `on_click` armado: si el cursor se alejó del origen del press más que // el umbral, el usuario quiso arrastrar/barrer, no cliquear → cancelar. if let Some(p) = state.pending_click.as_ref() { let dx = position.x - p.origin.x; let dy = position.y - p.origin.y; if (dx * dx + dy * dy).sqrt() > CLICK_MOVE_CANCEL { state.pending_click = None; } } // Y el mismo trato para el menú contextual armado: alejarse con el // botón derecho apretado **es** la forma de arrepentirse. Sin esto, el // menú saldría igual al soltar en cualquier otro lado, que es el mismo // defecto que abrirlo en el press con un paso de más. if let Some(p) = state.pending_right_click.as_ref() { let dx = position.x - p.origin.x; let dy = position.y - p.origin.y; if (dx * dx + dy * dy).sqrt() > CLICK_MOVE_CANCEL { state.pending_right_click = None; } } // Drag activo: dispatchear delta al handler + actualizar // tracking del drop target hovereado (solo si hay payload). if let Some(drag) = state.drag.as_mut() { let dx = (position.x - drag.last_cursor.x) as f32; let dy = (position.y - drag.last_cursor.y) as f32; drag.last_cursor = position; let payload_active = drag.payload.is_some(); let mut need_redraw = false; if dx != 0.0 || dy != 0.0 { let msg_opt = match &drag.handler { DragHandlerKind::Delta(h) => h(DragPhase::Move, dx, dy), DragHandlerKind::DeltaAt(h, lx0, ly0) => { h(DragPhase::Move, dx, dy, *lx0, *ly0) } DragHandlerKind::Velocity(h) => { // Durante Move, vx=vy=0 — la velocidad sólo // tiene sentido al End. Aquí registramos el // sample para esa medición. let now = crate::Instant::now(); drag.samples.push_back((now, dx as f64, dy as f64)); while drag.samples.len() > VELOCITY_MAX_SAMPLES { drag.samples.pop_front(); } h(DragPhase::Move, dx, dy, 0.0, 0.0) } }; if let Some(msg) = msg_opt { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, &self.handle)); // Durante drag NO invalidamos el cache — // queda válido para el próximo Move. need_redraw = true; } } let mut hay_destino = false; let mut resorte: Option = None; if payload_active { if let Some(cache) = state.last_render.as_mut() { let new_drop = hit_test_drop( &cache.mounted, &cache.computed, position.x as f32, position.y as f32, ); hay_destino = new_drop.is_some(); if new_drop != cache.drop_hover_idx { cache.drop_hover_idx = new_drop; need_redraw = true; } // **El resorte**: quedarse posado sobre un destino lo abre // (ver `View::on_drop_hover`). El destino se mira ACÁ, con // el cache del cuadro a mano, pero el mensaje se dispara // afuera del préstamo — `state` está prestado mutable. resorte = paso_del_resorte(state.drag.as_mut(), new_drop) .and_then(|i| cache.mounted.nodes.get(i)) .and_then(|n| n.on_drop_hover.clone()); } } // **El cursor dice qué va a pasar al soltar**, y se recalcula en // cada movimiento porque las tres cosas de las que depende cambian // durante el gesto: si hay destino abajo, si Ctrl está apretado, y // si el arrastre lleva algo. // // Antes el cursor no cambiaba nunca durante un arrastre: la única // señal era el resaltado del destino, o sea que arrastrar sobre el // vacío y arrastrar sobre una carpeta se sentían igual hasta que // soltabas. if payload_active { let modo = if state.modifiers.ctrl { llimphi_compositor::Cursor::Copy } else if hay_destino { llimphi_compositor::Cursor::Grabbing } else { llimphi_compositor::Cursor::NoDrop }; if state.cursor_arrastre != Some(modo) { state.cursor_arrastre = Some(modo); state.window.set_cursor(to_winit_cursor(Some(modo))); } // El fantasma va pegado al cursor: cada movimiento pide cuadro // aunque el modelo no haya cambiado. Sólo con carga declarada — // sin ella no se pinta nada y no hay por qué gastar cuadros. if let Some(drag) = state.drag.as_mut() { drag.sobre_destino = hay_destino; if drag.carga.is_some() { need_redraw = true; } } } // ── El gesto se le entrega al compositor al salir de la ventana ── // // Adentro manda el arrastre propio: es el que sabe de filas, de // destinos y de qué se ilumina. Afuera no hay nada que el proceso // pueda hacer —la otra ventana es de otro cliente— así que el // único que puede llevar el archivo hasta allá es el compositor. // // **Se hace al SALIR y no al empezar**, y no es un detalle: en // cuanto se llama `start_drag` el compositor toma el puntero para // sí, la app deja de recibir movimiento, y con eso se apagarían el // fantasma y los destinos internos. Arrancarlo en el press además // convertiría cada click en un arrastre. // // Y no hay evento que avise: durante un arrastre el puntero queda // en **grab implícito** sobre nuestra superficie, así que // `CursorLeft` no llega nunca mientras el botón siga apretado. Lo // que sí llega es el movimiento con coordenadas fuera del rect, y // eso es lo que se mira. if payload_active && !state.arrastre_entregado { let (vw, vh) = state.surface.size(); let afuera = position.x < 0.0 || position.y < 0.0 || position.x >= vw as f64 || position.y >= vh as f64; let archivos: Vec = state .drag .as_ref() .and_then(|d| d.carga.as_ref()) .map(|c| c.archivos.iter().map(std::path::PathBuf::from).collect()) .unwrap_or_default(); if afuera && !archivos.is_empty() { // El aviso es el instrumento: sin él, «arrastré afuera y // no pasó nada» no distingue entre «el gesto no salió de // la app» y «salió y el otro lado no lo quiso». llimphi_hal::avisar!( "llimphi: arrastre entregado al compositor — {} archivo(s)", archivos.len(), ); if let Some(p) = state.puente.as_ref() { p.arrastrar(archivos); } else { llimphi_hal::avisar!("llimphi: …pero no hay puente (¿X11?), se queda adentro"); } // Una sola vez por gesto: el cursor puede entrar y salir // varias veces y `start_drag` dos veces es un error de // protocolo, o sea la conexión caída. state.arrastre_entregado = true; // Y el fantasma se va con él: lo que se ve de acá en más // lo pinta el compositor, y dejar el nuestro pegado al // cursor sería pintarlo dos veces. if let Some(d) = state.drag.as_mut() { d.carga = None; } if state.cursor_arrastre.take().is_some() { state.window.set_cursor(to_winit_cursor(None)); } need_redraw = true; } } // El resorte, ya afuera del préstamo del cache: abrir el destino // cambia el modelo, así que el cuadro siguiente lo refleja. if let Some(msg) = resorte { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, &self.handle)); need_redraw = true; } if need_redraw { state.window.request_redraw(); } } else { // Sin drag: chequear hover. Si hay overlay, el // hover-test va contra él; el árbol principal queda // congelado mientras el overlay esté arriba. // // Además del repintado (para el `hover_fill`), en la // transición de hover dispatcheamos dos Msgs: el // `on_pointer_leave` del nodo que abandonamos y el // `on_pointer_enter` del recién hovereado. Es lo que // permite, p.ej., cambiar de menú con el mouse, abrir un // submenú al pasar por encima, o cerrar un tooltip/drawer // al salir. Extraemos los Msg en un scope para soltar el // borrow del cache antes de mutar el modelo. let mut enter_msg: Option = None; let mut leave_msg: Option = None; let mut hovered_changed = false; let mut new_hovered: Option = state.hovered; // Forma del cursor del nodo recién hovereado (sólo se // resuelve en la transición; ver `to_winit_cursor`). let mut new_cursor: Option = None; if let Some(cache) = state.last_render.as_ref() { let (mounted, computed) = match cache.overlay.as_ref() { Some(ov) => (&ov.mounted, &ov.computed), None => (&cache.mounted, &cache.computed), }; let new_hover = hit_test_hover( mounted, computed, position.x as f32, position.y as f32, ); // Comparamos contra el hover PERSISTENTE (state.hovered), // no contra el del cache: el render recomputa el del cache // al cursor actual cada cuadro, así que en una app que // re-renderiza sin parar la transición de hover se perdería // (y el hover-switch de menús no andaría). Ver `hovered`. if new_hover != state.hovered { hovered_changed = true; // El nodo que dejamos (índice persistente previo) // dispara su leave; el nuevo, su enter. Ambos se // resuelven contra el árbol montado actual, igual // que el resto del hit-test de hover. leave_msg = state .hovered .and_then(|i| mounted.nodes.get(i)) .and_then(|n| n.on_pointer_leave.clone()); enter_msg = new_hover .and_then(|i| mounted.nodes.get(i)) .and_then(|n| n.on_pointer_enter.clone()); new_cursor = hit_test_cursor( mounted, computed, position.x as f32, position.y as f32, ); } new_hovered = new_hover; } state.hovered = new_hovered; if hovered_changed { state.window.set_cursor(to_winit_cursor(new_cursor)); // Invalidar el cache de paint retenido: el `hover_fill` // del nodo viejo está pintado en `state.scene` y el del // nuevo no — un cache hit re-presentaría el frame stale. state.invalidar_render(); state.window.request_redraw(); } // Despachamos leave antes que enter: el orden natural de // la transición es "salgo de A, luego entro en B". for msg in [leave_msg, enter_msg].into_iter().flatten() { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, &self.handle)); // El estado cambió → invalidamos el cache para // re-render (p.ej. el submenú que se abre/cierra). state.invalidar_render(); } let _ = prev_cursor; } // Movimiento posicional: dispatch `on_pointer_move_at` en CADA move // mientras el cursor está sobre el nodo (no sólo al entrar, como // `on_pointer_enter`). Base del thumbnail/drawer que sigue al // cursor. Corre CON o SIN drag activo: un drag colgado (release // perdido fuera de la ventana) no debe comerse la señal de // actividad del mouse (p. ej. el reveal del OSC de media). // Extraemos el handler (Arc) + rect en un scope para soltar el // borrow del cache antes de mutar el modelo. let move_call: Option<(ClickAtFn, f32, f32, f32, f32)> = state.last_render.as_ref().and_then(|cache| { let (mounted, computed) = match cache.overlay.as_ref() { Some(ov) => (&ov.mounted, &ov.computed), None => (&cache.mounted, &cache.computed), }; let px = position.x as f32; let py = position.y as f32; let i = hit_test_pointer_move(mounted, computed, px, py)?; let node = &mounted.nodes[i]; let h = node.on_pointer_move_at.clone()?; let r = computed.get(node.id)?; Some((h, px - r.x, py - r.y, r.w, r.h)) }); if let Some((h, lx, ly, w, hh)) = move_call { if let Some(msg) = h(lx, ly, w, hh) { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, &self.handle)); state.invalidar_render(); state.window.request_redraw(); } } } // ── IME ────────────────────────────────────────────────────────────────── /// Un archivo soltado sobre la ventana. Lo llaman **dos** caminos: la rama /// `WindowEvent::DroppedFile` (X11, Windows, macOS) y el puente /// `wl_data_device` de Wayland vía `UserEvent::FileDrop` — winit no emite /// ese evento en su backend Wayland. Que sea un solo método es lo que hace /// que las apps no tengan que saber por cuál entró. pub(super) fn aplicar_file_drop(&mut self, path: std::path::PathBuf) { let Some(state) = self.state.as_mut() else { return }; if let Some(msg) = A::on_file_drop(state.model.as_ref().expect("model"), path) { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, &self.handle)); state.invalidar_render(); state.window.request_redraw(); } } fn handle_ime(&mut self, ime: llimphi_hal::winit::event::Ime) { use llimphi_hal::winit::event::Ime; let Some(state) = self.state.as_mut() else { return }; let ev = match ime { Ime::Enabled => ImeEvent::Enabled, Ime::Preedit(text, cursor) => ImeEvent::Preedit { text, cursor }, Ime::Commit(text) => ImeEvent::Commit(text), Ime::Disabled => ImeEvent::Disabled, }; if let Some(msg) = A::on_ime(state.model.as_ref().expect("model"), &ev) { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, &self.handle)); state.invalidar_render(); state.window.request_redraw(); } } // ── Teclado ─────────────────────────────────────────────────────────────── fn handle_keyboard_input( &mut self, event: llimphi_hal::winit::event::KeyEvent, ) { depurar_tecla(&event, self.state.as_ref().map(|s| s.modifiers)); let Some(state) = self.state.as_mut() else { return }; // ── Auto-repeat sintético: seguimiento de la tecla sostenida ── // winit no siempre genera los eventos repetidos en Wayland; el runtime los // sintetiza en `about_to_wait`. Aquí sólo se ARMA/DESARMA el seguimiento. { if event.repeat { // winit YA repite: apagamos la síntesis propia (no duplicar). state.winit_repeats = true; state.held_key = None; } else if event.state == ElementState::Released { if state .held_key .as_ref() .is_some_and(|h| h.event.logical_key == event.logical_key) { state.held_key = None; } } else if !state.winit_repeats { let repetible = tecla_repetible( &event.logical_key, event.text.is_some(), state.modifiers, ); state.held_key = repetible.then(|| crate::HeldKey { event: event.clone(), next: crate::Instant::now() + crate::KEY_REPEAT_DELAY, confirmada: false, }); } } // Tab / Shift+Tab mueven el foco entre nodos `focusable`, // que administra el runtime. Sólo intercepta si hay // enfocables y en Pressed; si no, cae al `on_key` normal // (apps que usan Tab para otra cosa lo siguen recibiendo). let is_tab = event.state == ElementState::Pressed && matches!( crate::tecla_de_winit(&event.logical_key), Key::Named(NamedKey::Tab) ); if is_tab { let order = state .last_render .as_ref() .map(|c| focus_order(&c.mounted, &c.computed)) .unwrap_or_default(); if !order.is_empty() { let next = next_focus(&order, state.focused, state.modifiers.shift); state.focused = next; if let Some(msg) = A::on_focus(state.model.as_ref().expect("model"), next) { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, &self.handle)); } state.invalidar_render(); state.window.request_redraw(); return; } } // Ctrl/Cmd+C copia la selección de texto activa (fuera del // editor). Consume el evento sólo si había algo seleccionado; // si no, cae al `on_key` normal (apps que usan Ctrl+C para lo // suyo lo siguen recibiendo). if event.state == ElementState::Pressed && (state.modifiers.ctrl || state.modifiers.meta) && key_is_char(&crate::tecla_de_winit(&event.logical_key), 'c') { if let Some(tsel) = state.selection { let text = state .last_render .as_ref() .and_then(|c| selectable_by_key(c, tsel.key)) .and_then(|(spec, _)| { spec.content.get(tsel.sel.text_range()).map(str::to_string) }); if let Some(t) = text.filter(|t| !t.is_empty()) { copy_to_clipboard(&t); return; } } } let ev = KeyEvent { // La FRONTERA: de la tecla de winit al modelo del compositor (W4.7). key: crate::tecla_de_winit(&event.logical_key), state: match event.state { ElementState::Pressed => KeyState::Pressed, ElementState::Released => KeyState::Released, }, text: event.text.as_ref().map(|t| t.to_string()), modifiers: state.modifiers, repeat: event.repeat, }; if let Some(msg) = A::on_key(state.model.as_ref().expect("model"), &ev) { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, &self.handle)); state.invalidar_render(); state.window.request_redraw(); } } // ── Rueda del mouse ─────────────────────────────────────────────────────── fn handle_mouse_wheel( &mut self, delta: MouseScrollDelta, ) { let handle = self.handle.clone(); let Some(state) = self.state.as_mut() else { return }; // Convención winit: LineDelta es líneas; PixelDelta es // píxeles físicos (touchpads). En CSS y aquí, positivo // (rueda hacia adelante / dos dedos arriba) = scroll // hacia arriba, así que invertimos `y` para que el // contenido "siga al dedo" en y positivo. `x` queda // como llega. // // La variante ADEMÁS dice de qué clase de dispositivo viene, y es el // único lugar del runner de ventana donde ese dato existe: `LineDelta` // sale de una rueda física (clicks), `PixelDelta` de un touchpad o una // rueda de precisión. Con eso el sentido se decide por clase — ver // `llimphi_entrada::PoliticaRueda`. let (wd, fuente) = match delta { MouseScrollDelta::LineDelta(x, y) => (WheelDelta { x, y: -y }, FuenteRueda::Raton), MouseScrollDelta::PixelDelta(p) => ( WheelDelta { x: (p.x as f32) / 20.0, y: -(p.y as f32) / 20.0, }, FuenteRueda::Touchpad, ), }; let wd = crate::politica_rueda().aplicar(wd, fuente); // Caché de hit-test invalidada (un update recién corrió y el redraw // aún no la reconstruyó): **diferir** — se acumula el delta y se // procesa al final del próximo redraw, contra el árbol fresco. Sin // esto, una app con ticks frecuentes (media a 30/s) invalida la caché // antes de casi cada rueda y el scroll por wheel queda muerto // (chain=0 sistemático, cazado con LLIMPHI_DEBUG_WHEEL en vivo). if state.last_render.is_none() { let acc = state.pending_wheel.get_or_insert(WheelDelta { x: 0.0, y: 0.0 }); acc.x += wd.x; acc.y += wd.y; state.window.request_redraw(); return; } Self::process_wheel(state, &handle, wd); } /// Procesa una rueda contra el árbol de hit-test vigente. Asociada (sin /// `self`) para que también el redraw pueda **replay-ear** la rueda /// diferida (ver `pending_wheel`) con el árbol recién armado. pub(super) fn process_wheel( state: &mut crate::RuntimeState, handle: &crate::Handle, wd: WheelDelta, ) { let cursor = (state.cursor.x as f32, state.cursor.y as f32); // Ctrl+rueda = **pinch-to-zoom sintético** (camino universal de // desktop: Wayland/Windows no emiten el gesto de pinch del // trackpad). Si hay un nodo `on_scale` bajo el cursor, el zoom // lo consume ANTES que el scroll/`on_wheel`. Factor // multiplicativo incremental `1.1^(-dy)`: rueda hacia arriba // (`dy<0`) agranda, hacia abajo achica. El punto focal es el // cursor (en coords locales al nodo), para zoomear "hacia el // cursor". Sin nodo zoomeable, Ctrl+rueda cae al `on_wheel` // global como siempre (browsers, etc. lo siguen recibiendo). if state.modifiers.ctrl { let scale_hit = state .last_render .as_ref() .and_then(|cache| scale_hit_from_cache(cache, cursor.0, cursor.1)); if let Some((h, fx, fy)) = scale_hit { let factor = 1.1_f32.powf(-wd.y); if let Some(msg) = h(GesturePhase::Update, factor, fx, fy) { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, handle)); state.invalidar_render(); state.window.request_redraw(); } return; } } // Primero: ¿hay nodos con `on_scroll` bajo el cursor? Se // arma la **cadena** (front→back) y se invoca cada handler // en orden; el primero que devuelva `Some` consume el // evento. Un scroll interno que llegó al extremo de su eje // puede devolver `None` para dejar el sobrante al ancestro // scrollable más cercano (scroll anidado). Si todos // devuelven `None`, cae al `on_wheel` global. // El overlay tiene prioridad, igual que con clicks. Se // extraen los handlers en un scope para soltar el borrow // del cache antes de mutar el modelo. let mut msgs: Vec = if let Some(cache) = state.last_render.as_ref() { let (m, c) = match cache.overlay.as_ref() { Some(ov) => (&ov.mounted, &ov.computed), None => (&cache.mounted, &cache.computed), }; let chain = hit_test_scroll_chain(m, c, cursor.0, cursor.1); // Reparto parcial: el hijo consume hasta su tope y el sobrante // sigue al ancestro — puede producir VARIOS Msg por evento. consume_scroll_chain(m, &chain, wd.x, wd.y) } else { Vec::new() }; if msgs.is_empty() { if let Some(m) = A::on_wheel( state.model.as_ref().expect("model"), wd, cursor, state.modifiers, ) { msgs.push(m); } } if !msgs.is_empty() { let mut model = state.model.take().expect("model"); for msg in msgs { model = super::vigia::aplicar_update::(model, msg, handle); } state.model = Some(model); state.invalidar_render(); state.window.request_redraw(); } } // ── Gestos trackpad ─────────────────────────────────────────────────────── fn handle_pinch_gesture( &mut self, delta: f64, phase: llimphi_hal::winit::event::TouchPhase, ) { use llimphi_hal::winit::event::TouchPhase; let Some(state) = self.state.as_mut() else { return }; // Pinch del trackpad (winit lo emite **sólo en macOS/iOS**; en // Wayland/Windows el zoom va por Ctrl+rueda, arriba). `delta` es // el cambio de escala incremental (p. ej. 0.01 = +1%); lo // mapeamos al mismo `on_scale` que Ctrl+rueda, con factor // multiplicativo `1.0 + delta`. La fase de winit se traduce a // la de gesto (Begin/Update/End) para que el handler pueda, p. // ej., abrir/cerrar un estado de zoom en vivo. let cursor = (state.cursor.x as f32, state.cursor.y as f32); let gphase = match phase { TouchPhase::Started => GesturePhase::Begin, TouchPhase::Moved => GesturePhase::Update, TouchPhase::Ended | TouchPhase::Cancelled => GesturePhase::End, }; // `delta` puede venir NaN según la doc de winit; en ese caso // (o fuera de Update) el factor neutro es 1.0. let factor = if gphase == GesturePhase::Update && delta.is_finite() { (1.0 + delta) as f32 } else { 1.0 }; let scale_hit = state .last_render .as_ref() .and_then(|cache| scale_hit_from_cache(cache, cursor.0, cursor.1)); if let Some((h, fx, fy)) = scale_hit { if let Some(msg) = h(gphase, factor, fx, fy) { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, &self.handle)); state.invalidar_render(); state.window.request_redraw(); } } } fn handle_rotation_gesture( &mut self, delta: f32, phase: llimphi_hal::winit::event::TouchPhase, ) { use llimphi_hal::winit::event::TouchPhase; let Some(state) = self.state.as_mut() else { return }; // Rotación de dos dedos en el trackpad (winit la emite **sólo // en macOS**). `delta` viene en **grados**; lo convertimos a // radianes para el handler (positivo = horario). La fase de // winit se traduce a la de gesto (Begin/Update/End). No hay // camino universal por teclado/rueda como sí lo tiene el zoom. let cursor = (state.cursor.x as f32, state.cursor.y as f32); let gphase = match phase { TouchPhase::Started => GesturePhase::Begin, TouchPhase::Moved => GesturePhase::Update, TouchPhase::Ended | TouchPhase::Cancelled => GesturePhase::End, }; let delta_rad = if gphase == GesturePhase::Update && delta.is_finite() { delta.to_radians() } else { 0.0 }; let rotate_hit = state .last_render .as_ref() .and_then(|cache| rotate_hit_from_cache(cache, cursor.0, cursor.1)); if let Some((h, fx, fy)) = rotate_hit { if let Some(msg) = h(gphase, delta_rad, fx, fy) { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, &self.handle)); state.invalidar_render(); state.window.request_redraw(); } } } // ── Click izquierdo ─────────────────────────────────────────────────────── fn handle_left_press(&mut self) { let Some(state) = self.state.as_mut() else { return }; // Cache frío (típico durante reproducción de video: el tick anula // `last_render` en cada frame y el redraw throttled lo deja `None` al // momento del click): lo reconstruimos desde el modelo vigente ANTES de // resolver el click. Sin esto, todos los hit-tests de abajo se saltaban // y el click era un no-op silencioso (el engranaje de config no abría; // F2 sí, porque el teclado no depende de `last_render`). Ver // `build_hit_cache`. let was_cold = state.last_render.is_none(); if was_cold { build_hit_cache::(state); } let cursor = state.cursor; // ── DIAG (opt-in `MEDIA_CLICK_DIAG=1`): por qué un click no llega al // nodo que se ve arriba. Reporta si el cache llegó frío (reconstruido), // el nodo que GANA el hit-test (rect + handlers) y TODAS las capas // `over` bajo el cursor — si el gear (capa `over`) está bajo el cursor // pero no gana, es la asimetría paint/hit. ─ if click_diag_enabled() { let (cx, cy) = (cursor.x as f32, cursor.y as f32); llimphi_hal::avisar!( "[click-diag] handle_left_press cursor=({cx:.0},{cy:.0}) cache={}", if was_cold { "frío→reconstruido" } else { "vigente" }, ); if let Some(cache) = state.last_render.as_ref() { let (m, c) = match cache.overlay.as_ref() { Some(ov) => (&ov.mounted, &ov.computed), None => (&cache.mounted, &cache.computed), }; let win = hit_test_click(m, c, cx, cy); match win { Some(i) => { let n = &m.nodes[i]; let r = c.get(n.id); llimphi_hal::avisar!( "[click-diag] cursor=({cx:.0},{cy:.0}) GANA idx={i} rect={r:?} \ on_click={} on_click_screen={} over_layer={}", n.on_click.is_some(), n.on_click_screen.is_some(), n.over_layer, ); } None => llimphi_hal::avisar!("[click-diag] cursor=({cx:.0},{cy:.0}) sin hit"), } // Capas `over` cuyo rect contiene el cursor (candidatas visibles // "arriba" que el hit-test plano puede estar ignorando). for (i, n) in m.nodes.iter().enumerate() { if !n.over_layer { continue; } if let Some(r) = c.get(n.id) { let inside = cx >= r.x && cx < r.x + r.w && cy >= r.y && cy < r.y + r.h; if inside { llimphi_hal::avisar!( "[click-diag] over idx={i} subtree_end={} rect=({:.0},{:.0},{:.0},{:.0}) \ → contiene el cursor", n.subtree_end, r.x, r.y, r.w, r.h, ); } } } } } // ── Select/dropdown: `on_click_screen` (rect absoluto del nodo) ───── // Un nodo con `on_click_screen` (el disparador de un select) abre su // popup. El handler recibe el RECT ABSOLUTO del nodo `(x, y, w, h)` en // coords de ventana — así el menú puede anclarse al borde inferior del // disparador y copiar su ancho. Aditivo y con prioridad de overlay; si // dispara, cortamos (el disparador no lleva on_click). let screen_hit = state.last_render.as_ref().and_then(|cache| { let (m, c) = match cache.overlay.as_ref() { Some(ov) => (&ov.mounted, &ov.computed), None => (&cache.mounted, &cache.computed), }; hit_test_click(m, c, cursor.x as f32, cursor.y as f32).and_then(|i| { let node = &m.nodes[i]; let handler = node.on_click_screen.clone()?; // El rect ABSOLUTO del nodo. Si el lookup falla o sale degenerado // (w≈0), caemos al cursor (posición conocida-buena) en vez de a // (0,0) —que anclaba el popup a la esquina de la ventana—. let r = match c.get(node.id) { Some(r) if r.w > 0.5 => (r.x, r.y, r.w, r.h), _ => (cursor.x as f32, cursor.y as f32, 0.0, 0.0), }; Some((handler, r)) }) }); if let Some((handler, (rx, ry, rw, rh))) = screen_hit { // **Tampoco se dispara en el press.** Es el disparador de un // desplegable (un select, el botón de una barra que abre menú), y // abrirlo en el mousedown tiene el mismo problema que el menú // contextual: el popup nace bajo el cursor y se come el release, // así que soltar encima elige el primer ítem sin querer. El `Msg` // se resuelve **acá** —con el rect del disparador, que es lo que // ancla el popup— y se dispara al soltar. if let Some(msg) = handler(rx, ry, rw, rh) { state.pending_click = Some(PendingClick { msg, origin: cursor, retener_hasta: None, soltado: false, }); } return; } // Click-to-focus: si el click cae sobre un nodo enfocable, // el runtime le da el foco ANTES de procesar la acción de // click. Extraemos el id en un scope (suelta el borrow del // cache) y recién después mutamos el foco/modelo. let focus_hit = state .last_render .as_ref() .and_then(|cache| { let (m, c) = match cache.overlay.as_ref() { Some(ov) => (&ov.mounted, &ov.computed), None => (&cache.mounted, &cache.computed), }; hit_test_focusable(m, c, cursor.x as f32, cursor.y as f32) }); if focus_hit.is_some() && focus_hit != state.focused { state.focused = focus_hit; if let Some(msg) = A::on_focus(state.model.as_ref().expect("model"), focus_hit) { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, &self.handle)); } state.invalidar_render(); } // ── Selección de texto fuera del editor (aditiva) ────────── // Si el press cae sobre un nodo `View::selectable`, arranca una // selección (anchor = punto). Un press en cualquier otro lado // limpia la selección activa. Aditivo: un label seleccionable // sin `on_click`/drag no choca con el camino de abajo. let sel_hit = state.last_render.as_ref().and_then(|c| { selectable_hit_from_cache(c, cursor.x as f32, cursor.y as f32) }); if let Some((key, spec, (rx, ry, rw, _rh))) = sel_hit { let layout = build_selectable_layout(&mut state.typesetter, &spec, rw); let lx = cursor.x as f32 - rx; let ly = cursor.y as f32 - ry; let sel = llimphi_text::parley::Selection::from_point(&layout, lx, ly); state.selection = Some(TextSelection { key, sel, dragging: true }); state.invalidar_render(); state.window.request_redraw(); } else if state.selection.is_some() { state.selection = None; state.invalidar_render(); state.window.request_redraw(); } // ── Arena de gestos (aditiva): doble-tap + long-press ────── // Se resuelven con su propio hit-test y NO tocan el camino de // click/drag de abajo (intacto). El árbitro real es el tiempo: // doble-tap = dos presses cercanos dentro de una ventana; // long-press = un press que sobrevive ~500 ms quieto (lo vence // `about_to_wait`, lo cancela el movimiento/release). let now = crate::Instant::now(); // Doble-tap: si este press cae sobre un nodo con doble-tap y // hubo un tap previo cercano y a tiempo, dispará. Si no, este // press queda registrado como "primer tap". // // Con `double_tap_exclusive` el gesto además **arbitra** contra el click // del mismo nodo: el primer tap deja el click retenido (`retener_hasta`) // y el segundo lo descarta, así un doble-click dispara sólo el doble. // Lo pide el diente de un rag-sidebar, que despliega con un click y fija // el espacio con dos. let mut retener_click: Option = None; let mut suprimir_click = false; if let Some((resolved, exclusivo)) = state.last_render.as_ref().and_then(|c| { double_tap_hit_from_cache(c, cursor.x as f32, cursor.y as f32) }) { let qualifies = double_tap_qualifies(state.last_tap, now, cursor); if qualifies { state.last_tap = None; // consumido; un 3er tap no re-dispara if exclusivo { // El click del primer tap sigue retenido: muere acá, y el de // este press no llega a armarse. state.pending_click = None; suprimir_click = true; } if let Some(msg) = resolved.invoke() { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, &self.handle)); state.invalidar_render(); state.window.request_redraw(); } } else { state.last_tap = Some((now, cursor)); if exclusivo { retener_click = Some(now + DOUBLE_TAP_WINDOW); } } } // Long-press: si el press cae sobre un nodo con long-press, // armalo. `about_to_wait` lo dispara al vencer; CursorMoved lo // cancela si el cursor se aleja; el release lo cancela siempre. if let Some(handler) = state.last_render.as_ref().and_then(|c| { long_press_hit_from_cache(c, cursor.x as f32, cursor.y as f32) }) { state.pending_long_press = Some(PendingLongPress { deadline: now + LONG_PRESS_DELAY, origin: cursor, handler, }); } // Ripple/InkWell: si el press cae sobre un nodo con ripple, // dispará la salpicadura desde el punto. Aditivo — no toca el // camino click/drag de abajo; un botón con `on_click` + // `.ripple(...)` recibe ambos. if let Some((rp, lx, ly)) = state .last_render .as_ref() .and_then(|c| ripple_hit_from_cache(c, cursor.x as f32, cursor.y as f32)) { state .ripple_registry .trigger(rp.key, lx, ly, rp.color, rp.duration, now); // El ripple se pinta sobre la scene; invalidamos el cache // de paint para que el próximo redraw lo dibuje (sin esto, // un cache hit re-presentaría el frame sin la salpicadura). state.invalidar_render(); state.window.request_redraw(); } // Con overlay activo, los clicks van EXCLUSIVAMENTE a él. // Si el cursor cae sobre un nodo del overlay sin handler, // el click se descarta — la convención de "scrim que // dismissa" pide que la app meta su propio fondo // clicable con `on_click = DismissOverlay`. let idx_and_action: Option> = if let Some(cache) = state.last_render.as_ref() { if let Some(ov) = cache.overlay.as_ref() { lookup_click_hit(&ov.mounted, &ov.computed, cursor.x as f32, cursor.y as f32) } else { lookup_click_hit(&cache.mounted, &cache.computed, cursor.x as f32, cursor.y as f32) } } else { let (w, h) = state.surface.size(); // Mismo resolve de LayoutBuilder que el redraw, para que el // hit-test del fallback vea los hijos producidos por builders. let view = resolve_layout_builders::( state.model.as_ref().expect("model"), (w as f32, h as f32), &mut state.typesetter, ); let model_ref = state.model.as_ref().expect("model"); let overlay_view = super::vigia::aplicar_view_overlay::(model_ref); let mut layout = LayoutTree::new(); let mounted: Mounted = mount(&mut layout, view); let ts = &mut state.typesetter; let computed = { let tmap = &mounted.text_measures; layout .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| { match tmap.get(&nid) { Some(tm) => measure_text_node(ts, tm, known, avail), None => llimphi_layout::taffy::Size::ZERO, } }) .expect("layout") }; if let Some(ov) = overlay_view { let mut olay = LayoutTree::new(); let omounted: Mounted = mount(&mut olay, ov); let ocomp = { let tmap = &omounted.text_measures; olay .compute_with_measure(omounted.root, (w as f32, h as f32), |nid, known, avail| { match tmap.get(&nid) { Some(tm) => measure_text_node(ts, tm, known, avail), None => llimphi_layout::taffy::Size::ZERO, } }) .expect("layout overlay") }; lookup_click_hit(&omounted, &ocomp, cursor.x as f32, cursor.y as f32) } else { lookup_click_hit(&mounted, &computed, cursor.x as f32, cursor.y as f32) } }; // La carga del arrastre (lo que se va a ver viajando). Se resuelve acá, // una vez por gesto, contra el mismo cache que el resto del hit. let carga = state .last_render .as_ref() .and_then(|c| carga_hit(c, cursor.x as f32, cursor.y as f32)); // Prioridad: drag_velocity > drag_at + on_click_at > drag > // on_click_at > on_click. `drag_velocity` gana exclusivo // (no convive con on_click_at) — el caller que lo elige // suele querer un fling físico puro (lienzos pan-and-fling). if let Some((_, _, Some(handler_v), payload, _, _, _)) = &idx_and_action { state.drag = Some(DragState { handler: DragHandlerKind::Velocity(handler_v.clone()), last_cursor: cursor, payload: *payload, carga: carga.clone(), sobre_destino: false, resorte: None, samples: std::collections::VecDeque::with_capacity(VELOCITY_MAX_SAMPLES), }); state.window.request_redraw(); } else if let Some((_, Some(handler_at), _, payload, _, click_at, Some((ox, oy, rw, rh)))) = &idx_and_action { // drag_at + on_click_at COEXISTEN y arrancan un drag rastreado con // la posición inicial. CUÁNDO se dispara el click depende de si el // drag lleva **payload**, y la distinción no es cosmética: // // - **Sin payload** (canvas: lienzo de tullpu, text-input, // text-editor…) el drag mueve/selecciona *dentro* del mismo nodo. // Ahí select-on-press es lo correcto y es el diseño original. // // - **Con payload** el drag es una TRANSFERENCIA: el nodo se va a // soltar sobre otro (reordenar dientes, pestañas, items de lista, // paneles teselados). Disparar el click en el press ahí es un // defecto doble: (1) el botón "salta" en el mousedown, que se // siente mal, y (2) el update que provoca hace `invalidar_render` // y reconstruye el árbol *con el drag ya en curso*, así que el // `hit_test_drop` del release resuelve contra un cache que ya no // es el del origen y el reorden no llega nunca. El drag se veía // "muerto" sin estarlo. // // Con payload, entonces, el click se ARMA igual que un `on_click` // plano: lo dispara el release si el cursor no se alejó más que // `CLICK_MOVE_CANCEL`. Un arrastre de verdad supera el umbral, el // pending se cancela solo (ver `cursor_moved`) y queda únicamente el // drop. Un toque sin mover sigue activando, al soltar. let lx0 = cursor.x as f32 - ox; let ly0 = cursor.y as f32 - oy; if let Some(click_at_h) = click_at { if let Some(msg) = click_at_h(lx0, ly0, *rw, *rh).filter(|_| !suprimir_click) { if payload.is_some() || retener_click.is_some() { state.pending_click = Some(PendingClick { msg, origin: cursor, retener_hasta: retener_click, soltado: false, }); } else { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, &self.handle)); state.invalidar_render(); } } } state.drag = Some(DragState { handler: DragHandlerKind::DeltaAt(handler_at.clone(), lx0, ly0), last_cursor: cursor, payload: *payload, carga: carga.clone(), sobre_destino: false, resorte: None, samples: std::collections::VecDeque::new(), }); state.window.request_redraw(); } else if let Some((Some(handler), _, _, payload, _, _, _)) = &idx_and_action { // `drag` simple (sin _at, sin velocity) mantiene la // semántica antigua: gana exclusivo sobre on_click. state.drag = Some(DragState { handler: DragHandlerKind::Delta(handler.clone()), last_cursor: cursor, payload: *payload, carga: carga.clone(), sobre_destino: false, resorte: None, samples: std::collections::VecDeque::new(), }); // Si hay payload, repintar para que el drop target // bajo cursor (si lo hay) se ilumine de entrada. if payload.is_some() { if let Some(cache) = state.last_render.as_mut() { let new_drop = hit_test_drop( &cache.mounted, &cache.computed, cursor.x as f32, cursor.y as f32, ); if new_drop != cache.drop_hover_idx { cache.drop_hover_idx = new_drop; state.window.request_redraw(); } } } } else if let Some((_, _, _, _, _, Some(handler), Some((ox, oy, rw, rh)))) = &idx_and_action { // on_click_at gana sobre on_click si ambos existen. let lx = cursor.x as f32 - ox; let ly = cursor.y as f32 - oy; if let Some(msg) = handler(lx, ly, *rw, *rh).filter(|_| !suprimir_click) { // `on_click_at` SIN drag es un click como cualquier otro que // además necesita saber DÓNDE se tocó (una celda, una rueda de // color, una fila). Que necesite coordenadas no lo vuelve otro // gesto: se arma en el press —resolviendo el `Msg` con las // coords del press, que son las que el usuario apuntó— y sale // al soltar, igual que `on_click`. La rama que sí dispara en el // press es la de `drag_at + on_click_at` sin payload (canvas, // texto), donde seleccionar-al-apretar es el gesto correcto. state.pending_click = Some(PendingClick { msg, origin: cursor, retener_hasta: retener_click, soltado: false, }); } } else if let Some((_, _, _, _, Some(msg), _, _)) = idx_and_action { // `on_click` plano: NO se dispara en el press. Semántica de // escritorio — se arma y se dispara al soltar (`handle_left_release`) // si el cursor no se alejó más que `CLICK_MOVE_CANCEL`. Así el click // no salta en mousedown y un arrastre minúsculo accidental sigue // contando como click. No invalidamos el cache: nada visual cambió. if !suprimir_click { state.pending_click = Some(PendingClick { msg, origin: cursor, retener_hasta: retener_click, soltado: false, }); } } } // ── Click medio ─────────────────────────────────────────────────────────── fn handle_middle_press(&mut self) { let Some(state) = self.state.as_mut() else { return }; // Cache frío (ver `build_hit_cache` / `handle_left_press`): reconstruir // antes de hit-testear para que el click no se pierda durante video. if state.last_render.is_none() { build_hit_cache::(state); } // Middle-click: dispatcha `on_middle_click` del nodo // bajo cursor si lo declaró. La capa overlay tiene // prioridad (mismo razonamiento que el left/right click). let cursor = state.cursor; let lookup = |m: &Mounted, c: &ComputedLayout| -> Option { hit_test_middle_click(m, c, cursor.x as f32, cursor.y as f32) .and_then(|i| m.nodes[i].on_middle_click.clone()) }; let msg = if let Some(cache) = state.last_render.as_ref() { if let Some(ov) = cache.overlay.as_ref() { lookup(&ov.mounted, &ov.computed) } else { lookup(&cache.mounted, &cache.computed) } } else { None }; if let Some(msg) = msg { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, &self.handle)); state.invalidar_render(); state.window.request_redraw(); } } // ── Click derecho ───────────────────────────────────────────────────────── fn handle_right_press(&mut self) { let Some(state) = self.state.as_mut() else { return }; // Cache frío (ver `build_hit_cache` / `handle_left_press`): reconstruir // antes de hit-testear para que el menú contextual no se pierda durante // video. if state.last_render.is_none() { build_hit_cache::(state); } // Right-click: dispatcheamos `on_right_click` o // `on_right_click_at` del nodo bajo cursor. La capa // overlay tiene prioridad (mismo razonamiento que el // left-click). Nodos sin handler de right-click no // reaccionan — no "filtramos" al left. let cursor = state.cursor; #[allow(clippy::type_complexity)] let lookup = |m: &Mounted, c: &ComputedLayout| -> Option<(Option, Option>, Option>, (f32, f32, f32, f32))> { hit_test_right_click(m, c, cursor.x as f32, cursor.y as f32).map(|i| { let node = &m.nodes[i]; let rect = c .get(node.id) .map(|r| (r.x, r.y, r.w, r.h)) .unwrap_or((0.0, 0.0, 0.0, 0.0)); ( node.on_right_click.clone(), node.on_right_click_at.clone(), node.on_right_click_screen.clone(), rect, ) }) }; let hit = if let Some(cache) = state.last_render.as_ref() { if let Some(ov) = cache.overlay.as_ref() { lookup(&ov.mounted, &ov.computed) } else { lookup(&cache.mounted, &cache.computed) } } else { None }; if let Some((msg_opt, at_opt, screen_opt, (ox, oy, rw, rh))) = hit { // `on_right_click_screen` (coords absolutas de ventana, para menús // anclados al puntero) gana sobre `on_right_click_at` (coords locales // al nodo, para grillas/canvas), y ambos sobre el `on_right_click` plano. let msg = if let Some(handler) = screen_opt { handler(cursor.x as f32, cursor.y as f32, rw, rh) } else if let Some(handler) = at_opt { handler(cursor.x as f32 - ox, cursor.y as f32 - oy, rw, rh) } else { msg_opt }; // **No se dispara en el press.** El menú contextual se despliega al // SOLTAR, igual que el click izquierdo — y por las mismas razones, // que en el derecho pesan incluso más: // // * Con el menú abierto en el mousedown, el gesto ya no se puede // arrepentir: el momento de decidir «no, acá no» es justamente // mientras el botón sigue apretado. // * El menú aparece **bajo el cursor** y captura el overlay al // instante, así que el release siguiente cae adentro del menú // recién nacido en vez de sobre lo que apuntaste. // * Y un derecho con arrastre (marcar y menú, o el derecho que // algunas apps usan para pan) se volvía imposible de distinguir. // // Se arma como el izquierdo y lo dispara `handle_right_release` si // el cursor no se alejó más que `CLICK_MOVE_CANCEL`. if let Some(msg) = msg { state.pending_right_click = Some(PendingClick { msg, origin: cursor, retener_hasta: None, soltado: false, }); } } } /// El derecho **al soltar**: dispara el menú armado en el press si el /// cursor no se alejó. Ver el porqué en [`Self::handle_right_press`]. fn handle_right_release(&mut self) { let Some(state) = self.state.as_mut() else { return }; let Some(pending) = state.pending_right_click.take() else { return }; let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, pending.msg, &self.handle)); state.invalidar_render(); state.window.request_redraw(); } // ── Release izquierdo ───────────────────────────────────────────────────── fn handle_left_release(&mut self) { let Some(state) = self.state.as_mut() else { return }; // El cursor del arrastre se levanta SIEMPRE al soltar, aunque el drop // no haya caído en ningún lado: un puntero que se queda en «acá no» // después de soltar es peor que no haberlo cambiado nunca. if state.cursor_arrastre.take().is_some() { let bajo = state.last_render.as_ref().and_then(|c| { let (m, comp) = match c.overlay.as_ref() { Some(ov) => (&ov.mounted, &ov.computed), None => (&c.mounted, &c.computed), }; hit_test_cursor(m, comp, state.cursor.x as f32, state.cursor.y as f32) }); state.window.set_cursor(to_winit_cursor(bajo)); } // El botón se soltó antes de vencer el long-press → no era un // long-press (fue un click/drag); cancela el gesto armado. state.pending_long_press = None; // Y el próximo arrastre vuelve a poder entregarse. state.arrastre_entregado = false; // `on_click` armado en el press y no cancelado por movimiento → este es // el click real: se dispara ahora, al soltar. Es mutuamente excluyente // con `drag` (rama else-if en el press), así que no colisiona con el // cierre del drag de más abajo. if let Some(pending) = state.pending_click.take() { // Click RETENIDO por un doble-tap exclusivo: soltar no alcanza, el // segundo tap todavía puede llegar. Queda anotado como soltado y lo // dispara `about_to_wait` cuando vence la ventana del doble. if pending.retener_hasta.is_some_and(|t| crate::Instant::now() < t) { state.pending_click = Some(PendingClick { soltado: true, ..pending }); } else { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, pending.msg, &self.handle)); state.invalidar_render(); state.window.request_redraw(); } } // Fin del arrastre de selección: la selección queda viva (para // Ctrl/Cmd+C) pero deja de extenderse con el cursor. if let Some(tsel) = state.selection.as_mut() { tsel.dragging = false; } if let Some(drag) = state.drag.take() { let cursor = state.cursor; // 1. Drop: si hay payload + drop target bajo cursor, // invocamos su handler. El Msg resultante se aplica // ANTES del End del drag — la convención es "drop // primero, cleanup del drag después". if let Some(payload) = drag.payload { // Cache frío (ver `build_hit_cache`, y es el MISMO bicho que // hacía «click no-op silencioso»): `last_render` se anula en // cada `Msg` —el tick propio de la app incluido— y sólo vuelve // a `Some` tras un redraw completo. Si el release cae en esa // ventana, el drop se saltaba el hit-test entero y el gesto // moría **sin decir nada**: la fila destino se ilumina mientras // arrastrás (eso mira el cache del Move, que sí está vivo) y al // soltar no pasa nada. Visto en metal el 2026-08-21 arrastrando // archivos a una colección de nahual. Los `handle_*_press` ya // reconstruían acá; este camino se había quedado afuera. if state.last_render.is_none() { build_hit_cache::(state); } if let Some(cache) = state.last_render.as_ref() { if let Some(idx) = hit_test_drop( &cache.mounted, &cache.computed, cursor.x as f32, cursor.y as f32, ) { if let Some(drop_h) = cache.mounted.nodes[idx].on_drop.clone() { if let Some(msg) = (drop_h)(payload) { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, &self.handle)); } } } } } // 2. Cierre del drag. let end_msg = match &drag.handler { DragHandlerKind::Delta(h) => h(DragPhase::End, 0.0, 0.0), DragHandlerKind::DeltaAt(h, lx0, ly0) => { h(DragPhase::End, 0.0, 0.0, *lx0, *ly0) } DragHandlerKind::Velocity(h) => { let (vx, vy) = compute_drag_velocity(&drag.samples, crate::Instant::now()); h(DragPhase::End, 0.0, 0.0, vx, vy) } }; if let Some(msg) = end_msg { let model = state.model.take().expect("model"); state.model = Some(super::vigia::aplicar_update::(model, msg, &self.handle)); } // Cache invalidado siempre — hover/drop pueden cambiar // y el modelo posiblemente mutó. state.invalidar_render(); state.window.request_redraw(); } } } /// ¿Esta tecla merece auto-repeat sintético? /// /// Dos familias, y la diferencia importa: /// /// - **Edición/navegación** (borrar, moverse): repiten siempre, incluso con /// modificador — `ctrl+backspace` borra palabra y `ctrl+flecha` salta /// palabra, y sostenerlas es justo para lo que están. /// - **Caracteres**: repiten sólo *sin* ctrl/alt/super. Con uno de esos encima /// no es tecleo, es un **atajo**, y un atajo se ejecuta la vez que se pidió. /// Sin esta distinción, sostener un acorde tres cuartos de segundo —lo que /// dura teclearlo sin apuro— lo dispara veinticinco veces por segundo. /// /// Los modificadores solos no traen texto ni son de edición, así que caen por /// su propio peso. pub(super) fn tecla_repetible( logical: &llimphi_hal::winit::keyboard::Key, tiene_texto: bool, mods: Modifiers, ) -> bool { use llimphi_hal::winit::keyboard::{Key, NamedKey}; let edicion = matches!( logical, Key::Named( NamedKey::Backspace | NamedKey::Delete | NamedKey::ArrowLeft | NamedKey::ArrowRight | NamedKey::ArrowUp | NamedKey::ArrowDown ) ); let atajo = mods.ctrl || mods.alt || mods.meta; edicion || (tiene_texto && !atajo) } #[cfg(test)] mod pruebas_repetible { use super::tecla_repetible; use crate::Modifiers; use llimphi_hal::winit::keyboard::{Key, NamedKey}; fn tecla(c: &str) -> Key { Key::Character(c.into()) } #[test] fn lo_que_se_escribe_repite() { assert!(tecla_repetible(&tecla("a"), true, Modifiers::default())); let shift = Modifiers { shift: true, ..Default::default() }; assert!( tecla_repetible(&tecla("A"), true, shift), "shift es para escribir mayúsculas, no un atajo" ); } #[test] fn borrar_y_moverse_repiten_aun_con_modificador() { let ctrl = Modifiers { ctrl: true, ..Default::default() }; assert!(tecla_repetible(&Key::Named(NamedKey::Backspace), false, ctrl)); assert!(tecla_repetible(&Key::Named(NamedKey::ArrowLeft), false, ctrl)); } /// **Regresión**: un acorde es una orden, y una orden se ejecuta la vez que /// se pidió. Sostener `ctrl+shift+t` no puede abrir seis pestañas. #[test] fn un_acorde_no_repite() { let ctrl_shift = Modifiers { ctrl: true, shift: true, ..Default::default() }; assert!(!tecla_repetible(&tecla("T"), true, ctrl_shift)); let alt = Modifiers { alt: true, ..Default::default() }; assert!(!tecla_repetible(&tecla("1"), true, alt)); let meta = Modifiers { meta: true, ..Default::default() }; assert!(!tecla_repetible(&tecla("l"), true, meta)); } /// Enter, Escape, Tab y los modificadores no traen texto y no son de /// edición: no repiten sin tener que enumerarlos. #[test] fn mandar_y_los_modificadores_no_repiten() { let m = Modifiers::default(); assert!(!tecla_repetible(&Key::Named(NamedKey::Enter), false, m)); assert!(!tecla_repetible(&Key::Named(NamedKey::Escape), false, m)); assert!(!tecla_repetible(&Key::Named(NamedKey::Tab), false, m)); assert!(!tecla_repetible(&Key::Named(NamedKey::Shift), false, m)); } } /// **Cuánto hay que quedarse posado** sobre un destino para que se abra solo. /// /// Medio segundo largo: más corto y el destino se abre de paso, mientras cruzás /// el panel para llegar a otro; más largo y el gesto se siente trabado. Es el /// mismo orden de magnitud que usan los gestores de archivos de escritorio para /// sus carpetas con resorte. pub const RESORTE: std::time::Duration = std::time::Duration::from_millis(550); /// **Avanza el reloj del resorte** y dice si a ESTE movimiento le toca dispararlo. /// /// Pura sobre el estado del arrastre para poder exigirle por test las tres cosas /// que no se ven en un pantallazo: que cambiar de destino reinicie la cuenta /// (pasar por encima de tres plegables no abre los tres), que dispare **una sola /// vez** por posada (si no, el destino se re-abriría en cada movimiento del /// puntero) y que sin destino no dispare nada. fn paso_del_resorte( drag: Option<&mut crate::DragState>, destino: Option, ) -> Option { let drag = drag?; let idx = match destino { Some(i) => i, None => { drag.resorte = None; return None; } }; let ahora = crate::Instant::now(); match drag.resorte.as_mut() { Some((i, _, _)) if *i != idx => { drag.resorte = Some((idx, ahora, false)); None } Some((_, _, true)) => None, Some((_, desde, disparado)) => { if ahora.duration_since(*desde) >= RESORTE { *disparado = true; Some(idx) } else { None } } None => { drag.resorte = Some((idx, ahora, false)); None } } }