Skip to content
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ USAGE:
ttyper [FLAGS] [OPTIONS] [contents]

FLAGS:
-d, --debug
-d, --debug
-h, --help Prints help information
--list-languages List installed languages
--no-backtrack Disable backtracking to completed words
Expand Down Expand Up @@ -160,6 +160,41 @@ results_chart_y = "gray;italic"

# restart/quit prompt in results ui
results_restart_prompt = "gray;italic"

[key_map]

# key map for removing previous word
remove_previous_word = "C-Backspace"

# key map for removing previous character
remove_previous_char = "Backspace"

# key map for space/next word
next_word = "Space"
```

### Key Maps

In this config file, you can define key maps to customize your experience. Key maps allow you to associate specific actions with keyboard inputs.

Key Map Structure:

- Single characters are allowed only when accompanied by a modifier.
- Certain special keys, like `Backspace`, are allowed both by themselves and with a modifier.

Some examples:
```toml
[key_map]

# This reperesnts `Ctrl + Backspace`
remove_previous_word = "C-Backspace"

# This reperesnts `Ctrl + h`
remove_previous_char = "C-h"

# This reperesnts `Space`
next_word = "Space"

```

### style format
Expand Down
16 changes: 5 additions & 11 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,17 @@ use serde::{
Deserialize,
};

#[derive(Debug, Deserialize)]
use crate::key::KeyMap;

#[derive(Debug, Deserialize, Default, Clone)]
#[serde(default)]
pub struct Config {
pub default_language: String,
pub theme: Theme,
pub key_map: KeyMap,
}

impl Default for Config {
fn default() -> Self {
Self {
default_language: "english200".into(),
theme: Theme::default(),
}
}
}

#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, Clone)]
#[serde(default)]
pub struct Theme {
#[serde(deserialize_with = "deserialize_style")]
Expand Down
144 changes: 144 additions & 0 deletions src/key.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
use core::panic;

use crossterm::event::{KeyCode, KeyModifiers};
use serde::{de, Deserialize};

#[derive(Debug, Deserialize, Default, Clone)]
#[serde(default)]
pub struct KeyMap {
#[serde(deserialize_with = "deseralize_key")]
pub remove_previous_word: Key,
#[serde(deserialize_with = "deseralize_key")]
pub remove_previous_char: Key,
#[serde(deserialize_with = "deseralize_key")]
pub next_word: Key,
}

fn deseralize_key<'de, D>(deserializer: D) -> Result<Key, D::Error>
where
D: de::Deserializer<'de>,
{
struct KeyVisitor;
impl<'de> de::Visitor<'de> for KeyVisitor {
type Value = Key;

fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("key specification")
}

fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
match get_key_from_string(v) {
Some(key) => Ok(key),
None => {
panic!("Key map `{}` is invalid", v)
}
}
}
}

return deserializer.deserialize_str(KeyVisitor);
}

#[derive(Debug, Clone)]
pub struct Key {
pub code: KeyCode,
pub modifier: KeyModifiers,
}

impl Default for Key {
fn default() -> Self {
Self {
code: KeyCode::Null,
modifier: KeyModifiers::NONE,
}
}
}

fn get_key_code_from_string(string: &str) -> KeyCode {
if string.chars().count() == 1 {
let key_code_char = string.chars().next();
if let Some(key_code_char) = key_code_char {
if key_code_char.is_lowercase() {
return KeyCode::Char(key_code_char);
}
}
}
match string {
"Backspace" => KeyCode::Backspace,
"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,
"Delete" => KeyCode::Delete,
"Insert" => KeyCode::Insert,
"Esc" => KeyCode::Esc,
"CapsLock" => KeyCode::CapsLock,
"ScrollLock" => KeyCode::ScrollLock,
"NumLock" => KeyCode::NumLock,
"PrintScreen" => KeyCode::PrintScreen,
"Pause" => KeyCode::Pause,
"Menu" => KeyCode::Menu,
"KeypadBegin" => KeyCode::KeypadBegin,
_ => KeyCode::Null,
}
}

fn get_key_modifier_from_string(string: &str) -> KeyModifiers {
match string {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would probably be good to support non-abbreviated modifiers for each of these also. For example, "Ctrl", "Alt", etc. Some people also call the Meta key "Super" or "Win".

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not quite understand this?
Should I allow "Ctrl-Backspace" as an option for config?
And Meta and Super are seperate modifiers, so i dont quite get what you mean.

"C" => KeyModifiers::CONTROL,
"A" => KeyModifiers::ALT,
"W" => KeyModifiers::SUPER,
"H" => KeyModifiers::HYPER,
"M" => KeyModifiers::META,
_ => KeyModifiers::NONE,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An unknown modifier should probably be an error, not a silent fallback to no modifier.

}
}

fn get_key_from_string(string: &str) -> Option<Key> {
let mut key = Key {
code: KeyCode::Null,
modifier: KeyModifiers::NONE,
};
match string.split('-').count() {
1 => {
if string.chars().count() == 1 {
key.code = KeyCode::Null;
} else {
key.code = get_key_code_from_string(string);
}
}
2 => {
let mut split = string.split('-');
let key_code = split.next();
if let Some(key_code) = key_code {
if key_code.chars().count() == 1 {
key.modifier = get_key_modifier_from_string(key_code);
}
}
if key.modifier != KeyModifiers::NONE {
let key_code = split.next();
if let Some(key_code) = key_code {
key.code = get_key_code_from_string(key_code);
if key.code == KeyCode::Null {
key.modifier = KeyModifiers::NONE;
}
}
}
}
_ => {}
}
if key.modifier == KeyModifiers::NONE && key.code == KeyCode::Null {
return None;
}
Some(key)
}
8 changes: 6 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod config;
mod key;
mod test;
mod ui;

Expand Down Expand Up @@ -228,6 +229,7 @@ fn main() -> crossterm::Result<()> {
"Couldn't get test contents. Make sure the specified language actually exists.",
),
!opt.no_backtrack,
config.clone(),
));

state.render_into(&mut terminal, &config)?;
Expand Down Expand Up @@ -276,7 +278,8 @@ fn main() -> crossterm::Result<()> {
opt.gen_contents().expect(
"Couldn't get test contents. Make sure the specified language actually exists.",
),
!opt.no_backtrack
!opt.no_backtrack,
config.clone(),
));
}
Event::Key(KeyEvent {
Expand All @@ -294,7 +297,8 @@ fn main() -> crossterm::Result<()> {
.flat_map(|w| vec![w.clone(); 5])
.collect();
practice_words.shuffle(&mut thread_rng());
state = State::Test(Test::new(practice_words, !opt.no_backtrack));
state =
State::Test(Test::new(practice_words, !opt.no_backtrack, config.clone()));
}
Event::Key(KeyEvent {
code: KeyCode::Char('q'),
Expand Down
66 changes: 65 additions & 1 deletion src/test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use std::fmt;
use std::time::Instant;

use crate::config::Config;

pub struct TestEvent {
pub time: Instant,
pub key: KeyEvent,
Expand Down Expand Up @@ -48,15 +50,17 @@ pub struct Test {
pub current_word: usize,
pub complete: bool,
pub backtracking_enabled: bool,
pub config: Config,
}

impl Test {
pub fn new(words: Vec<String>, backtracking_enabled: bool) -> Self {
pub fn new(words: Vec<String>, backtracking_enabled: bool, config: Config) -> Self {
Self {
words: words.into_iter().map(TestWord::from).collect(),
current_word: 0,
complete: false,
backtracking_enabled,
config,
}
}

Expand All @@ -66,6 +70,66 @@ impl Test {
}

let word = &mut self.words[self.current_word];

if key.code == self.config.key_map.next_word.code
&& key
.modifiers
.contains(self.config.key_map.next_word.modifier)
{
if word.text.chars().nth(word.progress.len()) == Some(' ') {
word.progress.push(' ');
word.events.push(TestEvent {
time: Instant::now(),
correct: Some(true),
key,
})
} else if !word.progress.is_empty() || word.text.is_empty() {
word.events.push(TestEvent {
time: Instant::now(),
correct: Some(word.text == word.progress),
key,
});
self.next_word();
}
return;
}

if key.code == self.config.key_map.remove_previous_char.code
&& key
.modifiers
.contains(self.config.key_map.remove_previous_char.modifier)
{
if word.progress.is_empty() && self.backtracking_enabled {
self.last_word();
} else {
word.events.push(TestEvent {
time: Instant::now(),
correct: Some(!word.text.starts_with(&word.progress[..])),
key,
});
word.progress.pop();
}
return;
}

if key.code == self.config.key_map.remove_previous_word.code
&& key
.modifiers
.contains(self.config.key_map.remove_previous_word.modifier)
{
if self.words[self.current_word].progress.is_empty() {
self.last_word();
}
let word = &mut self.words[self.current_word];
word.events.push(TestEvent {
time: Instant::now(),
correct: None,
key,
});
word.progress.clear();
return;
}

match key.code {
KeyCode::Char(' ') | KeyCode::Enter => {
if word.text.chars().nth(word.progress.len()) == Some(' ') {
Expand Down