Skip to main content

vfa_tui/ui/
layout.rs

1use ratatui::layout::{Constraint, Direction, Layout, Rect};
2
3/// Computed layout regions for the application.
4///
5/// The layout is deterministic: identical terminal dimensions always produce
6/// identical region coordinates (Requirement 18.1). No randomness, no caches,
7/// no environment variables influence the computation — only the terminal
8/// width and height (passed via `area`).
9pub struct AppLayout {
10    pub sidebar: Rect,
11    pub main_content: Rect,
12    pub status_bar: Rect,
13    pub help_bar: Rect,
14}
15
16/// Fixed sidebar width for wide terminals (>80 columns).
17const SIDEBAR_WIDTH_WIDE: u16 = 20;
18/// Sidebar width for medium terminals (41–80 columns).
19const SIDEBAR_WIDTH_MEDIUM: u16 = 16;
20/// Sidebar width for narrow terminals (≤40 columns).
21const SIDEBAR_WIDTH_NARROW: u16 = 12;
22
23/// Status bar height (always 1 line at the bottom).
24const STATUS_BAR_HEIGHT: u16 = 1;
25/// Help bar height (always 1 line at the very bottom).
26const HELP_BAR_HEIGHT: u16 = 1;
27
28/// Minimum main content width before sidebar is hidden.
29const MIN_CONTENT_WIDTH: u16 = 10;
30
31/// Compute the layout regions given the terminal area.
32///
33/// Layout structure (Requirement 16.3):
34/// - Vertical: main area | status bar (1 line) | help bar (1 line)
35/// - Main area horizontal: sidebar (fixed width ~20 chars) | content (remaining)
36///
37/// The sidebar width adapts to terminal width:
38/// - >80 columns: 20 chars
39/// - 41–80 columns: 16 chars
40/// - ≤40 columns: 12 chars
41///
42/// This function is pure — same `area` always produces the same `AppLayout`.
43pub fn compute_layout(area: Rect) -> AppLayout {
44    // Vertical split: main | status_bar | help_bar
45    let vertical = Layout::default()
46        .direction(Direction::Vertical)
47        .constraints([
48            Constraint::Min(3),
49            Constraint::Length(STATUS_BAR_HEIGHT),
50            Constraint::Length(HELP_BAR_HEIGHT),
51        ])
52        .split(area);
53
54    let main_area = vertical[0];
55    let status_bar = vertical[1];
56    let help_bar = vertical[2];
57
58    // Horizontal split of main area: sidebar | content
59    let sidebar_width = compute_sidebar_width(main_area.width);
60
61    let horizontal = Layout::default()
62        .direction(Direction::Horizontal)
63        .constraints([
64            Constraint::Length(sidebar_width),
65            Constraint::Min(MIN_CONTENT_WIDTH),
66        ])
67        .split(main_area);
68
69    let sidebar = horizontal[0];
70    let main_content = horizontal[1];
71
72    AppLayout {
73        sidebar,
74        main_content,
75        status_bar,
76        help_bar,
77    }
78}
79
80/// Compute the sidebar width based on available terminal width.
81///
82/// Returns a fixed width that adapts to the terminal size:
83/// - Wide (>80): 20 columns
84/// - Medium (41–80): 16 columns
85/// - Narrow (≤40): 12 columns
86fn compute_sidebar_width(terminal_width: u16) -> u16 {
87    if terminal_width > 80 {
88        SIDEBAR_WIDTH_WIDE
89    } else if terminal_width > 40 {
90        SIDEBAR_WIDTH_MEDIUM
91    } else {
92        SIDEBAR_WIDTH_NARROW
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn compute_layout_standard_terminal() {
102        let area = Rect::new(0, 0, 120, 40);
103        let layout = compute_layout(area);
104        assert_eq!(layout.sidebar.width, 20);
105        assert_eq!(layout.main_content.x, 20);
106        assert_eq!(layout.status_bar.height, 1);
107        assert_eq!(layout.help_bar.height, 1);
108        assert_eq!(layout.status_bar.y + 1, layout.help_bar.y);
109    }
110
111    #[test]
112    fn compute_layout_small_terminal() {
113        let area = Rect::new(0, 0, 50, 20);
114        let layout = compute_layout(area);
115        assert_eq!(layout.sidebar.width, 16);
116        assert!(layout.main_content.width > 0);
117    }
118
119    #[test]
120    fn compute_layout_very_small_terminal() {
121        let area = Rect::new(0, 0, 30, 10);
122        let layout = compute_layout(area);
123        assert_eq!(layout.sidebar.width, 12);
124        assert!(layout.main_content.width > 0);
125    }
126
127    #[test]
128    fn compute_layout_minimum_viable() {
129        let area = Rect::new(0, 0, 20, 5);
130        let layout = compute_layout(area);
131        // Should not panic even with tiny terminal
132        assert!(layout.sidebar.width > 0 || layout.main_content.width > 0);
133    }
134
135    #[test]
136    fn compute_layout_deterministic() {
137        // Requirement 18.1: same dimensions produce identical layout
138        let area = Rect::new(0, 0, 100, 30);
139        let layout1 = compute_layout(area);
140        let layout2 = compute_layout(area);
141        assert_eq!(layout1.sidebar, layout2.sidebar);
142        assert_eq!(layout1.main_content, layout2.main_content);
143        assert_eq!(layout1.status_bar, layout2.status_bar);
144        assert_eq!(layout1.help_bar, layout2.help_bar);
145    }
146
147    #[test]
148    fn status_bar_is_second_to_last_line() {
149        let area = Rect::new(0, 0, 80, 24);
150        let layout = compute_layout(area);
151        // Status bar is 1 line above help bar
152        assert_eq!(layout.status_bar.y, area.height - 2);
153        assert_eq!(layout.help_bar.y, area.height - 1);
154    }
155
156    #[test]
157    fn help_bar_is_last_line() {
158        let area = Rect::new(0, 0, 80, 24);
159        let layout = compute_layout(area);
160        assert_eq!(layout.help_bar.y + layout.help_bar.height, area.height);
161    }
162
163    #[test]
164    fn sidebar_width_constants() {
165        assert_eq!(compute_sidebar_width(120), SIDEBAR_WIDTH_WIDE);
166        assert_eq!(compute_sidebar_width(81), SIDEBAR_WIDTH_WIDE);
167        assert_eq!(compute_sidebar_width(80), SIDEBAR_WIDTH_MEDIUM);
168        assert_eq!(compute_sidebar_width(41), SIDEBAR_WIDTH_MEDIUM);
169        assert_eq!(compute_sidebar_width(40), SIDEBAR_WIDTH_NARROW);
170        assert_eq!(compute_sidebar_width(20), SIDEBAR_WIDTH_NARROW);
171    }
172
173    #[test]
174    fn main_content_fills_remaining_width() {
175        let area = Rect::new(0, 0, 100, 30);
176        let layout = compute_layout(area);
177        assert_eq!(layout.sidebar.width + layout.main_content.width, area.width);
178    }
179}