tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 14d76bd4af13b219f0b162a54f09e784dea9aa8d
llimphi-compositor/src/render.rs
Historia
Quién tocó qué
crudo
feat: llimphi standalone — framework UI soberano extraído del monorepo
1use super::*;
2
3pub fn mount<Msg: Clone>(layout: &mut LayoutTree, v: View<Msg>) -> Mounted<Msg> {
4 let mut nodes = Vec::new();
5 let mut text_measures = std::collections::HashMap::new();
6 let root = mount_recursive(layout, v, &mut nodes, &mut text_measures);
7 Mounted { root, nodes, text_measures }
8}
9
10/// Mount en pre-orden directo sobre `out`: pusheamos el padre como
11/// placeholder (id real desconocido hasta crear el taffy node), recursamos
12/// hijos sobre el mismo `out`, y al volver completamos `id` + `subtree_end`.
13pub fn mount_recursive<Msg: Clone>(
14 layout: &mut LayoutTree,
15 v: View<Msg>,
16 out: &mut Vec<MountedNode<Msg>>,
17 text_measures: &mut std::collections::HashMap<NodeId, TextMeasure>,
18) -> NodeId {
19 let View {
20 style,
21 fill,
22 hover_fill,
23 radius,
24 text,
25 image,
26 painter,
27 gpu_painter,
28 on_click,
29 on_click_at,
30 on_right_click,
31 on_right_click_at,
32 on_middle_click,
33 drag,
34 drag_at,
35 drag_payload,
36 on_drop,
37 drop_hover_fill,
38 clip,
39 on_pointer_enter,
40 on_pointer_leave,
41 on_scroll,
42 focusable,
43 alpha,
44 transform,
45 tooltip,
46 children,
47 } = v;
48 let parent_idx = out.len();
49 out.push(MountedNode {
50 id: NodeId::new(0), // placeholder, lo sobreescribimos abajo
51 fill,
52 hover_fill,
53 radius,
54 text,
55 image,
56 painter,
57 gpu_painter,
58 on_click,
59 on_click_at,
60 on_right_click,
61 on_right_click_at,
62 on_middle_click,
63 drag,
64 drag_at,
65 drag_payload,
66 on_drop,
67 drop_hover_fill,
68 clip,
69 on_pointer_enter,
70 on_pointer_leave,
71 on_scroll,
72 focusable,
73 alpha,
74 transform,
75 tooltip,
76 subtree_end: 0,
77 });
78 let mut child_ids = Vec::with_capacity(children.len());
79 for child in children {
80 child_ids.push(mount_recursive(layout, child, out, text_measures));
81 }
82 let id = if child_ids.is_empty() {
83 layout.leaf(style).expect("layout leaf")
84 } else {
85 layout.node(style, &child_ids).expect("layout node")
86 };
87 out[parent_idx].id = id;
88 out[parent_idx].subtree_end = out.len();
89 // Hoja de texto uniforme: registrá su contenido para que el runtime lo
90 // mida con parley. El texto multicolor (`runs`) lo dimensiona el caller
91 // (editor: un nodo por línea), así que no lo medimos acá.
92 if child_ids.is_empty() {
93 if let Some(text) = out[parent_idx].text.as_ref() {
94 if text.runs.is_none() {
95 text_measures.insert(
96 id,
97 TextMeasure {
98 content: text.content.clone(),
99 size_px: text.size_px,
100 alignment: text.alignment,
101 italic: text.italic,
102 font_family: text.font_family.clone(),
103 line_height: text.line_height,
104 },
105 );
106 }
107 }
108 }
109 id
110}
111
112/// Mide una hoja de texto para taffy: shaping + line-break con parley contra
113/// el ancho disponible, devolviendo el bounding box. Si el ancho ya está
114/// resuelto (`known.width`) se usa ese; si no, se deriva del `available`
115/// (Definite → ese ancho; MaxContent → sin límite = una línea; MinContent →
116/// 0 = envuelve a la palabra más ancha). El `line_height` sale del propio
117/// `TextMeasure`, el mismo que usa `paint`, así medida y pintado coinciden.
118pub fn measure_text_node(
119 ts: &mut llimphi_text::Typesetter,
120 tm: &TextMeasure,
121 known: llimphi_layout::taffy::Size<Option<f32>>,
122 available: llimphi_layout::taffy::Size<llimphi_layout::taffy::AvailableSpace>,
123) -> llimphi_layout::taffy::Size<f32> {
124 use llimphi_layout::taffy::AvailableSpace;
125 let max_width: Option<f32> = known.width.or(match available.width {
126 AvailableSpace::Definite(w) => Some(w),
127 AvailableSpace::MaxContent => None,
128 AvailableSpace::MinContent => Some(0.0),
129 });
130 let block = llimphi_text::TextBlock {
131 text: &tm.content,
132 size_px: tm.size_px,
133 color: Color::BLACK,
134 origin: (0.0, 0.0),
135 max_width,
136 alignment: tm.alignment,
137 line_height: tm.line_height,
138 italic: tm.italic,
139 font_family: tm.font_family.clone(),
140 };
141 let m = llimphi_text::measure(ts, &block);
142 llimphi_layout::taffy::Size { width: m.width, height: m.height }
143}
144
145pub fn paint<Msg>(
146 scene: &mut vello::Scene,
147 mounted: &Mounted<Msg>,
148 computed: &ComputedLayout,
149 typesetter: &mut llimphi_text::Typesetter,
150 hover_idx: Option<usize>,
151 drop_hover_idx: Option<usize>,
152) {
153 // Stack de subtree_end de los `push_layer` activos (clip y/o alpha).
154 // Vello requiere pop_layer en orden LIFO estricto, así que mantenemos
155 // un único stack común y popeamos en el orden en que se pushearon.
156 // Dos entradas con el mismo `subtree_end` (alpha + clip sobre el
157 // mismo nodo) se cierran en el orden inverso al push.
158 let mut layer_stack: Vec<usize> = Vec::new();
159 // Stack de transformaciones afines de subtree. Cada entrada guarda el
160 // `subtree_end` y la `cur_xf` previa para restaurarla al salir del
161 // subárbol. `cur_xf` es el producto acumulado de todos los `transform`
162 // de los ancestros activos — se multiplica en cada draw call. Cuando
163 // ningún nodo transforma, queda en `IDENTITY` y el paint es idéntico
164 // al previo (cero regresión).
165 let mut xf_stack: Vec<(usize, Affine)> = Vec::new();
166 let mut cur_xf = Affine::IDENTITY;
167 for (idx, node) in mounted.nodes.iter().enumerate() {
168 // Cierre de capas que ya quedaron atrás (idx ≥ subtree_end).
169 while let Some(&end) = layer_stack.last() {
170 if idx >= end {
171 scene.pop_layer();
172 layer_stack.pop();
173 } else {
174 break;
175 }
176 }
177 // Restaurá la transformación al salir de subárboles transformados.
178 while let Some(&(end, prev)) = xf_stack.last() {
179 if idx >= end {
180 cur_xf = prev;
181 xf_stack.pop();
182 } else {
183 break;
184 }
185 }
186 let Some(r) = computed.get(node.id) else {
187 continue;
188 };
189 // Transform CSS del nodo: se aplica alrededor del centro de su rect
190 // (`transform-origin: 50% 50%`) y se compone sobre la del padre. Se
191 // empuja ANTES del alpha/fill para que toda la pintura del subtree
192 // (incl. la capa de alpha y el clip) caiga en el espacio transformado.
193 if let Some(local) = node.transform {
194 let cx = (r.x + r.w * 0.5) as f64;
195 let cy = (r.y + r.h * 0.5) as f64;
196 let centered =
197 Affine::translate((cx, cy)) * local * Affine::translate((-cx, -cy));
198 xf_stack.push((node.subtree_end, cur_xf));
199 cur_xf *= centered;
200 }
201 // Alpha de subtree: push ANTES de cualquier paint de este nodo
202 // para que fill/text/image/painter/children entren en la misma
203 // capa y se compongan juntos al alfa indicado. Si el nodo tiene
204 // hijos, su `subtree_end > idx + 1` y la capa permanece abierta
205 // hasta que el loop alcance el primer índice fuera del subárbol.
206 // Para nodos hoja con alpha el push y el pop son consecutivos —
207 // funcionalmente equivalente a multiplicar el alpha del fill,
208 // pero permite usar el mismo API sin distinguir hoja vs rama.
209 if let Some(a) = node.alpha {
210 let rect = KurboRect::new(
211 r.x as f64,
212 r.y as f64,
213 (r.x + r.w) as f64,
214 (r.y + r.h) as f64,
215 );
216 scene.push_layer(Mix::Normal, a, cur_xf, &rect);
217 layer_stack.push(node.subtree_end);
218 }
219 // Prioridad de pintura: drop-hover (drag activo) > hover normal >
220 // fill base. Solo aplica el override si el slot correspondiente
221 // está poblado; el siguiente cae como fallback.
222 let effective_fill = if Some(idx) == drop_hover_idx {
223 node.drop_hover_fill.or(node.hover_fill).or(node.fill)
224 } else if Some(idx) == hover_idx {
225 node.hover_fill.or(node.fill)
226 } else {
227 node.fill
228 };
229 if let Some(color) = effective_fill {
230 let rr = RoundedRect::new(
231 r.x as f64,
232 r.y as f64,
233 (r.x + r.w) as f64,
234 (r.y + r.h) as f64,
235 node.radius,
236 );
237 scene.fill(Fill::NonZero, cur_xf, color, None, &rr);
238 }
239 if let Some(image) = node.image.as_ref() {
240 // Aspect-fit centrado: el min de las dos escalas ocupa
241 // todo el rect en el eje más restrictivo y deja banda en
242 // el otro. Defensivo: envolvemos en push_layer/pop_layer
243 // con el rect del nodo para que, aunque el caller pida
244 // un layout mal-dimensionado, la imagen nunca pinte fuera
245 // del nodo (visualmente preferible a un overflow opaco).
246 if image.width > 0 && image.height > 0 && r.w > 0.0 && r.h > 0.0 {
247 let sx = r.w as f64 / image.width as f64;
248 let sy = r.h as f64 / image.height as f64;
249 let s = sx.min(sy);
250 let disp_w = image.width as f64 * s;
251 let disp_h = image.height as f64 * s;
252 let tx = r.x as f64 + (r.w as f64 - disp_w) * 0.5;
253 let ty = r.y as f64 + (r.h as f64 - disp_h) * 0.5;
254 let transform = Affine::translate((tx, ty)) * Affine::scale(s);
255 let node_rect = KurboRect::new(
256 r.x as f64,
257 r.y as f64,
258 (r.x + r.w) as f64,
259 (r.y + r.h) as f64,
260 );
261 scene.push_layer(Mix::Clip, 1.0, cur_xf, &node_rect);
262 scene.draw_image(image, cur_xf * transform);
263 scene.pop_layer();
264 }
265 }
266 if let Some(painter) = node.painter.as_ref() {
267 (painter)(
268 scene,
269 typesetter,
270 PaintRect {
271 x: r.x,
272 y: r.y,
273 w: r.w,
274 h: r.h,
275 },
276 );
277 }
278 if let Some(text) = node.text.as_ref() {
279 if let Some(runs) = text.runs.as_ref() {
280 // Texto multicolor (syntax highlighting): una sola pasada de
281 // shaping con color por rango, anclado arriba-izquierda. Cae
282 // por el flujo normal (clip/alpha se cierran como siempre).
283 let layout = typesetter.layout_runs(
284 &text.content,
285 text.size_px,
286 text.color,
287 runs,
288 text.alignment,
289 text.line_height,
290 );
291 llimphi_text::draw_layout_runs(scene, &layout, (r.x as f64, r.y as f64));
292 } else {
293 // Parley resuelve la alineación horizontal vía max_width +
294 // alignment. Para Center también centramos verticalmente; para
295 // Start/End/Justify anclamos arriba (párrafo/editor).
296 let block = llimphi_text::TextBlock {
297 text: &text.content,
298 size_px: text.size_px,
299 color: text.color,
300 origin: (r.x as f64, r.y as f64),
301 max_width: Some(r.w),
302 alignment: text.alignment,
303 line_height: text.line_height,
304 italic: text.italic,
305 font_family: text.font_family.clone(),
306 };
307 // Shaping una sola vez: el `Layout` retornado se reusa para
308 // medir (cuando hay centrado vertical) y para pintar.
309 let layout = llimphi_text::layout_block(typesetter, &block);
310 let origin =
311 if matches!(text.alignment, llimphi_text::Alignment::Center) {
312 let m = llimphi_text::measurement(&layout);
313 (
314 r.x as f64,
315 r.y as f64 + ((r.h - m.height) as f64 * 0.5).max(0.0),
316 )
317 } else {
318 block.origin
319 };
320 llimphi_text::draw_layout_xf(
321 scene,
322 &layout,
323 text.color,
324 cur_xf * Affine::translate(origin),
325 );
326 }
327 }
328 if node.clip {
329 let clip_rect = KurboRect::new(
330 r.x as f64,
331 r.y as f64,
332 (r.x + r.w) as f64,
333 (r.y + r.h) as f64,
334 );
335 scene.push_layer(Mix::Clip, 1.0, cur_xf, &clip_rect);
336 layer_stack.push(node.subtree_end);
337 }
338 }
339 // Cerrá capas (clip + alpha) que llegaron al final sin pop intermedio.
340 while layer_stack.pop().is_some() {
341 scene.pop_layer();
342 }
343}
344
345/// Pasada GPU directo: recorre el `Mounted` en pre-orden DFS (mismo orden
346/// que [`paint`]) e invoca cada `gpu_painter` con el encoder y la
347/// `TextureView` del frame. Se ejecuta DESPUÉS de la pasada vello — la
348/// intermediate ya tiene fill/image/painter/text encima cuando los
349/// callbacks corren, así que su `LoadOp` debe ser `Load`. Devuelve si
350/// se invocó al menos un painter (para que el caller decida si vale la
351/// pena finalizar y submitir el encoder).
352/// `true` si algún nodo del árbol registró un `gpu_painter` (p. ej. el video
353/// de media vía `gpu_paint_with`). El eventloop lo usa para decidir si la
354/// capa de overlay necesita componerse aparte (sobre el contenido gpu) en vez
355/// de pintarse en la escena principal.
356pub fn has_gpu_painter<Msg>(mounted: &Mounted<Msg>) -> bool {
357 mounted.nodes.iter().any(|n| n.gpu_painter.is_some())
358}
359
360pub fn paint_gpu<Msg>(
361 mounted: &Mounted<Msg>,
362 computed: &ComputedLayout,
363 device: &wgpu::Device,
364 queue: &wgpu::Queue,
365 encoder: &mut wgpu::CommandEncoder,
366 view: &wgpu::TextureView,
367 viewport: (u32, u32),
368) -> bool {
369 let mut any = false;
370 for node in &mounted.nodes {
371 let Some(painter) = node.gpu_painter.as_ref() else {
372 continue;
373 };
374 let Some(r) = computed.get(node.id) else {
375 continue;
376 };
377 (painter)(
378 device,
379 queue,
380 encoder,
381 view,
382 PaintRect {
383 x: r.x,
384 y: r.y,
385 w: r.w,
386 h: r.h,
387 },
388 viewport,
389 );
390 any = true;
391 }
392 any
393}
394
395/// Hit-test parametrizado por elegibilidad. Devuelve el índice del nodo
396/// más al frente (último en pre-orden) cuyo rect contiene `(x, y)` y para
397/// el cual `pred` devuelve `true`, respetando `clip`: si el punto cae
398/// afuera de un nodo con clip, el subárbol entero es invisible.
399///
400/// **Respeta `transform`**: igual que [`paint`], compone el afín acumulado
401/// de los ancestros (cada `transform` alrededor del centro del rect del
402/// nodo, convención CSS `transform-origin: 50% 50%`). El punto de pantalla
403/// `(x, y)` se lleva al espacio local del nodo invirtiendo ese afín, y se
404/// testea contra el rect sin transformar. Así un nodo rotado/escalado/
405/// trasladado recibe los clicks donde realmente se ve pintado (recorrido
406/// tipo Prezi, lienzos de tullpu, `@keyframes` de puriy). Un subárbol con
407/// afín singular (escala 0) es inalcanzable, igual que es invisible.
408pub fn hit_test_pred<Msg, F>(
409 mounted: &Mounted<Msg>,
410 computed: &ComputedLayout,
411 x: f32,
412 y: f32,
413 pred: F,
414) -> Option<usize>
415where
416 F: Fn(&MountedNode<Msg>) -> bool,
417{
418 let mut hit: Option<usize> = None;
419 let mut clip_stack: Vec<usize> = Vec::new();
420 // Espejo del stack de transformaciones de `paint`: `cur_xf` es el
421 // producto acumulado de los `transform` de los ancestros activos
422 // (local → pantalla). Vacío ⇒ identidad ⇒ camino directo sin invertir
423 // (cero costo para la abrumadora mayoría de árboles sin transform).
424 let mut xf_stack: Vec<(usize, Affine)> = Vec::new();
425 let mut cur_xf = Affine::IDENTITY;
426 let mut idx = 0;
427 while idx < mounted.nodes.len() {
428 while let Some(&end) = clip_stack.last() {
429 if idx >= end {
430 clip_stack.pop();
431 } else {
432 break;
433 }
434 }
435 while let Some(&(end, prev)) = xf_stack.last() {
436 if idx >= end {
437 cur_xf = prev;
438 xf_stack.pop();
439 } else {
440 break;
441 }
442 }
443 let node = &mounted.nodes[idx];
444 let Some(r) = computed.get(node.id) else {
445 idx += 1;
446 continue;
447 };
448 // Componé el transform de este nodo igual que `paint`, ANTES de
449 // resolver el punto local (su propio rect ya cae en el espacio
450 // transformado).
451 if let Some(local) = node.transform {
452 let cx = (r.x + r.w * 0.5) as f64;
453 let cy = (r.y + r.h * 0.5) as f64;
454 let centered =
455 Affine::translate((cx, cy)) * local * Affine::translate((-cx, -cy));
456 xf_stack.push((node.subtree_end, cur_xf));
457 cur_xf *= centered;
458 }
459 // Punto en el espacio local del nodo. Sin transform activo, es el
460 // punto de pantalla tal cual. Con transform, se invierte el afín;
461 // si es singular (no invertible) el subárbol es inalcanzable.
462 let (lx, ly) = if xf_stack.is_empty() {
463 (x as f64, y as f64)
464 } else if cur_xf.determinant().abs() < 1e-9 {
465 idx = node.subtree_end;
466 continue;
467 } else {
468 let p = cur_xf.inverse() * Point::new(x as f64, y as f64);
469 (p.x, p.y)
470 };
471 let inside = lx >= r.x as f64
472 && lx < (r.x + r.w) as f64
473 && ly >= r.y as f64
474 && ly < (r.y + r.h) as f64;
475 if node.clip {
476 if !inside {
477 idx = node.subtree_end;
478 continue;
479 }
480 clip_stack.push(node.subtree_end);
481 }
482 if inside && pred(node) {
483 hit = Some(idx);
484 }
485 idx += 1;
486 }
487 hit
488}
489
490/// Hit-test específico para clicks (incluye nodos draggables).
491pub fn hit_test_click<Msg>(
492 mounted: &Mounted<Msg>,
493 computed: &ComputedLayout,
494 x: f32,
495 y: f32,
496) -> Option<usize> {
497 hit_test_pred(mounted, computed, x, y, |n| {
498 n.on_click.is_some()
499 || n.on_click_at.is_some()
500 || n.drag.is_some()
501 || n.drag_at.is_some()
502 })
503}
504
505/// Hit-test específico para right-click. Sólo considera nodos que
506/// declararon `on_right_click` o `on_right_click_at` — un right-click
507/// sobre un nodo sin handler no hace nada (no se "filtra" al click
508/// izquierdo).
509pub fn hit_test_right_click<Msg>(
510 mounted: &Mounted<Msg>,
511 computed: &ComputedLayout,
512 x: f32,
513 y: f32,
514) -> Option<usize> {
515 hit_test_pred(mounted, computed, x, y, |n| {
516 n.on_right_click.is_some() || n.on_right_click_at.is_some()
517 })
518}
519
520/// Hit-test específico para middle-click. Mismo modelo que right-click:
521/// sólo nodos que declararon `on_middle_click` reaccionan.
522pub fn hit_test_middle_click<Msg>(
523 mounted: &Mounted<Msg>,
524 computed: &ComputedLayout,
525 x: f32,
526 y: f32,
527) -> Option<usize> {
528 hit_test_pred(mounted, computed, x, y, |n| n.on_middle_click.is_some())
529}
530
531/// Hit-test específico para hover (nodos con `hover_fill`).
532pub fn hit_test_hover<Msg>(
533 mounted: &Mounted<Msg>,
534 computed: &ComputedLayout,
535 x: f32,
536 y: f32,
537) -> Option<usize> {
538 hit_test_pred(mounted, computed, x, y, |n| n.hover_fill.is_some())
539}
540
541/// Hit-test específico para drop targets (nodos con `on_drop`). Usado
542/// durante un drag activo para resaltar el destino y para invocar el
543/// handler al soltar.
544pub fn hit_test_drop<Msg>(
545 mounted: &Mounted<Msg>,
546 computed: &ComputedLayout,
547 x: f32,
548 y: f32,
549) -> Option<usize> {
550 hit_test_pred(mounted, computed, x, y, |n| n.on_drop.is_some())
551}
552
553/// Hit-test específico para áreas de scroll (nodos con `on_scroll`). El
554/// runtime lo usa al recibir la rueda: el nodo más al frente bajo el
555/// cursor con handler de scroll consume el evento antes del `on_wheel`
556/// global.
557pub fn hit_test_scroll<Msg>(
558 mounted: &Mounted<Msg>,
559 computed: &ComputedLayout,
560 x: f32,
561 y: f32,
562) -> Option<usize> {
563 hit_test_pred(mounted, computed, x, y, |n| n.on_scroll.is_some())
564}
565
566/// Hit-test para foco: el id `focusable` del nodo más al frente bajo el
567/// cursor (click-to-focus). `None` si no se clickeó nada enfocable.
568pub fn hit_test_focusable<Msg>(
569 mounted: &Mounted<Msg>,
570 computed: &ComputedLayout,
571 x: f32,
572 y: f32,
573) -> Option<u64> {
574 hit_test_pred(mounted, computed, x, y, |n| n.focusable.is_some())
575 .and_then(|i| mounted.nodes[i].focusable)
576}
577
578/// Ids enfocables en orden de Tab (pre-orden del árbol = orden de
579/// inserción de `Mounted::nodes`). Sólo nodos con rect computado
580/// (presentes en el layout). Es el orden DOM-like de tabulación.
581pub fn focus_order<Msg>(mounted: &Mounted<Msg>, computed: &ComputedLayout) -> Vec<u64> {
582 mounted
583 .nodes
584 .iter()
585 .filter_map(|n| {
586 n.focusable
587 .filter(|_| computed.get(n.id).is_some())
588 })
589 .collect()
590}
591
592/// Próximo id de foco al pulsar Tab (o Shift+Tab si `reverse`), dado el
593/// `order` (de [`focus_order`]) y el `current`. Envuelve en los extremos.
594/// Si no hay enfocables devuelve `None`; si `current` ya no existe en el
595/// orden, arranca por el primero (Tab) o el último (Shift+Tab).
596pub fn next_focus(order: &[u64], current: Option<u64>, reverse: bool) -> Option<u64> {
597 if order.is_empty() {
598 return None;
599 }
600 let n = order.len();
601 let pos = current.and_then(|c| order.iter().position(|&id| id == c));
602 let next_idx = match pos {
603 Some(i) => {
604 if reverse {
605 (i + n - 1) % n
606 } else {
607 (i + 1) % n
608 }
609 }
610 None => {
611 if reverse {
612 n - 1
613 } else {
614 0
615 }
616 }
617 };
618 Some(order[next_idx])
619}
620
621#[cfg(test)]
622mod tests {
623 use crate::{hit_test_click, mount, View};
624 use llimphi_layout::taffy::prelude::*;
625 use llimphi_layout::{LayoutTree, Style};
626 use vello::kurbo::Affine;
627
628 /// Un hijo clickeable de 100×100 anclado arriba-izquierda. Devuelve
629 /// `(mounted, computed)` ya layouteados sobre un viewport 400×400.
630 fn fixture(
631 transform: Option<Affine>,
632 ) -> (crate::Mounted<()>, llimphi_layout::ComputedLayout) {
633 let mut child = View::<()>::new(Style {
634 size: Size {
635 width: length(100.0),
636 height: length(100.0),
637 },
638 ..Default::default()
639 })
640 .on_click(());
641 if let Some(xf) = transform {
642 child = child.transform(xf);
643 }
644 let root = View::<()>::new(Style {
645 align_items: Some(AlignItems::FlexStart),
646 justify_content: Some(JustifyContent::FlexStart),
647 ..Default::default()
648 })
649 .children(vec![child]);
650 let mut layout = LayoutTree::new();
651 let mounted = mount(&mut layout, root);
652 let computed = layout.compute(mounted.root, (400.0, 400.0)).expect("layout");
653 (mounted, computed)
654 }
655
656 #[test]
657 fn sin_transform_el_hit_cae_en_el_rect() {
658 let (m, c) = fixture(None);
659 assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), Some(1)); // dentro
660 assert_eq!(hit_test_click(&m, &c, 250.0, 50.0), None); // fuera
661 }
662
663 #[test]
664 fn traslacion_mueve_el_area_clickeable() {
665 // El nodo se ve corrido +200px en x; el click debe seguirlo.
666 let (m, c) = fixture(Some(Affine::translate((200.0, 0.0))));
667 assert_eq!(hit_test_click(&m, &c, 250.0, 50.0), Some(1)); // donde se ve
668 assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), None); // ya no donde estaba
669 }
670
671 #[test]
672 fn rotacion_180_grados_alrededor_del_centro() {
673 // Rotar 180° alrededor del centro (50,50) deja el rect en su sitio:
674 // una esquina mapea a la opuesta, pero el cuadrado cubre lo mismo.
675 let (m, c) = fixture(Some(Affine::rotate(std::f64::consts::PI)));
676 assert_eq!(hit_test_click(&m, &c, 10.0, 10.0), Some(1));
677 assert_eq!(hit_test_click(&m, &c, 90.0, 90.0), Some(1));
678 assert_eq!(hit_test_click(&m, &c, 150.0, 150.0), None);
679 }
680
681 #[test]
682 fn escala_cero_es_inalcanzable() {
683 let (m, c) = fixture(Some(Affine::scale(0.0)));
684 assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), None);
685 }
686
687 #[test]
688 fn tab_traversal_envuelve_en_los_extremos() {
689 use crate::next_focus;
690 let order = [10u64, 20, 30];
691 // Avanza.
692 assert_eq!(next_focus(&order, Some(10), false), Some(20));
693 assert_eq!(next_focus(&order, Some(30), false), Some(10)); // wrap
694 // Retrocede (Shift+Tab).
695 assert_eq!(next_focus(&order, Some(20), true), Some(10));
696 assert_eq!(next_focus(&order, Some(10), true), Some(30)); // wrap
697 // Sin foco previo: Tab → primero, Shift+Tab → último.
698 assert_eq!(next_focus(&order, None, false), Some(10));
699 assert_eq!(next_focus(&order, None, true), Some(30));
700 // Foco obsoleto (id que ya no está) → arranca por el extremo.
701 assert_eq!(next_focus(&order, Some(99), false), Some(10));
702 // Lista vacía.
703 assert_eq!(next_focus(&[], Some(10), false), None);
704 }
705}