agent_air_tui/layout/
standard.rs1use ratatui::layout::{Constraint, Direction, Layout};
4
5use crate::widgets::widget_ids;
6
7use super::types::{LayoutContext, LayoutResult, WidgetSizes};
8
9#[derive(Clone)]
11pub struct StandardOptions {
12 pub main_widget_id: &'static str,
14 pub input_widget_id: &'static str,
16 pub panel_widget_ids: Vec<&'static str>,
18 pub popup_widget_ids: Vec<&'static str>,
20 pub overlay_widget_ids: Vec<&'static str>,
22 pub min_main_height: u16,
24 pub fixed_input_height: Option<u16>,
26 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
49pub fn compute(ctx: &LayoutContext, sizes: &WidgetSizes, opts: &StandardOptions) -> LayoutResult {
51 let mut result = LayoutResult::default();
52 let area = ctx.frame_area;
53
54 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 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 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 let mut constraints = vec![Constraint::Min(opts.min_main_height)]; 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)); 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 let chunks = Layout::default()
101 .direction(Direction::Vertical)
102 .constraints(constraints)
103 .split(area);
104
105 let mut chunk_idx = 0;
107
108 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 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 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 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 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 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 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 }
228}