use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use chrono::Utc;
use crate::tui::app::{App, EventDisplayed, EventKind, WaterfallFilter};
use crate::tui::theme::{Palette, color_lerp, fade_factor};
pub fn render(frame: &mut Frame, area: Rect, app: &App, palette: &Palette) {
let block = Block::new()
.borders(Borders::ALL)
.border_style(Style::new().fg(palette.rule))
.title(Span::styled(
format!(
" EVENT WATERFALL · {} events · stream {}",
app.waterfall.len(),
if app.event_count > 0 { "OPEN" } else { "—" }
),
Style::new().fg(palette.fg_2).add_modifier(Modifier::BOLD),
));
let inner = block.inner(area);
frame.render_widget(block, area);
let card_rows: u16 = if app.terminal_card.is_some() { 6 } else { 0 };
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(1), Constraint::Length(card_rows), ])
.split(inner);
let active_filter = app.waterfall_filter;
let chips_line = render_chips(active_filter, palette);
let chips_para = Paragraph::new(chips_line);
frame.render_widget(chips_para, chunks[0]);
let max_lines = chunks[1].height as usize;
let visible: Vec<&EventDisplayed> = app
.waterfall
.iter()
.rev()
.filter(|ev| active_filter.matches(ev.kind))
.take(max_lines)
.collect();
let lines: Vec<Line> = visible
.iter()
.enumerate()
.map(|(i, ev)| {
let prev = visible.get(i + 1);
let same_minute = prev
.map(|p| {
p.ts.format("%Y-%m-%d %H:%M").to_string()
== ev.ts.format("%Y-%m-%d %H:%M").to_string()
})
.unwrap_or(false);
let is_terminal = ev.text.contains("→ completed")
|| ev.text.contains("→ failed")
|| ev.text.contains("→ cancelled")
|| ev.text.contains("→ merged")
|| ev.text.contains("→ cooled");
event_line(ev, palette, app.reduce_motion, same_minute, is_terminal)
})
.collect();
let para = Paragraph::new(lines);
frame.render_widget(para, chunks[1]);
if let Some(card) = &app.terminal_card {
render_terminal_card(frame, chunks[2], card, palette);
}
}
fn render_terminal_card(
frame: &mut Frame,
area: Rect,
card: &crate::tui::app::TerminalCard,
palette: &Palette,
) {
use ratatui::style::Color;
let (color, glyph, headline) = match card.final_status.as_str() {
"completed" | "merged" => (palette.state_pass, "✓", "shift complete"),
"failed" | "cooled" => (palette.red, "✖", "the forge cooled"),
"cancelled" => (palette.amber, "—", "shift cancelled"),
_ => (palette.fg_2, "·", "shift terminal"),
};
let sep = Line::from(Span::styled(
"─".repeat(area.width as usize),
Style::new().fg(color),
));
let header = Line::from(vec![
Span::styled(
format!(" {glyph} "),
Style::new().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled(
headline,
Style::new().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" · {}", &card.run_id[..8.min(card.run_id.len())]),
Style::new().fg(palette.fg_3),
),
]);
let stats_left = Line::from(vec![
Span::styled(" duration ", Style::new().fg(palette.fg_3)),
Span::styled(
format_short_secs(card.duration_secs),
Style::new().fg(palette.fg_0),
),
Span::styled(" · events ", Style::new().fg(palette.fg_3)),
Span::styled(card.total_events.to_string(), Style::new().fg(palette.fg_0)),
Span::styled(" · tools ", Style::new().fg(palette.fg_3)),
Span::styled(card.tool_count.to_string(), Style::new().fg(palette.fg_0)),
Span::styled(" · blueprints ", Style::new().fg(palette.fg_3)),
Span::styled(card.blueprints.to_string(), Style::new().fg(palette.copper)),
]);
let verdict_line = match &card.verdict {
Some(v) if v.to_uppercase().contains("PASS") => Line::from(vec![
Span::styled(" yield ", Style::new().fg(palette.fg_3)),
Span::styled(
"PASS",
Style::new()
.fg(palette.state_pass)
.add_modifier(Modifier::BOLD),
),
Span::styled(" ✓", Style::new().fg(palette.state_pass)),
]),
Some(v) if v.to_uppercase().contains("FAIL") => Line::from(vec![
Span::styled(" yield ", Style::new().fg(palette.fg_3)),
Span::styled(
"the forge cooled",
Style::new().fg(palette.red).add_modifier(Modifier::BOLD),
),
Span::styled(" ✖", Style::new().fg(palette.red)),
]),
_ => Line::from(Span::styled(" yield —", Style::new().fg(palette.fg_4))),
};
let _ = Color::Reset;
let para = Paragraph::new(vec![sep, header, Line::raw(""), stats_left, verdict_line]);
frame.render_widget(para, area);
}
fn format_short_secs(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m{:02}s", secs / 60, secs % 60)
} else {
format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
}
}
fn render_chips<'a>(active: WaterfallFilter, palette: &Palette) -> Line<'a> {
let chips = [
WaterfallFilter::All,
WaterfallFilter::Errors,
WaterfallFilter::Tools,
WaterfallFilter::Routing,
];
let mut spans = Vec::with_capacity(chips.len() * 2);
for chip in chips {
let style = if chip == active {
Style::new()
.fg(palette.bg_0)
.bg(palette.ember)
.add_modifier(Modifier::BOLD)
} else {
Style::new().fg(palette.fg_3)
};
spans.push(Span::styled(format!(" [{}] ", chip.label()), style));
}
Line::from(spans)
}
fn event_line<'a>(
ev: &'a EventDisplayed,
palette: &Palette,
reduce_motion: bool,
same_minute_as_below: bool,
is_terminal: bool,
) -> Line<'a> {
let ts = if same_minute_as_below {
format!(" :{}", ev.ts.format("%S"))
} else {
ev.ts.format("%H:%M:%S").to_string()
};
let base_color = kind_color(ev.kind, palette);
let margin = if matches!(ev.kind, EventKind::Error) {
Span::styled("│", Style::new().fg(palette.red))
} else {
Span::raw(" ")
};
let final_color = if reduce_motion {
base_color
} else {
let age_secs = (Utc::now() - ev.ts).num_milliseconds().max(0) as f32 / 1000.0;
let factor = fade_factor(age_secs);
let lerp_t = (1.0 - factor) / 0.4;
color_lerp(base_color, palette.bg_1, lerp_t)
};
let text_style = if is_terminal {
Style::new().fg(final_color).add_modifier(Modifier::BOLD)
} else {
Style::new().fg(final_color)
};
Line::from(vec![
margin,
Span::styled(ts, Style::new().fg(palette.fg_4)),
Span::raw(" "),
Span::styled(ev.text.clone(), text_style),
])
}
fn kind_color(kind: EventKind, palette: &Palette) -> ratatui::style::Color {
match kind {
EventKind::Heartbeat => palette.heartbeat,
EventKind::StateTransition => palette.fg_1,
EventKind::ToolUse => palette.magenta,
EventKind::SessionUpdate => palette.cyan,
EventKind::Artifact => palette.copper,
EventKind::BlueprintsInjected => palette.copper,
EventKind::Routing => palette.cyan_dim,
EventKind::Warning => palette.amber,
EventKind::Error => palette.red,
EventKind::Success => palette.state_pass,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filter_matches_errors() {
assert!(WaterfallFilter::Errors.matches(EventKind::Error));
assert!(WaterfallFilter::Errors.matches(EventKind::Warning));
assert!(!WaterfallFilter::Errors.matches(EventKind::Heartbeat));
}
#[test]
fn filter_all_matches_everything() {
assert!(WaterfallFilter::All.matches(EventKind::Heartbeat));
assert!(WaterfallFilter::All.matches(EventKind::Error));
}
}