tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1use super::*;
2
3/// Mide una hoja de texto para taffy: shaping + line-break con parley contra
4/// el ancho disponible, devolviendo el bounding box. Si el ancho ya está
5/// resuelto (`known.width`) se usa ese; si no, se deriva del `available`
6/// (Definite → ese ancho; MaxContent → sin límite = una línea; MinContent →
7/// 0 = envuelve a la palabra más ancha). El `line_height` sale del propio
8/// `TextMeasure`, el mismo que usa `paint`, así medida y pintado coinciden.
9/// Mide una hoja de contenido REEMPLAZADO (una imagen) como lo hace un browser
10/// con un `<img>` sin `width`/`height` de autor y `max-width: 100%`:
11///
12/// - **max-content** → su ancho natural. Es lo que hace que un `float` o un
13/// `inline-block` que la contiene se dimensione a la imagen en vez de
14/// colapsar.
15/// - **min-content** → 0. Es lo que deja encoger al contenedor que la tiene
16/// adentro; sin esto, un `max-width` del contenedor se planta en el ancho
17/// natural de la imagen y las columnas de al lado se van de la pantalla.
18/// - **ancho definido** → el menor entre lo disponible y el natural: nunca se
19/// agranda más allá de su tamaño real, y nunca se pasa del contenedor.
20///
21/// El alto sale del ancho por proporción, así la imagen no se deforma. Los dos
22/// ejes respetan `known` cuando el layout ya decidió uno.
23///
24/// Que esto sea una FUNCIÓN y no un `Style` es el punto: un `Style` sólo puede
25/// dar el mismo número a min-content y a max-content, y ahí se rompe una de las
26/// dos puntas (ver [`IntrinsicSize`]).
27pub fn measure_intrinsic_node(
28 nat: &IntrinsicSize,
29 known: llimphi_layout::taffy::Size<Option<f32>>,
30 available: llimphi_layout::taffy::Size<llimphi_layout::taffy::AvailableSpace>,
31) -> llimphi_layout::taffy::Size<f32> {
32 use llimphi_layout::taffy::AvailableSpace;
33 let natural_w = nat.width.max(0.0);
34 let natural_h = nat.height.max(0.0);
35 let width = known.width.unwrap_or(match available.width {
36 AvailableSpace::Definite(w) => w.min(natural_w).max(0.0),
37 AvailableSpace::MaxContent => natural_w,
38 AvailableSpace::MinContent => 0.0,
39 });
40 // Proporción: si el natural es degenerado (0 en algún eje) no hay ratio que
41 // preservar y el alto cae al natural.
42 let height = known.height.unwrap_or(if natural_w > 0.0 && natural_h > 0.0 {
43 width * (natural_h / natural_w)
44 } else {
45 natural_h
46 });
47 llimphi_layout::taffy::Size { width, height }
48}
49
50pub fn measure_text_node(
51 ts: &mut llimphi_text::Typesetter,
52 tm: &TextMeasure,
53 known: llimphi_layout::taffy::Size<Option<f32>>,
54 available: llimphi_layout::taffy::Size<llimphi_layout::taffy::AvailableSpace>,
55) -> llimphi_layout::taffy::Size<f32> {
56 use llimphi_layout::taffy::AvailableSpace;
57 // `white-space: nowrap`/`pre`: el texto se mide en una sola línea (ancho
58 // completo) ignorando el `available`/`known` — equivale a MaxContent.
59 //
60 // `known.width` llega REDONDEADO por taffy (round_layout, píxeles enteros)
61 // y puede quedar hasta 1px por debajo del ancho natural fraccional del
62 // texto (natural 95.288 → caja 95). Medir a ese ancho exacto PARTÍA la
63 // línea en dos («197 points» de HN a 2 renglones) — el mismo sub-píxel
64 // que el paint ya esquiva sumando `TEXT_WRAP_ROUNDING_SLACK` al
65 // re-shapear. El mismo slack acá deja medida y pintado CONSISTENTES
66 // (ambos envuelven al mismo ancho w+1). El `Definite` del available NO
67 // lleva slack: viene fraccional, sin redondeo que compensar.
68 let max_width: Option<f32> = if tm.no_wrap {
69 None
70 } else {
71 known
72 .width
73 .map(|w| w + TEXT_WRAP_ROUNDING_SLACK)
74 .or(match available.width {
75 AvailableSpace::Definite(w) => Some(w),
76 AvailableSpace::MaxContent => None,
77 AvailableSpace::MinContent => Some(0.0),
78 })
79 };
80 // Ancho intrínseco EXACTO (fraccional) en la rama max-content. Hubo aquí
81 // un `ceil()` defensivo contra el redondeo de taffy (round_layout floorea
82 // una caja shrink-to-fit por debajo del natural y el texto se partía) —
83 // pero el ceil INFLABA cada medida hasta 1px, y en una tabla eso se
84 // ACUMULA por columna: la de releases de kernel.org sumaba min-contents
85 // de 803 en un contenedor de 800 y taffy 0.12 (que respeta la suma
86 // exacta, a diferencia de 0.9) colapsaba TODAS las columnas a su mínimo
87 // con celdas de 2-3 renglones. La defensa contra el sub-píxel vive ahora
88 // en el SLACK sobre `known.width` (arriba), consistente con el paint.
89 //
90 // `full_width` (no `width`): parley EXCLUYE el espacio final del `width()`
91 // (la ws de fin de línea "cuelga" per CSS), pero al re-shapear en el paint a
92 // ese ancho SÍ cuenta el avance del espacio → lo empuja a una 2ª línea
93 // fantasma que la medida nunca reservó (clip). P.ej. `"archived mailing
94 // list "` mide width=148.932 pero full=153.432; con caja 149 el paint parte
95 // a 2 líneas, con caja 154 (=ceil(full)) queda en 1. Sin espacio final
96 // `full_width == width`, así que es no-op para el caso común.
97 let ceil_intrinsic = max_width.is_none();
98 // RichText: si hay spans, mediar con `layout_spans` para que taffy
99 // reserve el alto considerando overrides de tamaño por rango (un span
100 // con `size_px = 24` dentro de un párrafo de 14 px agranda esa línea).
101 // El clamp `max_lines`/`ellipsis` no se aplica al camino spans en v1
102 // (RichText típico no clampea — los headings y links viven el bloque
103 // completo); el caller que necesite clamp con spans puede recortar el
104 // texto antes de pasarlo.
105 if let Some(spans) = tm.spans.as_ref() {
106 if !spans.is_empty() {
107 let layout = ts.layout_spans(
108 &tm.content,
109 tm.size_px,
110 llimphi_text::peniko::Color::from_rgba8(0, 0, 0, 255),
111 tm.weight,
112 tm.line_height,
113 tm.italic,
114 tm.font_family.as_deref(),
115 tm.underline,
116 tm.strikethrough,
117 spans,
118 max_width,
119 tm.alignment,
120 );
121 let w = if ceil_intrinsic { layout.full_width() } else { layout.width() };
122 return llimphi_layout::taffy::Size {
123 width: w,
124 height: layout.height(),
125 };
126 }
127 }
128 // Camino directo a `layout_clamped` (no `TextBlock`) para transportar
129 // `weight` (bold mide más ancho) y `max_lines` (taffy reserva el alto de
130 // N líneas, no el del texto completo). Sin clamp, equivale a `layout`.
131 let layout = ts.layout_clamped(
132 &tm.content,
133 tm.size_px,
134 max_width,
135 tm.alignment,
136 tm.line_height,
137 tm.italic,
138 tm.font_family.as_deref(),
139 tm.weight,
140 tm.max_lines,
141 tm.ellipsis,
142 tm.underline,
143 tm.strikethrough,
144 tm.letter_spacing,
145 tm.word_spacing,
146 tm.overflow_wrap,
147 &tm.font_features,
148 &tm.font_variations,
149 tm.font_width,
150 );
151 let m = llimphi_text::measurement(&layout);
152 let width = if ceil_intrinsic { layout.full_width() } else { m.width };
153 llimphi_layout::taffy::Size { width, height: m.height }
154}
155
156// El `Typesetter` compartido para medir vive en un `thread_local!`, que
157// necesita `std` — y, de fondo, hilos. wawa no tiene ninguna de las dos cosas:
158// su reactor es cooperativo de un solo núcleo. Quien mida allá presta su propio
159// `Typesetter`, igual que `llimphi_text::text_width_with`.
160#[cfg(feature = "std")]
161thread_local! {
162 /// `Typesetter` reusado para [`measured_height`] (crear uno es caro —
163 /// enumera fuentes del sistema). Independiente del del runtime; mismas
164 /// fuentes ⇒ mismas métricas.
165 static MEASURE_TS: core::cell::RefCell<Option<llimphi_text::Typesetter>> =
166 const { core::cell::RefCell::new(None) };
167}
168
169/// Mide el **alto** que tendría `view` layouteado a un ancho `available_width`
170/// (con alto libre). Monta + computa en un `LayoutTree` descartable, usando un
171/// `Typesetter` thread-local para el shaping del texto. Pensado para construir
172/// los `offsets` de una lista perezosa de **alto variado** cuyas alturas
173/// dependen del contenido (reflow de texto): el caller lo llama por ítem CUANDO
174/// cambia el contenido —no por frame— y cachea el resultado, luego pasa los
175/// `offsets` a `lazy_list_varied`. Devuelve `0.0` si el layout falla.
176#[cfg(feature = "std")]
177pub fn measured_height<Msg: Clone>(view: View<Msg>, available_width: f32) -> f32 {
178 let mut lt = LayoutTree::new();
179 let m = mount(&mut lt, view);
180 MEASURE_TS.with(|cell| {
181 let mut slot = cell.borrow_mut();
182 let ts = slot.get_or_insert_with(llimphi_text::Typesetter::new);
183 let tmap = &m.text_measures;
184 lt.compute_with_measure(m.root, (available_width.max(0.0), 1.0e6), |nid, k, a| {
185 match tmap.get(&nid) {
186 Some(tm) => measure_text_node(ts, tm, k, a),
187 None => llimphi_layout::taffy::Size { width: 0.0, height: 0.0 },
188 }
189 })
190 .ok()
191 .and_then(|c| c.get(m.root).map(|r| r.h))
192 .unwrap_or(0.0)
193 })
194}
195
196/// ¿Está prendida la sonda de capas? (`LLIMPHI_DEBUG_CAPAS=1`).
197/// **El techo de capas apiladas**, regulable desde el host. Ver la nota en
198/// `rango.rs`: vello devuelve la escena entera transparente pasado cierto
199/// apilado, el umbral depende de la GPU, y medirlo exige barrer valores en
200/// metal — con una constante, cada valor cuesta una compilación.
201/// **4, y el número está medido, no elegido.** El 2026-09-15, con el menú
202/// contextual abierto sobre el cajón de pata (Iris Xe / Vulkan): el árbol de
203/// 251 nodos llegaba a profundidad **5** y su escena salía 100 % vacía —el
204/// cajón congelado y el menú invisible— mientras los árboles de 151 y 187
205/// nodos, a profundidad 3 y 4, pintaban bien. Con el techo en 4 la misma
206/// escena pasó de 1 color a 1.952 y el menú apareció. O sea que el umbral de
207/// esta GPU está **entre 4 y 5**, no en ~6 como estimó la nota de julio: aquella
208/// vez alcanzó con capear los clips porque el árbol era menos profundo.
209static MAX_CAPAS: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(4);
210
211/// Cuántas capas se permiten apilar antes de dejar de abrir nuevas.
212pub(crate) fn max_capas() -> usize {
213 MAX_CAPAS.load(core::sync::atomic::Ordering::Relaxed)
214}
215
216/// Fija el techo (lo llama el host; `0` se ignora para no dejar una escena sin
217/// ninguna capa por un archivo mal escrito).
218pub fn set_max_capas(n: usize) {
219 if n > 0 {
220 MAX_CAPAS.store(n, core::sync::atomic::Ordering::Relaxed);
221 }
222}
223
224#[cfg(feature = "std")]
225pub(crate) fn sonda_capas_activa() -> bool {
226 static D: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
227 if *D.get_or_init(|| std::env::var("LLIMPHI_DEBUG_CAPAS").is_ok_and(|v| v != "0")) {
228 return true;
229 }
230 // **Y por centinela en disco**, que es la única forma de encenderla en el
231 // estado que falla (Regla 8.ter). A las apps de escritorio las relanza el
232 // compositor: nadie les puede inyectar una env, así que una sonda que sólo
233 // se activa al arrancar no existe para ellas — y el defecto que hay que
234 // cazar (la escena que colapsa a transparente) aparece **mientras** la app
235 // corre. Se re-lee con vencimiento de un segundo: `touch` la prende y `rm`
236 // la apaga, sin reiniciar y sin perder el estado que se está mirando.
237 static CENT: std::sync::Mutex<Option<(bool, std::time::Instant)>> =
238 std::sync::Mutex::new(None);
239 let Ok(mut cache) = CENT.lock() else { return false };
240 if let Some((v, t)) = *cache {
241 if t.elapsed() < std::time::Duration::from_secs(1) {
242 return v;
243 }
244 }
245 let v = std::path::Path::new("/tmp/llimphi-capas").exists();
246 *cache = Some((v, std::time::Instant::now()));
247 v
248}
249
250/// Informa la profundidad de capas de la escena, a lo sumo una vez por segundo
251/// (pintamos decenas de veces por segundo; una línea por fotograma sería ruido)
252/// **y además siempre que aparezca una marca nueva** — que es el evento que
253/// interesa cazar.
254///
255/// **Para qué**: vello colapsa la escena ENTERA a transparente, sin error ni
256/// log, cuando el stack de blend/clip se pasa (≈6 capas medidas en Iris Xe /
257/// Vulkan). El síntoma es una ventana que "desaparece" pero sigue viva en el
258/// alt-tab. `MAX_CLIP_DEPTH` acota los clips, pero alpha/blend/mask y la capa
259/// de cada imagen **no** contaban, así que la profundidad real de una escena era
260/// un número que nadie tenía. Sin él no se puede distinguir "estoy contra el
261/// techo" de "el problema es otro".
262#[cfg(feature = "std")]
263pub(crate) fn reportar_capas(prof_max: usize, imagenes: usize, con_imagen: usize, nodos: usize) {
264 use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
265 use std::sync::Mutex;
266 static PICO: AtomicUsize = AtomicUsize::new(0);
267 static ULTIMO_MS: AtomicUsize = AtomicUsize::new(0);
268 // **Se acumula por ÁRBOL, no se muestrea uno.** Antes esto imprimía la
269 // pasada que caía justo al vencer el segundo y tiraba las demás — y por
270 // cuadro hay varias (árbol principal, capa `over`, overlay de menús). Así la
271 // sonda contestaba «0 imágenes» mientras otra pasada del MISMO cuadro
272 // dibujaba 24, y con ese número se concluía lo contrario de lo que pasaba
273 // (2026-08-11, cazando las miniaturas negras de media). Cada árbol se
274 // identifica por su cantidad de nodos, que es lo único estable y gratis que
275 // hay a mano.
276 struct Forma {
277 nodos: usize,
278 pasadas: usize,
279 dibujadas: usize,
280 pedidas: usize,
281 prof: usize,
282 }
283 static FORMAS: Mutex<Vec<Forma>> = Mutex::new(Vec::new());
284 let mut formas = match FORMAS.lock() {
285 Ok(f) => f,
286 Err(e) => e.into_inner(),
287 };
288 match formas.iter_mut().find(|f| f.nodos == nodos) {
289 Some(f) => {
290 f.pasadas += 1;
291 f.dibujadas += imagenes;
292 f.pedidas += con_imagen;
293 f.prof = f.prof.max(prof_max);
294 }
295 None => formas.push(Forma {
296 nodos,
297 pasadas: 1,
298 dibujadas: imagenes,
299 pedidas: con_imagen,
300 prof: prof_max,
301 }),
302 }
303 let previo = PICO.fetch_max(prof_max, Relaxed);
304 // Reloj monótono barato en ms desde el arranque del proceso.
305 static T0: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
306 let ahora = T0.get_or_init(std::time::Instant::now).elapsed().as_millis() as usize;
307 let ultimo = ULTIMO_MS.load(Relaxed);
308 if ahora.saturating_sub(ultimo) < 1000 {
309 return;
310 }
311 ULTIMO_MS.store(ahora, Relaxed);
312 formas.sort_by_key(|f| core::cmp::Reverse(f.pasadas));
313 let detalle: alloc::vec::Vec<alloc::string::String> = formas
314 .iter()
315 .map(|f| {
316 alloc::format!(
317 "{}n×{} img {}/{} prof {}",
318 f.nodos, f.pasadas, f.dibujadas, f.pedidas, f.prof
319 )
320 })
321 .collect();
322 let linea = alloc::format!(
323 "llimphi-capas · en 1s por árbol: {} · pico histórico {}",
324 detalle.join(" | "),
325 prof_max.max(previo),
326 );
327 eprintln!("{linea}");
328 // **Y a un archivo.** El `eprintln` solo no alcanza: a las apps del
329 // escritorio las relanza el compositor y su stderr es una tubería que no
330 // drena nadie, así que la sonda existía y no se podía leer — la otra mitad
331 // de la Regla 8.ter, y la que faltaba el 2026-09-15 justo cuando hacía
332 // falta el número. Mismo centinela que la enciende.
333 {
334 use std::io::Write as _;
335 if let Ok(mut f) = std::fs::OpenOptions::new()
336 .create(true)
337 .append(true)
338 .open("/tmp/llimphi-capas.txt")
339 {
340 let _ = writeln!(f, "{linea}");
341 }
342 }
343 formas.clear();
344}
345
346// La sonda es de diagnóstico de escritorio: vive de variables de entorno, del
347// reloj del sistema y de stderr. Bare-metal no tiene ninguna de las tres, y el
348// bug que caza (vello colapsando la escena por profundidad de blend) es de un
349// backend que allá no corre. Se apaga entera y sin costo, en vez de fingir.
350#[cfg(not(feature = "std"))]
351pub(crate) fn sonda_capas_activa() -> bool {
352 false
353}
354
355#[cfg(not(feature = "std"))]
356pub(crate) fn reportar_capas(_prof_max: usize, _imagenes: usize, _con_imagen: usize, _nodos: usize) {}
357