Skip to main content

frus_widgets/
layoutbuilder.rs

1//! [`LayoutBuilder`]: builds its content **from the box actually available** — the
2//! most powerful responsive primitive, since a component adapts wherever it is placed,
3//! in a narrow sidebar or full screen, and not merely to the window.
4//!
5//! As with a virtualised list item, the content is **built on the fly** every frame, so
6//! it has **no retained state**: hover and clicks work, but persistent keyboard focus
7//! and deferred overlays do not.
8//!
9//! The sizing contract: **as big as what it built**, on any axis its style leaves open.
10//! That is the reference's rule — `size = constraints.constrain(child.size)` — and it is
11//! what lets a `LayoutBuilder` be dropped into a column without having to guess a height
12//! for it first.
13//!
14//! It works because the widget is a **measured** leaf rather than a plain one: the
15//! layout engine calls back during the computation, with the space actually available,
16//! at the same point the reference runs its layout callback. The closure builds the
17//! subtree, lays it out in a tree of its own, and returns its size.
18//!
19//! Three consequences worth knowing:
20//!
21//! - **A pinned axis stays pinned.** `width`, `height` and `flex` still win, because a
22//!   dimension the style already gives is one the engine never asks about.
23//! - **The closure runs more than once a frame** — once for the measurement, once for
24//!   the paint, and again for each intrinsic question the engine asks. It must therefore
25//!   be cheap and free of side effects, which it must be anyway: it is called with no
26//!   retained state.
27//! - **A subtree holding one is not layout-cached.** The relayout cache reuses geometry
28//!   when a fingerprint of the styles and the structure has not moved, and a closure has
29//!   no fingerprint: two frames that look identical to it can still want different
30//!   boxes. So the root is recomputed every frame rather than risk a stale one.
31
32use frus_core::{Rect, Scene, Size};
33use frus_layout::{Dimension, Style};
34
35use crate::interaction::Status;
36use crate::theme::Theme;
37use crate::widget::Widget;
38
39/// Builds its child from the available size (`size → widget`).
40pub struct LayoutBuilder<Msg = crate::callback::Callback> {
41    width: Dimension,
42    height: Dimension,
43    flex_grow: f32,
44    /// Whether it takes all the room on offer, as an [`Aligned`](crate::Aligned) does, rather
45    /// than the size of what it built (milestone 586).
46    fills: bool,
47    build: Box<dyn Fn(Size) -> Box<dyn Widget<Msg>>>,
48}
49
50impl<Msg> LayoutBuilder<Msg> {
51    /// Builds from a `size → widget` factory. The size received is the **real box**
52    /// allocated to this `LayoutBuilder`, in logical px.
53    pub fn new<W: Widget<Msg> + 'static>(build: impl Fn(Size) -> W + 'static) -> Self {
54        Self {
55            width: Dimension::Auto,
56            height: Dimension::Auto,
57            flex_grow: 0.0,
58            fills: false,
59            build: Box::new(move |size| Box::new(build(size)) as Box<dyn Widget<Msg>>),
60        }
61    }
62
63    /// Sets the width, in logical pixels.
64    pub fn width(mut self, width: f32) -> Self {
65        self.width = Dimension::Length(width);
66        self
67    }
68
69    /// Sets the height, in logical pixels.
70    pub fn height(mut self, height: f32) -> Self {
71        self.height = Dimension::Length(height);
72        self
73    }
74
75    /// Takes all the room on offer, so that the size handed to the closure is the room and
76    /// not what the closure built (milestone 586).
77    pub(crate) fn filling(mut self) -> Self {
78        self.fills = true;
79        self
80    }
81
82    /// Flex growth factor along the parent's main axis.
83    pub fn flex(mut self, grow: f32) -> Self {
84        self.flex_grow = grow;
85        self
86    }
87}
88
89impl<Msg> Widget<Msg> for LayoutBuilder<Msg> {
90    fn style(&self) -> Style {
91        Style {
92            width: self.width,
93            height: self.height,
94            flex_grow: self.flex_grow,
95            ..Default::default()
96        }
97    }
98
99    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
100        &[]
101    }
102
103    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
104
105    fn on_click(&self) -> Option<Msg> {
106        None
107    }
108
109    fn layout_builder(&self) -> Option<&dyn Fn(Size) -> Box<dyn Widget<Msg>>> {
110        Some(&*self.build)
111    }
112
113    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
114        if self.fills {
115            crate::widget::FillAxes::BOTH
116        } else {
117            crate::widget::FillAxes::NONE
118        }
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::{build_ui, Container, Runtime, SizeClass};
126    use frus_core::{Color, Primitive};
127
128    #[test]
129    fn receives_its_real_box() {
130        use std::cell::Cell;
131        use std::rc::Rc;
132
133        let seen = Rc::new(Cell::new(Size::new(0.0, 0.0)));
134        let probe = seen.clone();
135        let lb = LayoutBuilder::<()>::new(move |size| {
136            probe.set(size);
137            Container::<()>::new().color(Color::rgb(1.0, 0.0, 0.0))
138        })
139        .width(320.0)
140        .height(48.0);
141
142        let _ui = build_ui(
143            &lb,
144            Size::new(800.0, 600.0),
145            &Runtime::default(),
146            &Theme::default(),
147        );
148        // The factory receives the LayoutBuilder's box, not the window's.
149        assert_eq!(seen.get(), Size::new(320.0, 48.0));
150    }
151
152    /// The reference's rule: with no height of its own, the box is as tall as what the
153    /// closure built. Before milestone 355 this laid out 0 px tall and the widget's own
154    /// documentation told you to go and find a number for it.
155    #[test]
156    fn it_is_as_big_as_what_it_built() {
157        let lb = LayoutBuilder::<()>::new(|_| {
158            Container::<()>::new()
159                .height(60.0)
160                .color(Color::rgb(1.0, 0.0, 0.0))
161        });
162        let root = crate::Flex::<()>::column()
163            .width(200.0)
164            .height(400.0)
165            .child(lb);
166        let ui = build_ui(
167            &root,
168            Size::new(200.0, 400.0),
169            &Runtime::default(),
170            &Theme::default(),
171        );
172        let painted = ui
173            .scene()
174            .primitives()
175            .iter()
176            .find_map(|p| match p {
177                Primitive::Rect { rect, color, .. } if color.r > 0.9 && color.g < 0.1 => {
178                    Some(*rect)
179                }
180                _ => None,
181            })
182            .expect("the built content is painted");
183        assert_eq!(
184            painted.height, 60.0,
185            "the box is its content's: {painted:?}"
186        );
187    }
188
189    /// A sibling after it is pushed down by what it built — which is the point: the box
190    /// is real, not a zero-height hole the rest of the column lays out through.
191    #[test]
192    fn what_it_built_pushes_its_siblings_down() {
193        let column = crate::Flex::<()>::column()
194            .width(200.0)
195            .height(400.0)
196            .child(LayoutBuilder::<()>::new(|_| {
197                Container::<()>::new().height(60.0)
198            }))
199            .child(
200                Container::<()>::new()
201                    .height(20.0)
202                    .color(Color::rgb(0.0, 1.0, 0.0)),
203            );
204        let ui = build_ui(
205            &column,
206            Size::new(200.0, 400.0),
207            &Runtime::default(),
208            &Theme::default(),
209        );
210        let sibling = ui
211            .scene()
212            .primitives()
213            .iter()
214            .find_map(|p| match p {
215                Primitive::Rect { rect, color, .. } if color.g > 0.9 && color.r < 0.1 => {
216                    Some(*rect)
217                }
218                _ => None,
219            })
220            .expect("the sibling is painted");
221        assert_eq!(sibling.y, 60.0, "it starts below the built content");
222    }
223
224    /// An axis the style pins is still the style's: the engine never asks about a
225    /// dimension it already knows, so every existing `height(...)` behaves as it did.
226    #[test]
227    fn a_pinned_axis_is_still_the_style_s() {
228        let lb = LayoutBuilder::<()>::new(|_| {
229            Container::<()>::new()
230                .height(60.0)
231                .color(Color::rgb(1.0, 0.0, 0.0))
232        })
233        .height(120.0);
234        let root = crate::Flex::<()>::column()
235            .width(200.0)
236            .height(400.0)
237            .child(lb)
238            .child(
239                Container::<()>::new()
240                    .height(20.0)
241                    .color(Color::rgb(0.0, 1.0, 0.0)),
242            );
243        let ui = build_ui(
244            &root,
245            Size::new(200.0, 400.0),
246            &Runtime::default(),
247            &Theme::default(),
248        );
249        let sibling = ui
250            .scene()
251            .primitives()
252            .iter()
253            .find_map(|p| match p {
254                Primitive::Rect { rect, color, .. } if color.g > 0.9 && color.r < 0.1 => {
255                    Some(*rect)
256                }
257                _ => None,
258            })
259            .expect("the sibling is painted");
260        assert_eq!(sibling.y, 120.0, "the style's 120, not the content's 60");
261    }
262
263    #[test]
264    fn adapts_content_to_available_width() {
265        // Picks a number of tiles from the box's size class.
266        let count_tiles = |window_w: f32| {
267            let lb = LayoutBuilder::<()>::new(|size| {
268                let tiles = match SizeClass::from_width(size.width) {
269                    SizeClass::Compact => 1,
270                    SizeClass::Medium => 2,
271                    SizeClass::Expanded => 3,
272                };
273                let mut row = crate::Flex::<()>::row();
274                for _ in 0..tiles {
275                    row = row.child(
276                        Container::<()>::new()
277                            .width(10.0)
278                            .height(10.0)
279                            .color(Color::WHITE),
280                    );
281                }
282                row
283            })
284            .height(20.0);
285            // The root column is sized, so the stretch gives the LayoutBuilder the
286            // window's width and its box reflects the available width.
287            let root = crate::Flex::<()>::column()
288                .width(window_w)
289                .height(200.0)
290                .child(lb);
291            let ui = build_ui(
292                &root,
293                Size::new(window_w, 200.0),
294                &Runtime::default(),
295                &Theme::default(),
296            );
297            ui.scene()
298                .primitives()
299                .iter()
300                .filter(|p| matches!(p, Primitive::Rect { color, .. } if *color == Color::WHITE))
301                .count()
302        };
303        assert_eq!(count_tiles(400.0), 1);
304        assert_eq!(count_tiles(700.0), 2);
305        assert_eq!(count_tiles(1000.0), 3);
306    }
307}