Skip to main content

teksilo_widgets/primitives/
wrap.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Wrap — a horizontal flow layout that wraps children to the next line when
5//! they exceed the available width.
6//!
7//! Children are placed left-to-right (or right-to-left under RTL layout) and
8//! wrapped to the next line when the next item would exceed the container
9//! width.  Each line's height is the tallest child on that line.  Use
10//! [`spacing`](Wrap::spacing) for the horizontal gap between items and
11//! [`line_spacing`](Wrap::line_spacing) for the vertical gap between lines.
12//!
13//! `Wrap` is the right choice for chip rows, badge lists, and any collection
14//! whose items vary in width and should reflow as the container resizes.  For
15//! a fixed grid use [`crate::primitives::Grid`] instead.
16//!
17//! ```rust
18//! # use teksilo_widgets::primitives::{Wrap, TextWidget};
19//! # use teksilo_i18n::lit;
20//! let _chips = Wrap::new()
21//!     .spacing(8.0)
22//!     .line_spacing(6.0)
23//!     .child(TextWidget::new(lit!("Rust")))
24//!     .child(TextWidget::new(lit!("GUI")))
25//!     .child(TextWidget::new(lit!("Desktop")));
26//! ```
27
28use teksilo_canvas::{Point, Rect, Size, SizeProposal};
29use teksilo_core::accessibility::AccessNodeBuilder;
30use teksilo_core::signal::Prop;
31use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
32use teksilo_core::widget_id::WidgetId;
33
34/// A horizontal flow layout that wraps children to the next line.
35#[derive(Debug)]
36pub struct Wrap {
37    spacing: Prop<f32>,
38    line_spacing: Prop<f32>,
39    child_ids: Vec<WidgetId>,
40    pending: Vec<PendingChild>,
41}
42
43impl Wrap {
44    /// Create an empty `Wrap` container with zero spacing.
45    pub fn new() -> Self {
46        Self {
47            spacing: Prop::Static(0.0),
48            line_spacing: Prop::Static(0.0),
49            child_ids: Vec::new(),
50            pending: Vec::new(),
51        }
52    }
53
54    /// Horizontal spacing between items on the same line. Accepts a static
55    /// `f32` or a reactive `Signal<f32>`.
56    pub fn spacing(mut self, spacing: impl Into<Prop<f32>>) -> Self {
57        self.spacing = spacing.into();
58        self
59    }
60
61    /// Vertical spacing between lines. Accepts a static `f32` or a
62    /// reactive `Signal<f32>`.
63    pub fn line_spacing(mut self, spacing: impl Into<Prop<f32>>) -> Self {
64        self.line_spacing = spacing.into();
65        self
66    }
67
68    /// Add a pre-registered child by ID.
69    pub fn add_child(mut self, id: WidgetId) -> Self {
70        self.pending.push(PendingChild::Id(id));
71        self
72    }
73
74    /// Add an inline child widget (deferred insertion).
75    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
76        self.pending.push(PendingChild::Deferred(Box::new(widget)));
77        self
78    }
79
80    /// Add multiple inline children from an iterator.
81    pub fn children(mut self, iter: impl IntoIterator<Item = impl Widget + 'static>) -> Self {
82        for widget in iter {
83            self.pending.push(PendingChild::Deferred(Box::new(widget)));
84        }
85        self
86    }
87
88    /// Conditionally add a child. No-op if `None`.
89    pub fn child_opt(mut self, widget: Option<impl Widget + 'static>) -> Self {
90        if let Some(w) = widget {
91            self.pending.push(PendingChild::Deferred(Box::new(w)));
92        }
93        self
94    }
95
96    /// Compute the line layout: returns (sizes, line_breaks).
97    /// `line_breaks[i]` = true means a new line starts before child `i`.
98    fn compute_layout(
99        &self,
100        available_width: f32,
101        children: &[WidgetId],
102        ctx: &LayoutContext,
103    ) -> (Vec<Size>, Vec<bool>) {
104        let child_proposal = SizeProposal::unspecified();
105        let mut sizes = Vec::with_capacity(children.len());
106        let mut line_breaks = vec![false; children.len()];
107        let mut x = 0.0_f32;
108        let spacing = self.spacing.get();
109
110        for (i, &child_id) in children.iter().enumerate() {
111            let size = ctx
112                .child_size(child_id, child_proposal)
113                .unwrap_or(Size::ZERO);
114            sizes.push(size);
115
116            if i > 0 {
117                let needed = x + spacing + size.width;
118                if needed > available_width {
119                    line_breaks[i] = true;
120                    x = size.width;
121                } else {
122                    x += spacing + size.width;
123                }
124            } else {
125                x = size.width;
126            }
127        }
128        (sizes, line_breaks)
129    }
130}
131
132impl Default for Wrap {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138impl Widget for Wrap {
139    fn layout_response(
140        &self,
141        proposal: SizeProposal,
142        ctx: &LayoutContext,
143    ) -> teksilo_core::widget::LayoutResponse {
144        if self.child_ids.is_empty() {
145            return (proposal.resolve(0.0, 0.0)).into();
146        }
147
148        let available_width = proposal.width.unwrap_or(f32::MAX);
149        let (sizes, line_breaks) = self.compute_layout(available_width, &self.child_ids, ctx);
150
151        let spacing = self.spacing.get();
152        let line_spacing = self.line_spacing.get();
153        let mut max_line_width = 0.0_f32;
154        let mut line_width = 0.0_f32;
155        let mut line_height = 0.0_f32;
156        let mut total_height = 0.0_f32;
157        let mut line_count = 0;
158
159        for (i, size) in sizes.iter().enumerate() {
160            if line_breaks[i] || i == 0 {
161                if i > 0 {
162                    max_line_width = max_line_width.max(line_width);
163                    total_height += line_height;
164                    line_count += 1;
165                }
166                line_width = size.width;
167                line_height = size.height;
168            } else {
169                line_width += spacing + size.width;
170                line_height = line_height.max(size.height);
171            }
172        }
173        // Last line
174        max_line_width = max_line_width.max(line_width);
175        total_height += line_height;
176        line_count += 1;
177
178        let total_line_gap = line_spacing * (line_count as f32 - 1.0).max(0.0);
179        Size::new(max_line_width, total_height + total_line_gap).into()
180    }
181
182    fn place_children(
183        &self,
184        bounds: Rect,
185        _proposal: SizeProposal,
186        children: &mut [WidgetPlacement],
187        ctx: &LayoutContext,
188    ) {
189        if children.is_empty() {
190            return;
191        }
192
193        let active_ids: Vec<WidgetId> = children.iter().map(|c| c.id).collect();
194        let (sizes, line_breaks) = self.compute_layout(bounds.width, &active_ids, ctx);
195        let spacing = self.spacing.get();
196        let line_spacing = self.line_spacing.get();
197        let mut x = bounds.x;
198        let mut y = bounds.y;
199        let mut line_height = 0.0_f32;
200
201        for (i, child) in children.iter_mut().enumerate() {
202            if i >= sizes.len() {
203                break;
204            }
205            if line_breaks[i] {
206                y += line_height + line_spacing;
207                x = bounds.x;
208                line_height = 0.0;
209            }
210
211            child.origin = Point::new(x, y);
212            child.size = sizes[i];
213            line_height = line_height.max(sizes[i].height);
214
215            x += sizes[i].width + spacing;
216        }
217    }
218
219    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
220
221    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
222        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
223    }
224
225    fn children(&self) -> Vec<WidgetId> {
226        self.child_ids.clone()
227    }
228
229    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
230        let pending = std::mem::take(&mut self.pending);
231        if !pending.is_empty() {
232            self.child_ids = pending
233                .into_iter()
234                .map(|child| match child {
235                    PendingChild::Id(id) => id,
236                    PendingChild::Deferred(w) => ctx.add_boxed(w),
237                })
238                .collect();
239        }
240        let self_id = ctx.self_id();
241        let registry = ctx.binding_registry();
242        self.spacing.register_if_bound(
243            self_id,
244            registry,
245            teksilo_core::binding::BindingLevel::Relayout,
246        );
247        self.line_spacing.register_if_bound(
248            self_id,
249            registry,
250            teksilo_core::binding::BindingLevel::Relayout,
251        );
252        self.child_ids.clone()
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use teksilo_core::widget_tree::WidgetTree;
260
261    #[derive(Debug)]
262    struct FixedLeaf(f32, f32);
263    impl Widget for FixedLeaf {
264        fn layout_response(
265            &self,
266            _proposal: SizeProposal,
267            _ctx: &LayoutContext,
268        ) -> teksilo_core::widget::LayoutResponse {
269            Size::new(self.0, self.1).into()
270        }
271    }
272
273    #[test]
274    fn single_line_no_wrap() {
275        let mut tree = WidgetTree::new();
276        let a = tree.add(FixedLeaf(40.0, 20.0));
277        let b = tree.add(FixedLeaf(40.0, 20.0));
278        let _wrap = tree.add(Wrap::new().spacing(10.0).add_child(a).add_child(b));
279        tree.layout(SizeProposal::exact(200.0, 100.0));
280
281        assert!((tree.bounds(a).y - 0.0).abs() < 0.01);
282        assert!((tree.bounds(b).y - 0.0).abs() < 0.01);
283        assert!((tree.bounds(b).x - 50.0).abs() < 0.01); // 40 + 10
284    }
285
286    #[test]
287    fn wraps_to_next_line() {
288        let mut tree = WidgetTree::new();
289        let a = tree.add(FixedLeaf(80.0, 20.0));
290        let b = tree.add(FixedLeaf(80.0, 20.0));
291        let c = tree.add(FixedLeaf(80.0, 20.0));
292        let _wrap = tree.add(
293            Wrap::new()
294                .spacing(10.0)
295                .line_spacing(5.0)
296                .add_child(a)
297                .add_child(b)
298                .add_child(c),
299        );
300        tree.layout(SizeProposal::exact(200.0, 200.0));
301
302        // 80 + 10 + 80 = 170 fits in 200, so a and b on line 1
303        assert!((tree.bounds(a).y - 0.0).abs() < 0.01);
304        assert!((tree.bounds(b).y - 0.0).abs() < 0.01);
305        // 170 + 10 + 80 = 260 > 200, so c wraps to line 2
306        assert!((tree.bounds(c).y - 25.0).abs() < 0.01); // 20 + 5 line_spacing
307        assert!((tree.bounds(c).x - 0.0).abs() < 0.01); // starts at beginning
308    }
309
310    #[test]
311    fn intrinsic_height_accounts_for_wrapping() {
312        let mut tree = WidgetTree::new();
313        let a = tree.add(FixedLeaf(60.0, 20.0));
314        let b = tree.add(FixedLeaf(60.0, 30.0));
315        let c = tree.add(FixedLeaf(60.0, 20.0));
316        let wrap = tree.add(
317            Wrap::new()
318                .spacing(10.0)
319                .line_spacing(5.0)
320                .add_child(a)
321                .add_child(b)
322                .add_child(c),
323        );
324        tree.layout(SizeProposal {
325            width: Some(140.0),
326            height: None,
327        });
328
329        // Line 1: a(60) + 10 + b(60) = 130 fits in 140
330        // Line 2: c(60)
331        // Height: max(20,30) + 5 + 20 = 55
332        let wb = tree.bounds(wrap);
333        assert!((wb.height - 55.0).abs() < 0.01);
334    }
335
336    #[test]
337    fn empty_wrap() {
338        let mut tree = WidgetTree::new();
339        let _wrap = tree.add(Wrap::new());
340        tree.layout(SizeProposal::exact(200.0, 100.0));
341        // No crash
342    }
343
344    #[test]
345    fn dormant_child_does_not_misalign_placement() {
346        let mut tree = WidgetTree::new();
347        let a = tree.add(FixedLeaf(40.0, 20.0));
348        let b = tree.add(FixedLeaf(50.0, 25.0));
349        let c = tree.add(FixedLeaf(60.0, 30.0));
350        let _wrap = tree.add(
351            Wrap::new()
352                .spacing(10.0)
353                .add_child(a)
354                .add_child(b)
355                .add_child(c),
356        );
357        tree.layout(SizeProposal::exact(300.0, 100.0));
358
359        // Before dormant: a at x=0, b at x=50, c at x=110
360        assert!((tree.bounds(b).x - 50.0).abs() < 0.01);
361        assert!((tree.bounds(b).width - 50.0).abs() < 0.01);
362        assert!((tree.bounds(c).x - 110.0).abs() < 0.01);
363        assert!((tree.bounds(c).width - 60.0).abs() < 0.01);
364
365        // Make first child dormant
366        tree.set_dormant(a);
367        tree.layout(SizeProposal::exact(300.0, 100.0));
368
369        // b should get its own size (50x25), not a's size (40x20)
370        assert!((tree.bounds(b).x - 0.0).abs() < 0.01);
371        assert!((tree.bounds(b).width - 50.0).abs() < 0.01);
372        assert!((tree.bounds(b).height - 25.0).abs() < 0.01);
373        // c follows b
374        assert!((tree.bounds(c).x - 60.0).abs() < 0.01); // 50 + 10
375        assert!((tree.bounds(c).width - 60.0).abs() < 0.01);
376        assert!((tree.bounds(c).height - 30.0).abs() < 0.01);
377    }
378}