rdice-tui 0.1.2

Terminal UI for persistent dice trays powered by rdice-core
Documentation
use std::io;
use std::time::Duration;

use crossterm::event::{self, Event, KeyCode, KeyEvent};
use crossterm::execute;
use crossterm::terminal::{
    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use rdice_core::error::{DiceError, Result};
use rdice_tui::app::App;
use rdice_tui::command::parse_command;
use rdice_tui::input::{InputAction, InputState};

fn main() {
    if let Err(err) = run() {
        eprintln!("Error: {err}");
        std::process::exit(1);
    }
}

fn run() -> Result<()> {
    let mut app = App::load_default()?;
    let mut terminal = setup_terminal()?;
    let mut input = InputState::default();

    let app_result = run_app(&mut terminal, &mut app, &mut input);
    let restore_result = restore_terminal(&mut terminal);

    app_result?;
    restore_result
}

fn run_app(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
    app: &mut App,
    input: &mut InputState,
) -> Result<()> {
    while !app.should_quit {
        terminal
            .draw(|frame| rdice_tui::ui::render(frame, app))
            .map_err(to_storage_error)?;

        if event::poll(Duration::from_millis(100)).map_err(to_storage_error)?
            && let Event::Key(key) = event::read().map_err(to_storage_error)?
        {
            handle_key(app, input, key)?;
        }
    }

    Ok(())
}

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

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

fn handle_key(app: &mut App, input: &mut InputState, key: KeyEvent) -> Result<()> {
    if app.dice_creation.is_some() {
        handle_dice_creation_key(app, key);
        return Ok(());
    }

    if app.command_buffer.is_some() {
        handle_command_key(app, key);
        return Ok(());
    }

    let action = match key.code {
        KeyCode::Esc => {
            input.clear();
            Some(InputAction::Escape)
        }
        KeyCode::PageUp => Some(InputAction::PreviousPage),
        KeyCode::PageDown => Some(InputAction::NextPage),
        KeyCode::Char(ch) => {
            let action = input.push(ch);
            if action.is_none() {
                app.message = input.pending_hint().map(str::to_string);
            }
            action
        }
        _ => None,
    };

    if let Some(action) = action {
        match apply_input_action(app, action) {
            Ok(()) => {}
            Err(err) => app.message = Some(err.to_string()),
        }
    }

    Ok(())
}

fn handle_dice_creation_key(app: &mut App, key: KeyEvent) {
    match key.code {
        KeyCode::Esc => app.cancel_dice_creation(),
        KeyCode::Enter => {
            if let Err(err) = app.advance_dice_creation() {
                if let Some(wizard) = &mut app.dice_creation {
                    wizard.error = Some(err.to_string());
                } else {
                    app.message = Some(err.to_string());
                }
            }
        }
        KeyCode::Backspace => app.backspace_dice_creation(),
        KeyCode::Char(ch) => app.push_dice_creation_char(ch),
        _ => {}
    }
}

fn handle_command_key(app: &mut App, key: KeyEvent) {
    match key.code {
        KeyCode::Esc => app.leave_command_mode(),
        KeyCode::Enter => {
            let command_input = app.command_buffer.clone().unwrap_or_default();
            app.leave_command_mode();
            match parse_command(&command_input).and_then(|command| app.apply_command(command)) {
                Ok(()) => {}
                Err(err) => app.message = Some(err.to_string()),
            }
        }
        KeyCode::Backspace => {
            if let Some(buffer) = &mut app.command_buffer
                && buffer.len() > guided_command_prefix_len(buffer)
            {
                buffer.pop();
            }
        }
        KeyCode::Char(ch) => {
            if let Some(buffer) = &mut app.command_buffer {
                buffer.push(ch);
            }
        }
        _ => {}
    }
}

fn guided_command_prefix_len(buffer: &str) -> usize {
    [
        "dice edit ",
        "tray new ",
        "tray rename ",
        "dice delete ",
        "tray delete ",
    ]
    .into_iter()
    .find_map(|prefix| buffer.starts_with(prefix).then_some(prefix.len()))
    .unwrap_or(0)
}

fn apply_input_action(app: &mut App, action: InputAction) -> Result<()> {
    match action {
        InputAction::ToggleTray(id) => match app.screen {
            rdice_tui::app::Screen::Overview => app.toggle_tray_selection(id)?,
            rdice_tui::app::Screen::AddDie(_) => app.add_die_by_page_id(id)?,
            _ => {}
        },
        InputAction::OpenTray(id) => app.open_tray_by_page_id(id)?,
        InputAction::Roll => app.roll_from_current_screen()?,
        InputAction::ToggleText => app.toggle_text_visible(),
        InputAction::ToggleRange => app.toggle_range_visible(),
        InputAction::ToggleEv => app.toggle_ev_visible(),
        InputAction::OpenHistory => app.apply_command(rdice_tui::command::Command::History)?,
        InputAction::PreviousPage => app.previous_page(),
        InputAction::NextPage => app.next_page(),
        InputAction::EnterCommandMode => app.enter_command_mode(),
        InputAction::Quit => app.should_quit = true,
        InputAction::Escape => app.escape(),
        InputAction::AddDie => {
            if let rdice_tui::app::Screen::TrayDetail(name) = &app.screen {
                app.add_die_page = 0;
                app.screen = rdice_tui::app::Screen::AddDie(name.clone());
            }
        }
        InputAction::OpenManager => app.open_context_manager(),
        InputAction::OpenDiceManager => app.open_dice_manager(),
        InputAction::NewTarget => app.prefill_new_target()?,
        InputAction::DeleteTarget(target_id) => match app.screen {
            rdice_tui::app::Screen::TrayDetail(_) | rdice_tui::app::Screen::AddDie(_) => {
                app.remove_slot(target_id)?
            }
            rdice_tui::app::Screen::TrayManager | rdice_tui::app::Screen::DiceManager => {
                app.prefill_delete_target(target_id as usize)?
            }
            _ => {}
        },
        InputAction::EditTarget(target_id) => app.prefill_edit_target(target_id)?,
        InputAction::ToggleSlotLock(slot_id) => app.toggle_slot_lock(slot_id)?,
    }

    Ok(())
}

fn to_storage_error(err: impl std::fmt::Display) -> DiceError {
    DiceError::StorageError(err.to_string())
}

#[cfg(test)]
mod tests {
    use super::guided_command_prefix_len;

    #[test]
    fn guided_command_prefixes_are_protected() {
        assert_eq!(guided_command_prefix_len("dice edit "), "dice edit ".len());
        assert_eq!(guided_command_prefix_len("manager dice"), 0);
    }
}