npm_run_scripts/tui/
layout.rs

1//! Layout calculations for the TUI.
2
3use ratatui::layout::{Constraint, Direction, Layout, Rect};
4
5use crate::config::AppearanceConfig;
6
7/// Minimum terminal dimensions.
8pub const MIN_WIDTH: u16 = 40;
9pub const MIN_HEIGHT: u16 = 10;
10
11/// Main layout areas.
12#[derive(Debug, Clone, Copy)]
13pub struct MainLayout {
14    /// Header area.
15    pub header: Rect,
16    /// Filter bar area.
17    pub filter: Rect,
18    /// Scripts grid area.
19    pub scripts: Rect,
20    /// Description panel area.
21    pub description: Rect,
22    /// Footer area.
23    pub footer: Rect,
24}
25
26impl MainLayout {
27    /// Calculate the main layout for the given area with default settings.
28    pub fn new(area: Rect) -> Self {
29        Self::with_config(area, &AppearanceConfig::default())
30    }
31
32    /// Calculate the main layout with configuration options.
33    pub fn with_config(area: Rect, config: &AppearanceConfig) -> Self {
34        // Handle minimum terminal size
35        if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
36            return Self::minimal_layout(area);
37        }
38
39        let (header_height, filter_height, desc_height, footer_height) = if config.compact {
40            (1, 1, 2, if config.show_footer { 1 } else { 0 })
41        } else {
42            (1, 1, 4, if config.show_footer { 1 } else { 0 })
43        };
44
45        let constraints = if config.show_footer {
46            vec![
47                Constraint::Length(header_height),
48                Constraint::Length(filter_height),
49                Constraint::Min(3), // Scripts (flexible, minimum 3 rows)
50                Constraint::Length(desc_height),
51                Constraint::Length(footer_height),
52            ]
53        } else {
54            vec![
55                Constraint::Length(header_height),
56                Constraint::Length(filter_height),
57                Constraint::Min(3),
58                Constraint::Length(desc_height),
59                Constraint::Length(0), // No footer
60            ]
61        };
62
63        let chunks = Layout::default()
64            .direction(Direction::Vertical)
65            .constraints(constraints)
66            .split(area);
67
68        Self {
69            header: chunks[0],
70            filter: chunks[1],
71            scripts: chunks[2],
72            description: chunks[3],
73            footer: chunks[4],
74        }
75    }
76
77    /// Create minimal layout for small terminals.
78    fn minimal_layout(area: Rect) -> Self {
79        let chunks = Layout::default()
80            .direction(Direction::Vertical)
81            .constraints([
82                Constraint::Length(1), // Header
83                Constraint::Length(1), // Filter
84                Constraint::Min(1),    // Scripts
85                Constraint::Length(1), // Description
86                Constraint::Length(1), // Footer
87            ])
88            .split(area);
89
90        Self {
91            header: chunks[0],
92            filter: chunks[1],
93            scripts: chunks[2],
94            description: chunks[3],
95            footer: chunks[4],
96        }
97    }
98
99    /// Calculate available rows for scripts.
100    pub fn script_rows(&self) -> usize {
101        self.scripts.height as usize
102    }
103
104    /// Check if the terminal is too small.
105    pub fn is_too_small(&self) -> bool {
106        self.scripts.height < 1
107    }
108}
109
110/// Calculate grid columns based on terminal width.
111pub fn calculate_columns(width: u16) -> usize {
112    match width {
113        0..=59 => 1,
114        60..=89 => 2,
115        90..=119 => 3,
116        120..=159 => 4,
117        _ => 5,
118    }
119}
120
121/// Calculate column width for the scripts grid.
122pub fn calculate_column_width(total_width: u16, columns: usize) -> u16 {
123    if columns == 0 {
124        return total_width;
125    }
126    let padding = 2; // Left and right padding
127    let available = total_width.saturating_sub(padding);
128    let gap_count = columns.saturating_sub(1) as u16;
129    let gaps_width = gap_count; // 1 char gap between columns
130    available.saturating_sub(gaps_width) / columns as u16
131}
132
133/// Calculate grid layout for scripts.
134#[derive(Debug, Clone, Copy)]
135pub struct GridLayout {
136    /// Number of columns.
137    pub columns: usize,
138    /// Number of rows.
139    pub rows: usize,
140    /// Width of each column.
141    pub column_width: u16,
142    /// Total visible items.
143    pub visible_items: usize,
144}
145
146impl GridLayout {
147    /// Create a new grid layout.
148    pub fn new(area: Rect, total_items: usize) -> Self {
149        let columns = calculate_columns(area.width);
150        let column_width = calculate_column_width(area.width, columns);
151        let rows = area.height as usize;
152        let visible_items = (rows * columns).min(total_items);
153
154        Self {
155            columns,
156            rows,
157            column_width,
158            visible_items,
159        }
160    }
161
162    /// Get the row and column for a given index.
163    pub fn position(&self, index: usize) -> (usize, usize) {
164        let row = index / self.columns;
165        let col = index % self.columns;
166        (row, col)
167    }
168
169    /// Get the index for a given row and column.
170    pub fn index(&self, row: usize, col: usize) -> usize {
171        row * self.columns + col
172    }
173
174    /// Check if an index is visible.
175    pub fn is_visible(&self, index: usize, scroll_offset: usize) -> bool {
176        index >= scroll_offset && index < scroll_offset + self.visible_items
177    }
178
179    /// Get the display position for an index (accounting for scroll).
180    pub fn display_position(&self, index: usize, scroll_offset: usize) -> Option<(usize, usize)> {
181        if self.is_visible(index, scroll_offset) {
182            let display_index = index - scroll_offset;
183            Some(self.position(display_index))
184        } else {
185            None
186        }
187    }
188}
189
190/// Create a centered rectangle for popups/overlays.
191pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
192    let popup_layout = Layout::default()
193        .direction(Direction::Vertical)
194        .constraints([
195            Constraint::Percentage((100 - percent_y) / 2),
196            Constraint::Percentage(percent_y),
197            Constraint::Percentage((100 - percent_y) / 2),
198        ])
199        .split(area);
200
201    Layout::default()
202        .direction(Direction::Horizontal)
203        .constraints([
204            Constraint::Percentage((100 - percent_x) / 2),
205            Constraint::Percentage(percent_x),
206            Constraint::Percentage((100 - percent_x) / 2),
207        ])
208        .split(popup_layout[1])[1]
209}
210
211/// Create a fixed-size centered rectangle for popups/overlays.
212pub fn centered_rect_fixed(width: u16, height: u16, area: Rect) -> Rect {
213    let actual_width = width.min(area.width);
214    let actual_height = height.min(area.height);
215
216    let x = area.x + (area.width.saturating_sub(actual_width)) / 2;
217    let y = area.y + (area.height.saturating_sub(actual_height)) / 2;
218
219    Rect::new(x, y, actual_width, actual_height)
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn test_main_layout_default() {
228        let area = Rect::new(0, 0, 100, 30);
229        let layout = MainLayout::new(area);
230
231        assert_eq!(layout.header.height, 1);
232        assert_eq!(layout.filter.height, 1);
233        assert!(layout.scripts.height >= 3);
234        assert_eq!(layout.description.height, 4);
235        assert_eq!(layout.footer.height, 1);
236    }
237
238    #[test]
239    fn test_main_layout_compact() {
240        let area = Rect::new(0, 0, 100, 30);
241        let config = AppearanceConfig {
242            compact: true,
243            ..Default::default()
244        };
245        let layout = MainLayout::with_config(area, &config);
246
247        assert_eq!(layout.description.height, 2);
248    }
249
250    #[test]
251    fn test_main_layout_no_footer() {
252        let area = Rect::new(0, 0, 100, 30);
253        let config = AppearanceConfig {
254            show_footer: false,
255            ..Default::default()
256        };
257        let layout = MainLayout::with_config(area, &config);
258
259        assert_eq!(layout.footer.height, 0);
260    }
261
262    #[test]
263    fn test_main_layout_small_terminal() {
264        let area = Rect::new(0, 0, 30, 8);
265        let layout = MainLayout::new(area);
266
267        // Should create minimal layout
268        assert_eq!(layout.header.height, 1);
269        assert_eq!(layout.filter.height, 1);
270        assert_eq!(layout.description.height, 1);
271        assert_eq!(layout.footer.height, 1);
272    }
273
274    #[test]
275    fn test_calculate_columns() {
276        assert_eq!(calculate_columns(40), 1);
277        assert_eq!(calculate_columns(59), 1);
278        assert_eq!(calculate_columns(60), 2);
279        assert_eq!(calculate_columns(89), 2);
280        assert_eq!(calculate_columns(90), 3);
281        assert_eq!(calculate_columns(119), 3);
282        assert_eq!(calculate_columns(120), 4);
283        assert_eq!(calculate_columns(159), 4);
284        assert_eq!(calculate_columns(160), 5);
285    }
286
287    #[test]
288    fn test_calculate_column_width() {
289        assert_eq!(calculate_column_width(100, 2), 48);
290        assert_eq!(calculate_column_width(120, 3), 38);
291        assert_eq!(calculate_column_width(60, 1), 58);
292        assert_eq!(calculate_column_width(50, 0), 50);
293    }
294
295    #[test]
296    fn test_grid_layout_position() {
297        let area = Rect::new(0, 0, 100, 10);
298        let grid = GridLayout::new(area, 30);
299
300        assert_eq!(grid.columns, 3);
301        assert_eq!(grid.rows, 10);
302
303        assert_eq!(grid.position(0), (0, 0));
304        assert_eq!(grid.position(1), (0, 1));
305        assert_eq!(grid.position(2), (0, 2));
306        assert_eq!(grid.position(3), (1, 0));
307        assert_eq!(grid.position(5), (1, 2));
308    }
309
310    #[test]
311    fn test_grid_layout_index() {
312        let area = Rect::new(0, 0, 100, 10);
313        let grid = GridLayout::new(area, 30);
314
315        assert_eq!(grid.index(0, 0), 0);
316        assert_eq!(grid.index(0, 2), 2);
317        assert_eq!(grid.index(1, 0), 3);
318        assert_eq!(grid.index(2, 1), 7);
319    }
320
321    #[test]
322    fn test_grid_layout_visibility() {
323        let area = Rect::new(0, 0, 60, 5); // 2 columns, 5 rows = 10 visible
324        let grid = GridLayout::new(area, 20);
325
326        assert_eq!(grid.visible_items, 10);
327        assert!(grid.is_visible(0, 0));
328        assert!(grid.is_visible(9, 0));
329        assert!(!grid.is_visible(10, 0));
330
331        // With scroll offset
332        assert!(!grid.is_visible(0, 5));
333        assert!(grid.is_visible(5, 5));
334        assert!(grid.is_visible(14, 5));
335    }
336
337    #[test]
338    fn test_centered_rect() {
339        let area = Rect::new(0, 0, 100, 50);
340        let centered = centered_rect(50, 50, area);
341
342        // Should be roughly centered
343        assert!(centered.x >= 20 && centered.x <= 30);
344        assert!(centered.y >= 10 && centered.y <= 15);
345        assert!(centered.width >= 45 && centered.width <= 55);
346        assert!(centered.height >= 22 && centered.height <= 28);
347    }
348
349    #[test]
350    fn test_centered_rect_fixed() {
351        let area = Rect::new(0, 0, 100, 50);
352        let centered = centered_rect_fixed(40, 20, area);
353
354        assert_eq!(centered.width, 40);
355        assert_eq!(centered.height, 20);
356        assert_eq!(centered.x, 30); // (100 - 40) / 2
357        assert_eq!(centered.y, 15); // (50 - 20) / 2
358    }
359
360    #[test]
361    fn test_centered_rect_fixed_clamps_to_area() {
362        let area = Rect::new(0, 0, 30, 20);
363        let centered = centered_rect_fixed(100, 50, area);
364
365        assert_eq!(centered.width, 30);
366        assert_eq!(centered.height, 20);
367        assert_eq!(centered.x, 0);
368        assert_eq!(centered.y, 0);
369    }
370}