Skip to main content

katui_layout/
lib.rs

1//! Splits a cell rectangle along one axis into sized children.
2//!
3//! Lengths take their exact cells and percentages round down. Fill
4//! children split what is left, the first fills taking any leftover cell.
5//! A length longer than the area keeps its size and leaves the fills
6//! empty.
7
8use katui_scene::placement::CellRect;
9
10/// One child's size claim.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Constraint {
13    /// A fixed number of cells.
14    Length(u16),
15    /// A share of the whole axis, rounded down.
16    Percentage(u8),
17    /// An equal share of whatever the others leave.
18    Fill,
19}
20
21/// Split `area` into columns, left to right.
22#[must_use]
23pub fn split_horizontal(area: CellRect, constraints: &[Constraint]) -> Vec<CellRect> {
24    let sizes = resolve(u32::from(area.columns), constraints);
25    let mut x = area.x;
26    sizes
27        .into_iter()
28        .map(|width| {
29            let rect = CellRect {
30                x,
31                y: area.y,
32                columns: width,
33                rows: area.rows,
34            };
35            x = x.saturating_add(width);
36            rect
37        })
38        .collect()
39}
40
41/// Split `area` into rows, top to bottom.
42#[must_use]
43pub fn split_vertical(area: CellRect, constraints: &[Constraint]) -> Vec<CellRect> {
44    let sizes = resolve(u32::from(area.rows), constraints);
45    let mut y = area.y;
46    sizes
47        .into_iter()
48        .map(|height| {
49            let rect = CellRect {
50                x: area.x,
51                y,
52                columns: area.columns,
53                rows: height,
54            };
55            y = y.saturating_add(height);
56            rect
57        })
58        .collect()
59}
60
61fn resolve(whole: u32, constraints: &[Constraint]) -> Vec<u16> {
62    let whole = whole.min(u32::from(u16::MAX));
63    let mut sizes = Vec::with_capacity(constraints.len());
64    let mut fill_indexes = Vec::new();
65    let mut used = 0u32;
66    for (index, constraint) in constraints.iter().enumerate() {
67        match constraint {
68            Constraint::Length(cells) => {
69                used = used.saturating_add(u32::from(*cells));
70                sizes.push(*cells);
71            }
72            Constraint::Percentage(percent) => {
73                let size = whole * u32::from(*percent) / 100;
74                used = used.saturating_add(size);
75                sizes.push(u16::try_from(size).unwrap_or(u16::MAX));
76            }
77            Constraint::Fill => {
78                fill_indexes.push(index);
79                sizes.push(0);
80            }
81        }
82    }
83    if !fill_indexes.is_empty() {
84        let mut rest = whole.saturating_sub(used.min(whole));
85        let share = u32::try_from(fill_indexes.len()).unwrap_or(1);
86        let each = rest / share;
87        for &index in &fill_indexes {
88            if let Some(slot) = sizes.get_mut(index) {
89                *slot = u16::try_from(each).unwrap_or(u16::MAX);
90            }
91            rest = rest.saturating_sub(each);
92        }
93        // the division's remainder goes to the first fills, one cell each
94        for &index in &fill_indexes {
95            if rest == 0 {
96                break;
97            }
98            if let Some(slot) = sizes.get_mut(index) {
99                *slot = (*slot).saturating_add(1);
100            }
101            rest -= 1;
102        }
103    }
104    sizes
105}
106
107#[cfg(test)]
108mod tests {
109    use super::{Constraint, split_horizontal, split_vertical};
110    use katui_scene::placement::CellRect;
111
112    fn area(columns: u16, rows: u16) -> CellRect {
113        CellRect {
114            x: 0,
115            y: 0,
116            columns,
117            rows,
118        }
119    }
120
121    fn widths(rects: &[CellRect]) -> Vec<u16> {
122        rects.iter().map(|rect| rect.columns).collect()
123    }
124
125    fn heights(rects: &[CellRect]) -> Vec<u16> {
126        rects.iter().map(|rect| rect.rows).collect()
127    }
128
129    #[test]
130    fn lengths_hold_their_cells() {
131        let rects = split_horizontal(
132            area(20, 5),
133            &[
134                Constraint::Length(5),
135                Constraint::Fill,
136                Constraint::Length(3),
137            ],
138        );
139        assert_eq!(widths(&rects), vec![5, 12, 3]);
140        assert_eq!(
141            rects[2].x + rects[2].columns,
142            20,
143            "the split reaches the edge"
144        );
145    }
146
147    #[test]
148    fn percentages_round_down_and_fills_take_the_rest() {
149        let rects = split_horizontal(
150            area(13, 4),
151            &[
152                Constraint::Percentage(50),
153                Constraint::Fill,
154                Constraint::Fill,
155            ],
156        );
157        assert_eq!(widths(&rects), vec![6, 4, 3]);
158    }
159
160    #[test]
161    fn fills_share_a_leftover_cell_to_the_earliest() {
162        let rects = split_horizontal(
163            area(11, 4),
164            &[Constraint::Fill, Constraint::Fill, Constraint::Length(4)],
165        );
166        assert_eq!(widths(&rects), vec![4, 3, 4]);
167    }
168
169    #[test]
170    fn vertical_splits_stack_rows() {
171        let rects = split_vertical(
172            area(30, 10),
173            &[
174                Constraint::Length(1),
175                Constraint::Fill,
176                Constraint::Length(2),
177            ],
178        );
179        assert_eq!(heights(&rects), vec![1, 7, 2]);
180        assert_eq!(rects[1].y, 1);
181        assert_eq!(rects[2].y, 8);
182    }
183
184    #[test]
185    fn splits_start_at_the_areas_origin() {
186        let rects = split_horizontal(
187            CellRect {
188                x: 5,
189                y: 7,
190                columns: 10,
191                rows: 3,
192            },
193            &[Constraint::Fill, Constraint::Fill],
194        );
195        assert_eq!(rects[0].x, 5);
196        assert_eq!(rects[1].x, 10);
197        assert_eq!(rects[0].rows, 3);
198    }
199
200    #[test]
201    fn an_overlong_claim_saturates_instead_of_wrapping() {
202        let rects = split_horizontal(area(5, 2), &[Constraint::Length(9), Constraint::Fill]);
203        assert_eq!(widths(&rects), vec![9, 0]);
204    }
205}