xript-wiz 0.4.1

Interactive TUI wizard for the xript toolchain — powered by xript fragments.
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;

use crate::app::App;

struct CardDef {
    icon: &'static str,
    name: &'static str,
    description: &'static str,
}

const CARDS: &[CardDef] = &[
    CardDef {
        icon: "\u{2713}",
        name: "Validate",
        description: "Check a manifest against the xript spec",
    },
    CardDef {
        icon: "\u{26a1}",
        name: "Scaffold",
        description: "Create a new app or mod project",
    },
    CardDef {
        icon: "\u{2261}",
        name: "Sanitize",
        description: "Clean dangerous content from HTML fragments",
    },
    CardDef {
        icon: "\u{2690}",
        name: "Audit",
        description: "Analyze manifest capability coverage",
    },
    CardDef {
        icon: "\u{0394}",
        name: "Diff",
        description: "Compare manifest against last git tag",
    },
    CardDef {
        icon: "\u{2715}",
        name: "Quit",
        description: "Exit the wizard",
    },
];

pub fn render(frame: &mut Frame, area: Rect, app: &App) {
    let card_height: u16 = 3;
    let gap: u16 = 1;
    let total_cards = CARDS.len() as u16;
    let total_height = total_cards * card_height + (total_cards - 1) * gap;

    let content_area = if area.height > total_height + 2 {
        let top_pad = (area.height - total_height) / 3;
        Rect {
            x: area.x,
            y: area.y + top_pad,
            width: area.width,
            height: total_height.min(area.height.saturating_sub(top_pad)),
        }
    } else {
        area
    };

    let card_width = 44.min(area.width.saturating_sub(4));
    let left_pad = (area.width.saturating_sub(card_width)) / 2;

    let mut constraints: Vec<Constraint> = Vec::new();
    for i in 0..CARDS.len() {
        constraints.push(Constraint::Length(card_height));
        if i < CARDS.len() - 1 {
            constraints.push(Constraint::Length(gap));
        }
    }
    if content_area.height > total_height {
        constraints.push(Constraint::Min(0));
    }

    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints(&constraints)
        .split(content_area);

    for (i, card_def) in CARDS.iter().enumerate() {
        let chunk_idx = i * 2;
        if chunk_idx >= chunks.len() {
            break;
        }

        let card_area = Rect {
            x: chunks[chunk_idx].x + left_pad,
            y: chunks[chunk_idx].y,
            width: card_width,
            height: card_height,
        };

        render_card(frame, card_area, card_def, i == app.selected);
    }
}

fn render_card(frame: &mut Frame, area: Rect, card: &CardDef, selected: bool) {
    if selected {
        let title_line = Line::from(vec![
            Span::styled(
                format!(" {} ", card.icon),
                Style::default()
                    .fg(Color::Cyan)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::styled(
                card.name,
                Style::default()
                    .fg(Color::Cyan)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::styled(" ", Style::default()),
        ]);

        let block = Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(Color::Cyan))
            .title(title_line);

        let inner = block.inner(area);
        frame.render_widget(block, area);

        let desc = Paragraph::new(Line::from(Span::styled(
            card.description,
            Style::default().fg(Color::White),
        )));
        frame.render_widget(desc, inner);
    } else {
        let block = Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(Color::DarkGray));

        let inner = block.inner(area);
        frame.render_widget(block, area);

        let line = Line::from(vec![
            Span::styled(
                format!(" {} ", card.icon),
                Style::default().fg(Color::DarkGray),
            ),
            Span::styled(
                card.name,
                Style::default()
                    .fg(Color::Gray)
                    .add_modifier(Modifier::BOLD),
            ),
        ]);
        let title_para = Paragraph::new(line);

        let title_area = Rect {
            x: inner.x,
            y: inner.y,
            width: inner.width,
            height: 1.min(inner.height),
        };
        frame.render_widget(title_para, title_area);
    }
}

pub fn menu_len() -> usize {
    CARDS.len()
}