tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ main llimphi-compositor/src/render/medicion.rs Rust · 357 siq'ikuna · 16.3 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

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