agent_core/tui/layout/
standard.rs1use ratatui::layout::{Constraint, Direction, Layout};
4
5use crate::tui::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::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
51pub fn compute(ctx: &LayoutContext, sizes: &WidgetSizes, opts: &StandardOptions) -> LayoutResult {
53 let mut result = LayoutResult::default();
54 let area = ctx.frame_area;
55
56 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 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 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 let mut constraints = vec![Constraint::Min(opts.min_main_height)]; 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)); 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 let chunks = Layout::default()
103 .direction(Direction::Vertical)
104 .constraints(constraints)
105 .split(area);
106
107 let mut chunk_idx = 0;
109
110 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 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 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 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 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 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 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 }
224}