Skip to main content

llimphi_compositor/
render.rs

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}