Skip to main content

louie/layout/
mod.rs

1//! Layout engine with constraint-based space allocation.
2//!
3//! Inspired by ratatui's layout system with flexbox-like semantics.
4
5pub use crate::core::rect::Margin;
6use crate::core::rect::Rect;
7
8pub use crate::core::text::Alignment;
9
10/// Layouting direction.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
12pub enum Direction {
13    #[default]
14    Vertical,
15    Horizontal,
16}
17
18/// Size constraint for layout segments.
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub enum Constraint {
21    /// Fixed length in cells.
22    Length(u16),
23    /// Percentage of available space (0-100).
24    Percentage(u16),
25    /// Minimum size.
26    Min(u16),
27    /// Maximum size.
28    Max(u16),
29    /// Ratio of available space (numerator/denominator).
30    Ratio(u32, u32),
31    /// Fill remaining space proportionally (weight relative to other fills).
32    Fill(u16),
33}
34
35/// How to distribute excess space after constraints are satisfied.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
37pub enum Flex {
38    /// Pack segments at the start.
39    #[default]
40    Start,
41    /// Center segments.
42    Center,
43    /// Pack segments at the end.
44    End,
45    /// Distribute space evenly between segments.
46    SpaceBetween,
47    /// Distribute space evenly around segments.
48    SpaceAround,
49}
50
51/// Layout builder.
52#[derive(Debug, Clone)]
53pub struct Layout {
54    direction: Direction,
55    constraints: Vec<Constraint>,
56    margin: Margin,
57    flex: Flex,
58    spacing: i16,
59}
60
61impl Default for Layout {
62    fn default() -> Self {
63        Self {
64            direction: Direction::Vertical,
65            constraints: Vec::new(),
66            margin: Margin::ZERO,
67            flex: Flex::Start,
68            spacing: 0,
69        }
70    }
71}
72
73impl Layout {
74    pub fn new(direction: Direction, constraints: impl Into<Vec<Constraint>>) -> Self {
75        Self {
76            direction,
77            constraints: constraints.into(),
78            ..Default::default()
79        }
80    }
81
82    pub fn vertical(constraints: impl Into<Vec<Constraint>>) -> Self {
83        Self::new(Direction::Vertical, constraints)
84    }
85
86    pub fn horizontal(constraints: impl Into<Vec<Constraint>>) -> Self {
87        Self::new(Direction::Horizontal, constraints)
88    }
89
90    pub fn direction(mut self, direction: Direction) -> Self {
91        self.direction = direction;
92        self
93    }
94
95    pub fn constraints(mut self, constraints: impl Into<Vec<Constraint>>) -> Self {
96        self.constraints = constraints.into();
97        self
98    }
99
100    pub fn margin(mut self, margin: Margin) -> Self {
101        self.margin = margin;
102        self
103    }
104
105    pub fn flex(mut self, flex: Flex) -> Self {
106        self.flex = flex;
107        self
108    }
109
110    pub fn spacing(mut self, spacing: i16) -> Self {
111        self.spacing = spacing;
112        self
113    }
114
115    /// Split the given area into segments according to constraints.
116    pub fn split(&self, area: Rect) -> Vec<Rect> {
117        let inner = area.inner(self.margin);
118        if self.constraints.is_empty() || inner.is_empty() {
119            return vec![inner];
120        }
121
122        let total_space = match self.direction {
123            Direction::Vertical => inner.height,
124            Direction::Horizontal => inner.width,
125        };
126
127        let n = self.constraints.len();
128        let total_spacing = if n > 1 {
129            (n as i32 - 1) * self.spacing as i32
130        } else {
131            0
132        };
133        let available = (total_space as i32 - total_spacing).max(0) as u16;
134
135        // Phase 1: compute initial sizes
136        let mut sizes: Vec<u16> = self
137            .constraints
138            .iter()
139            .map(|c| match c {
140                Constraint::Length(l) => (*l).min(available),
141                Constraint::Percentage(p) => ((available as u32 * *p as u32) / 100) as u16,
142                Constraint::Min(m) => *m,
143                Constraint::Max(m) => (*m).min(available),
144                Constraint::Ratio(num, den) => {
145                    (available as u32 * *num).checked_div(*den).unwrap_or(0) as u16
146                }
147                Constraint::Fill(_) => 0,
148            })
149            .collect();
150
151        // Phase 2: distribute remaining space to Fill constraints
152        let fixed_total: u16 = sizes.iter().sum();
153        let remaining = available.saturating_sub(fixed_total);
154
155        let fill_total_weight: u16 = self
156            .constraints
157            .iter()
158            .filter_map(|c| match c {
159                Constraint::Fill(w) => Some(*w),
160                _ => None,
161            })
162            .sum();
163
164        if fill_total_weight > 0 && remaining > 0 {
165            let mut distributed = 0u16;
166            let fill_count = self
167                .constraints
168                .iter()
169                .filter(|c| matches!(c, Constraint::Fill(_)))
170                .count();
171            let mut fill_idx = 0;
172
173            for (i, c) in self.constraints.iter().enumerate() {
174                if let Constraint::Fill(w) = c {
175                    fill_idx += 1;
176                    let share = if fill_idx == fill_count {
177                        // Last fill gets the remainder to avoid rounding errors
178                        remaining - distributed
179                    } else {
180                        ((remaining as u32 * *w as u32) / fill_total_weight as u32) as u16
181                    };
182                    sizes[i] = share;
183                    distributed += share;
184                }
185            }
186        }
187
188        // Phase 2b: distribute remaining space to Min constraints.
189        // Min(m) means "at least m, but grow to fill available space" — this
190        // matches ratatui semantics where Min(0) acts as a flexible fill.
191        {
192            let used: u16 = sizes.iter().sum();
193            let leftover = available.saturating_sub(used);
194            let min_count = self
195                .constraints
196                .iter()
197                .filter(|c| matches!(c, Constraint::Min(_)))
198                .count();
199            if min_count > 0 && leftover > 0 {
200                let share = leftover / min_count as u16;
201                let mut distributed = 0u16;
202                let mut idx = 0;
203                for (i, c) in self.constraints.iter().enumerate() {
204                    if let Constraint::Min(_) = c {
205                        idx += 1;
206                        let extra = if idx == min_count {
207                            leftover - distributed
208                        } else {
209                            share
210                        };
211                        sizes[i] += extra;
212                        distributed += extra;
213                    }
214                }
215            }
216        }
217
218        // Phase 3: apply Min/Max constraint adjustments
219        for (i, c) in self.constraints.iter().enumerate() {
220            match c {
221                Constraint::Min(m) => sizes[i] = sizes[i].max(*m),
222                Constraint::Max(m) => sizes[i] = sizes[i].min(*m),
223                _ => {}
224            }
225        }
226
227        // Phase 4: clamp total to available space
228        let total_used: u16 = sizes.iter().sum();
229        if total_used > available {
230            // Proportionally shrink all segments
231            let scale = available as f64 / total_used as f64;
232            let mut shrunk_total = 0u16;
233            for (i, size) in sizes.iter_mut().enumerate() {
234                if i == n - 1 {
235                    *size = available - shrunk_total;
236                } else {
237                    *size = (*size as f64 * scale) as u16;
238                    shrunk_total += *size;
239                }
240            }
241        }
242
243        // Phase 5: compute positions and emit Rects
244        let mut rects = Vec::with_capacity(n);
245        let actual_total: u16 = sizes.iter().sum();
246        let excess = available.saturating_sub(actual_total);
247
248        let start_offset = match self.flex {
249            Flex::Start | Flex::SpaceBetween => 0,
250            Flex::Center | Flex::SpaceAround => excess / 2,
251            Flex::End => excess,
252        };
253
254        let mut pos = match self.direction {
255            Direction::Vertical => inner.y + start_offset,
256            Direction::Horizontal => inner.x + start_offset,
257        };
258
259        for (i, size) in sizes.iter().enumerate() {
260            let rect = match self.direction {
261                Direction::Vertical => Rect::new(inner.x, pos, inner.width, *size),
262                Direction::Horizontal => Rect::new(pos, inner.y, *size, inner.height),
263            };
264            rects.push(rect);
265            pos = pos.saturating_add(*size);
266            if i < n - 1 {
267                pos = (pos as i32 + self.spacing as i32).max(0) as u16;
268            }
269        }
270
271        rects
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn vertical_fixed_lengths() {
281        let area = Rect::new(0, 0, 80, 24);
282        let rects =
283            Layout::vertical(vec![Constraint::Length(3), Constraint::Length(5)]).split(area);
284        assert_eq!(rects.len(), 2);
285        assert_eq!(rects[0], Rect::new(0, 0, 80, 3));
286        assert_eq!(rects[1], Rect::new(0, 3, 80, 5));
287    }
288
289    #[test]
290    fn fill_distributes_remaining() {
291        let area = Rect::new(0, 0, 80, 24);
292        let rects = Layout::vertical(vec![Constraint::Length(4), Constraint::Fill(1)]).split(area);
293        assert_eq!(rects[0].height, 4);
294        assert_eq!(rects[1].height, 20);
295    }
296
297    #[test]
298    fn horizontal_with_margin() {
299        let area = Rect::new(0, 0, 80, 24);
300        let rects =
301            Layout::horizontal(vec![Constraint::Percentage(50), Constraint::Percentage(50)])
302                .margin(Margin::uniform(1))
303                .split(area);
304        assert_eq!(rects[0].x, 1);
305        assert_eq!(rects[0].width + rects[1].width, 78);
306    }
307
308    #[test]
309    fn min_absorbs_remaining_space() {
310        let area = Rect::new(0, 0, 80, 24);
311        let rects = Layout::vertical(vec![Constraint::Min(0), Constraint::Length(3)]).split(area);
312        assert_eq!(rects[0].height, 21);
313        assert_eq!(rects[1].height, 3);
314        assert_eq!(rects[1].y, 21);
315    }
316}