use super::*; /// 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. /// Mide una hoja de contenido REEMPLAZADO (una imagen) como lo hace un browser /// con un `` sin `width`/`height` de autor y `max-width: 100%`: /// /// - **max-content** → su ancho natural. Es lo que hace que un `float` o un /// `inline-block` que la contiene se dimensione a la imagen en vez de /// colapsar. /// - **min-content** → 0. Es lo que deja encoger al contenedor que la tiene /// adentro; sin esto, un `max-width` del contenedor se planta en el ancho /// natural de la imagen y las columnas de al lado se van de la pantalla. /// - **ancho definido** → el menor entre lo disponible y el natural: nunca se /// agranda más allá de su tamaño real, y nunca se pasa del contenedor. /// /// El alto sale del ancho por proporción, así la imagen no se deforma. Los dos /// ejes respetan `known` cuando el layout ya decidió uno. /// /// Que esto sea una FUNCIÓN y no un `Style` es el punto: un `Style` sólo puede /// dar el mismo número a min-content y a max-content, y ahí se rompe una de las /// dos puntas (ver [`IntrinsicSize`]). pub fn measure_intrinsic_node( nat: &IntrinsicSize, known: llimphi_layout::taffy::Size>, available: llimphi_layout::taffy::Size, ) -> llimphi_layout::taffy::Size { use llimphi_layout::taffy::AvailableSpace; let natural_w = nat.width.max(0.0); let natural_h = nat.height.max(0.0); let width = known.width.unwrap_or(match available.width { AvailableSpace::Definite(w) => w.min(natural_w).max(0.0), AvailableSpace::MaxContent => natural_w, AvailableSpace::MinContent => 0.0, }); // Proporción: si el natural es degenerado (0 en algún eje) no hay ratio que // preservar y el alto cae al natural. let height = known.height.unwrap_or(if natural_w > 0.0 && natural_h > 0.0 { width * (natural_h / natural_w) } else { natural_h }); llimphi_layout::taffy::Size { width, height } } 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. // // `known.width` llega REDONDEADO por taffy (round_layout, píxeles enteros) // y puede quedar hasta 1px por debajo del ancho natural fraccional del // texto (natural 95.288 → caja 95). Medir a ese ancho exacto PARTÍA la // línea en dos («197 points» de HN a 2 renglones) — el mismo sub-píxel // que el paint ya esquiva sumando `TEXT_WRAP_ROUNDING_SLACK` al // re-shapear. El mismo slack acá deja medida y pintado CONSISTENTES // (ambos envuelven al mismo ancho w+1). El `Definite` del available NO // lleva slack: viene fraccional, sin redondeo que compensar. let max_width: Option = if tm.no_wrap { None } else { known .width .map(|w| w + TEXT_WRAP_ROUNDING_SLACK) .or(match available.width { AvailableSpace::Definite(w) => Some(w), AvailableSpace::MaxContent => None, AvailableSpace::MinContent => Some(0.0), }) }; // Ancho intrínseco EXACTO (fraccional) en la rama max-content. Hubo aquí // un `ceil()` defensivo contra el redondeo de taffy (round_layout floorea // una caja shrink-to-fit por debajo del natural y el texto se partía) — // pero el ceil INFLABA cada medida hasta 1px, y en una tabla eso se // ACUMULA por columna: la de releases de kernel.org sumaba min-contents // de 803 en un contenedor de 800 y taffy 0.12 (que respeta la suma // exacta, a diferencia de 0.9) colapsaba TODAS las columnas a su mínimo // con celdas de 2-3 renglones. La defensa contra el sub-píxel vive ahora // en el SLACK sobre `known.width` (arriba), consistente con el paint. // // `full_width` (no `width`): parley EXCLUYE el espacio final del `width()` // (la ws de fin de línea "cuelga" per CSS), pero al re-shapear en el paint a // ese ancho SÍ cuenta el avance del espacio → lo empuja a una 2ª línea // fantasma que la medida nunca reservó (clip). P.ej. `"archived mailing // list "` mide width=148.932 pero full=153.432; con caja 149 el paint parte // a 2 líneas, con caja 154 (=ceil(full)) queda en 1. Sin espacio final // `full_width == width`, así que es no-op para el caso común. let ceil_intrinsic = max_width.is_none(); // 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, llimphi_text::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, ); let w = if ceil_intrinsic { layout.full_width() } else { layout.width() }; return llimphi_layout::taffy::Size { width: w, 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, &tm.font_features, &tm.font_variations, tm.font_width, ); let m = llimphi_text::measurement(&layout); let width = if ceil_intrinsic { layout.full_width() } else { m.width }; llimphi_layout::taffy::Size { width, height: m.height } } // El `Typesetter` compartido para medir vive en un `thread_local!`, que // necesita `std` — y, de fondo, hilos. wawa no tiene ninguna de las dos cosas: // su reactor es cooperativo de un solo núcleo. Quien mida allá presta su propio // `Typesetter`, igual que `llimphi_text::text_width_with`. #[cfg(feature = "std")] thread_local! { /// `Typesetter` reusado para [`measured_height`] (crear uno es caro — /// enumera fuentes del sistema). Independiente del del runtime; mismas /// fuentes ⇒ mismas métricas. static MEASURE_TS: core::cell::RefCell> = const { core::cell::RefCell::new(None) }; } /// Mide el **alto** que tendría `view` layouteado a un ancho `available_width` /// (con alto libre). Monta + computa en un `LayoutTree` descartable, usando un /// `Typesetter` thread-local para el shaping del texto. Pensado para construir /// los `offsets` de una lista perezosa de **alto variado** cuyas alturas /// dependen del contenido (reflow de texto): el caller lo llama por ítem CUANDO /// cambia el contenido —no por frame— y cachea el resultado, luego pasa los /// `offsets` a `lazy_list_varied`. Devuelve `0.0` si el layout falla. #[cfg(feature = "std")] pub fn measured_height(view: View, available_width: f32) -> f32 { let mut lt = LayoutTree::new(); let m = mount(&mut lt, view); MEASURE_TS.with(|cell| { let mut slot = cell.borrow_mut(); let ts = slot.get_or_insert_with(llimphi_text::Typesetter::new); let tmap = &m.text_measures; lt.compute_with_measure(m.root, (available_width.max(0.0), 1.0e6), |nid, k, a| { match tmap.get(&nid) { Some(tm) => measure_text_node(ts, tm, k, a), None => llimphi_layout::taffy::Size { width: 0.0, height: 0.0 }, } }) .ok() .and_then(|c| c.get(m.root).map(|r| r.h)) .unwrap_or(0.0) }) } /// ¿Está prendida la sonda de capas? (`LLIMPHI_DEBUG_CAPAS=1`). /// **El techo de capas apiladas**, regulable desde el host. Ver la nota en /// `rango.rs`: vello devuelve la escena entera transparente pasado cierto /// apilado, el umbral depende de la GPU, y medirlo exige barrer valores en /// metal — con una constante, cada valor cuesta una compilación. /// **4, y el número está medido, no elegido.** El 2026-09-15, con el menú /// contextual abierto sobre el cajón de pata (Iris Xe / Vulkan): el árbol de /// 251 nodos llegaba a profundidad **5** y su escena salía 100 % vacía —el /// cajón congelado y el menú invisible— mientras los árboles de 151 y 187 /// nodos, a profundidad 3 y 4, pintaban bien. Con el techo en 4 la misma /// escena pasó de 1 color a 1.952 y el menú apareció. O sea que el umbral de /// esta GPU está **entre 4 y 5**, no en ~6 como estimó la nota de julio: aquella /// vez alcanzó con capear los clips porque el árbol era menos profundo. static MAX_CAPAS: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(4); /// Cuántas capas se permiten apilar antes de dejar de abrir nuevas. pub(crate) fn max_capas() -> usize { MAX_CAPAS.load(core::sync::atomic::Ordering::Relaxed) } /// Fija el techo (lo llama el host; `0` se ignora para no dejar una escena sin /// ninguna capa por un archivo mal escrito). pub fn set_max_capas(n: usize) { if n > 0 { MAX_CAPAS.store(n, core::sync::atomic::Ordering::Relaxed); } } #[cfg(feature = "std")] pub(crate) fn sonda_capas_activa() -> bool { static D: std::sync::OnceLock = std::sync::OnceLock::new(); if *D.get_or_init(|| std::env::var("LLIMPHI_DEBUG_CAPAS").is_ok_and(|v| v != "0")) { return true; } // **Y por centinela en disco**, que es la única forma de encenderla en el // estado que falla (Regla 8.ter). A las apps de escritorio las relanza el // compositor: nadie les puede inyectar una env, así que una sonda que sólo // se activa al arrancar no existe para ellas — y el defecto que hay que // cazar (la escena que colapsa a transparente) aparece **mientras** la app // corre. Se re-lee con vencimiento de un segundo: `touch` la prende y `rm` // la apaga, sin reiniciar y sin perder el estado que se está mirando. static CENT: std::sync::Mutex> = std::sync::Mutex::new(None); let Ok(mut cache) = CENT.lock() else { return false }; if let Some((v, t)) = *cache { if t.elapsed() < std::time::Duration::from_secs(1) { return v; } } let v = std::path::Path::new("/tmp/llimphi-capas").exists(); *cache = Some((v, std::time::Instant::now())); v } /// Informa la profundidad de capas de la escena, a lo sumo una vez por segundo /// (pintamos decenas de veces por segundo; una línea por fotograma sería ruido) /// **y además siempre que aparezca una marca nueva** — que es el evento que /// interesa cazar. /// /// **Para qué**: vello colapsa la escena ENTERA a transparente, sin error ni /// log, cuando el stack de blend/clip se pasa (≈6 capas medidas en Iris Xe / /// Vulkan). El síntoma es una ventana que "desaparece" pero sigue viva en el /// alt-tab. `MAX_CLIP_DEPTH` acota los clips, pero alpha/blend/mask y la capa /// de cada imagen **no** contaban, así que la profundidad real de una escena era /// un número que nadie tenía. Sin él no se puede distinguir "estoy contra el /// techo" de "el problema es otro". #[cfg(feature = "std")] pub(crate) fn reportar_capas(prof_max: usize, imagenes: usize, con_imagen: usize, nodos: usize) { use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; use std::sync::Mutex; static PICO: AtomicUsize = AtomicUsize::new(0); static ULTIMO_MS: AtomicUsize = AtomicUsize::new(0); // **Se acumula por ÁRBOL, no se muestrea uno.** Antes esto imprimía la // pasada que caía justo al vencer el segundo y tiraba las demás — y por // cuadro hay varias (árbol principal, capa `over`, overlay de menús). Así la // sonda contestaba «0 imágenes» mientras otra pasada del MISMO cuadro // dibujaba 24, y con ese número se concluía lo contrario de lo que pasaba // (2026-08-11, cazando las miniaturas negras de media). Cada árbol se // identifica por su cantidad de nodos, que es lo único estable y gratis que // hay a mano. struct Forma { nodos: usize, pasadas: usize, dibujadas: usize, pedidas: usize, prof: usize, } static FORMAS: Mutex> = Mutex::new(Vec::new()); let mut formas = match FORMAS.lock() { Ok(f) => f, Err(e) => e.into_inner(), }; match formas.iter_mut().find(|f| f.nodos == nodos) { Some(f) => { f.pasadas += 1; f.dibujadas += imagenes; f.pedidas += con_imagen; f.prof = f.prof.max(prof_max); } None => formas.push(Forma { nodos, pasadas: 1, dibujadas: imagenes, pedidas: con_imagen, prof: prof_max, }), } let previo = PICO.fetch_max(prof_max, Relaxed); // Reloj monótono barato en ms desde el arranque del proceso. static T0: std::sync::OnceLock = std::sync::OnceLock::new(); let ahora = T0.get_or_init(std::time::Instant::now).elapsed().as_millis() as usize; let ultimo = ULTIMO_MS.load(Relaxed); if ahora.saturating_sub(ultimo) < 1000 { return; } ULTIMO_MS.store(ahora, Relaxed); formas.sort_by_key(|f| core::cmp::Reverse(f.pasadas)); let detalle: alloc::vec::Vec = formas .iter() .map(|f| { alloc::format!( "{}n×{} img {}/{} prof {}", f.nodos, f.pasadas, f.dibujadas, f.pedidas, f.prof ) }) .collect(); let linea = alloc::format!( "llimphi-capas · en 1s por árbol: {} · pico histórico {}", detalle.join(" | "), prof_max.max(previo), ); eprintln!("{linea}"); // **Y a un archivo.** El `eprintln` solo no alcanza: a las apps del // escritorio las relanza el compositor y su stderr es una tubería que no // drena nadie, así que la sonda existía y no se podía leer — la otra mitad // de la Regla 8.ter, y la que faltaba el 2026-09-15 justo cuando hacía // falta el número. Mismo centinela que la enciende. { use std::io::Write as _; if let Ok(mut f) = std::fs::OpenOptions::new() .create(true) .append(true) .open("/tmp/llimphi-capas.txt") { let _ = writeln!(f, "{linea}"); } } formas.clear(); } // La sonda es de diagnóstico de escritorio: vive de variables de entorno, del // reloj del sistema y de stderr. Bare-metal no tiene ninguna de las tres, y el // bug que caza (vello colapsando la escena por profundidad de blend) es de un // backend que allá no corre. Se apaga entera y sin costo, en vez de fingir. #[cfg(not(feature = "std"))] pub(crate) fn sonda_capas_activa() -> bool { false } #[cfg(not(feature = "std"))] pub(crate) fn reportar_capas(_prof_max: usize, _imagenes: usize, _con_imagen: usize, _nodos: usize) {}