Skip to main content

sequoia_layout/
grid.rs

1//! Grid tracks, placements, and automatic flow.
2
3use crate::container::Align;
4use crate::dock::sanitize_gap;
5
6/// Size of a single grid column or row.
7#[derive(Clone, Debug, PartialEq)]
8#[non_exhaustive]
9#[derive(Default)]
10pub enum Track {
11    /// Size to the content of the track.
12    #[default]
13    Fit,
14    /// Fixed size in logical pixels.
15    Px(f32),
16    /// Size as a percentage of the available space.
17    Percent(f32),
18    /// Share of the free space, proportional to the weight.
19    Fill(f32),
20    /// Combination of a minimum size and a maximum track definition.
21    MinMax {
22        /// Lower bound of the track size.
23        min: f32,
24        /// Definition used for the upper bound.
25        max: Box<Track>,
26    },
27}
28
29impl Track {
30    /// Creates a track with a minimum size and a maximum definition.
31    ///
32    /// Nested [`Track::MinMax`] maximums are flattened to their inner track.
33    pub fn minmax(min: f32, max: Track) -> Self {
34        let min = if min.is_finite() { min.max(0.0) } else { 0.0 };
35
36        let max = match max {
37            Self::MinMax { min: _, max: inner } => *inner,
38            other => other,
39        };
40        Self::MinMax {
41            min,
42            max: Box::new(max),
43        }
44    }
45
46    /// Returns `true` if the track claims free space.
47    pub fn is_fill_like(&self) -> bool {
48        match self {
49            Self::Fill(w) => *w > 0.0,
50            Self::MinMax { max, .. } => (**max).is_fill_like(),
51            _ => false,
52        }
53    }
54
55    /// Returns the weight used to share free space, or zero.
56    pub fn fill_weight(&self) -> f32 {
57        match self {
58            Self::Fill(w) if w.is_finite() => w.max(0.0),
59            Self::MinMax { max, .. } => (**max).fill_weight(),
60            _ => 0.0,
61        }
62    }
63
64    /// Returns the minimum size enforced by this track.
65    pub fn min_floor(&self) -> f32 {
66        match self {
67            Self::MinMax { min, .. } => *min,
68            _ => 0.0,
69        }
70    }
71}
72
73/// Placement of a grid item, with optional row and column indices.
74#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
75pub struct GridPlacement {
76    /// Explicit column index, or `None` for auto placement.
77    pub column: Option<usize>,
78    /// Explicit row index, or `None` for auto placement.
79    pub row: Option<usize>,
80    /// Number of columns the item spans.
81    pub col_span: usize,
82    /// Number of rows the item spans.
83    pub row_span: usize,
84}
85
86impl GridPlacement {
87    /// Placement with no explicit position and single-cell spans.
88    pub const AUTO: Self = Self {
89        column: None,
90        row: None,
91        col_span: 1,
92        row_span: 1,
93    };
94
95    /// Creates an automatic placement.
96    pub fn new() -> Self {
97        Self::AUTO
98    }
99
100    /// Sets both the column and row index.
101    pub fn at(mut self, column: usize, row: usize) -> Self {
102        self.column = Some(column);
103        self.row = Some(row);
104        self
105    }
106
107    /// Sets the column index.
108    pub fn column(mut self, column: usize) -> Self {
109        self.column = Some(column);
110        self
111    }
112
113    /// Sets the row index.
114    pub fn row(mut self, row: usize) -> Self {
115        self.row = Some(row);
116        self
117    }
118
119    /// Sets both spans, clamped to at least one cell.
120    pub fn span(mut self, col_span: usize, row_span: usize) -> Self {
121        self.col_span = col_span.max(1);
122        self.row_span = row_span.max(1);
123        self
124    }
125
126    /// Sets the column span, clamped to at least one cell.
127    pub fn col_span(mut self, span: usize) -> Self {
128        self.col_span = span.max(1);
129        self
130    }
131
132    /// Sets the row span, clamped to at least one cell.
133    pub fn row_span(mut self, span: usize) -> Self {
134        self.row_span = span.max(1);
135        self
136    }
137
138    /// Returns `true` if the item has no explicit position or span.
139    pub fn is_auto(self) -> bool {
140        self.column.is_none() && self.row.is_none() && self.col_span <= 1 && self.row_span <= 1
141    }
142}
143
144/// Direction in which auto-placed grid items flow.
145#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
146pub enum GridFlow {
147    /// Fill rows left to right, then move to the next row.
148    #[default]
149    Row,
150    /// Fill columns top to bottom, then move to the next column.
151    Column,
152}
153
154/// Layout style for a grid container.
155#[derive(Clone, Debug, PartialEq)]
156pub struct GridStyle {
157    /// Column track definitions.
158    pub columns: Vec<Track>,
159    /// Row track definitions.
160    pub rows: Vec<Track>,
161    /// Gap between columns.
162    pub column_gap: f32,
163    /// Gap between rows.
164    pub row_gap: f32,
165    /// Auto-placement direction.
166    pub auto_flow: GridFlow,
167    /// Default cross-axis alignment of items within their cells.
168    pub align_items: Align,
169    /// Default main-axis alignment of items within their cells.
170    pub justify_items: Align,
171}
172
173impl Default for GridStyle {
174    fn default() -> Self {
175        Self {
176            columns: Vec::new(),
177            rows: Vec::new(),
178            column_gap: 0.0,
179            row_gap: 0.0,
180            auto_flow: GridFlow::Row,
181            align_items: Align::Stretch,
182            justify_items: Align::Stretch,
183        }
184    }
185}
186
187impl GridStyle {
188    /// Creates a style with the given column tracks.
189    pub fn new(columns: impl IntoIterator<Item = Track>) -> Self {
190        Self {
191            columns: columns.into_iter().collect(),
192            ..Self::default()
193        }
194    }
195
196    /// Sets the row tracks.
197    pub fn rows(mut self, rows: impl IntoIterator<Item = Track>) -> Self {
198        self.rows = rows.into_iter().collect();
199        self
200    }
201
202    /// Sets both the column and row gaps.
203    pub fn gap(mut self, gap: f32) -> Self {
204        let gap = sanitize_gap(gap);
205        self.column_gap = gap;
206        self.row_gap = gap;
207        self
208    }
209
210    /// Sets the gap between columns.
211    pub fn column_gap(mut self, gap: f32) -> Self {
212        self.column_gap = sanitize_gap(gap);
213        self
214    }
215
216    /// Sets the gap between rows.
217    pub fn row_gap(mut self, gap: f32) -> Self {
218        self.row_gap = sanitize_gap(gap);
219        self
220    }
221
222    /// Sets the auto-placement direction.
223    pub fn auto_flow(mut self, flow: GridFlow) -> Self {
224        self.auto_flow = flow;
225        self
226    }
227
228    /// Uses column-wise auto placement.
229    pub fn column_flow(mut self) -> Self {
230        self.auto_flow = GridFlow::Column;
231        self
232    }
233
234    /// Sets the default cross-axis alignment of items.
235    pub fn align_items(mut self, align: Align) -> Self {
236        self.align_items = align;
237        self
238    }
239
240    /// Sets the default main-axis alignment of items.
241    pub fn justify_items(mut self, align: Align) -> Self {
242        self.justify_items = align;
243        self
244    }
245
246    /// Sets both the align and justify alignment of items.
247    pub fn place_items(mut self, align: Align) -> Self {
248        self.align_items = align;
249        self.justify_items = align;
250        self
251    }
252}
253
254fn resolve_single(track: &Track, content: f32, available: f32) -> (f32, f32) {
255    let content = if content.is_finite() {
256        content.max(0.0)
257    } else {
258        0.0
259    };
260    match track {
261        Track::Px(v) => (if v.is_finite() { v.max(0.0) } else { content }, 0.0),
262        Track::Percent(p) => {
263            if available.is_finite() && p.is_finite() {
264                ((available * p / 100.0).max(0.0), 0.0)
265            } else {
266                (content, 0.0)
267            }
268        }
269        Track::Fit => (content, 0.0),
270        Track::Fill(w) => (0.0, if w.is_finite() { w.max(0.0) } else { 0.0 }),
271        Track::MinMax { min, max } => {
272            let (fixed, fill) = resolve_single(max, content, available);
273
274            if fill > 0.0 {
275                (min.max(fixed).max(0.0), fill)
276            } else {
277                (fixed.max(*min).max(0.0), 0.0)
278            }
279        }
280    }
281}
282
283/// Resolves track sizes to concrete lengths.
284///
285/// `content` provides the intrinsic content size for each track and
286/// `available` is the space shared by fill tracks.
287pub fn resolve_tracks(tracks: &[Track], content: &[f32], available: f32, gap: f32) -> Vec<f32> {
288    resolve_tracks_clamped(tracks, content, available, gap, None, None)
289}
290
291/// Resolves track sizes with optional per-track min and max limits.
292///
293/// Fill tracks share the remaining space; a track that reaches its max stops
294/// growing so the rest is handed to its siblings.
295pub fn resolve_tracks_clamped(
296    tracks: &[Track],
297    content: &[f32],
298    available: f32,
299    gap: f32,
300    mins: Option<&[f32]>,
301    maxs: Option<&[f32]>,
302) -> Vec<f32> {
303    let gap = sanitize_gap(gap);
304    let gaps = gap * tracks.len().saturating_sub(1) as f32;
305    let mut sizes = vec![0.0; tracks.len()];
306    let mut fill_weight_sum = 0.0;
307
308    for (i, track) in tracks.iter().enumerate() {
309        let c = content.get(i).copied().unwrap_or(0.0);
310        let (fixed, fill) = resolve_single(track, c, available);
311
312        let floor = track.min_floor();
313        let mut size = fixed.max(floor);
314        if let Track::MinMax { min, .. } = track {
315            size = size.max(*min).max(0.0);
316
317            let _ = c;
318        }
319        if let Some(mns) = mins {
320            let mn = mns.get(i).copied().unwrap_or(0.0);
321            let mn = if mn.is_finite() { mn.max(0.0) } else { 0.0 };
322            size = size.max(mn);
323        }
324        if let Some(mxs) = maxs {
325            let mx = mxs.get(i).copied().unwrap_or(f32::INFINITY);
326            let mx = if mx.is_nan() {
327                f32::INFINITY
328            } else if mx.is_infinite() {
329                if mx > 0.0 { f32::INFINITY } else { 0.0 }
330            } else {
331                mx.max(0.0)
332            };
333            size = size.min(mx);
334        }
335        sizes[i] = size.max(0.0);
336        fill_weight_sum += fill;
337    }
338
339    if fill_weight_sum > 0.0 {
340        let max_of = |i: usize| -> Option<f32> {
341            maxs.and_then(|m| m.get(i).copied()).map(|mx| {
342                if mx.is_nan() {
343                    f32::INFINITY
344                } else if mx.is_infinite() {
345                    if mx > 0.0 { f32::INFINITY } else { 0.0 }
346                } else {
347                    mx.max(0.0)
348                }
349            })
350        };
351        let capped = |i: usize, size: f32| max_of(i).is_some_and(|mx| size >= mx - 0.005);
352
353        if available.is_finite() {
354            // Fill tracks share the free space; a track that reaches its max
355            // stops taking space so the rest is handed to its siblings.
356            for _ in 0..tracks.len().max(1) {
357                let used: f32 = sizes.iter().sum::<f32>() + gaps;
358                let free = available - used;
359                if free <= 0.005 {
360                    break;
361                }
362                let active: f32 = tracks
363                    .iter()
364                    .enumerate()
365                    .filter(|(i, t)| t.fill_weight() > 0.0 && !capped(*i, sizes[*i]))
366                    .map(|(_, t)| t.fill_weight())
367                    .sum();
368                if active <= 0.0 {
369                    break;
370                }
371                for (i, track) in tracks.iter().enumerate() {
372                    let w = track.fill_weight();
373                    if w <= 0.0 || capped(i, sizes[i]) {
374                        continue;
375                    }
376                    let floor = sizes[i];
377                    let mut v = floor + free * w / active;
378                    if let Some(mx) = max_of(i) {
379                        v = v.min(mx.max(floor));
380                    }
381                    sizes[i] = v.max(floor).max(0.0);
382                }
383            }
384        } else {
385            for (i, track) in tracks.iter().enumerate() {
386                let w = track.fill_weight();
387                if w > 0.0 {
388                    let floor = sizes[i];
389                    let extra = content.get(i).copied().unwrap_or(0.0).max(0.0).max(floor) - floor;
390                    sizes[i] = (floor + extra.max(0.0)).max(floor).max(0.0);
391                }
392            }
393        }
394    }
395
396    sizes
397}
398
399/// Returns the `(column, row)` cell for each auto-placed item.
400///
401/// `ncols` is clamped to at least one column.
402pub fn auto_cells(count: usize, ncols: usize, flow: GridFlow) -> Vec<(usize, usize)> {
403    let ncols = ncols.max(1);
404    (0..count)
405        .map(|i| match flow {
406            GridFlow::Row => (i % ncols, i / ncols),
407            GridFlow::Column => {
408                let nrows = count.div_ceil(ncols).max(1);
409                (i / nrows, i % nrows)
410            }
411        })
412        .collect()
413}
414
415/// Returns the starting offset of each track, including gaps.
416pub fn track_offsets(sizes: &[f32], gap: f32) -> Vec<f32> {
417    let mut offsets = Vec::with_capacity(sizes.len());
418    let mut cursor = 0.0;
419    for &size in sizes {
420        offsets.push(cursor);
421        cursor += size + gap;
422    }
423    offsets
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    fn approx(a: f32, b: f32) -> bool {
431        (a - b).abs() < 0.01
432    }
433
434    #[test]
435    fn px_and_auto_tracks_take_fixed_and_content() {
436        let sizes = resolve_tracks(
437            &[Track::Px(100.0), Track::Fit, Track::Px(50.0)],
438            &[0.0, 70.0, 0.0],
439            400.0,
440            0.0,
441        );
442        assert_eq!(sizes.len(), 3);
443        assert!(approx(sizes[0], 100.0));
444        assert!(approx(sizes[1], 70.0));
445        assert!(approx(sizes[2], 50.0));
446    }
447
448    #[test]
449    fn fill_tracks_split_remaining_space() {
450        let sizes = resolve_tracks(
451            &[Track::Px(100.0), Track::Fill(1.0), Track::Fill(3.0)],
452            &[0.0; 3],
453            400.0,
454            0.0,
455        );
456        assert!(approx(sizes[0], 100.0));
457        assert!(approx(sizes[1], 75.0));
458        assert!(approx(sizes[2], 225.0));
459    }
460
461    #[test]
462    fn fill_respects_gaps_and_overflow_clamps_to_zero() {
463        let with_gap = resolve_tracks(
464            &[Track::Fill(1.0), Track::Fill(1.0)],
465            &[0.0; 2],
466            110.0,
467            10.0,
468        );
469        assert!(approx(with_gap[0], 50.0));
470        assert!(approx(with_gap[1], 50.0));
471
472        let overflow = resolve_tracks(&[Track::Px(300.0), Track::Fill(2.0)], &[0.0; 2], 100.0, 0.0);
473        assert!(approx(overflow[0], 300.0));
474        assert!(approx(overflow[1], 0.0));
475    }
476
477    #[test]
478    fn unbounded_axis_falls_back_to_content() {
479        let sizes = resolve_tracks(
480            &[Track::Percent(50.0), Track::Fill(1.0), Track::Px(20.0)],
481            &[80.0, 40.0, 0.0],
482            f32::INFINITY,
483            0.0,
484        );
485        assert!(approx(sizes[0], 80.0));
486        assert!(approx(sizes[1], 40.0));
487        assert!(approx(sizes[2], 20.0));
488    }
489
490    #[test]
491    fn track_offsets_place_tracks_after_gaps() {
492        let offsets = track_offsets(&[100.0, 50.0, 30.0], 10.0);
493        assert!(approx(offsets[0], 0.0));
494        assert!(approx(offsets[1], 110.0));
495        assert!(approx(offsets[2], 170.0));
496    }
497}