use crate::state::TuiState;
use ratatui::{
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
const WIDTH_FULL: u16 = 80; #[allow(dead_code)] const WIDTH_HIDE_HELP: u16 = 65; const WIDTH_COMPRESS: u16 = 50; const WIDTH_MINIMAL: u16 = 40;
pub fn render(state: &TuiState, width: u16) -> Paragraph<'static> {
let mut spans = vec![];
let current = state.current_view + 1; let total = state.total_iterations();
let iter_display = format!("[iter {}/{}]", current, total);
spans.push(Span::raw(iter_display));
if let Some(elapsed) = state.get_iteration_elapsed()
&& width > WIDTH_COMPRESS
{
let total_secs = elapsed.as_secs();
let mins = total_secs / 60;
let secs = total_secs % 60;
spans.push(Span::raw(format!(" {mins:02}:{secs:02}")));
}
spans.push(Span::raw(" | "));
if width > WIDTH_COMPRESS {
spans.push(Span::raw(state.get_pending_hat_display()));
} else {
let hat_display = state.get_pending_hat_display();
let emoji = hat_display.chars().next().unwrap_or('?');
spans.push(Span::raw(emoji.to_string()));
}
if let Some(idle) = state.idle_timeout_remaining
&& width > WIDTH_MINIMAL
{
spans.push(Span::raw(format!(" | idle: {}s", idle.as_secs())));
}
spans.push(Span::raw(" | "));
let mode = if state.following_latest {
if width > WIDTH_COMPRESS {
Span::styled("[LIVE]", Style::default().fg(Color::Green))
} else {
Span::styled("▶", Style::default().fg(Color::Green))
}
} else if width > WIDTH_COMPRESS {
Span::styled("[REVIEW]", Style::default().fg(Color::Yellow))
} else {
Span::styled("◀", Style::default().fg(Color::Yellow))
};
spans.push(mode);
if state.in_scroll_mode {
if width > WIDTH_COMPRESS {
spans.push(Span::styled(" [SCROLL]", Style::default().fg(Color::Cyan)));
} else {
spans.push(Span::styled(" [S]", Style::default().fg(Color::Cyan)));
}
}
if width >= WIDTH_FULL {
spans.push(Span::styled(
" | ? help",
Style::default().fg(Color::DarkGray),
));
}
let line = Line::from(spans);
let block = Block::default().borders(Borders::BOTTOM);
Paragraph::new(line).block(block)
}
#[cfg(test)]
mod tests {
use super::*;
use ralph_proto::{Event, HatId};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use std::time::Duration;
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, width);
f.render_widget(widget, f.area());
})
.unwrap();
let buffer = terminal.backend().buffer();
buffer
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>()
}
#[test]
fn header_shows_iteration_position() {
let mut state = TuiState::new();
state.start_new_iteration();
state.start_new_iteration();
state.start_new_iteration();
state.current_view = 2;
let text = render_to_string(&state);
assert!(
text.contains("[iter 3/3]"),
"should show [iter 3/3], got: {}",
text
);
}
#[test]
fn header_shows_iteration_at_first() {
let mut state = TuiState::new();
state.start_new_iteration();
state.start_new_iteration();
state.start_new_iteration();
state.current_view = 0;
let text = render_to_string(&state);
assert!(
text.contains("[iter 1/3]"),
"should show [iter 1/3], got: {}",
text
);
}
#[test]
fn header_shows_elapsed_time() {
let mut state = TuiState::new();
let event = Event::new("task.start", "");
state.update(&event);
state.iteration_started = Some(
std::time::Instant::now()
.checked_sub(Duration::from_secs(272))
.unwrap(),
);
let text = render_to_string(&state);
assert!(text.contains("04:32"), "should show 04:32, got: {}", text);
}
#[test]
fn header_shows_hat() {
let mut state = TuiState::new();
state.pending_hat = Some((HatId::new("builder"), "🔨Builder".to_string()));
let text = render_to_string(&state);
assert!(text.contains("Builder"), "should show hat, got: {}", text);
}
#[test]
fn header_shows_idle_countdown_when_present() {
let mut state = TuiState::new();
state.idle_timeout_remaining = Some(Duration::from_secs(25));
let text = render_to_string(&state);
assert!(
text.contains("idle: 25s"),
"should show idle countdown, got: {}",
text
);
}
#[test]
fn header_hides_idle_countdown_when_none() {
let mut state = TuiState::new();
state.idle_timeout_remaining = None;
let text = render_to_string(&state);
assert!(
!text.contains("idle:"),
"should not show idle when None, got: {}",
text
);
}
#[test]
fn header_shows_scroll_indicator() {
let mut state = TuiState::new();
state.in_scroll_mode = true;
let text = render_to_string(&state);
assert!(
text.contains("[SCROLL]"),
"should show scroll indicator, got: {}",
text
);
}
#[test]
fn header_full_format() {
let mut state = TuiState::new();
let event = Event::new("task.start", "");
state.update(&event);
for _ in 0..10 {
state.start_new_iteration();
}
state.current_view = 2; state.following_latest = true;
state.iteration_started = Some(
std::time::Instant::now()
.checked_sub(Duration::from_secs(272))
.unwrap(),
);
state.pending_hat = Some((HatId::new("builder"), "🔨Builder".to_string()));
state.idle_timeout_remaining = Some(Duration::from_secs(25));
state.in_scroll_mode = true;
let text = render_to_string(&state);
assert!(
text.contains("[iter 3/10]"),
"missing iteration, got: {}",
text
);
assert!(
text.contains("04:32"),
"missing elapsed time, got: {}",
text
);
assert!(text.contains("Builder"), "missing hat, got: {}", text);
assert!(
text.contains("idle: 25s"),
"missing idle countdown, got: {}",
text
);
assert!(text.contains("[LIVE]"), "missing mode, got: {}", text);
assert!(
text.contains("[SCROLL]"),
"missing scroll indicator, got: {}",
text
);
assert!(
text.contains("? help"),
"missing help hint at width 80, got: {}",
text
);
}
fn create_full_state() -> TuiState {
let mut state = TuiState::new();
let event = Event::new("task.start", "");
state.update(&event);
for _ in 0..10 {
state.start_new_iteration();
}
state.current_view = 2; state.following_latest = true;
state.iteration_started = Some(
std::time::Instant::now()
.checked_sub(Duration::from_secs(272))
.unwrap(),
);
state.pending_hat = Some((HatId::new("builder"), "🔨Builder".to_string()));
state.idle_timeout_remaining = Some(Duration::from_secs(25));
state.in_scroll_mode = true;
state
}
#[test]
fn header_at_80_chars_shows_help_hint() {
let state = create_full_state();
let text = render_to_string_with_width(&state, 80);
assert!(
text.contains("? help"),
"help hint should be visible at 80 chars, got: {}",
text
);
assert!(
text.contains("[iter 3/10]"),
"iteration should be visible, got: {}",
text
);
assert!(
text.contains("[LIVE]"),
"mode should be visible, got: {}",
text
);
}
#[test]
fn header_at_65_chars_hides_help() {
let state = create_full_state();
let text = render_to_string_with_width(&state, 65);
assert!(
!text.contains("? help"),
"help hint should be hidden at 65 chars, got: {}",
text
);
assert!(
text.contains("[iter 3/10]"),
"iteration should be visible, got: {}",
text
);
assert!(
text.contains("[LIVE]"),
"mode should be visible (not compressed), got: {}",
text
);
}
#[test]
fn header_at_50_chars_compresses_mode() {
let state = create_full_state();
let text = render_to_string_with_width(&state, 50);
assert!(
text.contains('▶'),
"mode icon should be visible, got: {}",
text
);
assert!(
!text.contains("[LIVE]"),
"mode text '[LIVE]' should be hidden at 50 chars, got: {}",
text
);
assert!(
!text.contains("04:32"),
"elapsed time should be hidden at 50 chars, got: {}",
text
);
assert!(
text.contains("[iter 3/10]"),
"iteration should be visible, got: {}",
text
);
}
#[test]
fn header_at_40_chars_minimal() {
let state = create_full_state();
let text = render_to_string_with_width(&state, 40);
assert!(
text.contains("[iter"),
"iteration should be visible at 40 chars, got: {}",
text
);
assert!(
text.contains('▶'),
"mode icon should be visible at 40 chars, got: {}",
text
);
assert!(
!text.contains("idle"),
"idle should be hidden at 40 chars, got: {}",
text
);
}
#[test]
fn header_at_30_chars_extreme() {
let state = create_full_state();
let text = render_to_string_with_width(&state, 30);
assert!(
text.contains("[iter"),
"iteration should be visible even at 30 chars, got: {}",
text
);
assert!(
text.contains('▶'),
"mode icon should be visible even at 30 chars, got: {}",
text
);
}
#[test]
fn header_shows_iteration_position_from_tui_state() {
let mut state = TuiState::new();
for _ in 0..5 {
state.start_new_iteration();
}
state.current_view = 2;
let text = render_to_string(&state);
assert!(
text.contains("[iter 3/5]"),
"should show [iter 3/5] for current_view=2, total=5, got: {}",
text
);
}
#[test]
fn header_shows_single_iteration() {
let mut state = TuiState::new();
state.start_new_iteration();
let text = render_to_string(&state);
assert!(
text.contains("[iter 1/1]"),
"should show [iter 1/1] for single iteration, got: {}",
text
);
}
#[test]
fn header_shows_live_mode_when_following_latest() {
let mut state = TuiState::new();
state.start_new_iteration();
state.following_latest = true;
let text = render_to_string(&state);
assert!(
text.contains("[LIVE]"),
"should show [LIVE] when following_latest=true, got: {}",
text
);
}
#[test]
fn header_shows_review_mode_when_not_following_latest() {
let mut state = TuiState::new();
state.start_new_iteration();
state.start_new_iteration();
state.current_view = 0;
state.following_latest = false;
let text = render_to_string(&state);
assert!(
text.contains("[REVIEW]"),
"should show [REVIEW] when following_latest=false, got: {}",
text
);
}
#[test]
fn header_preserves_hat_display_with_new_format() {
let mut state = TuiState::new();
state.start_new_iteration();
state.pending_hat = Some((HatId::new("builder"), "🔨Builder".to_string()));
let text = render_to_string(&state);
assert!(
text.contains("Builder"),
"should preserve hat display, got: {}",
text
);
}
#[test]
fn header_preserves_elapsed_time_with_new_format() {
let mut state = TuiState::new();
state.start_new_iteration();
let event = Event::new("task.start", "");
state.update(&event);
state.iteration_started = Some(
std::time::Instant::now()
.checked_sub(Duration::from_secs(300))
.unwrap(),
);
let text = render_to_string(&state);
assert!(
text.contains("05:00"),
"should preserve elapsed time display, got: {}",
text
);
}
#[test]
fn header_handles_empty_iterations() {
let state = TuiState::new();
let text = render_to_string(&state);
assert!(
text.contains("[iter 1/0]"),
"should show [iter 1/0] for empty state (position 1, 0 total), got: {}",
text
);
}
}