Skip to main content

vtcode_tui/core_tui/widgets/
footer.rs

1use 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
17/// Widget for rendering the footer area with status and hints
18///
19/// The footer provides a stable region for:
20/// - Left status (git branch, model info)
21/// - Right status (token count, context usage)
22/// - Help hints (shown conditionally)
23///
24/// # Example
25/// ```ignore
26/// FooterWidget::new(&styles)
27///     .left_status("main ✓")
28///     .right_status("claude-4 | 12K tokens")
29///     .hint("? for help")
30///     .mode(LayoutMode::Standard)
31///     .render(footer_area, buf);
32/// ```
33pub 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    /// Create a new footer widget
46    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    /// Set the left status text (e.g., git branch)
60    #[must_use]
61    pub fn left_status(mut self, status: &'a str) -> Self {
62        self.left_status = Some(status);
63        self
64    }
65
66    /// Set the right status text (e.g., model info)
67    #[must_use]
68    pub fn right_status(mut self, status: &'a str) -> Self {
69        self.right_status = Some(status);
70        self
71    }
72
73    /// Set the hint text (shown when idle)
74    #[must_use]
75    pub fn hint(mut self, hint: &'a str) -> Self {
76        self.hint = Some(hint);
77        self
78    }
79
80    /// Set the layout mode
81    #[must_use]
82    pub fn mode(mut self, mode: LayoutMode) -> Self {
83        self.mode = mode;
84        self
85    }
86
87    /// Show a top border
88    #[must_use]
89    pub fn show_border(mut self, show: bool) -> Self {
90        self.show_border = show;
91        self
92    }
93
94    /// Set spinner text (shown when processing)
95    #[must_use]
96    pub fn spinner(mut self, spinner: &'a str) -> Self {
97        self.spinner = Some(spinner);
98        self
99    }
100
101    /// Set shimmer phase for animated status text
102    #[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        // Left status
112        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        // Spinner (if active)
129        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        // Calculate space needed for right status
137        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        // Add padding and right status if there's room
143        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
219/// Default keybind hints for different contexts
220pub 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}