tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

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

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

1// 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}