sdd-layer 0.20.0

Spec-Driven Development CLI and agent harness
//! Componente de modal polido com botões focáveis (T-04).
//! Inspirado no estilo "Update Available" do OpenCode: fundo escurecido,
//! borda no acento primário, título à esquerda + " esc " à direita,
//! corpo em linhas alternadas strong/muted e botões alinhados à direita.
//! Função pura — sem estado global, sem I/O, sem panic em qualquer tamanho de área.

#![allow(dead_code)]

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

// ---------------------------------------------------------------------------
// Estruturas públicas
// ---------------------------------------------------------------------------

/// Representa um botão dentro do modal.
///
/// O campo `focused` determina o estilo visual: fundo no acento primário e
/// negrito quando `true`; texto muted quando `false`.
pub struct ModalButton<'a> {
    /// Texto exibido no botão.
    pub label: &'a str,
    /// Se `true`, o botão recebe destaque visual (foco atual).
    pub focused: bool,
}

// ---------------------------------------------------------------------------
// Função de renderização
// ---------------------------------------------------------------------------

/// Renderiza um modal centralizado na tela com título, corpo e botões.
///
/// # Parâmetros
/// - `frame`: frame do ratatui onde o modal será desenhado.
/// - `screen`: área total disponível (normalmente `frame.area()`).
/// - `title`: texto exibido no lado esquerdo do título da borda.
/// - `body`: fatia de strings; cada item vira uma `Line`. Pode ser vazia.
/// - `buttons`: fatia de [`ModalButton`]; renderizados na última linha, alinhados à direita.
/// - `theme`: tokens semânticos de cor.
///
/// # Garantias
/// - Nunca causa panic independentemente do tamanho de `screen`, `body` ou `buttons`.
/// - Não mantém estado; pode ser chamada a cada frame.
pub fn render(
    frame: &mut Frame,
    screen: Rect,
    title: &str,
    body: &[&str],
    buttons: &[ModalButton],
    theme: &Theme,
) {
    // 1. Escurece o fundo completo da tela.
    theme.overlay_dim(frame, screen);

    // 2. Calcula o retângulo central (60 % de largura × 50 % de altura).
    let modal = theme.centered_rect(60, 50, screen);

    // 3. Limpa a região do modal antes de desenhar.
    frame.render_widget(Clear, modal);

    // 4. Monta as linhas de conteúdo (corpo + linha de botões).
    let mut lines: Vec<Line> = Vec::new();

    // Linha em branco de respiro após a borda do título.
    lines.push(Line::from(""));

    // Corpo: linhas alternadas entre text_strong e text_muted para ritmo visual.
    for (i, text) in body.iter().enumerate() {
        let cor = if i % 2 == 0 {
            theme.text_strong
        } else {
            theme.text_muted
        };
        lines.push(Line::from(Span::styled(
            format!("  {text}"),
            Style::default().fg(cor),
        )));
    }

    // Linha de espaçamento antes dos botões.
    lines.push(Line::from(""));

    // Linha dos botões: construída como spans separados por espaços, alinhados à direita.
    let mut btn_spans: Vec<Span> = Vec::new();
    for (i, btn) in buttons.iter().enumerate() {
        if i > 0 {
            btn_spans.push(Span::raw("  "));
        }
        let estilo = if btn.focused {
            Style::default()
                .bg(theme.primary)
                .fg(theme.on_primary)
                .add_modifier(Modifier::BOLD)
        } else {
            Style::default().fg(theme.text_muted)
        };
        btn_spans.push(Span::styled(format!(" {} ", btn.label), estilo));
    }

    // Padding à esquerda para empurrar botões à direita (linha preenchida com espaços).
    if !btn_spans.is_empty() {
        let mut linha_btn = vec![Span::raw("  ")]; // recuo esquerdo mínimo
        linha_btn.extend(btn_spans);
        lines.push(Line::from(linha_btn));
    }

    // Linha de respiro final.
    lines.push(Line::from(""));

    // 5. Bloco com borda e título duplo (esquerda: título da feature; direita: dica esc).
    let titulo_esq = format!(" {title} ");
    let bloco = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(theme.primary))
        .title(titulo_esq.as_str())
        .title_bottom(Line::from(Span::styled(
            " esc ",
            Style::default().fg(theme.text_muted),
        )));

    // 6. Parágrafo com wrap habilitado para segurança em terminais estreitos.
    let paragrafo = Paragraph::new(Text::from(lines))
        .block(bloco)
        .wrap(Wrap { trim: false });

    frame.render_widget(paragrafo, modal);
}

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

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

    fn make_theme() -> Theme {
        theme::theme(ColorDepth::TrueColor)
    }

    /// Auxiliar: executa `render` dentro de um `Terminal::draw` e retorna `Ok(())` ou panic.
    fn smoke(width: u16, height: u16, body: &[&str], buttons_cfg: &[(&str, bool)]) {
        let backend = TestBackend::new(width, height);
        let mut terminal = Terminal::new(backend).unwrap();
        let t = make_theme();

        terminal
            .draw(|f| {
                let screen = Rect::new(0, 0, width, height);
                let btns: Vec<ModalButton> = buttons_cfg
                    .iter()
                    .map(|(label, focused)| ModalButton {
                        label,
                        focused: *focused,
                    })
                    .collect();
                render(f, screen, "Teste", body, &btns, &t);
            })
            .unwrap();
    }

    // --- testes de não-panic ---

    #[test]
    fn render_dois_botoes_80x24_sem_panic() {
        smoke(
            80,
            24,
            &["Linha de corpo.", "Segunda linha."],
            &[("Confirmar", true), ("Cancelar", false)],
        );
    }

    #[test]
    fn render_dois_botoes_40x12_sem_panic() {
        smoke(40, 12, &["Corpo."], &[("Ok", true), ("Não", false)]);
    }

    #[test]
    fn render_body_vazio_sem_panic() {
        smoke(80, 24, &[], &[("Ok", true)]);
    }

    #[test]
    fn render_multiplas_linhas_sem_panic() {
        let corpo: Vec<&str> = (0..20)
            .map(|_| "linha de texto longa para stress test")
            .collect();
        smoke(
            80,
            24,
            &corpo,
            &[("Sim", true), ("Não", false), ("Cancelar", false)],
        );
    }

    #[test]
    fn render_sem_botoes_sem_panic() {
        smoke(80, 24, &["Apenas corpo, sem botões."], &[]);
    }

    #[test]
    fn render_terminal_minimo_sem_panic() {
        // Terminal muito pequeno: o ratatui deve truncar graciosamente.
        smoke(10, 5, &["x"], &[("Ok", true)]);
    }

    #[test]
    fn render_botao_nao_focado_usa_text_muted() {
        // Smoke: garante que botão não-focado não causa panic.
        smoke(80, 24, &["Corpo"], &[("Confirmar", false)]);
    }

    #[test]
    fn render_botao_focado_usa_primary() {
        // Smoke: garante que botão focado não causa panic.
        smoke(80, 24, &["Corpo"], &[("Confirmar", true)]);
    }
}