vtcode_tui/core_tui/widgets/
footer.rs1use ratatui::{
2 buffer::Buffer,
3 layout::Rect,
4 text::{Line, Span},
5 widgets::{Block, Clear, Paragraph, Widget},
6};
7
8use super::layout_mode::LayoutMode;
9use super::panel::PanelStyles;
10use crate::core_tui::language_badge::language_badge_style;
11use crate::ui::tui::session::styling::SessionStyles;
12use crate::ui::tui::session::terminal_capabilities;
13use tui_shimmer::shimmer_spans_with_style_at_phase;
14
15use crate::ui::tui::session::status_requires_shimmer;
16
17pub struct FooterWidget<'a> {
34 styles: &'a SessionStyles,
35 left_status: Option<&'a str>,
36 right_status: Option<&'a str>,
37 hint: Option<&'a str>,
38 mode: LayoutMode,
39 show_border: bool,
40 spinner: Option<&'a str>,
41 shimmer_phase: Option<f32>,
42}
43
44impl<'a> FooterWidget<'a> {
45 pub fn new(styles: &'a SessionStyles) -> Self {
47 Self {
48 styles,
49 left_status: None,
50 right_status: None,
51 hint: None,
52 mode: LayoutMode::Standard,
53 show_border: false,
54 spinner: None,
55 shimmer_phase: None,
56 }
57 }
58
59 #[must_use]
61 pub fn left_status(mut self, status: &'a str) -> Self {
62 self.left_status = Some(status);
63 self
64 }
65
66 #[must_use]
68 pub fn right_status(mut self, status: &'a str) -> Self {
69 self.right_status = Some(status);
70 self
71 }
72
73 #[must_use]
75 pub fn hint(mut self, hint: &'a str) -> Self {
76 self.hint = Some(hint);
77 self
78 }
79
80 #[must_use]
82 pub fn mode(mut self, mode: LayoutMode) -> Self {
83 self.mode = mode;
84 self
85 }
86
87 #[must_use]
89 pub fn show_border(mut self, show: bool) -> Self {
90 self.show_border = show;
91 self
92 }
93
94 #[must_use]
96 pub fn spinner(mut self, spinner: &'a str) -> Self {
97 self.spinner = Some(spinner);
98 self
99 }
100
101 #[must_use]
103 pub fn shimmer_phase(mut self, phase: f32) -> Self {
104 self.shimmer_phase = Some(phase);
105 self
106 }
107
108 fn build_status_line(&self, width: u16) -> Line<'static> {
109 let mut spans = Vec::new();
110
111 if let Some(left) = self.left_status {
113 if status_requires_shimmer(left) {
114 if let Some(phase) = self.shimmer_phase {
115 spans.extend(shimmer_spans_with_style_at_phase(
116 left,
117 self.styles.muted_style(),
118 phase,
119 ));
120 } else {
121 spans.push(Span::styled(left.to_string(), self.styles.muted_style()));
122 }
123 } else {
124 spans.push(Span::styled(left.to_string(), self.styles.accent_style()));
125 }
126 }
127
128 if let Some(spinner) = self.spinner {
130 if !spans.is_empty() {
131 spans.push(Span::raw(" "));
132 }
133 spans.push(Span::styled(spinner.to_string(), self.styles.muted_style()));
134 }
135
136 let right_text = self.right_status.unwrap_or("");
138 let left_len: usize = spans.iter().map(|s| s.content.len()).sum();
139 let right_len = right_text.len();
140 let available = width as usize;
141
142 if left_len + right_len + 2 <= available {
144 let padding = available.saturating_sub(left_len + right_len);
145 spans.push(Span::raw(" ".repeat(padding)));
146 spans.extend(self.build_right_status_spans(right_text));
147 }
148
149 Line::from(spans)
150 }
151
152 fn build_right_status_spans(&self, status: &str) -> Vec<Span<'static>> {
153 let mut spans = Vec::new();
154 let mut parts = status.split(" | ").peekable();
155
156 while let Some(part) = parts.next() {
157 let style = language_badge_style(part).unwrap_or_else(|| self.styles.muted_style());
158 spans.push(Span::styled(part.to_string(), style));
159
160 if parts.peek().is_some() {
161 spans.push(Span::styled(" | ".to_string(), self.styles.muted_style()));
162 }
163 }
164
165 spans
166 }
167
168 fn build_hint_line(&self) -> Option<Line<'static>> {
169 match self.mode {
170 LayoutMode::Compact => None,
171 _ => self
172 .hint
173 .map(|hint| Line::from(Span::styled(hint.to_string(), self.styles.muted_style()))),
174 }
175 }
176}
177
178impl Widget for FooterWidget<'_> {
179 fn render(self, area: Rect, buf: &mut Buffer) {
180 if area.height == 0 || area.width == 0 {
181 return;
182 }
183
184 Clear.render(area, buf);
185
186 let inner = if self.show_border && self.mode.show_borders() {
187 let block = Block::bordered()
188 .border_type(terminal_capabilities::get_border_type())
189 .border_style(self.styles.border_style());
190 let inner = block.inner(area);
191 block.render(area, buf);
192 inner
193 } else {
194 area
195 };
196
197 if inner.height == 0 {
198 return;
199 }
200
201 let status_line = self.build_status_line(inner.width);
202 let hint_line = self.build_hint_line();
203
204 let lines: Vec<Line<'static>> = if inner.height >= 2 {
205 if let Some(hint) = hint_line {
206 vec![status_line, hint]
207 } else {
208 vec![status_line]
209 }
210 } else {
211 vec![status_line]
212 };
213
214 let paragraph = Paragraph::new(lines);
215 paragraph.render(inner, buf);
216 }
217}
218
219pub mod hints {
221 pub const IDLE: &str = "? help • / command • @ file";
222 pub const PROCESSING: &str = "Ctrl+C cancel";
223 pub const MODAL: &str = "↑↓ navigate • Enter select • Esc close";
224 pub const EDITING: &str = "Enter send • Ctrl+C cancel • ↑ history";
225}
226
227#[cfg(test)]
228mod tests {
229 use super::FooterWidget;
230 use crate::core_tui::session::styling::SessionStyles;
231 use crate::ui::tui::types::InlineTheme;
232 use ratatui::style::Color;
233
234 #[test]
235 fn build_right_status_spans_highlights_dominant_language() {
236 let styles = SessionStyles::new(InlineTheme::default());
237 let widget = FooterWidget::new(&styles);
238
239 let spans = widget.build_right_status_spans("Rust | model | 17% context left");
240
241 assert_eq!(spans[0].content.as_ref(), "Rust");
242 assert_eq!(spans[0].style.fg, Some(Color::Rgb(0xCE, 0x7E, 0x47)));
243 assert_eq!(spans[2].content.as_ref(), "model");
244 assert_ne!(spans[2].style.fg, Some(Color::Rgb(0xCE, 0x7E, 0x47)));
245 }
246}