gioser

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definitions

⎇ 5ae7c737031521b9a873f3d7cff36261fbf7ae06 llimphi-ui/src/eventloop/input.rs Rust · 1006 lines · 46.1 KB History Who touched what raw

refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel ccab39f14 · 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
42impl<A: App> Runtime<A> {
43    /// Maneja un `WindowEvent` de la ventana primaria. La delegación a
44    /// secciones internas sigue el orden de frecuencia (más común primero).
45    pub(super) fn handle_primary_window_event(
46        &mut self,
47        event_loop: &llimphi_hal::winit::event_loop::ActiveEventLoop,
48        event: llimphi_hal::winit::event::WindowEvent,
49    ) {
50        use llimphi_hal::winit::event::WindowEvent;
51        let Some(state) = self.state.as_mut() else {
52            return;
53        };
54        // Cada window_event debe pasar primero por el adapter AccessKit para
55        // que las tecnologías asistivas se enteren del estado real de la
56        // ventana (focus_change del SO, cursor moves, etc.). El adapter
57        // no consume el evento — lo despacha aparte vía el EventLoopProxy.
58        state.a11y_adapter.process_event(&state.window, &event);
59        match event {
60            WindowEvent::CloseRequested => event_loop.exit(),
61            WindowEvent::Resized(size) => {
62                state.surface.resize(size.width, size.height);
63                // La app puede reaccionar al nuevo viewport (emitir un
64                // evento `resize`, recalcular layout, etc.). El update se
65                // corre tras reconfigurar la surface; el cache se invalida
66                // para repintar con el tamaño nuevo.
67                if let Some(msg) =
68                    A::on_resize(state.model.as_ref().expect("model"), size.width, size.height)
69                {
70                    let model = state.model.take().expect("model");
71                    state.model = Some(A::update(model, msg, &self.handle));
72                    state.last_render = None;
73                }
74                state.window.request_redraw();
75            }
76            WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
77                // El DPI de la ventana cambió (movida a otro monitor, escalado
78                // del sistema). winit envía un Resized aparte para el nuevo
79                // tamaño físico; aquí sólo propagamos el factor.
80                if let Some(msg) =
81                    A::on_scale_factor(state.model.as_ref().expect("model"), scale_factor)
82                {
83                    let model = state.model.take().expect("model");
84                    state.model = Some(A::update(model, msg, &self.handle));
85                    state.last_render = None;
86                }
87                state.window.request_redraw();
88            }
89            WindowEvent::CursorMoved { position, .. } => {
90                self.handle_cursor_moved(position);
91            }
92            WindowEvent::ModifiersChanged(mods) => {
93                let Some(state) = self.state.as_mut() else { return };
94                state.modifiers = mods.state().into();
95            }
96            WindowEvent::Ime(ime) if A::ime_allowed() => {
97                self.handle_ime(ime);
98            }
99            WindowEvent::KeyboardInput { event, .. } => {
100                self.handle_keyboard_input(event);
101            }
102            WindowEvent::DroppedFile(path) => {
103                // Un evento por archivo (winit los entrega serializados); si
104                // el usuario suelta varios, el bucle re-entra y aplicamos
105                // updates en orden.
106                let Some(state) = self.state.as_mut() else { return };
107                if let Some(msg) = A::on_file_drop(state.model.as_ref().expect("model"), path) {
108                    let model = state.model.take().expect("model");
109                    state.model = Some(A::update(model, msg, &self.handle));
110                    state.last_render = None;
111                    state.window.request_redraw();
112                }
113            }
114            WindowEvent::MouseWheel { delta, .. } => {
115                self.handle_mouse_wheel(delta);
116            }
117            WindowEvent::PinchGesture { delta, phase, .. } => {
118                self.handle_pinch_gesture(delta, phase);
119            }
120            WindowEvent::RotationGesture { delta, phase, .. } => {
121                self.handle_rotation_gesture(delta, phase);
122            }
123            WindowEvent::MouseInput {
124                state: ElementState::Pressed,
125                button: MouseButton::Left,
126                ..
127            } => {
128                self.handle_left_press();
129            }
130            WindowEvent::MouseInput {
131                state: ElementState::Pressed,
132                button: MouseButton::Middle,
133                ..
134            } => {
135                self.handle_middle_press();
136            }
137            WindowEvent::MouseInput {
138                state: ElementState::Pressed,
139                button: MouseButton::Right,
140                ..
141            } => {
142                self.handle_right_press();
143            }
144            WindowEvent::MouseInput {
145                state: ElementState::Released,
146                button: MouseButton::Left,
147                ..
148            } => {
149                self.handle_left_release();
150            }
151            WindowEvent::RedrawRequested => {
152                super::redraw::handle_redraw::<A>(
153                    self.state.as_mut().expect("state en redraw"),
154                    &self.handle,
155                );
156            }
157            _ => {}
158        }
159    }
160
161    // ── Cursor moved ─────────────────────────────────────────────────────────
162
163    fn handle_cursor_moved(
164        &mut self,
165        position: llimphi_hal::winit::dpi::PhysicalPosition<f64>,
166    ) {
167        let Some(state) = self.state.as_mut() else { return };
168        let prev_cursor = state.cursor;
169        state.cursor = position;
170        // Selección de texto en curso: extender el foco al punto actual.
171        if let Some(tsel) = state.selection.filter(|s| s.dragging) {
172            let info = state
173                .last_render
174                .as_ref()
175                .and_then(|c| selectable_by_key(c, tsel.key));
176            if let Some((spec, (rx, ry, rw, _rh))) = info {
177                let layout = build_selectable_layout(&mut state.typesetter, &spec, rw);
178                let lx = position.x as f32 - rx;
179                let ly = position.y as f32 - ry;
180                let new_sel = tsel.sel.extend_to_point(&layout, lx, ly);
181                state.selection = Some(TextSelection { sel: new_sel, ..tsel });
182                state.last_render = None;
183                state.window.request_redraw();
184            }
185        }
186        // Long-press armado: si el cursor se alejó del origen del press
187        // más que el umbral, el gesto pasó a drag/scroll → cancelar.
188        if let Some(p) = state.pending_long_press.as_ref() {
189            let dx = position.x - p.origin.x;
190            let dy = position.y - p.origin.y;
191            if (dx * dx + dy * dy).sqrt() > LONG_PRESS_MOVE_CANCEL {
192                state.pending_long_press = None;
193            }
194        }
195        // Drag activo: dispatchear delta al handler + actualizar
196        // tracking del drop target hovereado (solo si hay payload).
197        if let Some(drag) = state.drag.as_mut() {
198            let dx = (position.x - drag.last_cursor.x) as f32;
199            let dy = (position.y - drag.last_cursor.y) as f32;
200            drag.last_cursor = position;
201            let payload_active = drag.payload.is_some();
202            let mut need_redraw = false;
203            if dx != 0.0 || dy != 0.0 {
204                let msg_opt = match &drag.handler {
205                    DragHandlerKind::Delta(h) => h(DragPhase::Move, dx, dy),
206                    DragHandlerKind::DeltaAt(h, lx0, ly0) => {
207                        h(DragPhase::Move, dx, dy, *lx0, *ly0)
208                    }
209                    DragHandlerKind::Velocity(h) => {
210                        // Durante Move, vx=vy=0 — la velocidad sólo
211                        // tiene sentido al End. Acá registramos el
212                        // sample para esa medición.
213                        let now = std::time::Instant::now();
214                        drag.samples.push_back((now, dx as f64, dy as f64));
215                        while drag.samples.len() > VELOCITY_MAX_SAMPLES {
216                            drag.samples.pop_front();
217                        }
218                        h(DragPhase::Move, dx, dy, 0.0, 0.0)
219                    }
220                };
221                if let Some(msg) = msg_opt {
222                    let model = state.model.take().expect("model");
223                    state.model = Some(A::update(model, msg, &self.handle));
224                    // Durante drag NO invalidamos el cache —
225                    // queda válido para el próximo Move.
226                    need_redraw = true;
227                }
228            }
229            if payload_active {
230                if let Some(cache) = state.last_render.as_mut() {
231                    let new_drop = hit_test_drop(
232                        &cache.mounted,
233                        &cache.computed,
234                        position.x as f32,
235                        position.y as f32,
236                    );
237                    if new_drop != cache.drop_hover_idx {
238                        cache.drop_hover_idx = new_drop;
239                        need_redraw = true;
240                    }
241                }
242            }
243            if need_redraw {
244                state.window.request_redraw();
245            }
246        } else {
247            // Sin drag: chequear hover. Si hay overlay, el
248            // hover-test va contra él; el árbol principal queda
249            // congelado mientras el overlay esté arriba.
250            //
251            // Además del repintado (para el `hover_fill`), en la
252            // transición de hover dispatcheamos dos Msgs: el
253            // `on_pointer_leave` del nodo que abandonamos y el
254            // `on_pointer_enter` del recién hovereado. Es lo que
255            // permite, p.ej., cambiar de menú con el mouse, abrir un
256            // submenú al pasar por encima, o cerrar un tooltip/drawer
257            // al salir. Extraemos los Msg en un scope para soltar el
258            // borrow del cache antes de mutar el modelo.
259            let mut enter_msg: Option<A::Msg> = None;
260            let mut leave_msg: Option<A::Msg> = None;
261            let mut hovered_changed = false;
262            let mut new_hovered: Option<usize> = state.hovered;
263            // Forma del cursor del nodo recién hovereado (sólo se
264            // resuelve en la transición; ver `to_winit_cursor`).
265            let mut new_cursor: Option<Cursor> = None;
266            if let Some(cache) = state.last_render.as_ref() {
267                let (mounted, computed) = match cache.overlay.as_ref() {
268                    Some(ov) => (&ov.mounted, &ov.computed),
269                    None => (&cache.mounted, &cache.computed),
270                };
271                let new_hover = hit_test_hover(
272                    mounted,
273                    computed,
274                    position.x as f32,
275                    position.y as f32,
276                );
277                // Comparamos contra el hover PERSISTENTE (state.hovered),
278                // no contra el del cache: el render recomputa el del cache
279                // al cursor actual cada cuadro, así que en una app que
280                // re-renderiza sin parar la transición de hover se perdería
281                // (y el hover-switch de menús no andaría). Ver `hovered`.
282                if new_hover != state.hovered {
283                    hovered_changed = true;
284                    // El nodo que dejamos (índice persistente previo)
285                    // dispara su leave; el nuevo, su enter. Ambos se
286                    // resuelven contra el árbol montado actual, igual
287                    // que el resto del hit-test de hover.
288                    leave_msg = state
289                        .hovered
290                        .and_then(|i| mounted.nodes.get(i))
291                        .and_then(|n| n.on_pointer_leave.clone());
292                    enter_msg = new_hover
293                        .and_then(|i| mounted.nodes.get(i))
294                        .and_then(|n| n.on_pointer_enter.clone());
295                    new_cursor = hit_test_cursor(
296                        mounted,
297                        computed,
298                        position.x as f32,
299                        position.y as f32,
300                    );
301                }
302                new_hovered = new_hover;
303            }
304            state.hovered = new_hovered;
305            if hovered_changed {
306                state.window.set_cursor(to_winit_cursor(new_cursor));
307                // Invalidar el cache de paint retenido: el `hover_fill`
308                // del nodo viejo está pintado en `state.scene` y el del
309                // nuevo no — un cache hit re-presentaría el frame stale.
310                state.last_render = None;
311                state.window.request_redraw();
312            }
313            // Despachamos leave antes que enter: el orden natural de
314            // la transición es "salgo de A, luego entro en B".
315            for msg in [leave_msg, enter_msg].into_iter().flatten() {
316                let model = state.model.take().expect("model");
317                state.model = Some(A::update(model, msg, &self.handle));
318                // El estado cambió → invalidamos el cache para
319                // re-render (p.ej. el submenú que se abre/cierra).
320                state.last_render = None;
321            }
322            // Movimiento posicional: dispatch `on_pointer_move_at` en CADA move
323            // mientras el cursor está sobre el nodo (no sólo al entrar, como
324            // `on_pointer_enter`). Base del thumbnail/drawer que sigue al
325            // cursor. Extraemos el handler (Arc) + rect en un scope para soltar
326            // el borrow del cache antes de mutar el modelo.
327            let move_call: Option<(ClickAtFn<A::Msg>, f32, f32, f32, f32)> =
328                state.last_render.as_ref().and_then(|cache| {
329                    let (mounted, computed) = match cache.overlay.as_ref() {
330                        Some(ov) => (&ov.mounted, &ov.computed),
331                        None => (&cache.mounted, &cache.computed),
332                    };
333                    let px = position.x as f32;
334                    let py = position.y as f32;
335                    let i = hit_test_pointer_move(mounted, computed, px, py)?;
336                    let node = &mounted.nodes[i];
337                    let h = node.on_pointer_move_at.clone()?;
338                    let r = computed.get(node.id)?;
339                    Some((h, px - r.x, py - r.y, r.w, r.h))
340                });
341            if let Some((h, lx, ly, w, hh)) = move_call {
342                if let Some(msg) = h(lx, ly, w, hh) {
343                    let model = state.model.take().expect("model");
344                    state.model = Some(A::update(model, msg, &self.handle));
345                    state.last_render = None;
346                    state.window.request_redraw();
347                }
348            }
349            let _ = prev_cursor;
350        }
351    }
352
353    // ── IME ──────────────────────────────────────────────────────────────────
354
355    fn handle_ime(&mut self, ime: llimphi_hal::winit::event::Ime) {
356        use llimphi_hal::winit::event::Ime;
357        let Some(state) = self.state.as_mut() else { return };
358        let ev = match ime {
359            Ime::Enabled => ImeEvent::Enabled,
360            Ime::Preedit(text, cursor) => ImeEvent::Preedit { text, cursor },
361            Ime::Commit(text) => ImeEvent::Commit(text),
362            Ime::Disabled => ImeEvent::Disabled,
363        };
364        if let Some(msg) = A::on_ime(state.model.as_ref().expect("model"), &ev) {
365            let model = state.model.take().expect("model");
366            state.model = Some(A::update(model, msg, &self.handle));
367            state.last_render = None;
368            state.window.request_redraw();
369        }
370    }
371
372    // ── Teclado ───────────────────────────────────────────────────────────────
373
374    fn handle_keyboard_input(
375        &mut self,
376        event: llimphi_hal::winit::event::KeyEvent,
377    ) {
378        let Some(state) = self.state.as_mut() else { return };
379        // Tab / Shift+Tab mueven el foco entre nodos `focusable`,
380        // que administra el runtime. Sólo intercepta si hay
381        // enfocables y en Pressed; si no, cae al `on_key` normal
382        // (apps que usan Tab para otra cosa lo siguen recibiendo).
383        let is_tab = event.state == ElementState::Pressed
384            && matches!(event.logical_key, Key::Named(NamedKey::Tab));
385        if is_tab {
386            let order = state
387                .last_render
388                .as_ref()
389                .map(|c| focus_order(&c.mounted, &c.computed))
390                .unwrap_or_default();
391            if !order.is_empty() {
392                let next = next_focus(&order, state.focused, state.modifiers.shift);
393                state.focused = next;
394                if let Some(msg) =
395                    A::on_focus(state.model.as_ref().expect("model"), next)
396                {
397                    let model = state.model.take().expect("model");
398                    state.model = Some(A::update(model, msg, &self.handle));
399                }
400                state.last_render = None;
401                state.window.request_redraw();
402                return;
403            }
404        }
405        // Ctrl/Cmd+C copia la selección de texto activa (fuera del
406        // editor). Consume el evento sólo si había algo seleccionado;
407        // si no, cae al `on_key` normal (apps que usan Ctrl+C para lo
408        // suyo lo siguen recibiendo).
409        if event.state == ElementState::Pressed
410            && (state.modifiers.ctrl || state.modifiers.meta)
411            && key_is_char(&event.logical_key, 'c')
412        {
413            if let Some(tsel) = state.selection {
414                let text = state
415                    .last_render
416                    .as_ref()
417                    .and_then(|c| selectable_by_key(c, tsel.key))
418                    .and_then(|(spec, _)| {
419                        spec.content.get(tsel.sel.text_range()).map(str::to_string)
420                    });
421                if let Some(t) = text.filter(|t| !t.is_empty()) {
422                    copy_to_clipboard(&t);
423                    return;
424                }
425            }
426        }
427        let ev = KeyEvent {
428            key: event.logical_key.clone(),
429            state: match event.state {
430                ElementState::Pressed => KeyState::Pressed,
431                ElementState::Released => KeyState::Released,
432            },
433            text: event.text.as_ref().map(|t| t.to_string()),
434            modifiers: state.modifiers,
435            repeat: event.repeat,
436        };
437        if let Some(msg) = A::on_key(state.model.as_ref().expect("model"), &ev) {
438            let model = state.model.take().expect("model");
439            state.model = Some(A::update(model, msg, &self.handle));
440            state.last_render = None;
441            state.window.request_redraw();
442        }
443    }
444
445    // ── Rueda del mouse ───────────────────────────────────────────────────────
446
447    fn handle_mouse_wheel(
448        &mut self,
449        delta: MouseScrollDelta,
450    ) {
451        let Some(state) = self.state.as_mut() else { return };
452        // Convención winit: LineDelta es líneas; PixelDelta es
453        // píxeles físicos (touchpads). En CSS y aquí, positivo
454        // (rueda hacia adelante / dos dedos arriba) = scroll
455        // hacia arriba, así que invertimos `y` para que el
456        // contenido "siga al dedo" en y positivo. `x` queda
457        // como llega.
458        let wd = match delta {
459            MouseScrollDelta::LineDelta(x, y) => WheelDelta { x, y: -y },
460            MouseScrollDelta::PixelDelta(p) => WheelDelta {
461                x: (p.x as f32) / 20.0,
462                y: -(p.y as f32) / 20.0,
463            },
464        };
465        let cursor = (state.cursor.x as f32, state.cursor.y as f32);
466        // Ctrl+rueda = **pinch-to-zoom sintético** (camino universal de
467        // desktop: Wayland/Windows no emiten el gesto de pinch del
468        // trackpad). Si hay un nodo `on_scale` bajo el cursor, el zoom
469        // lo consume ANTES que el scroll/`on_wheel`. Factor
470        // multiplicativo incremental `1.1^(-dy)`: rueda hacia arriba
471        // (`dy<0`) agranda, hacia abajo achica. El punto focal es el
472        // cursor (en coords locales al nodo), para zoomear "hacia el
473        // cursor". Sin nodo zoomeable, Ctrl+rueda cae al `on_wheel`
474        // global como siempre (browsers, etc. lo siguen recibiendo).
475        if state.modifiers.ctrl {
476            let scale_hit = state
477                .last_render
478                .as_ref()
479                .and_then(|cache| scale_hit_from_cache(cache, cursor.0, cursor.1));
480            if let Some((h, fx, fy)) = scale_hit {
481                let factor = 1.1_f32.powf(-wd.y);
482                if let Some(msg) = h(GesturePhase::Update, factor, fx, fy) {
483                    let model = state.model.take().expect("model");
484                    state.model = Some(A::update(model, msg, &self.handle));
485                    state.last_render = None;
486                    state.window.request_redraw();
487                }
488                return;
489            }
490        }
491        // Primero: ¿hay nodos con `on_scroll` bajo el cursor? Se
492        // arma la **cadena** (front→back) y se invoca cada handler
493        // en orden; el primero que devuelva `Some` consume el
494        // evento. Un scroll interno que llegó al extremo de su eje
495        // puede devolver `None` para dejar el sobrante al ancestro
496        // scrollable más cercano (scroll anidado). Si todos
497        // devuelven `None`, cae al `on_wheel` global.
498        // El overlay tiene prioridad, igual que con clicks. Se
499        // extraen los handlers en un scope para soltar el borrow
500        // del cache antes de mutar el modelo.
501        let scroll_chain: Vec<ScrollFn<A::Msg>> =
502            if let Some(cache) = state.last_render.as_ref() {
503                let (m, c) = match cache.overlay.as_ref() {
504                    Some(ov) => (&ov.mounted, &ov.computed),
505                    None => (&cache.mounted, &cache.computed),
506                };
507                hit_test_scroll_chain(m, c, cursor.0, cursor.1)
508                    .into_iter()
509                    .filter_map(|i| m.nodes[i].on_scroll.clone())
510                    .collect()
511            } else {
512                Vec::new()
513            };
514        let mut msg: Option<A::Msg> = None;
515        for h in &scroll_chain {
516            if let Some(m) = h(wd.x, wd.y) {
517                msg = Some(m);
518                break;
519            }
520        }
521        if msg.is_none() {
522            msg = A::on_wheel(
523                state.model.as_ref().expect("model"),
524                wd,
525                cursor,
526                state.modifiers,
527            );
528        }
529        if let Some(msg) = msg {
530            let model = state.model.take().expect("model");
531            state.model = Some(A::update(model, msg, &self.handle));
532            state.last_render = None;
533            state.window.request_redraw();
534        }
535    }
536
537    // ── Gestos trackpad ───────────────────────────────────────────────────────
538
539    fn handle_pinch_gesture(
540        &mut self,
541        delta: f64,
542        phase: llimphi_hal::winit::event::TouchPhase,
543    ) {
544        use llimphi_hal::winit::event::TouchPhase;
545        let Some(state) = self.state.as_mut() else { return };
546        // Pinch del trackpad (winit lo emite **sólo en macOS/iOS**; en
547        // Wayland/Windows el zoom va por Ctrl+rueda, arriba). `delta` es
548        // el cambio de escala incremental (p. ej. 0.01 = +1%); lo
549        // mapeamos al mismo `on_scale` que Ctrl+rueda, con factor
550        // multiplicativo `1.0 + delta`. La fase de winit se traduce a
551        // la de gesto (Begin/Update/End) para que el handler pueda, p.
552        // ej., abrir/cerrar un estado de zoom en vivo.
553        let cursor = (state.cursor.x as f32, state.cursor.y as f32);
554        let gphase = match phase {
555            TouchPhase::Started => GesturePhase::Begin,
556            TouchPhase::Moved => GesturePhase::Update,
557            TouchPhase::Ended | TouchPhase::Cancelled => GesturePhase::End,
558        };
559        // `delta` puede venir NaN según la doc de winit; en ese caso
560        // (o fuera de Update) el factor neutro es 1.0.
561        let factor = if gphase == GesturePhase::Update && delta.is_finite() {
562            (1.0 + delta) as f32
563        } else {
564            1.0
565        };
566        let scale_hit = state
567            .last_render
568            .as_ref()
569            .and_then(|cache| scale_hit_from_cache(cache, cursor.0, cursor.1));
570        if let Some((h, fx, fy)) = scale_hit {
571            if let Some(msg) = h(gphase, factor, fx, fy) {
572                let model = state.model.take().expect("model");
573                state.model = Some(A::update(model, msg, &self.handle));
574                state.last_render = None;
575                state.window.request_redraw();
576            }
577        }
578    }
579
580    fn handle_rotation_gesture(
581        &mut self,
582        delta: f32,
583        phase: llimphi_hal::winit::event::TouchPhase,
584    ) {
585        use llimphi_hal::winit::event::TouchPhase;
586        let Some(state) = self.state.as_mut() else { return };
587        // Rotación de dos dedos en el trackpad (winit la emite **sólo
588        // en macOS**). `delta` viene en **grados**; lo convertimos a
589        // radianes para el handler (positivo = horario). La fase de
590        // winit se traduce a la de gesto (Begin/Update/End). No hay
591        // camino universal por teclado/rueda como sí lo tiene el zoom.
592        let cursor = (state.cursor.x as f32, state.cursor.y as f32);
593        let gphase = match phase {
594            TouchPhase::Started => GesturePhase::Begin,
595            TouchPhase::Moved => GesturePhase::Update,
596            TouchPhase::Ended | TouchPhase::Cancelled => GesturePhase::End,
597        };
598        let delta_rad = if gphase == GesturePhase::Update && delta.is_finite() {
599            delta.to_radians()
600        } else {
601            0.0
602        };
603        let rotate_hit = state
604            .last_render
605            .as_ref()
606            .and_then(|cache| rotate_hit_from_cache(cache, cursor.0, cursor.1));
607        if let Some((h, fx, fy)) = rotate_hit {
608            if let Some(msg) = h(gphase, delta_rad, fx, fy) {
609                let model = state.model.take().expect("model");
610                state.model = Some(A::update(model, msg, &self.handle));
611                state.last_render = None;
612                state.window.request_redraw();
613            }
614        }
615    }
616
617    // ── Click izquierdo ───────────────────────────────────────────────────────
618
619    fn handle_left_press(&mut self) {
620        let Some(state) = self.state.as_mut() else { return };
621        let cursor = state.cursor;
622        // Click-to-focus: si el click cae sobre un nodo enfocable,
623        // el runtime le da el foco ANTES de procesar la acción de
624        // click. Extraemos el id en un scope (suelta el borrow del
625        // cache) y recién después mutamos el foco/modelo.
626        let focus_hit = state
627            .last_render
628            .as_ref()
629            .and_then(|cache| {
630                let (m, c) = match cache.overlay.as_ref() {
631                    Some(ov) => (&ov.mounted, &ov.computed),
632                    None => (&cache.mounted, &cache.computed),
633                };
634                hit_test_focusable(m, c, cursor.x as f32, cursor.y as f32)
635            });
636        if focus_hit.is_some() && focus_hit != state.focused {
637            state.focused = focus_hit;
638            if let Some(msg) =
639                A::on_focus(state.model.as_ref().expect("model"), focus_hit)
640            {
641                let model = state.model.take().expect("model");
642                state.model = Some(A::update(model, msg, &self.handle));
643            }
644            state.last_render = None;
645        }
646        // ── Selección de texto fuera del editor (aditiva) ──────────
647        // Si el press cae sobre un nodo `View::selectable`, arranca una
648        // selección (anchor = punto). Un press en cualquier otro lado
649        // limpia la selección activa. Aditivo: un label seleccionable
650        // sin `on_click`/drag no choca con el camino de abajo.
651        let sel_hit = state.last_render.as_ref().and_then(|c| {
652            selectable_hit_from_cache(c, cursor.x as f32, cursor.y as f32)
653        });
654        if let Some((key, spec, (rx, ry, rw, _rh))) = sel_hit {
655            let layout = build_selectable_layout(&mut state.typesetter, &spec, rw);
656            let lx = cursor.x as f32 - rx;
657            let ly = cursor.y as f32 - ry;
658            let sel = llimphi_text::parley::Selection::from_point(&layout, lx, ly);
659            state.selection = Some(TextSelection { key, sel, dragging: true });
660            state.last_render = None;
661            state.window.request_redraw();
662        } else if state.selection.is_some() {
663            state.selection = None;
664            state.last_render = None;
665            state.window.request_redraw();
666        }
667        // ── Arena de gestos (aditiva): doble-tap + long-press ──────
668        // Se resuelven con su propio hit-test y NO tocan el camino de
669        // click/drag de abajo (intacto). El árbitro real es el tiempo:
670        // doble-tap = dos presses cercanos dentro de una ventana;
671        // long-press = un press que sobrevive ~500 ms quieto (lo vence
672        // `about_to_wait`, lo cancela el movimiento/release).
673        let now = std::time::Instant::now();
674        // Doble-tap: si este press cae sobre un nodo con doble-tap y
675        // hubo un tap previo cercano y a tiempo, dispará. Si no, este
676        // press queda registrado como "primer tap".
677        if let Some(resolved) =
678            state.last_render.as_ref().and_then(|c| {
679                double_tap_hit_from_cache(c, cursor.x as f32, cursor.y as f32)
680            })
681        {
682            let qualifies = double_tap_qualifies(state.last_tap, now, cursor);
683            if qualifies {
684                state.last_tap = None; // consumido; un 3er tap no re-dispara
685                if let Some(msg) = resolved.invoke() {
686                    let model = state.model.take().expect("model");
687                    state.model = Some(A::update(model, msg, &self.handle));
688                    state.last_render = None;
689                    state.window.request_redraw();
690                }
691            } else {
692                state.last_tap = Some((now, cursor));
693            }
694        }
695        // Long-press: si el press cae sobre un nodo con long-press,
696        // armalo. `about_to_wait` lo dispara al vencer; CursorMoved lo
697        // cancela si el cursor se aleja; el release lo cancela siempre.
698        if let Some(handler) = state.last_render.as_ref().and_then(|c| {
699            long_press_hit_from_cache(c, cursor.x as f32, cursor.y as f32)
700        }) {
701            state.pending_long_press = Some(PendingLongPress {
702                deadline: now + LONG_PRESS_DELAY,
703                origin: cursor,
704                handler,
705            });
706        }
707        // Ripple/InkWell: si el press cae sobre un nodo con ripple,
708        // dispará la salpicadura desde el punto. Aditivo — no toca el
709        // camino click/drag de abajo; un botón con `on_click` +
710        // `.ripple(...)` recibe ambos.
711        if let Some((rp, lx, ly)) = state
712            .last_render
713            .as_ref()
714            .and_then(|c| ripple_hit_from_cache(c, cursor.x as f32, cursor.y as f32))
715        {
716            state
717                .ripple_registry
718                .trigger(rp.key, lx, ly, rp.color, rp.duration, now);
719            // El ripple se pinta sobre la scene; invalidamos el cache
720            // de paint para que el próximo redraw lo dibuje (sin esto,
721            // un cache hit re-presentaría el frame sin la salpicadura).
722            state.last_render = None;
723            state.window.request_redraw();
724        }
725        // Con overlay activo, los clicks van EXCLUSIVAMENTE a él.
726        // Si el cursor cae sobre un nodo del overlay sin handler,
727        // el click se descarta — la convención de "scrim que
728        // dismissa" pide que la app meta su propio fondo
729        // clicable con `on_click = DismissOverlay`.
730        let idx_and_action: Option<HitInfo<A::Msg>> = if let Some(cache) =
731            state.last_render.as_ref()
732        {
733            if let Some(ov) = cache.overlay.as_ref() {
734                lookup_click_hit(&ov.mounted, &ov.computed, cursor.x as f32, cursor.y as f32)
735            } else {
736                lookup_click_hit(&cache.mounted, &cache.computed, cursor.x as f32, cursor.y as f32)
737            }
738        } else {
739            let (w, h) = state.surface.size();
740            // Mismo resolve de LayoutBuilder que el redraw, para que el
741            // hit-test del fallback vea los hijos producidos por builders.
742            let view = resolve_layout_builders::<A>(
743                state.model.as_ref().expect("model"),
744                (w as f32, h as f32),
745                &mut state.typesetter,
746            );
747            let model_ref = state.model.as_ref().expect("model");
748            let overlay_view = A::view_overlay(model_ref);
749            let mut layout = LayoutTree::new();
750            let mounted: Mounted<A::Msg> = mount(&mut layout, view);
751            let ts = &mut state.typesetter;
752            let computed = {
753                let tmap = &mounted.text_measures;
754                layout
755                    .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| {
756                        match tmap.get(&nid) {
757                            Some(tm) => measure_text_node(ts, tm, known, avail),
758                            None => llimphi_layout::taffy::Size::ZERO,
759                        }
760                    })
761                    .expect("layout")
762            };
763            if let Some(ov) = overlay_view {
764                let mut olay = LayoutTree::new();
765                let omounted: Mounted<A::Msg> = mount(&mut olay, ov);
766                let ocomp = {
767                    let tmap = &omounted.text_measures;
768                    olay
769                        .compute_with_measure(omounted.root, (w as f32, h as f32), |nid, known, avail| {
770                            match tmap.get(&nid) {
771                                Some(tm) => measure_text_node(ts, tm, known, avail),
772                                None => llimphi_layout::taffy::Size::ZERO,
773                            }
774                        })
775                        .expect("layout overlay")
776                };
777                lookup_click_hit(&omounted, &ocomp, cursor.x as f32, cursor.y as f32)
778            } else {
779                lookup_click_hit(&mounted, &computed, cursor.x as f32, cursor.y as f32)
780            }
781        };
782        // Prioridad: drag_velocity > drag_at + on_click_at > drag >
783        // on_click_at > on_click. `drag_velocity` gana exclusivo
784        // (no convive con on_click_at) — el caller que lo elige
785        // suele querer un fling físico puro (lienzos pan-and-fling).
786        if let Some((_, _, Some(handler_v), payload, _, _, _)) = &idx_and_action {
787            state.drag = Some(DragState {
788                handler: DragHandlerKind::Velocity(handler_v.clone()),
789                last_cursor: cursor,
790                payload: *payload,
791                samples: std::collections::VecDeque::with_capacity(VELOCITY_MAX_SAMPLES),
792            });
793            state.window.request_redraw();
794        } else if let Some((_, Some(handler_at), _, payload, _, click_at, Some((ox, oy, rw, rh)))) =
795            &idx_and_action
796        {
797            // drag_at + on_click_at COEXISTEN: el press dispara
798            // on_click_at (si está) y arranca un drag rastreado con
799            // la posición inicial. Diseño pensado para canvas
800            // elements que necesitan select-on-press + move-on-drag.
801            let lx0 = cursor.x as f32 - ox;
802            let ly0 = cursor.y as f32 - oy;
803            // Disparar on_click_at en el press (si también está).
804            if let Some(click_at_h) = click_at {
805                if let Some(msg) = click_at_h(lx0, ly0, *rw, *rh) {
806                    let model = state.model.take().expect("model");
807                    state.model = Some(A::update(model, msg, &self.handle));
808                    state.last_render = None;
809                }
810            }
811            state.drag = Some(DragState {
812                handler: DragHandlerKind::DeltaAt(handler_at.clone(), lx0, ly0),
813                last_cursor: cursor,
814                payload: *payload,
815                samples: std::collections::VecDeque::new(),
816            });
817            state.window.request_redraw();
818        } else if let Some((Some(handler), _, _, payload, _, _, _)) = &idx_and_action {
819            // `drag` simple (sin _at, sin velocity) mantiene la
820            // semántica antigua: gana exclusivo sobre on_click.
821            state.drag = Some(DragState {
822                handler: DragHandlerKind::Delta(handler.clone()),
823                last_cursor: cursor,
824                payload: *payload,
825                samples: std::collections::VecDeque::new(),
826            });
827            // Si hay payload, repintar para que el drop target
828            // bajo cursor (si lo hay) se ilumine de entrada.
829            if payload.is_some() {
830                if let Some(cache) = state.last_render.as_mut() {
831                    let new_drop = hit_test_drop(
832                        &cache.mounted,
833                        &cache.computed,
834                        cursor.x as f32,
835                        cursor.y as f32,
836                    );
837                    if new_drop != cache.drop_hover_idx {
838                        cache.drop_hover_idx = new_drop;
839                        state.window.request_redraw();
840                    }
841                }
842            }
843        } else if let Some((_, _, _, _, _, Some(handler), Some((ox, oy, rw, rh)))) =
844            &idx_and_action
845        {
846            // on_click_at gana sobre on_click si ambos existen.
847            let lx = cursor.x as f32 - ox;
848            let ly = cursor.y as f32 - oy;
849            if let Some(msg) = handler(lx, ly, *rw, *rh) {
850                let model = state.model.take().expect("model");
851                state.model = Some(A::update(model, msg, &self.handle));
852                state.last_render = None;
853                state.window.request_redraw();
854            }
855        } else if let Some((_, _, _, _, Some(msg), _, _)) = idx_and_action {
856            let model = state.model.take().expect("model");
857            state.model = Some(A::update(model, msg, &self.handle));
858            state.last_render = None;
859            state.window.request_redraw();
860        }
861    }
862
863    // ── Click medio ───────────────────────────────────────────────────────────
864
865    fn handle_middle_press(&mut self) {
866        let Some(state) = self.state.as_mut() else { return };
867        // Middle-click: dispatcha `on_middle_click` del nodo
868        // bajo cursor si lo declaró. La capa overlay tiene
869        // prioridad (mismo razonamiento que el left/right click).
870        let cursor = state.cursor;
871        let lookup =
872            |m: &Mounted<A::Msg>, c: &ComputedLayout| -> Option<A::Msg> {
873                hit_test_middle_click(m, c, cursor.x as f32, cursor.y as f32)
874                    .and_then(|i| m.nodes[i].on_middle_click.clone())
875            };
876        let msg = if let Some(cache) = state.last_render.as_ref() {
877            if let Some(ov) = cache.overlay.as_ref() {
878                lookup(&ov.mounted, &ov.computed)
879            } else {
880                lookup(&cache.mounted, &cache.computed)
881            }
882        } else {
883            None
884        };
885        if let Some(msg) = msg {
886            let model = state.model.take().expect("model");
887            state.model = Some(A::update(model, msg, &self.handle));
888            state.last_render = None;
889            state.window.request_redraw();
890        }
891    }
892
893    // ── Click derecho ─────────────────────────────────────────────────────────
894
895    fn handle_right_press(&mut self) {
896        let Some(state) = self.state.as_mut() else { return };
897        // Right-click: dispatcheamos `on_right_click` o
898        // `on_right_click_at` del nodo bajo cursor. La capa
899        // overlay tiene prioridad (mismo razonamiento que el
900        // left-click). Nodos sin handler de right-click no
901        // reaccionan — no "filtramos" al left.
902        let cursor = state.cursor;
903        let lookup =
904            |m: &Mounted<A::Msg>, c: &ComputedLayout| -> Option<(Option<A::Msg>, Option<ClickAtFn<A::Msg>>, (f32, f32, f32, f32))> {
905                hit_test_right_click(m, c, cursor.x as f32, cursor.y as f32).map(|i| {
906                    let node = &m.nodes[i];
907                    let rect = c
908                        .get(node.id)
909                        .map(|r| (r.x, r.y, r.w, r.h))
910                        .unwrap_or((0.0, 0.0, 0.0, 0.0));
911                    (
912                        node.on_right_click.clone(),
913                        node.on_right_click_at.clone(),
914                        rect,
915                    )
916                })
917            };
918        let hit = if let Some(cache) = state.last_render.as_ref() {
919            if let Some(ov) = cache.overlay.as_ref() {
920                lookup(&ov.mounted, &ov.computed)
921            } else {
922                lookup(&cache.mounted, &cache.computed)
923            }
924        } else {
925            None
926        };
927        if let Some((msg_opt, at_opt, (ox, oy, rw, rh))) = hit {
928            let msg = if let Some(handler) = at_opt {
929                handler(
930                    cursor.x as f32 - ox,
931                    cursor.y as f32 - oy,
932                    rw,
933                    rh,
934                )
935            } else {
936                msg_opt
937            };
938            if let Some(msg) = msg {
939                let model = state.model.take().expect("model");
940                state.model = Some(A::update(model, msg, &self.handle));
941                state.last_render = None;
942                state.window.request_redraw();
943            }
944        }
945    }
946
947    // ── Release izquierdo ─────────────────────────────────────────────────────
948
949    fn handle_left_release(&mut self) {
950        let Some(state) = self.state.as_mut() else { return };
951        // El botón se soltó antes de vencer el long-press → no era un
952        // long-press (fue un click/drag); cancelá el gesto armado.
953        state.pending_long_press = None;
954        // Fin del arrastre de selección: la selección queda viva (para
955        // Ctrl/Cmd+C) pero deja de extenderse con el cursor.
956        if let Some(tsel) = state.selection.as_mut() {
957            tsel.dragging = false;
958        }
959        if let Some(drag) = state.drag.take() {
960            let cursor = state.cursor;
961            // 1. Drop: si hay payload + drop target bajo cursor,
962            //    invocamos su handler. El Msg resultante se aplica
963            //    ANTES del End del drag — la convención es "drop
964            //    primero, cleanup del drag después".
965            if let Some(payload) = drag.payload {
966                if let Some(cache) = state.last_render.as_ref() {
967                    if let Some(idx) = hit_test_drop(
968                        &cache.mounted,
969                        &cache.computed,
970                        cursor.x as f32,
971                        cursor.y as f32,
972                    ) {
973                        if let Some(drop_h) =
974                            cache.mounted.nodes[idx].on_drop.clone()
975                        {
976                            if let Some(msg) = (drop_h)(payload) {
977                                let model = state.model.take().expect("model");
978                                state.model = Some(A::update(model, msg, &self.handle));
979                            }
980                        }
981                    }
982                }
983            }
984            // 2. Cierre del drag.
985            let end_msg = match &drag.handler {
986                DragHandlerKind::Delta(h) => h(DragPhase::End, 0.0, 0.0),
987                DragHandlerKind::DeltaAt(h, lx0, ly0) => {
988                    h(DragPhase::End, 0.0, 0.0, *lx0, *ly0)
989                }
990                DragHandlerKind::Velocity(h) => {
991                    let (vx, vy) =
992                        compute_drag_velocity(&drag.samples, std::time::Instant::now());
993                    h(DragPhase::End, 0.0, 0.0, vx, vy)
994                }
995            };
996            if let Some(msg) = end_msg {
997                let model = state.model.take().expect("model");
998                state.model = Some(A::update(model, msg, &self.handle));
999            }
1000            // Cache invalidado siempre — hover/drop pueden cambiar
1001            // y el modelo posiblemente mutó.
1002            state.last_render = None;
1003            state.window.request_redraw();
1004        }
1005    }
1006}