darq 0.1.0

darq CLI + TUI — autonomous issue → PR pipeline with SAT and a learning loop.
Documentation
//! EventWaterfall — scrolling event log with fade-out.
//!
//! Top row: filter chip strip `[all] [errors] [tools] [routing]` (active in ember).
//! Below: one line per event, format `HH:MM:SS <glyph> <text>`.
//! Fade-out: opacity 1 → 0.6 over 8 seconds (Phase 4.4 enables time-based fade).

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);

    // Split inner: chips · events · (terminal card if shift complete).
    // The terminal card takes the bottom 6 rows when present, fills the
    // otherwise-empty real estate AND gives visual weight to the climax.
    let card_rows: u16 = if app.terminal_card.is_some() { 6 } else { 0 };
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1),         // filter chips
            Constraint::Min(1),            // event log
            Constraint::Length(card_rows), // terminal card (or 0)
        ])
        .split(inner);

    // Active filter from App state (Phase 6.6).
    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]);

    // Event lines — newest at top, filtered, fade with age. Timestamps condense
    // to `:SS` for runs of events in the same minute (de-noises the left column).
    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)| {
            // Compare to the *next-displayed* event (visible[i+1]) — that's the
            // line BELOW in render order = the older event in this newest-first list.
            // If they share a minute, show the short timestamp on this line.
            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);
            // Detect terminal-state lines for bold emphasis.
            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]);

    // Render the shift-complete card if present — fills the empty bottom area
    // with key stats and gives visual weight to the run's climax.
    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"),
    };
    // Top separator row
    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> {
    // Condense timestamp: full HH:MM:SS unless previous (older) line shares the
    // minute, in which case render `   :SS` so the column reads as a sparse
    // progression instead of repeating "04:38:" eight times.
    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);

    // Phase 7.5: red bar at left margin for failure events — easier to scan.
    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 {
        // Fade-out: opacity 1 → 0.6 over 8s (ages > 8s clamp to 0.6).
        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));
    }
}