pacc 0.1.0

A TUI assistant for pacman, paru, and AUR workflows on Arch Linux
use ratatui::{
    Frame,
    layout::{Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style, Stylize},
    text::{Line, Span},
    widgets::{
        Block, Borders, Cell, Clear, List, ListItem, ListState, Paragraph, Row, Table, TableState,
    },
};

use crate::app::{App, Focus};

pub fn ui(frame: &mut Frame, app: &App) {
    let layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(3),
            Constraint::Min(14),
            Constraint::Length(8),
        ])
        .split(frame.area());

    render_header(frame, app, layout[0]);
    render_body(frame, app, layout[1]);
    render_logs(frame, app, layout[2]);
}

fn render_header(frame: &mut Frame, app: &App, area: Rect) {
    let spans = Focus::ALL.into_iter().map(|focus| {
        let active = focus == app.focus();
        let style = if active {
            Style::default()
                .fg(Color::Black)
                .bg(Color::Cyan)
                .add_modifier(Modifier::BOLD)
        } else {
            Style::default().fg(Color::Gray)
        };

        Span::styled(format!(" {} ", focus.title()), style)
    });

    let title = Paragraph::new(Line::from(vec![
        Span::styled(" pacc ", Style::default().fg(Color::Black).bg(Color::Green)),
        Span::raw("  Arch package assistant  "),
        Span::raw("Tab switch"),
        Span::raw("  "),
        Span::raw("j/k move"),
        Span::raw("  "),
        Span::raw("Enter act"),
        Span::raw("  "),
        Span::raw("r refresh"),
        Span::raw("  "),
        Span::raw("q quit"),
    ]))
    .block(
        Block::default()
            .borders(Borders::ALL)
            .title(Line::from(spans.collect::<Vec<_>>())),
    );

    frame.render_widget(title, area);
}

fn render_body(frame: &mut Frame, app: &App, area: Rect) {
    let columns = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage(34),
            Constraint::Percentage(38),
            Constraint::Percentage(28),
        ])
        .split(area);

    render_overview(frame, app, columns[0]);
    render_packages(frame, app, columns[1]);
    render_actions(frame, app, columns[2]);
}

fn render_overview(frame: &mut Frame, app: &App, area: Rect) {
    let snapshot = app.snapshot();
    let lines = vec![
        Line::from(vec![
            Span::styled("pacman: ", Style::default().add_modifier(Modifier::BOLD)),
            status_span(snapshot.pacman.badge()),
        ]),
        Line::from(vec![
            Span::styled("paru:   ", Style::default().add_modifier(Modifier::BOLD)),
            status_span(snapshot.paru.badge()),
        ]),
        Line::raw(""),
        Line::raw("Planned modules"),
        Line::raw("1. sync and upgrade"),
        Line::raw("2. local package search"),
        Line::raw("3. AUR inspection"),
        Line::raw("4. orphan and cache cleanup"),
    ];

    let block = panel_block("System", app.focus() == Focus::Overview);
    let paragraph = Paragraph::new(lines).block(block);
    frame.render_widget(paragraph, area);
}

fn render_packages(frame: &mut Frame, app: &App, area: Rect) {
    let rows = app.snapshot().packages.iter().map(|pkg| {
        Row::new(vec![
            Cell::from(pkg.name.as_str()),
            Cell::from(pkg.source.label()),
            Cell::from(pkg.state_label()),
        ])
    });

    let widths = [
        Constraint::Percentage(58),
        Constraint::Percentage(20),
        Constraint::Percentage(22),
    ];

    let table = Table::new(rows, widths)
        .header(
            Row::new(vec!["Package", "Source", "State"]).style(
                Style::default()
                    .fg(Color::Yellow)
                    .add_modifier(Modifier::BOLD),
            ),
        )
        .row_highlight_style(
            Style::default()
                .bg(Color::DarkGray)
                .add_modifier(Modifier::BOLD),
        )
        .block(panel_block("Packages", app.focus() == Focus::Packages));

    let mut state = TableState::default().with_selected(Some(app.selected_package()));
    frame.render_stateful_widget(table, area, &mut state);
}

fn render_actions(frame: &mut Frame, app: &App, area: Rect) {
    let items = app.actions().iter().map(|action| {
        ListItem::new(Line::from(vec![
            Span::styled("", Style::default().fg(Color::LightBlue)),
            Span::raw(*action),
        ]))
    });

    let list = List::new(items)
        .highlight_style(
            Style::default()
                .bg(Color::DarkGray)
                .add_modifier(Modifier::BOLD),
        )
        .highlight_symbol(">")
        .block(panel_block("Action Stubs", app.focus() == Focus::Actions));

    let mut state = ListState::default();
    state.select(Some(app.selected_action()));
    frame.render_stateful_widget(list, area, &mut state);
}

fn render_logs(frame: &mut Frame, app: &App, area: Rect) {
    frame.render_widget(Clear, area);
    let lines = app
        .logs()
        .iter()
        .map(|line| Line::from(Span::raw(line.as_str())))
        .collect::<Vec<_>>();

    let paragraph =
        Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title("Logs"));
    frame.render_widget(paragraph, area);
}

fn panel_block<'a>(title: &'a str, focused: bool) -> Block<'a> {
    let border_style = if focused {
        Style::default().fg(Color::Cyan)
    } else {
        Style::default().fg(Color::DarkGray)
    };

    Block::default()
        .title(title.bold())
        .borders(Borders::ALL)
        .border_style(border_style)
}

fn status_span(status: &str) -> Span<'static> {
    match status {
        "ready" => Span::styled(status.to_string(), Style::default().fg(Color::Green)),
        _ => Span::styled(status.to_string(), Style::default().fg(Color::Red)),
    }
}