diff --git a/Cargo.lock b/Cargo.lock index 061306f..5131e17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -343,7 +343,7 @@ dependencies = [ [[package]] name = "typo-eq" -version = "0.1.1" +version = "0.2.0" dependencies = [ "chrono", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 9422b81..12f6b71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,8 +2,14 @@ name = "typo-eq" description = "Typo-eq is a typing training app for other languages. All it needs is a dictionary for words and their translations." license = "GPL-3.0" -version = "0.1.1" +version = "0.2.0" edition = "2021" +repository = "https://github.com/onelikeandidie/typo-eq" + +[lib] +name = "typo_eq" +path = "src/lib.rs" +crate-type = ["lib"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/README.md b/README.md index 43e3239..ca37096 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ cargo run -- --dict path/to/xdxf/file A short loading screen should appear as your dictionary is loaded. Bigger dictionaries typically take longer (_obvio_), the Svenska-English dictionary -from the Swedish People's Dictionary takes around 1.06 seconds. +from the Swedish People's Dictionary takes around 1.32 seconds. ## Screenshots diff --git a/TODO.md b/TODO.md index 940ac1f..b9b06b2 100644 --- a/TODO.md +++ b/TODO.md @@ -2,7 +2,7 @@ - [x] Import dictionary from XDXF (XML Dictionary eXchange Format) - [x] Render a random word to screen -- [ ] Show progress on said word +- [x] Show progress on said word - [x] Handle Mistakes - [ ] Difficulty Levels - [ ] Challenges? diff --git a/docs/screenshot01.png b/docs/screenshot01.png index ac94e6f..375db76 100644 Binary files a/docs/screenshot01.png and b/docs/screenshot01.png differ diff --git a/docs/screenshot02.png b/docs/screenshot02.png index d011ab7..4c2f1d0 100644 Binary files a/docs/screenshot02.png and b/docs/screenshot02.png differ diff --git a/docs/screenshot03.png b/docs/screenshot03.png index e788c1a..1b4f604 100644 Binary files a/docs/screenshot03.png and b/docs/screenshot03.png differ diff --git a/src/app/mod.rs b/src/app/mod.rs index e1ca013..ff2b2b4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3,10 +3,8 @@ use std::io::{stdout, Write}; use std::sync::mpsc; use std::thread::{self, sleep}; use std::time::Duration; -use crossterm::cursor::{MoveLeft, MoveToColumn, MoveToPreviousLine, MoveToNextLine}; use crossterm::event::{read, Event, KeyCode, KeyEvent, KeyModifiers}; -use crossterm::style::{SetForegroundColor, Color, ResetColor}; -use crossterm::{self, execute, ExecutableCommand}; +use crossterm::style::Color; use crossterm::terminal::{Clear, ClearType}; use rand::distributions::Uniform; use rand::prelude::Distribution; @@ -30,10 +28,8 @@ pub const SKIP_CHARACTERS: [char; 2] = [ '/', '|' ]; -fn zero() -> (u16, u16) {(0, 0)} - pub fn create_app(config: Config) { - let mut stdout = stdout(); + let stdout = stdout(); let renderer = Renderer::init(); @@ -65,20 +61,24 @@ pub fn create_app(config: Config) { (Utc::now().timestamp_millis() - load_time) as f64 / 1000.0 ).as_str()); } - _ => {}, } } let dict = dict.expect("Could not load dictionary"); let mut state = State::default(); - sleep(Duration::from_secs(1)); + sleep(Duration::from_millis(500)); + // Show dictionary loaded + renderer.print_at_center( + format!("{} -> {}", dict.from, dict.to).as_str(), + (0, -6), None, None, None, None, + ); + let mut old_words: Vec = Vec::new(); let mut word = new_word(&dict); - renderer.print_at_center_default(format!( - "{} -> {}", - word.original, - word.translation - ).as_str()); + renderer.clear_line_at_center((0,0)); + render_translations(&renderer, &word); + render_center(&renderer, &word, &state); + render_cursor(&renderer, &word, &state); while let Ok(event) = read() { match event { @@ -96,14 +96,8 @@ pub fn create_app(config: Config) { code: KeyCode::Backspace, .. }) => { - if state.progress == 0 { - continue; - } - state.progress -= 1; state.failed = false; - stdout.execute(MoveLeft(1)).unwrap(); - print!(" "); - stdout.execute(MoveToColumn(state.progress as u16)).unwrap(); + render_center(&renderer, &word, &state); } Event::Key(KeyEvent { code: KeyCode::Char(c), @@ -118,82 +112,43 @@ pub fn create_app(config: Config) { if (SKIP_CHARACTERS.contains(current_char) && !c.is_alphanumeric()) // Progress if the character input was correct || current_char == &c { - if state.failed { - // Move cursor back and write the right char - stdout.execute(MoveLeft(1)).unwrap(); - } - renderer.print_at_center( - format!("{}", c).as_str(), - (state.progress as i16 - (word.size / 2) as i16, 2), - None, None, None, - None - ); state.progress += 1; state.failed = false; } else { - if !state.failed { - renderer.print_at_center( - format!("{}", current_char).as_str(), - (state.progress as i16 - (word.size / 2) as i16, 2), - None, Some(Color::DarkRed), None, - None - ); - } state.failed = true; state.stats.chars_failed += 1; } state.stats.chars_typed += 1; stdout.lock().flush().unwrap(); } - // Update progress display - renderer.print_at_center( - format!("{}/{}", state.progress, word.size).as_str(), - (2, -2), Some(TextAlign::Left), - Some(Color::DarkGrey), None, - None - ); - // Update wpm display let current_timestamp = Utc::now().timestamp_millis(); let diff = current_timestamp - state.last_word_timestamp; state.wpm = 1.0 / (diff as f64 / 1000.0 / 60.0); - renderer.print_at_center( - format!("{} wpm", state.wpm.round()).as_str(), - (-2, -2), Some(TextAlign::Right), - Some(Color::DarkYellow), None, - None - ); - Cursor::move_to_center(((state.progress as i16 - (word.size / 2) as i16), 2)); + render_center(&renderer, &word, &state); if state.progress >= word.size { // Update last word completed timestamp state.last_word_timestamp = Utc::now().timestamp_millis(); state.stats.completed += 1; - // Show the completed word in grey - renderer.print_at_center( - format!( - "{} -> {}", - word.original, - word.translation - ).as_str(), (0, -4), - None, Some(Color::DarkGrey), None, - Some(Clear(ClearType::CurrentLine)), - ); + // Add last word to the book of words + old_words.push(word); + if old_words.len() >= 5 { + old_words.remove(0); + } + render_completed_words(&renderer, &old_words); // Clear the user input renderer.clear_line_at_center((0, 2)); // New word word = new_word(&dict); state.progress = 0; - renderer.print_at_center_default(format!( - "{} -> {}", - word.original, - word.translation - ).as_str()); - Cursor::move_to_center((0, 2)); + renderer.clear_line_at_center((0,0)); + render_center(&renderer, &word, &state); + render_translations(&renderer, &word); } } _ => {}, } + render_cursor(&renderer, &word, &state); } - // Update wpm let current_timestamp = Utc::now().timestamp_millis(); let diff = current_timestamp - state.started_at; @@ -223,17 +178,132 @@ pub fn create_app(config: Config) { Cursor::move_to_center((0, 8)); } +pub fn render_center(renderer: &Renderer, word: &Word, state: &State) { + // Update progress display + renderer.print_at_center( + format!("{}/{}", state.progress, word.size).as_str(), + (word.size as i16 / 2 + 4, 0), Some(TextAlign::Left), + Some(Color::DarkGrey), None, + None + ); + // Update wpm display + renderer.print_at_center( + format!("{} wpm", state.wpm.round()).as_str(), + (- (word.size as i16 / 2) - 4, 0), Some(TextAlign::Right), + Some(Color::DarkYellow), None, + None + ); + // Update word shown + let left = word.original_chars[..state.progress].into_iter().collect::(); + let right = word.original_chars[state.progress..].into_iter().collect::(); + let fail_char = word.original_chars.get(state.progress).unwrap_or(&' '); + let left_x = - (word.size as i16 / 2); + let right_x = left_x + state.progress as i16; + renderer.print_at_center( + left.as_str(), + (left_x, 0), Some(TextAlign::Left), + Some(Color::DarkGreen), None, None + ); + renderer.print_at_center( + right.as_str(), + (right_x, 0), Some(TextAlign::Left), + None, None, None + ); + if state.failed { + renderer.print_at_center( + format!("{}", fail_char).as_str(), + (right_x, 0), Some(TextAlign::Left), + Some(Color::DarkRed), None, None + ); + } +} + pub fn new_word(dict: &Dictionary) -> Word { let mut rng = thread_rng(); let distribuition = Uniform::new(0, dict.entries.len()); let word_index = distribuition.sample(&mut rng); - let word = dict.entries.get(word_index); + let word = dict.words.get(word_index); if let Some(word) = word { return Word { size: word.identifier.chars().count(), original: word.identifier.clone(), + original_chars: word.identifier.chars().collect(), translation: word.translation.clone(), } } panic!("Word could not be selected, out of bounds"); +} + +pub fn render_completed_words(renderer: &Renderer, words: &Vec) { + for i in 0..(words.len()) { + let word = words.get((words.len() - 1) - i).unwrap(); + renderer.clear_line_at_center((0, -2 - i as i16)); + // Show the completed word in grey + renderer.print_at_center( + format!("{}", word.original).as_str(), + (-2, -2 - i as i16), + Some(TextAlign::Right), Some(Color::DarkGrey), None, + None, + ); + renderer.print_at_center( + "->", + (0, -2 - i as i16), + None, Some(Color::DarkGrey), None, + None, + ); + renderer.print_at_center( + format!( + "{}", + word.translation.first().unwrap_or(&"no translation".to_string()) + ).as_str(), + (2, -2 - i as i16), + Some(TextAlign::Left), Some(Color::DarkGrey), None, + None, + ); + } +} + +pub fn render_translations(renderer: &Renderer, word: &Word) { + renderer.clear_down_from_center_at(2); + for i in 0..(word.translation.len()) { + let translation = word.translation.get(i).unwrap(); + // Show the completed word in grey + renderer.print_at_center( + format!( + "{}", + translation + ).as_str(), (0, 2 + i as i16), + None, None, None, + None, + ); + } +} + +pub fn render_new_word(renderer: &Renderer, word: &Word) { + renderer.print_at_center( + format!( + "{}", + word.original, + ).as_str(), (0,0), None, + None, None, + Some(Clear(ClearType::CurrentLine)) + ); +} + +pub fn render_cursor(renderer: &Renderer, word: &Word, state: &State) { + renderer.print_at_center( + "^", (get_progress_cursor(word, state), 1), + None, + Some(Color::DarkYellow), None, + Some(Clear(ClearType::CurrentLine)) + ); +} + +pub fn get_progress_cursor(word: &Word, state: &State) -> i16 { + state.progress as i16 - (word.size / 2) as i16 +} + +pub fn reset_cursor(word: &Word, state: &State) { + let new_cursor_pos_x = get_progress_cursor(word, state) + (if state.failed {1} else {0}); + Cursor::move_to_center((new_cursor_pos_x, 0)); } \ No newline at end of file diff --git a/src/app/render.rs b/src/app/render.rs index 0a34775..a6b68c3 100644 --- a/src/app/render.rs +++ b/src/app/render.rs @@ -1,6 +1,6 @@ use std::io::{stdout, Write}; -use crossterm::{terminal::{self, EnterAlternateScreen, enable_raw_mode, disable_raw_mode, LeaveAlternateScreen, Clear, ClearType}, style::{Color, SetForegroundColor, ResetColor}, execute, cursor::{MoveTo, position}, event::{PushKeyboardEnhancementFlags, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags}}; +use crossterm::{terminal::{EnterAlternateScreen, enable_raw_mode, disable_raw_mode, LeaveAlternateScreen, Clear, ClearType}, style::{Color, SetForegroundColor, ResetColor}, execute, cursor::{MoveTo, position, MoveToRow, Hide, Show}, event::{PushKeyboardEnhancementFlags, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags}}; use super::util::term_center; @@ -22,6 +22,7 @@ impl Renderer { execute!( stdout, EnterAlternateScreen, + Hide, PushKeyboardEnhancementFlags( KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES @@ -35,6 +36,7 @@ impl Renderer { execute!( stdout, PopKeyboardEnhancementFlags, + Show, LeaveAlternateScreen, ).unwrap(); @@ -134,6 +136,15 @@ impl Renderer { ), ) } + pub fn clear_down_from_center_at(self: &Self, offset_y: i16) { + let center = term_center(); + let mut stdout = stdout(); + execute!( + stdout, + MoveToRow((center.1 as i16 + offset_y) as u16), + Clear(ClearType::FromCursorDown) + ).expect("Could not clear lines down from center") + } pub fn clear_line_at(self: &Self, position: (u16, u16)) { let mut stdout = stdout(); execute!( diff --git a/src/app/word.rs b/src/app/word.rs index 2d80d08..a6a1903 100644 --- a/src/app/word.rs +++ b/src/app/word.rs @@ -1,11 +1,22 @@ use chrono::Utc; +#[derive(Debug)] pub struct Word { pub size: usize, pub original: String, + pub original_chars: Vec, + pub translation: Vec, +} + +#[derive(Debug)] +pub struct Phrase { + pub size: usize, + pub original: String, + pub original_chars: Vec, pub translation: String, } +#[derive(Debug)] pub struct State { pub progress: usize, pub failed: bool, @@ -29,7 +40,7 @@ impl Default for State { } } -#[derive(Default)] +#[derive(Debug, Default)] pub struct Stats { pub completed: u64, pub chars_typed: u64, diff --git a/src/config.rs b/src/config.rs index 99799ac..38d9366 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,21 +5,24 @@ use super::util::get_index; #[derive(Debug)] pub struct ConfigFile { pub dictionary_path: String, + pub show_phrases: bool, } #[derive(Debug, Clone)] pub struct Config { pub dictionary_path: String, + pub show_phrases: bool, pub debugging: bool, } pub fn extract_config(args: &Vec) -> Result { let mut config = Config { dictionary_path: "".to_string(), + show_phrases: false, debugging: false, }; - // Check if instead, the arguments were passed - let has_dict = args.contains(&"--dict".to_string()); + // Check if the dict file was set or use default + let has_dict = args.contains(&"--dict".to_string()) || args.contains(&"-d".to_string()); if has_dict { let index_of_dict = (get_index(&args, "--dict") + 1) as usize; let dictionary_path = args @@ -36,6 +39,8 @@ pub fn extract_config(args: &Vec) -> Result { Err(error) => return Err(error.to_string()), } } + let show_phrases = args.contains(&"--phrases".to_string()) || args.contains(&"-p".to_string()); + config.show_phrases = show_phrases; let debugging = args.contains(&"--debug".to_string()); config.debugging = debugging; diff --git a/src/importer/dictionary.rs b/src/importer/dictionary.rs index 81ca4d9..384435a 100644 --- a/src/importer/dictionary.rs +++ b/src/importer/dictionary.rs @@ -6,43 +6,78 @@ use quick_xml::events::Event; use quick_xml::Reader; #[derive(Debug, Clone)] -pub struct DictionaryEntry { +pub enum DictionaryEntry { + Word(DictionaryWord), + Phrase(DictionaryPhrase), +} + +#[derive(Debug, Clone)] +pub struct DictionaryWord { + pub kind: String, + pub identifier: String, + pub translation: Vec, +} + +impl DictionaryWord { + pub fn new(kind: String) -> Self { + Self { + kind, + identifier: String::new(), + translation: Vec::new(), + } + } +} + +#[derive(Debug, Clone)] +pub struct DictionaryPhrase { pub kind: String, pub identifier: String, pub translation: String, + pub example_for: String, } -impl DictionaryEntry { +impl DictionaryPhrase { pub fn new(kind: String) -> Self { Self { kind, identifier: String::new(), translation: String::new(), + example_for: String::new(), } } } -impl Display for DictionaryEntry { +impl Display for DictionaryWord { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}->{}", self.identifier, self.translation) + write!(f, "{}->{}", self.identifier, self.translation.concat()) } } #[derive(Clone)] pub struct Dictionary { pub entries: Vec, + pub words: Vec, + pub phrases: Vec, + pub from: String, + pub to: String, } impl Dictionary { pub fn from_file(mut file: File) -> Self { file.seek(SeekFrom::Start(0)).unwrap(); let file = BufReader::new(file); - let mut dict = Vec::new(); + let mut entries = Vec::new(); + let mut words = Vec::new(); + let mut phrases = Vec::new(); + let mut from = "Unkown".to_string(); + let mut to = "Unkown".to_string(); let mut parser = Reader::from_reader(file); - let mut entry = None; + let mut word = None; + let mut phrase = None; let mut buf = Vec::new(); let mut current_tag = String::new(); + let mut current_attrs = Vec::new(); loop { match parser.read_event_into(&mut buf) { Err(e) => panic!("Error at position {}: {:?}", parser.buffer_position(), e), @@ -51,18 +86,52 @@ impl Dictionary { let tag = e.name(); let tag = String::from_utf8(tag.as_ref().to_vec()).unwrap(); if tag.as_str() == "ar" { - let new_entry = DictionaryEntry::new(tag.clone()); - entry = Some(new_entry); + let new_entry = DictionaryWord::new(tag.clone()); + word = Some(new_entry); } + if tag.as_str() == "xdxf" { + let lang_from = e.try_get_attribute("lang_from"); + let lang_to = e.try_get_attribute("lang_to"); + if let Ok(lang_from) = lang_from { + if let Some(lang_from) = lang_from { + from = String::from_utf8(lang_from.value.to_vec()).unwrap(); + } + } + if let Ok(lang_to) = lang_to { + if let Some(lang_to) = lang_to { + to = String::from_utf8(lang_to.value.to_vec()).unwrap(); + } + } + } + if tag.as_str() == "exm" { + let new_phrase = DictionaryPhrase::new(tag.clone()); + phrase = Some(new_phrase); + } + let attrs = e.attributes().map(|attr| { + if let Ok(attr) = attr { + String::from_utf8(attr.value.to_vec()).unwrap() + } else { + String::new() + } + }); current_tag = tag; + current_attrs = attrs.collect(); } Ok(Event::Text(e)) => { - if let Some(entry) = entry.as_mut() { + if let Some(entry) = word.as_mut() { if current_tag == "k" { entry.identifier = e.unescape().unwrap().to_string(); + if let Some(phrase) = phrase.as_mut() { + phrase.example_for = entry.identifier.clone(); + } } if current_tag == "dtrn" { - entry.translation = e.unescape().unwrap().to_string(); + entry.translation.push(e.unescape().unwrap().to_string()); + } + if current_tag == "ex" && current_attrs.contains(&"exm".to_string()) { + if let Some(phrase) = phrase.as_mut() { + phrase.identifier = e.unescape().unwrap().to_string(); + } } } } @@ -70,17 +139,24 @@ impl Dictionary { let tag = e.name(); let tag = String::from_utf8(tag.as_ref().to_vec()).unwrap(); if tag.as_str() == "ar" { - if let Some(new_entry) = entry { - dict.push(new_entry); - entry = None; + if let Some(new_word) = word { + words.push(new_word.clone()); + entries.push(DictionaryEntry::Word(new_word)); + word = None; + } + if let Some(new_phrase) = phrase { + phrases.push(new_phrase.clone()); + entries.push(DictionaryEntry::Phrase(new_phrase)); + phrase = None; } } current_tag = String::new(); + current_attrs = Vec::new(); } _ => {} } buf.clear(); } - return Dictionary { entries: dict }; + return Dictionary { entries, words, phrases, from, to }; } }