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, Wrap};
use ratatui::Frame;

use crate::app::App;

const PROJECT_TYPES: &[&str] = &["app", "mod"];
const LANGUAGES: &[&str] = &["ts", "js"];

const TYPE_LABELS: &[&str] = &["App", "Mod"];
const LANG_LABELS: &[&str] = &["TypeScript", "JavaScript"];

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScaffoldField {
    Name,
    Type,
    Lang,
}

impl ScaffoldField {
    #[cfg(test)]
    pub fn next(self) -> Option<ScaffoldField> {
        match self {
            ScaffoldField::Name => Some(ScaffoldField::Type),
            ScaffoldField::Type => Some(ScaffoldField::Lang),
            ScaffoldField::Lang => None,
        }
    }

    #[cfg(test)]
    pub fn prev(self) -> Option<ScaffoldField> {
        match self {
            ScaffoldField::Name => None,
            ScaffoldField::Type => Some(ScaffoldField::Name),
            ScaffoldField::Lang => Some(ScaffoldField::Type),
        }
    }
}

pub fn render(frame: &mut Frame, area: Rect, app: &App) {
    let has_result = app.result_fragment.is_some();
    let active_field = app.scaffold_field;

    let mut constraints = vec![
        Constraint::Length(1),
        Constraint::Length(1),
        Constraint::Length(3),
        Constraint::Length(1),
        Constraint::Length(3),
        Constraint::Length(1),
        Constraint::Length(3),
    ];

    if !has_result {
        constraints.push(Constraint::Length(1));
    }

    constraints.push(Constraint::Min(1));

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

    let title = Paragraph::new(Line::from(Span::styled(
        "Configure your new xript project:",
        Style::default().fg(Color::Gray),
    )));
    frame.render_widget(title, chunks[0]);

    let name_active = active_field == ScaffoldField::Name && !has_result;
    let name_label_style = if name_active {
        Style::default()
            .fg(Color::Cyan)
            .add_modifier(Modifier::BOLD)
    } else {
        Style::default().fg(Color::DarkGray)
    };
    let name_label = Paragraph::new(Line::from(Span::styled("Project name", name_label_style)));
    frame.render_widget(name_label, chunks[1]);

    let cursor = if name_active { "\u{2588}" } else { "" };
    let name_value = if name_active {
        format!("{}{}", app.input, cursor)
    } else {
        let n = &app.scaffold_name;
        if n.is_empty() {
            "my-xript-project".to_string()
        } else {
            n.clone()
        }
    };

    let name_border_color = if name_active {
        Color::Cyan
    } else {
        Color::DarkGray
    };
    let name_block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(name_border_color));
    let name_para = Paragraph::new(Line::from(vec![
        Span::styled("> ", Style::default().fg(Color::Cyan)),
        Span::styled(
            name_value,
            Style::default().fg(if name_active {
                Color::Cyan
            } else {
                Color::White
            }),
        ),
    ]))
    .block(name_block);
    frame.render_widget(name_para, chunks[2]);

    let type_active = active_field == ScaffoldField::Type && !has_result;
    let type_label_style = if type_active {
        Style::default()
            .fg(Color::Cyan)
            .add_modifier(Modifier::BOLD)
    } else {
        Style::default().fg(Color::DarkGray)
    };
    let type_label = Paragraph::new(Line::from(Span::styled("Project type", type_label_style)));
    frame.render_widget(type_label, chunks[3]);

    render_toggle_cards(
        frame,
        chunks[4],
        TYPE_LABELS,
        app.scaffold_type_idx,
        type_active,
    );

    let lang_active = active_field == ScaffoldField::Lang && !has_result;
    let lang_label_style = if lang_active {
        Style::default()
            .fg(Color::Cyan)
            .add_modifier(Modifier::BOLD)
    } else {
        Style::default().fg(Color::DarkGray)
    };
    let lang_label = Paragraph::new(Line::from(Span::styled("Language", lang_label_style)));
    frame.render_widget(lang_label, chunks[5]);

    render_toggle_cards(
        frame,
        chunks[6],
        LANG_LABELS,
        app.scaffold_lang_idx,
        lang_active,
    );

    let mut next_chunk = 7;

    if !has_result {
        let hint = match active_field {
            ScaffoldField::Name => {
                "\u{2191}\u{2193} navigate fields \u{00b7} Enter/Tab for next \u{00b7} Esc to go back"
            }
            ScaffoldField::Type => {
                "\u{2190}\u{2192} change selection \u{00b7} \u{2191}\u{2193} navigate fields \u{00b7} Esc to go back"
            }
            ScaffoldField::Lang => {
                "\u{2190}\u{2192} change selection \u{00b7} Enter to generate \u{00b7} Esc to go back"
            }
        };
        let hint_para = Paragraph::new(Line::from(Span::styled(
            hint,
            Style::default().fg(Color::DarkGray),
        )));
        frame.render_widget(hint_para, chunks[next_chunk]);
        next_chunk += 1;
    }

    if let Some(ref result) = app.result_fragment {
        let text = match result.as_str() {
            Some(s) => s.to_string(),
            None => result.to_string(),
        };

        let result_block = Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(Color::Green))
            .title(Span::styled(
                " Preview ",
                Style::default()
                    .fg(Color::Green)
                    .add_modifier(Modifier::BOLD),
            ));

        let result_para = Paragraph::new(text)
            .style(Style::default().fg(Color::Green))
            .block(result_block)
            .wrap(Wrap { trim: false });

        if next_chunk < chunks.len() {
            frame.render_widget(result_para, chunks[next_chunk]);
        }
    }
}

fn render_toggle_cards(
    frame: &mut Frame,
    area: Rect,
    labels: &[&str],
    selected_idx: usize,
    row_active: bool,
) {
    let card_count = labels.len();
    let gap: u16 = 2;
    let total_gap = (card_count.saturating_sub(1) as u16) * gap;
    let card_width = area
        .width
        .saturating_sub(total_gap)
        .checked_div(card_count as u16)
        .unwrap_or(10)
        .min(24);

    let mut constraints: Vec<Constraint> = Vec::new();
    for i in 0..card_count {
        constraints.push(Constraint::Length(card_width));
        if i < card_count - 1 {
            constraints.push(Constraint::Length(gap));
        }
    }
    constraints.push(Constraint::Min(0));

    let chunks = Layout::default()
        .direction(Direction::Horizontal)
        .constraints(&constraints)
        .split(area);

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

        let is_selected = i == selected_idx;

        let (border_color, text_color, text_mod) = if is_selected && row_active {
            (Color::Cyan, Color::Cyan, Modifier::BOLD)
        } else if is_selected {
            (Color::Gray, Color::White, Modifier::BOLD)
        } else {
            (Color::DarkGray, Color::DarkGray, Modifier::empty())
        };

        let indicator = if is_selected { "\u{25cf} " } else { "\u{25cb} " };

        let block = Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(border_color));

        let inner = block.inner(chunks[chunk_idx]);
        frame.render_widget(block, chunks[chunk_idx]);

        let line = Line::from(vec![
            Span::styled(indicator, Style::default().fg(text_color)),
            Span::styled(
                label.to_string(),
                Style::default().fg(text_color).add_modifier(text_mod),
            ),
        ]);
        let para = Paragraph::new(line);
        frame.render_widget(para, inner);
    }
}

pub fn cycle_type(idx: usize, forward: bool) -> usize {
    let len = PROJECT_TYPES.len();
    if forward {
        (idx + 1) % len
    } else {
        (idx + len - 1) % len
    }
}

pub fn cycle_lang(idx: usize, forward: bool) -> usize {
    let len = LANGUAGES.len();
    if forward {
        (idx + 1) % len
    } else {
        (idx + len - 1) % len
    }
}

pub fn generate_preview(name: &str, type_idx: usize, lang_idx: usize) -> serde_json::Value {
    let project_type = PROJECT_TYPES[type_idx];
    let lang = LANGUAGES[lang_idx];
    let ext = lang;
    let project_name = if name.is_empty() {
        "my-xript-project"
    } else {
        name
    };

    let mut files = vec![format!("{}/", project_name)];

    if project_type == "app" {
        files.push("  manifest.json".to_string());
        files.push("  src/".to_string());
        files.push(format!("    main.{}", ext));
        files.push("  package.json".to_string());
        if lang == "ts" {
            files.push("    tsconfig.json".to_string());
        }
    } else {
        files.push("  mod-manifest.json".to_string());
        files.push("  src/".to_string());
        files.push(format!("    mod.{}", ext));
        files.push("  fragments/".to_string());
        files.push("    panel.html".to_string());
        files.push("  package.json".to_string());
        if lang == "ts" {
            files.push("    tsconfig.json".to_string());
        }
    }

    let type_label = TYPE_LABELS[type_idx];
    let lang_label = LANG_LABELS[lang_idx];

    let preview = format!(
        "\u{2714} Would generate {} project \"{}\" ({}):\n{}",
        type_label,
        project_name,
        lang_label,
        files.join("\n")
    );

    serde_json::json!(preview)
}