tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ main llimphi-layout/tests/grid_maximise_overshoot.rs Rust · 235 líneas · 10.9 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

1//! Aislamiento del desborde de la tabla «CSS priority scheme» del artículo CSS
2//! de Wikipedia (salud_web +148px) y del grid del footer de wordpress.org: las
3//! pistas `auto` de un grid CRECEN MÁS ALLÁ de su max-content y se pasan de un
4//! contenedor DEFINIDO que ya estaba sobre-suscripto.
5//!
6//! Es el mismo bug upstream que motivó el fork revertido en 789595479 y que
7//! quedó anotado como «sin repro sintético» — este archivo ES el repro.
8//! Reportado como **DioxusLabs/taffy#1000** (con repro autónomo, sólo depende
9//! de `taffy = "0.12.2"`). Lo que faltaba para reproducirlo son DOS
10//! ingredientes juntos:
11//!   1. una **función de medida** (no hojas de tamaño fijo), y
12//!   2. max-contents **asimétricos**, de modo que una pista tope su límite y
13//!      sobre espacio para repartir entre las otras.
14//! Con pistas simétricas (`[400, 400]` en 500) taffy reparte bien, y con una
15//! asimetría que no llega a topar (`[100, 674]` en 500) también — por eso los
16//! intentos anteriores daban verde.
17//!
18//! Consecuencia para el wire, MEDIDA (no hipótesis): capar la tabla con
19//! `max-width: 100%` en `box_style` **empeora** el corpus. La caja queda en 928
20//! pero las pistas siguen en ~1074, así que el desborde pasa de 1 (la tabla) a
21//! 11 (cada celda). Por eso `tabla_shrink` NO lleva cap: hasta que taffy
22//! reparta bien, un desborde de la tabla entera es más fiel que once de celdas.
23//!
24//! CSS Grid §12.6-12.8: al distribuir espacio libre, una pista `auto` se
25//! congela al llegar a su growth limit (su max-content) y el sobrante se
26//! reparte entre las que aún pueden crecer. Aquí la pista 0 debería congelarse
27//! en 400 y la 1 quedarse con el resto (528): 400 + 528 = 928, exacto.
28//!
29//! ## CAUSA RAÍZ ENCONTRADA (2026-07-28) — y arreglada en 8 líneas
30//!
31//! Instrumentando `taffy 0.12.2` cayó: `distribute_space_up_to_limits`
32//! (`compute/grid/track_sizing.rs`) reparte en VARIAS iteraciones y acumula lo
33//! entregado en `track.item_incurred_increase`, pero las dos cuentas que
34//! deciden cuánto le cabe a cada pista miran `track_affected_property(track)`
35//! PELADO, sin sumarle lo ya acumulado: el margen restante
36//! (`min_increase_limit`) queda inflado, y el guarda que aplica el incremento
37//! deja pasar pistas que el filtro de elegibilidad ya había congelado.
38//!
39//! Traza del infobox (contenedor 352, bases [87, 140], límites [147, 197],
40//! libre 125): la 1ª iteración da +57 a cada una → [144, 197] y sobran 11; en
41//! la 2ª sólo la pista 0 es elegible, pero su margen se calcula 147−87 = 60
42//! (son 3) y el guarda compara 87+11 ≤ 147 (debería ser 144+11) → **las dos**
43//! se llevan +11. Salida [155, 208] = 363 con límites que sumaban 344.
44//!
45//! El parche vive en `../taffy-1000-distribute-space.patch` (sumar
46//! `item_incurred_increase` en las dos cuentas). **Verificado contra el repo
47//! de taffy en el tag v0.12.2: pasa la suite ENTERA — 4421 tests generados de
48//! web-platform, 105 unitarios, 43 hand-written, 5 de doc, cero fallas** — y
49//! arregla los dos casos de este archivo. Sigue vivo en master (0972b84).
50//! Aplicarlo acá pide vendorizar taffy + `[patch.crates-io]`: decisión de
51//! dependencia, no de layout, así que los tests siguen `#[ignore]`.
52
53use llimphi_layout::taffy::{self, prelude::*, AvailableSpace};
54
55/// Arma un grid de `maxc.len()` pistas `auto` con ancho DEFINIDO `ancho`, una
56/// celda de texto por pista (min-content 60, max-content `maxc[i]`), y
57/// devuelve el ancho resuelto de cada pista.
58fn pistas(ancho: f32, maxc: &[f32]) -> Vec<f32> {
59    const MIN_CONTENT: f32 = 60.0;
60    let mut t: TaffyTree<f32> = TaffyTree::new();
61    let celdas: Vec<NodeId> = maxc
62        .iter()
63        .map(|mx| t.new_leaf_with_context(Style::default(), *mx).unwrap())
64        .collect();
65    let g = t
66        .new_with_children(
67            Style {
68                display: Display::Grid,
69                grid_template_columns: vec![auto(); maxc.len()],
70                size: Size { width: length(ancho), height: auto() },
71                ..Default::default()
72            },
73            &celdas,
74        )
75        .unwrap();
76    t.compute_layout_with_measure(
77        g,
78        taffy::Size {
79            width: AvailableSpace::Definite(ancho),
80            height: AvailableSpace::MaxContent,
81        },
82        |known, avail, _n, ctx, _st| {
83            let Some(&mut mx) = ctx else { return taffy::Size::ZERO };
84            // Medidor honesto: nunca reporta más que su max-content ni menos
85            // que su min-content. (Verificado con instrumentación: taffy pide
86            // MinContent→60 y MaxContent→`mx`, y aun así asigna de más.)
87            let w = known.width.unwrap_or(match avail.width {
88                AvailableSpace::Definite(w) => w.clamp(MIN_CONTENT, mx),
89                AvailableSpace::MaxContent => mx,
90                AvailableSpace::MinContent => MIN_CONTENT,
91            });
92            taffy::Size { width: w, height: 19.0 }
93        },
94    )
95    .unwrap();
96    celdas
97        .iter()
98        .map(|c| t.layout(*c).unwrap().size.width)
99        .collect()
100}
101
102/// Los casos que taffy SÍ reparte bien — fijan lo que no hay que romper al
103/// arreglar el de arriba, y explican por qué el bug se escondió tanto.
104#[test]
105fn pistas_auto_que_taffy_reparte_bien() {
106    // Simétricas: las dos topan a la vez, sin sobrante que redistribuir.
107    let p = pistas(500.0, &[400.0, 400.0]);
108    assert!((p.iter().sum::<f32>() - 500.0).abs() < 1.0, "simétricas caben: {p:?}");
109
110    // Asimétricas donde la angosta topa: el sobrante va entera a la otra.
111    let p = pistas(500.0, &[100.0, 674.0]);
112    assert!((p[0] - 100.0).abs() < 1.0, "la pista angosta se congela en su max-content: {p:?}");
113    assert!((p.iter().sum::<f32>() - 500.0).abs() < 1.0, "y el total cabe: {p:?}");
114
115    // El contenedor da de sobra: cada pista llega a su max-content (y el
116    // remanente estira, que es lo que pide Grid §12.8).
117    let p = pistas(1200.0, &[400.0, 674.0]);
118    assert!(p[0] >= 400.0 - 1.0, "con lugar, la pista llega a su max-content: {p:?}");
119}
120
121/// EL BUG, hoy ARREGLADO por el fork (ver el encabezado): con taffy 0.12.2 de
122/// crates.io esto falla; con el `[patch.crates-io]` del Cargo.toml raíz pasa.
123/// Es la regresión que avisa si alguien retira el patch antes de tiempo.
124#[test]
125fn pistas_auto_no_deben_pasarse_del_contenedor_definido() {
126    // El caso real de Wikipedia, a escala: contenedor 928, dos columnas cuyos
127    // max-content suman 1074. Reparto correcto: [400, 528] = 928 exacto.
128    let p = pistas(928.0, &[400.0, 674.0]);
129    assert!(
130        p[0] <= 400.0 + 1.0,
131        "la pista 0 no puede superar su max-content (400), midió {:.0}",
132        p[0]
133    );
134    assert!(
135        p.iter().sum::<f32>() <= 928.0 + 1.0,
136        "las pistas no pueden pasarse del contenedor definido (928), suman {:.0}: {p:?}",
137        p.iter().sum::<f32>()
138    );
139}
140
141/// Arma un grid de 2 pistas `auto` con ancho DEFINIDO `ancho` y VARIAS filas:
142/// `col[i] = (min_content, max_content)` de la celda de la fila `i`. Devuelve
143/// el ancho resuelto de las dos pistas.
144fn pistas_multifila(ancho: f32, col1: &[(f32, f32)], col2: &[(f32, f32)]) -> (f32, f32) {
145    let mut t: TaffyTree<(f32, f32)> = TaffyTree::new();
146    let mut celdas = Vec::new();
147    for i in 0..col1.len() {
148        for c in [col1[i], col2[i]] {
149            celdas.push(t.new_leaf_with_context(Style::default(), c).unwrap());
150        }
151    }
152    let g = t
153        .new_with_children(
154            Style {
155                display: Display::Grid,
156                grid_template_columns: vec![auto(), auto()],
157                size: Size { width: length(ancho), height: auto() },
158                justify_content: Some(JustifyContent::START),
159                ..Default::default()
160            },
161            &celdas,
162        )
163        .unwrap();
164    t.compute_layout_with_measure(
165        g,
166        taffy::Size { width: AvailableSpace::Definite(ancho), height: AvailableSpace::MaxContent },
167        |known, avail, _n, ctx, _st| {
168            let Some(&mut (mn, mx)) = ctx else { return taffy::Size::ZERO };
169            let w = known.width.unwrap_or(match avail.width {
170                AvailableSpace::Definite(w) => w.clamp(mn.min(mx), mx),
171                AvailableSpace::MaxContent => mx,
172                AvailableSpace::MinContent => mn,
173            });
174            taffy::Size { width: w, height: 20.0 }
175        },
176    )
177    .unwrap();
178    (
179        t.layout(celdas[0]).unwrap().size.width,
180        t.layout(celdas[1]).unwrap().size.width,
181    )
182}
183
184/// SEGUNDA CARA del mismo bug upstream, con un disparador más simple: DOS
185/// ítems en cada pista alcanzan, sin ninguna celda que abarque columnas y con
186/// los max-content de las pistas CABIENDO de sobra en el contenedor.
187///
188/// Es el residuo del infobox «Rust Foundation» del artículo de Rust en
189/// Wikipedia (`salud_web`: 9 celdas a +14px, medido 2026-07-28). Los números
190/// son los reales, tomados con `dump_layout --release <url>` + `PURIY_MINC`:
191/// la tabla declara 352px, la columna de etiquetas tiene max-content 147
192/// («Executive Director») y la de datos 197 («February 8, 2021; 5 years ago»).
193/// 147 + 197 = 344 ≤ 352: las dos pistas ENTRAN. taffy igual devuelve 155 y
194/// 208 = 363, once píxeles fuera de una tabla que sobraba lugar.
195///
196/// Lo que delata que es un bug de reparto y no una medida nuestra: el
197/// resultado depende de CUÁNTAS filas hay, con los mismos límites de pista.
198/// Con 5 filas taffy da [147, 197] —exacto— y con 2, 3 ó 4 da [155, 208].
199/// Un algoritmo correcto no puede empeorar al agregarle filas angostas.
200///
201/// Ninguna función de pista lo esquiva: `auto`, `minmax(auto, max-content)`,
202/// `fit-content(100%)` y `minmax(min-content, auto)` dan los cuatro 155+208 —
203/// por eso la tabla aplanada sigue con `auto` (`flatten_table`) y el arreglo
204/// tenía que ser adentro de taffy.
205#[test]
206fn dos_items_por_pista_no_deben_pasarse_del_contenedor() {
207    let col1 = [(87.0_f32, 87.0_f32), (84.0, 147.0)];
208    let col2 = [(140.0_f32, 197.0_f32), (60.0, 180.0)];
209    let (a, b) = pistas_multifila(352.0, &col1, &col2);
210    assert!(
211        a <= 147.0 + 1.0,
212        "la pista de etiquetas no puede superar su max-content (147), midió {a:.0}"
213    );
214    assert!(
215        a + b <= 352.0 + 1.0,
216        "las dos pistas no pueden pasarse del contenedor definido (352), suman {:.0} ({a:.0} + {b:.0})",
217        a + b
218    );
219}
220
221/// El contra-ejemplo que prueba que los límites de pista NO son el problema:
222/// las MISMAS dos columnas, con tres filas angostas de más, se reparten bien.
223/// Fija el comportamiento correcto para que el día que taffy arregle lo de
224/// arriba se note si en el camino rompió esto.
225#[test]
226fn con_cinco_filas_las_mismas_pistas_se_reparten_bien() {
227    let col1 = [(87.0_f32, 87.0_f32), (84.0, 147.0), (72.0, 72.0), (80.0, 80.0), (90.0, 90.0)];
228    let col2 = [(140.0_f32, 197.0_f32), (60.0, 180.0), (100.0, 190.0), (90.0, 150.0), (80.0, 120.0)];
229    let (a, b) = pistas_multifila(352.0, &col1, &col2);
230    assert!(
231        a + b <= 352.0 + 1.0,
232        "con cinco filas sí entran: {a:.0} + {b:.0} = {:.0}",
233        a + b
234    );
235}