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() {
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;
}
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(())
}