fp-dotfiles-tui 0.2.5

Minimal premium TUI for fp-dotfiles-manager
//! Minimal premium TUI frontend for `fp-dotfiles-manager`.
//! Shells out to the CLI; keeps the original binary zero-dep and tiny.

mod app;
mod commands;
mod theme;
mod ui;

use app::{Action, App, Mode};
use crossterm::{
    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::{
    env,
    error::Error,
    io::{self, stdout},
    process::Command,
    time::Duration,
};

fn main() -> Result<(), Box<dyn Error>> {
    let cli = resolve_cli();
    let mut app = App::new(cli);

    enable_raw_mode()?;
    let mut out = stdout();
    execute!(out, EnterAlternateScreen, EnableMouseCapture)?;
    let backend = CrosstermBackend::new(out);
    let mut terminal = Terminal::new(backend)?;

    let result = run_loop(&mut terminal, &mut app);

    disable_raw_mode()?;
    execute!(
        terminal.backend_mut(),
        LeaveAlternateScreen,
        DisableMouseCapture
    )?;
    terminal.show_cursor()?;

    if let Some(cmd) = app.take_pending_interactive() {
        // Interactive commands (edit, cd, edit-lists) need a normal TTY.
        let status = Command::new(&app.cli)
            .args(&cmd)
            .status();
        if let Err(e) = status {
            eprintln!("failed to run {:?}: {e}", cmd);
        }
    }

    result
}

fn resolve_cli() -> String {
    env::var("FP_DOTFILES_MANAGER")
        .or_else(|_| env::var("DOTFILES_MANAGER_BIN"))
        .unwrap_or_else(|_| "fp-dotfiles-manager".into())
}

fn run_loop(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
    app: &mut App,
) -> Result<(), Box<dyn Error>> {
    loop {
        terminal.draw(|f| ui::draw(f, app))?;

        if !event::poll(Duration::from_millis(80))? {
            continue;
        }

        let Event::Key(key) = event::read()? else {
            continue;
        };
        if key.kind != KeyEventKind::Press {
            continue;
        }

        // Global quit from main menu
        if app.mode == Mode::Menu
            && key.modifiers == KeyModifiers::NONE
            && matches!(
                key.code,
                KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc
            )
        {
            return Ok(());
        }

        if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
            return Ok(());
        }

        match app.mode {
            Mode::Menu => handle_menu(app, key.code)?,
            Mode::Prompt => handle_prompt(app, key.code)?,
            Mode::Output => handle_output(app, key.code)?,
            Mode::Confirm => handle_confirm(app, key.code)?,
            Mode::Help => {
                if matches!(
                    key.code,
                    KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') | KeyCode::Enter
                ) {
                    app.mode = Mode::Menu;
                }
            }
        }

        if app.should_quit {
            return Ok(());
        }
    }
}

fn handle_menu(app: &mut App, code: KeyCode) -> io::Result<()> {
    match code {
        KeyCode::Down | KeyCode::Char('j') => app.next(),
        KeyCode::Up | KeyCode::Char('k') => app.prev(),
        KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => app.next_category(),
        KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => app.prev_category(),
        KeyCode::Home => app.select_first(),
        KeyCode::End => app.select_last(),
        KeyCode::Char('?') => app.mode = Mode::Help,
        KeyCode::Char('r') => {
            app.run_selected()?;
        }
        KeyCode::Enter | KeyCode::Char(' ') => {
            let action = app.selected_action();
            match action {
                Action::Help => app.mode = Mode::Help,
                Action::Run { needs_confirm, .. } if needs_confirm => {
                    app.mode = Mode::Confirm;
                }
                Action::Run { needs_input, .. } if needs_input => {
                    app.begin_prompt();
                }
                Action::Run { .. } => app.run_selected()?,
            }
        }
        KeyCode::Char(c) if c.is_ascii_digit() => {
            let n = c.to_digit(10).unwrap_or(0) as usize;
            if n >= 1 && n <= app.current_items().len() {
                app.selected = n - 1;
            }
        }
        _ => {}
    }
    Ok(())
}

fn handle_prompt(app: &mut App, code: KeyCode) -> io::Result<()> {
    match code {
        KeyCode::Esc => {
            app.input.clear();
            app.mode = Mode::Menu;
        }
        KeyCode::Enter => {
            if app.input.trim().is_empty() && app.prompt_required {
                app.status = "input required".into();
            } else {
                app.submit_prompt()?;
            }
        }
        KeyCode::Backspace => {
            app.input.pop();
        }
        KeyCode::Char(c) => {
            app.input.push(c);
        }
        _ => {}
    }
    Ok(())
}

fn handle_output(app: &mut App, code: KeyCode) -> io::Result<()> {
    match code {
        KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {
            app.mode = Mode::Menu;
            app.output.clear();
        }
        KeyCode::Down | KeyCode::Char('j') => app.scroll_output(1),
        KeyCode::Up | KeyCode::Char('k') => app.scroll_output(-1),
        KeyCode::PageDown => app.scroll_output(10),
        KeyCode::PageUp => app.scroll_output(-10),
        KeyCode::Home => app.output_scroll = 0,
        KeyCode::End => {
            let max = app.output.lines().count().saturating_sub(1);
            app.output_scroll = max;
        }
        _ => {}
    }
    Ok(())
}

fn handle_confirm(app: &mut App, code: KeyCode) -> io::Result<()> {
    match code {
        KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
            app.confirm_yes = true;
            app.run_selected()?;
        }
        KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
            app.mode = Mode::Menu;
            app.status = "cancelled".into();
        }
        _ => {}
    }
    Ok(())
}