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#[cfg(test)]
4mod repaint_boundary_tests {
5 use super::*;
6 use crate::{mount, View};
7 use llimphi_layout::taffy::prelude::*;
8 use llimphi_layout::LayoutTree;
9
10 fn mount_compute(v: View<()>, vp: (f32, f32)) -> (Mounted<()>, ComputedLayout) {
11 let mut lt = LayoutTree::new();
12 let m = mount(&mut lt, v);
13 let c = lt.compute(m.root, vp).expect("layout");
14 (m, c)
15 }
16
17 /// Un boundary top-level se recolecta; uno bajo un `clip` no (no está limpio).
18 #[test]
19 fn recolecta_solo_boundaries_limpios() {
20 let limpio = View::<()>::new(Style::default())
21 .repaint_boundary(1)
22 .children(vec![View::new(Style::default()).fill(Color::WHITE)]);
23 let (m, _) = mount_compute(
24 View::<()>::new(Style::default()).children(vec![limpio]),
25 (200.0, 200.0),
26 );
27 assert_eq!(collect_clean_boundaries(&m).len(), 1);
28
29 // Ahora el mismo boundary DENTRO de un nodo con clip → no limpio.
30 let dentro_de_clip = View::<()>::new(Style::default()).clip(true).children(vec![
31 View::<()>::new(Style::default())
32 .repaint_boundary(2)
33 .children(vec![View::new(Style::default()).fill(Color::WHITE)]),
34 ]);
35 let (m2, _) = mount_compute(dentro_de_clip, (200.0, 200.0));
36 assert_eq!(collect_clean_boundaries(&m2).len(), 0);
37 }
38
39 /// El gate rechaza subárboles con painter / gradiente / hover adentro.
40 #[test]
41 fn gate_rechaza_no_cacheables() {
42 // painter → no cacheable.
43 let con_painter = View::<()>::new(Style::default())
44 .paint_with(|_s, _t, _r| {});
45 let (m, _) = mount_compute(
46 View::<()>::new(Style::default())
47 .repaint_boundary(1)
48 .children(vec![con_painter]),
49 (100.0, 100.0),
50 );
51 assert!(!boundary_cacheable(&m, 0, m.nodes.len(), None, None));
52
53 // Fill/texto puro → cacheable.
54 let puro = View::<()>::new(Style::default())
55 .repaint_boundary(1)
56 .fill(Color::WHITE)
57 .children(vec![View::new(Style::default()).fill(Color::BLACK)]);
58 let (m2, _) = mount_compute(puro, (100.0, 100.0));
59 assert!(boundary_cacheable(&m2, 0, m2.nodes.len(), None, None));
60 // …salvo que el hover caiga adentro.
61 assert!(!boundary_cacheable(&m2, 0, m2.nodes.len(), Some(1), None));
62 }
63
64 /// El hash es estable ante un rebuild idéntico, cambia con el fill, y NO
65 /// cambia bajo una traslación pura del boundary (rects relativos).
66 #[test]
67 fn hash_estable_sensible_y_translation_invariante() {
68 let build = |fill: Color, x: f32| {
69 View::<()>::new(Style {
70 inset: Rect { left: length(x), top: length(0.0), right: auto(), bottom: auto() },
71 position: Position::Absolute,
72 size: Size { width: length(50.0), height: length(50.0) },
73 ..Default::default()
74 })
75 .repaint_boundary(1)
76 .fill(fill)
77 .children(vec![View::new(Style {
78 size: Size { width: length(20.0), height: length(20.0) },
79 ..Default::default()
80 })
81 .fill(Color::BLACK)])
82 };
83 let vp = (300.0, 300.0);
84 let (m_a, c_a) = mount_compute(build(Color::WHITE, 0.0), vp);
85 let o_a = c_a.get(m_a.nodes[0].id).map(|r| (r.x, r.y)).unwrap();
86 let h_a = boundary_hash(&m_a, &c_a, 0, m_a.nodes.len(), o_a);
87
88 // Rebuild idéntico → mismo hash.
89 let (m_b, c_b) = mount_compute(build(Color::WHITE, 0.0), vp);
90 let o_b = c_b.get(m_b.nodes[0].id).map(|r| (r.x, r.y)).unwrap();
91 assert_eq!(h_a, boundary_hash(&m_b, &c_b, 0, m_b.nodes.len(), o_b));
92
93 // Cambió el fill → hash distinto.
94 let (m_c, c_c) = mount_compute(build(Color::from_rgba8(1, 2, 3, 255), 0.0), vp);
95 let o_c = c_c.get(m_c.nodes[0].id).map(|r| (r.x, r.y)).unwrap();
96 assert_ne!(h_a, boundary_hash(&m_c, &c_c, 0, m_c.nodes.len(), o_c));
97
98 // Trasladado 100px en x → MISMO hash (rects relativos al origen).
99 let (m_d, c_d) = mount_compute(build(Color::WHITE, 100.0), vp);
100 let o_d = c_d.get(m_d.nodes[0].id).map(|r| (r.x, r.y)).unwrap();
101 assert_eq!(h_a, boundary_hash(&m_d, &c_d, 0, m_d.nodes.len(), o_d));
102 }
103
104 /// End-to-end (CPU, sin GPU): pintar dos veces el mismo árbol con boundary
105 /// da 1 miss + 1 hit; cambiar el fill fuerza otro miss.
106 #[test]
107 fn paint_cached_hit_y_miss() {
108 let mut ts = llimphi_text::Typesetter::new();
109 let mut cache = RepaintCache::new();
110 let build = |fill: Color| {
111 View::<()>::new(Style {
112 size: Size { width: percent(1.0), height: percent(1.0) },
113 ..Default::default()
114 })
115 .children(vec![View::<()>::new(Style {
116 size: Size { width: length(80.0), height: length(80.0) },
117 ..Default::default()
118 })
119 .repaint_boundary(7)
120 .fill(fill)
121 .children(vec![View::new(Style {
122 size: Size { width: length(30.0), height: length(30.0) },
123 ..Default::default()
124 })
125 .fill(Color::BLACK)])])
126 };
127 let vp = (200.0, 200.0);
128
129 let (m1, c1) = mount_compute(build(Color::WHITE), vp);
130 let mut s1 = crate::Lienzo::new();
131 paint_cached(&mut s1, &m1, &c1, &mut ts, None, None, &mut cache);
132 assert_eq!((cache.hits, cache.misses), (0, 1), "primer frame = miss");
133 assert_eq!(cache.len(), 1);
134
135 // Mismo contenido → hit.
136 let (m2, c2) = mount_compute(build(Color::WHITE), vp);
137 let mut s2 = crate::Lienzo::new();
138 paint_cached(&mut s2, &m2, &c2, &mut ts, None, None, &mut cache);
139 assert_eq!((cache.hits, cache.misses), (1, 1), "segundo frame igual = hit");
140
141 // Cambió el fill → miss de nuevo.
142 let (m3, c3) = mount_compute(build(Color::from_rgba8(9, 9, 9, 255)), vp);
143 let mut s3 = crate::Lienzo::new();
144 paint_cached(&mut s3, &m3, &c3, &mut ts, None, None, &mut cache);
145 assert_eq!((cache.hits, cache.misses), (1, 2), "cambio de fill = miss");
146
147 // Un frame SIN boundary limpia el caché por GC.
148 let (m4, c4) = mount_compute(
149 View::<()>::new(Style::default()).fill(Color::WHITE),
150 vp,
151 );
152 let mut s4 = crate::Lienzo::new();
153 paint_cached(&mut s4, &m4, &c4, &mut ts, None, None, &mut cache);
154 assert_eq!(cache.len(), 0, "boundary ausente → GC");
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use crate::{hit_test_click, mount, View};
161 use llimphi_layout::taffy::prelude::*;
162 use llimphi_layout::{LayoutTree, Style};
163 use llimphi_text::peniko::kurbo::Affine;
164
165 #[test]
166 fn sticky_top_se_pega_al_scrollport_al_pasar_el_umbral() {
167 use super::sticky_offset;
168 use llimphi_layout::Rect;
169 // Scrollport = el área de página (arranca en y=50, alto 400).
170 let sp = (0.0, 50.0, 300.0, 400.0);
171 let top = [Some(0.0), None, None, None]; // top:0 relativo al scrollport
172 // En flujo, por debajo del umbral (y=200 > 50): sin enganche.
173 let en_flujo = Rect { x: 0.0, y: 200.0, w: 300.0, h: 40.0 };
174 assert_eq!(sticky_offset(top, en_flujo, sp), (0.0, 0.0));
175 // Justo en el umbral (y == scrollport.y): sin enganche todavía.
176 let en_umbral = Rect { x: 0.0, y: 50.0, w: 300.0, h: 40.0 };
177 assert_eq!(sticky_offset(top, en_umbral, sp), (0.0, 0.0));
178 // Scrolleado por encima del umbral (y=10 < 50): se pega, dy = 50-10 = 40.
179 let scrolleado = Rect { x: 0.0, y: 10.0, w: 300.0, h: 40.0 };
180 let (dx, dy) = sticky_offset(top, scrolleado, sp);
181 assert_eq!(dx, 0.0);
182 assert!((dy - 40.0).abs() < 1e-4, "dy debe pegarlo a y=50 (dy=40), es {dy}");
183 // Aún más arriba (y=-90): dy = 50-(-90) = 140 → borde superior en 50.
184 let muy_arriba = Rect { x: 0.0, y: -90.0, w: 300.0, h: 40.0 };
185 assert!((sticky_offset(top, muy_arriba, sp).1 - 140.0).abs() < 1e-4);
186 }
187
188 #[test]
189 fn sticky_bottom_y_precedencia_top() {
190 use super::sticky_offset;
191 use llimphi_layout::Rect;
192 let sp = (0.0, 0.0, 300.0, 200.0); // scrollport 0..200 en y
193 // bottom:0 → pega el borde inferior a y=200 cuando lo pasa.
194 let bottom = [None, None, Some(0.0), None];
195 let paso = Rect { x: 0.0, y: 180.0, w: 300.0, h: 40.0 }; // bottom=220 > 200
196 let (_dx, dy) = sticky_offset(bottom, paso, sp);
197 assert!((dy - (-20.0)).abs() < 1e-4, "empuja hacia arriba 20 (bottom a 200), es {dy}");
198 // top gana sobre bottom cuando ambos disparan.
199 let ambos = [Some(0.0), None, Some(0.0), None];
200 let arriba = Rect { x: 0.0, y: -30.0, w: 300.0, h: 40.0 };
201 let (_x, dy2) = sticky_offset(ambos, arriba, sp);
202 assert!((dy2 - 30.0).abs() < 1e-4, "top gana: dy=30 (pega a y=0), es {dy2}");
203 }
204
205 #[test]
206 fn over_subtree_salta_la_base_y_pinta_en_la_pasada_over() {
207 // View::over(true): el subárbol NO pinta en la pasada base (paint) y
208 // SÍ pinta completo en la pasada over (paint_over_layers) — el
209 // contrato que permite UI encima de contenido gpu_paint_with.
210 use super::{has_over_painter, paint, paint_over_layers};
211 use core::sync::atomic::{AtomicUsize, Ordering};
212 use alloc::sync::Arc;
213
214 let base_hits = Arc::new(AtomicUsize::new(0));
215 let over_hits = Arc::new(AtomicUsize::new(0));
216 let (b, o) = (base_hits.clone(), over_hits.clone());
217 let sz = |w: f32, h: f32| Style {
218 size: Size { width: length(w), height: length(h) },
219 ..Default::default()
220 };
221 let tree: View<()> = View::new(sz(200.0, 200.0)).children(vec![
222 View::new(sz(100.0, 100.0))
223 .paint_with(move |_s, _t, _r| { b.fetch_add(1, Ordering::Relaxed); }),
224 View::new(sz(100.0, 100.0)).over(true).children(vec![View::new(sz(50.0, 50.0))
225 .paint_with(move |_s, _t, _r| { o.fetch_add(1, Ordering::Relaxed); })]),
226 ]);
227
228 let mut layout = LayoutTree::new();
229 let mounted = mount(&mut layout, tree);
230 assert!(has_over_painter(&mounted), "el flag over debe activar la pasada");
231 let computed = layout
232 .compute_with_measure(mounted.root, (400.0, 400.0), |_, _, _| {
233 llimphi_layout::taffy::prelude::Size::ZERO
234 })
235 .expect("layout");
236 let mut ts = llimphi_text::Typesetter::new();
237
238 // Pasada base: pinta el hermano normal, saltea el subárbol over.
239 let mut scene = crate::Lienzo::new();
240 paint(&mut scene, &mounted, &computed, &mut ts, None, None);
241 assert_eq!(base_hits.load(Ordering::Relaxed), 1, "el nodo normal pinta en base");
242 assert_eq!(over_hits.load(Ordering::Relaxed), 0, "el subárbol over NO pinta en base");
243
244 // Pasada over: pinta el subárbol completo (y sólo él).
245 let mut over_scene = crate::Lienzo::new();
246 let any = paint_over_layers(&mut over_scene, &mounted, &computed, &mut ts, None, None);
247 assert!(any, "hay un subárbol over");
248 assert_eq!(over_hits.load(Ordering::Relaxed), 1, "el subárbol over pinta en la pasada over");
249 assert_eq!(base_hits.load(Ordering::Relaxed), 1, "el normal no se repinta en over");
250 }
251
252 #[test]
253 fn dos_capas_over_hermano_y_anidada_ambas_pintan() {
254 // Estructura de media: sidebar `.over` como HERMANO, y la barra `.over`
255 // ANIDADA dentro de otro hermano (content → canvas → barra). El bug del
256 // "no se ve con el sidebar abierto" sería que `paint_over_layers`, al
257 // saltar a `subtree_end` de la primera capa, se comiera la segunda.
258 use super::{paint_over_layers};
259 use core::sync::atomic::{AtomicUsize, Ordering};
260 use alloc::sync::Arc;
261
262 let sidebar_hits = Arc::new(AtomicUsize::new(0));
263 let bar_hits = Arc::new(AtomicUsize::new(0));
264 let (s, b) = (sidebar_hits.clone(), bar_hits.clone());
265 let sz = |w: f32, h: f32| Style {
266 size: Size { width: length(w), height: length(h) },
267 ..Default::default()
268 };
269 // root[ sidebar(.over)[panel] , content[ canvas[ bar(.over) ] ] ]
270 let tree: View<()> = View::new(sz(400.0, 300.0)).children(vec![
271 View::new(sz(100.0, 300.0)).over(true).children(vec![
272 View::new(sz(80.0, 280.0)).paint_with(move |_s, _t, _r| {
273 s.fetch_add(1, Ordering::Relaxed);
274 }),
275 ]),
276 View::new(sz(300.0, 300.0)).children(vec![
277 View::new(sz(300.0, 300.0)).children(vec![
278 View::new(sz(280.0, 44.0)).over(true).paint_with(move |_s, _t, _r| {
279 b.fetch_add(1, Ordering::Relaxed);
280 }),
281 ]),
282 ]),
283 ]);
284
285 let mut layout = LayoutTree::new();
286 let mounted = mount(&mut layout, tree);
287 let computed = layout
288 .compute_with_measure(mounted.root, (400.0, 300.0), |_, _, _| {
289 llimphi_layout::taffy::prelude::Size::ZERO
290 })
291 .expect("layout");
292 let mut ts = llimphi_text::Typesetter::new();
293 let mut over_scene = crate::Lienzo::new();
294 paint_over_layers(&mut over_scene, &mounted, &computed, &mut ts, None, None);
295 assert_eq!(sidebar_hits.load(Ordering::Relaxed), 1, "la capa over del sidebar pinta");
296 assert_eq!(
297 bar_hits.load(Ordering::Relaxed),
298 1,
299 "la capa over ANIDADA (barra) también pinta — no se la come el subtree_end del sidebar"
300 );
301 }
302
303 #[test]
304 fn intrinsic_incluye_espacio_final_evita_wrap_fantasma() {
305 // Bug: un texto con ESPACIO FINAL medido a max-content (shrink-to-fit)
306 // recibía caja = ceil(width), pero parley EXCLUYE la ws de fin del
307 // `width()`. El paint re-shapea a `caja + slack` y ahí el avance del
308 // espacio SÍ cuenta → parte a una 2ª línea fantasma que la medida
309 // reservó como 1 → clip. El fix mide el intrínseco con `full_width`
310 // (incluye el espacio), así la caja es ≥ ancho+espacio y el paint no
311 // parte. Verificado end-to-end: medir a MaxContent y re-medir a
312 // Definite(caja + slack) debe dar el MISMO alto (una línea).
313 use super::{measure_text_node, TEXT_WRAP_ROUNDING_SLACK};
314 use crate::TextMeasure;
315 use llimphi_layout::taffy::{AvailableSpace, Size};
316
317 let tm = |content: &str| TextMeasure {
318 content: content.to_string(),
319 size_px: 16.0,
320 alignment: llimphi_text::Alignment::Start,
321 italic: false,
322 font_family: None,
323 line_height: 1.2,
324 weight: 400.0,
325 max_lines: None,
326 ellipsis: false,
327 underline: false,
328 strikethrough: false,
329 spans: None,
330 letter_spacing: 0.0,
331 word_spacing: 0.0,
332 no_wrap: false,
333 overflow_wrap: false,
334 font_features: String::new(),
335 font_variations: String::new(),
336 font_width: 100.0,
337 };
338 let mut ts = llimphi_text::Typesetter::new();
339 let max_content = Size {
340 width: AvailableSpace::MaxContent,
341 height: AvailableSpace::MaxContent,
342 };
343 let none = Size { width: None, height: None };
344
345 for content in ["archived mailing list ", "one two three four five ", "Houdini "] {
346 // 1) Medida intrínseca (max-content), como la usa taffy para la caja.
347 let intr = measure_text_node(&mut ts, &tm(content), none, max_content);
348 let box_w = intr.width; // ya viene ceil'd por el fix
349 // 2) Paint real: re-shape a Definite(caja + slack).
350 let painted = measure_text_node(
351 &mut ts,
352 &tm(content),
353 Size { width: Some(box_w + TEXT_WRAP_ROUNDING_SLACK), height: None },
354 Size {
355 width: AvailableSpace::Definite(box_w + TEXT_WRAP_ROUNDING_SLACK),
356 height: AvailableSpace::MaxContent,
357 },
358 );
359 assert!(
360 (painted.height - intr.height).abs() < 0.6,
361 "{content:?}: el paint no debe partir a 2 líneas — intr h={:.1} caja={:.1} paint h={:.1}",
362 intr.height, box_w, painted.height
363 );
364 }
365
366 // Control: SIN espacio final la caja no cambia (full_width == width).
367 let sin = measure_text_node(&mut ts, &tm("archived mailing list"), none, max_content);
368 let con = measure_text_node(&mut ts, &tm("archived mailing list "), none, max_content);
369 assert!(
370 con.width > sin.width + 1.0,
371 "el espacio final debe ensanchar la caja intrínseca: sin={:.1} con={:.1}",
372 sin.width, con.width
373 );
374 }
375
376 #[test]
377 fn transform_origin_fija_el_pivote() {
378 // El pivote de `transform-origin` queda FIJO bajo la transformación:
379 // top-left (frac 0,0) fija la esquina (0,0); el default (None) fija el
380 // centro (50,50). Rect 100×100 en el origen, rotación 90°.
381 use super::resolve_node_transform;
382 use crate::TransformPivot;
383 use llimphi_text::peniko::kurbo::Point;
384 let r = llimphi_layout::Rect { x: 0.0, y: 0.0, w: 100.0, h: 100.0 };
385 let rot = Affine::rotate(core::f64::consts::FRAC_PI_2);
386
387 let tl = TransformPivot { px: (0.0, 0.0), frac: (0.0, 0.0) };
388 let xf_tl = resolve_node_transform(Some(rot), None, Some(tl), r).unwrap();
389 let p = xf_tl * Point::new(0.0, 0.0);
390 assert!(p.x.abs() < 1e-6 && p.y.abs() < 1e-6, "pivote top-left fijo, fue {p:?}");
391
392 // Default (None) ⇒ centro: (50,50) queda fijo.
393 let xf_c = resolve_node_transform(Some(rot), None, None, r).unwrap();
394 let c = xf_c * Point::new(50.0, 50.0);
395 assert!(
396 (c.x - 50.0).abs() < 1e-6 && (c.y - 50.0).abs() < 1e-6,
397 "centro fijo con pivote default, fue {c:?}"
398 );
399 // Y el centro NO queda fijo con pivote top-left (distingue los dos casos).
400 let c2 = xf_tl * Point::new(50.0, 50.0);
401 assert!((c2.x - 50.0).abs() > 1.0 || (c2.y - 50.0).abs() > 1.0, "top-left mueve el centro");
402 }
403
404 #[test]
405 fn resolve_clip_radius_lados_y_porcentajes() {
406 use super::resolve_clip_radius;
407 // Caja 200×100, centro al (50%,50%) = (100,50) local.
408 let (w, h, cxl, cyl): (f64, f64, f64, f64) = (200.0, 100.0, 100.0, 50.0);
409 // side 0: px + pct_w·w + pct_h·h + pct_diag·diag.
410 let diag = crate::sqrt_f64(w * w + h * h) / core::f64::consts::SQRT_2;
411 let r = resolve_clip_radius(&[10.0, 0.0, 0.0, 50.0, 0.0], cxl, cyl, w, h, true);
412 assert!((r - (10.0 + 0.5 * diag)).abs() < 1e-6);
413 // closest-side circle (1): min(100,100,50,50) = 50.
414 assert_eq!(
415 resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 1.0], cxl, cyl, w, h, true),
416 50.0
417 );
418 // farthest-side circle (2): max(...) = 100.
419 assert_eq!(
420 resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 2.0], cxl, cyl, w, h, true),
421 100.0
422 );
423 // closest-side ellipse eje X (3, is_x): min(cxl, w-cxl) = 100.
424 assert_eq!(
425 resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 3.0], cxl, cyl, w, h, true),
426 100.0
427 );
428 // closest-side ellipse eje Y (3, !is_x): min(cyl, h-cyl) = 50.
429 assert_eq!(
430 resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 3.0], cxl, cyl, w, h, false),
431 50.0
432 );
433 // Centro descentrado (30, 20): closest circle = min(30,170,20,80)=20.
434 assert_eq!(
435 resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 1.0], 30.0, 20.0, w, h, true),
436 20.0
437 );
438 }
439
440 /// El slack de redondeo del paint absorbe la pérdida de hasta ~1px que
441 /// taffy introduce al redondear los bordes de una caja encogida-a-contenido.
442 /// Regresión de "«Learn more» envuelve a 2 líneas": la MEDIDA dimensiona el
443 /// nodo a `nat` (una línea), taffy lo deja en `nat - d` (con `d ≤ 1.0` por
444 /// redondeo), y el paint —si envolviera exactamente a ese ancho— partiría la
445 /// última palabra a una 2ª línea que desborda. Test font-independiente:
446 /// creamos un déficit `d` dentro del rango de taffy y verificamos que sin
447 /// slack envuelve y CON slack no.
448 #[test]
449 fn slack_de_redondeo_evita_wrap_espurio_en_texto_encogido() {
450 use llimphi_text::{Alignment, Typesetter};
451 let mut ts = Typesetter::new();
452 let clamp = |ts: &mut Typesetter, mw: Option<f32>| {
453 ts.layout_clamped(
454 "Learn more",
455 16.0,
456 mw,
457 Alignment::Start,
458 1.2,
459 false,
460 None,
461 400.0,
462 None,
463 false,
464 false,
465 false,
466 0.0,
467 0.0,
468 false,
469 "",
470 "",
471 100.0,
472 )
473 };
474 // Ancho natural (sin límite): una sola línea.
475 let nat = llimphi_text::measurement(&clamp(&mut ts, None)).width;
476 assert_eq!(clamp(&mut ts, None).lines().count(), 1, "natural = 1 línea");
477 // Déficit dentro del rango de redondeo de taffy (0 < d ≤ 1.0). Casi 1.0
478 // = peor caso: los redondeos de ambos bordes restan hasta 1px juntos.
479 let deficit = 0.999_f32;
480 let rounded_down = nat - deficit;
481 assert!(
482 clamp(&mut ts, Some(rounded_down)).lines().count() >= 2,
483 "sin slack, el ancho redondeado hacia abajo ({rounded_down}) parte el texto"
484 );
485 assert_eq!(
486 clamp(&mut ts, Some(rounded_down + super::TEXT_WRAP_ROUNDING_SLACK))
487 .lines()
488 .count(),
489 1,
490 "con el slack {}, el texto vuelve a una línea",
491 super::TEXT_WRAP_ROUNDING_SLACK
492 );
493 }
494
495 /// Un hijo clickeable de 100×100 anclado arriba-izquierda. Devuelve
496 /// `(mounted, computed)` ya layouteados sobre un viewport 400×400.
497 fn fixture(
498 transform: Option<Affine>,
499 ) -> (crate::Mounted<()>, llimphi_layout::ComputedLayout) {
500 let mut child = View::<()>::new(Style {
501 size: Size {
502 width: length(100.0),
503 height: length(100.0),
504 },
505 ..Default::default()
506 })
507 .on_click(());
508 if let Some(xf) = transform {
509 child = child.transform(xf);
510 }
511 let root = View::<()>::new(Style {
512 align_items: Some(AlignItems::FLEX_START),
513 justify_content: Some(JustifyContent::FLEX_START),
514 ..Default::default()
515 })
516 .children(vec![child]);
517 let mut layout = LayoutTree::new();
518 let mounted = mount(&mut layout, root);
519 let computed = layout.compute(mounted.root, (400.0, 400.0)).expect("layout");
520 (mounted, computed)
521 }
522
523 #[test]
524 fn sin_transform_el_hit_cae_en_el_rect() {
525 let (m, c) = fixture(None);
526 assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), Some(1)); // dentro
527 assert_eq!(hit_test_click(&m, &c, 250.0, 50.0), None); // fuera
528 }
529
530 #[test]
531 fn traslacion_mueve_el_area_clickeable() {
532 // El nodo se ve corrido +200px en x; el click debe seguirlo.
533 let (m, c) = fixture(Some(Affine::translate((200.0, 0.0))));
534 assert_eq!(hit_test_click(&m, &c, 250.0, 50.0), Some(1)); // donde se ve
535 assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), None); // ya no donde estaba
536 }
537
538 #[test]
539 fn rotacion_180_grados_alrededor_del_centro() {
540 // Rotar 180° alrededor del centro (50,50) deja el rect en su sitio:
541 // una esquina mapea a la opuesta, pero el cuadrado cubre lo mismo.
542 let (m, c) = fixture(Some(Affine::rotate(core::f64::consts::PI)));
543 assert_eq!(hit_test_click(&m, &c, 10.0, 10.0), Some(1));
544 assert_eq!(hit_test_click(&m, &c, 90.0, 90.0), Some(1));
545 assert_eq!(hit_test_click(&m, &c, 150.0, 150.0), None);
546 }
547
548 #[test]
549 fn escala_cero_es_inalcanzable() {
550 let (m, c) = fixture(Some(Affine::scale(0.0)));
551 assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), None);
552 }
553
554 /// Como `fixture` pero seteando `transform_rel` (traslación en fracción
555 /// del tamaño del nodo) en vez del afín fijo.
556 fn fixture_rel(
557 rel: (f64, f64),
558 ) -> (crate::Mounted<()>, llimphi_layout::ComputedLayout) {
559 let child = View::<()>::new(Style {
560 size: Size { width: length(100.0), height: length(100.0) },
561 ..Default::default()
562 })
563 .on_click(())
564 .transform_rel(rel);
565 let root = View::<()>::new(Style {
566 align_items: Some(AlignItems::FLEX_START),
567 justify_content: Some(JustifyContent::FLEX_START),
568 ..Default::default()
569 })
570 .children(vec![child]);
571 let mut layout = LayoutTree::new();
572 let mounted = mount(&mut layout, root);
573 let computed = layout.compute(mounted.root, (400.0, 400.0)).expect("layout");
574 (mounted, computed)
575 }
576
577 /// Replica la estructura de dominium: un wrapper que clipea y lleva
578 /// `on_click_at` + `draggable_at`, con un hijo "canvas" que pinta por
579 /// uno de dos caminos. Devuelve `(mounted, computed)`. `gpu` elige el
580 /// camino: `false` = `paint_with` (camino LEGACY), `true` =
581 /// `gpu_paint_with().paint_over()` (camino Tier 1 nuevo).
582 fn dominium_like(gpu: bool) -> (crate::Mounted<()>, llimphi_layout::ComputedLayout) {
583 let mut canvas = View::<()>::new(Style {
584 size: Size { width: percent(1.0), height: percent(1.0) },
585 ..Default::default()
586 });
587 canvas = if gpu {
588 canvas
589 .gpu_paint_with(|_d, _q, _e, _t, _r, _vp| {})
590 .paint_over(|_s, _ts, _r| {})
591 } else {
592 canvas.paint_with(|_s, _ts, _r| {})
593 };
594 let wrapper = View::<()>::new(Style {
595 size: Size { width: percent(1.0), height: percent(1.0) },
596 ..Default::default()
597 })
598 .clip(true)
599 .on_click_at(|_lx, _ly, _rw, _rh| Some(()))
600 .draggable_at(|_phase, _dx, _dy, _x0, _y0| Some(()))
601 .children(vec![canvas]);
602 let mut layout = LayoutTree::new();
603 let mounted = mount(&mut layout, wrapper);
604 let computed = layout.compute(mounted.root, (400.0, 400.0)).expect("layout");
605 (mounted, computed)
606 }
607
608 #[test]
609 fn canvas_gpu_only_es_clickeable_igual_que_legacy() {
610 // BUG 2: el wrapper con `on_click_at`/`draggable_at` debe ser
611 // hit-testeable en AMBOS caminos. El click cae sobre el wrapper
612 // (índice 0, raíz) — un punto interior lo encuentra sin importar el
613 // tipo de painter del hijo.
614 let (m_leg, c_leg) = dominium_like(false);
615 assert_eq!(hit_test_click(&m_leg, &c_leg, 200.0, 200.0), Some(0), "LEGACY (paint_with)");
616 let (m_gpu, c_gpu) = dominium_like(true);
617 assert_eq!(hit_test_click(&m_gpu, &c_gpu, 200.0, 200.0), Some(0), "GPU (gpu_paint_with+paint_over)");
618 }
619
620 /// Una capa `.over(true)` se pinta DESPUÉS de todo lo demás, así que el
621 /// usuario la ve arriba. El hit-test tiene que coincidir con eso aunque el
622 /// nodo base esté DESPUÉS en orden de árbol.
623 ///
624 /// Caso real que lo motivó (saga del ⚙ de media, 2026-07): la barra de
625 /// control de media es `.over(true)` y el lienzo de video que va detrás
626 /// aparece después en el árbol; con el criterio viejo («último nodo que
627 /// cumple, en orden de árbol») el lienzo le robaba los clicks a los
628 /// botones que el usuario veía encima.
629 #[test]
630 fn capa_over_le_gana_el_click_al_nodo_base_posterior() {
631 fn caja<Msg: Clone>(msg: Msg) -> View<Msg> {
632 View::<Msg>::new(Style {
633 position: Position::Absolute,
634 inset: Rect { left: length(0.0), top: length(0.0), right: auto(), bottom: auto() },
635 size: Size { width: length(200.0), height: length(200.0) },
636 ..Default::default()
637 })
638 .on_click(msg)
639 }
640 // Dos cajas superpuestas en el MISMO rect. La `over` va PRIMERO en el
641 // árbol (la posición desfavorable), la base va después.
642 let root = View::<u8>::new(Style::default()).children(vec![
643 caja(1u8).over(true), // se pinta al final ⇒ está arriba
644 caja(2u8), // posterior en el árbol, pero debajo
645 ]);
646 let mut layout = LayoutTree::new();
647 let m = mount(&mut layout, root);
648 let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
649 // idx 1 = la caja over, idx 2 = la base. Gana la que se ve.
650 assert_eq!(
651 hit_test_click(&m, &c, 100.0, 100.0),
652 Some(1),
653 "el click debe ir a la capa over (la que el usuario ve encima), \
654 no al nodo base posterior en orden de árbol"
655 );
656 // Control: fuera de las cajas no pega nada.
657 assert_eq!(hit_test_click(&m, &c, 300.0, 300.0), None);
658 }
659
660 /// El contrapeso del test anterior: sin capas over, el criterio de siempre
661 /// (último en orden de árbol = el que se pintó encima) queda intacto.
662 #[test]
663 fn sin_capas_over_gana_el_ultimo_del_arbol_como_siempre() {
664 fn caja<Msg: Clone>(msg: Msg) -> View<Msg> {
665 View::<Msg>::new(Style {
666 position: Position::Absolute,
667 inset: Rect { left: length(0.0), top: length(0.0), right: auto(), bottom: auto() },
668 size: Size { width: length(200.0), height: length(200.0) },
669 ..Default::default()
670 })
671 .on_click(msg)
672 }
673 let root = View::<u8>::new(Style::default()).children(vec![caja(1u8), caja(2u8)]);
674 let mut layout = LayoutTree::new();
675 let m = mount(&mut layout, root);
676 let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
677 assert_eq!(hit_test_click(&m, &c, 100.0, 100.0), Some(2));
678 }
679
680 #[test]
681 fn nodo_gpu_paint_with_solo_es_hittable_por_si_mismo() {
682 // Crítico para el motor voxel futuro: una vista 3D GPU-only que
683 // lleve su PROPIO `on_click_at` debe ser clickeable, aunque NO
684 // tenga `paint_with` ni contenido vello — sólo `gpu_painter`.
685 let canvas = View::<()>::new(Style {
686 size: Size { width: length(100.0), height: length(100.0) },
687 ..Default::default()
688 })
689 .gpu_paint_with(|_d, _q, _e, _t, _r, _vp| {})
690 .on_click(());
691 let root = View::<()>::new(Style {
692 align_items: Some(AlignItems::FLEX_START),
693 justify_content: Some(JustifyContent::FLEX_START),
694 ..Default::default()
695 })
696 .children(vec![canvas]);
697 let mut layout = LayoutTree::new();
698 let m = mount(&mut layout, root);
699 let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
700 assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), Some(1), "gpu-only con on_click debe ser hittable");
701 }
702
703 #[test]
704 fn transform_rel_resuelve_contra_el_tamano_del_nodo() {
705 // El nodo es 100×100 en (0,0). `transform_rel(-0.5,-0.5)` =
706 // `translate(-50%,-50%)` = correr -50px,-50px (la mitad de 100). El
707 // área pintada pasa a (-50,-50)..(50,50): el centro del rect original
708 // (50,50) queda ahora en (0,0).
709 let (m, c) = fixture_rel((-0.5, -0.5));
710 // Donde se ve ahora (el viejo centro corrido a 0,0; y la esquina
711 // inferior-derecha del original (100,100) ahora en (50,50)).
712 assert_eq!(hit_test_click(&m, &c, 25.0, 25.0), Some(1)); // dentro del corrido
713 assert_eq!(hit_test_click(&m, &c, 49.0, 49.0), Some(1)); // casi esquina nueva
714 // Donde estaba antes pero ya NO (el rect se corrió fuera de ahí).
715 assert_eq!(hit_test_click(&m, &c, 75.0, 75.0), None);
716 // Sin transform_rel ese mismo punto SÍ caería dentro (control).
717 let (m0, c0) = fixture_rel((0.0, 0.0)); // (0,0) = no-op
718 assert_eq!(hit_test_click(&m0, &c0, 75.0, 75.0), Some(1));
719 }
720
721 #[test]
722 fn hit_test_cursor_directo_y_por_herencia() {
723 use crate::{hit_test_cursor, Cursor};
724 // Padre 200×200 con cursor Text; dentro un hijo 100×100 (arriba-izq)
725 // SIN cursor propio; y un segundo hijo 50×50 con cursor Pointer.
726 let hijo_sin = View::<()>::new(Style {
727 size: Size { width: length(100.0), height: length(100.0) },
728 ..Default::default()
729 });
730 let hijo_con = View::<()>::new(Style {
731 size: Size { width: length(50.0), height: length(50.0) },
732 ..Default::default()
733 })
734 .cursor(Cursor::Pointer);
735 let root = View::<()>::new(Style {
736 size: Size { width: length(200.0), height: length(200.0) },
737 flex_direction: FlexDirection::Column,
738 align_items: Some(AlignItems::FLEX_START),
739 justify_content: Some(JustifyContent::FLEX_START),
740 ..Default::default()
741 })
742 .cursor(Cursor::Text)
743 .children(vec![hijo_sin, hijo_con]);
744 let mut layout = LayoutTree::new();
745 let m = mount(&mut layout, root);
746 let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
747 // Sobre el hijo sin cursor (0..100, 0..100) → hereda Text del padre.
748 assert_eq!(hit_test_cursor(&m, &c, 50.0, 50.0), Some(Cursor::Text));
749 // Sobre el hijo con cursor propio (apilado debajo: y 100..150) → Pointer.
750 assert_eq!(hit_test_cursor(&m, &c, 25.0, 120.0), Some(Cursor::Pointer));
751 // Sobre el padre pero fuera de ambos hijos (x>100) → Text del padre.
752 assert_eq!(hit_test_cursor(&m, &c, 150.0, 50.0), Some(Cursor::Text));
753 // Fuera del padre → None (la ventana usa su default).
754 assert_eq!(hit_test_cursor(&m, &c, 350.0, 350.0), None);
755 }
756
757 #[test]
758 fn tab_traversal_envuelve_en_los_extremos() {
759 use crate::next_focus;
760 let order = [10u64, 20, 30];
761 // Avanza.
762 assert_eq!(next_focus(&order, Some(10), false), Some(20));
763 assert_eq!(next_focus(&order, Some(30), false), Some(10)); // wrap
764 // Retrocede (Shift+Tab).
765 assert_eq!(next_focus(&order, Some(20), true), Some(10));
766 assert_eq!(next_focus(&order, Some(10), true), Some(30)); // wrap
767 // Sin foco previo: Tab → primero, Shift+Tab → último.
768 assert_eq!(next_focus(&order, None, false), Some(10));
769 assert_eq!(next_focus(&order, None, true), Some(30));
770 // Foco obsoleto (id que ya no está) → arranca por el extremo.
771 assert_eq!(next_focus(&order, Some(99), false), Some(10));
772 // Lista vacía.
773 assert_eq!(next_focus(&[], Some(10), false), None);
774 }
775
776 #[test]
777 fn hit_test_scale_directo_y_por_herencia() {
778 use crate::{hit_test_scale, GesturePhase};
779 // Canvas zoomeable 200×200 (declara on_scale); dentro un widget 50×50
780 // (arriba-izq) SIN on_scale (no zoomea). El gesto sobre el widget debe
781 // "caer" al canvas ancestro (herencia, como el cursor), y fuera de
782 // todo debe dar None (el evento cae al scroll/on_wheel).
783 let widget = View::<()>::new(Style {
784 size: Size { width: length(50.0), height: length(50.0) },
785 ..Default::default()
786 });
787 let canvas = View::<()>::new(Style {
788 size: Size { width: length(200.0), height: length(200.0) },
789 align_items: Some(AlignItems::FLEX_START),
790 justify_content: Some(JustifyContent::FLEX_START),
791 ..Default::default()
792 })
793 .on_scale(|_phase: GesturePhase, _f, _fx, _fy| None)
794 .children(vec![widget]);
795 let mut layout = LayoutTree::new();
796 let m = mount(&mut layout, canvas);
797 let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
798 // Sobre el widget sin on_scale (0..50,0..50) → cae al canvas (idx 0).
799 assert_eq!(hit_test_scale(&m, &c, 25.0, 25.0), Some(0));
800 // Sobre el canvas fuera del widget (x>50) → el canvas (idx 0).
801 assert_eq!(hit_test_scale(&m, &c, 150.0, 25.0), Some(0));
802 // Fuera del canvas → None.
803 assert_eq!(hit_test_scale(&m, &c, 350.0, 350.0), None);
804 }
805
806 #[test]
807 fn hit_test_rotate_directo_y_por_herencia() {
808 use crate::{hit_test_rotate, GesturePhase};
809 // Mismo patrón que escala: canvas rotable con un widget no-rotable
810 // encima; el gesto cae al ancestro que declara on_rotate.
811 let widget = View::<()>::new(Style {
812 size: Size { width: length(50.0), height: length(50.0) },
813 ..Default::default()
814 });
815 let canvas = View::<()>::new(Style {
816 size: Size { width: length(200.0), height: length(200.0) },
817 align_items: Some(AlignItems::FLEX_START),
818 justify_content: Some(JustifyContent::FLEX_START),
819 ..Default::default()
820 })
821 .on_rotate(|_phase: GesturePhase, _d, _fx, _fy| None)
822 .children(vec![widget]);
823 let mut layout = LayoutTree::new();
824 let m = mount(&mut layout, canvas);
825 let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
826 assert_eq!(hit_test_rotate(&m, &c, 25.0, 25.0), Some(0));
827 assert_eq!(hit_test_rotate(&m, &c, 150.0, 25.0), Some(0));
828 assert_eq!(hit_test_rotate(&m, &c, 350.0, 350.0), None);
829 }
830
831 #[test]
832 fn hit_test_selectable_solo_sobre_texto_seleccionable() {
833 use crate::hit_test_selectable;
834 // Un label seleccionable 100×30 arriba-izq dentro de un panel 200×200
835 // SIN selectable. Sólo el label matchea; el resto del panel da None.
836 let label = View::<()>::new(Style {
837 size: Size { width: length(100.0), height: length(30.0) },
838 ..Default::default()
839 })
840 .text("hola", 14.0, llimphi_text::peniko::Color::from_rgba8(255, 255, 255, 255))
841 .selectable(7);
842 let panel = View::<()>::new(Style {
843 size: Size { width: length(200.0), height: length(200.0) },
844 align_items: Some(AlignItems::FLEX_START),
845 justify_content: Some(JustifyContent::FLEX_START),
846 ..Default::default()
847 })
848 .children(vec![label]);
849 let mut layout = LayoutTree::new();
850 let m = mount(&mut layout, panel);
851 let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
852 // Sobre el label (0..100, 0..30) → el label (idx 1).
853 assert_eq!(hit_test_selectable(&m, &c, 50.0, 15.0), Some(1));
854 // Sobre el panel fuera del label → None (el panel no es selectable).
855 assert_eq!(hit_test_selectable(&m, &c, 150.0, 150.0), None);
856 }
857
858 #[test]
859 fn hit_test_scroll_chain_devuelve_front_to_back() {
860 use crate::hit_test_scroll_chain;
861 // Padre scrollable 200×200 con un hijo scrollable 100×100 (arriba-izq).
862 // Bajo el hijo: chain = [hijo, padre]. Bajo el padre pero fuera del
863 // hijo: chain = [padre]. Fuera de ambos: chain vacío.
864 let hijo = View::<()>::new(Style {
865 size: Size { width: length(100.0), height: length(100.0) },
866 ..Default::default()
867 })
868 .on_scroll(|_dx, _dy| None::<()>);
869 let padre = View::<()>::new(Style {
870 size: Size { width: length(200.0), height: length(200.0) },
871 align_items: Some(AlignItems::FLEX_START),
872 justify_content: Some(JustifyContent::FLEX_START),
873 ..Default::default()
874 })
875 .on_scroll(|_dx, _dy| None::<()>)
876 .children(vec![hijo]);
877 let mut layout = LayoutTree::new();
878 let m = mount(&mut layout, padre);
879 let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
880 // Sobre el hijo (0..100,0..100) → chain = [hijo=1, padre=0].
881 let ch = hit_test_scroll_chain(&m, &c, 50.0, 50.0);
882 assert_eq!(ch, vec![1, 0]);
883 // Sobre el padre fuera del hijo (x>100) → chain = [padre=0].
884 let ch = hit_test_scroll_chain(&m, &c, 150.0, 50.0);
885 assert_eq!(ch, vec![0]);
886 // Fuera del padre → chain vacío.
887 let ch = hit_test_scroll_chain(&m, &c, 350.0, 350.0);
888 assert!(ch.is_empty());
889 }
890
891 #[test]
892 fn consume_scroll_chain_reparte_el_sobrante() {
893 use crate::{consume_scroll_chain, hit_test_scroll_chain};
894 // Hijo `take` que sólo puede absorber 3 líneas; padre `take` sin tope.
895 // Un evento de 10 líneas debe producir DOS Msg: hijo 3 + padre 7.
896 #[derive(Clone, Debug, PartialEq)]
897 enum M {
898 Hijo(f32),
899 Padre(f32),
900 }
901 let hijo = View::<M>::new(Style {
902 size: Size { width: length(100.0), height: length(100.0) },
903 ..Default::default()
904 })
905 .on_scroll_take(|dx, dy| {
906 let can = dy.min(3.0);
907 (Some(M::Hijo(can)), dx, dy - can)
908 });
909 let padre = View::<M>::new(Style {
910 size: Size { width: length(200.0), height: length(200.0) },
911 align_items: Some(AlignItems::FLEX_START),
912 justify_content: Some(JustifyContent::FLEX_START),
913 ..Default::default()
914 })
915 .on_scroll_take(|_dx, dy| (Some(M::Padre(dy)), 0.0, 0.0))
916 .children(vec![hijo]);
917 let mut layout = LayoutTree::new();
918 let m = mount(&mut layout, padre);
919 let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
920 let chain = hit_test_scroll_chain(&m, &c, 50.0, 50.0);
921 let msgs = consume_scroll_chain(&m, &chain, 0.0, 10.0);
922 assert_eq!(msgs, vec![M::Hijo(3.0), M::Padre(7.0)]);
923 // Si el hijo consume todo, el padre no recibe nada.
924 let msgs = consume_scroll_chain(&m, &chain, 0.0, 2.0);
925 assert_eq!(msgs, vec![M::Hijo(2.0)]);
926 }
927
928 #[test]
929 fn consume_scroll_chain_legado_y_defensas() {
930 use crate::{consume_scroll_chain, hit_test_scroll_chain};
931 #[derive(Clone, Debug, PartialEq)]
932 enum M {
933 Padre(f32),
934 }
935 // Hijo LEGACY que devuelve None (topado) → el padre take recibe el
936 // delta completo (semántica histórica intacta).
937 let hijo = View::<M>::new(Style {
938 size: Size { width: length(100.0), height: length(100.0) },
939 ..Default::default()
940 })
941 .on_scroll(|_dx, _dy| None::<M>);
942 let padre = View::<M>::new(Style {
943 size: Size { width: length(200.0), height: length(200.0) },
944 align_items: Some(AlignItems::FLEX_START),
945 justify_content: Some(JustifyContent::FLEX_START),
946 ..Default::default()
947 })
948 .on_scroll_take(|_dx, dy| (Some(M::Padre(dy)), 0.0, 0.0))
949 .children(vec![hijo]);
950 let mut layout = LayoutTree::new();
951 let m = mount(&mut layout, padre);
952 let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
953 let chain = hit_test_scroll_chain(&m, &c, 50.0, 50.0);
954 assert_eq!(consume_scroll_chain(&m, &chain, 0.0, 5.0), vec![M::Padre(5.0)]);
955
956 // Defensa: un take mentiroso que "agranda" el sobrante queda acotado
957 // al delta recibido — el padre nunca ve más de lo que entró.
958 let tramposo = View::<M>::new(Style {
959 size: Size { width: length(100.0), height: length(100.0) },
960 ..Default::default()
961 })
962 .on_scroll_take(|_dx, _dy| (None, 0.0, 999.0));
963 let padre2 = View::<M>::new(Style {
964 size: Size { width: length(200.0), height: length(200.0) },
965 align_items: Some(AlignItems::FLEX_START),
966 justify_content: Some(JustifyContent::FLEX_START),
967 ..Default::default()
968 })
969 .on_scroll_take(|_dx, dy| (Some(M::Padre(dy)), 0.0, 0.0))
970 .children(vec![tramposo]);
971 let mut layout = LayoutTree::new();
972 let m = mount(&mut layout, padre2);
973 let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
974 let chain = hit_test_scroll_chain(&m, &c, 50.0, 50.0);
975 assert_eq!(consume_scroll_chain(&m, &chain, 0.0, 5.0), vec![M::Padre(5.0)]);
976 }
977
978 #[test]
979 fn hit_test_double_tap_y_long_press() {
980 use crate::{hit_test_double_tap, hit_test_long_press};
981 // Un nodo 100×100 con doble-tap; otro 100×100 apilado debajo con
982 // long-press. Cada hit-test sólo ve su propio gesto.
983 let arriba = View::<()>::new(Style {
984 size: Size { width: length(100.0), height: length(100.0) },
985 ..Default::default()
986 })
987 .on_double_tap(());
988 let abajo = View::<()>::new(Style {
989 size: Size { width: length(100.0), height: length(100.0) },
990 ..Default::default()
991 })
992 .on_long_press(());
993 let root = View::<()>::new(Style {
994 flex_direction: FlexDirection::Column,
995 align_items: Some(AlignItems::FLEX_START),
996 justify_content: Some(JustifyContent::FLEX_START),
997 ..Default::default()
998 })
999 .children(vec![arriba, abajo]);
1000 let mut layout = LayoutTree::new();
1001 let m = mount(&mut layout, root);
1002 let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
1003 // Nodo de arriba (y 0..100): doble-tap sí, long-press no.
1004 assert_eq!(hit_test_double_tap(&m, &c, 50.0, 50.0), Some(1));
1005 assert_eq!(hit_test_long_press(&m, &c, 50.0, 50.0), None);
1006 // Nodo de abajo (y 100..200): long-press sí, doble-tap no.
1007 assert_eq!(hit_test_long_press(&m, &c, 50.0, 150.0), Some(2));
1008 assert_eq!(hit_test_double_tap(&m, &c, 50.0, 150.0), None);
1009 // Fuera de ambos.
1010 assert_eq!(hit_test_double_tap(&m, &c, 300.0, 300.0), None);
1011 assert_eq!(hit_test_long_press(&m, &c, 300.0, 300.0), None);
1012 }
1013}
1014
1015#[cfg(test)]
1016mod reciclado {
1017 use super::*;
1018 use llimphi_layout::taffy::prelude::*;
1019
1020 /// Árbol de `n` hojas bajo una fila, con estilos fijos.
1021 fn arbol(n: usize) -> View<()> {
1022 View::<()>::new(Style { flex_direction: FlexDirection::Row, ..Default::default() })
1023 .children(
1024 (0..n)
1025 .map(|i| {
1026 View::<()>::new(Style {
1027 size: Size { width: length(10.0 + i as f32 % 7.0), height: length(10.0) },
1028 flex_shrink: 0.0,
1029 ..Default::default()
1030 })
1031 })
1032 .collect(),
1033 )
1034 }
1035
1036 /// La prueba de que el reciclado hace lo que dice: el segundo mount escribe
1037 /// **sobre el mismo bloque de heap** (mismo puntero, misma capacidad) en vez
1038 /// de pedir otro, y el árbol resultante es idéntico al que sale sin reciclar.
1039 /// Si alguien rompe el `clear()`-y-reusar, el puntero cambia y esto cae.
1040 #[test]
1041 fn reciclar_reusa_el_mismo_bloque_y_da_el_mismo_arbol() {
1042 let mut tree = LayoutTree::new();
1043 let (m1, ret1, _) = mount_incremental(&mut tree, arbol(64), None);
1044 let ptr1 = m1.nodes.as_ptr();
1045 let cap1 = m1.nodes.capacity();
1046 let len1 = m1.nodes.len();
1047
1048 let (m2, _, reuso) =
1049 mount_incremental_reciclando(&mut tree, arbol(64), Some(ret1), Some(m1));
1050 assert!(reuso, "misma forma ⇒ debe reusar el layout retenido");
1051 assert_eq!(m2.nodes.len(), len1, "mismo árbol ⇒ mismo nº de nodos");
1052 assert_eq!(m2.nodes.capacity(), cap1, "el buffer reciclado no se re-pidió");
1053 assert_eq!(m2.nodes.as_ptr(), ptr1, "el buffer reciclado cambió de bloque");
1054
1055 // Y contra el camino histórico (sin reciclar): mismo resultado.
1056 let mut tree_ref = LayoutTree::new();
1057 let (mref, _, _) = mount_incremental(&mut tree_ref, arbol(64), None);
1058 assert_eq!(m2.nodes.len(), mref.nodes.len());
1059 for (a, b) in m2.nodes.iter().zip(mref.nodes.iter()) {
1060 assert_eq!(a.subtree_end, b.subtree_end, "forma del subárbol");
1061 assert_eq!(a.fill, b.fill);
1062 }
1063 }
1064
1065 /// El contrapeso: un árbol que se desploma de 4.096 a 4 nodos no puede
1066 /// dejarnos el buffer grande tomado para siempre. Se encoge con histéresis
1067 /// (sobra >4× ⇒ deja 2×), así una UI que oscila no rebota.
1068 #[test]
1069 fn el_buffer_se_encoge_cuando_el_arbol_se_desploma() {
1070 let mut tree = LayoutTree::new();
1071 let (grande, ret, _) = mount_incremental(&mut tree, arbol(4096), None);
1072 let cap_grande = grande.nodes.capacity();
1073 assert!(cap_grande >= 4096);
1074
1075 let (chico, _, _) =
1076 mount_incremental_reciclando(&mut tree, arbol(3), Some(ret), Some(grande));
1077 assert_eq!(chico.nodes.len(), 4, "raíz + 3 hojas");
1078 assert!(
1079 chico.nodes.capacity() < cap_grande / 4,
1080 "el buffer no se encogió: {} (venía de {cap_grande})",
1081 chico.nodes.capacity()
1082 );
1083 }
1084}
1085
1086#[cfg(test)]
1087mod over_bbox_tests {
1088 use super::*;
1089 use crate::{mount, Shadow, View};
1090 use llimphi_layout::taffy::prelude::*;
1091 use llimphi_layout::taffy::Rect as TaffyRect;
1092 use llimphi_layout::LayoutTree;
1093
1094 fn mount_compute(v: View<()>, vp: (f32, f32)) -> (Mounted<()>, ComputedLayout) {
1095 let mut lt = LayoutTree::new();
1096 let m = mount(&mut lt, v);
1097 let c = lt.compute(m.root, vp).expect("layout");
1098 (m, c)
1099 }
1100
1101 /// Sin nada marcado `over`, la pasada no tiene bbox — el caller se ahorra
1102 /// la scratch entera.
1103 #[test]
1104 fn sin_capa_over_no_hay_bbox() {
1105 let (m, c) = mount_compute(
1106 View::<()>::new(Style::default())
1107 .children(vec![View::new(Style::default()).fill(Color::WHITE)]),
1108 (200.0, 200.0),
1109 );
1110 assert!(over_bbox(&m, &c).is_none());
1111 }
1112
1113 /// El bbox encierra el subárbol over y NO el resto del árbol: es el punto
1114 /// de todo el cambio. Una barra al pie de un frame grande da una franja,
1115 /// no el frame.
1116 #[test]
1117 fn el_bbox_encierra_solo_la_capa_over() {
1118 let barra = View::<()>::new(Style {
1119 position: Position::Absolute,
1120 inset: TaffyRect {
1121 left: length(20.0_f32),
1122 top: length(180.0_f32),
1123 right: auto(),
1124 bottom: auto(),
1125 },
1126 size: Size {
1127 width: length(160.0_f32),
1128 height: length(6.0_f32),
1129 },
1130 ..Default::default()
1131 })
1132 .fill(Color::WHITE)
1133 .over(true);
1134 let fondo = View::<()>::new(Style {
1135 size: Size {
1136 width: percent(1.0_f32),
1137 height: percent(1.0_f32),
1138 },
1139 ..Default::default()
1140 })
1141 .fill(Color::BLACK);
1142 let (m, c) = mount_compute(
1143 View::<()>::new(Style {
1144 size: Size {
1145 width: length(200.0_f32),
1146 height: length(200.0_f32),
1147 },
1148 ..Default::default()
1149 })
1150 .children(vec![fondo, barra]),
1151 (200.0, 200.0),
1152 );
1153 let (x0, y0, x1, y1) = over_bbox(&m, &c).expect("hay capa over");
1154 // El rect es (20, 180, 160×6), más el margen de seguridad.
1155 assert_eq!(
1156 (x0, y0, x1, y1),
1157 (
1158 20.0 - OVER_BBOX_MARGEN,
1159 180.0 - OVER_BBOX_MARGEN,
1160 180.0 + OVER_BBOX_MARGEN,
1161 186.0 + OVER_BBOX_MARGEN
1162 )
1163 );
1164 // Y lo que importa: es una franja, no el frame. Si esto deja de valer,
1165 // rasterizar la capa over volvió a costar un frame entero.
1166 assert!(
1167 (y1 - y0) < 40.0,
1168 "el bbox se comió el alto del frame: {y0}..{y1}"
1169 );
1170 }
1171
1172 /// La sombra pinta FUERA del rect de su nodo: si el bbox no la contempla,
1173 /// recortar ahí la corta a la mitad.
1174 #[test]
1175 fn el_bbox_crece_con_la_sombra() {
1176 let build = |sombra: Option<Shadow>| {
1177 let mut v = View::<()>::new(Style {
1178 size: Size {
1179 width: length(40.0_f32),
1180 height: length(40.0_f32),
1181 },
1182 ..Default::default()
1183 })
1184 .fill(Color::WHITE)
1185 .over(true);
1186 if let Some(s) = sombra {
1187 v = v.shadow(s);
1188 }
1189 View::<()>::new(Style::default()).children(vec![v])
1190 };
1191 let (m1, c1) = mount_compute(build(None), (200.0, 200.0));
1192 let sin = over_bbox(&m1, &c1).expect("hay capa");
1193
1194 let (m2, c2) = mount_compute(
1195 build(Some(Shadow {
1196 color: Color::BLACK,
1197 blur: 10.0,
1198 dx: 0.0,
1199 dy: 4.0,
1200 spread: 2.0,
1201 })),
1202 (200.0, 200.0),
1203 );
1204 let con = over_bbox(&m2, &c2).expect("hay capa");
1205
1206 assert!(con.0 < sin.0 && con.1 < sin.1, "la sombra no ensanchó arriba/izquierda");
1207 assert!(con.2 > sin.2 && con.3 > sin.3, "la sombra no ensanchó abajo/derecha");
1208 // 3σ de blur + spread hacia cada lado, y el offset hacia abajo.
1209 assert_eq!(con.3 - sin.3, 4.0 + 2.0 + 10.0 * OVER_BBOX_SIGMAS as f32);
1210 }
1211}