tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
1use super::*;
2
3/// Hit-test parametrizado por elegibilidad. Devuelve el índice del nodo
4/// más al frente (último en pre-orden) cuyo rect contiene `(x, y)` y para
5/// el cual `pred` devuelve `true`, respetando `clip`: si el punto cae
6/// afuera de un nodo con clip, el subárbol entero es invisible.
7///
8/// **Respeta `transform`**: igual que [`paint`], compone el afín acumulado
9/// de los ancestros (cada `transform` alrededor del centro del rect del
10/// nodo, convención CSS `transform-origin: 50% 50%`). El punto de pantalla
11/// `(x, y)` se lleva al espacio local del nodo invirtiendo ese afín, y se
12/// testea contra el rect sin transformar. Así un nodo rotado/escalado/
13/// trasladado recibe los clicks donde realmente se ve pintado (recorrido
14/// tipo Prezi, lienzos de tullpu, `@keyframes` de puriy). Un subárbol con
15/// afín singular (escala 0) es inalcanzable, igual que es invisible.
16pub fn hit_test_pred<Msg, F>(
17 mounted: &Mounted<Msg>,
18 computed: &ComputedLayout,
19 x: f32,
20 y: f32,
21 pred: F,
22) -> Option<usize>
23where
24 F: Fn(&MountedNode<Msg>) -> bool,
25{
26 let mut hit: Option<usize> = None;
27 // Segundo candidato, para los subárboles [`View::over`]: `paint` los SALTA
28 // y `paint_over_layers` los pinta DESPUÉS, así que quedan encima de todo lo
29 // demás aunque su lugar en el árbol sea anterior. Sin esto, un nodo base
30 // posterior en orden de árbol le roba el click a un botón que el usuario ve
31 // arriba (caso real: la barra de control de media es `.over(true)`).
32 // El z efectivo es: [nodos normales, orden de árbol] < [capas over, orden de
33 // árbol], así que basta un candidato aparte y preferirlo al final. Las capas
34 // over anidadas las pinta la externa (`paint_over_layers` salta a
35 // `subtree_end`), o sea que no agregan un nivel de z: alcanza con saber si
36 // estamos DENTRO de alguna.
37 let mut hit_over: Option<usize> = None;
38 let mut over_end: usize = 0;
39 let mut clip_stack: Vec<usize> = Vec::new();
40 // Espejo del stack de transformaciones de `paint`: `cur_xf` es el
41 // producto acumulado de los `transform` de los ancestros activos
42 // (local → pantalla). Vacío ⇒ identidad ⇒ camino directo sin invertir
43 // (cero costo para la abrumadora mayoría de árboles sin transform).
44 let mut xf_stack: Vec<(usize, Affine)> = Vec::new();
45 let mut cur_xf = Affine::IDENTITY;
46 let mut idx = 0;
47 while idx < mounted.nodes.len() {
48 while let Some(&end) = clip_stack.last() {
49 if idx >= end {
50 clip_stack.pop();
51 } else {
52 break;
53 }
54 }
55 while let Some(&(end, prev)) = xf_stack.last() {
56 if idx >= end {
57 cur_xf = prev;
58 xf_stack.pop();
59 } else {
60 break;
61 }
62 }
63 let node = &mounted.nodes[idx];
64 if node.over_layer && idx >= over_end {
65 over_end = node.subtree_end;
66 }
67 let Some(r) = computed.get(node.id) else {
68 idx += 1;
69 continue;
70 };
71 // Componé el transform de este nodo igual que `paint`, ANTES de
72 // resolver el punto local (su propio rect ya cae en el espacio
73 // transformado).
74 if let Some(centered) = resolve_node_transform(node.transform, node.transform_rel, node.transform_origin, r) {
75 xf_stack.push((node.subtree_end, cur_xf));
76 cur_xf *= centered;
77 }
78 // Punto en el espacio local del nodo. Sin transform activo, es el
79 // punto de pantalla tal cual. Con transform, se invierte el afín;
80 // si es singular (no invertible) el subárbol es inalcanzable.
81 let (lx, ly) = if xf_stack.is_empty() {
82 (x as f64, y as f64)
83 } else if cur_xf.determinant().abs() < 1e-9 {
84 idx = node.subtree_end;
85 continue;
86 } else {
87 let p = cur_xf.inverse() * Point::new(x as f64, y as f64);
88 (p.x, p.y)
89 };
90 let inside = lx >= r.x as f64
91 && lx < (r.x + r.w) as f64
92 && ly >= r.y as f64
93 && ly < (r.y + r.h) as f64;
94 if node.clip {
95 if !inside {
96 idx = node.subtree_end;
97 continue;
98 }
99 clip_stack.push(node.subtree_end);
100 }
101 if inside && pred(node) {
102 if idx < over_end {
103 hit_over = Some(idx);
104 } else {
105 hit = Some(idx);
106 }
107 }
108 idx += 1;
109 }
110 hit_over.or(hit)
111}
112
113/// Hit-test específico para clicks (incluye nodos draggables).
114pub fn hit_test_click<Msg>(
115 mounted: &Mounted<Msg>,
116 computed: &ComputedLayout,
117 x: f32,
118 y: f32,
119) -> Option<usize> {
120 hit_test_pred(mounted, computed, x, y, |n| {
121 n.on_click.is_some()
122 || n.on_click_at.is_some()
123 || n.on_click_screen.is_some()
124 || n.drag.is_some()
125 || n.drag_at.is_some()
126 || n.drag_velocity.is_some()
127 })
128}
129
130/// Hit-test específico para right-click. Sólo considera nodos que
131/// declararon `on_right_click` o `on_right_click_at` — un right-click
132/// sobre un nodo sin handler no hace nada (no se "filtra" al click
133/// izquierdo).
134pub fn hit_test_right_click<Msg>(
135 mounted: &Mounted<Msg>,
136 computed: &ComputedLayout,
137 x: f32,
138 y: f32,
139) -> Option<usize> {
140 hit_test_pred(mounted, computed, x, y, |n| {
141 n.on_right_click.is_some() || n.on_right_click_at.is_some() || n.on_right_click_screen.is_some()
142 })
143}
144
145/// Hit-test específico para middle-click. Mismo modelo que right-click:
146/// sólo nodos que declararon `on_middle_click` reaccionan.
147pub fn hit_test_middle_click<Msg>(
148 mounted: &Mounted<Msg>,
149 computed: &ComputedLayout,
150 x: f32,
151 y: f32,
152) -> Option<usize> {
153 hit_test_pred(mounted, computed, x, y, |n| n.on_middle_click.is_some())
154}
155
156/// Hit-test específico para hover (nodos con `hover_fill`).
157pub fn hit_test_hover<Msg>(
158 mounted: &Mounted<Msg>,
159 computed: &ComputedLayout,
160 x: f32,
161 y: f32,
162) -> Option<usize> {
163 hit_test_pred(mounted, computed, x, y, |n| n.hover_fill.is_some())
164}
165
166/// Hit-test específico para **tooltips** (nodos con `tooltip`). Separado del
167/// hover: un nodo puede declarar tooltip sin `hover_fill` (p. ej. los iconos
168/// fugaces de pata, que pintan por `paint_with` y no quieren tinte de hover) —
169/// con el predicado de hover su tooltip jamás se encontraba.
170pub fn hit_test_tooltip<Msg>(
171 mounted: &Mounted<Msg>,
172 computed: &ComputedLayout,
173 x: f32,
174 y: f32,
175) -> Option<usize> {
176 hit_test_pred(mounted, computed, x, y, |n| n.tooltip.is_some())
177}
178
179/// Hit-test para movimiento posicional del cursor (nodos con
180/// `on_pointer_move_at`). El runtime lo invoca en cada `CursorMoved` para
181/// reportar la posición local al nodo más al frente que lo declare.
182pub fn hit_test_pointer_move<Msg>(
183 mounted: &Mounted<Msg>,
184 computed: &ComputedLayout,
185 x: f32,
186 y: f32,
187) -> Option<usize> {
188 hit_test_pred(mounted, computed, x, y, |n| n.on_pointer_move_at.is_some())
189}
190
191/// Hit-test específico para la **forma del cursor**: devuelve el [`Cursor`]
192/// del nodo más al frente bajo el punto que declare uno. Como un hijo sin
193/// cursor no matchea el predicado, el cursor "cae" al ancestro más cercano que
194/// lo declare — herencia estilo CSS sin recorrer el árbol a mano. `None` =
195/// ningún nodo bajo el punto declara cursor (el runtime usa el default de la
196/// ventana). Lo invoca `llimphi-ui` en la transición de hover.
197pub fn hit_test_cursor<Msg>(
198 mounted: &Mounted<Msg>,
199 computed: &ComputedLayout,
200 x: f32,
201 y: f32,
202) -> Option<Cursor> {
203 hit_test_pred(mounted, computed, x, y, |n| n.cursor.is_some())
204 .and_then(|i| mounted.nodes[i].cursor)
205}
206
207/// Hit-test específico para drop targets (nodos con `on_drop`). Usado
208/// durante un drag activo para resaltar el destino y para invocar el
209/// handler al soltar.
210pub fn hit_test_drop<Msg>(
211 mounted: &Mounted<Msg>,
212 computed: &ComputedLayout,
213 x: f32,
214 y: f32,
215) -> Option<usize> {
216 hit_test_pred(mounted, computed, x, y, |n| n.on_drop.is_some())
217}
218
219/// Hit-test específico para áreas de scroll (nodos con `on_scroll`). El
220/// runtime lo usa al recibir la rueda: el nodo más al frente bajo el
221/// cursor con handler de scroll consume el evento antes del `on_wheel`
222/// global.
223pub fn hit_test_scroll<Msg>(
224 mounted: &Mounted<Msg>,
225 computed: &ComputedLayout,
226 x: f32,
227 y: f32,
228) -> Option<usize> {
229 hit_test_pred(mounted, computed, x, y, |n| n.on_scroll.is_some())
230}
231
232/// Cadena de **scroll anidado**: devuelve todos los nodos con `on_scroll`
233/// que contienen el punto, ordenados **front→back** (el primero es el más
234/// al frente, igual que [`hit_test_scroll`]; los siguientes son sus
235/// ancestros scrollables). El runtime itera la cadena al recibir la rueda
236/// y se queda con el primer handler que devuelva `Some`: si un scroll
237/// interno está en el extremo del eje y devuelve `None`, el evento "pasa"
238/// al ancestro scrollable más cercano (lista dentro de panel, etc.).
239/// Recorrido idéntico al de [`hit_test_pred`] pero acumulando todos los
240/// hits en vez de pisar.
241pub fn hit_test_scroll_chain<Msg>(
242 mounted: &Mounted<Msg>,
243 computed: &ComputedLayout,
244 x: f32,
245 y: f32,
246) -> Vec<usize> {
247 let mut chain: Vec<usize> = Vec::new();
248 let mut clip_stack: Vec<usize> = Vec::new();
249 let mut xf_stack: Vec<(usize, Affine)> = Vec::new();
250 let mut cur_xf = Affine::IDENTITY;
251 let mut idx = 0;
252 while idx < mounted.nodes.len() {
253 while let Some(&end) = clip_stack.last() {
254 if idx >= end {
255 clip_stack.pop();
256 } else {
257 break;
258 }
259 }
260 while let Some(&(end, prev)) = xf_stack.last() {
261 if idx >= end {
262 cur_xf = prev;
263 xf_stack.pop();
264 } else {
265 break;
266 }
267 }
268 let node = &mounted.nodes[idx];
269 let Some(r) = computed.get(node.id) else {
270 idx += 1;
271 continue;
272 };
273 if let Some(centered) = resolve_node_transform(node.transform, node.transform_rel, node.transform_origin, r) {
274 xf_stack.push((node.subtree_end, cur_xf));
275 cur_xf *= centered;
276 }
277 let (lx, ly) = if xf_stack.is_empty() {
278 (x as f64, y as f64)
279 } else if cur_xf.determinant().abs() < 1e-9 {
280 idx = node.subtree_end;
281 continue;
282 } else {
283 let p = cur_xf.inverse() * Point::new(x as f64, y as f64);
284 (p.x, p.y)
285 };
286 let inside = lx >= r.x as f64
287 && lx < (r.x + r.w) as f64
288 && ly >= r.y as f64
289 && ly < (r.y + r.h) as f64;
290 if node.clip {
291 if !inside {
292 idx = node.subtree_end;
293 continue;
294 }
295 clip_stack.push(node.subtree_end);
296 }
297 if inside && (node.on_scroll.is_some() || node.on_scroll_take.is_some()) {
298 chain.push(idx);
299 }
300 idx += 1;
301 }
302 // El recorrido es pre-orden, así que los ancestros aparecen primero y
303 // los hijos después. Para front→back necesitamos el orden inverso.
304 chain.reverse();
305 chain
306}
307
308/// Reparte un evento de rueda a lo largo de la cadena de scroll (front→back,
309/// de [`hit_test_scroll_chain`]). Para cada nodo:
310///
311/// - con [`on_scroll_take`](crate::View::on_scroll_take): consume lo que
312/// puede y el **sobrante** sigue al próximo de la cadena — un mismo evento
313/// puede mover al hijo hasta su tope Y al ancestro con el resto;
314/// - con `on_scroll` clásico: semántica histórica — `Some` consume TODO lo
315/// restante y corta; `None` deja pasar el delta completo.
316///
317/// Devuelve los `Msg` en orden (el runtime los aplica en secuencia sobre el
318/// `update`). Vacío = nadie consumió (el evento cae al `on_wheel` global).
319/// Pura respecto del árbol: no muta nada, testeable headless.
320pub fn consume_scroll_chain<Msg>(
321 mounted: &Mounted<Msg>,
322 chain: &[usize],
323 dx: f32,
324 dy: f32,
325) -> Vec<Msg> {
326 const EPS: f32 = 1e-4;
327 let mut msgs = Vec::new();
328 let (mut rx, mut ry) = (dx, dy);
329 for &idx in chain {
330 if rx.abs() <= EPS && ry.abs() <= EPS {
331 break;
332 }
333 let node = &mounted.nodes[idx];
334 if let Some(take) = &node.on_scroll_take {
335 let (msg, nx, ny) = take(rx, ry);
336 if let Some(m) = msg {
337 msgs.push(m);
338 }
339 // Defensa: un handler no puede "crear" delta ni invertir su
340 // signo — el sobrante se acota al delta que recibió.
341 rx = clamp_rest(nx, rx);
342 ry = clamp_rest(ny, ry);
343 } else if let Some(h) = &node.on_scroll {
344 if let Some(m) = h(rx, ry) {
345 msgs.push(m);
346 break;
347 }
348 }
349 }
350 msgs
351}
352
353/// Acota el sobrante declarado por un handler al rango `[0, delta]` (con el
354/// signo del delta): ni negativo del otro lado, ni mayor que lo recibido.
355pub(crate) fn clamp_rest(rest: f32, delta: f32) -> f32 {
356 if delta >= 0.0 {
357 rest.clamp(0.0, delta)
358 } else {
359 rest.clamp(delta, 0.0)
360 }
361}
362
363/// Hit-test específico para gestos de **escala** (pinch-to-zoom): el nodo más
364/// al frente bajo el punto que declaró un `on_scale`. Como un hijo sin handler
365/// no matchea el predicado, el gesto "cae" al ancestro más cercano que lo
366/// declare (un canvas grande zoomeable con widgets encima que no zoomean). El
367/// runtime lo invoca al recibir Ctrl+rueda o un pinch de trackpad. `None` =
368/// ningún nodo zoomeable bajo el cursor (el evento cae al scroll/`on_wheel`).
369pub fn hit_test_scale<Msg>(
370 mounted: &Mounted<Msg>,
371 computed: &ComputedLayout,
372 x: f32,
373 y: f32,
374) -> Option<usize> {
375 hit_test_pred(mounted, computed, x, y, |n| n.on_scale.is_some())
376}
377
378/// Hit-test específico para gestos de **rotación** (trackpad): el nodo más al
379/// frente bajo el punto que declaró un `on_rotate`. Análogo a
380/// [`hit_test_scale`]; el runtime lo invoca al recibir un `RotationGesture`.
381/// `None` = ningún nodo rotable bajo el cursor.
382pub fn hit_test_rotate<Msg>(
383 mounted: &Mounted<Msg>,
384 computed: &ComputedLayout,
385 x: f32,
386 y: f32,
387) -> Option<usize> {
388 hit_test_pred(mounted, computed, x, y, |n| n.on_rotate.is_some())
389}
390
391/// Hit-test para **doble-tap**: el nodo más al frente bajo el punto que
392/// declaró `on_double_tap`/`on_double_tap_at`. El runtime lo usa al detectar
393/// dos presses rápidos y cercanos.
394pub fn hit_test_double_tap<Msg>(
395 mounted: &Mounted<Msg>,
396 computed: &ComputedLayout,
397 x: f32,
398 y: f32,
399) -> Option<usize> {
400 hit_test_pred(mounted, computed, x, y, |n| {
401 n.on_double_tap.is_some() || n.on_double_tap_at.is_some()
402 })
403}
404
405/// Hit-test para **long-press**: el nodo más al frente bajo el punto que
406/// declaró `on_long_press`/`on_long_press_at`. El runtime lo usa al armar el
407/// gesto en el press (que vence por tiempo si no hay movimiento ni release).
408pub fn hit_test_long_press<Msg>(
409 mounted: &Mounted<Msg>,
410 computed: &ComputedLayout,
411 x: f32,
412 y: f32,
413) -> Option<usize> {
414 hit_test_pred(mounted, computed, x, y, |n| {
415 n.on_long_press.is_some() || n.on_long_press_at.is_some()
416 })
417}
418
419/// Hit-test para **ripple**: el nodo más al frente bajo el punto que declaró
420/// un [`Ripple`] (vía [`View::ripple`]). El runtime lo usa en el press para
421/// disparar la salpicadura. Aditivo — no compite con click/drag.
422pub fn hit_test_ripple<Msg>(
423 mounted: &Mounted<Msg>,
424 computed: &ComputedLayout,
425 x: f32,
426 y: f32,
427) -> Option<usize> {
428 hit_test_pred(mounted, computed, x, y, |n| n.ripple.is_some())
429}
430
431/// Hit-test para foco: el id `focusable` del nodo más al frente bajo el
432/// cursor (click-to-focus). `None` si no se clickeó nada enfocable.
433pub fn hit_test_focusable<Msg>(
434 mounted: &Mounted<Msg>,
435 computed: &ComputedLayout,
436 x: f32,
437 y: f32,
438) -> Option<u64> {
439 hit_test_pred(mounted, computed, x, y, |n| n.focusable.is_some())
440 .and_then(|i| mounted.nodes[i].focusable)
441}
442
443/// Hit-test para **selección de texto**: el índice del nodo de texto
444/// seleccionable (`text_select_key`) más al frente bajo el cursor. El runtime
445/// lo usa para arrancar/extender una selección; devuelve el índice (no la key)
446/// para que el caller acceda al `text` + rect del nodo. `None` si no hay texto
447/// seleccionable bajo el punto.
448pub fn hit_test_selectable<Msg>(
449 mounted: &Mounted<Msg>,
450 computed: &ComputedLayout,
451 x: f32,
452 y: f32,
453) -> Option<usize> {
454 hit_test_pred(mounted, computed, x, y, |n| n.text_select_key.is_some())
455}
456
457/// Ids enfocables en orden de Tab (pre-orden del árbol = orden de
458/// inserción de `Mounted::nodes`). Sólo nodos con rect computado
459/// (presentes en el layout). Es el orden DOM-like de tabulación.
460pub fn focus_order<Msg>(mounted: &Mounted<Msg>, computed: &ComputedLayout) -> Vec<u64> {
461 mounted
462 .nodes
463 .iter()
464 .filter_map(|n| {
465 n.focusable
466 .filter(|_| computed.get(n.id).is_some())
467 })
468 .collect()
469}
470
471/// Próximo id de foco al pulsar Tab (o Shift+Tab si `reverse`), dado el
472/// `order` (de [`focus_order`]) y el `current`. Envuelve en los extremos.
473/// Si no hay enfocables devuelve `None`; si `current` ya no existe en el
474/// orden, arranca por el primero (Tab) o el último (Shift+Tab).
475pub fn next_focus(order: &[u64], current: Option<u64>, reverse: bool) -> Option<u64> {
476 if order.is_empty() {
477 return None;
478 }
479 let n = order.len();
480 let pos = current.and_then(|c| order.iter().position(|&id| id == c));
481 let next_idx = match pos {
482 Some(i) => {
483 if reverse {
484 (i + n - 1) % n
485 } else {
486 (i + 1) % n
487 }
488 }
489 None => {
490 if reverse {
491 n - 1
492 } else {
493 0
494 }
495 }
496 };
497 Some(order[next_idx])
498}
499