use crate::state::TuiState;
use ratatui::{
layout::{Constraint, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Widget},
};
pub struct Footer<'a> {
state: &'a TuiState,
}
impl<'a> Footer<'a> {
pub fn new(state: &'a TuiState) -> Self {
Self { state }
}
}
impl Widget for Footer<'_> {
fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) {
let block = Block::default().borders(Borders::TOP);
let inner_area = block.inner(area);
block.render(area, buf);
if let Some(query) = &self.state.search_state.query {
let match_info = if self.state.search_state.matches.is_empty() {
"no matches".to_string()
} else {
format!(
"{}/{}",
self.state.search_state.current_match + 1,
self.state.search_state.matches.len()
)
};
let line = Line::from(vec![
Span::raw(" "),
Span::styled(
format!("Search: {} ", query),
Style::default().fg(Color::Yellow),
),
Span::styled(match_info, Style::default().fg(Color::Cyan)),
]);
Paragraph::new(line).render(inner_area, buf);
return;
}
if !self.state.search_query.is_empty() {
let prompt = if self.state.search_forward { "/" } else { "?" };
let line = Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{}{}", prompt, self.state.search_query),
Style::default().fg(Color::Yellow),
),
]);
Paragraph::new(line).render(inner_area, buf);
return;
}
let mut left_spans = vec![Span::raw(" ")];
if let Some(iter_num) = self.state.new_iteration_alert
&& !self.state.following_latest
{
left_spans.push(Span::styled(
format!("▶ New: iter {} ", iter_num),
Style::default().fg(Color::Green),
));
left_spans.push(Span::raw("│ "));
}
let elapsed_display = if let Some(elapsed) = self.state.get_loop_elapsed() {
let total_secs = elapsed.as_secs();
let mins = total_secs / 60;
let secs = total_secs % 60;
format!("Total Time Elapsed: {mins:02}:{secs:02}")
} else {
"Total Time Elapsed: 00:00".to_string()
};
left_spans.push(Span::raw(elapsed_display));
let indicator_text = if self.state.loop_completed {
"■ DONE"
} else {
"◉ ACTIVE"
};
let indicator_style = if self.state.loop_completed {
Style::default().fg(Color::Blue)
} else {
Style::default().fg(Color::Green)
};
let left_content_width: usize = left_spans.iter().map(|s| s.width()).sum();
let chunks = Layout::horizontal([
Constraint::Length(left_content_width as u16), Constraint::Fill(1), Constraint::Length((indicator_text.len() + 2) as u16), ])
.split(inner_area);
let left = Line::from(left_spans);
Paragraph::new(left).render(chunks[0], buf);
let right = Line::from(vec![
Span::styled(indicator_text, indicator_style),
Span::raw(" "),
]);
Paragraph::new(right).render(chunks[2], buf);
}
}
pub fn render(state: &TuiState) -> Footer<'_> {
Footer::new(state)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn render_to_string(state: &TuiState) -> String {
render_to_string_with_width(state, 80)
}
fn render_to_string_with_width(state: &TuiState, width: u16) -> String {
let backend = TestBackend::new(width, 2);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let widget = render(state);
f.render_widget(widget, f.area());
})
.unwrap();
let buffer = terminal.backend().buffer();
buffer
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>()
}
#[test]
fn footer_shows_new_iteration_alert() {
let mut state = TuiState::new();
state.new_iteration_alert = Some(5);
state.following_latest = false;
let text = render_to_string(&state);
assert!(
text.contains("▶ New: iter 5"),
"should show new iteration alert, got: {}",
text
);
}
#[test]
fn footer_no_alert_when_following() {
let mut state = TuiState::new();
state.new_iteration_alert = Some(5);
state.following_latest = true;
let text = render_to_string(&state);
assert!(
!text.contains("▶ New:"),
"should NOT show alert when following_latest=true, got: {}",
text
);
}
#[test]
fn footer_shows_elapsed_time() {
let mut state = TuiState::new();
state.loop_started = Some(
std::time::Instant::now()
.checked_sub(std::time::Duration::from_secs(150))
.unwrap(),
);
let text = render_to_string(&state);
assert!(
text.contains("Total Time Elapsed: 02:30"),
"should show 'Total Time Elapsed: 02:30', got: {}",
text
);
}
#[test]
fn footer_shows_active_indicator() {
let mut state = TuiState::new();
state.pending_hat = Some((ralph_proto::HatId::new("builder"), "🔨Builder".to_string()));
let text = render_to_string(&state);
assert!(
text.contains('◉') && text.contains("ACTIVE"),
"should show ACTIVE indicator, got: {}",
text
);
}
#[test]
fn footer_shows_search_query() {
let mut state = TuiState::new();
state.search_state.query = Some("test".to_string());
state.search_state.matches = vec![(0, 0), (1, 0)];
let text = render_to_string(&state);
assert!(
text.contains("Search: test"),
"should show search query, got: {}",
text
);
assert!(
text.contains("1/2"),
"should show match position, got: {}",
text
);
}
#[test]
fn footer_shows_no_matches_when_empty() {
let mut state = TuiState::new();
state.search_state.query = Some("notfound".to_string());
state.search_state.matches = vec![];
let text = render_to_string(&state);
assert!(
text.contains("no matches"),
"should show no matches indicator, got: {}",
text
);
}
#[test]
fn footer_shows_done_indicator_when_complete() {
let mut state = TuiState::new();
state.loop_completed = true;
let text = render_to_string(&state);
assert!(
text.contains('■') && text.contains("DONE"),
"should show DONE indicator, got: {}",
text
);
}
#[test]
fn footer_shows_active_at_startup() {
let state = TuiState::new();
let text = render_to_string(&state);
assert!(
text.contains('◉') && text.contains("ACTIVE"),
"should show ACTIVE indicator at startup, got: {}",
text
);
}
}