tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
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}