bawa 0.1.0

A TUI game save organizer
Documentation
use super::MergeConfig;
use crate::commands::{
    Command, ConfirmationCommand, GameSelectionCommand, HelpCommand, ProfileSelectionCommand,
};
use anyhow::{Context, Result};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use indexmap::IndexMap;
use serde::Deserialize;
use std::{
    collections::HashMap,
    ops::{Deref, DerefMut},
};

#[derive(Deserialize)]
pub struct UserKeyBindings {
    #[serde(flatten)]
    general: Option<HashMap<String, String>>,
    game_selection: Option<HashMap<String, String>>,
    profile_selection: Option<HashMap<String, String>>,
    help: Option<HashMap<String, String>>,
    confirmation: Option<HashMap<String, String>>,
}

fn parse_binding(binding: &str) -> Result<KeyEvent> {
    let mut tokens = binding.rsplit('-');

    let code = if let Some(token) = tokens.next() {
        match token {
            "backspace" => KeyCode::Backspace,
            "space" => KeyCode::Char(' '),
            "enter" => KeyCode::Enter,
            "left" => KeyCode::Left,
            "right" => KeyCode::Right,
            "up" => KeyCode::Up,
            "down" => KeyCode::Down,
            "home" => KeyCode::Home,
            "end" => KeyCode::End,
            "pageup" => KeyCode::PageUp,
            "pagedown" => KeyCode::PageDown,
            "tab" => KeyCode::Tab,
            "backtab" => KeyCode::BackTab,
            "del" | "delete" => KeyCode::Delete,
            "insert" => KeyCode::Insert,
            "esc" | "escape" => KeyCode::Esc,
            _ => {
                if token.len() == 1 {
                    KeyCode::Char(token.chars().next().unwrap())
                } else if let Some(numbers) = token
                    .strip_prefix('f')
                    .and_then(|numbers| numbers.parse::<u8>().ok())
                {
                    KeyCode::F(numbers)
                } else {
                    anyhow::bail!("\"{}\" is not a valid key", token)
                }
            }
        }
    } else {
        anyhow::bail!("\"{}\" is not a valid binding", binding)
    };

    let mut modifiers = KeyModifiers::NONE;

    for token in tokens {
        match token {
            "ctrl" => modifiers.insert(KeyModifiers::CONTROL),
            "shift" => modifiers.insert(KeyModifiers::SHIFT),
            "alt" => modifiers.insert(KeyModifiers::ALT),
            _ => anyhow::bail!("\"{}\" is not a valid modifier", token),
        }
    }

    Ok(KeyEvent::new(code, modifiers))
}

#[derive(PartialEq, Eq, Debug)]
pub struct KeyBindings {
    pub general: IndexMap<KeyEvent, Command>,
    pub game_selection: IndexMap<KeyEvent, GameSelectionCommand>,
    pub profile_selection: IndexMap<KeyEvent, ProfileSelectionCommand>,
    pub help: IndexMap<KeyEvent, HelpCommand>,
    pub confirmation: IndexMap<KeyEvent, ConfirmationCommand>,
}

impl Default for KeyBindings {
    #[rustfmt::skip]
    fn default() -> Self {
        let mut general = IndexMap::new();
        let mut game_selection = IndexMap::new();
        let mut profile_selection = IndexMap::new();
        let mut help = IndexMap::new();
        let mut confirmation = IndexMap::new();

        macro_rules! insert_binding {
            ($map: expr, $key: expr, $command: expr) => {
                $map.insert(parse_binding($key).unwrap(), $command);
            };
        }

        insert_binding!(general, "j", Command::OnDown);
        insert_binding!(general, "down", Command::OnDown);
        insert_binding!(general, "k", Command::OnUp);
        insert_binding!(general, "up", Command::OnUp);
        insert_binding!(general, "h", Command::OnLeft);
        insert_binding!(general, "left", Command::OnLeft);
        insert_binding!(general, "l", Command::OnRight);
        insert_binding!(general, "right", Command::OnRight);
        insert_binding!(general, "g", Command::SelectFirst);
        insert_binding!(general, "G", Command::SelectLast);
        insert_binding!(general, "J", Command::DownDirectory);
        insert_binding!(general, "shift-down", Command::DownDirectory);
        insert_binding!(general, "K", Command::UpDirectory);
        insert_binding!(general, "shift-up", Command::UpDirectory);
        insert_binding!(general, "f", Command::LoadSaveFile);
        insert_binding!(general, "ctrl-f", Command::LoadActiveSaveFile);
        insert_binding!(general, "F", Command::MarkSaveFile);
        insert_binding!(general, "i", Command::ImportSaveFile);
        insert_binding!(general, "I", Command::ImportSaveFileTopLevel);
        insert_binding!(general, "R", Command::ReplaceSaveFile);
        insert_binding!(general, "d", Command::DeleteFile);
        insert_binding!(general, "c", Command::CreateFolder);
        insert_binding!(general, "C", Command::CreateFolderTopLevel);
        insert_binding!(general, "r", Command::Rename);
        insert_binding!(general, "p", Command::MoveEntries);
        insert_binding!(general, "P", Command::MoveEntriesTopLevel);
        insert_binding!(general, "M", Command::MoveUp);
        insert_binding!(general, "m", Command::MoveDown);
        insert_binding!(general, "a", Command::OpenAllFolds);
        insert_binding!(general, "z", Command::CloseAllFolds);
        insert_binding!(general, "W", Command::OpenGameWindow);
        insert_binding!(general, "w", Command::OpenProfileWindow);
        insert_binding!(general, "ctrl-h", Command::ToggleHelp);
        insert_binding!(general, "f1", Command::ToggleHelp);
        insert_binding!(general, "/", Command::EnterSearch);
        insert_binding!(general, "n", Command::RepeatLastSearch);
        insert_binding!(general, "N", Command::RepeatLastSearchBackward);
        insert_binding!(general, "s", Command::OpenFuzzyFinder);
        insert_binding!(general, "S", Command::OpenFuzzyFinderGlobal);
        insert_binding!(general, "space", Command::MarkEntry);
        insert_binding!(general, "esc", Command::Reset);
        insert_binding!(general, "q", Command::Quit);
        insert_binding!(general, "ctrl-c", Command::Quit);

        insert_binding!(game_selection, "c", GameSelectionCommand::Create);
        insert_binding!(game_selection, "r", GameSelectionCommand::Rename);
        insert_binding!(game_selection, "d", GameSelectionCommand::Delete);
        insert_binding!(game_selection, "enter", GameSelectionCommand::Select);
        insert_binding!(game_selection, "s", GameSelectionCommand::SetSavefile);
        insert_binding!(game_selection, "escape", GameSelectionCommand::Abort);

        insert_binding!(profile_selection, "c", ProfileSelectionCommand::Create);
        insert_binding!(profile_selection, "r", ProfileSelectionCommand::Rename);
        insert_binding!(profile_selection, "d", ProfileSelectionCommand::Delete);
        insert_binding!(profile_selection, "enter", ProfileSelectionCommand::Select);
        insert_binding!(profile_selection, "escape", ProfileSelectionCommand::Abort);

        insert_binding!(help, "ctrl-y", HelpCommand::ScrollUp);
        insert_binding!(help, "ctrl-e", HelpCommand::ScrollDown);
        insert_binding!(help, "g", HelpCommand::GoToTop);
        insert_binding!(help, "G", HelpCommand::GoToBottom);
        insert_binding!(help, "esc", HelpCommand::Abort);

        insert_binding!(confirmation, "y", ConfirmationCommand::Confirm);
        insert_binding!(confirmation, "enter", ConfirmationCommand::Confirm);
        insert_binding!(confirmation, "n", ConfirmationCommand::Cancel);
        insert_binding!(confirmation, "esc", ConfirmationCommand::Cancel);
        insert_binding!(confirmation, "ctrl-y", ConfirmationCommand::ScrollUp);
        insert_binding!(confirmation, "ctrl-e", ConfirmationCommand::ScrollDown);
        insert_binding!(confirmation, "g", ConfirmationCommand::GoToTop);
        insert_binding!(confirmation, "G", ConfirmationCommand::GoToBottom);

        Self {
            general,
            game_selection,
            profile_selection,
            help,
            confirmation,
        }
    }
}

fn set_bindings<'a, T, E>(
    key_bindings: &mut IndexMap<KeyEvent, T>,
    user_key_bindings: &'a HashMap<String, String>,
) -> Result<(), anyhow::Error>
where
    T: TryFrom<&'a str, Error = E>,
    E: Into<anyhow::Error>,
{
    for (bindings, command) in user_key_bindings {
        for binding in bindings.split_whitespace() {
            let binding = parse_binding(binding)
                .with_context(|| format!("Error: failed to parse binding \"{binding}\""))?;
            if command.is_empty() {
                key_bindings.swap_remove(&binding);
            } else {
                key_bindings.insert(
                    binding,
                    T::try_from(command.as_str())
                        .map_err(|e| anyhow::anyhow!(e))
                        .with_context(|| format!("Error: failed to parse command \"{command}\""))?,
                );
            }
        }
    }

    Ok(())
}

impl MergeConfig for KeyBindings {
    type Other = UserKeyBindings;

    fn merge(&mut self, user_key_bindings: Self::Other) -> Result<()> {
        if let Some(bindings) = user_key_bindings.general {
            set_bindings(&mut self.general, &bindings)?;
        }

        if let Some(bindings) = user_key_bindings.game_selection {
            set_bindings(&mut self.game_selection, &bindings)?;
        }

        if let Some(bindings) = user_key_bindings.profile_selection {
            set_bindings(&mut self.profile_selection, &bindings)?;
        }

        if let Some(bindings) = user_key_bindings.help {
            set_bindings(&mut self.help, &bindings)?;
        }

        if let Some(bindings) = user_key_bindings.confirmation {
            set_bindings(&mut self.confirmation, &bindings)?;
        }

        Ok(())
    }
}

impl Deref for KeyBindings {
    type Target = IndexMap<KeyEvent, Command>;

    fn deref(&self) -> &Self::Target {
        &self.general
    }
}

impl DerefMut for KeyBindings {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.general
    }
}

#[cfg(test)]
mod tests {
    use crate::config::{keys::UserKeyBindings, tests::read_example_config, Config};

    #[test]
    fn example_up_to_date() {
        let default = Config::default().key_bindings;
        let user_config = read_example_config();

        let UserKeyBindings {
            general,
            game_selection,
            profile_selection,
            help,
            confirmation,
        } = user_config.key_bindings.unwrap();

        assert!(general.is_some_and(|keys| keys.len() == default.general.len()));
        assert!(game_selection.is_some_and(|keys| keys.len() == default.game_selection.len()));
        assert!(profile_selection.is_some_and(|keys| keys.len() == default.profile_selection.len()));
        assert!(help.is_some_and(|keys| keys.len() == default.help.len()));
        assert!(confirmation.is_some_and(|keys| keys.len() == default.confirmation.len()));
    }
}