tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1
llimphi-ui/src/eventloop/input.rs
Historia
Quién tocó qué
crudo
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
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}