tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1// input.rs — Manejo de todos los eventos de entrada de la ventana primaria.
2// Cubre: teclado, mouse (click/drag/rueda/gestos), IME, drop de archivos,
3// resize, DPI. Cada variante de `WindowEvent` queda en su sección.
4
5use super::super::*;
6use super::helpers::*;
7
8/// Resultado de un hit-test de click izquierdo. Tupla interna para extraer
9/// handlers del cache antes de mutar el modelo (suelta el borrow).
10pub(super) type HitInfo<M> = (
11 Option<DragFn<M>>,
12 Option<DragAtFn<M>>,
13 Option<DragVelocityFn<M>>,
14 Option<u64>,
15 Option<M>,
16 Option<ClickAtFn<M>>,
17 Option<(f32, f32, f32, f32)>,
18);
19
20/// Extrae el `HitInfo` de click izquierdo para un árbol montado dado.
21pub(super) fn lookup_click_hit<Msg: Clone>(
22 m: &Mounted<Msg>,
23 c: &ComputedLayout,
24 cx: f32,
25 cy: f32,
26) -> Option<HitInfo<Msg>> {
27 hit_test_click(m, c, cx, cy).map(|i| {
28 let node = &m.nodes[i];
29 let rect = c.get(node.id).map(|r| (r.x, r.y, r.w, r.h));
30 (
31 node.drag.clone(),
32 node.drag_at.clone(),
33 node.drag_velocity.clone(),
34 node.drag_payload,
35 node.on_click.clone(),
36 node.on_click_at.clone(),
37 rect,
38 )
39 })
40}
41
42/// La **carga** que el nodo bajo `(cx, cy)` declaró para su arrastre
43/// ([`llimphi_compositor::View::drag_carga`]). Es un hit-test aparte y no un
44/// campo más del [`HitInfo`] a propósito: la tupla se destructura en cinco
45/// lugares y agregarle un octavo hueco por un dato que sólo mira el pintado
46/// del fantasma es peor negocio que un segundo `hit_test_click` en el press
47/// —que ocurre una vez por gesto, no por cuadro—.
48fn carga_hit<Msg: Clone>(
49 cache: &RenderCache<Msg>,
50 cx: f32,
51 cy: f32,
52) -> Option<std::sync::Arc<llimphi_compositor::CargaArrastre>> {
53 let (m, c) = match cache.overlay.as_ref() {
54 Some(ov) => (&ov.mounted, &ov.computed),
55 None => (&cache.mounted, &cache.computed),
56 };
57 hit_test_click(m, c, cx, cy).and_then(|i| m.nodes[i].drag_carga.clone())
58}
59
60/// ¿Diag de click prendido? (`MEDIA_CLICK_DIAG=1`). Cacheado: un `env::var`
61/// una sola vez, no por click.
62fn click_diag_enabled() -> bool {
63 use std::sync::OnceLock;
64 static D: OnceLock<bool> = OnceLock::new();
65 *D.get_or_init(|| std::env::var("MEDIA_CLICK_DIAG").is_ok())
66}
67
68/// Reconstruye un `RenderCache` fresco desde el modelo vigente (mount + layout
69/// del árbol principal y del overlay, **sin pintar**) y lo deja en
70/// `state.last_render`. Se llama al tope de cada handler de press cuando el
71/// cache llegó invalidado (`None`).
72///
73/// Por qué: `last_render` se anula en CADA `Msg` (el tick de video incluido) y
74/// sólo vuelve a `Some` tras un redraw completo. Con video tickeando + redraw
75/// throttled, la ventana en que `last_render` está `None` es enorme, y un click
76/// que cae ahí **se saltaba todos los hit-tests** de `handle_*_press` (sólo el
77/// `on_click` plano tenía fallback; `on_click_screen`/foco/selección/gestos no)
78/// → click no-op silencioso: el engranaje de config de media no abría, F2 sí.
79/// Reconstruir acá hace que TODOS los caminos de hit-test vean el árbol del
80/// modelo vigente, sin depender de que exista un frame recién pintado.
81///
82/// Anula `state.retained`: montamos el árbol pero NO lo pintamos, así que la
83/// invariante de retención ("`last_render` Some ⇒ nada visual cambió desde el
84/// paint") ya no vale — el próximo redraw debe repintar completo el modelo
85/// vigente en vez de re-presentar el frame viejo.
86pub(super) fn build_hit_cache<A: App>(state: &mut crate::RuntimeState<A>) {
87 let (w, h) = state.surface.size();
88 let vp = (w as f32, h as f32);
89 // Mismo resolve de LayoutBuilder que el redraw, para que el hit-test vea
90 // los hijos producidos por builders diferidos.
91 let view = resolve_layout_builders::<A>(
92 state.model.as_ref().expect("model"),
93 vp,
94 &mut state.typesetter,
95 );
96 let overlay_view =
97 super::vigia::aplicar_view_overlay::<A>(state.model.as_ref().expect("model"));
98 // `LayoutTree` efímero propio: no tocamos `state.layout`/`retained_layout`
99 // (los usa el redraw retenido) para no ensuciar su reuso incremental.
100 let mut layout = LayoutTree::new();
101 // Reciclado de buffers (Bloque 21): esta función corre justamente cuando
102 // `last_render` está invalidado, o sea cuando el pool tiene el árbol del
103 // frame anterior esperando. Sin `prev` no hay reuso de NodeIds (el
104 // `LayoutTree` es efímero, arriba), pero sí se reusa el bloque de memoria.
105 let (mounted, _, _): (Mounted<A::Msg>, _, _) =
106 llimphi_compositor::mount_incremental_reciclando(
107 &mut layout,
108 view,
109 None,
110 state.mount_pool.take(),
111 );
112 let computed = {
113 let ts = &mut state.typesetter;
114 let tmap = &mounted.text_measures;
115 layout
116 .compute_with_measure(mounted.root, vp, |nid, known, avail| match tmap.get(&nid) {
117 Some(tm) => measure_text_node(ts, tm, known, avail),
118 None => llimphi_layout::taffy::Size::ZERO,
119 })
120 .expect("layout")
121 };
122 let overlay = overlay_view.map(|ov| {
123 let mut olay = LayoutTree::new();
124 let omounted: Mounted<A::Msg> = mount(&mut olay, ov);
125 let ocomputed = {
126 let ts = &mut state.typesetter;
127 let tmap = &omounted.text_measures;
128 olay.compute_with_measure(omounted.root, vp, |nid, known, avail| {
129 match tmap.get(&nid) {
130 Some(tm) => measure_text_node(ts, tm, known, avail),
131 None => llimphi_layout::taffy::Size::ZERO,
132 }
133 })
134 .expect("layout overlay")
135 };
136 let ohover =
137 hit_test_hover(&omounted, &ocomputed, state.cursor.x as f32, state.cursor.y as f32);
138 crate::OverlayCache { mounted: omounted, computed: ocomputed, hover_idx: ohover }
139 });
140 state.last_render = Some(crate::RenderCache {
141 mounted,
142 computed,
143 drop_hover_idx: None,
144 overlay,
145 });
146 // Montamos sin pintar → el frame retenido ya no representa el modelo
147 // vigente. Invalidar la retención fuerza un redraw completo (correcto).
148 state.retained = None;
149}
150
151impl<A: App> Runtime<A> {
152 /// Android: muestra/oculta el teclado en pantalla siguiendo a
153 /// [`App::wants_text_input`]. Se llama en cada `RedrawRequested` (todo
154 /// update pide redraw, así que el foco nunca queda atrás). El estado
155 /// del IME no se puede leer, sólo empujar: se cachea la última orden y
156 /// se re-afirma tras un tap (`soft_input_reassert`) por si el usuario
157 /// lo bajó con Atrás.
158 #[cfg(target_os = "android")]
159 pub(super) fn sync_soft_input(&mut self) {
160 let wants = self
161 .state
162 .as_ref()
163 .and_then(|s| s.model.as_ref())
164 .map(A::wants_text_input)
165 .unwrap_or(false);
166 let force = std::mem::take(&mut self.soft_input_reassert);
167 if wants == self.soft_input_visible && !(wants && force) {
168 return;
169 }
170 self.soft_input_visible = wants;
171 if let Some(app) = &self.android_app {
172 if wants {
173 app.show_soft_input(true);
174 } else {
175 app.hide_soft_input(true);
176 }
177 }
178 }
179
180 /// Maneja un `WindowEvent` de la ventana primaria. La delegación a
181 /// secciones internas sigue el orden de frecuencia (más común primero).
182 pub(super) fn handle_primary_window_event(
183 &mut self,
184 event_loop: &llimphi_hal::winit::event_loop::ActiveEventLoop,
185 event: llimphi_hal::winit::event::WindowEvent,
186 ) {
187 use llimphi_hal::winit::event::WindowEvent;
188 let Some(state) = self.state.as_mut() else {
189 return;
190 };
191 // Cada window_event debe pasar primero por el adapter AccessKit para
192 // que las tecnologías asistivas se enteren del estado real de la
193 // ventana (focus_change del SO, cursor moves, etc.). El adapter
194 // no consume el evento — lo despacha aparte vía el EventLoopProxy.
195 state.a11y_adapter.process_event(&state.window, &event);
196 match event {
197 WindowEvent::CloseRequested => event_loop.exit(),
198 WindowEvent::Resized(size) => {
199 state.surface.resize(size.width, size.height);
200 // La app puede reaccionar al nuevo viewport (emitir un
201 // evento `resize`, recalcular layout, etc.). El update se
202 // corre tras reconfigurar la surface; el cache se invalida
203 // para repintar con el tamaño nuevo.
204 if let Some(msg) =
205 A::on_resize(state.model.as_ref().expect("model"), size.width, size.height)
206 {
207 let model = state.model.take().expect("model");
208 state.model = Some(super::vigia::aplicar_update::<A>(model, msg, &self.handle));
209 state.invalidar_render();
210 }
211 // **Pantalla completa: el aviso llega acá.** En Wayland,
212 // `window.fullscreen()` lee el ÚLTIMO `configure` del
213 // compositor, así que refleja también lo que el escritorio
214 // decidió por su cuenta (el `F11` del WM), no sólo lo que pidió
215 // la app. El cambio de estado viene siempre con un `Resized` —la
216 // ventana pasa de su celda al monitor entero—, así que se compara
217 // acá y no en cada cuadro (`fullscreen()` toma un lock y consulta
218 // el monitor: no es gratis).
219 let ahora_fs = state.window.fullscreen().is_some();
220 if ahora_fs != state.fullscreen {
221 state.fullscreen = ahora_fs;
222 if let Some(msg) =
223 A::on_fullscreen(state.model.as_ref().expect("model"), ahora_fs)
224 {
225 let model = state.model.take().expect("model");
226 state.model =
227 Some(super::vigia::aplicar_update::<A>(model, msg, &self.handle));
228 state.invalidar_render();
229 }
230 }
231 state.window.request_redraw();
232 }
233 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
234 // El DPI de la ventana cambió (movida a otro monitor, escalado
235 // del sistema). winit envía un Resized aparte para el nuevo
236 // tamaño físico; aquí sólo propagamos el factor.
237 if let Some(msg) =
238 A::on_scale_factor(state.model.as_ref().expect("model"), scale_factor)
239 {
240 let model = state.model.take().expect("model");
241 state.model = Some(super::vigia::aplicar_update::<A>(model, msg, &self.handle));
242 state.invalidar_render();
243 }
244 state.window.request_redraw();
245 }
246 WindowEvent::CursorMoved { position, .. } => {
247 self.handle_cursor_moved(position);
248 }
249 WindowEvent::Focused(focused) => {
250 // Perder el foco con un drag activo casi siempre significa un
251 // release perdido (soltado fuera de la ventana / grab que el
252 // compositor no entregó). Cerrarlo aquí evita el drag colgado
253 // que se come todos los CursorMoved para siempre.
254 if !focused {
255 if let Some(drag) = state.drag.take() {
256 let end_msg = match &drag.handler {
257 DragHandlerKind::Delta(h) => h(DragPhase::End, 0.0, 0.0),
258 DragHandlerKind::DeltaAt(h, lx0, ly0) => {
259 h(DragPhase::End, 0.0, 0.0, *lx0, *ly0)
260 }
261 DragHandlerKind::Velocity(h) => {
262 h(DragPhase::End, 0.0, 0.0, 0.0, 0.0)
263 }
264 };
265 if let Some(msg) = end_msg {
266 let model = state.model.take().expect("model");
267 state.model = Some(super::vigia::aplicar_update::<A>(model, msg, &self.handle));
268 }
269 state.invalidar_render();
270 state.window.request_redraw();
271 }
272 }
273 // El foco de la ventana entró/salió. La app puede replegarse a
274 // una barra, pausar animaciones, etc. (distinto del foco de nodo
275 // que maneja `on_focus`).
276 if let Some(msg) =
277 A::on_window_focus(state.model.as_ref().expect("model"), focused)
278 {
279 let model = state.model.take().expect("model");
280 state.model = Some(super::vigia::aplicar_update::<A>(model, msg, &self.handle));
281 state.invalidar_render();
282 state.window.request_redraw();
283 }
284 }
285 WindowEvent::ModifiersChanged(mods) => {
286 let Some(state) = self.state.as_mut() else { return };
287 state.modifiers = crate::modificadores_de_winit(mods.state());
288 // La app puede querer saberlo (Shift+click / Ctrl+click de una
289 // lista: el click no lleva los modificadores, así que se
290 // guardan en el modelo). `None` = no le importa, sin repintar.
291 if let Some(msg) =
292 A::on_modifiers(state.model.as_ref().expect("model"), state.modifiers)
293 {
294 let model = state.model.take().expect("model");
295 state.model = Some(super::vigia::aplicar_update::<A>(model, msg, &self.handle));
296 state.invalidar_render();
297 state.window.request_redraw();
298 }
299 }
300 WindowEvent::Ime(ime) if A::ime_allowed() => {
301 self.handle_ime(ime);
302 }
303 WindowEvent::KeyboardInput { event, .. } => {
304 self.handle_keyboard_input(event);
305 }
306 WindowEvent::DroppedFile(path) => {
307 // Un evento por archivo (winit los entrega serializados); si
308 // el usuario suelta varios, el bucle re-entra y aplicamos
309 // updates en orden.
310 self.aplicar_file_drop(path);
311 }
312 WindowEvent::MouseWheel { delta, .. } => {
313 self.handle_mouse_wheel(delta);
314 }
315 WindowEvent::PinchGesture { delta, phase, .. } => {
316 self.handle_pinch_gesture(delta, phase);
317 }
318 WindowEvent::RotationGesture { delta, phase, .. } => {
319 self.handle_rotation_gesture(delta, phase);
320 }
321 WindowEvent::MouseInput {
322 state: ElementState::Pressed,
323 button: MouseButton::Left,
324 ..
325 } => {
326 self.handle_left_press();
327 }
328 WindowEvent::MouseInput {
329 state: ElementState::Pressed,
330 button: MouseButton::Middle,
331 ..
332 } => {
333 self.handle_middle_press();
334 }
335 WindowEvent::MouseInput {
336 state: ElementState::Pressed,
337 button: MouseButton::Right,
338 ..
339 } => {
340 self.handle_right_press();
341 }
342 WindowEvent::MouseInput {
343 state: ElementState::Released,
344 button: MouseButton::Left,
345 ..
346 } => {
347 self.handle_left_release();
348 }
349 WindowEvent::MouseInput {
350 state: ElementState::Released,
351 button: MouseButton::Right,
352 ..
353 } => {
354 self.handle_right_release();
355 }
356 WindowEvent::RedrawRequested => {
357 // Android: el teclado en pantalla sigue al foco de texto de
358 // la app (App::wants_text_input), sincronizado por frame.
359 #[cfg(target_os = "android")]
360 self.sync_soft_input();
361 super::redraw::handle_redraw::<A>(
362 self.state.as_mut().expect("state en redraw"),
363 &self.handle,
364 );
365 // El redraw pudo detectar un DeviceLost (Iris Xe reset de output,
366 // cambio de VT). No lo puede resolver solo — rehacer el device
367 // toca también las surfaces secundarias, que viven en `Runtime`.
368 if self
369 .state
370 .as_ref()
371 .is_some_and(|s| s.needs_device_rebuild)
372 {
373 self.rebuild_gpu_after_device_loss();
374 }
375 }
376 _ => {}
377 }
378 }
379
380 /// Reconstruye **todo el stack GPU** tras un [`llimphi_hal::SurfaceError::DeviceLost`]:
381 /// nuevo `Hal` (instance+adapter+device+queue) + `WinitSurface` primaria,
382 /// renderer vello y los tres compositores; avisa a la app
383 /// ([`App::on_gpu_reset`]) para que rehaga sus recursos propios; rehace las
384 /// surfaces de todas las ventanas secundarias contra el device nuevo (su
385 /// `WinitSurface` cacheaba un clone del device muerto); invalida las caches
386 /// de frame y pide redraw. Es la recuperación real que reemplaza el bandaid
387 /// de forzar backend GL: sirve para CUALQUIER GPU/driver que pierda el device
388 /// (Iris Xe reset de output, TDR de Windows, cambio de VT, suspend/resume),
389 /// no sólo esta máquina. Si el rebuild falla (GPU aún no lista), deja la
390 /// marca y reintenta en el próximo frame.
391 ///
392 /// **Web:** no hay recuperación todavía — ver la variante `wasm32` justo
393 /// debajo.
394 #[cfg(not(target_arch = "wasm32"))]
395 pub(super) fn rebuild_gpu_after_device_loss(&mut self) {
396 let Some(state) = self.state.as_mut() else { return };
397 state.needs_device_rebuild = false;
398 let window = state.window.clone();
399 // **Soltar la VkSurface vieja ANTES de crear la nueva.** Van contra la
400 // MISMA ventana, y en Android eso es ilegal mientras la vieja viva:
401 // `vkCreateAndroidSurfaceKHR` falla y wgpu-hal lo trata como fatal →
402 // abort (medido en metal el 2026-07-28; el rebuild moría ahí sin decir
403 // por qué). Antes se reemplazaba `state.surface` recién al final, o sea
404 // la vieja seguía viva durante la creación. Entre esta línea y la
405 // instalación de la nueva no se piden frames: estamos dentro del
406 // handler, no hay redraw en el medio.
407 state.surface.soltar_vk_surface();
408 let (hal, surface) = match llimphi_hal::Hal::recreate_for_window(window) {
409 Ok(pair) => pair,
410 Err(e) => {
411 llimphi_hal::avisar!(
412 "llimphi-ui: rebuild de device tras DeviceLost falló: {e}; reintento al próximo frame"
413 );
414 state.needs_device_rebuild = true;
415 state.window.request_redraw();
416 return;
417 }
418 };
419 let renderer = match llimphi_raster::Renderer::new(&hal) {
420 Ok(r) => r,
421 Err(e) => {
422 llimphi_hal::avisar!(
423 "llimphi-ui: renderer vello no reinicializó tras DeviceLost: {e}; reintento al próximo frame"
424 );
425 state.needs_device_rebuild = true;
426 state.window.request_redraw();
427 return;
428 }
429 };
430 state.overlay_compositor = llimphi_hal::OverlayCompositor::new(&hal.device);
431 state.blur_compositor = llimphi_hal::BlurCompositor::new(&hal.device);
432 state.color_filter_compositor = llimphi_hal::ColorFilterCompositor::new(&hal.device);
433 state.surface = surface;
434 state.renderer = renderer;
435 state.hal = hal;
436 // La app rehace SUS recursos GPU (video/voxel/pipelines directos) contra
437 // el device nuevo. UI pura (vello) = no-op por defecto.
438 if let Some(model) = state.model.as_mut() {
439 A::on_gpu_reset(model, &state.hal);
440 }
441 // Invalidar toda cache de frame: el contenido se re-pinta desde cero.
442 state.invalidar_render();
443 state.retained = None;
444 state.window.request_redraw();
445 // Rehacer las surfaces secundarias contra el device nuevo (campos
446 // disjuntos: `self.state` en shared, `self.secondaries` en mut).
447 let new_hal = &self.state.as_ref().expect("state recién puesto").hal;
448 for sec in &mut self.secondaries {
449 match llimphi_hal::WinitSurface::new(new_hal, sec.window.clone()) {
450 Ok(s) => {
451 sec.surface = s;
452 sec.last_render = None;
453 sec.window.request_redraw();
454 }
455 Err(e) => llimphi_hal::avisar!(
456 "llimphi-ui: no se pudo rehacer la surface de una ventana secundaria tras DeviceLost: {e}"
457 ),
458 }
459 }
460 llimphi_hal::avisar!(
461 "llimphi-ui: stack GPU reconstruido tras DeviceLost (device+surface+renderer+compositores+{} secundaria/s).",
462 self.secondaries.len()
463 );
464 }
465
466 /// Web: **todavía no hay recuperación de device**. `Hal::recreate_for_window`
467 /// no existe en wasm porque bloquea con `pollster`, y rehacerlo asíncrono
468 /// (como el arranque) es trabajo aparte. Acá se limpia la marca y se avisa
469 /// una vez: dejarla puesta pondría a reintentar en cada
470 /// `requestAnimationFrame` sin arreglar nada.
471 ///
472 /// En el navegador un device perdido es raro y su remedio natural es
473 /// recargar la página, así que la degradación es honesta.
474 #[cfg(target_arch = "wasm32")]
475 pub(super) fn rebuild_gpu_after_device_loss(&mut self) {
476 if let Some(state) = self.state.as_mut() {
477 state.needs_device_rebuild = false;
478 }
479 web_sys::console::error_1(
480 &"llimphi-ui · se perdió el device GPU; en web todavía no hay recuperación — recargá la página."
481 .into(),
482 );
483 }
484
485 // ── Cursor moved ─────────────────────────────────────────────────────────
486
487 fn handle_cursor_moved(
488 &mut self,
489 position: llimphi_hal::winit::dpi::PhysicalPosition<f64>,
490 ) {
491 let Some(state) = self.state.as_mut() else { return };
492 let prev_cursor = state.cursor;
493 state.cursor = position;
494 // Señal cruda de actividad de mouse: se dispara SIEMPRE, sin depender
495 // del hit-test de nodos ni de que haya un frame cacheado (a diferencia
496 // de `on_pointer_move_at`, que requiere `last_render` válido y puede
497 // caer en el hueco donde un Tick lo invalidó). Es lo que un OSC estilo
498 // mpv necesita para revelarse de forma fiable al mover el mouse.
499 if let Some(msg) = A::on_cursor_moved(
500 state.model.as_ref().expect("model"),
501 position.x as f32,
502 position.y as f32,
503 ) {
504 let model = state.model.take().expect("model");
505 state.model = Some(super::vigia::aplicar_update::<A>(model, msg, &self.handle));
506 state.invalidar_render();
507 state.window.request_redraw();
508 }
509 // Selección de texto en curso: extender el foco al punto actual.
510 if let Some(tsel) = state.selection.filter(|s| s.dragging) {
511 let info = state
512 .last_render
513 .as_ref()
514 .and_then(|c| selectable_by_key(c, tsel.key));
515 if let Some((spec, (rx, ry, rw, _rh))) = info {
516 let layout = build_selectable_layout(&mut state.typesetter, &spec, rw);
517 let lx = position.x as f32 - rx;
518 let ly = position.y as f32 - ry;
519 let new_sel = tsel.sel.extend_to_point(&layout, lx, ly);
520 state.selection = Some(TextSelection { sel: new_sel, ..tsel });
521 state.invalidar_render();
522 state.window.request_redraw();
523 }
524 }
525 // Long-press armado: si el cursor se alejó del origen del press
526 // más que el umbral, el gesto pasó a drag/scroll → cancelar.
527 if let Some(p) = state.pending_long_press.as_ref() {
528 let dx = position.x - p.origin.x;
529 let dy = position.y - p.origin.y;
530 if (dx * dx + dy * dy).sqrt() > LONG_PRESS_MOVE_CANCEL {
531 state.pending_long_press = None;
532 }
533 }
534 // `on_click` armado: si el cursor se alejó del origen del press más que
535 // el umbral, el usuario quiso arrastrar/barrer, no cliquear → cancelar.
536 if let Some(p) = state.pending_click.as_ref() {
537 let dx = position.x - p.origin.x;
538 let dy = position.y - p.origin.y;
539 if (dx * dx + dy * dy).sqrt() > CLICK_MOVE_CANCEL {
540 state.pending_click = None;
541 }
542 }
543 // Y el mismo trato para el menú contextual armado: alejarse con el
544 // botón derecho apretado **es** la forma de arrepentirse. Sin esto, el
545 // menú saldría igual al soltar en cualquier otro lado, que es el mismo
546 // defecto que abrirlo en el press con un paso de más.
547 if let Some(p) = state.pending_right_click.as_ref() {
548 let dx = position.x - p.origin.x;
549 let dy = position.y - p.origin.y;
550 if (dx * dx + dy * dy).sqrt() > CLICK_MOVE_CANCEL {
551 state.pending_right_click = None;
552 }
553 }
554 // Drag activo: dispatchear delta al handler + actualizar
555 // tracking del drop target hovereado (solo si hay payload).
556 if let Some(drag) = state.drag.as_mut() {
557 let dx = (position.x - drag.last_cursor.x) as f32;
558 let dy = (position.y - drag.last_cursor.y) as f32;
559 drag.last_cursor = position;
560 let payload_active = drag.payload.is_some();
561 let mut need_redraw = false;
562 if dx != 0.0 || dy != 0.0 {
563 let msg_opt = match &drag.handler {
564 DragHandlerKind::Delta(h) => h(DragPhase::Move, dx, dy),
565 DragHandlerKind::DeltaAt(h, lx0, ly0) => {
566 h(DragPhase::Move, dx, dy, *lx0, *ly0)
567 }
568 DragHandlerKind::Velocity(h) => {
569 // Durante Move, vx=vy=0 — la velocidad sólo
570 // tiene sentido al End. Aquí registramos el
571 // sample para esa medición.
572 let now = crate::Instant::now();
573 drag.samples.push_back((now, dx as f64, dy as f64));
574 while drag.samples.len() > VELOCITY_MAX_SAMPLES {
575 drag.samples.pop_front();
576 }
577 h(DragPhase::Move, dx, dy, 0.0, 0.0)
578 }
579 };
580 if let Some(msg) = msg_opt {
581 let model = state.model.take().expect("model");
582 state.model = Some(super::vigia::aplicar_update::<A>(model, msg, &self.handle));
583 // Durante drag NO invalidamos el cache —
584 // queda válido para el próximo Move.
585 need_redraw = true;
586 }
587 }
588 let mut hay_destino = false;
589 let mut resorte: Option<A::Msg> = None;
590 if payload_active {
591 if let Some(cache) = state.last_render.as_mut() {
592 let new_drop = hit_test_drop(
593 &cache.mounted,
594 &cache.computed,
595 position.x as f32,
596 position.y as f32,
597 );
598 hay_destino = new_drop.is_some();
599 if new_drop != cache.drop_hover_idx {
600 cache.drop_hover_idx = new_drop;
601 need_redraw = true;
602 }
603 // **El resorte**: quedarse posado sobre un destino lo abre
604 // (ver `View::on_drop_hover`). El destino se mira ACÁ, con
605 // el cache del cuadro a mano, pero el mensaje se dispara
606 // afuera del préstamo — `state` está prestado mutable.
607 resorte = paso_del_resorte(state.drag.as_mut(), new_drop)
608 .and_then(|i| cache.mounted.nodes.get(i))
609 .and_then(|n| n.on_drop_hover.clone());
610 }
611 }
612 // **El cursor dice qué va a pasar al soltar**, y se recalcula en
613 // cada movimiento porque las tres cosas de las que depende cambian
614 // durante el gesto: si hay destino abajo, si Ctrl está apretado, y
615 // si el arrastre lleva algo.
616 //
617 // Antes el cursor no cambiaba nunca durante un arrastre: la única
618 // señal era el resaltado del destino, o sea que arrastrar sobre el
619 // vacío y arrastrar sobre una carpeta se sentían igual hasta que
620 // soltabas.
621 if payload_active {
622 let modo = if state.modifiers.ctrl {
623 llimphi_compositor::Cursor::Copy
624 } else if hay_destino {
625 llimphi_compositor::Cursor::Grabbing
626 } else {
627 llimphi_compositor::Cursor::NoDrop
628 };
629 if state.cursor_arrastre != Some(modo) {
630 state.cursor_arrastre = Some(modo);
631 state.window.set_cursor(to_winit_cursor(Some(modo)));
632 }
633 // El fantasma va pegado al cursor: cada movimiento pide cuadro
634 // aunque el modelo no haya cambiado. Sólo con carga declarada —
635 // sin ella no se pinta nada y no hay por qué gastar cuadros.
636 if let Some(drag) = state.drag.as_mut() {
637 drag.sobre_destino = hay_destino;
638 if drag.carga.is_some() {
639 need_redraw = true;
640 }
641 }
642 }
643 // ── El gesto se le entrega al compositor al salir de la ventana ──
644 //
645 // Adentro manda el arrastre propio: es el que sabe de filas, de
646 // destinos y de qué se ilumina. Afuera no hay nada que el proceso
647 // pueda hacer —la otra ventana es de otro cliente— así que el
648 // único que puede llevar el archivo hasta allá es el compositor.
649 //
650 // **Se hace al SALIR y no al empezar**, y no es un detalle: en
651 // cuanto se llama `start_drag` el compositor toma el puntero para
652 // sí, la app deja de recibir movimiento, y con eso se apagarían el
653 // fantasma y los destinos internos. Arrancarlo en el press además
654 // convertiría cada click en un arrastre.
655 //
656 // Y no hay evento que avise: durante un arrastre el puntero queda
657 // en **grab implícito** sobre nuestra superficie, así que
658 // `CursorLeft` no llega nunca mientras el botón siga apretado. Lo
659 // que sí llega es el movimiento con coordenadas fuera del rect, y
660 // eso es lo que se mira.
661 if payload_active && !state.arrastre_entregado {
662 let (vw, vh) = state.surface.size();
663 let afuera = position.x < 0.0
664 || position.y < 0.0
665 || position.x >= vw as f64
666 || position.y >= vh as f64;
667 let archivos: Vec<std::path::PathBuf> = state
668 .drag
669 .as_ref()
670 .and_then(|d| d.carga.as_ref())
671 .map(|c| c.archivos.iter().map(std::path::PathBuf::from).collect())
672 .unwrap_or_default();
673 if afuera && !archivos.is_empty() {
674 // El aviso es el instrumento: sin él, «arrastré afuera y
675 // no pasó nada» no distingue entre «el gesto no salió de
676 // la app» y «salió y el otro lado no lo quiso».
677 llimphi_hal::avisar!(
678 "llimphi: arrastre entregado al compositor — {} archivo(s)",
679 archivos.len(),
680 );
681 if let Some(p) = state.puente.as_ref() {
682 p.arrastrar(archivos);
683 } else {
684 llimphi_hal::avisar!("llimphi: …pero no hay puente (¿X11?), se queda adentro");
685 }
686 // Una sola vez por gesto: el cursor puede entrar y salir
687 // varias veces y `start_drag` dos veces es un error de
688 // protocolo, o sea la conexión caída.
689 state.arrastre_entregado = true;
690 // Y el fantasma se va con él: lo que se ve de acá en más
691 // lo pinta el compositor, y dejar el nuestro pegado al
692 // cursor sería pintarlo dos veces.
693 if let Some(d) = state.drag.as_mut() {
694 d.carga = None;
695 }
696 if state.cursor_arrastre.take().is_some() {
697 state.window.set_cursor(to_winit_cursor(None));
698 }
699 need_redraw = true;
700 }
701 }
702 // El resorte, ya afuera del préstamo del cache: abrir el destino
703 // cambia el modelo, así que el cuadro siguiente lo refleja.
704 if let Some(msg) = resorte {
705 let model = state.model.take().expect("model");
706 state.model = Some(super::vigia::aplicar_update::<A>(model, msg, &self.handle));
707 need_redraw = true;
708 }
709 if need_redraw {
710 state.window.request_redraw();
711 }
712 } else {
713 // Sin drag: chequear hover. Si hay overlay, el
714 // hover-test va contra él; el árbol principal queda
715 // congelado mientras el overlay esté arriba.
716 //
717 // Además del repintado (para el `hover_fill`), en la
718 // transición de hover dispatcheamos dos Msgs: el
719 // `on_pointer_leave` del nodo que abandonamos y el
720 // `on_pointer_enter` del recién hovereado. Es lo que
721 // permite, p.ej., cambiar de menú con el mouse, abrir un
722 // submenú al pasar por encima, o cerrar un tooltip/drawer
723 // al salir. Extraemos los Msg en un scope para soltar el
724 // borrow del cache antes de mutar el modelo.
725 let mut enter_msg: Option<A::Msg> = None;
726 let mut leave_msg: Option<A::Msg> = None;
727 let mut hovered_changed = false;
728 let mut new_hovered: Option<usize> = state.hovered;
729 // Forma del cursor del nodo recién hovereado (sólo se
730 // resuelve en la transición; ver `to_winit_cursor`).
731 let mut new_cursor: Option<Cursor> = None;
732 if let Some(cache) = state.last_render.as_ref() {
733 let (mounted, computed) = match cache.overlay.as_ref() {
734 Some(ov) => (&ov.mounted, &ov.computed),
735 None => (&cache.mounted, &cache.computed),
736 };
737 let new_hover = hit_test_hover(
738 mounted,
739 computed,
740 position.x as f32,
741 position.y as f32,
742 );
743 // Comparamos contra el hover PERSISTENTE (state.hovered),
744 // no contra el del cache: el render recomputa el del cache
745 // al cursor actual cada cuadro, así que en una app que
746 // re-renderiza sin parar la transición de hover se perdería
747 // (y el hover-switch de menús no andaría). Ver `hovered`.
748 if new_hover != state.hovered {
749 hovered_changed = true;
750 // El nodo que dejamos (índice persistente previo)
751 // dispara su leave; el nuevo, su enter. Ambos se
752 // resuelven contra el árbol montado actual, igual
753 // que el resto del hit-test de hover.
754 leave_msg = state
755 .hovered
756 .and_then(|i| mounted.nodes.get(i))
757 .and_then(|n| n.on_pointer_leave.clone());
758 enter_msg = new_hover
759 .and_then(|i| mounted.nodes.get(i))
760 .and_then(|n| n.on_pointer_enter.clone());
761 new_cursor = hit_test_cursor(
762 mounted,
763 computed,
764 position.x as f32,
765 position.y as f32,
766 );
767 }
768 new_hovered = new_hover;
769 }
770 state.hovered = new_hovered;
771 if hovered_changed {
772 state.window.set_cursor(to_winit_cursor(new_cursor));
773 // Invalidar el cache de paint retenido: el `hover_fill`
774 // del nodo viejo está pintado en `state.scene` y el del
775 // nuevo no — un cache hit re-presentaría el frame stale.
776 state.invalidar_render();
777 state.window.request_redraw();
778 }
779 // Despachamos leave antes que enter: el orden natural de
780 // la transición es "salgo de A, luego entro en B".
781 for msg in [leave_msg, enter_msg].into_iter().flatten() {
782 let model = state.model.take().expect("model");
783 state.model = Some(super::vigia::aplicar_update::<A>(model, msg, &self.handle));
784 // El estado cambió → invalidamos el cache para
785 // re-render (p.ej. el submenú que se abre/cierra).
786 state.invalidar_render();
787 }
788 let _ = prev_cursor;
789 }
790 // Movimiento posicional: dispatch `on_pointer_move_at` en CADA move
791 // mientras el cursor está sobre el nodo (no sólo al entrar, como
792 // `on_pointer_enter`). Base del thumbnail/drawer que sigue al
793 // cursor. Corre CON o SIN drag activo: un drag colgado (release
794 // perdido fuera de la ventana) no debe comerse la señal de
795 // actividad del mouse (p. ej. el reveal del OSC de media).
796 // Extraemos el handler (Arc) + rect en un scope para soltar el
797 // borrow del cache antes de mutar el modelo.
798 let move_call: Option<(ClickAtFn<A::Msg>, f32, f32, f32, f32)> =
799 state.last_render.as_ref().and_then(|cache| {
800 let (mounted, computed) = match cache.overlay.as_ref() {
801 Some(ov) => (&ov.mounted, &ov.computed),
802 None => (&cache.mounted, &cache.computed),
803 };
804 let px = position.x as f32;
805 let py = position.y as f32;
806 let i = hit_test_pointer_move(mounted, computed, px, py)?;
807 let node = &mounted.nodes[i];
808 let h = node.on_pointer_move_at.clone()?;
809 let r = computed.get(node.id)?;
810 Some((h, px - r.x, py - r.y, r.w, r.h))
811 });
812 if let Some((h, lx, ly, w, hh)) = move_call {
813 if let Some(msg) = h(lx, ly, w, hh) {
814 let model = state.model.take().expect("model");
815 state.model = Some(super::vigia::aplicar_update::<A>(model, msg, &self.handle));
816 state.invalidar_render();
817 state.window.request_redraw();
818 }
819 }
820 }
821
822 // ── IME ──────────────────────────────────────────────────────────────────
823
824 /// Un archivo soltado sobre la ventana. Lo llaman **dos** caminos: la rama
825 /// `WindowEvent::DroppedFile` (X11, Windows, macOS) y el puente
826 /// `wl_data_device` de Wayland vía `UserEvent::FileDrop` — winit no emite
827 /// ese evento en su backend Wayland. Que sea un solo método es lo que hace
828 /// que las apps no tengan que saber por cuál entró.
829 pub(super) fn aplicar_file_drop(&mut self, path: std::path::PathBuf) {
830 let Some(state) = self.state.as_mut() else { return };
831 if let Some(msg) = A::on_file_drop(state.model.as_ref().expect("model"), path) {
832 let model = state.model.take().expect("model");
833 state.model = Some(super::vigia::aplicar_update::<A>(model, msg, &self.handle));
834 state.invalidar_render();
835 state.window.request_redraw();
836 }
837 }
838
839 fn handle_ime(&mut self, ime: llimphi_hal::winit::event::Ime) {
840 use llimphi_hal::winit::event::Ime;
841 let Some(state) = self.state.as_mut() else { return };
842 let ev = match ime {
843 Ime::Enabled => ImeEvent::Enabled,
844 Ime::Preedit(text, cursor) => ImeEvent::Preedit { text, cursor },
845 Ime::Commit(text) => ImeEvent::Commit(text),
846 Ime::Disabled => ImeEvent::Disabled,
847 };
848 if let Some(msg) = A::on_ime(state.model.as_ref().expect("model"), &ev) {
849 let model = state.model.take().expect("model");
850 state.model = Some(super::vigia::aplicar_update::<A>(model, msg, &self.handle));
851 state.invalidar_render();
852 state.window.request_redraw();
853 }
854 }
855
856 // ── Teclado ───────────────────────────────────────────────────────────────
857
858 fn handle_keyboard_input(
859 &mut self,
860 event: llimphi_hal::winit::event::KeyEvent,
861 ) {
862 depurar_tecla(&event, self.state.as_ref().map(|s| s.modifiers));
863 let Some(state) = self.state.as_mut() else { return };
864 // ── Auto-repeat sintético: seguimiento de la tecla sostenida ──
865 // winit no siempre genera los eventos repetidos en Wayland; el runtime los
866 // sintetiza en `about_to_wait`. Aquí sólo se ARMA/DESARMA el seguimiento.
867 {
868 if event.repeat {
869 // winit YA repite: apagamos la síntesis propia (no duplicar).
870 state.winit_repeats = true;
871 state.held_key = None;
872 } else if event.state == ElementState::Released {
873 if state
874 .held_key
875 .as_ref()
876 .is_some_and(|h| h.event.logical_key == event.logical_key)
877 {
878 state.held_key = None;
879 }
880 } else if !state.winit_repeats {
881 let repetible = tecla_repetible(
882 &event.logical_key,
883 event.text.is_some(),
884 state.modifiers,
885 );
886 state.held_key = repetible.then(|| crate::HeldKey {
887 event: event.clone(),
888 next: crate::Instant::now() + crate::KEY_REPEAT_DELAY,
889 confirmada: false,
890 });
891 }
892 }
893 // Tab / Shift+Tab mueven el foco entre nodos `focusable`,
894 // que administra el runtime. Sólo intercepta si hay
895 // enfocables y en Pressed; si no, cae al `on_key` normal
896 // (apps que usan Tab para otra cosa lo siguen recibiendo).
897 let is_tab = event.state == ElementState::Pressed
898 && matches!(
899 crate::tecla_de_winit(&event.logical_key),
900 Key::Named(NamedKey::Tab)
901 );
902 if is_tab {
903 let order = state
904 .last_render
905 .as_ref()
906 .map(|c| focus_order(&c.mounted, &c.computed))
907 .unwrap_or_default();
908 if !order.is_empty() {
909 let next = next_focus(&order, state.focused, state.modifiers.shift);
910 state.focused = next;
911 if let Some(msg) =
912 A::on_focus(state.model.as_ref().expect("model"), next)
913 {
914 let model = state.model.take().expect("model");
915 state.model = Some(super::vigia::aplicar_update::<A>(model, msg, &self.handle));
916 }
917 state.invalidar_render();
918 state.window.request_redraw();
919 return;
920 }
921 }
922 // Ctrl/Cmd+C copia la selección de texto activa (fuera del
923 // editor). Consume el evento sólo si había algo seleccionado;
924 // si no, cae al `on_key` normal (apps que usan Ctrl+C para lo
925 // suyo lo siguen recibiendo).
926 if event.state == ElementState::Pressed
927 && (state.modifiers.ctrl || state.modifiers.meta)
928 && key_is_char(&crate::tecla_de_winit(&event.logical_key), 'c')
929 {
930 if let Some(tsel) = state.selection {
931 let text = state
932 .last_render
933 .as_ref()
934 .and_then(|c| selectable_by_key(c, tsel.key))
935 .and_then(|(spec, _)| {
936 spec.content.get(tsel.sel.text_range()).map(str::to_string)
937 });
938 if let Some(t) = text.filter(|t| !t.is_empty()) {
939 copy_to_clipboard(&t);
940 return;
941 }
942 }
943 }
944 let ev = KeyEvent {
945 // La FRONTERA: de la tecla de winit al modelo del compositor (W4.7).
946 key: crate::tecla_de_winit(&event.logical_key),
947 state: match event.state {
948 ElementState::Pressed => KeyState::Pressed,
949 ElementState::Released => KeyState::Released,
950 },
951 text: event.text.as_ref().map(|t| t.to_string()),
952 modifiers: state.modifiers,
953 repeat: event.repeat,
954 };
955 if let Some(msg) = A::on_key(state.model.as_ref().expect("model"), &ev) {
956 let model = state.model.take().expect("model");
957 state.model = Some(super::vigia::aplicar_update::<A>(model, msg, &self.handle));
958 state.invalidar_render();
959 state.window.request_redraw();
960 }
961 }
962
963 // ── Rueda del mouse ───────────────────────────────────────────────────────
964
965 fn handle_mouse_wheel(
966 &mut self,
967 delta: MouseScrollDelta,
968 ) {
969 let handle = self.handle.clone();
970 let Some(state) = self.state.as_mut() else { return };
971 // Convención winit: LineDelta es líneas; PixelDelta es
972 // píxeles físicos (touchpads). En CSS y aquí, positivo
973 // (rueda hacia adelante / dos dedos arriba) = scroll
974 // hacia arriba, así que invertimos `y` para que el
975 // contenido "siga al dedo" en y positivo. `x` queda
976 // como llega.
977 //
978 // La variante ADEMÁS dice de qué clase de dispositivo viene, y es el
979 // único lugar del runner de ventana donde ese dato existe: `LineDelta`
980 // sale de una rueda física (clicks), `PixelDelta` de un touchpad o una
981 // rueda de precisión. Con eso el sentido se decide por clase — ver
982 // `llimphi_entrada::PoliticaRueda`.
983 let (wd, fuente) = match delta {
984 MouseScrollDelta::LineDelta(x, y) => (WheelDelta { x, y: -y }, FuenteRueda::Raton),
985 MouseScrollDelta::PixelDelta(p) => (
986 WheelDelta {
987 x: (p.x as f32) / 20.0,
988 y: -(p.y as f32) / 20.0,
989 },
990 FuenteRueda::Touchpad,
991 ),
992 };
993 let wd = crate::politica_rueda().aplicar(wd, fuente);
994 // Caché de hit-test invalidada (un update recién corrió y el redraw
995 // aún no la reconstruyó): **diferir** — se acumula el delta y se
996 // procesa al final del próximo redraw, contra el árbol fresco. Sin
997 // esto, una app con ticks frecuentes (media a 30/s) invalida la caché
998 // antes de casi cada rueda y el scroll por wheel queda muerto
999 // (chain=0 sistemático, cazado con LLIMPHI_DEBUG_WHEEL en vivo).
1000 if state.last_render.is_none() {
1001 let acc = state.pending_wheel.get_or_insert(WheelDelta { x: 0.0, y: 0.0 });
1002 acc.x += wd.x;
1003 acc.y += wd.y;
1004 state.window.request_redraw();
1005 return;
1006 }
1007 Self::process_wheel(state, &handle, wd);
1008 }
1009
1010 /// Procesa una rueda contra el árbol de hit-test vigente. Asociada (sin
1011 /// `self`) para que también el redraw pueda **replay-ear** la rueda
1012 /// diferida (ver `pending_wheel`) con el árbol recién armado.
1013 pub(super) fn process_wheel(
1014 state: &mut crate::RuntimeState<A>,
1015 handle: &crate::Handle<A::Msg>,
1016 wd: WheelDelta,
1017 ) {
1018 let cursor = (state.cursor.x as f32, state.cursor.y as f32);
1019 // Ctrl+rueda = **pinch-to-zoom sintético** (camino universal de
1020 // desktop: Wayland/Windows no emiten el gesto de pinch del
1021 // trackpad). Si hay un nodo `on_scale` bajo el cursor, el zoom
1022 // lo consume ANTES que el scroll/`on_wheel`. Factor
1023 // multiplicativo incremental `1.1^(-dy)`: rueda hacia arriba
1024 // (`dy<0`) agranda, hacia abajo achica. El punto focal es el
1025 // cursor (en coords locales al nodo), para zoomear "hacia el
1026 // cursor". Sin nodo zoomeable, Ctrl+rueda cae al `on_wheel`
1027 // global como siempre (browsers, etc. lo siguen recibiendo).
1028 if state.modifiers.ctrl {
1029 let scale_hit = state
1030 .last_render
1031 .as_ref()
1032 .and_then(|cache| scale_hit_from_cache(cache, cursor.0, cursor.1));
1033 if let Some((h, fx, fy)) = scale_hit {
1034 let factor = 1.1_f32.powf(-wd.y);
1035 if let Some(msg) = h(GesturePhase::Update, factor, fx, fy) {
1036 let model = state.model.take().expect("model");
1037 state.model = Some(super::vigia::aplicar_update::<A>(model, msg, handle));
1038 state.invalidar_render();
1039 state.window.request_redraw();
1040 }
1041 return;
1042 }
1043 }
1044 // Primero: ¿hay nodos con `on_scroll` bajo el cursor? Se
1045 // arma la **cadena** (front→back) y se invoca cada handler
1046 // en orden; el primero que devuelva `Some` consume el
1047 // evento. Un scroll interno que llegó al extremo de su eje
1048 // puede devolver `None` para dejar el sobrante al ancestro
1049 // scrollable más cercano (scroll anidado). Si todos
1050 // devuelven `None`, cae al `on_wheel` global.
1051 // El overlay tiene prioridad, igual que con clicks. Se
1052 // extraen los handlers en un scope para soltar el borrow
1053 // del cache antes de mutar el modelo.
1054 let mut msgs: Vec<A::Msg> = if let Some(cache) = state.last_render.as_ref() {
1055 let (m, c) = match cache.overlay.as_ref() {
1056 Some(ov) => (&ov.mounted, &ov.computed),
1057 None => (&cache.mounted, &cache.computed),
1058 };
1059 let chain = hit_test_scroll_chain(m, c, cursor.0, cursor.1);
1060 // Reparto parcial: el hijo consume hasta su tope y el sobrante
1061 // sigue al ancestro — puede producir VARIOS Msg por evento.
1062 consume_scroll_chain(m, &chain, wd.x, wd.y)
1063 } else {
1064 Vec::new()
1065 };
1066 if msgs.is_empty() {
1067 if let Some(m) = A::on_wheel(
1068 state.model.as_ref().expect("model"),
1069 wd,
1070 cursor,
1071 state.modifiers,
1072 ) {
1073 msgs.push(m);
1074 }
1075 }
1076 if !msgs.is_empty() {
1077 let mut model = state.model.take().expect("model");
1078 for msg in msgs {
1079 model = super::vigia::aplicar_update::<A>(model, msg, handle);
1080 }
1081 state.model = Some(model);
1082 state.invalidar_render();
1083 state.window.request_redraw();
1084 }
1085 }
1086
1087 // ── Gestos trackpad ───────────────────────────────────────────────────────
1088
1089 fn handle_pinch_gesture(
1090 &mut self,
1091 delta: f64,
1092 phase: llimphi_hal::winit::event::TouchPhase,
1093 ) {
1094 use llimphi_hal::winit::event::TouchPhase;
1095 let Some(state) = self.state.as_mut() else { return };
1096 // Pinch del trackpad (winit lo emite **sólo en macOS/iOS**; en
1097 // Wayland/Windows el zoom va por Ctrl+rueda, arriba). `delta` es
1098 // el cambio de escala incremental (p. ej. 0.01 = +1%); lo
1099 // mapeamos al mismo `on_scale` que Ctrl+rueda, con factor
1100 // multiplicativo `1.0 + delta`. La fase de winit se traduce a
1101 // la de gesto (Begin/Update/End) para que el handler pueda, p.
1102 // ej., abrir/cerrar un estado de zoom en vivo.
1103 let cursor = (state.cursor.x as f32, state.cursor.y as f32);
1104 let gphase = match phase {
1105 TouchPhase::Started => GesturePhase::Begin,
1106 TouchPhase::Moved => GesturePhase::Update,
1107 TouchPhase::Ended | TouchPhase::Cancelled => GesturePhase::End,
1108 };
1109 // `delta` puede venir NaN según la doc de winit; en ese caso
1110 // (o fuera de Update) el factor neutro es 1.0.
1111 let factor = if gphase == GesturePhase::Update && delta.is_finite() {
1112 (1.0 + delta) as f32
1113 } else {
1114 1.0
1115 };
1116 let scale_hit = state
1117 .last_render
1118 .as_ref()
1119 .and_then(|cache| scale_hit_from_cache(cache, cursor.0, cursor.1));
1120 if let Some((h, fx, fy)) = scale_hit {
1121 if let Some(msg) = h(gphase, factor, fx, fy) {
1122 let model = state.model.take().expect("model");
1123 state.model = Some(super::vigia::aplicar_update::<A>(model, msg, &self.handle));
1124 state.invalidar_render();
1125 state.window.request_redraw();
1126 }
1127 }
1128 }
1129
1130 fn handle_rotation_gesture(
1131 &mut self,
1132 delta: f32,
1133 phase: llimphi_hal::winit::event::TouchPhase,
1134 ) {
1135 use llimphi_hal::winit::event::TouchPhase;
1136 let Some(state) = self.state.as_mut() else { return };
1137 // Rotación de dos dedos en el trackpad (winit la emite **sólo
1138 // en macOS**). `delta` viene en **grados**; lo convertimos a
1139 // radianes para el handler (positivo = horario). La fase de
1140 // winit se traduce a la de gesto (Begin/Update/End). No hay
1141 // camino universal por teclado/rueda como sí lo tiene el zoom.
1142 let cursor = (state.cursor.x as f32, state.cursor.y as f32);
1143 let gphase = match phase {
1144 TouchPhase::Started => GesturePhase::Begin,
1145 TouchPhase::Moved => GesturePhase::Update,
1146 TouchPhase::Ended | TouchPhase::Cancelled => GesturePhase::End,
1147 };
1148 let delta_rad = if gphase == GesturePhase::Update && delta.is_finite() {
1149 delta.to_radians()
1150 } else {
1151 0.0
1152 };
1153 let rotate_hit = state
1154 .last_render
1155 .as_ref()
1156 .and_then(|cache| rotate_hit_from_cache(cache, cursor.0, cursor.1));
1157 if let Some((h, fx, fy)) = rotate_hit {
1158 if let Some(msg) = h(gphase, delta_rad, fx, fy) {
1159 let model = state.model.take().expect("model");
1160 state.model = Some(super::vigia::aplicar_update::<A>(model, msg, &self.handle));
1161 state.invalidar_render();
1162 state.window.request_redraw();
1163 }
1164 }
1165 }
1166
1167 // ── Click izquierdo ───────────────────────────────────────────────────────
1168
1169 fn handle_left_press(&mut self) {
1170 let Some(state) = self.state.as_mut() else { return };
1171 // Cache frío (típico durante reproducción de video: el tick anula
1172 // `last_render` en cada frame y el redraw throttled lo deja `None` al
1173 // momento del click): lo reconstruimos desde el modelo vigente ANTES de
1174 // resolver el click. Sin esto, todos los hit-tests de abajo se saltaban
1175 // y el click era un no-op silencioso (el engranaje de config no abría;
1176 // F2 sí, porque el teclado no depende de `last_render`). Ver
1177 // `build_hit_cache`.
1178 let was_cold = state.last_render.is_none();
1179 if was_cold {
1180 build_hit_cache::<A>(state);
1181 }
1182 let cursor = state.cursor;
1183 // ── DIAG (opt-in `MEDIA_CLICK_DIAG=1`): por qué un click no llega al
1184 // nodo que se ve arriba. Reporta si el cache llegó frío (reconstruido),
1185 // el nodo que GANA el hit-test (rect + handlers) y TODAS las capas
1186 // `over` bajo el cursor — si el gear (capa `over`) está bajo el cursor
1187 // pero no gana, es la asimetría paint/hit. ─
1188 if click_diag_enabled() {
1189 let (cx, cy) = (cursor.x as f32, cursor.y as f32);
1190 llimphi_hal::avisar!(
1191 "[click-diag] handle_left_press cursor=({cx:.0},{cy:.0}) cache={}",
1192 if was_cold { "frío→reconstruido" } else { "vigente" },
1193 );
1194 if let Some(cache) = state.last_render.as_ref() {
1195 let (m, c) = match cache.overlay.as_ref() {
1196 Some(ov) => (&ov.mounted, &ov.computed),
1197 None => (&cache.mounted, &cache.computed),
1198 };
1199 let win = hit_test_click(m, c, cx, cy);
1200 match win {
1201 Some(i) => {
1202 let n = &m.nodes[i];
1203 let r = c.get(n.id);
1204 llimphi_hal::avisar!(
1205 "[click-diag] cursor=({cx:.0},{cy:.0}) GANA idx={i} rect={r:?} \
1206 on_click={} on_click_screen={} over_layer={}",
1207 n.on_click.is_some(),
1208 n.on_click_screen.is_some(),
1209 n.over_layer,
1210 );
1211 }
1212 None => llimphi_hal::avisar!("[click-diag] cursor=({cx:.0},{cy:.0}) sin hit"),
1213 }
1214 // Capas `over` cuyo rect contiene el cursor (candidatas visibles
1215 // "arriba" que el hit-test plano puede estar ignorando).
1216 for (i, n) in m.nodes.iter().enumerate() {
1217 if !n.over_layer {
1218 continue;
1219 }
1220 if let Some(r) = c.get(n.id) {
1221 let inside = cx >= r.x && cx < r.x + r.w && cy >= r.y && cy < r.y + r.h;
1222 if inside {
1223 llimphi_hal::avisar!(
1224 "[click-diag] over idx={i} subtree_end={} rect=({:.0},{:.0},{:.0},{:.0}) \
1225 → contiene el cursor",
1226 n.subtree_end, r.x, r.y, r.w, r.h,
1227 );
1228 }
1229 }
1230 }
1231 }
1232 }
1233 // ── Select/dropdown: `on_click_screen` (rect absoluto del nodo) ─────
1234 // Un nodo con `on_click_screen` (el disparador de un select) abre su
1235 // popup. El handler recibe el RECT ABSOLUTO del nodo `(x, y, w, h)` en
1236 // coords de ventana — así el menú puede anclarse al borde inferior del
1237 // disparador y copiar su ancho. Aditivo y con prioridad de overlay; si
1238 // dispara, cortamos (el disparador no lleva on_click).
1239 let screen_hit = state.last_render.as_ref().and_then(|cache| {
1240 let (m, c) = match cache.overlay.as_ref() {
1241 Some(ov) => (&ov.mounted, &ov.computed),
1242 None => (&cache.mounted, &cache.computed),
1243 };
1244 hit_test_click(m, c, cursor.x as f32, cursor.y as f32).and_then(|i| {
1245 let node = &m.nodes[i];
1246 let handler = node.on_click_screen.clone()?;
1247 // El rect ABSOLUTO del nodo. Si el lookup falla o sale degenerado
1248 // (w≈0), caemos al cursor (posición conocida-buena) en vez de a
1249 // (0,0) —que anclaba el popup a la esquina de la ventana—.
1250 let r = match c.get(node.id) {
1251 Some(r) if r.w > 0.5 => (r.x, r.y, r.w, r.h),
1252 _ => (cursor.x as f32, cursor.y as f32, 0.0, 0.0),
1253 };
1254 Some((handler, r))
1255 })
1256 });
1257 if let Some((handler, (rx, ry, rw, rh))) = screen_hit {
1258 // **Tampoco se dispara en el press.** Es el disparador de un
1259 // desplegable (un select, el botón de una barra que abre menú), y
1260 // abrirlo en el mousedown tiene el mismo problema que el menú
1261 // contextual: el popup nace bajo el cursor y se come el release,
1262 // así que soltar encima elige el primer ítem sin querer. El `Msg`
1263 // se resuelve **acá** —con el rect del disparador, que es lo que
1264 // ancla el popup— y se dispara al soltar.
1265 if let Some(msg) = handler(rx, ry, rw, rh) {
1266 state.pending_click = Some(PendingClick {
1267 msg,
1268 origin: cursor,
1269 retener_hasta: None,
1270 soltado: false,
1271 });
1272 }
1273 return;
1274 }
1275 // Click-to-focus: si el click cae sobre un nodo enfocable,
1276 // el runtime le da el foco ANTES de procesar la acción de
1277 // click. Extraemos el id en un scope (suelta el borrow del
1278 // cache) y recién después mutamos el foco/modelo.
1279 let focus_hit = state
1280 .last_render
1281 .as_ref()
1282 .and_then(|cache| {
1283 let (m, c) = match cache.overlay.as_ref() {
1284 Some(ov) => (&ov.mounted, &ov.computed),
1285 None => (&cache.mounted, &cache.computed),
1286 };
1287 hit_test_focusable(m, c, cursor.x as f32, cursor.y as f32)
1288 });
1289 if focus_hit.is_some() && focus_hit != state.focused {
1290 state.focused = focus_hit;
1291 if let Some(msg) =
1292 A::on_focus(state.model.as_ref().expect("model"), focus_hit)
1293 {
1294 let model = state.model.take().expect("model");
1295 state.model = Some(super::vigia::aplicar_update::<A>(model, msg, &self.handle));
1296 }
1297 state.invalidar_render();
1298 }
1299 // ── Selección de texto fuera del editor (aditiva) ──────────
1300 // Si el press cae sobre un nodo `View::selectable`, arranca una
1301 // selección (anchor = punto). Un press en cualquier otro lado
1302 // limpia la selección activa. Aditivo: un label seleccionable
1303 // sin `on_click`/drag no choca con el camino de abajo.
1304 let sel_hit = state.last_render.as_ref().and_then(|c| {
1305 selectable_hit_from_cache(c, cursor.x as f32, cursor.y as f32)
1306 });
1307 if let Some((key, spec, (rx, ry, rw, _rh))) = sel_hit {
1308 let layout = build_selectable_layout(&mut state.typesetter, &spec, rw);
1309 let lx = cursor.x as f32 - rx;
1310 let ly = cursor.y as f32 - ry;
1311 let sel = llimphi_text::parley::Selection::from_point(&layout, lx, ly);
1312 state.selection = Some(TextSelection { key, sel, dragging: true });
1313 state.invalidar_render();
1314 state.window.request_redraw();
1315 } else if state.selection.is_some() {
1316 state.selection = None;
1317 state.invalidar_render();
1318 state.window.request_redraw();
1319 }
1320 // ── Arena de gestos (aditiva): doble-tap + long-press ──────
1321 // Se resuelven con su propio hit-test y NO tocan el camino de
1322 // click/drag de abajo (intacto). El árbitro real es el tiempo:
1323 // doble-tap = dos presses cercanos dentro de una ventana;
1324 // long-press = un press que sobrevive ~500 ms quieto (lo vence
1325 // `about_to_wait`, lo cancela el movimiento/release).
1326 let now = crate::Instant::now();
1327 // Doble-tap: si este press cae sobre un nodo con doble-tap y
1328 // hubo un tap previo cercano y a tiempo, dispará. Si no, este
1329 // press queda registrado como "primer tap".
1330 //
1331 // Con `double_tap_exclusive` el gesto además **arbitra** contra el click
1332 // del mismo nodo: el primer tap deja el click retenido (`retener_hasta`)
1333 // y el segundo lo descarta, así un doble-click dispara sólo el doble.
1334 // Lo pide el diente de un rag-sidebar, que despliega con un click y fija
1335 // el espacio con dos.
1336 let mut retener_click: Option<crate::Instant> = None;
1337 let mut suprimir_click = false;
1338 if let Some((resolved, exclusivo)) =
1339 state.last_render.as_ref().and_then(|c| {
1340 double_tap_hit_from_cache(c, cursor.x as f32, cursor.y as f32)
1341 })
1342 {
1343 let qualifies = double_tap_qualifies(state.last_tap, now, cursor);
1344 if qualifies {
1345 state.last_tap = None; // consumido; un 3er tap no re-dispara
1346 if exclusivo {
1347 // El click del primer tap sigue retenido: muere acá, y el de
1348 // este press no llega a armarse.
1349 state.pending_click = None;
1350 suprimir_click = true;
1351 }
1352 if let Some(msg) = resolved.invoke() {
1353 let model = state.model.take().expect("model");
1354 state.model = Some(super::vigia::aplicar_update::<A>(model, msg, &self.handle));
1355 state.invalidar_render();
1356 state.window.request_redraw();
1357 }
1358 } else {
1359 state.last_tap = Some((now, cursor));
1360 if exclusivo {
1361 retener_click = Some(now + DOUBLE_TAP_WINDOW);
1362 }
1363 }
1364 }
1365 // Long-press: si el press cae sobre un nodo con long-press,
1366 // armalo. `about_to_wait` lo dispara al vencer; CursorMoved lo
1367 // cancela si el cursor se aleja; el release lo cancela siempre.
1368 if let Some(handler) = state.last_render.as_ref().and_then(|c| {
1369 long_press_hit_from_cache(c, cursor.x as f32, cursor.y as f32)
1370 }) {
1371 state.pending_long_press = Some(PendingLongPress {
1372 deadline: now + LONG_PRESS_DELAY,
1373 origin: cursor,
1374 handler,
1375 });
1376 }
1377 // Ripple/InkWell: si el press cae sobre un nodo con ripple,
1378 // dispará la salpicadura desde el punto. Aditivo — no toca el
1379 // camino click/drag de abajo; un botón con `on_click` +
1380 // `.ripple(...)` recibe ambos.
1381 if let Some((rp, lx, ly)) = state
1382 .last_render
1383 .as_ref()
1384 .and_then(|c| ripple_hit_from_cache(c, cursor.x as f32, cursor.y as f32))
1385 {
1386 state
1387 .ripple_registry
1388 .trigger(rp.key, lx, ly, rp.color, rp.duration, now);
1389 // El ripple se pinta sobre la scene; invalidamos el cache
1390 // de paint para que el próximo redraw lo dibuje (sin esto,
1391 // un cache hit re-presentaría el frame sin la salpicadura).
1392 state.invalidar_render();
1393 state.window.request_redraw();
1394 }
1395 // Con overlay activo, los clicks van EXCLUSIVAMENTE a él.
1396 // Si el cursor cae sobre un nodo del overlay sin handler,
1397 // el click se descarta — la convención de "scrim que
1398 // dismissa" pide que la app meta su propio fondo
1399 // clicable con `on_click = DismissOverlay`.
1400 let idx_and_action: Option<HitInfo<A::Msg>> = if let Some(cache) =
1401 state.last_render.as_ref()
1402 {
1403 if let Some(ov) = cache.overlay.as_ref() {
1404 lookup_click_hit(&ov.mounted, &ov.computed, cursor.x as f32, cursor.y as f32)
1405 } else {
1406 lookup_click_hit(&cache.mounted, &cache.computed, cursor.x as f32, cursor.y as f32)
1407 }
1408 } else {
1409 let (w, h) = state.surface.size();
1410 // Mismo resolve de LayoutBuilder que el redraw, para que el
1411 // hit-test del fallback vea los hijos producidos por builders.
1412 let view = resolve_layout_builders::<A>(
1413 state.model.as_ref().expect("model"),
1414 (w as f32, h as f32),
1415 &mut state.typesetter,
1416 );
1417 let model_ref = state.model.as_ref().expect("model");
1418 let overlay_view = super::vigia::aplicar_view_overlay::<A>(model_ref);
1419 let mut layout = LayoutTree::new();
1420 let mounted: Mounted<A::Msg> = mount(&mut layout, view);
1421 let ts = &mut state.typesetter;
1422 let computed = {
1423 let tmap = &mounted.text_measures;
1424 layout
1425 .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| {
1426 match tmap.get(&nid) {
1427 Some(tm) => measure_text_node(ts, tm, known, avail),
1428 None => llimphi_layout::taffy::Size::ZERO,
1429 }
1430 })
1431 .expect("layout")
1432 };
1433 if let Some(ov) = overlay_view {
1434 let mut olay = LayoutTree::new();
1435 let omounted: Mounted<A::Msg> = mount(&mut olay, ov);
1436 let ocomp = {
1437 let tmap = &omounted.text_measures;
1438 olay
1439 .compute_with_measure(omounted.root, (w as f32, h as f32), |nid, known, avail| {
1440 match tmap.get(&nid) {
1441 Some(tm) => measure_text_node(ts, tm, known, avail),
1442 None => llimphi_layout::taffy::Size::ZERO,
1443 }
1444 })
1445 .expect("layout overlay")
1446 };
1447 lookup_click_hit(&omounted, &ocomp, cursor.x as f32, cursor.y as f32)
1448 } else {
1449 lookup_click_hit(&mounted, &computed, cursor.x as f32, cursor.y as f32)
1450 }
1451 };
1452 // La carga del arrastre (lo que se va a ver viajando). Se resuelve acá,
1453 // una vez por gesto, contra el mismo cache que el resto del hit.
1454 let carga = state
1455 .last_render
1456 .as_ref()
1457 .and_then(|c| carga_hit(c, cursor.x as f32, cursor.y as f32));
1458 // Prioridad: drag_velocity > drag_at + on_click_at > drag >
1459 // on_click_at > on_click. `drag_velocity` gana exclusivo
1460 // (no convive con on_click_at) — el caller que lo elige
1461 // suele querer un fling físico puro (lienzos pan-and-fling).
1462 if let Some((_, _, Some(handler_v), payload, _, _, _)) = &idx_and_action {
1463 state.drag = Some(DragState {
1464 handler: DragHandlerKind::Velocity(handler_v.clone()),
1465 last_cursor: cursor,
1466 payload: *payload,
1467 carga: carga.clone(),
1468 sobre_destino: false,
1469 resorte: None,
1470 samples: std::collections::VecDeque::with_capacity(VELOCITY_MAX_SAMPLES),
1471 });
1472 state.window.request_redraw();
1473 } else if let Some((_, Some(handler_at), _, payload, _, click_at, Some((ox, oy, rw, rh)))) =
1474 &idx_and_action
1475 {
1476 // drag_at + on_click_at COEXISTEN y arrancan un drag rastreado con
1477 // la posición inicial. CUÁNDO se dispara el click depende de si el
1478 // drag lleva **payload**, y la distinción no es cosmética:
1479 //
1480 // - **Sin payload** (canvas: lienzo de tullpu, text-input,
1481 // text-editor…) el drag mueve/selecciona *dentro* del mismo nodo.
1482 // Ahí select-on-press es lo correcto y es el diseño original.
1483 //
1484 // - **Con payload** el drag es una TRANSFERENCIA: el nodo se va a
1485 // soltar sobre otro (reordenar dientes, pestañas, items de lista,
1486 // paneles teselados). Disparar el click en el press ahí es un
1487 // defecto doble: (1) el botón "salta" en el mousedown, que se
1488 // siente mal, y (2) el update que provoca hace `invalidar_render`
1489 // y reconstruye el árbol *con el drag ya en curso*, así que el
1490 // `hit_test_drop` del release resuelve contra un cache que ya no
1491 // es el del origen y el reorden no llega nunca. El drag se veía
1492 // "muerto" sin estarlo.
1493 //
1494 // Con payload, entonces, el click se ARMA igual que un `on_click`
1495 // plano: lo dispara el release si el cursor no se alejó más que
1496 // `CLICK_MOVE_CANCEL`. Un arrastre de verdad supera el umbral, el
1497 // pending se cancela solo (ver `cursor_moved`) y queda únicamente el
1498 // drop. Un toque sin mover sigue activando, al soltar.
1499 let lx0 = cursor.x as f32 - ox;
1500 let ly0 = cursor.y as f32 - oy;
1501 if let Some(click_at_h) = click_at {
1502 if let Some(msg) = click_at_h(lx0, ly0, *rw, *rh).filter(|_| !suprimir_click) {
1503 if payload.is_some() || retener_click.is_some() {
1504 state.pending_click = Some(PendingClick {
1505 msg,
1506 origin: cursor,
1507 retener_hasta: retener_click,
1508 soltado: false,
1509 });
1510 } else {
1511 let model = state.model.take().expect("model");
1512 state.model =
1513 Some(super::vigia::aplicar_update::<A>(model, msg, &self.handle));
1514 state.invalidar_render();
1515 }
1516 }
1517 }
1518 state.drag = Some(DragState {
1519 handler: DragHandlerKind::DeltaAt(handler_at.clone(), lx0, ly0),
1520 last_cursor: cursor,
1521 payload: *payload,
1522 carga: carga.clone(),
1523 sobre_destino: false,
1524 resorte: None,
1525 samples: std::collections::VecDeque::new(),
1526 });
1527 state.window.request_redraw();
1528 } else if let Some((Some(handler), _, _, payload, _, _, _)) = &idx_and_action {
1529 // `drag` simple (sin _at, sin velocity) mantiene la
1530 // semántica antigua: gana exclusivo sobre on_click.
1531 state.drag = Some(DragState {
1532 handler: DragHandlerKind::Delta(handler.clone()),
1533 last_cursor: cursor,
1534 payload: *payload,
1535 carga: carga.clone(),
1536 sobre_destino: false,
1537 resorte: None,
1538 samples: std::collections::VecDeque::new(),
1539 });
1540 // Si hay payload, repintar para que el drop target
1541 // bajo cursor (si lo hay) se ilumine de entrada.
1542 if payload.is_some() {
1543 if let Some(cache) = state.last_render.as_mut() {
1544 let new_drop = hit_test_drop(
1545 &cache.mounted,
1546 &cache.computed,
1547 cursor.x as f32,
1548 cursor.y as f32,
1549 );
1550 if new_drop != cache.drop_hover_idx {
1551 cache.drop_hover_idx = new_drop;
1552 state.window.request_redraw();
1553 }
1554 }
1555 }
1556 } else if let Some((_, _, _, _, _, Some(handler), Some((ox, oy, rw, rh)))) =
1557 &idx_and_action
1558 {
1559 // on_click_at gana sobre on_click si ambos existen.
1560 let lx = cursor.x as f32 - ox;
1561 let ly = cursor.y as f32 - oy;
1562 if let Some(msg) = handler(lx, ly, *rw, *rh).filter(|_| !suprimir_click) {
1563 // `on_click_at` SIN drag es un click como cualquier otro que
1564 // además necesita saber DÓNDE se tocó (una celda, una rueda de
1565 // color, una fila). Que necesite coordenadas no lo vuelve otro
1566 // gesto: se arma en el press —resolviendo el `Msg` con las
1567 // coords del press, que son las que el usuario apuntó— y sale
1568 // al soltar, igual que `on_click`. La rama que sí dispara en el
1569 // press es la de `drag_at + on_click_at` sin payload (canvas,
1570 // texto), donde seleccionar-al-apretar es el gesto correcto.
1571 state.pending_click = Some(PendingClick {
1572 msg,
1573 origin: cursor,
1574 retener_hasta: retener_click,
1575 soltado: false,
1576 });
1577 }
1578 } else if let Some((_, _, _, _, Some(msg), _, _)) = idx_and_action {
1579 // `on_click` plano: NO se dispara en el press. Semántica de
1580 // escritorio — se arma y se dispara al soltar (`handle_left_release`)
1581 // si el cursor no se alejó más que `CLICK_MOVE_CANCEL`. Así el click
1582 // no salta en mousedown y un arrastre minúsculo accidental sigue
1583 // contando como click. No invalidamos el cache: nada visual cambió.
1584 if !suprimir_click {
1585 state.pending_click = Some(PendingClick {
1586 msg,
1587 origin: cursor,
1588 retener_hasta: retener_click,
1589 soltado: false,
1590 });
1591 }
1592 }
1593 }
1594
1595 // ── Click medio ───────────────────────────────────────────────────────────
1596
1597 fn handle_middle_press(&mut self) {
1598 let Some(state) = self.state.as_mut() else { return };
1599 // Cache frío (ver `build_hit_cache` / `handle_left_press`): reconstruir
1600 // antes de hit-testear para que el click no se pierda durante video.
1601 if state.last_render.is_none() {
1602 build_hit_cache::<A>(state);
1603 }
1604 // Middle-click: dispatcha `on_middle_click` del nodo
1605 // bajo cursor si lo declaró. La capa overlay tiene
1606 // prioridad (mismo razonamiento que el left/right click).
1607 let cursor = state.cursor;
1608 let lookup =
1609 |m: &Mounted<A::Msg>, c: &ComputedLayout| -> Option<A::Msg> {
1610 hit_test_middle_click(m, c, cursor.x as f32, cursor.y as f32)
1611 .and_then(|i| m.nodes[i].on_middle_click.clone())
1612 };
1613 let msg = if let Some(cache) = state.last_render.as_ref() {
1614 if let Some(ov) = cache.overlay.as_ref() {
1615 lookup(&ov.mounted, &ov.computed)
1616 } else {
1617 lookup(&cache.mounted, &cache.computed)
1618 }
1619 } else {
1620 None
1621 };
1622 if let Some(msg) = msg {
1623 let model = state.model.take().expect("model");
1624 state.model = Some(super::vigia::aplicar_update::<A>(model, msg, &self.handle));
1625 state.invalidar_render();
1626 state.window.request_redraw();
1627 }
1628 }
1629
1630 // ── Click derecho ─────────────────────────────────────────────────────────
1631
1632 fn handle_right_press(&mut self) {
1633 let Some(state) = self.state.as_mut() else { return };
1634 // Cache frío (ver `build_hit_cache` / `handle_left_press`): reconstruir
1635 // antes de hit-testear para que el menú contextual no se pierda durante
1636 // video.
1637 if state.last_render.is_none() {
1638 build_hit_cache::<A>(state);
1639 }
1640 // Right-click: dispatcheamos `on_right_click` o
1641 // `on_right_click_at` del nodo bajo cursor. La capa
1642 // overlay tiene prioridad (mismo razonamiento que el
1643 // left-click). Nodos sin handler de right-click no
1644 // reaccionan — no "filtramos" al left.
1645 let cursor = state.cursor;
1646 #[allow(clippy::type_complexity)]
1647 let lookup =
1648 |m: &Mounted<A::Msg>, c: &ComputedLayout| -> Option<(Option<A::Msg>, Option<ClickAtFn<A::Msg>>, Option<ClickAtFn<A::Msg>>, (f32, f32, f32, f32))> {
1649 hit_test_right_click(m, c, cursor.x as f32, cursor.y as f32).map(|i| {
1650 let node = &m.nodes[i];
1651 let rect = c
1652 .get(node.id)
1653 .map(|r| (r.x, r.y, r.w, r.h))
1654 .unwrap_or((0.0, 0.0, 0.0, 0.0));
1655 (
1656 node.on_right_click.clone(),
1657 node.on_right_click_at.clone(),
1658 node.on_right_click_screen.clone(),
1659 rect,
1660 )
1661 })
1662 };
1663 let hit = if let Some(cache) = state.last_render.as_ref() {
1664 if let Some(ov) = cache.overlay.as_ref() {
1665 lookup(&ov.mounted, &ov.computed)
1666 } else {
1667 lookup(&cache.mounted, &cache.computed)
1668 }
1669 } else {
1670 None
1671 };
1672 if let Some((msg_opt, at_opt, screen_opt, (ox, oy, rw, rh))) = hit {
1673 // `on_right_click_screen` (coords absolutas de ventana, para menús
1674 // anclados al puntero) gana sobre `on_right_click_at` (coords locales
1675 // al nodo, para grillas/canvas), y ambos sobre el `on_right_click` plano.
1676 let msg = if let Some(handler) = screen_opt {
1677 handler(cursor.x as f32, cursor.y as f32, rw, rh)
1678 } else if let Some(handler) = at_opt {
1679 handler(cursor.x as f32 - ox, cursor.y as f32 - oy, rw, rh)
1680 } else {
1681 msg_opt
1682 };
1683 // **No se dispara en el press.** El menú contextual se despliega al
1684 // SOLTAR, igual que el click izquierdo — y por las mismas razones,
1685 // que en el derecho pesan incluso más:
1686 //
1687 // * Con el menú abierto en el mousedown, el gesto ya no se puede
1688 // arrepentir: el momento de decidir «no, acá no» es justamente
1689 // mientras el botón sigue apretado.
1690 // * El menú aparece **bajo el cursor** y captura el overlay al
1691 // instante, así que el release siguiente cae adentro del menú
1692 // recién nacido en vez de sobre lo que apuntaste.
1693 // * Y un derecho con arrastre (marcar y menú, o el derecho que
1694 // algunas apps usan para pan) se volvía imposible de distinguir.
1695 //
1696 // Se arma como el izquierdo y lo dispara `handle_right_release` si
1697 // el cursor no se alejó más que `CLICK_MOVE_CANCEL`.
1698 if let Some(msg) = msg {
1699 state.pending_right_click = Some(PendingClick {
1700 msg,
1701 origin: cursor,
1702 retener_hasta: None,
1703 soltado: false,
1704 });
1705 }
1706 }
1707 }
1708
1709 /// El derecho **al soltar**: dispara el menú armado en el press si el
1710 /// cursor no se alejó. Ver el porqué en [`Self::handle_right_press`].
1711 fn handle_right_release(&mut self) {
1712 let Some(state) = self.state.as_mut() else { return };
1713 let Some(pending) = state.pending_right_click.take() else { return };
1714 let model = state.model.take().expect("model");
1715 state.model = Some(super::vigia::aplicar_update::<A>(model, pending.msg, &self.handle));
1716 state.invalidar_render();
1717 state.window.request_redraw();
1718 }
1719
1720 // ── Release izquierdo ─────────────────────────────────────────────────────
1721
1722 fn handle_left_release(&mut self) {
1723 let Some(state) = self.state.as_mut() else { return };
1724 // El cursor del arrastre se levanta SIEMPRE al soltar, aunque el drop
1725 // no haya caído en ningún lado: un puntero que se queda en «acá no»
1726 // después de soltar es peor que no haberlo cambiado nunca.
1727 if state.cursor_arrastre.take().is_some() {
1728 let bajo = state.last_render.as_ref().and_then(|c| {
1729 let (m, comp) = match c.overlay.as_ref() {
1730 Some(ov) => (&ov.mounted, &ov.computed),
1731 None => (&c.mounted, &c.computed),
1732 };
1733 hit_test_cursor(m, comp, state.cursor.x as f32, state.cursor.y as f32)
1734 });
1735 state.window.set_cursor(to_winit_cursor(bajo));
1736 }
1737 // El botón se soltó antes de vencer el long-press → no era un
1738 // long-press (fue un click/drag); cancela el gesto armado.
1739 state.pending_long_press = None;
1740 // Y el próximo arrastre vuelve a poder entregarse.
1741 state.arrastre_entregado = false;
1742 // `on_click` armado en el press y no cancelado por movimiento → este es
1743 // el click real: se dispara ahora, al soltar. Es mutuamente excluyente
1744 // con `drag` (rama else-if en el press), así que no colisiona con el
1745 // cierre del drag de más abajo.
1746 if let Some(pending) = state.pending_click.take() {
1747 // Click RETENIDO por un doble-tap exclusivo: soltar no alcanza, el
1748 // segundo tap todavía puede llegar. Queda anotado como soltado y lo
1749 // dispara `about_to_wait` cuando vence la ventana del doble.
1750 if pending.retener_hasta.is_some_and(|t| crate::Instant::now() < t) {
1751 state.pending_click = Some(PendingClick { soltado: true, ..pending });
1752 } else {
1753 let model = state.model.take().expect("model");
1754 state.model =
1755 Some(super::vigia::aplicar_update::<A>(model, pending.msg, &self.handle));
1756 state.invalidar_render();
1757 state.window.request_redraw();
1758 }
1759 }
1760 // Fin del arrastre de selección: la selección queda viva (para
1761 // Ctrl/Cmd+C) pero deja de extenderse con el cursor.
1762 if let Some(tsel) = state.selection.as_mut() {
1763 tsel.dragging = false;
1764 }
1765 if let Some(drag) = state.drag.take() {
1766 let cursor = state.cursor;
1767 // 1. Drop: si hay payload + drop target bajo cursor,
1768 // invocamos su handler. El Msg resultante se aplica
1769 // ANTES del End del drag — la convención es "drop
1770 // primero, cleanup del drag después".
1771 if let Some(payload) = drag.payload {
1772 // Cache frío (ver `build_hit_cache`, y es el MISMO bicho que
1773 // hacía «click no-op silencioso»): `last_render` se anula en
1774 // cada `Msg` —el tick propio de la app incluido— y sólo vuelve
1775 // a `Some` tras un redraw completo. Si el release cae en esa
1776 // ventana, el drop se saltaba el hit-test entero y el gesto
1777 // moría **sin decir nada**: la fila destino se ilumina mientras
1778 // arrastrás (eso mira el cache del Move, que sí está vivo) y al
1779 // soltar no pasa nada. Visto en metal el 2026-08-21 arrastrando
1780 // archivos a una colección de nahual. Los `handle_*_press` ya
1781 // reconstruían acá; este camino se había quedado afuera.
1782 if state.last_render.is_none() {
1783 build_hit_cache::<A>(state);
1784 }
1785 if let Some(cache) = state.last_render.as_ref() {
1786 if let Some(idx) = hit_test_drop(
1787 &cache.mounted,
1788 &cache.computed,
1789 cursor.x as f32,
1790 cursor.y as f32,
1791 ) {
1792 if let Some(drop_h) =
1793 cache.mounted.nodes[idx].on_drop.clone()
1794 {
1795 if let Some(msg) = (drop_h)(payload) {
1796 let model = state.model.take().expect("model");
1797 state.model = Some(super::vigia::aplicar_update::<A>(model, msg, &self.handle));
1798 }
1799 }
1800 }
1801 }
1802 }
1803 // 2. Cierre del drag.
1804 let end_msg = match &drag.handler {
1805 DragHandlerKind::Delta(h) => h(DragPhase::End, 0.0, 0.0),
1806 DragHandlerKind::DeltaAt(h, lx0, ly0) => {
1807 h(DragPhase::End, 0.0, 0.0, *lx0, *ly0)
1808 }
1809 DragHandlerKind::Velocity(h) => {
1810 let (vx, vy) =
1811 compute_drag_velocity(&drag.samples, crate::Instant::now());
1812 h(DragPhase::End, 0.0, 0.0, vx, vy)
1813 }
1814 };
1815 if let Some(msg) = end_msg {
1816 let model = state.model.take().expect("model");
1817 state.model = Some(super::vigia::aplicar_update::<A>(model, msg, &self.handle));
1818 }
1819 // Cache invalidado siempre — hover/drop pueden cambiar
1820 // y el modelo posiblemente mutó.
1821 state.invalidar_render();
1822 state.window.request_redraw();
1823 }
1824 }
1825}
1826
1827/// ¿Esta tecla merece auto-repeat sintético?
1828///
1829/// Dos familias, y la diferencia importa:
1830///
1831/// - **Edición/navegación** (borrar, moverse): repiten siempre, incluso con
1832/// modificador — `ctrl+backspace` borra palabra y `ctrl+flecha` salta
1833/// palabra, y sostenerlas es justo para lo que están.
1834/// - **Caracteres**: repiten sólo *sin* ctrl/alt/super. Con uno de esos encima
1835/// no es tecleo, es un **atajo**, y un atajo se ejecuta la vez que se pidió.
1836/// Sin esta distinción, sostener un acorde tres cuartos de segundo —lo que
1837/// dura teclearlo sin apuro— lo dispara veinticinco veces por segundo.
1838///
1839/// Los modificadores solos no traen texto ni son de edición, así que caen por
1840/// su propio peso.
1841pub(super) fn tecla_repetible(
1842 logical: &llimphi_hal::winit::keyboard::Key,
1843 tiene_texto: bool,
1844 mods: Modifiers,
1845) -> bool {
1846 use llimphi_hal::winit::keyboard::{Key, NamedKey};
1847 let edicion = matches!(
1848 logical,
1849 Key::Named(
1850 NamedKey::Backspace
1851 | NamedKey::Delete
1852 | NamedKey::ArrowLeft
1853 | NamedKey::ArrowRight
1854 | NamedKey::ArrowUp
1855 | NamedKey::ArrowDown
1856 )
1857 );
1858 let atajo = mods.ctrl || mods.alt || mods.meta;
1859 edicion || (tiene_texto && !atajo)
1860}
1861
1862#[cfg(test)]
1863mod pruebas_repetible {
1864 use super::tecla_repetible;
1865 use crate::Modifiers;
1866 use llimphi_hal::winit::keyboard::{Key, NamedKey};
1867
1868 fn tecla(c: &str) -> Key {
1869 Key::Character(c.into())
1870 }
1871
1872 #[test]
1873 fn lo_que_se_escribe_repite() {
1874 assert!(tecla_repetible(&tecla("a"), true, Modifiers::default()));
1875 let shift = Modifiers { shift: true, ..Default::default() };
1876 assert!(
1877 tecla_repetible(&tecla("A"), true, shift),
1878 "shift es para escribir mayúsculas, no un atajo"
1879 );
1880 }
1881
1882 #[test]
1883 fn borrar_y_moverse_repiten_aun_con_modificador() {
1884 let ctrl = Modifiers { ctrl: true, ..Default::default() };
1885 assert!(tecla_repetible(&Key::Named(NamedKey::Backspace), false, ctrl));
1886 assert!(tecla_repetible(&Key::Named(NamedKey::ArrowLeft), false, ctrl));
1887 }
1888
1889 /// **Regresión**: un acorde es una orden, y una orden se ejecuta la vez que
1890 /// se pidió. Sostener `ctrl+shift+t` no puede abrir seis pestañas.
1891 #[test]
1892 fn un_acorde_no_repite() {
1893 let ctrl_shift = Modifiers { ctrl: true, shift: true, ..Default::default() };
1894 assert!(!tecla_repetible(&tecla("T"), true, ctrl_shift));
1895 let alt = Modifiers { alt: true, ..Default::default() };
1896 assert!(!tecla_repetible(&tecla("1"), true, alt));
1897 let meta = Modifiers { meta: true, ..Default::default() };
1898 assert!(!tecla_repetible(&tecla("l"), true, meta));
1899 }
1900
1901 /// Enter, Escape, Tab y los modificadores no traen texto y no son de
1902 /// edición: no repiten sin tener que enumerarlos.
1903 #[test]
1904 fn mandar_y_los_modificadores_no_repiten() {
1905 let m = Modifiers::default();
1906 assert!(!tecla_repetible(&Key::Named(NamedKey::Enter), false, m));
1907 assert!(!tecla_repetible(&Key::Named(NamedKey::Escape), false, m));
1908 assert!(!tecla_repetible(&Key::Named(NamedKey::Tab), false, m));
1909 assert!(!tecla_repetible(&Key::Named(NamedKey::Shift), false, m));
1910 }
1911}
1912
1913/// **Cuánto hay que quedarse posado** sobre un destino para que se abra solo.
1914///
1915/// Medio segundo largo: más corto y el destino se abre de paso, mientras cruzás
1916/// el panel para llegar a otro; más largo y el gesto se siente trabado. Es el
1917/// mismo orden de magnitud que usan los gestores de archivos de escritorio para
1918/// sus carpetas con resorte.
1919pub const RESORTE: std::time::Duration = std::time::Duration::from_millis(550);
1920
1921/// **Avanza el reloj del resorte** y dice si a ESTE movimiento le toca dispararlo.
1922///
1923/// Pura sobre el estado del arrastre para poder exigirle por test las tres cosas
1924/// que no se ven en un pantallazo: que cambiar de destino reinicie la cuenta
1925/// (pasar por encima de tres plegables no abre los tres), que dispare **una sola
1926/// vez** por posada (si no, el destino se re-abriría en cada movimiento del
1927/// puntero) y que sin destino no dispare nada.
1928fn paso_del_resorte<Msg>(
1929 drag: Option<&mut crate::DragState<Msg>>,
1930 destino: Option<usize>,
1931) -> Option<usize> {
1932 let drag = drag?;
1933 let idx = match destino {
1934 Some(i) => i,
1935 None => {
1936 drag.resorte = None;
1937 return None;
1938 }
1939 };
1940 let ahora = crate::Instant::now();
1941 match drag.resorte.as_mut() {
1942 Some((i, _, _)) if *i != idx => {
1943 drag.resorte = Some((idx, ahora, false));
1944 None
1945 }
1946 Some((_, _, true)) => None,
1947 Some((_, desde, disparado)) => {
1948 if ahora.duration_since(*desde) >= RESORTE {
1949 *disparado = true;
1950 Some(idx)
1951 } else {
1952 None
1953 }
1954 }
1955 None => {
1956 drag.resorte = Some((idx, ahora, false));
1957 None
1958 }
1959 }
1960}