Skip to main content

spec_ai/spec_ai_tui/layout/
flex.rs

1//! Flexbox-like layout engine
2
3use super::{Constraint, Direction};
4use crate::spec_ai_tui::geometry::Rect;
5
6/// Layout builder for arranging widgets
7#[derive(Debug, Clone)]
8pub struct Layout {
9    direction: Direction,
10    constraints: Vec<Constraint>,
11    margin: u16,
12    spacing: u16,
13}
14
15impl Layout {
16    /// Create a new horizontal layout
17    pub fn horizontal() -> Self {
18        Self {
19            direction: Direction::Horizontal,
20            constraints: Vec::new(),
21            margin: 0,
22            spacing: 0,
23        }
24    }
25
26    /// Create a new vertical layout
27    pub fn vertical() -> Self {
28        Self {
29            direction: Direction::Vertical,
30            constraints: Vec::new(),
31            margin: 0,
32            spacing: 0,
33        }
34    }
35
36    /// Create a new layout with the given direction
37    pub fn new(direction: Direction) -> Self {
38        Self {
39            direction,
40            constraints: Vec::new(),
41            margin: 0,
42            spacing: 0,
43        }
44    }
45
46    /// Set the direction
47    pub fn direction(mut self, direction: Direction) -> Self {
48        self.direction = direction;
49        self
50    }
51
52    /// Set the constraints
53    pub fn constraints<I: IntoIterator<Item = Constraint>>(mut self, constraints: I) -> Self {
54        self.constraints = constraints.into_iter().collect();
55        self
56    }
57
58    /// Set the margin (applied to all sides)
59    pub fn margin(mut self, margin: u16) -> Self {
60        self.margin = margin;
61        self
62    }
63
64    /// Set the spacing between elements
65    pub fn spacing(mut self, spacing: u16) -> Self {
66        self.spacing = spacing;
67        self
68    }
69
70    /// Split an area according to constraints
71    pub fn split(&self, area: Rect) -> Vec<Rect> {
72        // Apply margin
73        let inner = area.inner(self.margin);
74        if inner.is_empty() || self.constraints.is_empty() {
75            return vec![];
76        }
77
78        // Determine the dimension we're splitting
79        let (total_space, cross_size) = match self.direction {
80            Direction::Horizontal => (inner.width, inner.height),
81            Direction::Vertical => (inner.height, inner.width),
82        };
83
84        // Account for spacing between elements
85        let num_gaps = self.constraints.len().saturating_sub(1) as u16;
86        let spacing_total = self.spacing * num_gaps;
87        let available = total_space.saturating_sub(spacing_total);
88
89        // First pass: resolve fixed constraints and count fill weights
90        let mut sizes: Vec<u16> = vec![0; self.constraints.len()];
91        let mut remaining = available;
92        let mut total_fill_weight = 0u32;
93
94        for (i, constraint) in self.constraints.iter().enumerate() {
95            // Percentages and ratios should be calculated from total available, not remaining
96            let resolve_base = match constraint {
97                Constraint::Percentage(_) | Constraint::Ratio(_, _) => available,
98                _ => remaining,
99            };
100            let (size, is_fill) = constraint.resolve(resolve_base);
101            if is_fill {
102                total_fill_weight += constraint.fill_weight() as u32;
103            } else {
104                sizes[i] = size;
105                remaining = remaining.saturating_sub(size);
106            }
107        }
108
109        // Second pass: distribute remaining space to Fill constraints
110        if total_fill_weight > 0 && remaining > 0 {
111            let fill_space = remaining;
112            let mut distributed = 0u16;
113
114            for (i, constraint) in self.constraints.iter().enumerate() {
115                if let Constraint::Fill(weight) = constraint {
116                    // Calculate this fill's share
117                    let share = (fill_space as u32 * *weight as u32 / total_fill_weight) as u16;
118                    sizes[i] = share;
119                    distributed += share;
120                }
121            }
122
123            // Distribute any rounding remainder to the last Fill
124            let leftover = fill_space.saturating_sub(distributed);
125            if leftover > 0 {
126                for (i, constraint) in self.constraints.iter().enumerate().rev() {
127                    if matches!(constraint, Constraint::Fill(_)) {
128                        sizes[i] = sizes[i].saturating_add(leftover);
129                        break;
130                    }
131                }
132            }
133        }
134
135        // Build result rectangles
136        let mut result = Vec::with_capacity(self.constraints.len());
137        let mut offset = match self.direction {
138            Direction::Horizontal => inner.x,
139            Direction::Vertical => inner.y,
140        };
141
142        for (i, size) in sizes.into_iter().enumerate() {
143            let rect = match self.direction {
144                Direction::Horizontal => Rect::new(offset, inner.y, size, cross_size),
145                Direction::Vertical => Rect::new(inner.x, offset, cross_size, size),
146            };
147            result.push(rect);
148
149            offset = offset.saturating_add(size);
150            if i < self.constraints.len() - 1 {
151                offset = offset.saturating_add(self.spacing);
152            }
153        }
154
155        result
156    }
157}
158
159impl Default for Layout {
160    fn default() -> Self {
161        Self::vertical()
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_vertical_split_fixed() {
171        let area = Rect::new(0, 0, 100, 50);
172        let chunks = Layout::vertical()
173            .constraints([
174                Constraint::Fixed(10),
175                Constraint::Fixed(20),
176                Constraint::Fixed(10),
177            ])
178            .split(area);
179
180        assert_eq!(chunks.len(), 3);
181        assert_eq!(chunks[0], Rect::new(0, 0, 100, 10));
182        assert_eq!(chunks[1], Rect::new(0, 10, 100, 20));
183        assert_eq!(chunks[2], Rect::new(0, 30, 100, 10));
184    }
185
186    #[test]
187    fn test_vertical_split_with_fill() {
188        let area = Rect::new(0, 0, 100, 50);
189        let chunks = Layout::vertical()
190            .constraints([
191                Constraint::Fixed(10),
192                Constraint::Fill(1),
193                Constraint::Fixed(5),
194            ])
195            .split(area);
196
197        assert_eq!(chunks.len(), 3);
198        assert_eq!(chunks[0].height, 10);
199        assert_eq!(chunks[1].height, 35); // 50 - 10 - 5
200        assert_eq!(chunks[2].height, 5);
201    }
202
203    #[test]
204    fn test_horizontal_split() {
205        let area = Rect::new(0, 0, 100, 50);
206        let chunks = Layout::horizontal()
207            .constraints([Constraint::Percentage(30), Constraint::Fill(1)])
208            .split(area);
209
210        assert_eq!(chunks.len(), 2);
211        assert_eq!(chunks[0].width, 30);
212        assert_eq!(chunks[1].width, 70);
213        assert_eq!(chunks[0].height, 50);
214        assert_eq!(chunks[1].height, 50);
215    }
216
217    #[test]
218    fn test_multiple_fills() {
219        let area = Rect::new(0, 0, 100, 100);
220        let chunks = Layout::vertical()
221            .constraints([
222                Constraint::Fill(1),
223                Constraint::Fill(2),
224                Constraint::Fill(1),
225            ])
226            .split(area);
227
228        assert_eq!(chunks.len(), 3);
229        // Total weight = 4, so 1/4, 2/4, 1/4
230        assert_eq!(chunks[0].height, 25);
231        assert_eq!(chunks[1].height, 50);
232        assert_eq!(chunks[2].height, 25);
233    }
234
235    #[test]
236    fn test_with_margin() {
237        let area = Rect::new(0, 0, 100, 50);
238        let chunks = Layout::vertical()
239            .margin(5)
240            .constraints([Constraint::Fill(1)])
241            .split(area);
242
243        assert_eq!(chunks.len(), 1);
244        assert_eq!(chunks[0], Rect::new(5, 5, 90, 40)); // Margin applied
245    }
246
247    #[test]
248    fn test_with_spacing() {
249        let area = Rect::new(0, 0, 100, 50);
250        let chunks = Layout::vertical()
251            .spacing(2)
252            .constraints([
253                Constraint::Fixed(10),
254                Constraint::Fixed(10),
255                Constraint::Fixed(10),
256            ])
257            .split(area);
258
259        assert_eq!(chunks.len(), 3);
260        assert_eq!(chunks[0].y, 0);
261        assert_eq!(chunks[1].y, 12); // 10 + 2 spacing
262        assert_eq!(chunks[2].y, 24); // 12 + 10 + 2 spacing
263    }
264
265    #[test]
266    fn test_percentage() {
267        let area = Rect::new(0, 0, 100, 100);
268        let chunks = Layout::vertical()
269            .constraints([
270                Constraint::Percentage(25),
271                Constraint::Percentage(50),
272                Constraint::Percentage(25),
273            ])
274            .split(area);
275
276        assert_eq!(chunks[0].height, 25);
277        assert_eq!(chunks[1].height, 50);
278        assert_eq!(chunks[2].height, 25);
279    }
280
281    #[test]
282    fn test_empty_constraints() {
283        let area = Rect::new(0, 0, 100, 50);
284        let chunks = Layout::vertical().constraints([]).split(area);
285        assert!(chunks.is_empty());
286    }
287
288    #[test]
289    fn test_empty_area() {
290        let area = Rect::new(0, 0, 0, 0);
291        let chunks = Layout::vertical()
292            .constraints([Constraint::Fill(1)])
293            .split(area);
294        assert!(chunks.is_empty());
295    }
296}