use super::*; pub fn mount(layout: &mut LayoutTree, v: View) -> Mounted { let mut nodes = Vec::new(); let mut text_measures = std::collections::HashMap::new(); let root = mount_recursive(layout, v, &mut nodes, &mut text_measures); Mounted { root, nodes, text_measures } } /// Mount en pre-orden directo sobre `out`: pusheamos el padre como /// placeholder (id real desconocido hasta crear el taffy node), recursamos /// hijos sobre el mismo `out`, y al volver completamos `id` + `subtree_end`. pub fn mount_recursive( layout: &mut LayoutTree, v: View, out: &mut Vec>, text_measures: &mut std::collections::HashMap, ) -> NodeId { let View { style, fill, hover_fill, radius, corner_radii, shadow, fill_gradient, border, text, image, image_fit, mask_image, mask_placement, mask_extra, painter, gpu_painter, over_painter, on_click, on_click_at, on_right_click, on_right_click_at, on_middle_click, drag, drag_at, drag_velocity, drag_payload, on_drop, drop_hover_fill, clip, clip_inset, clip_ellipse, clip_polygon, clip_path_svg, clip_ref_inset, on_pointer_enter, on_pointer_leave, on_pointer_move_at, on_scroll, on_scale, on_rotate, on_double_tap, on_double_tap_at, on_long_press, on_long_press_at, focusable, text_select_key, alpha, anim, animated_size, semantics, hero, transform, transform_rel, transform_origin, tooltip, cursor, ripple, layout_builder, backdrop_blur, filter, blend, children, } = v; let parent_idx = out.len(); out.push(MountedNode { id: NodeId::new(0), // placeholder, lo sobreescribimos abajo fill, hover_fill, radius, corner_radii, shadow, fill_gradient, border, text, image, image_fit, mask_image, mask_placement, mask_extra, painter, gpu_painter, over_painter, on_click, on_click_at, on_right_click, on_right_click_at, on_middle_click, drag, drag_at, drag_velocity, drag_payload, on_drop, drop_hover_fill, clip, clip_inset, clip_ellipse, clip_polygon, clip_path_svg, clip_ref_inset, on_pointer_enter, on_pointer_leave, on_pointer_move_at, on_scroll, on_scale, on_rotate, on_double_tap, on_double_tap_at, on_long_press, on_long_press_at, focusable, text_select_key, alpha, anim, animated_size, semantics, hero, transform, transform_rel, transform_origin, tooltip, cursor, ripple, // Un layout_builder ya expandido llega como nodo normal; si llega sin // expandir (caller no pasó por el runtime), se monta como hoja y este // flag permite que el runtime lo detecte y resuelva. is_layout_builder: layout_builder.is_some(), backdrop_blur, filter, blend, subtree_end: 0, }); let mut child_ids = Vec::with_capacity(children.len()); for child in children { child_ids.push(mount_recursive(layout, child, out, text_measures)); } let id = if child_ids.is_empty() { layout.leaf(style).expect("layout leaf") } else { layout.node(style, &child_ids).expect("layout node") }; out[parent_idx].id = id; out[parent_idx].subtree_end = out.len(); // Hoja de texto uniforme: registrá su contenido para que el runtime lo // mida con parley. El texto multicolor (`runs`) lo dimensiona el caller // (editor: un nodo por línea), así que no lo medimos acá. if child_ids.is_empty() { if let Some(text) = out[parent_idx].text.as_ref() { if text.runs.is_none() { text_measures.insert( id, TextMeasure { content: text.content.clone(), size_px: text.size_px, alignment: text.alignment, italic: text.italic, font_family: text.font_family.clone(), line_height: text.line_height, weight: text.weight, max_lines: text.max_lines, ellipsis: text.ellipsis, underline: text.underline, strikethrough: text.strikethrough, spans: text.spans.clone(), letter_spacing: text.letter_spacing, word_spacing: text.word_spacing, no_wrap: text.no_wrap, overflow_wrap: text.overflow_wrap, }, ); } } } id } /// Mide una hoja de texto para taffy: shaping + line-break con parley contra /// el ancho disponible, devolviendo el bounding box. Si el ancho ya está /// resuelto (`known.width`) se usa ese; si no, se deriva del `available` /// (Definite → ese ancho; MaxContent → sin límite = una línea; MinContent → /// 0 = envuelve a la palabra más ancha). El `line_height` sale del propio /// `TextMeasure`, el mismo que usa `paint`, así medida y pintado coinciden. pub fn measure_text_node( ts: &mut llimphi_text::Typesetter, tm: &TextMeasure, known: llimphi_layout::taffy::Size>, available: llimphi_layout::taffy::Size, ) -> llimphi_layout::taffy::Size { use llimphi_layout::taffy::AvailableSpace; // `white-space: nowrap`/`pre`: el texto se mide en una sola línea (ancho // completo) ignorando el `available`/`known` — equivale a MaxContent. let max_width: Option = if tm.no_wrap { None } else { known.width.or(match available.width { AvailableSpace::Definite(w) => Some(w), AvailableSpace::MaxContent => None, AvailableSpace::MinContent => Some(0.0), }) }; // RichText: si hay spans, mediar con `layout_spans` para que taffy // reserve el alto considerando overrides de tamaño por rango (un span // con `size_px = 24` dentro de un párrafo de 14 px agranda esa línea). // El clamp `max_lines`/`ellipsis` no se aplica al camino spans en v1 // (RichText típico no clampea — los headings y links viven el bloque // completo); el caller que necesite clamp con spans puede recortar el // texto antes de pasarlo. if let Some(spans) = tm.spans.as_ref() { if !spans.is_empty() { let layout = ts.layout_spans( &tm.content, tm.size_px, vello::peniko::Color::from_rgba8(0, 0, 0, 255), tm.weight, tm.line_height, tm.italic, tm.font_family.as_deref(), tm.underline, tm.strikethrough, spans, max_width, tm.alignment, ); return llimphi_layout::taffy::Size { width: layout.width(), height: layout.height(), }; } } // Camino directo a `layout_clamped` (no `TextBlock`) para transportar // `weight` (bold mide más ancho) y `max_lines` (taffy reserva el alto de // N líneas, no el del texto completo). Sin clamp, equivale a `layout`. let layout = ts.layout_clamped( &tm.content, tm.size_px, max_width, tm.alignment, tm.line_height, tm.italic, tm.font_family.as_deref(), tm.weight, tm.max_lines, tm.ellipsis, tm.underline, tm.strikethrough, tm.letter_spacing, tm.word_spacing, tm.overflow_wrap, ); let m = llimphi_text::measurement(&layout); llimphi_layout::taffy::Size { width: m.width, height: m.height } } /// Construye el `RoundedRect` del nodo respetando radio por esquina si lo /// hay (si no, el escalar uniforme), con un `inset` opcional restado al rect /// y a cada radio (lo usa el borde, que pinta media línea hacia adentro). pub(crate) fn node_rrect( x0: f64, y0: f64, x1: f64, y1: f64, radius: f64, corners: Option, inset: f64, ) -> RoundedRect { let radii = match corners { Some(c) => RoundedRectRadii::new( (c.top_left - inset).max(0.0), (c.top_right - inset).max(0.0), (c.bottom_right - inset).max(0.0), (c.bottom_left - inset).max(0.0), ), None => { let r = (radius - inset).max(0.0); RoundedRectRadii::new(r, r, r, r) } }; RoundedRect::new(x0 + inset, y0 + inset, x1 - inset, y1 - inset, radii) } /// Resuelve un radio de `clip-path: circle()/ellipse()` a px, dado su /// quíntuple `[px, pct_w, pct_h, pct_diag, side]`, el centro local `(cxl, /// cyl)` (relativo al origen del rect), el tamaño `(w, h)` y si el radio es /// del eje X (`is_x`). Con `side == 0` suma px + porcentajes (diag = /// √(w²+h²)/√2). Con `side != 0` ignora px/pct y mide la distancia del centro /// a los bordes: `1`/`2` = closest/farthest sobre los 4 lados (circle); /// `3`/`4` = ídem sobre el eje del radio (ellipse). Fase 7.1222. fn resolve_clip_radius(q: &[f32], cxl: f64, cyl: f64, w: f64, h: f64, is_x: bool) -> f64 { let side = q[4] as i32; if side == 0 { let diag = (w * w + h * h).sqrt() / core::f64::consts::SQRT_2; return q[0] as f64 + q[1] as f64 / 100.0 * w + q[2] as f64 / 100.0 * h + q[3] as f64 / 100.0 * diag; } let (dx_near, dx_far) = (cxl.min(w - cxl), cxl.max(w - cxl)); let (dy_near, dy_far) = (cyl.min(h - cyl), cyl.max(h - cyl)); match side { 1 => dx_near.min(dy_near), // closest-side, circle (4 lados) 2 => dx_far.max(dy_far), // farthest-side, circle 3 => { if is_x { dx_near } else { dy_near } } // closest-side, ellipse (eje) _ => { if is_x { dx_far } else { dy_far } } // 4 = farthest-side, ellipse } } pub fn paint( scene: &mut vello::Scene, mounted: &Mounted, computed: &ComputedLayout, typesetter: &mut llimphi_text::Typesetter, hover_idx: Option, drop_hover_idx: Option, ) { paint_range( scene, mounted, computed, typesetter, hover_idx, drop_hover_idx, 0, mounted.nodes.len(), Affine::IDENTITY, ); } /// Recolecta los nodos con [`MountedNode::backdrop_blur`] activo del árbol /// montado, junto con el sigma y el rect absoluto al cual restringir el /// blur. El runtime (`llimphi-ui::eventloop`) los aplica como post-pasada /// **después** de la rasterización vello, sobre la intermediate. /// /// La búsqueda **salta el subárbol** al encontrar un blur — sin anidamiento /// en v1: un blur dentro de otro blur sería redundante (el padre ya borrona /// el rect que cubre al hijo). /// /// **Limitación v1 (post-pasada)**: el blur ocurre tras vello, así que el /// fill/text/imagen del nodo blur y sus descendientes — pintados antes en /// la misma rasterización — quedan **borroneados** también. Útil para /// paneles "vidrio sobre fondo" sin contenido propio (el contenido nítido /// se compone como nodo hermano posterior con el mismo rect). La paridad /// completa con CSS `backdrop-filter` requiere scene-split (Bloque 11.B /// del roadmap). pub fn collect_backdrop_blurs( mounted: &Mounted, computed: &ComputedLayout, ) -> Vec { let mut out = Vec::new(); let mut idx = 0; while idx < mounted.nodes.len() { let node = &mounted.nodes[idx]; if let Some(sigma) = node.backdrop_blur { if let Some(r) = computed.get(node.id) { out.push(BackdropBlur { sigma, rect: (r.x, r.y, r.w, r.h), }); idx = node.subtree_end; continue; } } idx += 1; } out } /// Datos de un backdrop blur listos para que el runtime lo aplique sobre /// la intermediate vía `llimphi_hal::BlurCompositor::blur`. #[derive(Debug, Clone, Copy)] pub struct BackdropBlur { /// Sigma del Gauss en pixels lógicos. pub sigma: f32, /// Rect absoluto `(x, y, w, h)` del nodo, en pixels lógicos del viewport. pub rect: (f32, f32, f32, f32), } /// Una operación de `filter` lista para que el runtime la aplique sobre la /// intermediate, restringida a `rect`. Espeja [`BackdropBlur`] pero lleva una /// [`FilterOp`] genérica (el runtime hace match por variante). Fase 7.1232. #[derive(Debug, Clone)] pub struct FilterPass { /// Rect absoluto `(x, y, w, h)` del nodo, en pixels lógicos del viewport. pub rect: (f32, f32, f32, f32), /// La operación a aplicar (blur / color-matrix / …). pub op: FilterOp, } /// Recolecta los nodos con [`MountedNode::filter`] no vacío y los aplana en una /// lista de [`FilterPass`] **en orden de árbol y en orden de la lista de cada /// nodo** — así el runtime aplica la cadena `filter: a b c` en secuencia sobre /// el rect (a, luego b, luego c). El runtime las consume tras la rasterización /// vello, igual que [`collect_backdrop_blurs`]. /// /// Salta el subárbol al encontrar un nodo con filtro (como backdrop_blur): un /// filtro anidado sobre el mismo rect sería redundante en la post-pasada v1. pub fn collect_filters( mounted: &Mounted, computed: &ComputedLayout, ) -> Vec { let mut out = Vec::new(); let mut idx = 0; while idx < mounted.nodes.len() { let node = &mounted.nodes[idx]; if !node.filter.is_empty() { if let Some(r) = computed.get(node.id) { let rect = (r.x, r.y, r.w, r.h); for op in &node.filter { // `DropShadow` se pinta en vello (paint_range), no como // post-pasada GPU — la salteamos acá. Fase 7.1234. if matches!(op, FilterOp::DropShadow(_)) { continue; } out.push(FilterPass { rect, op: op.clone() }); } idx = node.subtree_end; continue; } } idx += 1; } out } /// Resuelve el afín efectivo de un nodo a partir de su `transform` (afín fijo) /// y/o `transform_rel` (traslación en fracción de su tamaño), centrado por /// `transform-origin: 50% 50%` contra su rect computado `r`. El `transform_rel` /// entra como factor más externo (`T_rel · transform`), igual que un /// `translate(<%>)` al frente de la lista CSS. `None` si el nodo no tiene /// ninguno de los dos (caso mayoritario → no se toca el stack de transform). /// Lo usan `paint_range` y los walks de hit-test para mantenerse en sincronía. pub(crate) fn resolve_node_transform( transform: Option, transform_rel: Option<(f64, f64)>, transform_origin: Option, r: llimphi_layout::Rect, ) -> Option { if transform.is_none() && transform_rel.is_none() { return None; } let mut local = transform.unwrap_or(Affine::IDENTITY); if let Some((fx, fy)) = transform_rel { local = Affine::translate((fx * r.w as f64, fy * r.h as f64)) * local; } // Pivote = `transform-origin`: `px + frac · tamaño` por eje contra el rect. // `None` ⇒ centro (default CSS `50% 50%`). El afín se ancla al pivote: // `T(pivote) · local · T(-pivote)`. let pivot = transform_origin.unwrap_or_default(); let ox = r.x as f64 + pivot.px.0 + pivot.frac.0 * r.w as f64; let oy = r.y as f64 + pivot.px.1 + pivot.frac.1 * r.h as f64; Some(Affine::translate((ox, oy)) * local * Affine::translate((-ox, -oy))) } /// Pinta el rango de nodos `[start, end)` de `mounted` en `scene`, partiendo de /// la transformación acumulada `base_xf`. [`paint`] lo llama con todo el árbol /// (`0..len`, `IDENTITY`). El rango permite **capturar un subárbol** en una /// escena aparte (p. ej. el snapshot de un nodo que va a animar su salida, ver /// [`crate::AnimRegistry`]): se pasa `(start, subtree_end)` del nodo raíz. Las /// coordenadas de los rects ya son absolutas, así que la subescena se puede /// reproducir luego con `scene.append` aunque sus ancestros ya no existan. /// /// Las capas (clip/alpha) que el subárbol abre se cierran dentro del rango (su /// `subtree_end ≤ end`) o por el drenaje final — la LIFO se respeta. `base_xf` /// debería ser la transformación de los ancestros del nodo raíz; al capturar /// se pasa `IDENTITY` (v1 no contempla raíces bajo ancestros transformados). /// Cierra una capa de aislamiento de `mask-image` aplicando la máscara al /// contenido ya pintado en la capa (el subárbol del nodo). Según `mask-mode` /// (Fase 7.1228) abre una capa de **luminancia** (`push_luminance_mask_layer`) /// o **alpha** (`Compose::DestIn`); dentro pinta la capa 0 (`img`) y las capas /// extra (`extra`, Fase 7.1231) combinadas por su operador `mask-composite`. El /// caller cierra la capa de aislamiento con su propio `pop_layer` tras esto. /// /// `placement` (Fase 7.1227+) fija el encaje con la misma aritmética que /// `background-image` (size → tamaño del tile, position → offset del primero, /// repeat → tiling por eje), el modo (`mask-mode`, Fase 7.1228) y las cajas de /// referencia (`mask-clip` recorta la capa, `mask-origin` ancla el tiling, Fase /// 7.1230). `None` = estirar al border-box en modo luminancia (Fase 7.1226). fn paint_mask_close( scene: &mut vello::Scene, img: &Image, extra: &[(Image, MaskCompose)], rect: KurboRect, xf: Affine, placement: Option, ) { // Cajas de referencia (Fase 7.1230): `mask-clip` recorta el efecto; // `mask-origin` ancla el tiling/position. Se encoge el border-box `rect` // por los insets resueltos. `None` = border-box (sin cambio). let shrink = |r: KurboRect, inset: Option<[f32; 4]>| -> KurboRect { match inset { None => r, Some([t, ri, b, le]) => KurboRect::new( r.x0 + le as f64, r.y0 + t as f64, (r.x1 - ri as f64).max(r.x0 + le as f64), (r.y1 - b as f64).max(r.y0 + t as f64), ), } }; let clip_rect = shrink(rect, placement.and_then(|p| p.clip_inset)); let origin_rect = shrink(rect, placement.and_then(|p| p.origin_inset)); // Apertura de la capa según `mask-mode` (Fase 7.1228), recortada a la caja // de `mask-clip`: luminance usa la capa de luminancia nativa de vello; // alpha compone la máscara con `Compose::DestIn` (mantiene el destino —el // subárbol ya pintado— donde la fuente —la máscara— tiene alpha). Sin // `MaskPlacement` el modo es luminancia (Fase 7.1226). let mode = placement.map(|p| p.mode).unwrap_or(MaskMode::Luminance); match mode { MaskMode::Luminance => scene.push_luminance_mask_layer(Fill::NonZero, 1.0, xf, &clip_rect), MaskMode::Alpha => scene.push_layer( Fill::NonZero, vello::peniko::BlendMode::new(Mix::Normal, vello::peniko::Compose::DestIn), 1.0, xf, &clip_rect, ), } // Capa 0 + capas extra (Fase 7.1231). Las extras comparten el `placement`. // `add` (default) se dibuja directo (source-over acumula la máscara); el // resto compone vía un `Compose` Porter-Duff en una sub-capa. // // NOTA: la composición multi-capa no está verificada a píxeles (CI sin GPU); // el mapeo mask-composite → Compose es el de la spec. Para `mask-mode: // luminance` con varias capas la combinación es aproximada (se compone la // imagen y luego la capa toma su luminancia), exacta para `alpha`. draw_mask_layer(scene, img, origin_rect, xf, placement); for (eimg, op) in extra { match op { MaskCompose::Add => draw_mask_layer(scene, eimg, origin_rect, xf, placement), _ => { let compose = match op { MaskCompose::Subtract => vello::peniko::Compose::SrcOut, MaskCompose::Intersect => vello::peniko::Compose::SrcIn, MaskCompose::Exclude => vello::peniko::Compose::Xor, MaskCompose::Add => unreachable!(), }; scene.push_layer( Fill::NonZero, vello::peniko::BlendMode::new(Mix::Normal, compose), 1.0, xf, &clip_rect, ); draw_mask_layer(scene, eimg, origin_rect, xf, placement); scene.pop_layer(); } } } scene.pop_layer(); } /// Pinta UNA imagen-máscara dentro de la capa de máscara ya abierta, con su /// encaje (`placement`): `None` la estira a `origin_rect` (Fase 7.1226), `Some` /// la tilea (size/position/repeat resueltos contra `origin_rect`, Fase /// 7.1227/7.1230). La comparten la capa 0 y las extra (Fase 7.1231). No abre ni /// cierra capas — el caller controla la capa de máscara y el compose. fn draw_mask_layer( scene: &mut vello::Scene, img: &Image, origin_rect: KurboRect, xf: Affine, placement: Option, ) { let iw = img.image.width.max(1) as f64; let ih = img.image.height.max(1) as f64; match placement { // Estirar la máscara a la caja de origen (= border-box si no hay // mask-origin). None => { let fit = Affine::translate((origin_rect.x0, origin_rect.y0)) * Affine::scale_non_uniform(origin_rect.width() / iw, origin_rect.height() / ih); scene.draw_image(img, xf * fit); } // size/position/repeat estilo background-image, resueltos contra la caja // de `mask-origin`. Some(p) => { let rw = origin_rect.width(); let rh = origin_rect.height(); // 1) Tamaño del tile (px). `Auto` por eje deriva el otro por aspecto. let resolve = |l: MaskLen, basis: f64| -> Option { match l { MaskLen::Px(n) => Some(n as f64), MaskLen::Pct(q) => Some(basis * q as f64 / 100.0), MaskLen::Auto => None, } }; let (tw, th) = match p.size { MaskSize::Auto => (iw, ih), MaskSize::Cover => { let s = (rw / iw).max(rh / ih); (iw * s, ih * s) } MaskSize::Contain => { let s = (rw / iw).min(rh / ih); (iw * s, ih * s) } MaskSize::Explicit { x, y } => match (resolve(x, rw), resolve(y, rh)) { (Some(w), Some(h)) => (w, h), (Some(w), None) => (w, w * ih / iw), (None, Some(h)) => (h * iw / ih, h), (None, None) => (iw, ih), }, }; if tw > 0.5 && th > 0.5 { // 2) Offset del primer tile. `Pct` = alineación CSS. let pos_off = |l: MaskLen, basis: f64, tile: f64| -> f64 { match l { MaskLen::Px(n) => n as f64, MaskLen::Pct(q) => (basis - tile) * q as f64 / 100.0, MaskLen::Auto => 0.0, } }; let ox = pos_off(p.pos_x, rw, tw); let oy = pos_off(p.pos_y, rh, th); // 3) Posiciones de inicio cubriendo [0, span] (o sólo el offset // si el eje no repite). Cap defensivo contra tiles diminutos. let axis = |off: f64, tile: f64, span: f64, rep: bool| -> Vec { if !rep { return vec![off]; } let mut start = off; while start > 0.0 { start -= tile; } let mut v = Vec::new(); let mut q = start; while q < span && v.len() < 4096 { v.push(q); q += tile; } v }; let xs = axis(ox, tw, rw, p.repeat_x); let ys = axis(oy, th, rh, p.repeat_y); let scale = Affine::scale_non_uniform(tw / iw, th / ih); for &x in &xs { for &y in &ys { let tf = Affine::translate((origin_rect.x0 + x, origin_rect.y0 + y)) * scale; scene.draw_image(img, xf * tf); } } } } } } #[allow(clippy::too_many_arguments)] pub fn paint_range( scene: &mut vello::Scene, mounted: &Mounted, computed: &ComputedLayout, typesetter: &mut llimphi_text::Typesetter, hover_idx: Option, drop_hover_idx: Option, start: usize, end: usize, base_xf: Affine, ) { // Stack de las capas `push_layer` activas. Vello requiere pop_layer en // orden LIFO estricto, así que mantenemos un único stack común y popeamos // en el orden inverso al push. Cada entrada es `(subtree_end, máscara?)`: // la mayoría son `None` (clip y/o alpha — la capa sólo se cierra con // `pop_layer`); las de `mask-image` llevan `Some((imagen, rect, xf))` y al // cerrar aplican la luminancia de la máscara sobre el subárbol ya pintado // (ver `paint_mask_close`) antes del `pop_layer` de la capa de aislamiento. // Dos entradas con el mismo `subtree_end` (p. ej. alpha + mask + clip sobre // el mismo nodo) se cierran en el orden inverso al push. // Payload de máscara: (capa 0, capas extra `(img, op)`, rect border-box, xf, // placement compartido). `paint_mask_close` lo consume al cerrar. type MaskClose = ( Image, Vec<(Image, MaskCompose)>, KurboRect, Affine, Option, ); let mut layer_stack: Vec<(usize, Option)> = Vec::new(); // Stack de transformaciones afines de subtree. Cada entrada guarda el // `subtree_end` y la `cur_xf` previa para restaurarla al salir del // subárbol. `cur_xf` es el producto acumulado de todos los `transform` // de los ancestros activos — se multiplica en cada draw call. Cuando // ningún nodo transforma, queda en `base_xf` y el paint es idéntico // al previo (cero regresión). let mut xf_stack: Vec<(usize, Affine)> = Vec::new(); let mut cur_xf = base_xf; for idx in start..end { let node = &mounted.nodes[idx]; // Cierre de capas que ya quedaron atrás (idx ≥ subtree_end). Si la // capa es una máscara, aplicamos su luminancia ANTES del pop. while let Some(&(end, _)) = layer_stack.last() { if idx >= end { let (_, mask) = layer_stack.pop().unwrap(); if let Some((img, extra, rect, xf, placement)) = &mask { paint_mask_close(scene, img, extra, *rect, *xf, *placement); } scene.pop_layer(); } else { break; } } // Restaurá la transformación al salir de subárboles transformados. while let Some(&(end, prev)) = xf_stack.last() { if idx >= end { cur_xf = prev; xf_stack.pop(); } else { break; } } let Some(r) = computed.get(node.id) else { continue; }; // Transform CSS del nodo: se aplica alrededor del centro de su rect // (`transform-origin: 50% 50%`) y se compone sobre la del padre. Se // empuja ANTES del alpha/fill para que toda la pintura del subtree // (incl. la capa de alpha y el clip) caiga en el espacio transformado. if let Some(centered) = resolve_node_transform(node.transform, node.transform_rel, node.transform_origin, r) { xf_stack.push((node.subtree_end, cur_xf)); cur_xf *= centered; } // `mix-blend-mode` (Fase 7.1237): abrí una capa de mezcla para el // subárbol del nodo con el modo CSS resuelto. Va ANTES del alpha (es la // capa más externa) para que el elemento entero —incluida su propia // opacidad— se mezcle como una unidad contra el backdrop ya pintado. Al // cerrarse (loop de cierre / drain final) vello compone el subárbol // aislado con el blend indicado. v1: el backdrop es la escena ya // pintada, no un fondo aislado (exacto con contenido opaco debajo). if let Some(bm) = node.blend { let rect = KurboRect::new( r.x as f64, r.y as f64, (r.x + r.w) as f64, (r.y + r.h) as f64, ); scene.push_layer(Fill::NonZero, bm, 1.0, cur_xf, &rect); layer_stack.push((node.subtree_end, None)); } // Alpha de subtree: push ANTES de cualquier paint de este nodo // para que fill/text/image/painter/children entren en la misma // capa y se compongan juntos al alfa indicado. Si el nodo tiene // hijos, su `subtree_end > idx + 1` y la capa permanece abierta // hasta que el loop alcance el primer índice fuera del subárbol. // Para nodos hoja con alpha el push y el pop son consecutivos — // funcionalmente equivalente a multiplicar el alpha del fill, // pero permite usar el mismo API sin distinguir hoja vs rama. if let Some(a) = node.alpha { let rect = KurboRect::new( r.x as f64, r.y as f64, (r.x + r.w) as f64, (r.y + r.h) as f64, ); scene.push_layer(Fill::NonZero, Mix::Normal, a, cur_xf, &rect); layer_stack.push((node.subtree_end, None)); } // `mask-image` (Fase 7.1226): abrí una capa de aislamiento para el // subárbol del nodo. La luminancia de la máscara se aplica al CERRARLA // (en el loop de cierre / drain final, vía `paint_mask_close`), así // recorta sólo a este nodo + hijos y no a los hermanos previos. Va // DESPUÉS del alpha (afuera del clip-path, que se pushea al final del // bloque) para envolver fill + contenido + hijos. if let Some(mask_img) = node.mask_image.as_ref() { let rect = KurboRect::new( r.x as f64, r.y as f64, (r.x + r.w) as f64, (r.y + r.h) as f64, ); scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, cur_xf, &rect); layer_stack.push(( node.subtree_end, Some(( mask_img.clone(), node.mask_extra.clone(), rect, cur_xf, node.mask_placement, )), )); } // Sombra (drop shadow): se pinta ANTES del relleno para quedar // detrás. Usa el blur gaussiano nativo de vello sobre un rect // redondeado offseteado + inflado por `spread`. if let Some(sh) = node.shadow.as_ref() { if sh.color.components[3] > 0.0 && r.w > 0.0 && r.h > 0.0 { let rect = KurboRect::new( (r.x as f64) + sh.dx - sh.spread, (r.y as f64) + sh.dy - sh.spread, (r.x + r.w) as f64 + sh.dx + sh.spread, (r.y + r.h) as f64 + sh.dy + sh.spread, ); let radius = (node.radius + sh.spread).max(0.0); scene.draw_blurred_rounded_rect(cur_xf, rect, sh.color, radius, sh.blur); } } // `filter: drop-shadow(...)` (Fase 7.1234): una o más sombras Gaussianas // del border-box, detrás del relleno. Misma primitiva que box-shadow; v1 // pinta la sombra del rect, no de la silueta alpha del subárbol. En // orden de la lista (la primera declarada queda más atrás). for op in node.filter.iter().rev() { if let FilterOp::DropShadow(sh) = op { if sh.color.components[3] > 0.0 && r.w > 0.0 && r.h > 0.0 { let rect = KurboRect::new( (r.x as f64) + sh.dx - sh.spread, (r.y as f64) + sh.dy - sh.spread, (r.x + r.w) as f64 + sh.dx + sh.spread, (r.y + r.h) as f64 + sh.dy + sh.spread, ); let radius = (node.radius + sh.spread).max(0.0); scene.draw_blurred_rounded_rect(cur_xf, rect, sh.color, radius, sh.blur); } } } // Prioridad de pintura: drop-hover (drag activo) > hover normal > // gradiente base > fill color base. Solo aplica el override si el // slot correspondiente está poblado; el siguiente cae como fallback. let hover_color = if Some(idx) == drop_hover_idx { node.drop_hover_fill.or(node.hover_fill).or(node.fill) } else if Some(idx) == hover_idx { node.hover_fill.or(node.fill) } else { None }; let rr = node_rrect( r.x as f64, r.y as f64, (r.x + r.w) as f64, (r.y + r.h) as f64, node.radius, node.corner_radii, 0.0, ); if let Some(color) = hover_color { // Hover/drop gana sobre el gradiente y el fill base. scene.fill(Fill::NonZero, cur_xf, color, None, &rr); } else if let Some(grad) = node.fill_gradient.as_ref() { // Gradiente autoreado en `[0,1]²`, mapeado al rect vía // brush_transform (incluye la transformación acumulada). let brush_xf = cur_xf * Affine::translate((r.x as f64, r.y as f64)) * Affine::scale_non_uniform(r.w as f64, r.h as f64); scene.fill(Fill::NonZero, cur_xf, grad, Some(brush_xf), &rr); } else if let Some(color) = node.fill { scene.fill(Fill::NonZero, cur_xf, color, None, &rr); } // Borde (stroke) sobre el relleno, inset media línea hacia adentro. if let Some(b) = node.border.as_ref() { if b.width > 0.0 && b.color.components[3] > 0.0 && r.w > 0.0 && r.h > 0.0 { let inset = b.width * 0.5; let brr = node_rrect( r.x as f64, r.y as f64, (r.x + r.w) as f64, (r.y + r.h) as f64, node.radius, node.corner_radii, inset, ); scene.stroke(&Stroke::new(b.width), cur_xf, b.color, None, &brr); } } if let Some(image) = node.image.as_ref() { // Encaje seleccionable (Bloque 12) — Contain/Cover/Fill/None. // Siempre clippeamos al `node_rrect` para respetar // `radius`/`corner_radii` (avatares + cards) y para que // `Cover`/`None` no derramen fuera del nodo. if image.image.width > 0 && image.image.height > 0 && r.w > 0.0 && r.h > 0.0 { let sx = r.w as f64 / image.image.width as f64; let sy = r.h as f64 / image.image.height as f64; let fit = node.image_fit.unwrap_or(ImageFit::Contain); let transform = match fit { ImageFit::Contain => { let s = sx.min(sy); let disp_w = image.image.width as f64 * s; let disp_h = image.image.height as f64 * s; let tx = r.x as f64 + (r.w as f64 - disp_w) * 0.5; let ty = r.y as f64 + (r.h as f64 - disp_h) * 0.5; Affine::translate((tx, ty)) * Affine::scale(s) } ImageFit::Cover => { let s = sx.max(sy); let disp_w = image.image.width as f64 * s; let disp_h = image.image.height as f64 * s; let tx = r.x as f64 + (r.w as f64 - disp_w) * 0.5; let ty = r.y as f64 + (r.h as f64 - disp_h) * 0.5; Affine::translate((tx, ty)) * Affine::scale(s) } ImageFit::Fill => { Affine::translate((r.x as f64, r.y as f64)) * Affine::scale_non_uniform(sx, sy) } ImageFit::None => { let disp_w = image.image.width as f64; let disp_h = image.image.height as f64; let tx = r.x as f64 + (r.w as f64 - disp_w) * 0.5; let ty = r.y as f64 + (r.h as f64 - disp_h) * 0.5; Affine::translate((tx, ty)) } }; let clip_rr = node_rrect( r.x as f64, r.y as f64, (r.x + r.w) as f64, (r.y + r.h) as f64, node.radius, node.corner_radii, 0.0, ); scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, cur_xf, &clip_rr); scene.draw_image(image, cur_xf * transform); scene.pop_layer(); } } if let Some(painter) = node.painter.as_ref() { (painter)( scene, typesetter, PaintRect { x: r.x, y: r.y, w: r.w, h: r.h, }, ); } if let Some(text) = node.text.as_ref() { let has_spans = text .spans .as_ref() .map(|s| !s.is_empty()) .unwrap_or(false); if has_spans { // RichText (Bloque 13): defaults a nivel bloque + spans // sobreescriben size/weight/italic/family/color/underline/ // strikethrough por rango de bytes. Respeta `max_width = r.w` // (wrap a párrafo) y la alignment del bloque; para Center // también centramos verticalmente como en el camino uniforme. let spans = text.spans.as_ref().unwrap(); let layout = typesetter.layout_spans( &text.content, text.size_px, text.color, text.weight, text.line_height, text.italic, text.font_family.as_deref(), text.underline, text.strikethrough, spans, Some(r.w), text.alignment, ); let origin = if matches!(text.alignment, llimphi_text::Alignment::Center) { let lh = layout.height() as f64; ( r.x as f64, r.y as f64 + ((r.h as f64 - lh) * 0.5).max(0.0), ) } else { (r.x as f64, r.y as f64) }; llimphi_text::draw_layout_runs_xf( scene, &layout, cur_xf * Affine::translate(origin), ); } else if let Some(runs) = text.runs.as_ref() { // Texto multicolor (syntax highlighting): una sola pasada de // shaping con color por rango, anclado arriba-izquierda. Cae // por el flujo normal (clip/alpha se cierran como siempre). let layout = typesetter.layout_runs( &text.content, text.size_px, text.color, runs, text.alignment, text.line_height, text.weight, text.underline, text.strikethrough, ); // `cur_xf *` para que el texto multicolor herede la // transformación del subárbol (scroll/rotación del padre), igual // que el texto de color único de abajo. Sin esto se pintaba en // coords de layout crudas y se desalineaba al scrollear. llimphi_text::draw_layout_runs_xf( scene, &layout, cur_xf * Affine::translate((r.x as f64, r.y as f64)), ); } else { // Parley resuelve la alineación horizontal vía max_width + // alignment. Para Center también centramos verticalmente; para // Start/End/Justify anclamos arriba (párrafo/editor). Camino // directo a `layout_clamped` para transportar `weight` y el // clamp de `max_lines`/`ellipsis` del TextSpec. // `white-space: nowrap`/`pre`: pintar en una sola línea (sin // `max_width`), no envolver al ancho del rect — el texto // desborda y lo recorta el `overflow` del contenedor si lo hay. let paint_max_width = if text.no_wrap { None } else { Some(r.w) }; let layout = typesetter.layout_clamped( &text.content, text.size_px, paint_max_width, text.alignment, text.line_height, text.italic, text.font_family.as_deref(), text.weight, text.max_lines, text.ellipsis, text.underline, text.strikethrough, text.letter_spacing, text.word_spacing, text.overflow_wrap, ); let origin = if matches!(text.alignment, llimphi_text::Alignment::Center) { let m = llimphi_text::measurement(&layout); ( r.x as f64, r.y as f64 + ((r.h - m.height) as f64 * 0.5).max(0.0), ) } else { (r.x as f64, r.y as f64) }; llimphi_text::draw_layout_xf( scene, &layout, text.color, cur_xf * Affine::translate(origin), ); } } if node.clip { // El hit-test (más abajo) usa siempre el rect completo — el clip-path // sólo afecta el pintado, una aproximación menor en su banda. // Prioridad: path > polygon > elipse > inset/rect. `pushed` queda // false sólo si un path() no parsea (no se abre capa → no se cierra). let mut pushed = true; // Caja de referencia (clip-path geometry-box, Fase 7.1225): encoge // el rect del nodo por `clip_ref_inset` ANTES de resolver la forma, // así circle/ellipse/polygon/path y sus % se miden contra esa caja. let [rit, rir, rib, ril] = node.clip_ref_inset.unwrap_or([0.0; 4]); let (bx, by) = ((r.x + ril) as f64, (r.y + rit) as f64); let (bw, bh) = ((r.w - ril - rir).max(0.0) as f64, (r.h - rit - rib).max(0.0) as f64); if let Some((evenodd, d)) = &node.clip_path_svg { // `clip-path: path()` — parsea el SVG y lo traslada al origen // de la caja de referencia (user units px). from_svg falla → no // recorta. match vello::kurbo::BezPath::from_svg(d) { Ok(mut path) => { path.apply_affine(Affine::translate((bx, by))); let fill = if *evenodd { Fill::EvenOdd } else { Fill::NonZero }; scene.push_layer(fill, BlendMode::default(), 1.0, cur_xf, &path); } Err(_) => pushed = false, } } else if let Some((evenodd, pts)) = &node.clip_polygon { // `clip-path: polygon()` — capa con un path cerrado. Cada punto // resuelve sus % contra la caja de referencia; move_to al 1º, // line_to al resto, close_path. let mut path = vello::kurbo::BezPath::new(); for (i, p) in pts.iter().enumerate() { let px = bx + p[0] as f64 + p[1] as f64 / 100.0 * bw; let py = by + p[2] as f64 + p[3] as f64 / 100.0 * bh; if i == 0 { path.move_to((px, py)); } else { path.line_to((px, py)); } } path.close_path(); let fill = if *evenodd { Fill::EvenOdd } else { Fill::NonZero }; scene.push_layer(fill, BlendMode::default(), 1.0, cur_xf, &path); } else if let Some(s) = node.clip_ellipse { // `clip-path: circle()/ellipse()` — capa elíptica. Centro y // radios resuelven contra la caja de referencia. El centro local // alimenta tanto la posición como el cómputo de los lados // (closest/farthest-side). let cxl = s[0] as f64 + s[1] as f64 / 100.0 * bw; let cyl = s[2] as f64 + s[3] as f64 / 100.0 * bh; let cx = bx + cxl; let cy = by + cyl; let rx = resolve_clip_radius(&s[4..9], cxl, cyl, bw, bh, true); let ry = resolve_clip_radius(&s[9..14], cxl, cyl, bw, bh, false); let ellipse = Ellipse::new((cx, cy), (rx, ry), 0.0); scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, cur_xf, &ellipse); } else { // `clip_inset` (clip-path: inset) encoge la caja de referencia // desde cada borde; `None` (overflow:hidden / geometry-box solo) // recorta a la caja de referencia completa. let [ct, cr, cb, cl] = node.clip_inset.unwrap_or([0.0; 4]); let clip_rect = KurboRect::new( bx + cl as f64, by + ct as f64, bx + bw - cr as f64, by + bh - cb as f64, ); scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, cur_xf, &clip_rect); } if pushed { layer_stack.push((node.subtree_end, None)); } } } // Cerrá capas (clip + alpha + mask) que llegaron al final sin pop // intermedio. Las de máscara aplican su luminancia antes del pop. while let Some((_, mask)) = layer_stack.pop() { if let Some((img, extra, rect, xf, placement)) = &mask { paint_mask_close(scene, img, extra, *rect, *xf, *placement); } scene.pop_layer(); } } /// Pasada GPU directo: recorre el `Mounted` en pre-orden DFS (mismo orden /// que [`paint`]) e invoca cada `gpu_painter` con el encoder y la /// `TextureView` del frame. Se ejecuta DESPUÉS de la pasada vello — la /// intermediate ya tiene fill/image/painter/text encima cuando los /// callbacks corren, así que su `LoadOp` debe ser `Load`. Devuelve si /// se invocó al menos un painter (para que el caller decida si vale la /// pena finalizar y submitir el encoder). /// `true` si algún nodo del árbol registró un `gpu_painter` (p. ej. el video /// de media vía `gpu_paint_with`). El eventloop lo usa para decidir si la /// capa de overlay necesita componerse aparte (sobre el contenido gpu) en vez /// de pintarse en la escena principal. pub fn has_gpu_painter(mounted: &Mounted) -> bool { mounted.nodes.iter().any(|n| n.gpu_painter.is_some()) } pub fn paint_gpu( mounted: &Mounted, computed: &ComputedLayout, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, view: &wgpu::TextureView, viewport: (u32, u32), ) -> bool { let mut any = false; for node in &mounted.nodes { let Some(painter) = node.gpu_painter.as_ref() else { continue; }; let Some(r) = computed.get(node.id) else { continue; }; (painter)( device, queue, encoder, view, PaintRect { x: r.x, y: r.y, w: r.w, h: r.h, }, viewport, ); any = true; } any } /// `true` si algún nodo del árbol registró un `over_painter` (vello /// "over" vía [`View::paint_over`]). El eventloop lo usa para decidir si /// vale la pena montar la pasada vello final + el composite sobre la /// intermedia. Coste cero (loop barato) cuando nadie usa el over-layer. pub fn has_over_painter(mounted: &Mounted) -> bool { mounted.nodes.iter().any(|n| n.over_painter.is_some()) } /// Pinta la pasada vello "over" en `scene`: recorre el árbol en orden /// DFS pre-orden e invoca cada `over_painter` con el `Typesetter` /// compartido y el rect absoluto del nodo. Espejo de [`paint_gpu`] pero /// del lado vello — la diferencia de timing la pone el caller, que /// rasteriza esta `scene` DESPUÉS del pase GPU y la compone sobre la /// intermedia. No resetea `scene` (el caller decide); sólo agrega /// primitivas. Como [`paint_gpu`], usa rects absolutos (no compone los /// `transform` de ancestros — el over-layer es contenido posicionado en /// coordenadas de pantalla, igual que el pintor GPU). pub fn paint_over( scene: &mut vello::Scene, mounted: &Mounted, computed: &ComputedLayout, typesetter: &mut llimphi_text::Typesetter, ) -> bool { let mut any = false; for node in &mounted.nodes { let Some(painter) = node.over_painter.as_ref() else { continue; }; let Some(r) = computed.get(node.id) else { continue; }; (painter)( scene, typesetter, PaintRect { x: r.x, y: r.y, w: r.w, h: r.h, }, ); any = true; } any } /// Hit-test parametrizado por elegibilidad. Devuelve el índice del nodo /// más al frente (último en pre-orden) cuyo rect contiene `(x, y)` y para /// el cual `pred` devuelve `true`, respetando `clip`: si el punto cae /// afuera de un nodo con clip, el subárbol entero es invisible. /// /// **Respeta `transform`**: igual que [`paint`], compone el afín acumulado /// de los ancestros (cada `transform` alrededor del centro del rect del /// nodo, convención CSS `transform-origin: 50% 50%`). El punto de pantalla /// `(x, y)` se lleva al espacio local del nodo invirtiendo ese afín, y se /// testea contra el rect sin transformar. Así un nodo rotado/escalado/ /// trasladado recibe los clicks donde realmente se ve pintado (recorrido /// tipo Prezi, lienzos de tullpu, `@keyframes` de puriy). Un subárbol con /// afín singular (escala 0) es inalcanzable, igual que es invisible. pub fn hit_test_pred( mounted: &Mounted, computed: &ComputedLayout, x: f32, y: f32, pred: F, ) -> Option where F: Fn(&MountedNode) -> bool, { let mut hit: Option = None; let mut clip_stack: Vec = Vec::new(); // Espejo del stack de transformaciones de `paint`: `cur_xf` es el // producto acumulado de los `transform` de los ancestros activos // (local → pantalla). Vacío ⇒ identidad ⇒ camino directo sin invertir // (cero costo para la abrumadora mayoría de árboles sin transform). let mut xf_stack: Vec<(usize, Affine)> = Vec::new(); let mut cur_xf = Affine::IDENTITY; let mut idx = 0; while idx < mounted.nodes.len() { while let Some(&end) = clip_stack.last() { if idx >= end { clip_stack.pop(); } else { break; } } while let Some(&(end, prev)) = xf_stack.last() { if idx >= end { cur_xf = prev; xf_stack.pop(); } else { break; } } let node = &mounted.nodes[idx]; let Some(r) = computed.get(node.id) else { idx += 1; continue; }; // Componé el transform de este nodo igual que `paint`, ANTES de // resolver el punto local (su propio rect ya cae en el espacio // transformado). if let Some(centered) = resolve_node_transform(node.transform, node.transform_rel, node.transform_origin, r) { xf_stack.push((node.subtree_end, cur_xf)); cur_xf *= centered; } // Punto en el espacio local del nodo. Sin transform activo, es el // punto de pantalla tal cual. Con transform, se invierte el afín; // si es singular (no invertible) el subárbol es inalcanzable. let (lx, ly) = if xf_stack.is_empty() { (x as f64, y as f64) } else if cur_xf.determinant().abs() < 1e-9 { idx = node.subtree_end; continue; } else { let p = cur_xf.inverse() * Point::new(x as f64, y as f64); (p.x, p.y) }; let inside = lx >= r.x as f64 && lx < (r.x + r.w) as f64 && ly >= r.y as f64 && ly < (r.y + r.h) as f64; if node.clip { if !inside { idx = node.subtree_end; continue; } clip_stack.push(node.subtree_end); } if inside && pred(node) { hit = Some(idx); } idx += 1; } hit } /// Hit-test específico para clicks (incluye nodos draggables). pub fn hit_test_click( mounted: &Mounted, computed: &ComputedLayout, x: f32, y: f32, ) -> Option { hit_test_pred(mounted, computed, x, y, |n| { n.on_click.is_some() || n.on_click_at.is_some() || n.drag.is_some() || n.drag_at.is_some() || n.drag_velocity.is_some() }) } /// Hit-test específico para right-click. Sólo considera nodos que /// declararon `on_right_click` o `on_right_click_at` — un right-click /// sobre un nodo sin handler no hace nada (no se "filtra" al click /// izquierdo). pub fn hit_test_right_click( mounted: &Mounted, computed: &ComputedLayout, x: f32, y: f32, ) -> Option { hit_test_pred(mounted, computed, x, y, |n| { n.on_right_click.is_some() || n.on_right_click_at.is_some() }) } /// Hit-test específico para middle-click. Mismo modelo que right-click: /// sólo nodos que declararon `on_middle_click` reaccionan. pub fn hit_test_middle_click( mounted: &Mounted, computed: &ComputedLayout, x: f32, y: f32, ) -> Option { hit_test_pred(mounted, computed, x, y, |n| n.on_middle_click.is_some()) } /// Hit-test específico para hover (nodos con `hover_fill`). pub fn hit_test_hover( mounted: &Mounted, computed: &ComputedLayout, x: f32, y: f32, ) -> Option { hit_test_pred(mounted, computed, x, y, |n| n.hover_fill.is_some()) } /// Hit-test para movimiento posicional del cursor (nodos con /// `on_pointer_move_at`). El runtime lo invoca en cada `CursorMoved` para /// reportar la posición local al nodo más al frente que lo declare. pub fn hit_test_pointer_move( mounted: &Mounted, computed: &ComputedLayout, x: f32, y: f32, ) -> Option { hit_test_pred(mounted, computed, x, y, |n| n.on_pointer_move_at.is_some()) } /// Hit-test específico para la **forma del cursor**: devuelve el [`Cursor`] /// del nodo más al frente bajo el punto que declare uno. Como un hijo sin /// cursor no matchea el predicado, el cursor "cae" al ancestro más cercano que /// lo declare — herencia estilo CSS sin recorrer el árbol a mano. `None` = /// ningún nodo bajo el punto declara cursor (el runtime usa el default de la /// ventana). Lo invoca `llimphi-ui` en la transición de hover. pub fn hit_test_cursor( mounted: &Mounted, computed: &ComputedLayout, x: f32, y: f32, ) -> Option { hit_test_pred(mounted, computed, x, y, |n| n.cursor.is_some()) .and_then(|i| mounted.nodes[i].cursor) } /// Hit-test específico para drop targets (nodos con `on_drop`). Usado /// durante un drag activo para resaltar el destino y para invocar el /// handler al soltar. pub fn hit_test_drop( mounted: &Mounted, computed: &ComputedLayout, x: f32, y: f32, ) -> Option { hit_test_pred(mounted, computed, x, y, |n| n.on_drop.is_some()) } /// Hit-test específico para áreas de scroll (nodos con `on_scroll`). El /// runtime lo usa al recibir la rueda: el nodo más al frente bajo el /// cursor con handler de scroll consume el evento antes del `on_wheel` /// global. pub fn hit_test_scroll( mounted: &Mounted, computed: &ComputedLayout, x: f32, y: f32, ) -> Option { hit_test_pred(mounted, computed, x, y, |n| n.on_scroll.is_some()) } /// Cadena de **scroll anidado**: devuelve todos los nodos con `on_scroll` /// que contienen el punto, ordenados **front→back** (el primero es el más /// al frente, igual que [`hit_test_scroll`]; los siguientes son sus /// ancestros scrollables). El runtime itera la cadena al recibir la rueda /// y se queda con el primer handler que devuelva `Some`: si un scroll /// interno está en el extremo del eje y devuelve `None`, el evento "pasa" /// al ancestro scrollable más cercano (lista dentro de panel, etc.). /// Recorrido idéntico al de [`hit_test_pred`] pero acumulando todos los /// hits en vez de pisar. pub fn hit_test_scroll_chain( mounted: &Mounted, computed: &ComputedLayout, x: f32, y: f32, ) -> Vec { let mut chain: Vec = Vec::new(); let mut clip_stack: Vec = Vec::new(); let mut xf_stack: Vec<(usize, Affine)> = Vec::new(); let mut cur_xf = Affine::IDENTITY; let mut idx = 0; while idx < mounted.nodes.len() { while let Some(&end) = clip_stack.last() { if idx >= end { clip_stack.pop(); } else { break; } } while let Some(&(end, prev)) = xf_stack.last() { if idx >= end { cur_xf = prev; xf_stack.pop(); } else { break; } } let node = &mounted.nodes[idx]; let Some(r) = computed.get(node.id) else { idx += 1; continue; }; if let Some(centered) = resolve_node_transform(node.transform, node.transform_rel, node.transform_origin, r) { xf_stack.push((node.subtree_end, cur_xf)); cur_xf *= centered; } let (lx, ly) = if xf_stack.is_empty() { (x as f64, y as f64) } else if cur_xf.determinant().abs() < 1e-9 { idx = node.subtree_end; continue; } else { let p = cur_xf.inverse() * Point::new(x as f64, y as f64); (p.x, p.y) }; let inside = lx >= r.x as f64 && lx < (r.x + r.w) as f64 && ly >= r.y as f64 && ly < (r.y + r.h) as f64; if node.clip { if !inside { idx = node.subtree_end; continue; } clip_stack.push(node.subtree_end); } if inside && node.on_scroll.is_some() { chain.push(idx); } idx += 1; } // El recorrido es pre-orden, así que los ancestros aparecen primero y // los hijos después. Para front→back necesitamos el orden inverso. chain.reverse(); chain } /// Hit-test específico para gestos de **escala** (pinch-to-zoom): el nodo más /// al frente bajo el punto que declaró un `on_scale`. Como un hijo sin handler /// no matchea el predicado, el gesto "cae" al ancestro más cercano que lo /// declare (un canvas grande zoomeable con widgets encima que no zoomean). El /// runtime lo invoca al recibir Ctrl+rueda o un pinch de trackpad. `None` = /// ningún nodo zoomeable bajo el cursor (el evento cae al scroll/`on_wheel`). pub fn hit_test_scale( mounted: &Mounted, computed: &ComputedLayout, x: f32, y: f32, ) -> Option { hit_test_pred(mounted, computed, x, y, |n| n.on_scale.is_some()) } /// Hit-test específico para gestos de **rotación** (trackpad): el nodo más al /// frente bajo el punto que declaró un `on_rotate`. Análogo a /// [`hit_test_scale`]; el runtime lo invoca al recibir un `RotationGesture`. /// `None` = ningún nodo rotable bajo el cursor. pub fn hit_test_rotate( mounted: &Mounted, computed: &ComputedLayout, x: f32, y: f32, ) -> Option { hit_test_pred(mounted, computed, x, y, |n| n.on_rotate.is_some()) } /// Hit-test para **doble-tap**: el nodo más al frente bajo el punto que /// declaró `on_double_tap`/`on_double_tap_at`. El runtime lo usa al detectar /// dos presses rápidos y cercanos. pub fn hit_test_double_tap( mounted: &Mounted, computed: &ComputedLayout, x: f32, y: f32, ) -> Option { hit_test_pred(mounted, computed, x, y, |n| { n.on_double_tap.is_some() || n.on_double_tap_at.is_some() }) } /// Hit-test para **long-press**: el nodo más al frente bajo el punto que /// declaró `on_long_press`/`on_long_press_at`. El runtime lo usa al armar el /// gesto en el press (que vence por tiempo si no hay movimiento ni release). pub fn hit_test_long_press( mounted: &Mounted, computed: &ComputedLayout, x: f32, y: f32, ) -> Option { hit_test_pred(mounted, computed, x, y, |n| { n.on_long_press.is_some() || n.on_long_press_at.is_some() }) } /// Hit-test para **ripple**: el nodo más al frente bajo el punto que declaró /// un [`Ripple`] (vía [`View::ripple`]). El runtime lo usa en el press para /// disparar la salpicadura. Aditivo — no compite con click/drag. pub fn hit_test_ripple( mounted: &Mounted, computed: &ComputedLayout, x: f32, y: f32, ) -> Option { hit_test_pred(mounted, computed, x, y, |n| n.ripple.is_some()) } /// Hit-test para foco: el id `focusable` del nodo más al frente bajo el /// cursor (click-to-focus). `None` si no se clickeó nada enfocable. pub fn hit_test_focusable( mounted: &Mounted, computed: &ComputedLayout, x: f32, y: f32, ) -> Option { hit_test_pred(mounted, computed, x, y, |n| n.focusable.is_some()) .and_then(|i| mounted.nodes[i].focusable) } /// Hit-test para **selección de texto**: el índice del nodo de texto /// seleccionable (`text_select_key`) más al frente bajo el cursor. El runtime /// lo usa para arrancar/extender una selección; devuelve el índice (no la key) /// para que el caller acceda al `text` + rect del nodo. `None` si no hay texto /// seleccionable bajo el punto. pub fn hit_test_selectable( mounted: &Mounted, computed: &ComputedLayout, x: f32, y: f32, ) -> Option { hit_test_pred(mounted, computed, x, y, |n| n.text_select_key.is_some()) } /// Ids enfocables en orden de Tab (pre-orden del árbol = orden de /// inserción de `Mounted::nodes`). Sólo nodos con rect computado /// (presentes en el layout). Es el orden DOM-like de tabulación. pub fn focus_order(mounted: &Mounted, computed: &ComputedLayout) -> Vec { mounted .nodes .iter() .filter_map(|n| { n.focusable .filter(|_| computed.get(n.id).is_some()) }) .collect() } /// Próximo id de foco al pulsar Tab (o Shift+Tab si `reverse`), dado el /// `order` (de [`focus_order`]) y el `current`. Envuelve en los extremos. /// Si no hay enfocables devuelve `None`; si `current` ya no existe en el /// orden, arranca por el primero (Tab) o el último (Shift+Tab). pub fn next_focus(order: &[u64], current: Option, reverse: bool) -> Option { if order.is_empty() { return None; } let n = order.len(); let pos = current.and_then(|c| order.iter().position(|&id| id == c)); let next_idx = match pos { Some(i) => { if reverse { (i + n - 1) % n } else { (i + 1) % n } } None => { if reverse { n - 1 } else { 0 } } }; Some(order[next_idx]) } #[cfg(test)] mod tests { use crate::{hit_test_click, mount, View}; use llimphi_layout::taffy::prelude::*; use llimphi_layout::{LayoutTree, Style}; use vello::kurbo::Affine; #[test] fn transform_origin_fija_el_pivote() { // El pivote de `transform-origin` queda FIJO bajo la transformación: // top-left (frac 0,0) fija la esquina (0,0); el default (None) fija el // centro (50,50). Rect 100×100 en el origen, rotación 90°. use super::resolve_node_transform; use crate::TransformPivot; use vello::kurbo::Point; let r = llimphi_layout::Rect { x: 0.0, y: 0.0, w: 100.0, h: 100.0 }; let rot = Affine::rotate(std::f64::consts::FRAC_PI_2); let tl = TransformPivot { px: (0.0, 0.0), frac: (0.0, 0.0) }; let xf_tl = resolve_node_transform(Some(rot), None, Some(tl), r).unwrap(); let p = xf_tl * Point::new(0.0, 0.0); assert!(p.x.abs() < 1e-6 && p.y.abs() < 1e-6, "pivote top-left fijo, fue {p:?}"); // Default (None) ⇒ centro: (50,50) queda fijo. let xf_c = resolve_node_transform(Some(rot), None, None, r).unwrap(); let c = xf_c * Point::new(50.0, 50.0); assert!( (c.x - 50.0).abs() < 1e-6 && (c.y - 50.0).abs() < 1e-6, "centro fijo con pivote default, fue {c:?}" ); // Y el centro NO queda fijo con pivote top-left (distingue los dos casos). let c2 = xf_tl * Point::new(50.0, 50.0); assert!((c2.x - 50.0).abs() > 1.0 || (c2.y - 50.0).abs() > 1.0, "top-left mueve el centro"); } #[test] fn resolve_clip_radius_lados_y_porcentajes() { use super::resolve_clip_radius; // Caja 200×100, centro al (50%,50%) = (100,50) local. let (w, h, cxl, cyl): (f64, f64, f64, f64) = (200.0, 100.0, 100.0, 50.0); // side 0: px + pct_w·w + pct_h·h + pct_diag·diag. let diag = (w * w + h * h).sqrt() / core::f64::consts::SQRT_2; let r = resolve_clip_radius(&[10.0, 0.0, 0.0, 50.0, 0.0], cxl, cyl, w, h, true); assert!((r - (10.0 + 0.5 * diag)).abs() < 1e-6); // closest-side circle (1): min(100,100,50,50) = 50. assert_eq!( resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 1.0], cxl, cyl, w, h, true), 50.0 ); // farthest-side circle (2): max(...) = 100. assert_eq!( resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 2.0], cxl, cyl, w, h, true), 100.0 ); // closest-side ellipse eje X (3, is_x): min(cxl, w-cxl) = 100. assert_eq!( resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 3.0], cxl, cyl, w, h, true), 100.0 ); // closest-side ellipse eje Y (3, !is_x): min(cyl, h-cyl) = 50. assert_eq!( resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 3.0], cxl, cyl, w, h, false), 50.0 ); // Centro descentrado (30, 20): closest circle = min(30,170,20,80)=20. assert_eq!( resolve_clip_radius(&[0.0, 0.0, 0.0, 0.0, 1.0], 30.0, 20.0, w, h, true), 20.0 ); } /// Un hijo clickeable de 100×100 anclado arriba-izquierda. Devuelve /// `(mounted, computed)` ya layouteados sobre un viewport 400×400. fn fixture( transform: Option, ) -> (crate::Mounted<()>, llimphi_layout::ComputedLayout) { let mut child = View::<()>::new(Style { size: Size { width: length(100.0), height: length(100.0), }, ..Default::default() }) .on_click(()); if let Some(xf) = transform { child = child.transform(xf); } let root = View::<()>::new(Style { align_items: Some(AlignItems::FlexStart), justify_content: Some(JustifyContent::FlexStart), ..Default::default() }) .children(vec![child]); let mut layout = LayoutTree::new(); let mounted = mount(&mut layout, root); let computed = layout.compute(mounted.root, (400.0, 400.0)).expect("layout"); (mounted, computed) } #[test] fn sin_transform_el_hit_cae_en_el_rect() { let (m, c) = fixture(None); assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), Some(1)); // dentro assert_eq!(hit_test_click(&m, &c, 250.0, 50.0), None); // fuera } #[test] fn traslacion_mueve_el_area_clickeable() { // El nodo se ve corrido +200px en x; el click debe seguirlo. let (m, c) = fixture(Some(Affine::translate((200.0, 0.0)))); assert_eq!(hit_test_click(&m, &c, 250.0, 50.0), Some(1)); // donde se ve assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), None); // ya no donde estaba } #[test] fn rotacion_180_grados_alrededor_del_centro() { // Rotar 180° alrededor del centro (50,50) deja el rect en su sitio: // una esquina mapea a la opuesta, pero el cuadrado cubre lo mismo. let (m, c) = fixture(Some(Affine::rotate(std::f64::consts::PI))); assert_eq!(hit_test_click(&m, &c, 10.0, 10.0), Some(1)); assert_eq!(hit_test_click(&m, &c, 90.0, 90.0), Some(1)); assert_eq!(hit_test_click(&m, &c, 150.0, 150.0), None); } #[test] fn escala_cero_es_inalcanzable() { let (m, c) = fixture(Some(Affine::scale(0.0))); assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), None); } /// Como `fixture` pero seteando `transform_rel` (traslación en fracción /// del tamaño del nodo) en vez del afín fijo. fn fixture_rel( rel: (f64, f64), ) -> (crate::Mounted<()>, llimphi_layout::ComputedLayout) { let child = View::<()>::new(Style { size: Size { width: length(100.0), height: length(100.0) }, ..Default::default() }) .on_click(()) .transform_rel(rel); let root = View::<()>::new(Style { align_items: Some(AlignItems::FlexStart), justify_content: Some(JustifyContent::FlexStart), ..Default::default() }) .children(vec![child]); let mut layout = LayoutTree::new(); let mounted = mount(&mut layout, root); let computed = layout.compute(mounted.root, (400.0, 400.0)).expect("layout"); (mounted, computed) } /// Replica la estructura de dominium: un wrapper que clipea y lleva /// `on_click_at` + `draggable_at`, con un hijo "canvas" que pinta por /// uno de dos caminos. Devuelve `(mounted, computed)`. `gpu` elige el /// camino: `false` = `paint_with` (camino LEGACY), `true` = /// `gpu_paint_with().paint_over()` (camino Tier 1 nuevo). fn dominium_like(gpu: bool) -> (crate::Mounted<()>, llimphi_layout::ComputedLayout) { let mut canvas = View::<()>::new(Style { size: Size { width: percent(1.0), height: percent(1.0) }, ..Default::default() }); canvas = if gpu { canvas .gpu_paint_with(|_d, _q, _e, _t, _r, _vp| {}) .paint_over(|_s, _ts, _r| {}) } else { canvas.paint_with(|_s, _ts, _r| {}) }; let wrapper = View::<()>::new(Style { size: Size { width: percent(1.0), height: percent(1.0) }, ..Default::default() }) .clip(true) .on_click_at(|_lx, _ly, _rw, _rh| Some(())) .draggable_at(|_phase, _dx, _dy, _x0, _y0| Some(())) .children(vec![canvas]); let mut layout = LayoutTree::new(); let mounted = mount(&mut layout, wrapper); let computed = layout.compute(mounted.root, (400.0, 400.0)).expect("layout"); (mounted, computed) } #[test] fn canvas_gpu_only_es_clickeable_igual_que_legacy() { // BUG 2: el wrapper con `on_click_at`/`draggable_at` debe ser // hit-testeable en AMBOS caminos. El click cae sobre el wrapper // (índice 0, raíz) — un punto interior lo encuentra sin importar el // tipo de painter del hijo. let (m_leg, c_leg) = dominium_like(false); assert_eq!(hit_test_click(&m_leg, &c_leg, 200.0, 200.0), Some(0), "LEGACY (paint_with)"); let (m_gpu, c_gpu) = dominium_like(true); assert_eq!(hit_test_click(&m_gpu, &c_gpu, 200.0, 200.0), Some(0), "GPU (gpu_paint_with+paint_over)"); } #[test] fn nodo_gpu_paint_with_solo_es_hittable_por_si_mismo() { // Crítico para el motor voxel futuro: una vista 3D GPU-only que // lleve su PROPIO `on_click_at` debe ser clickeable, aunque NO // tenga `paint_with` ni contenido vello — sólo `gpu_painter`. let canvas = View::<()>::new(Style { size: Size { width: length(100.0), height: length(100.0) }, ..Default::default() }) .gpu_paint_with(|_d, _q, _e, _t, _r, _vp| {}) .on_click(()); let root = View::<()>::new(Style { align_items: Some(AlignItems::FlexStart), justify_content: Some(JustifyContent::FlexStart), ..Default::default() }) .children(vec![canvas]); let mut layout = LayoutTree::new(); let m = mount(&mut layout, root); let c = layout.compute(m.root, (400.0, 400.0)).expect("layout"); assert_eq!(hit_test_click(&m, &c, 50.0, 50.0), Some(1), "gpu-only con on_click debe ser hittable"); } #[test] fn transform_rel_resuelve_contra_el_tamano_del_nodo() { // El nodo es 100×100 en (0,0). `transform_rel(-0.5,-0.5)` = // `translate(-50%,-50%)` = correr -50px,-50px (la mitad de 100). El // área pintada pasa a (-50,-50)..(50,50): el centro del rect original // (50,50) queda ahora en (0,0). let (m, c) = fixture_rel((-0.5, -0.5)); // Donde se ve ahora (el viejo centro corrido a 0,0; y la esquina // inferior-derecha del original (100,100) ahora en (50,50)). assert_eq!(hit_test_click(&m, &c, 25.0, 25.0), Some(1)); // dentro del corrido assert_eq!(hit_test_click(&m, &c, 49.0, 49.0), Some(1)); // casi esquina nueva // Donde estaba antes pero ya NO (el rect se corrió fuera de ahí). assert_eq!(hit_test_click(&m, &c, 75.0, 75.0), None); // Sin transform_rel ese mismo punto SÍ caería dentro (control). let (m0, c0) = fixture_rel((0.0, 0.0)); // (0,0) = no-op assert_eq!(hit_test_click(&m0, &c0, 75.0, 75.0), Some(1)); } #[test] fn hit_test_cursor_directo_y_por_herencia() { use crate::{hit_test_cursor, Cursor}; // Padre 200×200 con cursor Text; dentro un hijo 100×100 (arriba-izq) // SIN cursor propio; y un segundo hijo 50×50 con cursor Pointer. let hijo_sin = View::<()>::new(Style { size: Size { width: length(100.0), height: length(100.0) }, ..Default::default() }); let hijo_con = View::<()>::new(Style { size: Size { width: length(50.0), height: length(50.0) }, ..Default::default() }) .cursor(Cursor::Pointer); let root = View::<()>::new(Style { size: Size { width: length(200.0), height: length(200.0) }, flex_direction: FlexDirection::Column, align_items: Some(AlignItems::FlexStart), justify_content: Some(JustifyContent::FlexStart), ..Default::default() }) .cursor(Cursor::Text) .children(vec![hijo_sin, hijo_con]); let mut layout = LayoutTree::new(); let m = mount(&mut layout, root); let c = layout.compute(m.root, (400.0, 400.0)).expect("layout"); // Sobre el hijo sin cursor (0..100, 0..100) → hereda Text del padre. assert_eq!(hit_test_cursor(&m, &c, 50.0, 50.0), Some(Cursor::Text)); // Sobre el hijo con cursor propio (apilado debajo: y 100..150) → Pointer. assert_eq!(hit_test_cursor(&m, &c, 25.0, 120.0), Some(Cursor::Pointer)); // Sobre el padre pero fuera de ambos hijos (x>100) → Text del padre. assert_eq!(hit_test_cursor(&m, &c, 150.0, 50.0), Some(Cursor::Text)); // Fuera del padre → None (la ventana usa su default). assert_eq!(hit_test_cursor(&m, &c, 350.0, 350.0), None); } #[test] fn tab_traversal_envuelve_en_los_extremos() { use crate::next_focus; let order = [10u64, 20, 30]; // Avanza. assert_eq!(next_focus(&order, Some(10), false), Some(20)); assert_eq!(next_focus(&order, Some(30), false), Some(10)); // wrap // Retrocede (Shift+Tab). assert_eq!(next_focus(&order, Some(20), true), Some(10)); assert_eq!(next_focus(&order, Some(10), true), Some(30)); // wrap // Sin foco previo: Tab → primero, Shift+Tab → último. assert_eq!(next_focus(&order, None, false), Some(10)); assert_eq!(next_focus(&order, None, true), Some(30)); // Foco obsoleto (id que ya no está) → arranca por el extremo. assert_eq!(next_focus(&order, Some(99), false), Some(10)); // Lista vacía. assert_eq!(next_focus(&[], Some(10), false), None); } #[test] fn hit_test_scale_directo_y_por_herencia() { use crate::{hit_test_scale, GesturePhase}; // Canvas zoomeable 200×200 (declara on_scale); dentro un widget 50×50 // (arriba-izq) SIN on_scale (no zoomea). El gesto sobre el widget debe // "caer" al canvas ancestro (herencia, como el cursor), y fuera de // todo debe dar None (el evento cae al scroll/on_wheel). let widget = View::<()>::new(Style { size: Size { width: length(50.0), height: length(50.0) }, ..Default::default() }); let canvas = View::<()>::new(Style { size: Size { width: length(200.0), height: length(200.0) }, align_items: Some(AlignItems::FlexStart), justify_content: Some(JustifyContent::FlexStart), ..Default::default() }) .on_scale(|_phase: GesturePhase, _f, _fx, _fy| None) .children(vec![widget]); let mut layout = LayoutTree::new(); let m = mount(&mut layout, canvas); let c = layout.compute(m.root, (400.0, 400.0)).expect("layout"); // Sobre el widget sin on_scale (0..50,0..50) → cae al canvas (idx 0). assert_eq!(hit_test_scale(&m, &c, 25.0, 25.0), Some(0)); // Sobre el canvas fuera del widget (x>50) → el canvas (idx 0). assert_eq!(hit_test_scale(&m, &c, 150.0, 25.0), Some(0)); // Fuera del canvas → None. assert_eq!(hit_test_scale(&m, &c, 350.0, 350.0), None); } #[test] fn hit_test_rotate_directo_y_por_herencia() { use crate::{hit_test_rotate, GesturePhase}; // Mismo patrón que escala: canvas rotable con un widget no-rotable // encima; el gesto cae al ancestro que declara on_rotate. let widget = View::<()>::new(Style { size: Size { width: length(50.0), height: length(50.0) }, ..Default::default() }); let canvas = View::<()>::new(Style { size: Size { width: length(200.0), height: length(200.0) }, align_items: Some(AlignItems::FlexStart), justify_content: Some(JustifyContent::FlexStart), ..Default::default() }) .on_rotate(|_phase: GesturePhase, _d, _fx, _fy| None) .children(vec![widget]); let mut layout = LayoutTree::new(); let m = mount(&mut layout, canvas); let c = layout.compute(m.root, (400.0, 400.0)).expect("layout"); assert_eq!(hit_test_rotate(&m, &c, 25.0, 25.0), Some(0)); assert_eq!(hit_test_rotate(&m, &c, 150.0, 25.0), Some(0)); assert_eq!(hit_test_rotate(&m, &c, 350.0, 350.0), None); } #[test] fn hit_test_selectable_solo_sobre_texto_seleccionable() { use crate::hit_test_selectable; // Un label seleccionable 100×30 arriba-izq dentro de un panel 200×200 // SIN selectable. Sólo el label matchea; el resto del panel da None. let label = View::<()>::new(Style { size: Size { width: length(100.0), height: length(30.0) }, ..Default::default() }) .text("hola", 14.0, vello::peniko::Color::from_rgba8(255, 255, 255, 255)) .selectable(7); let panel = View::<()>::new(Style { size: Size { width: length(200.0), height: length(200.0) }, align_items: Some(AlignItems::FlexStart), justify_content: Some(JustifyContent::FlexStart), ..Default::default() }) .children(vec![label]); let mut layout = LayoutTree::new(); let m = mount(&mut layout, panel); let c = layout.compute(m.root, (400.0, 400.0)).expect("layout"); // Sobre el label (0..100, 0..30) → el label (idx 1). assert_eq!(hit_test_selectable(&m, &c, 50.0, 15.0), Some(1)); // Sobre el panel fuera del label → None (el panel no es selectable). assert_eq!(hit_test_selectable(&m, &c, 150.0, 150.0), None); } #[test] fn hit_test_scroll_chain_devuelve_front_to_back() { use crate::hit_test_scroll_chain; // Padre scrollable 200×200 con un hijo scrollable 100×100 (arriba-izq). // Bajo el hijo: chain = [hijo, padre]. Bajo el padre pero fuera del // hijo: chain = [padre]. Fuera de ambos: chain vacío. let hijo = View::<()>::new(Style { size: Size { width: length(100.0), height: length(100.0) }, ..Default::default() }) .on_scroll(|_dx, _dy| None::<()>); let padre = View::<()>::new(Style { size: Size { width: length(200.0), height: length(200.0) }, align_items: Some(AlignItems::FlexStart), justify_content: Some(JustifyContent::FlexStart), ..Default::default() }) .on_scroll(|_dx, _dy| None::<()>) .children(vec![hijo]); let mut layout = LayoutTree::new(); let m = mount(&mut layout, padre); let c = layout.compute(m.root, (400.0, 400.0)).expect("layout"); // Sobre el hijo (0..100,0..100) → chain = [hijo=1, padre=0]. let ch = hit_test_scroll_chain(&m, &c, 50.0, 50.0); assert_eq!(ch, vec![1, 0]); // Sobre el padre fuera del hijo (x>100) → chain = [padre=0]. let ch = hit_test_scroll_chain(&m, &c, 150.0, 50.0); assert_eq!(ch, vec![0]); // Fuera del padre → chain vacío. let ch = hit_test_scroll_chain(&m, &c, 350.0, 350.0); assert!(ch.is_empty()); } #[test] fn hit_test_double_tap_y_long_press() { use crate::{hit_test_double_tap, hit_test_long_press}; // Un nodo 100×100 con doble-tap; otro 100×100 apilado debajo con // long-press. Cada hit-test sólo ve su propio gesto. let arriba = View::<()>::new(Style { size: Size { width: length(100.0), height: length(100.0) }, ..Default::default() }) .on_double_tap(()); let abajo = View::<()>::new(Style { size: Size { width: length(100.0), height: length(100.0) }, ..Default::default() }) .on_long_press(()); let root = View::<()>::new(Style { flex_direction: FlexDirection::Column, align_items: Some(AlignItems::FlexStart), justify_content: Some(JustifyContent::FlexStart), ..Default::default() }) .children(vec![arriba, abajo]); let mut layout = LayoutTree::new(); let m = mount(&mut layout, root); let c = layout.compute(m.root, (400.0, 400.0)).expect("layout"); // Nodo de arriba (y 0..100): doble-tap sí, long-press no. assert_eq!(hit_test_double_tap(&m, &c, 50.0, 50.0), Some(1)); assert_eq!(hit_test_long_press(&m, &c, 50.0, 50.0), None); // Nodo de abajo (y 100..200): long-press sí, doble-tap no. assert_eq!(hit_test_long_press(&m, &c, 50.0, 150.0), Some(2)); assert_eq!(hit_test_double_tap(&m, &c, 50.0, 150.0), None); // Fuera de ambos. assert_eq!(hit_test_double_tap(&m, &c, 300.0, 300.0), None); assert_eq!(hit_test_long_press(&m, &c, 300.0, 300.0), None); } }