tawasuyu

tawasuyu/llimphi

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

Rust 124Markdown 82TOML 78Shell 12149 Definiciones

⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1 llimphi-ui/src/eventloop/input.rs Rust · 1682 líneas · 79.7 KB Historia Quién tocó qué crudo

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