changxi 0.3.0

TUI EPUB Reader
mod app;
mod cli;
mod config;
mod core;
mod error;
mod keybindings;
mod progress;
mod ui;

use crate::app::App;
use crate::cli::{get_profile_path, scan_directory};
use crate::config::Config;
use crate::core::EpubReader;
use crate::keybindings::{Action, handle_key_event};
use crate::progress::ProgressStore;
use crate::ui::{BookView, ContinuousView, DefaultView, View};
use crossterm::{
    event::{self, DisableMouseCapture, EnableMouseCapture, Event},
    execute,
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
    Terminal,
    backend::{Backend, CrosstermBackend},
};
use ratatui_image::picker::Picker;
use std::path::{Path, PathBuf};
use std::process::exit;
use std::{error::Error, io};

fn main() -> Result<(), Box<dyn Error>> {
    let args: Vec<String> = std::env::args().collect();

    let mut profile_dir: Option<PathBuf> = None;
    let mut scan_dir: Option<String> = None;
    let mut epub_path: Option<String> = None;

    let mut i = 1;
    while i < args.len() {
        match args[i].as_str() {
            "--profile" => {
                if i + 1 < args.len() {
                    profile_dir = Some(get_profile_path(&args[i + 1]));
                    i += 2;
                } else {
                    eprintln!("Error: --profile requires a name");
                    exit(1);
                }
            }
            "--scan" => {
                if i + 1 < args.len() {
                    scan_dir = Some(args[i + 1].clone());
                    i += 2;
                } else {
                    eprintln!("Error: --scan requires a directory");
                    exit(1);
                }
            }
            path if !path.starts_with('-') => {
                epub_path = Some(path.to_string());
                i += 1;
            }
            _ => {
                eprintln!("Unknown argument: {}", args[i]);
                exit(1);
            }
        }
    }

    if let Some(ref dir) = scan_dir {
        scan_directory(Path::new(dir), profile_dir.clone())?;
        if epub_path.is_none() {
            return Ok(());
        }
    }

    let progress_path = profile_dir.as_ref().map(|p| {
        let mut p = p.clone();
        p.push("progress.json");
        p
    });
    let progress_store = ProgressStore::load(progress_path);

    let mut current_path = if let Some(path) = epub_path {
        path
    } else if let Some(ref last_path) = progress_store.last_book_path {
        last_path.clone()
    } else {
        eprintln!("Usage: {} [options] <epub_file>", args[0]);
        eprintln!("Options:");
        eprintln!("  --profile <name>  Use a specific profile");
        eprintln!("  --scan <dir>      Scan directory for EPUB files");
        exit(1);
    };

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

    loop {
        // Convert to absolute path
        let absolute_path = match std::fs::canonicalize(&current_path) {
            Ok(p) => p,
            Err(e) => {
                disable_raw_mode()?;
                execute!(
                    terminal.backend_mut(),
                    LeaveAlternateScreen,
                    DisableMouseCapture
                )?;
                terminal.show_cursor()?;
                eprintln!("Failed to resolve path '{}': {}", current_path, e);
                return Ok(());
            }
        };
        let file_path_str = absolute_path.to_string_lossy().to_string();

        let reader = match EpubReader::new(&file_path_str) {
            Ok(r) => r,
            Err(e) => {
                disable_raw_mode()?;
                execute!(
                    terminal.backend_mut(),
                    LeaveAlternateScreen,
                    DisableMouseCapture
                )?;
                terminal.show_cursor()?;
                eprintln!("Failed to open EPUB '{}': {}", file_path_str, e);
                return Ok(());
            }
        };

        let book_id = Path::new(&file_path_str)
            .file_name()
            .map(|n| n.to_string_lossy().into_owned())
            .unwrap_or_else(|| file_path_str.clone());

        let progress_path = profile_dir.as_ref().map(|p| {
            let mut p = p.clone();
            p.push("progress.json");
            p
        });
        let progress_store = ProgressStore::load(progress_path);
        let book_progress = progress_store.get_book(&book_id);
        let mut picker = Picker::from_query_stdio().unwrap_or_else(|_| Picker::halfblocks());

        let config_path = profile_dir.as_ref().map(|p| {
            let mut p = p.clone();
            p.push("config.toml");
            p
        });
        let config = Config::load(config_path);
        let mut app = App::new(
            reader,
            book_id,
            file_path_str,
            &mut picker,
            book_progress,
            config.clone(),
            profile_dir.clone(),
        );

        let mut picker_clone = Picker::from_query_stdio().unwrap_or_else(|_| Picker::halfblocks());
        let res = run_app(&mut terminal, &mut app, &mut picker_clone, &config);

        // Save progress before switching or exiting
        app.save_progress();

        match res {
            Ok(Some(new_path)) => {
                current_path = new_path;
                terminal.clear()?;
            }
            Ok(None) => break,
            Err(err) => {
                disable_raw_mode()?;
                execute!(
                    terminal.backend_mut(),
                    LeaveAlternateScreen,
                    DisableMouseCapture
                )?;
                terminal.show_cursor()?;
                return Err(err);
            }
        }
    }

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

    Ok(())
}

fn run_app<B: Backend>(
    terminal: &mut Terminal<B>,
    app: &mut App,
    picker: &mut Picker,
    _config: &Config,
) -> Result<Option<String>, Box<dyn Error>>
where
    B::Error: 'static,
{
    let default_view = DefaultView::new();
    let book_view = BookView::new();
    let continuous_view = ContinuousView::new();

    loop {
        let view: &dyn View = match app.config.view_type.as_str() {
            "book" => &book_view,
            "continuous" => &continuous_view,
            _ => &default_view,
        };
        terminal.draw(|f| view.render(f, app, picker))?;

        if let Event::Key(key) = event::read()? {
            match handle_key_event(app, key, picker)? {
                Action::Exit => return Ok(None),
                Action::SwitchBook(path) => return Ok(Some(path)),
                Action::None => {}
            }
        }
    }
}