agent_core/tui/layout/
standard.rs

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