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