Skip to main content

ratatui_toolkit/master_layout/
layout.rs

1//! Pane layout strategies
2
3use ratatui::layout::{Constraint, Direction, Layout, Rect};
4
5/// Layout strategy for arranging panes within a tab
6#[derive(Debug, Clone)]
7pub enum PaneLayout {
8    /// Horizontal split with percentage widths
9    /// Example: [50, 50] creates two panes side-by-side, each 50% width
10    Horizontal(Vec<u16>),
11
12    /// Vertical split with percentage heights
13    /// Example: [30, 70] creates two panes stacked, 30% and 70% height
14    Vertical(Vec<u16>),
15
16    /// Grid layout with specified rows and columns
17    /// Panes are filled left-to-right, top-to-bottom
18    Grid { rows: usize, cols: usize },
19
20    /// Custom layout function
21    /// Takes available area and returns areas for each pane
22    Custom(fn(Rect) -> Vec<Rect>),
23}
24
25impl PaneLayout {
26    /// Calculate areas for the given number of panes
27    pub fn calculate_areas(&self, available_area: Rect, pane_count: usize) -> Vec<Rect> {
28        match self {
29            PaneLayout::Horizontal(percentages) => {
30                self.calculate_horizontal(available_area, percentages, pane_count)
31            }
32            PaneLayout::Vertical(percentages) => {
33                self.calculate_vertical(available_area, percentages, pane_count)
34            }
35            PaneLayout::Grid { rows, cols } => {
36                self.calculate_grid(available_area, *rows, *cols, pane_count)
37            }
38            PaneLayout::Custom(func) => func(available_area),
39        }
40    }
41
42    fn calculate_horizontal(
43        &self,
44        available_area: Rect,
45        percentages: &[u16],
46        pane_count: usize,
47    ) -> Vec<Rect> {
48        // Use percentages if provided, otherwise split equally
49        let constraints: Vec<Constraint> = if percentages.is_empty() {
50            // Equal split
51            let percent = 100 / pane_count.max(1) as u16;
52            (0..pane_count)
53                .map(|_| Constraint::Percentage(percent))
54                .collect()
55        } else {
56            // Use provided percentages, pad with equal splits if needed
57            let mut constraints = percentages
58                .iter()
59                .map(|&p| Constraint::Percentage(p))
60                .collect::<Vec<_>>();
61
62            // If we have more panes than percentages, split remaining space equally
63            if pane_count > percentages.len() {
64                let used: u16 = percentages.iter().sum();
65                let remaining = 100_u16.saturating_sub(used);
66                let additional_panes = pane_count - percentages.len();
67                let each = remaining / additional_panes as u16;
68
69                for _ in 0..additional_panes {
70                    constraints.push(Constraint::Percentage(each));
71                }
72            }
73
74            constraints
75        };
76
77        let chunks = Layout::default()
78            .direction(Direction::Horizontal)
79            .constraints(constraints)
80            .split(available_area);
81
82        chunks.iter().copied().take(pane_count).collect()
83    }
84
85    fn calculate_vertical(
86        &self,
87        available_area: Rect,
88        percentages: &[u16],
89        pane_count: usize,
90    ) -> Vec<Rect> {
91        let constraints: Vec<Constraint> = if percentages.is_empty() {
92            let percent = 100 / pane_count.max(1) as u16;
93            (0..pane_count)
94                .map(|_| Constraint::Percentage(percent))
95                .collect()
96        } else {
97            let mut constraints = percentages
98                .iter()
99                .map(|&p| Constraint::Percentage(p))
100                .collect::<Vec<_>>();
101
102            if pane_count > percentages.len() {
103                let used: u16 = percentages.iter().sum();
104                let remaining = 100_u16.saturating_sub(used);
105                let additional_panes = pane_count - percentages.len();
106                let each = remaining / additional_panes as u16;
107
108                for _ in 0..additional_panes {
109                    constraints.push(Constraint::Percentage(each));
110                }
111            }
112
113            constraints
114        };
115
116        let chunks = Layout::default()
117            .direction(Direction::Vertical)
118            .constraints(constraints)
119            .split(available_area);
120
121        chunks.iter().copied().take(pane_count).collect()
122    }
123
124    fn calculate_grid(
125        &self,
126        available_area: Rect,
127        rows: usize,
128        cols: usize,
129        pane_count: usize,
130    ) -> Vec<Rect> {
131        let mut areas = Vec::new();
132
133        // Calculate cell size
134        let cell_height = available_area.height / rows.max(1) as u16;
135        let cell_width = available_area.width / cols.max(1) as u16;
136
137        // Generate grid positions
138        for i in 0..pane_count {
139            let row = i / cols;
140            let col = i % cols;
141
142            if row >= rows {
143                break; // Don't go beyond grid bounds
144            }
145
146            let x = available_area.x + (col as u16 * cell_width);
147            let y = available_area.y + (row as u16 * cell_height);
148
149            // Last column/row takes remaining space
150            let width = if col == cols - 1 {
151                available_area.width - (col as u16 * cell_width)
152            } else {
153                cell_width
154            };
155
156            let height = if row == rows - 1 {
157                available_area.height - (row as u16 * cell_height)
158            } else {
159                cell_height
160            };
161
162            areas.push(Rect::new(x, y, width, height));
163        }
164
165        areas
166    }
167}
168
169impl Default for PaneLayout {
170    fn default() -> Self {
171        // Default to equal horizontal split
172        PaneLayout::Horizontal(vec![])
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn test_horizontal_equal_split() {
182        let layout = PaneLayout::Horizontal(vec![]);
183        let area = Rect::new(0, 0, 100, 50);
184        let areas = layout.calculate_areas(area, 2);
185
186        assert_eq!(areas.len(), 2);
187        assert_eq!(areas[0].width, 50);
188        assert_eq!(areas[1].width, 50);
189    }
190
191    #[test]
192    fn test_horizontal_custom_percentages() {
193        let layout = PaneLayout::Horizontal(vec![30, 70]);
194        let area = Rect::new(0, 0, 100, 50);
195        let areas = layout.calculate_areas(area, 2);
196
197        assert_eq!(areas.len(), 2);
198        // Note: Layout may not be exact due to rounding
199        assert!(areas[0].width <= 30);
200        assert!(areas[1].width >= 70);
201    }
202
203    #[test]
204    fn test_vertical_equal_split() {
205        let layout = PaneLayout::Vertical(vec![]);
206        let area = Rect::new(0, 0, 100, 100);
207        let areas = layout.calculate_areas(area, 2);
208
209        assert_eq!(areas.len(), 2);
210        assert_eq!(areas[0].height, 50);
211        assert_eq!(areas[1].height, 50);
212    }
213
214    #[test]
215    fn test_vertical_custom_percentages() {
216        let layout = PaneLayout::Vertical(vec![25, 75]);
217        let area = Rect::new(0, 0, 100, 100);
218        let areas = layout.calculate_areas(area, 2);
219
220        assert_eq!(areas.len(), 2);
221        assert!(areas[0].height <= 25);
222        assert!(areas[1].height >= 75);
223    }
224
225    #[test]
226    fn test_grid_2x2() {
227        let layout = PaneLayout::Grid { rows: 2, cols: 2 };
228        let area = Rect::new(0, 0, 100, 100);
229        let areas = layout.calculate_areas(area, 4);
230
231        assert_eq!(areas.len(), 4);
232
233        // Check positions
234        assert_eq!(areas[0].x, 0);
235        assert_eq!(areas[0].y, 0);
236
237        assert_eq!(areas[1].x, 50);
238        assert_eq!(areas[1].y, 0);
239
240        assert_eq!(areas[2].x, 0);
241        assert_eq!(areas[2].y, 50);
242
243        assert_eq!(areas[3].x, 50);
244        assert_eq!(areas[3].y, 50);
245    }
246
247    #[test]
248    fn test_grid_incomplete() {
249        let layout = PaneLayout::Grid { rows: 2, cols: 2 };
250        let area = Rect::new(0, 0, 100, 100);
251        let areas = layout.calculate_areas(area, 3);
252
253        // Should only create 3 panes
254        assert_eq!(areas.len(), 3);
255    }
256
257    #[test]
258    fn test_grid_overflow() {
259        let layout = PaneLayout::Grid { rows: 2, cols: 2 };
260        let area = Rect::new(0, 0, 100, 100);
261        let areas = layout.calculate_areas(area, 10);
262
263        // Should only create 4 panes (2x2 grid max)
264        assert_eq!(areas.len(), 4);
265    }
266
267    #[test]
268    fn test_custom_layout() {
269        fn custom_layout(area: Rect) -> Vec<Rect> {
270            vec![
271                Rect::new(area.x, area.y, area.width / 3, area.height),
272                Rect::new(
273                    area.x + area.width / 3,
274                    area.y,
275                    area.width * 2 / 3,
276                    area.height,
277                ),
278            ]
279        }
280
281        let layout = PaneLayout::Custom(custom_layout);
282        let area = Rect::new(0, 0, 90, 50);
283        let areas = layout.calculate_areas(area, 2);
284
285        assert_eq!(areas.len(), 2);
286        assert_eq!(areas[0].width, 30);
287        assert_eq!(areas[1].width, 60);
288    }
289
290    #[test]
291    fn test_horizontal_more_panes_than_percentages() {
292        let layout = PaneLayout::Horizontal(vec![40]);
293        let area = Rect::new(0, 0, 100, 50);
294        let areas = layout.calculate_areas(area, 3);
295
296        assert_eq!(areas.len(), 3);
297        // First pane gets 40%, remaining 60% split between 2 panes
298    }
299
300    #[test]
301    fn test_vertical_more_panes_than_percentages() {
302        let layout = PaneLayout::Vertical(vec![30]);
303        let area = Rect::new(0, 0, 100, 100);
304        let areas = layout.calculate_areas(area, 3);
305
306        assert_eq!(areas.len(), 3);
307        // First pane gets 30%, remaining 70% split between 2 panes
308    }
309
310    #[test]
311    fn test_default_layout() {
312        let layout = PaneLayout::default();
313        let area = Rect::new(0, 0, 100, 50);
314        let areas = layout.calculate_areas(area, 3);
315
316        // Default is horizontal equal split
317        assert_eq!(areas.len(), 3);
318        // Each pane should get approximately 33% of the width
319        for area in areas {
320            assert!(area.width > 0);
321        }
322    }
323}