tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ a3c2ea9ffa5a97812e9ec043374b756586210bfb
llimphi-compositor/src/render.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
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 corner_radii,
25 shadow,
26 fill_gradient,
27 border,
28 text,
29 image,
30 image_fit,
31 mask_image,
32 mask_placement,
33 mask_extra,
34 painter,
35 gpu_painter,
36 over_painter,
37 on_click,
38 on_click_at,
39 on_right_click,
40 on_right_click_at,
41 on_middle_click,
42 drag,
43 drag_at,
44 drag_velocity,
45 drag_payload,
46 on_drop,
47 drop_hover_fill,
48 clip,
49 clip_inset,
50 clip_ellipse,
51 clip_polygon,
52 clip_path_svg,
53 clip_ref_inset,
54 on_pointer_enter,
55 on_pointer_leave,
56 on_pointer_move_at,
57 on_scroll,
58 on_scale,
59 on_rotate,
60 on_double_tap,
61 on_double_tap_at,
62 on_long_press,
63 on_long_press_at,
64 focusable,
65 text_select_key,
66 alpha,
67 anim,
68 animated_size,
69 semantics,
70 hero,
71 transform,
72 transform_rel,
73 transform_origin,
74 tooltip,
75 cursor,
76 ripple,
77 layout_builder,
78 backdrop_blur,
79 filter,
80 blend,
81 children,
82 } = v;
83 let parent_idx = out.len();
84 out.push(MountedNode {
85 id: NodeId::new(0), // placeholder, lo sobreescribimos abajo
86 fill,
87 hover_fill,
88 radius,
89 corner_radii,
90 shadow,
91 fill_gradient,
92 border,
93 text,
94 image,
95 image_fit,
96 mask_image,
97 mask_placement,
98 mask_extra,
99 painter,
100 gpu_painter,
101 over_painter,
102 on_click,
103 on_click_at,
104 on_right_click,
105 on_right_click_at,
106 on_middle_click,
107 drag,
108 drag_at,
109 drag_velocity,
110 drag_payload,
111 on_drop,
112 drop_hover_fill,
113 clip,
114 clip_inset,
115 clip_ellipse,
116 clip_polygon,
117 clip_path_svg,
118 clip_ref_inset,
119 on_pointer_enter,
120 on_pointer_leave,
121 on_pointer_move_at,
122 on_scroll,
123 on_scale,
124 on_rotate,
125 on_double_tap,
126 on_double_tap_at,
127 on_long_press,
128 on_long_press_at,
129 focusable,
130 text_select_key,
131 alpha,
132 anim,
133 animated_size,
134 semantics,
135 hero,
136 transform,
137 transform_rel,
138 transform_origin,
139 tooltip,
140 cursor,
141 ripple,
142 // Un layout_builder ya expandido llega como nodo normal; si llega sin
143 // expandir (caller no pasó por el runtime), se monta como hoja y este
144 // flag permite que el runtime lo detecte y resuelva.
145 is_layout_builder: layout_builder.is_some(),
146 backdrop_blur,
147 filter,
148 blend,
149 subtree_end: 0,
150 });
151 let mut child_ids = Vec::with_capacity(children.len());
152 for child in children {
153 child_ids.push(mount_recursive(layout, child, out, text_measures));
154 }
155 let id = if child_ids.is_empty() {
156 layout.leaf(style).expect("layout leaf")
157 } else {
158 layout.node(style, &child_ids).expect("layout node")
159 };
160 out[parent_idx].id = id;
161 out[parent_idx].subtree_end = out.len();
162 // Hoja de texto uniforme: registrá su contenido para que el runtime lo
163 // mida con parley. El texto multicolor (`runs`) lo dimensiona el caller
164 // (editor: un nodo por línea), así que no lo medimos acá.
165 if child_ids.is_empty() {
166 if let Some(text) = out[parent_idx].text.as_ref() {
167 if text.runs.is_none() {
168 text_measures.insert(
169 id,
170 TextMeasure {
171 content: text.content.clone(),
172 size_px: text.size_px,
173 alignment: text.alignment,
174 italic: text.italic,
175 font_family: text.font_family.clone(),
176 line_height: text.line_height,
177 weight: text.weight,
178 max_lines: text.max_lines,
179 ellipsis: text.ellipsis,
180 underline: text.underline,
181 strikethrough: text.strikethrough,
182 spans: text.spans.clone(),
183 letter_spacing: text.letter_spacing,
184 word_spacing: text.word_spacing,
185 no_wrap: text.no_wrap,
186 overflow_wrap: text.overflow_wrap,
187 },
188 );
189 }
190 }
191 }
192 id
193}
194
195/// Mide una hoja de texto para taffy: shaping + line-break con parley contra
196/// el ancho disponible, devolviendo el bounding box. Si el ancho ya está
197/// resuelto (`known.width`) se usa ese; si no, se deriva del `available`
198/// (Definite → ese ancho; MaxContent → sin límite = una línea; MinContent →
199/// 0 = envuelve a la palabra más ancha). El `line_height` sale del propio
200/// `TextMeasure`, el mismo que usa `paint`, así medida y pintado coinciden.
201pub fn measure_text_node(
202 ts: &mut llimphi_text::Typesetter,
203 tm: &TextMeasure,
204 known: llimphi_layout::taffy::Size<Option<f32>>,
205 available: llimphi_layout::taffy::Size<llimphi_layout::taffy::AvailableSpace>,
206) -> llimphi_layout::taffy::Size<f32> {
207 use llimphi_layout::taffy::AvailableSpace;
208 // `white-space: nowrap`/`pre`: el texto se mide en una sola línea (ancho
209 // completo) ignorando el `available`/`known` — equivale a MaxContent.
210 let max_width: Option<f32> = if tm.no_wrap {
211 None
212 } else {
213 known.width.or(match available.width {
214 AvailableSpace::Definite(w) => Some(w),
215 AvailableSpace::MaxContent => None,
216 AvailableSpace::MinContent => Some(0.0),
217 })
218 };
219 // RichText: si hay spans, mediar con `layout_spans` para que taffy
220 // reserve el alto considerando overrides de tamaño por rango (un span
221 // con `size_px = 24` dentro de un párrafo de 14 px agranda esa línea).
222 // El clamp `max_lines`/`ellipsis` no se aplica al camino spans en v1
223 // (RichText típico no clampea — los headings y links viven el bloque
224 // completo); el caller que necesite clamp con spans puede recortar el
225 // texto antes de pasarlo.
226 if let Some(spans) = tm.spans.as_ref() {
227 if !spans.is_empty() {
228 let layout = ts.layout_spans(
229 &tm.content,
230 tm.size_px,
231 vello::peniko::Color::from_rgba8(0, 0, 0, 255),
232 tm.weight,
233 tm.line_height,
234 tm.italic,
235 tm.font_family.as_deref(),
236 tm.underline,
237 tm.strikethrough,
238 spans,
239 max_width,
240 tm.alignment,
241 );
242 return llimphi_layout::taffy::Size {
243 width: layout.width(),
244 height: layout.height(),
245 };
246 }
247 }
248 // Camino directo a `layout_clamped` (no `TextBlock`) para transportar
249 // `weight` (bold mide más ancho) y `max_lines` (taffy reserva el alto de
250 // N líneas, no el del texto completo). Sin clamp, equivale a `layout`.
251 let layout = ts.layout_clamped(
252 &tm.content,
253 tm.size_px,
254 max_width,
255 tm.alignment,
256 tm.line_height,
257 tm.italic,
258 tm.font_family.as_deref(),
259 tm.weight,
260 tm.max_lines,
261 tm.ellipsis,
262 tm.underline,
263 tm.strikethrough,
264 tm.letter_spacing,
265 tm.word_spacing,
266 tm.overflow_wrap,
267 );
268 let m = llimphi_text::measurement(&layout);
269 llimphi_layout::taffy::Size { width: m.width, height: m.height }
270}
271
272/// Construye el `RoundedRect` del nodo respetando radio por esquina si lo
273/// hay (si no, el escalar uniforme), con un `inset` opcional restado al rect
274/// y a cada radio (lo usa el borde, que pinta media línea hacia adentro).
275pub(crate) fn node_rrect(
276 x0: f64,
277 y0: f64,
278 x1: f64,
279 y1: f64,
280 radius: f64,
281 corners: Option<RoundedRectRadii>,
282 inset: f64,
283) -> RoundedRect {
284 let radii = match corners {
285 Some(c) => RoundedRectRadii::new(
286 (c.top_left - inset).max(0.0),
287 (c.top_right - inset).max(0.0),
288 (c.bottom_right - inset).max(0.0),
289 (c.bottom_left - inset).max(0.0),
290 ),
291 None => {
292 let r = (radius - inset).max(0.0);
293 RoundedRectRadii::new(r, r, r, r)
294 }
295 };
296 RoundedRect::new(x0 + inset, y0 + inset, x1 - inset, y1 - inset, radii)
297}
298
299/// Resuelve un radio de `clip-path: circle()/ellipse()` a px, dado su
300/// quíntuple `[px, pct_w, pct_h, pct_diag, side]`, el centro local `(cxl,
301/// cyl)` (relativo al origen del rect), el tamaño `(w, h)` y si el radio es
302/// del eje X (`is_x`). Con `side == 0` suma px + porcentajes (diag =
303/// √(w²+h²)/√2). Con `side != 0` ignora px/pct y mide la distancia del centro
304/// a los bordes: `1`/`2` = closest/farthest sobre los 4 lados (circle);
305/// `3`/`4` = ídem sobre el eje del radio (ellipse). Fase 7.1222.
306fn resolve_clip_radius(q: &[f32], cxl: f64, cyl: f64, w: f64, h: f64, is_x: bool) -> f64 {
307 let side = q[4] as i32;
308 if side == 0 {
309 let diag = (w * w + h * h).sqrt() / core::f64::consts::SQRT_2;
310 return q[0] as f64 + q[1] as f64 / 100.0 * w + q[2] as f64 / 100.0 * h
311 + q[3] as f64 / 100.0 * diag;
312 }
313 let (dx_near, dx_far) = (cxl.min(w - cxl), cxl.max(w - cxl));
314 let (dy_near, dy_far) = (cyl.min(h - cyl), cyl.max(h - cyl));
315 match side {
316 1 => dx_near.min(dy_near), // closest-side, circle (4 lados)
317 2 => dx_far.max(dy_far), // farthest-side, circle
318 3 => {
319 if is_x {
320 dx_near
321 } else {
322 dy_near
323 }
324 } // closest-side, ellipse (eje)
325 _ => {
326 if is_x {
327 dx_far
328 } else {
329 dy_far
330 }
331 } // 4 = farthest-side, ellipse
332 }
333}
334
335pub fn paint<Msg>(
336 scene: &mut vello::Scene,
337 mounted: &Mounted<Msg>,
338 computed: &ComputedLayout,
339 typesetter: &mut llimphi_text::Typesetter,
340 hover_idx: Option<usize>,
341 drop_hover_idx: Option<usize>,
342) {
343 paint_range(
344 scene,
345 mounted,
346 computed,
347 typesetter,
348 hover_idx,
349 drop_hover_idx,
350 0,
351 mounted.nodes.len(),
352 Affine::IDENTITY,
353 );
354}
355
356/// Recolecta los nodos con [`MountedNode::backdrop_blur`] activo del árbol
357/// montado, junto con el sigma y el rect absoluto al cual restringir el
358/// blur. El runtime (`llimphi-ui::eventloop`) los aplica como post-pasada
359/// **después** de la rasterización vello, sobre la intermediate.
360///
361/// La búsqueda **salta el subárbol** al encontrar un blur — sin anidamiento
362/// en v1: un blur dentro de otro blur sería redundante (el padre ya borrona
363/// el rect que cubre al hijo).
364///
365/// **Limitación v1 (post-pasada)**: el blur ocurre tras vello, así que el
366/// fill/text/imagen del nodo blur y sus descendientes — pintados antes en
367/// la misma rasterización — quedan **borroneados** también. Útil para
368/// paneles "vidrio sobre fondo" sin contenido propio (el contenido nítido
369/// se compone como nodo hermano posterior con el mismo rect). La paridad
370/// completa con CSS `backdrop-filter` requiere scene-split (Bloque 11.B
371/// del roadmap).
372pub fn collect_backdrop_blurs<Msg>(
373 mounted: &Mounted<Msg>,
374 computed: &ComputedLayout,
375) -> Vec<BackdropBlur> {
376 let mut out = Vec::new();
377 let mut idx = 0;
378 while idx < mounted.nodes.len() {
379 let node = &mounted.nodes[idx];
380 if let Some(sigma) = node.backdrop_blur {
381 if let Some(r) = computed.get(node.id) {
382 out.push(BackdropBlur {
383 sigma,
384 rect: (r.x, r.y, r.w, r.h),
385 });
386 idx = node.subtree_end;
387 continue;
388 }
389 }
390 idx += 1;
391 }
392 out
393}
394
395/// Datos de un backdrop blur listos para que el runtime lo aplique sobre
396/// la intermediate vía `llimphi_hal::BlurCompositor::blur`.
397#[derive(Debug, Clone, Copy)]
398pub struct BackdropBlur {
399 /// Sigma del Gauss en pixels lógicos.
400 pub sigma: f32,
401 /// Rect absoluto `(x, y, w, h)` del nodo, en pixels lógicos del viewport.
402 pub rect: (f32, f32, f32, f32),
403}
404
405/// Una operación de `filter` lista para que el runtime la aplique sobre la
406/// intermediate, restringida a `rect`. Espeja [`BackdropBlur`] pero lleva una
407/// [`FilterOp`] genérica (el runtime hace match por variante). Fase 7.1232.
408#[derive(Debug, Clone)]
409pub struct FilterPass {
410 /// Rect absoluto `(x, y, w, h)` del nodo, en pixels lógicos del viewport.
411 pub rect: (f32, f32, f32, f32),
412 /// La operación a aplicar (blur / color-matrix / …).
413 pub op: FilterOp,
414}
415
416/// Recolecta los nodos con [`MountedNode::filter`] no vacío y los aplana en una
417/// lista de [`FilterPass`] **en orden de árbol y en orden de la lista de cada
418/// nodo** — así el runtime aplica la cadena `filter: a b c` en secuencia sobre
419/// el rect (a, luego b, luego c). El runtime las consume tras la rasterización
420/// vello, igual que [`collect_backdrop_blurs`].
421///
422/// Salta el subárbol al encontrar un nodo con filtro (como backdrop_blur): un
423/// filtro anidado sobre el mismo rect sería redundante en la post-pasada v1.
424pub fn collect_filters<Msg>(
425 mounted: &Mounted<Msg>,
426 computed: &ComputedLayout,
427) -> Vec<FilterPass> {
428 let mut out = Vec::new();
429 let mut idx = 0;
430 while idx < mounted.nodes.len() {
431 let node = &mounted.nodes[idx];
432 if !node.filter.is_empty() {
433 if let Some(r) = computed.get(node.id) {
434 let rect = (r.x, r.y, r.w, r.h);
435 for op in &node.filter {
436 // `DropShadow` se pinta en vello (paint_range), no como
437 // post-pasada GPU — la salteamos acá. Fase 7.1234.
438 if matches!(op, FilterOp::DropShadow(_)) {
439 continue;
440 }
441 out.push(FilterPass { rect, op: op.clone() });
442 }
443 idx = node.subtree_end;
444 continue;
445 }
446 }
447 idx += 1;
448 }
449 out
450}
451
452/// Resuelve el afín efectivo de un nodo a partir de su `transform` (afín fijo)
453/// y/o `transform_rel` (traslación en fracción de su tamaño), centrado por
454/// `transform-origin: 50% 50%` contra su rect computado `r`. El `transform_rel`
455/// entra como factor más externo (`T_rel · transform`), igual que un
456/// `translate(<%>)` al frente de la lista CSS. `None` si el nodo no tiene
457/// ninguno de los dos (caso mayoritario → no se toca el stack de transform).
458/// Lo usan `paint_range` y los walks de hit-test para mantenerse en sincronía.
459pub(crate) fn resolve_node_transform(
460 transform: Option<Affine>,
461 transform_rel: Option<(f64, f64)>,
462 transform_origin: Option<crate::TransformPivot>,
463 r: llimphi_layout::Rect,
464) -> Option<Affine> {
465 if transform.is_none() && transform_rel.is_none() {
466 return None;
467 }
468 let mut local = transform.unwrap_or(Affine::IDENTITY);
469 if let Some((fx, fy)) = transform_rel {
470 local = Affine::translate((fx * r.w as f64, fy * r.h as f64)) * local;
471 }
472 // Pivote = `transform-origin`: `px + frac · tamaño` por eje contra el rect.
473 // `None` ⇒ centro (default CSS `50% 50%`). El afín se ancla al pivote:
474 // `T(pivote) · local · T(-pivote)`.
475 let pivot = transform_origin.unwrap_or_default();
476 let ox = r.x as f64 + pivot.px.0 + pivot.frac.0 * r.w as f64;
477 let oy = r.y as f64 + pivot.px.1 + pivot.frac.1 * r.h as f64;
478 Some(Affine::translate((ox, oy)) * local * Affine::translate((-ox, -oy)))
479}
480
481/// Pinta el rango de nodos `[start, end)` de `mounted` en `scene`, partiendo de
482/// la transformación acumulada `base_xf`. [`paint`] lo llama con todo el árbol
483/// (`0..len`, `IDENTITY`). El rango permite **capturar un subárbol** en una
484/// escena aparte (p. ej. el snapshot de un nodo que va a animar su salida, ver
485/// [`crate::AnimRegistry`]): se pasa `(start, subtree_end)` del nodo raíz. Las
486/// coordenadas de los rects ya son absolutas, así que la subescena se puede
487/// reproducir luego con `scene.append` aunque sus ancestros ya no existan.
488///
489/// Las capas (clip/alpha) que el subárbol abre se cierran dentro del rango (su
490/// `subtree_end ≤ end`) o por el drenaje final — la LIFO se respeta. `base_xf`
491/// debería ser la transformación de los ancestros del nodo raíz; al capturar
492/// se pasa `IDENTITY` (v1 no contempla raíces bajo ancestros transformados).
493/// Cierra una capa de aislamiento de `mask-image` aplicando la máscara al
494/// contenido ya pintado en la capa (el subárbol del nodo). Según `mask-mode`
495/// (Fase 7.1228) abre una capa de **luminancia** (`push_luminance_mask_layer`)
496/// o **alpha** (`Compose::DestIn`); dentro pinta la capa 0 (`img`) y las capas
497/// extra (`extra`, Fase 7.1231) combinadas por su operador `mask-composite`. El
498/// caller cierra la capa de aislamiento con su propio `pop_layer` tras esto.
499///
500/// `placement` (Fase 7.1227+) fija el encaje con la misma aritmética que
501/// `background-image` (size → tamaño del tile, position → offset del primero,
502/// repeat → tiling por eje), el modo (`mask-mode`, Fase 7.1228) y las cajas de
503/// referencia (`mask-clip` recorta la capa, `mask-origin` ancla el tiling, Fase
504/// 7.1230). `None` = estirar al border-box en modo luminancia (Fase 7.1226).
505fn paint_mask_close(
506 scene: &mut vello::Scene,
507 img: &Image,
508 extra: &[(Image, MaskCompose)],
509 rect: KurboRect,
510 xf: Affine,
511 placement: Option<MaskPlacement>,
512) {
513 // Cajas de referencia (Fase 7.1230): `mask-clip` recorta el efecto;
514 // `mask-origin` ancla el tiling/position. Se encoge el border-box `rect`
515 // por los insets resueltos. `None` = border-box (sin cambio).
516 let shrink = |r: KurboRect, inset: Option<[f32; 4]>| -> KurboRect {
517 match inset {
518 None => r,
519 Some([t, ri, b, le]) => KurboRect::new(
520 r.x0 + le as f64,
521 r.y0 + t as f64,
522 (r.x1 - ri as f64).max(r.x0 + le as f64),
523 (r.y1 - b as f64).max(r.y0 + t as f64),
524 ),
525 }
526 };
527 let clip_rect = shrink(rect, placement.and_then(|p| p.clip_inset));
528 let origin_rect = shrink(rect, placement.and_then(|p| p.origin_inset));
529 // Apertura de la capa según `mask-mode` (Fase 7.1228), recortada a la caja
530 // de `mask-clip`: luminance usa la capa de luminancia nativa de vello;
531 // alpha compone la máscara con `Compose::DestIn` (mantiene el destino —el
532 // subárbol ya pintado— donde la fuente —la máscara— tiene alpha). Sin
533 // `MaskPlacement` el modo es luminancia (Fase 7.1226).
534 let mode = placement.map(|p| p.mode).unwrap_or(MaskMode::Luminance);
535 match mode {
536 MaskMode::Luminance => scene.push_luminance_mask_layer(Fill::NonZero, 1.0, xf, &clip_rect),
537 MaskMode::Alpha => scene.push_layer(
538 Fill::NonZero,
539 vello::peniko::BlendMode::new(Mix::Normal, vello::peniko::Compose::DestIn),
540 1.0,
541 xf,
542 &clip_rect,
543 ),
544 }
545 // Capa 0 + capas extra (Fase 7.1231). Las extras comparten el `placement`.
546 // `add` (default) se dibuja directo (source-over acumula la máscara); el
547 // resto compone vía un `Compose` Porter-Duff en una sub-capa.
548 //
549 // NOTA: la composición multi-capa no está verificada a píxeles (CI sin GPU);
550 // el mapeo mask-composite → Compose es el de la spec. Para `mask-mode:
551 // luminance` con varias capas la combinación es aproximada (se compone la
552 // imagen y luego la capa toma su luminancia), exacta para `alpha`.
553 draw_mask_layer(scene, img, origin_rect, xf, placement);
554 for (eimg, op) in extra {
555 match op {
556 MaskCompose::Add => draw_mask_layer(scene, eimg, origin_rect, xf, placement),
557 _ => {
558 let compose = match op {
559 MaskCompose::Subtract => vello::peniko::Compose::SrcOut,
560 MaskCompose::Intersect => vello::peniko::Compose::SrcIn,
561 MaskCompose::Exclude => vello::peniko::Compose::Xor,
562 MaskCompose::Add => unreachable!(),
563 };
564 scene.push_layer(
565 Fill::NonZero,
566 vello::peniko::BlendMode::new(Mix::Normal, compose),
567 1.0,
568 xf,
569 &clip_rect,
570 );
571 draw_mask_layer(scene, eimg, origin_rect, xf, placement);
572 scene.pop_layer();
573 }
574 }
575 }
576 scene.pop_layer();
577}
578
579/// Pinta UNA imagen-máscara dentro de la capa de máscara ya abierta, con su
580/// encaje (`placement`): `None` la estira a `origin_rect` (Fase 7.1226), `Some`
581/// la tilea (size/position/repeat resueltos contra `origin_rect`, Fase
582/// 7.1227/7.1230). La comparten la capa 0 y las extra (Fase 7.1231). No abre ni
583/// cierra capas — el caller controla la capa de máscara y el compose.
584fn draw_mask_layer(
585 scene: &mut vello::Scene,
586 img: &Image,
587 origin_rect: KurboRect,
588 xf: Affine,
589 placement: Option<MaskPlacement>,
590) {
591 let iw = img.image.width.max(1) as f64;
592 let ih = img.image.height.max(1) as f64;
593 match placement {
594 // Estirar la máscara a la caja de origen (= border-box si no hay
595 // mask-origin).
596 None => {
597 let fit = Affine::translate((origin_rect.x0, origin_rect.y0))
598 * Affine::scale_non_uniform(origin_rect.width() / iw, origin_rect.height() / ih);
599 scene.draw_image(img, xf * fit);
600 }
601 // size/position/repeat estilo background-image, resueltos contra la caja
602 // de `mask-origin`.
603 Some(p) => {
604 let rw = origin_rect.width();
605 let rh = origin_rect.height();
606 // 1) Tamaño del tile (px). `Auto` por eje deriva el otro por aspecto.
607 let resolve = |l: MaskLen, basis: f64| -> Option<f64> {
608 match l {
609 MaskLen::Px(n) => Some(n as f64),
610 MaskLen::Pct(q) => Some(basis * q as f64 / 100.0),
611 MaskLen::Auto => None,
612 }
613 };
614 let (tw, th) = match p.size {
615 MaskSize::Auto => (iw, ih),
616 MaskSize::Cover => {
617 let s = (rw / iw).max(rh / ih);
618 (iw * s, ih * s)
619 }
620 MaskSize::Contain => {
621 let s = (rw / iw).min(rh / ih);
622 (iw * s, ih * s)
623 }
624 MaskSize::Explicit { x, y } => match (resolve(x, rw), resolve(y, rh)) {
625 (Some(w), Some(h)) => (w, h),
626 (Some(w), None) => (w, w * ih / iw),
627 (None, Some(h)) => (h * iw / ih, h),
628 (None, None) => (iw, ih),
629 },
630 };
631 if tw > 0.5 && th > 0.5 {
632 // 2) Offset del primer tile. `Pct` = alineación CSS.
633 let pos_off = |l: MaskLen, basis: f64, tile: f64| -> f64 {
634 match l {
635 MaskLen::Px(n) => n as f64,
636 MaskLen::Pct(q) => (basis - tile) * q as f64 / 100.0,
637 MaskLen::Auto => 0.0,
638 }
639 };
640 let ox = pos_off(p.pos_x, rw, tw);
641 let oy = pos_off(p.pos_y, rh, th);
642 // 3) Posiciones de inicio cubriendo [0, span] (o sólo el offset
643 // si el eje no repite). Cap defensivo contra tiles diminutos.
644 let axis = |off: f64, tile: f64, span: f64, rep: bool| -> Vec<f64> {
645 if !rep {
646 return vec![off];
647 }
648 let mut start = off;
649 while start > 0.0 {
650 start -= tile;
651 }
652 let mut v = Vec::new();
653 let mut q = start;
654 while q < span && v.len() < 4096 {
655 v.push(q);
656 q += tile;
657 }
658 v
659 };
660 let xs = axis(ox, tw, rw, p.repeat_x);
661 let ys = axis(oy, th, rh, p.repeat_y);
662 let scale = Affine::scale_non_uniform(tw / iw, th / ih);
663 for &x in &xs {
664 for &y in &ys {
665 let tf =
666 Affine::translate((origin_rect.x0 + x, origin_rect.y0 + y)) * scale;
667 scene.draw_image(img, xf * tf);
668 }
669 }
670 }
671 }
672 }
673}
674
675#[allow(clippy::too_many_arguments)]
676pub fn paint_range<Msg>(
677 scene: &mut vello::Scene,
678 mounted: &Mounted<Msg>,
679 computed: &ComputedLayout,
680 typesetter: &mut llimphi_text::Typesetter,
681 hover_idx: Option<usize>,
682 drop_hover_idx: Option<usize>,
683 start: usize,
684 end: usize,
685 base_xf: Affine,
686) {
687 // Stack de las capas `push_layer` activas. Vello requiere pop_layer en
688 // orden LIFO estricto, así que mantenemos un único stack común y popeamos
689 // en el orden inverso al push. Cada entrada es `(subtree_end, máscara?)`:
690 // la mayoría son `None` (clip y/o alpha — la capa sólo se cierra con
691 // `pop_layer`); las de `mask-image` llevan `Some((imagen, rect, xf))` y al
692 // cerrar aplican la luminancia de la máscara sobre el subárbol ya pintado
693 // (ver `paint_mask_close`) antes del `pop_layer` de la capa de aislamiento.
694 // Dos entradas con el mismo `subtree_end` (p. ej. alpha + mask + clip sobre
695 // el mismo nodo) se cierran en el orden inverso al push.
696 // Payload de máscara: (capa 0, capas extra `(img, op)`, rect border-box, xf,
697 // placement compartido). `paint_mask_close` lo consume al cerrar.
698 type MaskClose = (
699 Image,
700 Vec<(Image, MaskCompose)>,
701 KurboRect,
702 Affine,
703 Option<MaskPlacement>,
704 );
705 let mut layer_stack: Vec<(usize, Option<MaskClose>)> = Vec::new();
706 // Stack de transformaciones afines de subtree. Cada entrada guarda el
707 // `subtree_end` y la `cur_xf` previa para restaurarla al salir del
708 // subárbol. `cur_xf` es el producto acumulado de todos los `transform`
709 // de los ancestros activos — se multiplica en cada draw call. Cuando
710 // ningún nodo transforma, queda en `base_xf` y el paint es idéntico
711 // al previo (cero regresión).
712 let mut xf_stack: Vec<(usize, Affine)> = Vec::new();
713 let mut cur_xf = base_xf;
714 for idx in start..end {
715 let node = &mounted.nodes[idx];
716 // Cierre de capas que ya quedaron atrás (idx ≥ subtree_end). Si la
717 // capa es una máscara, aplicamos su luminancia ANTES del pop.
718 while let Some(&(end, _)) = layer_stack.last() {
719 if idx >= end {
720 let (_, mask) = layer_stack.pop().unwrap();
721 if let Some((img, extra, rect, xf, placement)) = &mask {
722 paint_mask_close(scene, img, extra, *rect, *xf, *placement);
723 }
724 scene.pop_layer();
725 } else {
726 break;
727 }
728 }
729 // Restaurá la transformación al salir de subárboles transformados.
730 while let Some(&(end, prev)) = xf_stack.last() {
731 if idx >= end {
732 cur_xf = prev;
733 xf_stack.pop();
734 } else {
735 break;
736 }
737 }
738 let Some(r) = computed.get(node.id) else {
739 continue;
740 };
741 // Transform CSS del nodo: se aplica alrededor del centro de su rect
742 // (`transform-origin: 50% 50%`) y se compone sobre la del padre. Se
743 // empuja ANTES del alpha/fill para que toda la pintura del subtree
744 // (incl. la capa de alpha y el clip) caiga en el espacio transformado.
745 if let Some(centered) = resolve_node_transform(node.transform, node.transform_rel, node.transform_origin, r) {
746 xf_stack.push((node.subtree_end, cur_xf));
747 cur_xf *= centered;
748 }
749 // `mix-blend-mode` (Fase 7.1237): abrí una capa de mezcla para el
750 // subárbol del nodo con el modo CSS resuelto. Va ANTES del alpha (es la
751 // capa más externa) para que el elemento entero —incluida su propia
752 // opacidad— se mezcle como una unidad contra el backdrop ya pintado. Al
753 // cerrarse (loop de cierre / drain final) vello compone el subárbol
754 // aislado con el blend indicado. v1: el backdrop es la escena ya
755 // pintada, no un fondo aislado (exacto con contenido opaco debajo).
756 if let Some(bm) = node.blend {
757 let rect = KurboRect::new(
758 r.x as f64,
759 r.y as f64,
760 (r.x + r.w) as f64,
761 (r.y + r.h) as f64,
762 );
763 scene.push_layer(Fill::NonZero, bm, 1.0, cur_xf, &rect);
764 layer_stack.push((node.subtree_end, None));
765 }
766 // Alpha de subtree: push ANTES de cualquier paint de este nodo
767 // para que fill/text/image/painter/children entren en la misma
768 // capa y se compongan juntos al alfa indicado. Si el nodo tiene
769 // hijos, su `subtree_end > idx + 1` y la capa permanece abierta
770 // hasta que el loop alcance el primer índice fuera del subárbol.
771 // Para nodos hoja con alpha el push y el pop son consecutivos —
772 // funcionalmente equivalente a multiplicar el alpha del fill,
773 // pero permite usar el mismo API sin distinguir hoja vs rama.
774 if let Some(a) = node.alpha {
775 let rect = KurboRect::new(
776 r.x as f64,
777 r.y as f64,
778 (r.x + r.w) as f64,
779 (r.y + r.h) as f64,
780 );
781 scene.push_layer(Fill::NonZero, Mix::Normal, a, cur_xf, &rect);
782 layer_stack.push((node.subtree_end, None));
783 }
784 // `mask-image` (Fase 7.1226): abrí una capa de aislamiento para el
785 // subárbol del nodo. La luminancia de la máscara se aplica al CERRARLA
786 // (en el loop de cierre / drain final, vía `paint_mask_close`), así
787 // recorta sólo a este nodo + hijos y no a los hermanos previos. Va
788 // DESPUÉS del alpha (afuera del clip-path, que se pushea al final del
789 // bloque) para envolver fill + contenido + hijos.
790 if let Some(mask_img) = node.mask_image.as_ref() {
791 let rect = KurboRect::new(
792 r.x as f64,
793 r.y as f64,
794 (r.x + r.w) as f64,
795 (r.y + r.h) as f64,
796 );
797 scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, cur_xf, &rect);
798 layer_stack.push((
799 node.subtree_end,
800 Some((
801 mask_img.clone(),
802 node.mask_extra.clone(),
803 rect,
804 cur_xf,
805 node.mask_placement,
806 )),
807 ));
808 }
809 // Sombra (drop shadow): se pinta ANTES del relleno para quedar
810 // detrás. Usa el blur gaussiano nativo de vello sobre un rect
811 // redondeado offseteado + inflado por `spread`.
812 if let Some(sh) = node.shadow.as_ref() {
813 if sh.color.components[3] > 0.0 && r.w > 0.0 && r.h > 0.0 {
814 let rect = KurboRect::new(
815 (r.x as f64) + sh.dx - sh.spread,
816 (r.y as f64) + sh.dy - sh.spread,
817 (r.x + r.w) as f64 + sh.dx + sh.spread,
818 (r.y + r.h) as f64 + sh.dy + sh.spread,
819 );
820 let radius = (node.radius + sh.spread).max(0.0);
821 scene.draw_blurred_rounded_rect(cur_xf, rect, sh.color, radius, sh.blur);
822 }
823 }
824 // `filter: drop-shadow(...)` (Fase 7.1234): una o más sombras Gaussianas
825 // del border-box, detrás del relleno. Misma primitiva que box-shadow; v1
826 // pinta la sombra del rect, no de la silueta alpha del subárbol. En
827 // orden de la lista (la primera declarada queda más atrás).
828 for op in node.filter.iter().rev() {
829 if let FilterOp::DropShadow(sh) = op {
830 if sh.color.components[3] > 0.0 && r.w > 0.0 && r.h > 0.0 {
831 let rect = KurboRect::new(
832 (r.x as f64) + sh.dx - sh.spread,
833 (r.y as f64) + sh.dy - sh.spread,
834 (r.x + r.w) as f64 + sh.dx + sh.spread,
835 (r.y + r.h) as f64 + sh.dy + sh.spread,
836 );
837 let radius = (node.radius + sh.spread).max(0.0);
838 scene.draw_blurred_rounded_rect(cur_xf, rect, sh.color, radius, sh.blur);
839 }
840 }
841 }
842 // Prioridad de pintura: drop-hover (drag activo) > hover normal >
843 // gradiente base > fill color base. Solo aplica el override si el
844 // slot correspondiente está poblado; el siguiente cae como fallback.
845 let hover_color = if Some(idx) == drop_hover_idx {
846 node.drop_hover_fill.or(node.hover_fill).or(node.fill)
847 } else if Some(idx) == hover_idx {
848 node.hover_fill.or(node.fill)
849 } else {
850 None
851 };
852 let rr = node_rrect(
853 r.x as f64,
854 r.y as f64,
855 (r.x + r.w) as f64,
856 (r.y + r.h) as f64,
857 node.radius,
858 node.corner_radii,
859 0.0,
860 );
861 if let Some(color) = hover_color {
862 // Hover/drop gana sobre el gradiente y el fill base.
863 scene.fill(Fill::NonZero, cur_xf, color, None, &rr);
864 } else if let Some(grad) = node.fill_gradient.as_ref() {
865 // Gradiente autoreado en `[0,1]²`, mapeado al rect vía
866 // brush_transform (incluye la transformación acumulada).
867 let brush_xf = cur_xf
868 * Affine::translate((r.x as f64, r.y as f64))
869 * Affine::scale_non_uniform(r.w as f64, r.h as f64);
870 scene.fill(Fill::NonZero, cur_xf, grad, Some(brush_xf), &rr);
871 } else if let Some(color) = node.fill {
872 scene.fill(Fill::NonZero, cur_xf, color, None, &rr);
873 }
874 // Borde (stroke) sobre el relleno, inset media línea hacia adentro.
875 if let Some(b) = node.border.as_ref() {
876 if b.width > 0.0 && b.color.components[3] > 0.0 && r.w > 0.0 && r.h > 0.0 {
877 let inset = b.width * 0.5;
878 let brr = node_rrect(
879 r.x as f64,
880 r.y as f64,
881 (r.x + r.w) as f64,
882 (r.y + r.h) as f64,
883 node.radius,
884 node.corner_radii,
885 inset,
886 );
887 scene.stroke(&Stroke::new(b.width), cur_xf, b.color, None, &brr);
888 }
889 }
890 if let Some(image) = node.image.as_ref() {
891 // Encaje seleccionable (Bloque 12) — Contain/Cover/Fill/None.
892 // Siempre clippeamos al `node_rrect` para respetar
893 // `radius`/`corner_radii` (avatares + cards) y para que
894 // `Cover`/`None` no derramen fuera del nodo.
895 if image.image.width > 0 && image.image.height > 0 && r.w > 0.0 && r.h > 0.0 {
896 let sx = r.w as f64 / image.image.width as f64;
897 let sy = r.h as f64 / image.image.height as f64;
898 let fit = node.image_fit.unwrap_or(ImageFit::Contain);
899 let transform = match fit {
900 ImageFit::Contain => {
901 let s = sx.min(sy);
902 let disp_w = image.image.width as f64 * s;
903 let disp_h = image.image.height as f64 * s;
904 let tx = r.x as f64 + (r.w as f64 - disp_w) * 0.5;
905 let ty = r.y as f64 + (r.h as f64 - disp_h) * 0.5;
906 Affine::translate((tx, ty)) * Affine::scale(s)
907 }
908 ImageFit::Cover => {
909 let s = sx.max(sy);
910 let disp_w = image.image.width as f64 * s;
911 let disp_h = image.image.height as f64 * s;
912 let tx = r.x as f64 + (r.w as f64 - disp_w) * 0.5;
913 let ty = r.y as f64 + (r.h as f64 - disp_h) * 0.5;
914 Affine::translate((tx, ty)) * Affine::scale(s)
915 }
916 ImageFit::Fill => {
917 Affine::translate((r.x as f64, r.y as f64))
918 * Affine::scale_non_uniform(sx, sy)
919 }
920 ImageFit::None => {
921 let disp_w = image.image.width as f64;
922 let disp_h = image.image.height as f64;
923 let tx = r.x as f64 + (r.w as f64 - disp_w) * 0.5;
924 let ty = r.y as f64 + (r.h as f64 - disp_h) * 0.5;
925 Affine::translate((tx, ty))
926 }
927 };
928 let clip_rr = node_rrect(
929 r.x as f64,
930 r.y as f64,
931 (r.x + r.w) as f64,
932 (r.y + r.h) as f64,
933 node.radius,
934 node.corner_radii,
935 0.0,
936 );
937 scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, cur_xf, &clip_rr);
938 scene.draw_image(image, cur_xf * transform);
939 scene.pop_layer();
940 }
941 }
942 if let Some(painter) = node.painter.as_ref() {
943 (painter)(
944 scene,
945 typesetter,
946 PaintRect {
947 x: r.x,
948 y: r.y,
949 w: r.w,
950 h: r.h,
951 },
952 );
953 }
954 if let Some(text) = node.text.as_ref() {
955 let has_spans = text
956 .spans
957 .as_ref()
958 .map(|s| !s.is_empty())
959 .unwrap_or(false);
960 if has_spans {
961 // RichText (Bloque 13): defaults a nivel bloque + spans
962 // sobreescriben size/weight/italic/family/color/underline/
963 // strikethrough por rango de bytes. Respeta `max_width = r.w`
964 // (wrap a párrafo) y la alignment del bloque; para Center
965 // también centramos verticalmente como en el camino uniforme.
966 let spans = text.spans.as_ref().unwrap();
967 let layout = typesetter.layout_spans(
968 &text.content,
969 text.size_px,
970 text.color,
971 text.weight,
972 text.line_height,
973 text.italic,
974 text.font_family.as_deref(),
975 text.underline,
976 text.strikethrough,
977 spans,
978 Some(r.w),
979 text.alignment,
980 );
981 let origin =
982 if matches!(text.alignment, llimphi_text::Alignment::Center) {
983 let lh = layout.height() as f64;
984 (
985 r.x as f64,
986 r.y as f64 + ((r.h as f64 - lh) * 0.5).max(0.0),
987 )
988 } else {
989 (r.x as f64, r.y as f64)
990 };
991 llimphi_text::draw_layout_runs_xf(
992 scene,
993 &layout,
994 cur_xf * Affine::translate(origin),
995 );
996 } else if let Some(runs) = text.runs.as_ref() {
997 // Texto multicolor (syntax highlighting): una sola pasada de
998 // shaping con color por rango, anclado arriba-izquierda. Cae
999 // por el flujo normal (clip/alpha se cierran como siempre).
1000 let layout = typesetter.layout_runs(
1001 &text.content,
1002 text.size_px,
1003 text.color,
1004 runs,
1005 text.alignment,
1006 text.line_height,
1007 text.weight,
1008 text.underline,
1009 text.strikethrough,
1010 );
1011 // `cur_xf *` para que el texto multicolor herede la
1012 // transformación del subárbol (scroll/rotación del padre), igual
1013 // que el texto de color único de abajo. Sin esto se pintaba en
1014 // coords de layout crudas y se desalineaba al scrollear.
1015 llimphi_text::draw_layout_runs_xf(
1016 scene,
1017 &layout,
1018 cur_xf * Affine::translate((r.x as f64, r.y as f64)),
1019 );
1020 } else {
1021 // Parley resuelve la alineación horizontal vía max_width +
1022 // alignment. Para Center también centramos verticalmente; para
1023 // Start/End/Justify anclamos arriba (párrafo/editor). Camino
1024 // directo a `layout_clamped` para transportar `weight` y el
1025 // clamp de `max_lines`/`ellipsis` del TextSpec.
1026 // `white-space: nowrap`/`pre`: pintar en una sola línea (sin
1027 // `max_width`), no envolver al ancho del rect — el texto
1028 // desborda y lo recorta el `overflow` del contenedor si lo hay.
1029 let paint_max_width = if text.no_wrap { None } else { Some(r.w) };
1030 let layout = typesetter.layout_clamped(
1031 &text.content,
1032 text.size_px,
1033 paint_max_width,
1034 text.alignment,
1035 text.line_height,
1036 text.italic,
1037 text.font_family.as_deref(),
1038 text.weight,
1039 text.max_lines,
1040 text.ellipsis,
1041 text.underline,
1042 text.strikethrough,
1043 text.letter_spacing,
1044 text.word_spacing,
1045 text.overflow_wrap,
1046 );
1047 let origin =
1048 if matches!(text.alignment, llimphi_text::Alignment::Center) {
1049 let m = llimphi_text::measurement(&layout);
1050 (
1051 r.x as f64,
1052 r.y as f64 + ((r.h - m.height) as f64 * 0.5).max(0.0),
1053 )
1054 } else {
1055 (r.x as f64, r.y as f64)
1056 };
1057 llimphi_text::draw_layout_xf(
1058 scene,
1059 &layout,
1060 text.color,
1061 cur_xf * Affine::translate(origin),
1062 );
1063 }
1064 }
1065 if node.clip {
1066 // El hit-test (más abajo) usa siempre el rect completo — el clip-path
1067 // sólo afecta el pintado, una aproximación menor en su banda.
1068 // Prioridad: path > polygon > elipse > inset/rect. `pushed` queda
1069 // false sólo si un path() no parsea (no se abre capa → no se cierra).
1070 let mut pushed = true;
1071 // Caja de referencia (clip-path geometry-box, Fase 7.1225): encoge
1072 // el rect del nodo por `clip_ref_inset` ANTES de resolver la forma,
1073 // así circle/ellipse/polygon/path y sus % se miden contra esa caja.
1074 let [rit, rir, rib, ril] = node.clip_ref_inset.unwrap_or([0.0; 4]);
1075 let (bx, by) = ((r.x + ril) as f64, (r.y + rit) as f64);
1076 let (bw, bh) = ((r.w - ril - rir).max(0.0) as f64, (r.h - rit - rib).max(0.0) as f64);
1077 if let Some((evenodd, d)) = &node.clip_path_svg {
1078 // `clip-path: path()` — parsea el SVG y lo traslada al origen
1079 // de la caja de referencia (user units px). from_svg falla → no
1080 // recorta.
1081 match vello::kurbo::BezPath::from_svg(d) {
1082 Ok(mut path) => {
1083 path.apply_affine(Affine::translate((bx, by)));
1084 let fill = if *evenodd { Fill::EvenOdd } else { Fill::NonZero };
1085 scene.push_layer(fill, BlendMode::default(), 1.0, cur_xf, &path);
1086 }
1087 Err(_) => pushed = false,
1088 }
1089 } else if let Some((evenodd, pts)) = &node.clip_polygon {
1090 // `clip-path: polygon()` — capa con un path cerrado. Cada punto
1091 // resuelve sus % contra la caja de referencia; move_to al 1º,
1092 // line_to al resto, close_path.
1093 let mut path = vello::kurbo::BezPath::new();
1094 for (i, p) in pts.iter().enumerate() {
1095 let px = bx + p[0] as f64 + p[1] as f64 / 100.0 * bw;
1096 let py = by + p[2] as f64 + p[3] as f64 / 100.0 * bh;
1097 if i == 0 {
1098 path.move_to((px, py));
1099 } else {
1100 path.line_to((px, py));
1101 }
1102 }
1103 path.close_path();
1104 let fill = if *evenodd { Fill::EvenOdd } else { Fill::NonZero };
1105 scene.push_layer(fill, BlendMode::default(), 1.0, cur_xf, &path);
1106 } else if let Some(s) = node.clip_ellipse {
1107 // `clip-path: circle()/ellipse()` — capa elíptica. Centro y
1108 // radios resuelven contra la caja de referencia. El centro local
1109 // alimenta tanto la posición como el cómputo de los lados
1110 // (closest/farthest-side).
1111 let cxl = s[0] as f64 + s[1] as f64 / 100.0 * bw;
1112 let cyl = s[2] as f64 + s[3] as f64 / 100.0 * bh;
1113 let cx = bx + cxl;
1114 let cy = by + cyl;
1115 let rx = resolve_clip_radius(&s[4..9], cxl, cyl, bw, bh, true);
1116 let ry = resolve_clip_radius(&s[9..14], cxl, cyl, bw, bh, false);
1117 let ellipse = Ellipse::new((cx, cy), (rx, ry), 0.0);
1118 scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, cur_xf, &ellipse);
1119 } else {
1120 // `clip_inset` (clip-path: inset) encoge la caja de referencia
1121 // desde cada borde; `None` (overflow:hidden / geometry-box solo)
1122 // recorta a la caja de referencia completa.
1123 let [ct, cr, cb, cl] = node.clip_inset.unwrap_or([0.0; 4]);
1124 let clip_rect = KurboRect::new(
1125 bx + cl as f64,
1126 by + ct as f64,
1127 bx + bw - cr as f64,
1128 by + bh - cb as f64,
1129 );
1130 scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, cur_xf, &clip_rect);
1131 }
1132 if pushed {
1133 layer_stack.push((node.subtree_end, None));
1134 }
1135 }
1136 }
1137 // Cerrá capas (clip + alpha + mask) que llegaron al final sin pop
1138 // intermedio. Las de máscara aplican su luminancia antes del pop.
1139 while let Some((_, mask)) = layer_stack.pop() {
1140 if let Some((img, extra, rect, xf, placement)) = &mask {
1141 paint_mask_close(scene, img, extra, *rect, *xf, *placement);
1142 }
1143 scene.pop_layer();
1144 }
1145}
1146
1147/// Pasada GPU directo: recorre el `Mounted` en pre-orden DFS (mismo orden
1148/// que [`paint`]) e invoca cada `gpu_painter` con el encoder y la
1149/// `TextureView` del frame. Se ejecuta DESPUÉS de la pasada vello — la
1150/// intermediate ya tiene fill/image/painter/text encima cuando los
1151/// callbacks corren, así que su `LoadOp` debe ser `Load`. Devuelve si
1152/// se invocó al menos un painter (para que el caller decida si vale la
1153/// pena finalizar y submitir el encoder).
1154/// `true` si algún nodo del árbol registró un `gpu_painter` (p. ej. el video
1155/// de media vía `gpu_paint_with`). El eventloop lo usa para decidir si la
1156/// capa de overlay necesita componerse aparte (sobre el contenido gpu) en vez
1157/// de pintarse en la escena principal.
1158pub fn has_gpu_painter<Msg>(mounted: &Mounted<Msg>) -> bool {
1159 mounted.nodes.iter().any(|n| n.gpu_painter.is_some())
1160}
1161
1162pub fn paint_gpu<Msg>(
1163 mounted: &Mounted<Msg>,
1164 computed: &ComputedLayout,
1165 device: &wgpu::Device,
1166 queue: &wgpu::Queue,
1167 encoder: &mut wgpu::CommandEncoder,
1168 view: &wgpu::TextureView,
1169 viewport: (u32, u32),
1170) -> bool {
1171 let mut any = false;
1172 for node in &mounted.nodes {
1173 let Some(painter) = node.gpu_painter.as_ref() else {
1174 continue;
1175 };
1176 let Some(r) = computed.get(node.id) else {
1177 continue;
1178 };
1179 (painter)(
1180 device,
1181 queue,
1182 encoder,
1183 view,
1184 PaintRect {
1185 x: r.x,
1186 y: r.y,
1187 w: r.w,
1188 h: r.h,
1189 },
1190 viewport,
1191 );
1192 any = true;
1193 }
1194 any
1195}
1196
1197/// `true` si algún nodo del árbol registró un `over_painter` (vello
1198/// "over" vía [`View::paint_over`]). El eventloop lo usa para decidir si
1199/// vale la pena montar la pasada vello final + el composite sobre la
1200/// intermedia. Coste cero (loop barato) cuando nadie usa el over-layer.
1201pub fn has_over_painter<Msg>(mounted: &Mounted<Msg>) -> bool {
1202 mounted.nodes.iter().any(|n| n.over_painter.is_some())
1203}
1204
1205/// Pinta la pasada vello "over" en `scene`: recorre el árbol en orden
1206/// DFS pre-orden e invoca cada `over_painter` con el `Typesetter`
1207/// compartido y el rect absoluto del nodo. Espejo de [`paint_gpu`] pero
1208/// del lado vello — la diferencia de timing la pone el caller, que
1209/// rasteriza esta `scene` DESPUÉS del pase GPU y la compone sobre la
1210/// intermedia. No resetea `scene` (el caller decide); sólo agrega
1211/// primitivas. Como [`paint_gpu`], usa rects absolutos (no compone los
1212/// `transform` de ancestros — el over-layer es contenido posicionado en
1213/// coordenadas de pantalla, igual que el pintor GPU).
1214pub fn paint_over<Msg>(
1215 scene: &mut vello::Scene,
1216 mounted: &Mounted<Msg>,
1217 computed: &ComputedLayout,
1218 typesetter: &mut llimphi_text::Typesetter,
1219) -> bool {
1220 let mut any = false;
1221 for node in &mounted.nodes {
1222 let Some(painter) = node.over_painter.as_ref() else {
1223 continue;
1224 };
1225 let Some(r) = computed.get(node.id) else {
1226 continue;
1227 };
1228 (painter)(
1229 scene,
1230 typesetter,
1231 PaintRect {
1232 x: r.x,
1233 y: r.y,
1234 w: r.w,
1235 h: r.h,
1236 },
1237 );
1238 any = true;
1239 }
1240 any
1241}
1242
1243/// Hit-test parametrizado por elegibilidad. Devuelve el índice del nodo
1244/// más al frente (último en pre-orden) cuyo rect contiene `(x, y)` y para
1245/// el cual `pred` devuelve `true`, respetando `clip`: si el punto cae
1246/// afuera de un nodo con clip, el subárbol entero es invisible.
1247///
1248/// **Respeta `transform`**: igual que [`paint`], compone el afín acumulado
1249/// de los ancestros (cada `transform` alrededor del centro del rect del
1250/// nodo, convención CSS `transform-origin: 50% 50%`). El punto de pantalla
1251/// `(x, y)` se lleva al espacio local del nodo invirtiendo ese afín, y se
1252/// testea contra el rect sin transformar. Así un nodo rotado/escalado/
1253/// trasladado recibe los clicks donde realmente se ve pintado (recorrido
1254/// tipo Prezi, lienzos de tullpu, `@keyframes` de puriy). Un subárbol con
1255/// afín singular (escala 0) es inalcanzable, igual que es invisible.
1256pub fn hit_test_pred<Msg, F>(
1257 mounted: &Mounted<Msg>,
1258 computed: &ComputedLayout,
1259 x: f32,
1260 y: f32,
1261 pred: F,
1262) -> Option<usize>
1263where
1264 F: Fn(&MountedNode<Msg>) -> bool,
1265{
1266 let mut hit: Option<usize> = None;
1267 let mut clip_stack: Vec<usize> = Vec::new();
1268 // Espejo del stack de transformaciones de `paint`: `cur_xf` es el
1269 // producto acumulado de los `transform` de los ancestros activos
1270 // (local → pantalla). Vacío ⇒ identidad ⇒ camino directo sin invertir
1271 // (cero costo para la abrumadora mayoría de árboles sin transform).
1272 let mut xf_stack: Vec<(usize, Affine)> = Vec::new();
1273 let mut cur_xf = Affine::IDENTITY;
1274 let mut idx = 0;
1275 while idx < mounted.nodes.len() {
1276 while let Some(&end) = clip_stack.last() {
1277 if idx >= end {
1278 clip_stack.pop();
1279 } else {
1280 break;
1281 }
1282 }
1283 while let Some(&(end, prev)) = xf_stack.last() {
1284 if idx >= end {
1285 cur_xf = prev;
1286 xf_stack.pop();
1287 } else {
1288 break;
1289 }
1290 }
1291 let node = &mounted.nodes[idx];
1292 let Some(r) = computed.get(node.id) else {
1293 idx += 1;
1294 continue;
1295 };
1296 // Componé el transform de este nodo igual que `paint`, ANTES de
1297 // resolver el punto local (su propio rect ya cae en el espacio
1298 // transformado).
1299 if let Some(centered) = resolve_node_transform(node.transform, node.transform_rel, node.transform_origin, r) {
1300 xf_stack.push((node.subtree_end, cur_xf));
1301 cur_xf *= centered;
1302 }
1303 // Punto en el espacio local del nodo. Sin transform activo, es el
1304 // punto de pantalla tal cual. Con transform, se invierte el afín;
1305 // si es singular (no invertible) el subárbol es inalcanzable.
1306 let (lx, ly) = if xf_stack.is_empty() {
1307 (x as f64, y as f64)
1308 } else if cur_xf.determinant().abs() < 1e-9 {
1309 idx = node.subtree_end;
1310 continue;
1311 } else {
1312 let p = cur_xf.inverse() * Point::new(x as f64, y as f64);
1313 (p.x, p.y)
1314 };
1315 let inside = lx >= r.x as f64
1316 && lx < (r.x + r.w) as f64
1317 && ly >= r.y as f64
1318 && ly < (r.y + r.h) as f64;
1319 if node.clip {
1320 if !inside {
1321 idx = node.subtree_end;
1322 continue;
1323 }
1324 clip_stack.push(node.subtree_end);
1325 }
1326 if inside && pred(node) {
1327 hit = Some(idx);
1328 }
1329 idx += 1;
1330 }
1331 hit
1332}
1333
1334/// Hit-test específico para clicks (incluye nodos draggables).
1335pub fn hit_test_click<Msg>(
1336 mounted: &Mounted<Msg>,
1337 computed: &ComputedLayout,
1338 x: f32,
1339 y: f32,
1340) -> Option<usize> {
1341 hit_test_pred(mounted, computed, x, y, |n| {
1342 n.on_click.is_some()
1343 || n.on_click_at.is_some()
1344 || n.drag.is_some()
1345 || n.drag_at.is_some()
1346 || n.drag_velocity.is_some()
1347 })
1348}
1349
1350/// Hit-test específico para right-click. Sólo considera nodos que
1351/// declararon `on_right_click` o `on_right_click_at` — un right-click
1352/// sobre un nodo sin handler no hace nada (no se "filtra" al click
1353/// izquierdo).
1354pub fn hit_test_right_click<Msg>(
1355 mounted: &Mounted<Msg>,
1356 computed: &ComputedLayout,
1357 x: f32,
1358 y: f32,
1359) -> Option<usize> {
1360 hit_test_pred(mounted, computed, x, y, |n| {
1361 n.on_right_click.is_some() || n.on_right_click_at.is_some()
1362 })
1363}
1364
1365/// Hit-test específico para middle-click. Mismo modelo que right-click:
1366/// sólo nodos que declararon `on_middle_click` reaccionan.
1367pub fn hit_test_middle_click<Msg>(
1368 mounted: &Mounted<Msg>,
1369 computed: &ComputedLayout,
1370 x: f32,
1371 y: f32,
1372) -> Option<usize> {
1373 hit_test_pred(mounted, computed, x, y, |n| n.on_middle_click.is_some())
1374}
1375
1376/// Hit-test específico para hover (nodos con `hover_fill`).
1377pub fn hit_test_hover<Msg>(
1378 mounted: &Mounted<Msg>,
1379 computed: &ComputedLayout,
1380 x: f32,
1381 y: f32,
1382) -> Option<usize> {
1383 hit_test_pred(mounted, computed, x, y, |n| n.hover_fill.is_some())
1384}
1385
1386/// Hit-test para movimiento posicional del cursor (nodos con
1387/// `on_pointer_move_at`). El runtime lo invoca en cada `CursorMoved` para
1388/// reportar la posición local al nodo más al frente que lo declare.
1389pub fn hit_test_pointer_move<Msg>(
1390 mounted: &Mounted<Msg>,
1391 computed: &ComputedLayout,
1392 x: f32,
1393 y: f32,
1394) -> Option<usize> {
1395 hit_test_pred(mounted, computed, x, y, |n| n.on_pointer_move_at.is_some())
1396}
1397
1398/// Hit-test específico para la **forma del cursor**: devuelve el [`Cursor`]
1399/// del nodo más al frente bajo el punto que declare uno. Como un hijo sin
1400/// cursor no matchea el predicado, el cursor "cae" al ancestro más cercano que
1401/// lo declare — herencia estilo CSS sin recorrer el árbol a mano. `None` =
1402/// ningún nodo bajo el punto declara cursor (el runtime usa el default de la
1403/// ventana). Lo invoca `llimphi-ui` en la transición de hover.
1404pub fn hit_test_cursor<Msg>(
1405 mounted: &Mounted<Msg>,
1406 computed: &ComputedLayout,
1407 x: f32,
1408 y: f32,
1409) -> Option<Cursor> {
1410 hit_test_pred(mounted, computed, x, y, |n| n.cursor.is_some())
1411 .and_then(|i| mounted.nodes[i].cursor)
1412}
1413
1414/// Hit-test específico para drop targets (nodos con `on_drop`). Usado
1415/// durante un drag activo para resaltar el destino y para invocar el
1416/// handler al soltar.
1417pub fn hit_test_drop<Msg>(
1418 mounted: &Mounted<Msg>,
1419 computed: &ComputedLayout,
1420 x: f32,
1421 y: f32,
1422) -> Option<usize> {
1423 hit_test_pred(mounted, computed, x, y, |n| n.on_drop.is_some())
1424}
1425
1426/// Hit-test específico para áreas de scroll (nodos con `on_scroll`). El
1427/// runtime lo usa al recibir la rueda: el nodo más al frente bajo el
1428/// cursor con handler de scroll consume el evento antes del `on_wheel`
1429/// global.
1430pub fn hit_test_scroll<Msg>(
1431 mounted: &Mounted<Msg>,
1432 computed: &ComputedLayout,
1433 x: f32,
1434 y: f32,
1435) -> Option<usize> {
1436 hit_test_pred(mounted, computed, x, y, |n| n.on_scroll.is_some())
1437}
1438
1439/// Cadena de **scroll anidado**: devuelve todos los nodos con `on_scroll`
1440/// que contienen el punto, ordenados **front→back** (el primero es el más
1441/// al frente, igual que [`hit_test_scroll`]; los siguientes son sus
1442/// ancestros scrollables). El runtime itera la cadena al recibir la rueda
1443/// y se queda con el primer handler que devuelva `Some`: si un scroll
1444/// interno está en el extremo del eje y devuelve `None`, el evento "pasa"
1445/// al ancestro scrollable más cercano (lista dentro de panel, etc.).
1446/// Recorrido idéntico al de [`hit_test_pred`] pero acumulando todos los
1447/// hits en vez de pisar.
1448pub fn hit_test_scroll_chain<Msg>(
1449 mounted: &Mounted<Msg>,
1450 computed: &ComputedLayout,
1451 x: f32,
1452 y: f32,
1453) -> Vec<usize> {
1454 let mut chain: Vec<usize> = Vec::new();
1455 let mut clip_stack: Vec<usize> = Vec::new();
1456 let mut xf_stack: Vec<(usize, Affine)> = Vec::new();
1457 let mut cur_xf = Affine::IDENTITY;
1458 let mut idx = 0;
1459 while idx < mounted.nodes.len() {
1460 while let Some(&end) = clip_stack.last() {
1461 if idx >= end {
1462 clip_stack.pop();
1463 } else {
1464 break;
1465 }
1466 }
1467 while let Some(&(end, prev)) = xf_stack.last() {
1468 if idx >= end {
1469 cur_xf = prev;
1470 xf_stack.pop();
1471 } else {
1472 break;
1473 }
1474 }
1475 let node = &mounted.nodes[idx];
1476 let Some(r) = computed.get(node.id) else {
1477 idx += 1;
1478 continue;
1479 };
1480 if let Some(centered) = resolve_node_transform(node.transform, node.transform_rel, node.transform_origin, r) {
1481 xf_stack.push((node.subtree_end, cur_xf));
1482 cur_xf *= centered;
1483 }
1484 let (lx, ly) = if xf_stack.is_empty() {
1485 (x as f64, y as f64)
1486 } else if cur_xf.determinant().abs() < 1e-9 {
1487 idx = node.subtree_end;
1488 continue;
1489 } else {
1490 let p = cur_xf.inverse() * Point::new(x as f64, y as f64);
1491 (p.x, p.y)
1492 };
1493 let inside = lx >= r.x as f64
1494 && lx < (r.x + r.w) as f64
1495 && ly >= r.y as f64
1496 && ly < (r.y + r.h) as f64;
1497 if node.clip {
1498 if !inside {
1499 idx = node.subtree_end;
1500 continue;
1501 }
1502 clip_stack.push(node.subtree_end);
1503 }
1504 if inside && node.on_scroll.is_some() {
1505 chain.push(idx);
1506 }
1507 idx += 1;
1508 }
1509 // El recorrido es pre-orden, así que los ancestros aparecen primero y
1510 // los hijos después. Para front→back necesitamos el orden inverso.
1511 chain.reverse();
1512 chain
1513}
1514
1515/// Hit-test específico para gestos de **escala** (pinch-to-zoom): el nodo más
1516/// al frente bajo el punto que declaró un `on_scale`. Como un hijo sin handler
1517/// no matchea el predicado, el gesto "cae" al ancestro más cercano que lo
1518/// declare (un canvas grande zoomeable con widgets encima que no zoomean). El
1519/// runtime lo invoca al recibir Ctrl+rueda o un pinch de trackpad. `None` =
1520/// ningún nodo zoomeable bajo el cursor (el evento cae al scroll/`on_wheel`).
1521pub fn hit_test_scale<Msg>(
1522 mounted: &Mounted<Msg>,
1523 computed: &ComputedLayout,
1524 x: f32,
1525 y: f32,
1526) -> Option<usize> {
1527 hit_test_pred(mounted, computed, x, y, |n| n.on_scale.is_some())
1528}
1529
1530/// Hit-test específico para gestos de **rotación** (trackpad): el nodo más al
1531/// frente bajo el punto que declaró un `on_rotate`. Análogo a
1532/// [`hit_test_scale`]; el runtime lo invoca al recibir un `RotationGesture`.
1533/// `None` = ningún nodo rotable bajo el cursor.
1534pub fn hit_test_rotate<Msg>(
1535 mounted: &Mounted<Msg>,
1536 computed: &ComputedLayout,
1537 x: f32,
1538 y: f32,
1539) -> Option<usize> {
1540 hit_test_pred(mounted, computed, x, y, |n| n.on_rotate.is_some())
1541}
1542
1543/// Hit-test para **doble-tap**: el nodo más al frente bajo el punto que
1544/// declaró `on_double_tap`/`on_double_tap_at`. El runtime lo usa al detectar
1545/// dos presses rápidos y cercanos.
1546pub fn hit_test_double_tap<Msg>(
1547 mounted: &Mounted<Msg>,
1548 computed: &ComputedLayout,
1549 x: f32,
1550 y: f32,
1551) -> Option<usize> {
1552 hit_test_pred(mounted, computed, x, y, |n| {
1553 n.on_double_tap.is_some() || n.on_double_tap_at.is_some()
1554 })
1555}
1556
1557/// Hit-test para **long-press**: el nodo más al frente bajo el punto que
1558/// declaró `on_long_press`/`on_long_press_at`. El runtime lo usa al armar el
1559/// gesto en el press (que vence por tiempo si no hay movimiento ni release).
1560pub fn hit_test_long_press<Msg>(
1561 mounted: &Mounted<Msg>,
1562 computed: &ComputedLayout,
1563 x: f32,
1564 y: f32,
1565) -> Option<usize> {
1566 hit_test_pred(mounted, computed, x, y, |n| {
1567 n.on_long_press.is_some() || n.on_long_press_at.is_some()
1568 })
1569}
1570
1571/// Hit-test para **ripple**: el nodo más al frente bajo el punto que declaró
1572/// un [`Ripple`] (vía [`View::ripple`]). El runtime lo usa en el press para
1573/// disparar la salpicadura. Aditivo — no compite con click/drag.
1574pub fn hit_test_ripple<Msg>(
1575 mounted: &Mounted<Msg>,
1576 computed: &ComputedLayout,
1577 x: f32,
1578 y: f32,
1579) -> Option<usize> {
1580 hit_test_pred(mounted, computed, x, y, |n| n.ripple.is_some())
1581}
1582
1583/// Hit-test para foco: el id `focusable` del nodo más al frente bajo el
1584/// cursor (click-to-focus). `None` si no se clickeó nada enfocable.
1585pub fn hit_test_focusable<Msg>(
1586 mounted: &Mounted<Msg>,
1587 computed: &ComputedLayout,
1588 x: f32,
1589 y: f32,
1590) -> Option<u64> {
1591 hit_test_pred(mounted, computed, x, y, |n| n.focusable.is_some())
1592 .and_then(|i| mounted.nodes[i].focusable)
1593}
1594
1595/// Hit-test para **selección de texto**: el índice del nodo de texto
1596/// seleccionable (`text_select_key`) más al frente bajo el cursor. El runtime
1597/// lo usa para arrancar/extender una selección; devuelve el índice (no la key)
1598/// para que el caller acceda al `text` + rect del nodo. `None` si no hay texto
1599/// seleccionable bajo el punto.
1600pub fn hit_test_selectable<Msg>(
1601 mounted: &Mounted<Msg>,
1602 computed: &ComputedLayout,
1603 x: f32,
1604 y: f32,
1605) -> Option<usize> {
1606 hit_test_pred(mounted, computed, x, y, |n| n.text_select_key.is_some())
1607}
1608
1609/// Ids enfocables en orden de Tab (pre-orden del árbol = orden de
1610/// inserción de `Mounted::nodes`). Sólo nodos con rect computado
1611/// (presentes en el layout). Es el orden DOM-like de tabulación.
1612pub fn focus_order<Msg>(mounted: &Mounted<Msg>, computed: &ComputedLayout) -> Vec<u64> {
1613 mounted
1614 .nodes
1615 .iter()
1616 .filter_map(|n| {
1617 n.focusable
1618 .filter(|_| computed.get(n.id).is_some())
1619 })
1620 .collect()
1621}
1622
1623/// Próximo id de foco al pulsar Tab (o Shift+Tab si `reverse`), dado el
1624/// `order` (de [`focus_order`]) y el `current`. Envuelve en los extremos.
1625/// Si no hay enfocables devuelve `None`; si `current` ya no existe en el
1626/// orden, arranca por el primero (Tab) o el último (Shift+Tab).
1627pub fn next_focus(order: &[u64], current: Option<u64>, reverse: bool) -> Option<u64> {
1628 if order.is_empty() {
1629 return None;
1630 }
1631 let n = order.len();
1632 let pos = current.and_then(|c| order.iter().position(|&id| id == c));
1633 let next_idx = match pos {
1634 Some(i) => {
1635 if reverse {
1636 (i + n - 1) % n
1637 } else {
1638 (i + 1) % n
1639 }
1640 }
1641 None => {
1642 if reverse {
1643 n - 1
1644 } else {
1645 0
1646 }
1647 }
1648 };
1649 Some(order[next_idx])
1650}
1651
1652#[cfg(test)]
1653mod tests {
1654 use crate::{hit_test_click, mount, View};
1655 use llimphi_layout::taffy::prelude::*;
1656 use llimphi_layout::{LayoutTree, Style};
1657 use vello::kurbo::Affine;
1658
1659 #[test]
1660 fn transform_origin_fija_el_pivote() {
1661 // El pivote de `transform-origin` queda FIJO bajo la transformación:
1662 // top-left (frac 0,0) fija la esquina (0,0); el default (None) fija el
1663 // centro (50,50). Rect 100×100 en el origen, rotación 90°.
1664 use super::resolve_node_transform;
1665 use crate::TransformPivot;
1666 use vello::kurbo::Point;
1667 let r = llimphi_layout::Rect { x: 0.0, y: 0.0, w: 100.0, h: 100.0 };
1668 let rot = Affine::rotate(std::f64::consts::FRAC_PI_2);
1669
1670 let tl = TransformPivot { px: (0.0, 0.0), frac: (0.0, 0.0) };
1671 let xf_tl = resolve_node_transform(Some(rot), None, Some(tl), r).unwrap();
1672 let p = xf_tl * Point::new(0.0, 0.0);
1673 assert!(p.x.abs() < 1e-6 && p.y.abs() < 1e-6, "pivote top-left fijo, fue {p:?}");
1674
1675 // Default (None) ⇒ centro: (50,50) queda fijo.
1676 let xf_c = resolve_node_transform(Some(rot), None, None, r).unwrap();
1677 let c = xf_c * Point::new(50.0, 50.0);
1678 assert!(
1679 (c.x - 50.0).abs() < 1e-6 && (c.y - 50.0).abs() < 1e-6,
1680 "centro fijo con pivote default, fue {c:?}"
1681 );
1682 // Y el centro NO queda fijo con pivote top-left (distingue los dos casos).
1683 let c2 = xf_tl * Point::new(50.0, 50.0);
1684 assert!((c2.x - 50.0).abs() > 1.0 || (c2.y - 50.0).abs() > 1.0, "top-left mueve el centro");
1685 }
1686
1687 #[test]
1688 fn resolve_clip_radius_lados_y_porcentajes() {
1689 use super::resolve_clip_radius;
1690 // Caja 200×100, centro al (50%,50%) = (100,50) local.
1691 let (w, h, cxl, cyl): (f64, f64, f64, f64) = (200.0, 100.0, 100.0, 50.0);
1692 // side 0: px + pct_w·w + pct_h·h + pct_diag·diag.
1693 let diag = (w * w + h * h).sqrt() / core::f64::consts::SQRT_2;
1694 let r = resolve_clip_radius(&[10.0, 0.0, 0.0, 50.0, 0.0], cxl, cyl, w, h, true);
1695 assert!((r - (10.0 + 0.5 * diag)).abs() < 1e-6);
1696 // closest-side circle (1): min(100,100,50,50) = 50.
1697 assert_eq!(
1698 resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 1.0], cxl, cyl, w, h, true),
1699 50.0
1700 );
1701 // farthest-side circle (2): max(...) = 100.
1702 assert_eq!(
1703 resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 2.0], cxl, cyl, w, h, true),
1704 100.0
1705 );
1706 // closest-side ellipse eje X (3, is_x): min(cxl, w-cxl) = 100.
1707 assert_eq!(
1708 resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 3.0], cxl, cyl, w, h, true),
1709 100.0
1710 );
1711 // closest-side ellipse eje Y (3, !is_x): min(cyl, h-cyl) = 50.
1712 assert_eq!(
1713 resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 3.0], cxl, cyl, w, h, false),
1714 50.0
1715 );
1716 // Centro descentrado (30, 20): closest circle = min(30,170,20,80)=20.
1717 assert_eq!(
1718 resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 1.0], 30.0, 20.0, w, h, true),
1719 20.0
1720 );
1721 }
1722
1723 /// Un hijo clickeable de 100×100 anclado arriba-izquierda. Devuelve
1724 /// `(mounted, computed)` ya layouteados sobre un viewport 400×400.
1725 fn fixture(
1726 transform: Option<Affine>,
1727 ) -> (crate::Mounted<()>, llimphi_layout::ComputedLayout) {
1728 let mut child = View::<()>::new(Style {
1729 size: Size {
1730 width: length(100.0),
1731 height: length(100.0),
1732 },
1733 ..Default::default()
1734 })
1735 .on_click(());
1736 if let Some(xf) = transform {
1737 child = child.transform(xf);
1738 }
1739 let root = View::<()>::new(Style {
1740 align_items: Some(AlignItems::FlexStart),
1741 justify_content: Some(JustifyContent::FlexStart),
1742 ..Default::default()
1743 })
1744 .children(vec![child]);
1745 let mut layout = LayoutTree::new();
1746 let mounted = mount(&mut layout, root);
1747 let computed = layout.compute(mounted.root, (400.0, 400.0)).expect("layout");
1748 (mounted, computed)
1749 }
1750
1751 #[test]
1752 fn sin_transform_el_hit_cae_en_el_rect() {
1753 let (m, c) = fixture(None);
1754 assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), Some(1)); // dentro
1755 assert_eq!(hit_test_click(&m, &c, 250.0, 50.0), None); // fuera
1756 }
1757
1758 #[test]
1759 fn traslacion_mueve_el_area_clickeable() {
1760 // El nodo se ve corrido +200px en x; el click debe seguirlo.
1761 let (m, c) = fixture(Some(Affine::translate((200.0, 0.0))));
1762 assert_eq!(hit_test_click(&m, &c, 250.0, 50.0), Some(1)); // donde se ve
1763 assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), None); // ya no donde estaba
1764 }
1765
1766 #[test]
1767 fn rotacion_180_grados_alrededor_del_centro() {
1768 // Rotar 180° alrededor del centro (50,50) deja el rect en su sitio:
1769 // una esquina mapea a la opuesta, pero el cuadrado cubre lo mismo.
1770 let (m, c) = fixture(Some(Affine::rotate(std::f64::consts::PI)));
1771 assert_eq!(hit_test_click(&m, &c, 10.0, 10.0), Some(1));
1772 assert_eq!(hit_test_click(&m, &c, 90.0, 90.0), Some(1));
1773 assert_eq!(hit_test_click(&m, &c, 150.0, 150.0), None);
1774 }
1775
1776 #[test]
1777 fn escala_cero_es_inalcanzable() {
1778 let (m, c) = fixture(Some(Affine::scale(0.0)));
1779 assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), None);
1780 }
1781
1782 /// Como `fixture` pero seteando `transform_rel` (traslación en fracción
1783 /// del tamaño del nodo) en vez del afín fijo.
1784 fn fixture_rel(
1785 rel: (f64, f64),
1786 ) -> (crate::Mounted<()>, llimphi_layout::ComputedLayout) {
1787 let child = View::<()>::new(Style {
1788 size: Size { width: length(100.0), height: length(100.0) },
1789 ..Default::default()
1790 })
1791 .on_click(())
1792 .transform_rel(rel);
1793 let root = View::<()>::new(Style {
1794 align_items: Some(AlignItems::FlexStart),
1795 justify_content: Some(JustifyContent::FlexStart),
1796 ..Default::default()
1797 })
1798 .children(vec![child]);
1799 let mut layout = LayoutTree::new();
1800 let mounted = mount(&mut layout, root);
1801 let computed = layout.compute(mounted.root, (400.0, 400.0)).expect("layout");
1802 (mounted, computed)
1803 }
1804
1805 /// Replica la estructura de dominium: un wrapper que clipea y lleva
1806 /// `on_click_at` + `draggable_at`, con un hijo "canvas" que pinta por
1807 /// uno de dos caminos. Devuelve `(mounted, computed)`. `gpu` elige el
1808 /// camino: `false` = `paint_with` (camino LEGACY), `true` =
1809 /// `gpu_paint_with().paint_over()` (camino Tier 1 nuevo).
1810 fn dominium_like(gpu: bool) -> (crate::Mounted<()>, llimphi_layout::ComputedLayout) {
1811 let mut canvas = View::<()>::new(Style {
1812 size: Size { width: percent(1.0), height: percent(1.0) },
1813 ..Default::default()
1814 });
1815 canvas = if gpu {
1816 canvas
1817 .gpu_paint_with(|_d, _q, _e, _t, _r, _vp| {})
1818 .paint_over(|_s, _ts, _r| {})
1819 } else {
1820 canvas.paint_with(|_s, _ts, _r| {})
1821 };
1822 let wrapper = View::<()>::new(Style {
1823 size: Size { width: percent(1.0), height: percent(1.0) },
1824 ..Default::default()
1825 })
1826 .clip(true)
1827 .on_click_at(|_lx, _ly, _rw, _rh| Some(()))
1828 .draggable_at(|_phase, _dx, _dy, _x0, _y0| Some(()))
1829 .children(vec![canvas]);
1830 let mut layout = LayoutTree::new();
1831 let mounted = mount(&mut layout, wrapper);
1832 let computed = layout.compute(mounted.root, (400.0, 400.0)).expect("layout");
1833 (mounted, computed)
1834 }
1835
1836 #[test]
1837 fn canvas_gpu_only_es_clickeable_igual_que_legacy() {
1838 // BUG 2: el wrapper con `on_click_at`/`draggable_at` debe ser
1839 // hit-testeable en AMBOS caminos. El click cae sobre el wrapper
1840 // (índice 0, raíz) — un punto interior lo encuentra sin importar el
1841 // tipo de painter del hijo.
1842 let (m_leg, c_leg) = dominium_like(false);
1843 assert_eq!(hit_test_click(&m_leg, &c_leg, 200.0, 200.0), Some(0), "LEGACY (paint_with)");
1844 let (m_gpu, c_gpu) = dominium_like(true);
1845 assert_eq!(hit_test_click(&m_gpu, &c_gpu, 200.0, 200.0), Some(0), "GPU (gpu_paint_with+paint_over)");
1846 }
1847
1848 #[test]
1849 fn nodo_gpu_paint_with_solo_es_hittable_por_si_mismo() {
1850 // Crítico para el motor voxel futuro: una vista 3D GPU-only que
1851 // lleve su PROPIO `on_click_at` debe ser clickeable, aunque NO
1852 // tenga `paint_with` ni contenido vello — sólo `gpu_painter`.
1853 let canvas = View::<()>::new(Style {
1854 size: Size { width: length(100.0), height: length(100.0) },
1855 ..Default::default()
1856 })
1857 .gpu_paint_with(|_d, _q, _e, _t, _r, _vp| {})
1858 .on_click(());
1859 let root = View::<()>::new(Style {
1860 align_items: Some(AlignItems::FlexStart),
1861 justify_content: Some(JustifyContent::FlexStart),
1862 ..Default::default()
1863 })
1864 .children(vec![canvas]);
1865 let mut layout = LayoutTree::new();
1866 let m = mount(&mut layout, root);
1867 let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
1868 assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), Some(1), "gpu-only con on_click debe ser hittable");
1869 }
1870
1871 #[test]
1872 fn transform_rel_resuelve_contra_el_tamano_del_nodo() {
1873 // El nodo es 100×100 en (0,0). `transform_rel(-0.5,-0.5)` =
1874 // `translate(-50%,-50%)` = correr -50px,-50px (la mitad de 100). El
1875 // área pintada pasa a (-50,-50)..(50,50): el centro del rect original
1876 // (50,50) queda ahora en (0,0).
1877 let (m, c) = fixture_rel((-0.5, -0.5));
1878 // Donde se ve ahora (el viejo centro corrido a 0,0; y la esquina
1879 // inferior-derecha del original (100,100) ahora en (50,50)).
1880 assert_eq!(hit_test_click(&m, &c, 25.0, 25.0), Some(1)); // dentro del corrido
1881 assert_eq!(hit_test_click(&m, &c, 49.0, 49.0), Some(1)); // casi esquina nueva
1882 // Donde estaba antes pero ya NO (el rect se corrió fuera de ahí).
1883 assert_eq!(hit_test_click(&m, &c, 75.0, 75.0), None);
1884 // Sin transform_rel ese mismo punto SÍ caería dentro (control).
1885 let (m0, c0) = fixture_rel((0.0, 0.0)); // (0,0) = no-op
1886 assert_eq!(hit_test_click(&m0, &c0, 75.0, 75.0), Some(1));
1887 }
1888
1889 #[test]
1890 fn hit_test_cursor_directo_y_por_herencia() {
1891 use crate::{hit_test_cursor, Cursor};
1892 // Padre 200×200 con cursor Text; dentro un hijo 100×100 (arriba-izq)
1893 // SIN cursor propio; y un segundo hijo 50×50 con cursor Pointer.
1894 let hijo_sin = View::<()>::new(Style {
1895 size: Size { width: length(100.0), height: length(100.0) },
1896 ..Default::default()
1897 });
1898 let hijo_con = View::<()>::new(Style {
1899 size: Size { width: length(50.0), height: length(50.0) },
1900 ..Default::default()
1901 })
1902 .cursor(Cursor::Pointer);
1903 let root = View::<()>::new(Style {
1904 size: Size { width: length(200.0), height: length(200.0) },
1905 flex_direction: FlexDirection::Column,
1906 align_items: Some(AlignItems::FlexStart),
1907 justify_content: Some(JustifyContent::FlexStart),
1908 ..Default::default()
1909 })
1910 .cursor(Cursor::Text)
1911 .children(vec![hijo_sin, hijo_con]);
1912 let mut layout = LayoutTree::new();
1913 let m = mount(&mut layout, root);
1914 let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
1915 // Sobre el hijo sin cursor (0..100, 0..100) → hereda Text del padre.
1916 assert_eq!(hit_test_cursor(&m, &c, 50.0, 50.0), Some(Cursor::Text));
1917 // Sobre el hijo con cursor propio (apilado debajo: y 100..150) → Pointer.
1918 assert_eq!(hit_test_cursor(&m, &c, 25.0, 120.0), Some(Cursor::Pointer));
1919 // Sobre el padre pero fuera de ambos hijos (x>100) → Text del padre.
1920 assert_eq!(hit_test_cursor(&m, &c, 150.0, 50.0), Some(Cursor::Text));
1921 // Fuera del padre → None (la ventana usa su default).
1922 assert_eq!(hit_test_cursor(&m, &c, 350.0, 350.0), None);
1923 }
1924
1925 #[test]
1926 fn tab_traversal_envuelve_en_los_extremos() {
1927 use crate::next_focus;
1928 let order = [10u64, 20, 30];
1929 // Avanza.
1930 assert_eq!(next_focus(&order, Some(10), false), Some(20));
1931 assert_eq!(next_focus(&order, Some(30), false), Some(10)); // wrap
1932 // Retrocede (Shift+Tab).
1933 assert_eq!(next_focus(&order, Some(20), true), Some(10));
1934 assert_eq!(next_focus(&order, Some(10), true), Some(30)); // wrap
1935 // Sin foco previo: Tab → primero, Shift+Tab → último.
1936 assert_eq!(next_focus(&order, None, false), Some(10));
1937 assert_eq!(next_focus(&order, None, true), Some(30));
1938 // Foco obsoleto (id que ya no está) → arranca por el extremo.
1939 assert_eq!(next_focus(&order, Some(99), false), Some(10));
1940 // Lista vacía.
1941 assert_eq!(next_focus(&[], Some(10), false), None);
1942 }
1943
1944 #[test]
1945 fn hit_test_scale_directo_y_por_herencia() {
1946 use crate::{hit_test_scale, GesturePhase};
1947 // Canvas zoomeable 200×200 (declara on_scale); dentro un widget 50×50
1948 // (arriba-izq) SIN on_scale (no zoomea). El gesto sobre el widget debe
1949 // "caer" al canvas ancestro (herencia, como el cursor), y fuera de
1950 // todo debe dar None (el evento cae al scroll/on_wheel).
1951 let widget = View::<()>::new(Style {
1952 size: Size { width: length(50.0), height: length(50.0) },
1953 ..Default::default()
1954 });
1955 let canvas = View::<()>::new(Style {
1956 size: Size { width: length(200.0), height: length(200.0) },
1957 align_items: Some(AlignItems::FlexStart),
1958 justify_content: Some(JustifyContent::FlexStart),
1959 ..Default::default()
1960 })
1961 .on_scale(|_phase: GesturePhase, _f, _fx, _fy| None)
1962 .children(vec![widget]);
1963 let mut layout = LayoutTree::new();
1964 let m = mount(&mut layout, canvas);
1965 let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
1966 // Sobre el widget sin on_scale (0..50,0..50) → cae al canvas (idx 0).
1967 assert_eq!(hit_test_scale(&m, &c, 25.0, 25.0), Some(0));
1968 // Sobre el canvas fuera del widget (x>50) → el canvas (idx 0).
1969 assert_eq!(hit_test_scale(&m, &c, 150.0, 25.0), Some(0));
1970 // Fuera del canvas → None.
1971 assert_eq!(hit_test_scale(&m, &c, 350.0, 350.0), None);
1972 }
1973
1974 #[test]
1975 fn hit_test_rotate_directo_y_por_herencia() {
1976 use crate::{hit_test_rotate, GesturePhase};
1977 // Mismo patrón que escala: canvas rotable con un widget no-rotable
1978 // encima; el gesto cae al ancestro que declara on_rotate.
1979 let widget = View::<()>::new(Style {
1980 size: Size { width: length(50.0), height: length(50.0) },
1981 ..Default::default()
1982 });
1983 let canvas = View::<()>::new(Style {
1984 size: Size { width: length(200.0), height: length(200.0) },
1985 align_items: Some(AlignItems::FlexStart),
1986 justify_content: Some(JustifyContent::FlexStart),
1987 ..Default::default()
1988 })
1989 .on_rotate(|_phase: GesturePhase, _d, _fx, _fy| None)
1990 .children(vec![widget]);
1991 let mut layout = LayoutTree::new();
1992 let m = mount(&mut layout, canvas);
1993 let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
1994 assert_eq!(hit_test_rotate(&m, &c, 25.0, 25.0), Some(0));
1995 assert_eq!(hit_test_rotate(&m, &c, 150.0, 25.0), Some(0));
1996 assert_eq!(hit_test_rotate(&m, &c, 350.0, 350.0), None);
1997 }
1998
1999 #[test]
2000 fn hit_test_selectable_solo_sobre_texto_seleccionable() {
2001 use crate::hit_test_selectable;
2002 // Un label seleccionable 100×30 arriba-izq dentro de un panel 200×200
2003 // SIN selectable. Sólo el label matchea; el resto del panel da None.
2004 let label = View::<()>::new(Style {
2005 size: Size { width: length(100.0), height: length(30.0) },
2006 ..Default::default()
2007 })
2008 .text("hola", 14.0, vello::peniko::Color::from_rgba8(255, 255, 255, 255))
2009 .selectable(7);
2010 let panel = View::<()>::new(Style {
2011 size: Size { width: length(200.0), height: length(200.0) },
2012 align_items: Some(AlignItems::FlexStart),
2013 justify_content: Some(JustifyContent::FlexStart),
2014 ..Default::default()
2015 })
2016 .children(vec![label]);
2017 let mut layout = LayoutTree::new();
2018 let m = mount(&mut layout, panel);
2019 let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
2020 // Sobre el label (0..100, 0..30) → el label (idx 1).
2021 assert_eq!(hit_test_selectable(&m, &c, 50.0, 15.0), Some(1));
2022 // Sobre el panel fuera del label → None (el panel no es selectable).
2023 assert_eq!(hit_test_selectable(&m, &c, 150.0, 150.0), None);
2024 }
2025
2026 #[test]
2027 fn hit_test_scroll_chain_devuelve_front_to_back() {
2028 use crate::hit_test_scroll_chain;
2029 // Padre scrollable 200×200 con un hijo scrollable 100×100 (arriba-izq).
2030 // Bajo el hijo: chain = [hijo, padre]. Bajo el padre pero fuera del
2031 // hijo: chain = [padre]. Fuera de ambos: chain vacío.
2032 let hijo = View::<()>::new(Style {
2033 size: Size { width: length(100.0), height: length(100.0) },
2034 ..Default::default()
2035 })
2036 .on_scroll(|_dx, _dy| None::<()>);
2037 let padre = View::<()>::new(Style {
2038 size: Size { width: length(200.0), height: length(200.0) },
2039 align_items: Some(AlignItems::FlexStart),
2040 justify_content: Some(JustifyContent::FlexStart),
2041 ..Default::default()
2042 })
2043 .on_scroll(|_dx, _dy| None::<()>)
2044 .children(vec![hijo]);
2045 let mut layout = LayoutTree::new();
2046 let m = mount(&mut layout, padre);
2047 let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
2048 // Sobre el hijo (0..100,0..100) → chain = [hijo=1, padre=0].
2049 let ch = hit_test_scroll_chain(&m, &c, 50.0, 50.0);
2050 assert_eq!(ch, vec![1, 0]);
2051 // Sobre el padre fuera del hijo (x>100) → chain = [padre=0].
2052 let ch = hit_test_scroll_chain(&m, &c, 150.0, 50.0);
2053 assert_eq!(ch, vec![0]);
2054 // Fuera del padre → chain vacío.
2055 let ch = hit_test_scroll_chain(&m, &c, 350.0, 350.0);
2056 assert!(ch.is_empty());
2057 }
2058
2059 #[test]
2060 fn hit_test_double_tap_y_long_press() {
2061 use crate::{hit_test_double_tap, hit_test_long_press};
2062 // Un nodo 100×100 con doble-tap; otro 100×100 apilado debajo con
2063 // long-press. Cada hit-test sólo ve su propio gesto.
2064 let arriba = View::<()>::new(Style {
2065 size: Size { width: length(100.0), height: length(100.0) },
2066 ..Default::default()
2067 })
2068 .on_double_tap(());
2069 let abajo = View::<()>::new(Style {
2070 size: Size { width: length(100.0), height: length(100.0) },
2071 ..Default::default()
2072 })
2073 .on_long_press(());
2074 let root = View::<()>::new(Style {
2075 flex_direction: FlexDirection::Column,
2076 align_items: Some(AlignItems::FlexStart),
2077 justify_content: Some(JustifyContent::FlexStart),
2078 ..Default::default()
2079 })
2080 .children(vec![arriba, abajo]);
2081 let mut layout = LayoutTree::new();
2082 let m = mount(&mut layout, root);
2083 let c = layout.compute(m.root, (400.0, 400.0)).expect("layout");
2084 // Nodo de arriba (y 0..100): doble-tap sí, long-press no.
2085 assert_eq!(hit_test_double_tap(&m, &c, 50.0, 50.0), Some(1));
2086 assert_eq!(hit_test_long_press(&m, &c, 50.0, 50.0), None);
2087 // Nodo de abajo (y 100..200): long-press sí, doble-tap no.
2088 assert_eq!(hit_test_long_press(&m, &c, 50.0, 150.0), Some(2));
2089 assert_eq!(hit_test_double_tap(&m, &c, 50.0, 150.0), None);
2090 // Fuera de ambos.
2091 assert_eq!(hit_test_double_tap(&m, &c, 300.0, 300.0), None);
2092 assert_eq!(hit_test_long_press(&m, &c, 300.0, 300.0), None);
2093 }
2094}