diff --git a/Cargo.lock b/Cargo.lock index 679f62e..79904f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -238,6 +238,7 @@ dependencies = [ "paste", "serde", "serde_ignored", + "strum", "test-case", "toml", "vte", @@ -505,6 +506,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "rustversion" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" + [[package]] name = "scopeguard" version = "1.1.0" @@ -591,6 +598,28 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strum" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8d03b598d3d0fff69bf533ee3ef19b8eeb342729596df84bcc7e1f96ec4059" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.23", +] + [[package]] name = "syn" version = "1.0.109" diff --git a/Cargo.toml b/Cargo.toml index 95e9eb1..5f92cc7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ nom = "7.1.3" paste = "1.0.14" serde = { version = "1.0.168", features = [ "derive" ] } serde_ignored = "0.1.9" +strum = { version = "0.25", features = ["derive"] } toml = "0.8.1" vte = "0.12.0" diff --git a/README.md b/README.md index 17d4e54..3056838 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,15 @@ addition = "#b8bb26" deletion = "#fb4934" key = "#d79921" error = "#cc241d" + +[keymap.navigation] +move_down = ['j', "Down"] +move_up = ['k', "Up"] +next_file = ['J'] +previous_file = ['K'] +toggle_expand = [" ", "Tab"] +goto_top = ['g'] +goto_bottom = ['G'] ``` ## Versioning diff --git a/src/config.rs b/src/config.rs index 73e3c35..90dc29f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,11 +1,17 @@ //! Gex configuration. #![allow(clippy::derivable_impls)] -use std::{fs, path::PathBuf, str::FromStr, sync::OnceLock}; +use std::{collections::HashMap, fs, path::PathBuf, str::FromStr, sync::OnceLock}; use anyhow::{Context, Result}; -use clap::Parser; -use crossterm::style::Color; -use serde::Deserialize; +use clap::{command, Parser}; +use crossterm::{event::KeyCode, style::Color}; +use serde::{ + de::{self, Visitor}, + Deserialize, +}; + +#[allow(unused_imports)] +use strum::{EnumIter, IntoEnumIterator}; pub static CONFIG: OnceLock = OnceLock::new(); #[macro_export] @@ -36,6 +42,7 @@ pub struct Clargs { pub struct Config { pub options: Options, pub colors: Colors, + pub keymap: Keymaps, } #[derive(Deserialize, Debug, PartialEq, Eq)] @@ -127,6 +134,108 @@ impl Default for Colors { } } +#[derive(Debug, PartialEq, Eq)] +pub struct Keymaps { + pub navigation: HashMap, +} + +struct KeymapsVisitor; + +impl<'de> Deserialize<'de> for Keymaps { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + impl<'de> Visitor<'de> for KeymapsVisitor { + type Value = Keymaps; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str( + " + [keymap.SECTION] + action_under_section = ['', \"\"], + ... + ", + ) + } + + fn visit_map(self, mut map: A) -> std::result::Result + where + A: serde::de::MapAccess<'de>, + { + let mut navigation = Self::Value::default().navigation; + + while let Some((section, section_values)) = + map.next_entry::>>()? + { + if section == "navigation" { + for (action, keys) in section_values { + let ac: Action = Deserialize::deserialize( + de::value::StringDeserializer::new(action), + )?; + + // over-write default key-map to action + navigation.retain(|_, value| value != &ac); + + for key in keys { + // cross-term can't, with Serde, directly deserialize '' into a KeyCode + if key.len() == 1 { + if let Some(c) = key.chars().next() { + let key = KeyCode::Char(c); + navigation.insert(key, ac.clone()); + continue; + } + } + + let key: KeyCode = Deserialize::deserialize( + de::value::StringDeserializer::new(key), + )?; + + navigation.insert(key, ac.clone()); + } + } + } + } + + Ok(Keymaps { navigation }) + } + } + + deserializer.deserialize_map(KeymapsVisitor) + } +} + +#[derive(Deserialize, Clone, Debug, PartialEq, Eq, EnumIter)] +#[serde(rename_all(deserialize = "snake_case"))] +pub enum Action { + MoveDown, + MoveUp, + NextFile, + PreviousFile, + ToggleExpand, + GotoTop, + GotoBottom, +} + +impl Default for Keymaps { + fn default() -> Self { + Self { + navigation: HashMap::from([ + (KeyCode::Char('j'), Action::MoveDown), + (KeyCode::Down, Action::MoveDown), + (KeyCode::Char('k'), Action::MoveUp), + (KeyCode::Up, Action::MoveUp), + (KeyCode::Char('J'), Action::NextFile), + (KeyCode::Char('K'), Action::PreviousFile), + (KeyCode::Char(' '), Action::ToggleExpand), + (KeyCode::Tab, Action::ToggleExpand), + (KeyCode::Char('g'), Action::GotoTop), + (KeyCode::Char('G'), Action::GotoBottom), + ]), + } + } +} + impl Config { /// Reads the config from the config file (usually `~/.config/gex/config.toml` on Linux) and /// returns it along with a Vec of unrecognised keys. @@ -236,6 +345,20 @@ mod tests { use super::*; use crossterm::style::Color; + #[test] + fn every_action_has_a_default_key() { + let mut action_list: Vec = Action::iter().collect(); + for (_, action) in Keymaps::default().navigation { + action_list.retain(|x| x != &action); + } + + assert!( + action_list.is_empty(), + "The following Actions do not have a default keybinding: {:?}", + action_list + ) + } + // Should be up to date with the example config in the README. #[test] fn parse_readme_example() { @@ -262,6 +385,15 @@ addition = \"#b8bb26\" deletion = \"#fb4934\" key = \"#d79921\" error = \"#cc241d\" + +[keymap.navigation] +move_down = [\'j\', \"Down\"] +move_up = [\'k\', \"Up\"] +next_file = [\'J\'] +previous_file = [\'K\'] +toggle_expand = [\" \", \"Tab\"] +goto_top = [\'g\'] +goto_bottom = [\'G\'] "; assert_eq!( toml::from_str(INPUT), @@ -288,6 +420,20 @@ error = \"#cc241d\" deletion: Color::from((251, 73, 52)), key: Color::from((215, 153, 33)), error: Color::from((204, 36, 29)) + }, + keymap: Keymaps { + navigation: HashMap::from([ + (KeyCode::Char('j'), Action::MoveDown), + (KeyCode::Down, Action::MoveDown), + (KeyCode::Char('k'), Action::MoveUp), + (KeyCode::Up, Action::MoveUp), + (KeyCode::Char('J'), Action::NextFile), + (KeyCode::Char('K'), Action::PreviousFile), + (KeyCode::Char(' '), Action::ToggleExpand), + (KeyCode::Tab, Action::ToggleExpand), + (KeyCode::Char('g'), Action::GotoTop), + (KeyCode::Char('G'), Action::GotoBottom), + ]), } }) ) diff --git a/src/main.rs b/src/main.rs index ec3a06d..dfc93f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,7 +29,7 @@ use git2::Repository; use crate::{ command::GexCommand, - config::{Config, CONFIG}, + config::{Action, Config, CONFIG}, minibuffer::{Callback, MessageType, MiniBuffer}, render::{Clear, Render, ResetAttributes}, }; @@ -238,108 +238,119 @@ See https://github.com/Piturnah/gex/issues/13.", MessageType::Error); } match state.view { - View::Status => match event.code { - KeyCode::Char('j') | KeyCode::Down => state.status.down()?, - KeyCode::Char('k') | KeyCode::Up => state.status.up()?, - KeyCode::Char('J') => state.status.file_down()?, - KeyCode::Char('K') => state.status.file_up()?, - KeyCode::Char('G') => state.status.cursor_last()?, - KeyCode::Char('g') => state.status.cursor_first()?, - KeyCode::Char('s') => { - if state.status.cursor - < state.status.count_untracked + state.status.count_unstaged - { - state.status.stage()?; + View::Status => { + match config.keymap.navigation.get(&event.code) { + Some(Action::MoveDown) => state.status.down()?, + Some(Action::MoveUp) => state.status.up()?, + Some(Action::NextFile) => state.status.file_down()?, + Some(Action::PreviousFile) => state.status.file_up()?, + Some(Action::ToggleExpand) => state.status.expand()?, + Some(Action::GotoBottom) => state.status.cursor_last()?, + Some(Action::GotoTop) => state.status.cursor_first()?, + _ => {} + }; + + match event.code { + KeyCode::Char('s') => { + if state.status.cursor + < state.status.count_untracked + state.status.count_unstaged + { + state.status.stage()?; + state.status.fetch(&state.repo, &config.options)?; + } + } + KeyCode::Char('S') => { + MiniBuffer::push_command_output(&git_process(&["add", "."])?); state.status.fetch(&state.repo, &config.options)?; } - } - KeyCode::Char('S') => { - MiniBuffer::push_command_output(&git_process(&["add", "."])?); - state.status.fetch(&state.repo, &config.options)?; - } - KeyCode::Char('u') => { - if state.status.cursor - >= state.status.count_untracked + state.status.count_unstaged - { - state.status.unstage()?; + KeyCode::Char('u') => { + if state.status.cursor + >= state.status.count_untracked + state.status.count_unstaged + { + state.status.unstage()?; + state.status.fetch(&state.repo, &config.options)?; + } + } + KeyCode::Char('U') => { + MiniBuffer::push_command_output(&git_process(&["reset"])?); state.status.fetch(&state.repo, &config.options)?; } - } - KeyCode::Char('U') => { - MiniBuffer::push_command_output(&git_process(&["reset"])?); - state.status.fetch(&state.repo, &config.options)?; - } - KeyCode::Tab | KeyCode::Char(' ') => state.status.expand()?, - KeyCode::Char('e') => { - state.status.open_editor()?; - state.status.fetch(&state.repo, &config.options)?; - } - KeyCode::Char('F') => { - MiniBuffer::push_command_output(&git_process(&["pull"])?); - state.status.fetch(&state.repo, &config.options)?; - } - KeyCode::Char('r') => state.status.fetch(&state.repo, &config.options)?, - KeyCode::Char(':') => { - state.minibuffer.command(true, &mut state.view); - state.status.fetch(&state.repo, &config.options)?; - } - KeyCode::Char('!') => { - state.minibuffer.command(false, &mut state.view); - state.status.fetch(&state.repo, &config.options)?; - } - KeyCode::Char('q') => { - terminal::disable_raw_mode().context("failed to disable raw mode")?; - crossterm::execute!( - stdout(), - terminal::LeaveAlternateScreen, - cursor::Show, - cursor::MoveToColumn(0) - ) - .context("failed to leave alternate screen")?; - process::exit(0); - } - KeyCode::Char(c1) => { - if let Some((_, cmd)) = - GexCommand::commands().iter().find(|(c2, _)| c1 == *c2) - { - state.view = View::Command(*cmd); + KeyCode::Char('e') => { + state.status.open_editor()?; + state.status.fetch(&state.repo, &config.options)?; } + KeyCode::Char('F') => { + MiniBuffer::push_command_output(&git_process(&["pull"])?); + state.status.fetch(&state.repo, &config.options)?; + } + KeyCode::Char('r') => state.status.fetch(&state.repo, &config.options)?, + KeyCode::Char(':') => { + state.minibuffer.command(true, &mut state.view); + state.status.fetch(&state.repo, &config.options)?; + } + KeyCode::Char('!') => { + state.minibuffer.command(false, &mut state.view); + state.status.fetch(&state.repo, &config.options)?; + } + KeyCode::Char('q') => { + terminal::disable_raw_mode().context("failed to disable raw mode")?; + crossterm::execute!( + stdout(), + terminal::LeaveAlternateScreen, + cursor::Show, + cursor::MoveToColumn(0) + ) + .context("failed to leave alternate screen")?; + process::exit(0); + } + KeyCode::Char(c1) => { + if let Some((_, cmd)) = + GexCommand::commands().iter().find(|(c2, _)| c1 == *c2) + { + state.view = View::Command(*cmd); + } + } + _ => {} + }; + } + View::BranchList => { + match config.keymap.navigation.get(&event.code) { + Some(Action::MoveDown) => { + state.branch_list.cursor = cmp::min( + state.branch_list.cursor + 1, + state.branch_list.branches.len() - 1, + ); + } + Some(Action::MoveUp) => { + state.branch_list.cursor = state.branch_list.cursor.saturating_sub(1); + } + Some(Action::GotoBottom) => { + state.branch_list.cursor = state.branch_list.branches.len() - 1; + } + Some(Action::GotoTop) => state.branch_list.cursor = 0, + _ => {} } - _ => {} - }, - View::BranchList => match event.code { - KeyCode::Char('k') | KeyCode::Up => { - state.branch_list.cursor = state.branch_list.cursor.saturating_sub(1); - } - KeyCode::Char('j') | KeyCode::Down => { - state.branch_list.cursor = cmp::min( - state.branch_list.cursor + 1, - state.branch_list.branches.len() - 1, - ); - } - KeyCode::Char('g' | 'K') => state.branch_list.cursor = 0, - KeyCode::Char('G' | 'J') => { - state.branch_list.cursor = state.branch_list.branches.len() - 1; - } - KeyCode::Char(' ') | KeyCode::Enter => { - MiniBuffer::push_command_output(&state.branch_list.checkout()?); - state.status.fetch(&state.repo, &config.options)?; - state.view = View::Status; - } - KeyCode::Esc => state.view = View::Status, - KeyCode::Char('q') => { - terminal::disable_raw_mode().context("failed to disable raw mode")?; - crossterm::execute!( - stdout(), - terminal::LeaveAlternateScreen, - cursor::Show, - cursor::MoveToColumn(0) - ) - .context("failed to leave alternate screen")?; - process::exit(0); + match event.code { + KeyCode::Char(' ') | KeyCode::Enter => { + MiniBuffer::push_command_output(&state.branch_list.checkout()?); + state.status.fetch(&state.repo, &config.options)?; + state.view = View::Status; + } + KeyCode::Esc => state.view = View::Status, + KeyCode::Char('q') => { + terminal::disable_raw_mode().context("failed to disable raw mode")?; + crossterm::execute!( + stdout(), + terminal::LeaveAlternateScreen, + cursor::Show, + cursor::MoveToColumn(0) + ) + .context("failed to leave alternate screen")?; + process::exit(0); + } + _ => {} } - _ => {} - }, + } View::Command(cmd) => match event.code { KeyCode::Esc => state.view = View::Status, KeyCode::Char('q') => {