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) {}