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//! **LayoutBuilder** — el 4º seam de PARIDAD-FLUTTER: construir un subárbol
2//! sensible al **tamaño del slot** del nodo (no de la ventana — para eso
3//! alcanza `on_resize` + el Model). Flutter `LayoutBuilder`.
4//!
5//! El modelo de Llimphi corre `view → mount → compute → paint`: el `View` se
6//! arma ANTES de conocer el layout, así que "construir distinto según el espacio
7//! disponible" exige diferir. La solución, sin tocar `mount`/`paint`, es una
8//! **resolución en dos pasadas** orquestada por el runtime:
9//!
10//! 1. Montar el árbol tal cual ([`crate::View::layout_builder`] queda como
11//! **hoja** — no tiene `children` estáticos) y computar el layout. Ahora cada
12//! builder tiene su rect resuelto por su `Style`/contexto flex.
13//! 2. [`collect_builder_constraints`] lee esos rects (en pre-orden), se pide un
14//! `view()` fresco y [`expand_layout_builders`] invoca cada closure con sus
15//! [`crate::Constraints`] para producir el subárbol real. Ese árbol expandido
16//! se monta y pinta normalmente.
17//!
18//! [`has_layout_builder`] hace que todo esto sea **coste cero** cuando ningún
19//! nodo usa el builder (el caso de la abrumadora mayoría de frames): es un
20//! simple walk que corta el camino de dos pasadas.
21//!
22//! **Correspondencia de orden.** `collect_builder_constraints` recorre
23//! `Mounted::nodes` (pre-orden, padre antes que hijos — el orden en que `mount`
24//! los pushea) filtrando `is_layout_builder`; `expand_layout_builders` recorre
25//! el `View` fresco en el MISMO pre-orden asignando un índice por builder. Como
26//! ambos árboles salen del mismo `view(model)` determinista, el i-ésimo builder
27//! de uno corresponde al i-ésimo del otro — por eso alcanza con un `Vec`
28//! ordenado, sin keys.
29//!
30//! **Límite v1**: sin anidamiento. Un builder cuyo subárbol producido contiene
31//! otro `layout_builder` no resuelve el interno (no existía en la pasada 1):
32//! queda como hoja. El anidamiento requeriría iterar la resolución; se difiere.
33
34// Preludio de `alloc`: bajo `no_std` estos tipos ya no vienen solos.
35#[allow(unused_imports)]
36use alloc::{boxed::Box, string::{String, ToString}, vec::Vec};
37
38use crate::{Constraints, ComputedLayout, Mounted, View};
39
40/// `true` si `view` o algún descendiente declara un [`crate::View::layout_builder`].
41/// El runtime lo usa para decidir si vale la pena la resolución en dos pasadas;
42/// cuando es `false` (lo normal) el camino diferido se evita por completo.
43pub fn has_layout_builder<Msg>(view: &View<Msg>) -> bool {
44 view.rare.as_ref().is_some_and(|r| r.layout_builder.is_some())
45 || view.children.iter().any(has_layout_builder)
46}
47
48/// Lee las [`Constraints`] (tamaño del slot) de cada nodo `is_layout_builder`
49/// del árbol montado, en pre-orden. El runtime las pasa a
50/// [`expand_layout_builders`]. Un nodo sin rect computado (fuera del layout)
51/// cae a `0×0`.
52pub fn collect_builder_constraints<Msg>(
53 mounted: &Mounted<Msg>,
54 computed: &ComputedLayout,
55) -> Vec<Constraints> {
56 mounted
57 .nodes
58 .iter()
59 .filter(|n| n.is_layout_builder)
60 .map(|n| {
61 computed
62 .get(n.id)
63 .map(|r| Constraints { max_width: r.w, max_height: r.h })
64 .unwrap_or(Constraints { max_width: 0.0, max_height: 0.0 })
65 })
66 .collect()
67}
68
69/// Expande los `layout_builder` de `view` (pre-orden) usando `cons` — una
70/// [`Constraints`] por builder, en el orden que produjo
71/// [`collect_builder_constraints`]. Cada builder se reemplaza por un nodo
72/// contenedor (su mismo `Style`) cuyo único hijo es lo que devolvió la closure
73/// invocada con sus constraints. Builders sin constraint correspondiente (más
74/// builders que `cons`, p. ej. uno anidado recién producido) caen a `0×0` y se
75/// resuelven igual, pero su tamaño será nulo (límite v1: sin anidamiento).
76/// Consume `view`.
77pub fn expand_layout_builders<Msg>(view: View<Msg>, cons: &[Constraints]) -> View<Msg> {
78 let mut idx = 0;
79 expand_rec(view, cons, &mut idx)
80}
81
82fn expand_rec<Msg>(mut view: View<Msg>, cons: &[Constraints], idx: &mut usize) -> View<Msg> {
83 if let Some(builder) = view.rare.as_mut().and_then(|r| r.layout_builder.take()) {
84 let c = cons
85 .get(*idx)
86 .copied()
87 .unwrap_or(Constraints { max_width: 0.0, max_height: 0.0 });
88 *idx += 1;
89 // El builder posee los hijos: descartamos cualquier `children` estático
90 // y ponemos lo que produjo la closure. NO recursamos en el resultado
91 // (v1 sin anidamiento — un builder interno queda como hoja al montarse).
92 let child = builder(c);
93 view.children = vec![child];
94 view
95 } else {
96 let children = core::mem::take(&mut view.children);
97 view.children = children
98 .into_iter()
99 .map(|c| expand_rec(c, cons, idx))
100 .collect();
101 view
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108 use crate::{mount, Constraints};
109 use llimphi_layout::taffy::prelude::*;
110 use llimphi_layout::{LayoutTree, Style};
111
112 /// Árbol sin builders → `has_layout_builder` falso y expand es no-op.
113 #[test]
114 fn sin_builder_es_noop() {
115 let v = View::<()>::new(Style::default())
116 .children(vec![View::<()>::new(Style::default())]);
117 assert!(!has_layout_builder(&v));
118 let v = expand_layout_builders(v, &[]);
119 assert_eq!(v.children.len(), 1);
120 }
121
122 #[test]
123 fn detecta_builder_anidado_en_hijos() {
124 let v = View::<()>::new(Style::default()).children(vec![
125 View::<()>::new(Style::default()),
126 View::<()>::new(Style::default()).layout_builder(|_c| View::<()>::new(Style::default())),
127 ]);
128 assert!(has_layout_builder(&v));
129 }
130
131 /// El builder recibe las constraints y produce su subárbol; el nodo deja de
132 /// ser builder y queda como contenedor con el hijo producido.
133 #[test]
134 fn expand_invoca_closure_con_constraints() {
135 // Dos columnas a percent(0.5) del root 400px → cada slot = 200px. La de
136 // la izquierda es un builder que mete 1 hijo si es angosta (<300) o 2 si
137 // es ancha. A 200px mete 1.
138 let build_col = |c: Constraints| {
139 let n = if c.max_width < 300.0 { 1 } else { 2 };
140 View::<()>::new(Style::default())
141 .children((0..n).map(|_| View::<()>::new(Style::default())).collect())
142 };
143 let root = View::<()>::new(Style {
144 size: Size { width: length(400.0), height: length(100.0) },
145 flex_direction: FlexDirection::Row,
146 ..Default::default()
147 })
148 .children(vec![
149 View::<()>::new(Style {
150 size: Size { width: percent(0.5), height: percent(1.0) },
151 ..Default::default()
152 })
153 .layout_builder(build_col),
154 View::<()>::new(Style {
155 size: Size { width: percent(0.5), height: percent(1.0) },
156 ..Default::default()
157 }),
158 ]);
159
160 // Pasada 1: montar (builder como hoja) y computar.
161 let mut l1 = LayoutTree::new();
162 let m1 = mount(&mut l1, root);
163 let c1 = l1.compute(m1.root, (400.0, 100.0)).expect("layout");
164 let cons = collect_builder_constraints(&m1, &c1);
165 assert_eq!(cons.len(), 1, "un solo builder");
166 assert!((cons[0].max_width - 200.0).abs() < 1.0, "slot 200px: {:?}", cons[0]);
167
168 // Pasada 2: árbol fresco (mismo Style) + expand.
169 let root2 = View::<()>::new(Style {
170 size: Size { width: length(400.0), height: length(100.0) },
171 flex_direction: FlexDirection::Row,
172 ..Default::default()
173 })
174 .children(vec![
175 View::<()>::new(Style {
176 size: Size { width: percent(0.5), height: percent(1.0) },
177 ..Default::default()
178 })
179 .layout_builder(build_col),
180 View::<()>::new(Style {
181 size: Size { width: percent(0.5), height: percent(1.0) },
182 ..Default::default()
183 }),
184 ]);
185 let expanded = expand_layout_builders(root2, &cons);
186 // El nodo builder (hijo 0 del root) ya no es builder y tiene 1 hijo
187 // producido (slot 200 < 300 → angosto → 1 columna).
188 let col_izq = &expanded.children[0];
189 assert!(raro!(col_izq, layout_builder).is_none(), "ya expandido");
190 assert_eq!(col_izq.children.len(), 1, "200px angosto → 1 hijo");
191 }
192
193 /// Con un slot ancho el mismo builder produce 2 hijos — verifica que la
194 /// rama de decisión depende de las constraints reales.
195 #[test]
196 fn slot_ancho_produce_mas_hijos() {
197 let build_col = |c: Constraints| {
198 let n = if c.max_width < 300.0 { 1 } else { 2 };
199 View::<()>::new(Style::default())
200 .children((0..n).map(|_| View::<()>::new(Style::default())).collect())
201 };
202 // Constraint inyectada directo: 500px → ancho. El builder devuelve UN
203 // contenedor (hijo único del nodo) con 2 columnas adentro.
204 let v = View::<()>::new(Style::default()).layout_builder(build_col);
205 let expanded = expand_layout_builders(v, &[Constraints { max_width: 500.0, max_height: 100.0 }]);
206 assert_eq!(expanded.children.len(), 1, "el builder produce 1 contenedor");
207 assert_eq!(expanded.children[0].children.len(), 2, "ancho → 2 columnas");
208 }
209
210 /// Pre-orden: dos builders hermanos reciben sus constraints en orden.
211 #[test]
212 fn dos_builders_reciben_constraints_en_preorden() {
213 let mk = |w: f32| {
214 move |_c: Constraints| {
215 View::<()>::new(Style {
216 size: Size { width: length(w), height: length(10.0) },
217 ..Default::default()
218 })
219 }
220 };
221 let root = View::<()>::new(Style::default()).children(vec![
222 View::<()>::new(Style::default()).layout_builder(mk(1.0)),
223 View::<()>::new(Style::default()).layout_builder(mk(2.0)),
224 ]);
225 let cons = vec![
226 Constraints { max_width: 111.0, max_height: 0.0 },
227 Constraints { max_width: 222.0, max_height: 0.0 },
228 ];
229 let expanded = expand_layout_builders(root, &cons);
230 // Ambos expandidos, en orden (verificamos vía el ancho del hijo producido
231 // que NO depende de la constraint aquí — sólo confirmamos que se invocaron
232 // los dos y que ninguno quedó como builder).
233 assert!(raro!(expanded.children[0], layout_builder).is_none());
234 assert!(raro!(expanded.children[1], layout_builder).is_none());
235 assert_eq!(expanded.children[0].children.len(), 1);
236 assert_eq!(expanded.children[1].children.len(), 1);
237 }
238}