use super::*; // ───────────────────────────────────────────────────────────────────────── // RepaintBoundary — caché de rasterización de subárboles (Bloque 23 de // PLAN-NUCLEO-RETENIDO.md). Ver `View::repaint_boundary`. // // Diseño *fail-safe*: `paint_range` queda INTACTO (cero regresión para todo el // que no use boundaries). Toda la lógica vive en `paint_cached`, un wrapper que // pinta los tramos no-boundary con el mismo `paint_range` de siempre e intercala // el append (cacheado o fresco) de cada boundary. Ante cualquier duda sobre si // un subárbol es cacheable, se pinta fresco → nunca produce un frame incorrecto. // ───────────────────────────────────────────────────────────────────────── /// Caché de rasterización de los [`View::repaint_boundary`]. Vive en el runtime /// entre frames. Cada entrada guarda la sub-escena vello del subárbol en coords /// **locales** al origen del boundary + un hash de su contenido; mientras el /// hash no cambie se reusa (`scene.append` trasladado al origen actual), así /// sobrevive a un scroll/translación del subárbol sin re-pintar. #[derive(Default)] pub struct RepaintCache { entries: HashMap, seen: hashbrown::HashSet, /// Aciertos de caché acumulados (telemetría / tests). pub hits: u64, /// Fallos de caché acumulados (telemetría / tests). pub misses: u64, } pub(crate) struct CachedBoundary { scene: Lienzo, hash: u64, } impl RepaintCache { pub fn new() -> Self { Self::default() } /// Entradas vivas. pub fn len(&self) -> usize { self.entries.len() } pub fn is_empty(&self) -> bool { self.entries.is_empty() } /// Vacía el caché (p. ej. al cambiar el viewport o el tema). pub fn clear(&mut self) { self.entries.clear(); self.seen.clear(); } fn begin_frame(&mut self) { self.seen.clear(); } /// Descarta las entradas cuyo boundary no apareció este frame. fn gc(&mut self) { let seen = &self.seen; self.entries.retain(|k, _| seen.contains(k)); } } /// `true` si algún nodo del árbol declara `repaint_boundary` (el runtime lo usa /// para decidir si vale la pena `paint_cached` o alcanza con `paint`). pub fn has_repaint_boundary(mounted: &Mounted) -> bool { mounted.nodes.iter().any(|n| n.repaint_boundary.is_some()) } /// ¿El nodo abre una capa vello / transform / scrollport que un `scene.append` /// trasladado NO podría reproducir si fuera ancestro de un boundary? Si es /// ancestro de un boundary, ese boundary NO está en posición "limpia". pub(crate) fn opens_layer(n: &MountedNode) -> bool { n.clip || n.alpha.is_some() || n.blend.is_some() || n.transform.is_some() || n.transform_rel.is_some() || n.mask_image.is_some() || n.sticky.is_some() || n.over_layer || n.clip_inset.is_some() || n.clip_ellipse.is_some() || n.clip_polygon.is_some() || n.clip_path_svg.is_some() || n.clip_ref_inset.is_some() } /// Recolecta los `repaint_boundary` en posición **limpia** (sin capa ancestral /// abierta), en pre-orden. Un boundary bajo un transform/clip/alpha NO se /// recolecta → cae a un tramo normal y se pinta fresco. pub(crate) fn collect_clean_boundaries(mounted: &Mounted) -> Vec<(usize, usize, u64)> { let mut out = Vec::new(); let mut layer_ends: Vec = Vec::new(); for idx in 0..mounted.nodes.len() { while layer_ends.last().is_some_and(|&e| idx >= e) { layer_ends.pop(); } let node = &mounted.nodes[idx]; if let Some(key) = node.repaint_boundary { if layer_ends.is_empty() { out.push((idx, node.subtree_end, key)); } } // El propio boundary puede abrir su capa (clip/alpha propios): se registra // DESPUÉS de recolectarlo (su capa es interna a su sub-escena). if opens_layer(node) { layer_ends.push(node.subtree_end); } } out } /// ¿El subárbol `[idx, end)` es cacheable? Paint-puro (sin closures de pintado /// ni contenido dinámico) y sin el nodo hovereado/drop adentro. Conservador: /// ante la duda `false` (se pinta fresco). /// /// **Gradiente e imagen entraron en 2026-08-05.** Quedaban fuera «porque no se /// hashean de forma barata», y eso dejaba al caché sin el caso que lo motivaba: /// un sidebar real tiene un degradé de fondo o un ícono, y **un solo nodo con /// imagen en el subárbol lo descalificaba entero**. La imagen se hashea por el /// **id del `Blob`** —un contador global de peniko, O(1)— y no por sus píxeles; /// dos blobs distintos con los mismos bytes dan ids distintos y eso es un miss /// de más, nunca un hit indebido. El gradiente usa el `bit_hash` **de peniko** /// para los stops, que es la definición upstream de «mismo gradiente». /// /// La **máscara** sigue afuera, y a propósito: su `MaskPlacement` es un nido de /// enums (size/pos/repeat/mode/clip-inset/origin-inset) cuya superficie de hash /// es más grande que lo que compra —una `mask-image` en chrome estático es rara— /// y cada campo que se olvidara sería un hit indebido, o sea un cuadro mal. pub(crate) fn boundary_cacheable( mounted: &Mounted, idx: usize, end: usize, hover_idx: Option, drop_hover_idx: Option, ) -> bool { if hover_idx.is_some_and(|h| h >= idx && h < end) { return false; } if drop_hover_idx.is_some_and(|d| d >= idx && d < end) { return false; } mounted.nodes[idx..end].iter().all(|n| { n.painter.is_none() && n.gpu_painter.is_none() && n.over_painter.is_none() && n.mask_image.is_none() && n.anim.is_none() && n.ripple.is_none() && n.hero.is_none() }) } pub(crate) fn hash_color(c: &Color, h: &mut impl Hasher) { for comp in c.components { comp.to_bits().hash(h); } } /// Hash de un gradiente. Los stops van por el `bit_hash` **de peniko**: es la /// definición upstream de «mismo gradiente», así que no hay una segunda idea /// nuestra que pueda quedar corta cuando peniko sume un campo. pub(crate) fn hash_gradient(g: &crate::peniko::Gradient, h: &mut impl Hasher) { use crate::peniko::color::cache_key::BitHash; use crate::peniko::GradientKind; core::mem::discriminant(&g.kind).hash(h); let mut punto = |x: f64, y: f64| { x.to_bits().hash(h); y.to_bits().hash(h); }; match &g.kind { GradientKind::Linear(l) => { punto(l.start.x, l.start.y); punto(l.end.x, l.end.y); } GradientKind::Radial(r) => { punto(r.start_center.x, r.start_center.y); punto(r.end_center.x, r.end_center.y); punto(r.start_radius as f64, r.end_radius as f64); } GradientKind::Sweep(sw) => { punto(sw.center.x, sw.center.y); punto(sw.start_angle as f64, sw.end_angle as f64); } } (g.extend as u8).hash(h); core::mem::discriminant(&g.interpolation_cs).hash(h); core::mem::discriminant(&g.hue_direction).hash(h); core::mem::discriminant(&g.interpolation_alpha_space).hash(h); g.stops.bit_hash(h); } /// Hash de una imagen: **el id del `Blob`**, no sus píxeles. /// /// El id es un contador global de peniko que se asigna al construir el blob, así /// que es O(1) y sobrevive a los clones —que es como una app pasa la misma /// imagen cuadro a cuadro—. El error posible es en la dirección segura: dos /// blobs distintos con los mismos bytes dan ids distintos y eso es un **miss de /// más**, un repintado gratis, nunca un hit indebido. pub(crate) fn hash_image(img: &Image, h: &mut impl Hasher) { img.image.data.id().hash(h); (img.image.format as u8).hash(h); (img.image.alpha_type as u8).hash(h); img.image.width.hash(h); img.image.height.hash(h); // El sampler cambia lo pintado sin cambiar un byte del blob. (img.sampler.x_extend as u8).hash(h); (img.sampler.y_extend as u8).hash(h); (img.sampler.quality as u8).hash(h); img.sampler.alpha.to_bits().hash(h); } /// Hash del contenido pintable del subárbol `[idx, end)`, con los rects /// **relativos al origen** — así una traslación pura del boundary (scroll) deja /// el hash igual y es cache-hit. Cubre las variantes que `boundary_cacheable` /// deja pasar (fill/text/borde/sombra/radio/alpha/clip/transform propios). pub(crate) fn boundary_hash( mounted: &Mounted, computed: &ComputedLayout, idx: usize, end: usize, origin: (f32, f32), ) -> u64 { let mut h = hasher_memo(); (end - idx).hash(&mut h); for i in idx..end { let n = &mounted.nodes[i]; match computed.get(n.id) { Some(r) => { (r.x - origin.0).to_bits().hash(&mut h); (r.y - origin.1).to_bits().hash(&mut h); r.w.to_bits().hash(&mut h); r.h.to_bits().hash(&mut h); } None => u32::MAX.hash(&mut h), } n.fill.is_some().hash(&mut h); if let Some(c) = &n.fill { hash_color(c, &mut h); } n.radius.to_bits().hash(&mut h); if let Some(cr) = &n.corner_radii { for v in [cr.top_left, cr.top_right, cr.bottom_right, cr.bottom_left] { v.to_bits().hash(&mut h); } } if let Some(b) = &n.border { b.width.to_bits().hash(&mut h); hash_color(&b.color, &mut h); } if let Some(s) = &n.shadow { hash_color(&s.color, &mut h); s.blur.to_bits().hash(&mut h); s.spread.to_bits().hash(&mut h); s.dx.to_bits().hash(&mut h); s.dy.to_bits().hash(&mut h); } if let Some(a) = n.alpha { a.to_bits().hash(&mut h); } n.clip.hash(&mut h); if let Some(t) = n.transform { for c in t.as_coeffs() { c.to_bits().hash(&mut h); } } if let Some((x, y)) = n.transform_rel { x.to_bits().hash(&mut h); y.to_bits().hash(&mut h); } if let Some(g) = &n.fill_gradient { hash_gradient(g, &mut h); n.fill_gradient_superficie.hash(&mut h); } if let Some(img) = &n.image { hash_image(img, &mut h); // El encaje decide la transformación con la que se pinta: dos // `Contain`/`Cover` sobre la misma imagen dan cuadros distintos. n.image_fit.map(|f| f as u8).hash(&mut h); } if let Some(t) = &n.text { t.content.hash(&mut h); t.size_px.to_bits().hash(&mut h); hash_color(&t.color, &mut h); t.weight.to_bits().hash(&mut h); t.line_height.to_bits().hash(&mut h); t.italic.hash(&mut h); (t.alignment as u8).hash(&mut h); } } h.finish() } /// Como [`paint`], pero **cachea la rasterización** de los subárboles marcados /// con [`View::repaint_boundary`] que estén en posición limpia y sean /// paint-puros. El resto del árbol (y los boundaries no cacheables) se pinta /// idéntico a `paint`. Si el árbol no tiene boundaries, delega en `paint` — /// coste ~0. pub fn paint_cached( scene: &mut Lienzo, mounted: &Mounted, computed: &ComputedLayout, typesetter: &mut llimphi_text::Typesetter, hover_idx: Option, drop_hover_idx: Option, cache: &mut RepaintCache, ) { cache.begin_frame(); let boundaries = collect_clean_boundaries(mounted); if boundaries.is_empty() { cache.gc(); paint(scene, mounted, computed, typesetter, hover_idx, drop_hover_idx); return; } let n = mounted.nodes.len(); let mut cursor = 0usize; for (bidx, bend, key) in boundaries { // Subsumido por un boundary externo ya procesado. if bidx < cursor { continue; } // Tramo previo al boundary: pintura normal. if cursor < bidx { paint_range( scene, mounted, computed, typesetter, hover_idx, drop_hover_idx, cursor, bidx, Affine::IDENTITY, ); } let origin = computed .get(mounted.nodes[bidx].id) .map(|r| (r.x, r.y)) .unwrap_or((0.0, 0.0)); if boundary_cacheable(mounted, bidx, bend, hover_idx, drop_hover_idx) { let hash = boundary_hash(mounted, computed, bidx, bend, origin); let hit = cache .entries .get(&key) .is_some_and(|c| c.hash == hash); if hit { cache.hits += 1; } else { cache.misses += 1; // Rasterizá el subárbol a una sub-escena en coords LOCALES. let mut sub = crate::Lienzo::new(); paint_range( &mut sub, mounted, computed, typesetter, hover_idx, drop_hover_idx, bidx, bend, Affine::translate((-origin.0 as f64, -origin.1 as f64)), ); cache.entries.insert(key, CachedBoundary { scene: sub, hash }); } cache.seen.insert(key); // Append trasladado al origen actual (sobrevive al scroll). if let Some(c) = cache.entries.get(&key) { scene.append( &c.scene, Some(Affine::translate((origin.0 as f64, origin.1 as f64))), ); } } else { // No cacheable → fresco inline (idéntico a `paint`). paint_range( scene, mounted, computed, typesetter, hover_idx, drop_hover_idx, bidx, bend, Affine::IDENTITY, ); } cursor = bend; } // Cola tras el último boundary. if cursor < n { paint_range( scene, mounted, computed, typesetter, hover_idx, drop_hover_idx, cursor, n, Affine::IDENTITY, ); } cache.gc(); }