use ratatui::{
buffer::Buffer,
layout::Rect,
style::Modifier,
text::{Line, Span},
widgets::{Block, Clear, Paragraph, Widget},
};
use super::layout_mode::LayoutMode;
use super::panel::PanelStyles;
use crate::core_tui::language_badge::language_badge_style;
use crate::ui::tui::session::styling::SessionStyles;
use crate::ui::tui::session::terminal_capabilities;
use tui_shimmer::shimmer_spans_with_style_at_phase;
use crate::ui::tui::session::status_requires_shimmer;
pub struct FooterWidget<'a> {
styles: &'a SessionStyles,
left_status: Option<&'a str>,
right_status: Option<&'a str>,
hint: Option<&'a str>,
mode: LayoutMode,
show_border: bool,
spinner: Option<&'a str>,
shimmer_phase: Option<f32>,
}
impl<'a> FooterWidget<'a> {
pub fn new(styles: &'a SessionStyles) -> Self {
Self {
styles,
left_status: None,
right_status: None,
hint: None,
mode: LayoutMode::Standard,
show_border: false,
spinner: None,
shimmer_phase: None,
}
}
#[must_use]
pub fn left_status(mut self, status: &'a str) -> Self {
self.left_status = Some(status);
self
}
#[must_use]
pub fn right_status(mut self, status: &'a str) -> Self {
self.right_status = Some(status);
self
}
#[must_use]
pub fn hint(mut self, hint: &'a str) -> Self {
self.hint = Some(hint);
self
}
#[must_use]
pub fn mode(mut self, mode: LayoutMode) -> Self {
self.mode = mode;
self
}
#[must_use]
pub fn show_border(mut self, show: bool) -> Self {
self.show_border = show;
self
}
#[must_use]
pub fn spinner(mut self, spinner: &'a str) -> Self {
self.spinner = Some(spinner);
self
}
#[must_use]
pub fn shimmer_phase(mut self, phase: f32) -> Self {
self.shimmer_phase = Some(phase);
self
}
fn build_status_line(&self, width: u16) -> Line<'static> {
let mut spans = Vec::new();
if let Some(left) = self.left_status {
if status_requires_shimmer(left) {
if let Some(phase) = self.shimmer_phase {
spans.extend(shimmer_spans_with_style_at_phase(
left,
self.styles.accent_style().add_modifier(Modifier::DIM),
phase,
));
} else {
spans.push(Span::styled(left.to_string(), self.styles.muted_style()));
}
} else {
spans.push(Span::styled(left.to_string(), self.styles.accent_style()));
}
}
if let Some(spinner) = self.spinner {
if !spans.is_empty() {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(spinner.to_string(), self.styles.muted_style()));
}
let right_text = self.right_status.unwrap_or("");
let left_len: usize = spans.iter().map(|s| s.content.len()).sum();
let right_len = right_text.len();
let available = width as usize;
if left_len + right_len + 2 <= available {
let padding = available.saturating_sub(left_len + right_len);
spans.push(Span::raw(" ".repeat(padding)));
spans.extend(self.build_right_status_spans(right_text));
}
Line::from(spans)
}
fn build_right_status_spans(&self, status: &str) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let mut parts = status.split(" | ").peekable();
while let Some(part) = parts.next() {
let style = language_badge_style(part).unwrap_or_else(|| self.styles.muted_style());
spans.push(Span::styled(part.to_string(), style));
if parts.peek().is_some() {
spans.push(Span::styled(" | ".to_string(), self.styles.muted_style()));
}
}
spans
}
fn build_hint_line(&self) -> Option<Line<'static>> {
match self.mode {
LayoutMode::Compact => None,
_ => self
.hint
.map(|hint| Line::from(Span::styled(hint.to_string(), self.styles.muted_style()))),
}
}
}
impl Widget for FooterWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
Clear.render(area, buf);
let inner = if self.show_border && self.mode.show_borders() {
let block = Block::bordered()
.border_type(terminal_capabilities::get_border_type())
.border_style(self.styles.border_style());
let inner = block.inner(area);
block.render(area, buf);
inner
} else {
area
};
if inner.height == 0 {
return;
}
let status_line = self.build_status_line(inner.width);
let hint_line = self.build_hint_line();
let lines: Vec<Line<'static>> = if inner.height >= 2 {
if let Some(hint) = hint_line {
vec![status_line, hint]
} else {
vec![status_line]
}
} else {
vec![status_line]
};
let paragraph = Paragraph::new(lines);
paragraph.render(inner, buf);
}
}
pub mod hints {
pub const IDLE: &str = "? help • / command • @ file";
pub const PROCESSING: &str = vtcode_commons::stop_hints::STOP_HINT_COMPACT;
pub const MODAL: &str = "↑↓ navigate • Enter select • Esc close";
pub const EDITING: &str = "Enter/Tab queue • Ctrl+Enter run/steer • /stop • ↑ history";
}
#[cfg(test)]
mod tests {
use super::FooterWidget;
use crate::core_tui::session::styling::SessionStyles;
use crate::ui::tui::types::InlineTheme;
use ratatui::style::Color;
#[test]
fn build_right_status_spans_highlights_dominant_language() {
let styles = SessionStyles::new(InlineTheme::default());
let widget = FooterWidget::new(&styles);
let spans = widget.build_right_status_spans("Rust | model | 17% context left");
assert_eq!(spans[0].content.as_ref(), "Rust");
assert_eq!(spans[0].style.fg, Some(Color::Rgb(0xCE, 0x7E, 0x47)));
assert_eq!(spans[2].content.as_ref(), "model");
assert_ne!(spans[2].style.fg, Some(Color::Rgb(0xCE, 0x7E, 0x47)));
}
}