Skip to main content

forme/layout/
flex.rs

1//! # Flex Layout Utilities
2//!
3//! Helper functions for the flexbox algorithm. The main flex logic lives
4//! in the layout engine's `layout_flex_row` method. This module provides
5//! the lower-level distribution calculations.
6
7/// Distribute remaining space among items based on flex-grow factors.
8pub fn distribute_grow(items: &mut [(f64, f64)], remaining: f64) {
9    // items: [(current_width, flex_grow)]
10    let total_grow: f64 = items.iter().map(|(_, g)| g).sum();
11    if total_grow <= 0.0 || remaining <= 0.0 {
12        return;
13    }
14    for (width, grow) in items.iter_mut() {
15        *width += remaining * (*grow / total_grow);
16    }
17}
18
19/// A single line of items in a wrapping flex row.
20#[derive(Debug, Clone)]
21pub struct WrapLine {
22    /// Index of the first item in this line.
23    pub start: usize,
24    /// One past the last item (exclusive end).
25    pub end: usize,
26}
27
28/// Partition items into wrap lines based on available width.
29/// Always adds at least one item per line (prevents infinite loops on oversized items).
30pub fn partition_into_lines(
31    base_widths: &[f64],
32    column_gap: f64,
33    available_width: f64,
34) -> Vec<WrapLine> {
35    if base_widths.is_empty() {
36        return vec![];
37    }
38
39    let mut lines = Vec::new();
40    let mut line_start = 0;
41    let mut line_width = 0.0;
42
43    for (i, &w) in base_widths.iter().enumerate() {
44        let needed = if i == line_start { w } else { column_gap + w };
45        // Tolerance for numeric noise only: percentage widths that sum
46        // to exactly 100% arrive with f32-precision error (0.6 stored as
47        // f32 resolves to 292.36801… of 487.28 — ~1.5e-5 over) and must
48        // not wrap. 0.01pt is invisible geometry and far above the noise.
49        if i > line_start && line_width + needed > available_width + 0.01 {
50            lines.push(WrapLine {
51                start: line_start,
52                end: i,
53            });
54            line_start = i;
55            line_width = w;
56        } else {
57            line_width += needed;
58        }
59    }
60
61    // Close the last line
62    if line_start < base_widths.len() {
63        lines.push(WrapLine {
64            start: line_start,
65            end: base_widths.len(),
66        });
67    }
68
69    lines
70}
71
72/// Shrink items to fit within available space based on flex-shrink factors.
73pub fn distribute_shrink(items: &mut [(f64, f64)], overflow: f64) {
74    // items: [(current_width, flex_shrink)]
75    let total_shrink_weighted: f64 = items.iter().map(|(w, s)| w * s).sum();
76    if total_shrink_weighted <= 0.0 || overflow >= 0.0 {
77        return;
78    }
79    let overflow = overflow.abs();
80    for (width, shrink) in items.iter_mut() {
81        let factor = (*width * *shrink) / total_shrink_weighted;
82        *width -= overflow * factor;
83        *width = width.max(0.0);
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn test_grow_distribution() {
93        let mut items = vec![(100.0, 1.0), (100.0, 2.0)];
94        distribute_grow(&mut items, 90.0);
95        assert!((items[0].0 - 130.0).abs() < 0.01);
96        assert!((items[1].0 - 160.0).abs() < 0.01);
97    }
98
99    #[test]
100    fn test_shrink_distribution() {
101        let mut items = vec![(200.0, 1.0), (100.0, 1.0)];
102        distribute_shrink(&mut items, -60.0);
103        // 200 gets shrunk more because it's wider
104        assert!(items[0].0 < 200.0);
105        assert!(items[1].0 < 100.0);
106        assert!((items[0].0 + items[1].0 - 240.0).abs() < 0.01);
107    }
108
109    #[test]
110    fn test_partition_single_line_fits() {
111        let widths = vec![100.0, 100.0, 100.0];
112        let lines = partition_into_lines(&widths, 10.0, 400.0);
113        assert_eq!(lines.len(), 1);
114        assert_eq!(lines[0].start, 0);
115        assert_eq!(lines[0].end, 3);
116    }
117
118    #[test]
119    fn test_partition_two_line_split() {
120        // 3 items × 100pt + 2 gaps × 10pt = 320pt; available = 250pt
121        let widths = vec![100.0, 100.0, 100.0];
122        let lines = partition_into_lines(&widths, 10.0, 250.0);
123        assert_eq!(lines.len(), 2);
124        assert_eq!(lines[0].start, 0);
125        assert_eq!(lines[0].end, 2); // first two fit: 100 + 10 + 100 = 210 <= 250
126        assert_eq!(lines[1].start, 2);
127        assert_eq!(lines[1].end, 3);
128    }
129
130    #[test]
131    fn test_partition_oversized_item() {
132        // Single item wider than available — must still get its own line
133        let widths = vec![500.0];
134        let lines = partition_into_lines(&widths, 10.0, 200.0);
135        assert_eq!(lines.len(), 1);
136        assert_eq!(lines[0].start, 0);
137        assert_eq!(lines[0].end, 1);
138    }
139
140    #[test]
141    fn test_partition_empty_input() {
142        let lines = partition_into_lines(&[], 10.0, 200.0);
143        assert!(lines.is_empty());
144    }
145
146    #[test]
147    fn test_partition_exact_fit() {
148        // 2 items × 100pt + 1 gap × 10pt = 210pt; available = 210pt — should fit on one line
149        let widths = vec![100.0, 100.0];
150        let lines = partition_into_lines(&widths, 10.0, 210.0);
151        assert_eq!(lines.len(), 1);
152    }
153}
154
155#[cfg(test)]
156mod epsilon_probe {
157    #[test]
158    fn exact_percent_sum_shares_a_line() {
159        // Widths carry f32 percent noise (0.6f32 as f64 = 0.60000002…):
160        // a 60% + 40% pair sums ~1.5e-5 over the row and must not wrap.
161        let w = 487.28_f64;
162        let widths = [w, f64::from(0.6_f32) * w, f64::from(0.4_f32) * w];
163        let lines = super::partition_into_lines(&widths, 0.0, w);
164        assert_eq!(lines.len(), 2, "{lines:?}");
165        assert_eq!((lines[1].start, lines[1].end), (1, 3));
166    }
167}