sdd-layer 0.12.0

Spec-Driven Development CLI and agent harness
//! Componente `StatusBar` (T-05): barra de status inferior estilo OpenCode/Antigravity.
//! Exibe rota ativa, tokens consumidos, custo estimado, atalhos de teclado e dica opcional.
//! Puro, sem estado/IO — recebe `StatusBarProps` e renderiza em `area` via `Frame`.

#![allow(dead_code)]

use crate::tui::theme::Theme;
use ratatui::{
    layout::Rect,
    style::{Modifier, Style},
    text::{Line, Span, Text},
    widgets::Paragraph,
    Frame,
};

// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------

/// Propriedades de entrada para o componente `StatusBar`.
///
/// Todos os campos opcionais são ignorados com segurança quando `None`.
pub struct StatusBarProps<'a> {
    /// Rota ou contexto atual exibido no badge à esquerda (ex.: `"/techspec"`).
    pub route: &'a str,
    /// Percentual de tokens consumidos em relação ao limite (0–100), se disponível.
    pub tokens_pct: Option<u8>,
    /// Representação textual do total de tokens absolutos (ex.: `"12 k"`), se disponível.
    pub tokens_abs: Option<&'a str>,
    /// Custo estimado formatado (ex.: `"$0.042"`), se disponível.
    pub cost: Option<&'a str>,
    /// Atalhos de teclado a exibir na linha principal (ex.: `"↑↓ navegar  q sair"`).
    pub shortcuts: &'a str,
    /// Dica contextual opcional; quando `Some`, ocupa a segunda linha.
    pub tip: Option<&'a str>,
}

// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------

/// Renderiza o `StatusBar` em `area`.
///
/// - Linha 1: badge de rota (fundo `primary`, texto `on_primary`) + rota (`text_strong`)
///   + métricas de tokens/custo (`text_muted`, separadas por ` · `) + atalhos (`text_muted`).
/// - Linha 2 (somente se `props.tip` for `Some`): badge " Tip " (`primary`) + texto (`text_muted`).
///
/// Nunca entra em pânico com campos `None`. Respeita os limites de `area`.
pub fn render(frame: &mut Frame, area: Rect, props: &StatusBarProps, theme: &Theme) {
    // ------------------------------------------------------------------
    // Linha 1 — rota, métricas e atalhos
    // ------------------------------------------------------------------
    let badge_style = Style::default()
        .bg(theme.primary)
        .fg(theme.on_primary)
        .add_modifier(Modifier::BOLD);

    let strong = Style::default().fg(theme.text_strong);
    let muted = Style::default().fg(theme.text_muted);

    // Badge " rota " com espaços de padding.
    let mut spans_line1: Vec<Span> = vec![
        Span::styled(" ", badge_style),
        Span::styled(props.route.to_owned(), badge_style),
        Span::styled(" ", badge_style),
        Span::styled(" ", strong), // espaço separador após badge
    ];

    // Métricas de tokens e custo (text_muted), inseridas antes dos atalhos.
    let mut metrics: Vec<String> = Vec::new();

    if let (Some(pct), Some(abs)) = (props.tokens_pct, props.tokens_abs) {
        metrics.push(format!("{}% · {}", pct, abs));
    } else if let Some(pct) = props.tokens_pct {
        metrics.push(format!("{}%", pct));
    } else if let Some(abs) = props.tokens_abs {
        metrics.push(abs.to_owned());
    }

    if let Some(cost) = props.cost {
        metrics.push(cost.to_owned());
    }

    if !metrics.is_empty() {
        let metrics_str = metrics.join(" · ");
        spans_line1.push(Span::styled(metrics_str, muted));
        spans_line1.push(Span::styled("  ", muted)); // separador visual antes dos atalhos
    }

    // Atalhos à direita (text_muted).
    if !props.shortcuts.is_empty() {
        spans_line1.push(Span::styled(props.shortcuts.to_owned(), muted));
    }

    let line1 = Line::from(spans_line1);

    // ------------------------------------------------------------------
    // Linha 2 — dica opcional
    // ------------------------------------------------------------------
    let lines: Vec<Line> = if let Some(tip_text) = props.tip {
        let tip_badge_style = Style::default()
            .bg(theme.primary)
            .fg(theme.on_primary)
            .add_modifier(Modifier::BOLD);

        let line2 = Line::from(vec![
            Span::styled(" Tip ", tip_badge_style),
            Span::styled(" ", muted),
            Span::styled(tip_text.to_owned(), muted),
        ]);

        vec![line1, line2]
    } else {
        vec![line1]
    };

    let text = Text::from(lines);
    let paragraph = Paragraph::new(text);
    frame.render_widget(paragraph, area);
}

// ---------------------------------------------------------------------------
// Testes
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tui::theme::{theme, ColorDepth};
    use ratatui::{backend::TestBackend, Terminal};

    fn make_terminal(width: u16, height: u16) -> Terminal<TestBackend> {
        Terminal::new(TestBackend::new(width, height)).unwrap()
    }

    /// Render com todos os campos preenchidos em 80x3 não deve entrar em pânico.
    #[test]
    fn render_todos_campos_80x3() {
        let mut terminal = make_terminal(80, 3);
        let t = theme(ColorDepth::TrueColor);

        terminal
            .draw(|f| {
                let area = Rect::new(0, 0, 80, 3);
                let props = StatusBarProps {
                    route: "/techspec",
                    tokens_pct: Some(42),
                    tokens_abs: Some("12 k"),
                    cost: Some("$0.042"),
                    shortcuts: "↑↓ navegar  q sair",
                    tip: Some("Use Tab para alternar painéis"),
                };
                render(f, area, &props, &t);
            })
            .unwrap();
    }

    /// Render com todos os campos opcionais como `None` em 80x3 não deve entrar em pânico.
    #[test]
    fn render_opcionais_none_80x3() {
        let mut terminal = make_terminal(80, 3);
        let t = theme(ColorDepth::Ansi16);

        terminal
            .draw(|f| {
                let area = Rect::new(0, 0, 80, 3);
                let props = StatusBarProps {
                    route: "/home",
                    tokens_pct: None,
                    tokens_abs: None,
                    cost: None,
                    shortcuts: "",
                    tip: None,
                };
                render(f, area, &props, &t);
            })
            .unwrap();
    }

    /// Render com todos os campos preenchidos em 40x3 não deve entrar em pânico.
    #[test]
    fn render_todos_campos_40x3() {
        let mut terminal = make_terminal(40, 3);
        let t = theme(ColorDepth::Ansi256);

        terminal
            .draw(|f| {
                let area = Rect::new(0, 0, 40, 3);
                let props = StatusBarProps {
                    route: "/prd",
                    tokens_pct: Some(99),
                    tokens_abs: Some("200 k"),
                    cost: Some("$1.20"),
                    shortcuts: "q sair",
                    tip: Some("Dica curta"),
                };
                render(f, area, &props, &t);
            })
            .unwrap();
    }

    /// Render com todos os campos opcionais como `None` em 40x3 não deve entrar em pânico.
    #[test]
    fn render_opcionais_none_40x3() {
        let mut terminal = make_terminal(40, 3);
        let t = theme(ColorDepth::TrueColor);

        terminal
            .draw(|f| {
                let area = Rect::new(0, 0, 40, 3);
                let props = StatusBarProps {
                    route: "/tasks",
                    tokens_pct: None,
                    tokens_abs: None,
                    cost: None,
                    shortcuts: "↵ confirmar",
                    tip: None,
                };
                render(f, area, &props, &t);
            })
            .unwrap();
    }

    /// Apenas `tokens_pct` presente — sem pânico e formato esperado.
    #[test]
    fn render_apenas_tokens_pct() {
        let mut terminal = make_terminal(80, 2);
        let t = theme(ColorDepth::Ansi16);

        terminal
            .draw(|f| {
                let area = Rect::new(0, 0, 80, 2);
                let props = StatusBarProps {
                    route: "/execution",
                    tokens_pct: Some(75),
                    tokens_abs: None,
                    cost: None,
                    shortcuts: "Ctrl+C cancelar",
                    tip: None,
                };
                render(f, area, &props, &t);
            })
            .unwrap();
    }

    /// Apenas `tokens_abs` presente — sem pânico.
    #[test]
    fn render_apenas_tokens_abs() {
        let mut terminal = make_terminal(80, 2);
        let t = theme(ColorDepth::Ansi256);

        terminal
            .draw(|f| {
                let area = Rect::new(0, 0, 80, 2);
                let props = StatusBarProps {
                    route: "/review",
                    tokens_pct: None,
                    tokens_abs: Some("8 k"),
                    cost: None,
                    shortcuts: "",
                    tip: None,
                };
                render(f, area, &props, &t);
            })
            .unwrap();
    }

    /// Apenas `cost` presente — sem pânico.
    #[test]
    fn render_apenas_cost() {
        let mut terminal = make_terminal(80, 2);
        let t = theme(ColorDepth::TrueColor);

        terminal
            .draw(|f| {
                let area = Rect::new(0, 0, 80, 2);
                let props = StatusBarProps {
                    route: "/memory",
                    tokens_pct: None,
                    tokens_abs: None,
                    cost: Some("$0.001"),
                    shortcuts: "↑↓ rolar",
                    tip: Some("Memória compactada com sucesso"),
                };
                render(f, area, &props, &t);
            })
            .unwrap();
    }
}