Skip to main content

agent_air_tui/layout/
standard.rs

1//! Standard vertical layout template
2
3use ratatui::layout::{Constraint, Direction, Layout};
4
5use crate::widgets::widget_ids;
6
7use super::types::{LayoutContext, LayoutResult, WidgetSizes};
8
9/// Options for the standard vertical layout
10#[derive(Clone)]
11pub struct StandardOptions {
12    /// Widget ID for the main content area (default: CHAT_VIEW)
13    pub main_widget_id: &'static str,
14    /// Widget ID for the input area (default: TEXT_INPUT)
15    pub input_widget_id: &'static str,
16    /// Widget IDs for panel widgets (shown between main and input when active)
17    pub panel_widget_ids: Vec<&'static str>,
18    /// Widget IDs for popup widgets (shown above input when active)
19    pub popup_widget_ids: Vec<&'static str>,
20    /// Widget IDs for overlay widgets (rendered on top of everything)
21    pub overlay_widget_ids: Vec<&'static str>,
22    /// Minimum height for the main content area
23    pub min_main_height: u16,
24    /// Fixed input height (None = auto-size from content)
25    pub fixed_input_height: Option<u16>,
26    /// Widget ID for the status bar (None = no status bar)
27    pub status_bar_widget_id: Option<&'static str>,
28}
29
30impl Default for StandardOptions {
31    fn default() -> Self {
32        Self {
33            main_widget_id: widget_ids::CHAT_VIEW,
34            input_widget_id: widget_ids::TEXT_INPUT,
35            panel_widget_ids: vec![
36                widget_ids::PERMISSION_PANEL,
37                widget_ids::BATCH_PERMISSION_PANEL,
38                widget_ids::QUESTION_PANEL,
39            ],
40            popup_widget_ids: vec![widget_ids::SLASH_POPUP],
41            overlay_widget_ids: vec![widget_ids::THEME_PICKER, widget_ids::SESSION_PICKER],
42            min_main_height: 5,
43            fixed_input_height: None,
44            status_bar_widget_id: Some(widget_ids::STATUS_BAR),
45        }
46    }
47}
48
49/// Compute the standard layout
50pub fn compute(ctx: &LayoutContext, sizes: &WidgetSizes, opts: &StandardOptions) -> LayoutResult {
51    let mut result = LayoutResult::default();
52    let area = ctx.frame_area;
53
54    // Calculate heights for dynamic elements
55    let input_height = opts.fixed_input_height.unwrap_or_else(|| {
56        if ctx.show_throbber {
57            3
58        } else {
59            (ctx.input_visual_lines as u16).max(1) + 2
60        }
61    });
62
63    // Calculate panel height (active panels only)
64    let panel_height: u16 = opts
65        .panel_widget_ids
66        .iter()
67        .filter(|id| sizes.is_active(id))
68        .map(|id| sizes.height(id))
69        .sum();
70
71    // Calculate popup height (active popups only)
72    let popup_height: u16 = opts
73        .popup_widget_ids
74        .iter()
75        .filter(|id| sizes.is_active(id))
76        .map(|id| sizes.height(id))
77        .sum();
78
79    // Build constraints
80    let mut constraints = vec![Constraint::Min(opts.min_main_height)]; // Main
81
82    if panel_height > 0 {
83        constraints.push(Constraint::Length(panel_height));
84    }
85
86    if popup_height > 0 {
87        constraints.push(Constraint::Length(popup_height));
88    }
89
90    constraints.push(Constraint::Length(input_height)); // Input
91
92    // Status bar
93    if let Some(status_id) = opts.status_bar_widget_id
94        && sizes.is_active(status_id)
95    {
96        constraints.push(Constraint::Length(sizes.height(status_id)));
97    }
98
99    // Apply layout
100    let chunks = Layout::default()
101        .direction(Direction::Vertical)
102        .constraints(constraints)
103        .split(area);
104
105    // Map chunks to widgets
106    let mut chunk_idx = 0;
107
108    // Main content
109    result
110        .widget_areas
111        .insert(opts.main_widget_id, chunks[chunk_idx]);
112    result.render_order.push(opts.main_widget_id);
113    chunk_idx += 1;
114
115    // Panels (split evenly if multiple active)
116    if panel_height > 0 {
117        let active_panels: Vec<_> = opts
118            .panel_widget_ids
119            .iter()
120            .filter(|id| sizes.is_active(id))
121            .collect();
122
123        if active_panels.len() == 1 {
124            result
125                .widget_areas
126                .insert(active_panels[0], chunks[chunk_idx]);
127            result.render_order.push(active_panels[0]);
128        } else if !active_panels.is_empty() {
129            // Split panel area among active panels
130            let panel_constraints: Vec<_> = active_panels
131                .iter()
132                .map(|id| Constraint::Length(sizes.height(id)))
133                .collect();
134            let panel_chunks = Layout::default()
135                .direction(Direction::Vertical)
136                .constraints(panel_constraints)
137                .split(chunks[chunk_idx]);
138
139            for (i, id) in active_panels.iter().enumerate() {
140                result.widget_areas.insert(id, panel_chunks[i]);
141                result.render_order.push(id);
142            }
143        }
144        chunk_idx += 1;
145    }
146
147    // Popups
148    if popup_height > 0 {
149        let active_popups: Vec<_> = opts
150            .popup_widget_ids
151            .iter()
152            .filter(|id| sizes.is_active(id))
153            .collect();
154
155        for id in active_popups {
156            result.widget_areas.insert(id, chunks[chunk_idx]);
157            result.render_order.push(id);
158        }
159        chunk_idx += 1;
160    }
161
162    // Input
163    result
164        .widget_areas
165        .insert(opts.input_widget_id, chunks[chunk_idx]);
166    result.input_area = Some(chunks[chunk_idx]);
167    result.render_order.push(opts.input_widget_id);
168    chunk_idx += 1;
169
170    // Status bar
171    if let Some(status_id) = opts.status_bar_widget_id
172        && sizes.is_active(status_id)
173    {
174        result.widget_areas.insert(status_id, chunks[chunk_idx]);
175        result.render_order.push(status_id);
176    }
177
178    // Overlays (use full frame area, added last to render on top)
179    for id in &opts.overlay_widget_ids {
180        if sizes.is_active(id) {
181            result.widget_areas.insert(id, area);
182            result.render_order.push(id);
183        }
184    }
185
186    result
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::themes::Theme;
193    use std::collections::{HashMap, HashSet};
194
195    fn test_context(area: ratatui::layout::Rect) -> LayoutContext<'static> {
196        static THEME: std::sync::LazyLock<Theme> = std::sync::LazyLock::new(Theme::default);
197        LayoutContext {
198            frame_area: area,
199            show_throbber: false,
200            input_visual_lines: 1,
201            theme: &THEME,
202            active_widgets: HashSet::new(),
203        }
204    }
205
206    fn test_sizes() -> WidgetSizes {
207        WidgetSizes {
208            heights: HashMap::new(),
209            is_active: HashMap::new(),
210        }
211    }
212
213    #[test]
214    fn test_standard_layout() {
215        use ratatui::layout::Rect;
216
217        let area = Rect::new(0, 0, 80, 24);
218        let ctx = test_context(area);
219        let sizes = test_sizes();
220
221        let result = compute(&ctx, &sizes, &StandardOptions::default());
222
223        assert!(result.widget_areas.contains_key(widget_ids::CHAT_VIEW));
224        assert!(result.widget_areas.contains_key(widget_ids::TEXT_INPUT));
225        // Status bar is now a regular widget, but won't be in widget_areas if not active
226        // (sizes.is_active returns false by default in test_sizes)
227    }
228}