void-focus 0.3.0-alpha.3

A feature-rich terminal focus timer with task tracking
Documentation
use std::io::{self, Stdout};
use std::time::Duration;

use anyhow::Result;
use crossterm::event::{self, Event, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{
    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use void::app::App;
use void::ui;

fn main() -> Result<()> {
    let args: Vec<String> = std::env::args().collect();
    if handle_cli(args)? {
        return Ok(());
    }

    let mut terminal = setup_terminal()?;
    let mut app = App::new()?;
    let res = run_app(&mut terminal, &mut app);
    restore_terminal(&mut terminal)?;
    if let Err(e) = res {
        eprintln!("Void error: {e:#}");
        std::process::exit(1);
    }
    Ok(())
}

fn handle_cli(args: Vec<String>) -> Result<bool> {
    if args.len() < 2 {
        return Ok(false);
    }
    match args[1].as_str() {
        "add" => {
            if args.len() < 3 {
                eprintln!("Usage: void add \"Task title\" [--due YYYY-MM-DD|today|tomorrow] [--tags tag1,tag2]");
                return Ok(true);
            }
            let title = args[2].clone();
            let mut due = None;
            let mut tags = Vec::new();

            let mut i = 3;
            while i < args.len() {
                match args[i].as_str() {
                    "--due" => {
                        i += 1;
                        if i < args.len() {
                            let val = args[i].as_str();
                            match void::storage::normalize_due_date(val, false) {
                                Ok(d) => due = d,
                                Err(e) => {
                                    eprintln!("Invalid due date: {}", e);
                                    return Ok(true);
                                }
                            }
                        }
                    }
                    "--tags" => {
                        i += 1;
                        if i < args.len() {
                            tags = void::storage::parse_tags(&args[i]);
                        }
                    }
                    _ => {}
                }
                i += 1;
            }

            let db = void::db::Database::open()?;
            let mut data = db.load_app_data().unwrap_or_default();
            let id = void::storage::add_task_full(
                &db,
                &mut data,
                void::storage::TaskPayload {
                    title: title.clone(),
                    notes: String::new(),
                    estimated_minutes: 25,
                    priority: void::model::Priority::Medium,
                    tags,
                    due_date: due,
                },
            )?;
            println!("Added task: \"{}\" (ID: {})", title, id);
            Ok(true)
        }
        "list" => {
            let db = void::db::Database::open()?;
            let data = db.load_app_data().unwrap_or_default();
            let pending = void::storage::sorted_pending_tasks(&data);
            if pending.is_empty() {
                println!("No pending tasks. You're all caught up!");
            } else {
                println!(
                    "{:<5} | {:<40} | {:<10} | {:<10}",
                    "ID", "TITLE", "PRIORITY", "DUE DATE"
                );
                println!("{:-<5}-+-{:-<40}-+-{:-<10}-+-{:-<10}", "", "", "", "");
                for t in pending {
                    let due = t.due_date.as_deref().unwrap_or("-");
                    println!(
                        "{:<5} | {:<40} | {:<10} | {:<10}",
                        t.id,
                        t.title.chars().take(40).collect::<String>(),
                        t.priority.label(),
                        due
                    );
                }
            }
            Ok(true)
        }
        "done" => {
            if args.len() < 3 {
                eprintln!("Usage: void done <task_id>");
                return Ok(true);
            }
            let id: u64 = args[2].parse().unwrap_or(0);
            let db = void::db::Database::open()?;
            let mut data = db.load_app_data().unwrap_or_default();

            if data.tasks.iter().any(|t| t.id == id) {
                void::storage::mark_task_done(&db, &mut data, id)?;
                println!("Task {} marked as done.", id);
            } else {
                eprintln!("Task {} not found.", id);
            }
            Ok(true)
        }
        "start" => {
            if args.len() < 3 {
                eprintln!("Usage: void start <task_id>");
                return Ok(true);
            }
            let id: u64 = args[2].parse().unwrap_or(0);
            let db = void::db::Database::open()?;
            let mut data = db.load_app_data().unwrap_or_default();

            if data
                .tasks
                .iter()
                .any(|t| t.id == id && t.status != void::model::TaskStatus::Done)
            {
                void::storage::promote_task_on_activate(&db, &mut data, id)?;
                db.persist_active_task(Some(id))?;
                // Return false to let the GUI boot up
                Ok(false)
            } else {
                eprintln!("Task {} not found or already done.", id);
                Ok(true)
            }
        }
        "help" | "--help" | "-h" => {
            println!("Void CLI - Terminal Focus Application\n");
            println!("Commands:");
            println!("  add \"Title\" [--due YYYY-MM-DD|today|tomorrow] [--tags tag1,tag2]");
            println!("  list              (Lists pending tasks)");
            println!("  done <task_id>    (Marks task as complete)");
            println!("  start <task_id>   (Sets task active and launches the GUI)");
            println!("  help              (Shows this message)");
            println!("\nRun without arguments to launch the GUI interface.");
            Ok(true)
        }
        _ => Ok(false),
    }
}

fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout);
    Ok(Terminal::new(backend)?)
}

fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
    terminal.show_cursor()?;
    Ok(())
}

fn set_window_title(title: &str) {
    let _ = execute!(
        io::stdout(),
        crossterm::style::Print(format!("\x1b]0;{}\x07", title.replace('\x1b', "")))
    );
}

fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()>
where
    B::Error: std::error::Error + Send + Sync + 'static,
{
    let mut last_tick = std::time::Instant::now();
    loop {
        app.refresh_chart_if_needed();
        set_window_title(&app.window_title());
        terminal.draw(|f| ui::render(f, app))?;

        let tick_rate = app.tick_rate();
        let timeout = tick_rate
            .checked_sub(last_tick.elapsed())
            .unwrap_or_else(|| Duration::from_millis(0));

        if event::poll(timeout)? {
            if let Event::Key(key) = event::read()? {
                if key.kind == KeyEventKind::Press {
                    app.handle_key(key);
                }
            }
        }
        if last_tick.elapsed() >= tick_rate {
            app.on_tick();
            last_tick = std::time::Instant::now();
        }
        if app.should_quit {
            return Ok(());
        }
    }
}