Skip to main content

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