From 2d63cffb337918526bdc12814cf2dab8610aa394 Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Wed, 18 Feb 2026 00:14:37 +0000 Subject: [PATCH] Passage drill improvements, stats page cleanup --- src/app.rs | 365 ++++++++++++++- src/config.rs | 35 ++ src/generator/cache.rs | 42 ++ src/generator/passage.rs | 462 ++++++++++++------- src/main.rs | 522 ++++++++++++++++++++-- src/session/drill.rs | 76 ++++ src/session/input.rs | 98 +++- src/session/result.rs | 11 + src/ui/components/activity_heatmap.rs | 20 +- src/ui/components/branch_progress_list.rs | 30 +- src/ui/components/skill_tree.rs | 11 +- src/ui/components/stats_dashboard.rs | 102 +++-- 12 files changed, 1507 insertions(+), 267 deletions(-) diff --git a/src/app.rs b/src/app.rs index 470966b..68f4a7b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,7 @@ use std::collections::HashSet; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::thread; use std::time::Instant; use rand::Rng; @@ -16,7 +19,10 @@ use crate::generator::code_patterns; use crate::generator::code_syntax::CodeSyntaxGenerator; use crate::generator::dictionary::Dictionary; use crate::generator::numbers; -use crate::generator::passage::PassageGenerator; +use crate::generator::passage::{ + GUTENBERG_BOOKS, PassageGenerator, book_by_key, download_book_to_cache_with_progress, + is_book_cached, passage_options, uncached_books, +}; use crate::generator::phonetic::PhoneticGenerator; use crate::generator::punctuate; use crate::generator::transition_table::TransitionTable; @@ -39,6 +45,9 @@ pub enum AppScreen { Settings, SkillTree, CodeLanguageSelect, + PassageBookSelect, + PassageIntro, + PassageDownloadProgress, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -48,6 +57,20 @@ pub enum DrillMode { Passage, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PassageDownloadCompleteAction { + StartPassageDrill, + ReturnToSettings, +} + +struct PassageDownloadJob { + downloaded_bytes: Arc, + total_bytes: Arc, + done: Arc, + success: Arc, + handle: Option>, +} + impl DrillMode { pub fn as_str(self) -> &'static str { match self { @@ -79,6 +102,7 @@ pub struct App { pub store: Option, pub should_quit: bool, pub settings_selected: usize, + pub settings_editing_download_dir: bool, pub stats_tab: usize, pub depressed_keys: HashSet, pub last_key_time: Option, @@ -88,12 +112,27 @@ pub struct App { pub skill_tree_detail_scroll: usize, pub drill_source_info: Option, pub code_language_selected: usize, + pub passage_book_selected: usize, + pub passage_intro_selected: usize, + pub passage_intro_downloads_enabled: bool, + pub passage_intro_download_dir: String, + pub passage_intro_paragraph_limit: usize, + pub passage_intro_downloading: bool, + pub passage_intro_download_total: usize, + pub passage_intro_downloaded: usize, + pub passage_intro_current_book: String, + pub passage_intro_download_bytes: u64, + pub passage_intro_download_bytes_total: u64, + pub passage_download_queue: Vec, + pub passage_drill_selection_override: Option, + pub passage_download_action: PassageDownloadCompleteAction, pub shift_held: bool, pub keyboard_model: KeyboardModel, rng: SmallRng, transition_table: TransitionTable, #[allow(dead_code)] dictionary: Dictionary, + passage_download_job: Option, } impl App { @@ -141,6 +180,9 @@ impl App { let dictionary = Dictionary::load(); let transition_table = TransitionTable::build_from_words(&dictionary.words_list()); let keyboard_model = KeyboardModel::from_name(&config.keyboard_layout); + let intro_downloads_enabled = config.passage_downloads_enabled; + let intro_download_dir = config.passage_download_dir.clone(); + let intro_paragraph_limit = config.passage_paragraphs_per_book; let mut app = Self { screen: AppScreen::Menu, @@ -159,6 +201,7 @@ impl App { store, should_quit: false, settings_selected: 0, + settings_editing_download_dir: false, stats_tab: 0, depressed_keys: HashSet::new(), last_key_time: None, @@ -168,11 +211,26 @@ impl App { skill_tree_detail_scroll: 0, drill_source_info: None, code_language_selected: 0, + passage_book_selected: 0, + passage_intro_selected: 0, + passage_intro_downloads_enabled: intro_downloads_enabled, + passage_intro_download_dir: intro_download_dir, + passage_intro_paragraph_limit: intro_paragraph_limit, + passage_intro_downloading: false, + passage_intro_download_total: 0, + passage_intro_downloaded: 0, + passage_intro_current_book: String::new(), + passage_intro_download_bytes: 0, + passage_intro_download_bytes_total: 0, + passage_download_queue: Vec::new(), + passage_drill_selection_override: None, + passage_download_action: PassageDownloadCompleteAction::StartPassageDrill, shift_held: false, keyboard_model, rng: SmallRng::from_entropy(), transition_table, dictionary, + passage_download_job: None, }; app.start_drill(); app @@ -325,7 +383,18 @@ impl App { DrillMode::Passage => { let filter = CharFilter::new(('a'..='z').collect()); let rng = SmallRng::from_rng(&mut self.rng).unwrap(); - let mut generator = PassageGenerator::new(rng); + let selection = self + .passage_drill_selection_override + .clone() + .unwrap_or_else(|| self.config.passage_book.clone()); + let mut generator = PassageGenerator::new( + rng, + &selection, + &self.config.passage_download_dir, + self.config.passage_paragraphs_per_book, + self.config.passage_downloads_enabled, + ); + self.passage_drill_selection_override = None; let text = generator.generate(&filter, None, word_count); (text, Some(generator.last_source().to_string())) } @@ -334,11 +403,21 @@ impl App { pub fn type_char(&mut self, ch: char) { if let Some(ref mut drill) = self.drill { - if let Some(event) = input::process_char(drill, ch) { + let event = input::process_char(drill, ch); + let had_event = event.is_some(); + if let Some(event) = event { self.drill_events.push(event); } if drill.is_complete() { + let synthetic_reached_end = drill + .synthetic_spans + .last() + .is_some_and(|span| span.end == drill.target.len()); + if synthetic_reached_end && had_event { + // Give the user a chance to backspace erroneous Enter/Tab spans at EOF. + return; + } self.finish_drill(); } } @@ -358,6 +437,7 @@ impl App { &self.drill_events, self.drill_mode.as_str(), ranked, + false, ); if ranked { @@ -411,6 +491,27 @@ impl App { } } + pub fn finish_partial_drill(&mut self) { + if let Some(ref drill) = self.drill { + let result = DrillResult::from_drill( + drill, + &self.drill_events, + self.drill_mode.as_str(), + false, + true, + ); + + self.drill_history.push(result.clone()); + if self.drill_history.len() > 500 { + self.drill_history.remove(0); + } + + self.last_result = Some(result); + self.screen = AppScreen::DrillResult; + self.save_data(); + } + } + fn save_data(&self) { if let Some(ref store) = self.store { let _ = store.save_profile(&self.profile); @@ -426,7 +527,15 @@ impl App { } pub fn retry_drill(&mut self) { - self.start_drill(); + if let Some(ref drill) = self.drill { + let text: String = drill.target.iter().collect(); + self.drill = Some(DrillState::new(&text)); + self.drill_events.clear(); + self.last_result = None; + self.screen = AppScreen::Drill; + } else { + self.start_drill(); + } } pub fn go_to_menu(&mut self) { @@ -485,6 +594,11 @@ impl App { self.skill_tree.update(&self.key_stats); } + // Partial sessions are visible in history but do not affect profile/streak activity. + if result.partial { + continue; + } + // Compute score let complexity = self.skill_tree.complexity(); let score = scoring::compute_score(result, complexity); @@ -544,9 +658,224 @@ impl App { pub fn go_to_settings(&mut self) { self.settings_selected = 0; + self.settings_editing_download_dir = false; self.screen = AppScreen::Settings; } + pub fn go_to_passage_book_select(&mut self) { + let options = passage_options(); + let selected = options + .iter() + .position(|(key, _)| *key == self.config.passage_book) + .unwrap_or(0); + self.passage_book_selected = selected; + self.screen = AppScreen::PassageBookSelect; + } + + pub fn go_to_passage_intro(&mut self) { + self.passage_intro_selected = 0; + self.passage_intro_downloads_enabled = self.config.passage_downloads_enabled; + self.passage_intro_download_dir = self.config.passage_download_dir.clone(); + self.passage_intro_paragraph_limit = self.config.passage_paragraphs_per_book; + self.passage_intro_downloading = false; + self.passage_intro_download_total = 0; + self.passage_intro_downloaded = 0; + self.passage_intro_current_book.clear(); + self.passage_intro_download_bytes = 0; + self.passage_intro_download_bytes_total = 0; + self.passage_download_queue.clear(); + self.passage_download_job = None; + self.passage_download_action = PassageDownloadCompleteAction::StartPassageDrill; + self.screen = AppScreen::PassageIntro; + } + + pub fn start_passage_drill(&mut self) { + // Lazy source selection: choose a specific source for this drill and + // download exactly one missing book when needed. + if self.passage_drill_selection_override.is_none() && self.config.passage_downloads_enabled + { + let chosen = if self.config.passage_book == "all" { + let count = GUTENBERG_BOOKS.len() + 1; // + built-in + let idx = self.rng.gen_range(0..count); + if idx == 0 { + "builtin".to_string() + } else { + GUTENBERG_BOOKS[idx - 1].key.to_string() + } + } else { + self.config.passage_book.clone() + }; + + if chosen != "builtin" + && !is_book_cached(&self.config.passage_download_dir, &chosen) + && book_by_key(&chosen).is_some() + { + self.passage_drill_selection_override = Some(chosen.clone()); + self.passage_intro_downloading = true; + self.passage_intro_download_total = 1; + self.passage_intro_downloaded = 0; + self.passage_intro_download_bytes = 0; + self.passage_intro_download_bytes_total = 0; + self.passage_intro_current_book = book_by_key(&chosen) + .map(|b| b.title.to_string()) + .unwrap_or_default(); + self.passage_download_queue = GUTENBERG_BOOKS + .iter() + .enumerate() + .filter_map(|(i, b)| (b.key == chosen).then_some(i)) + .collect(); + self.passage_download_action = PassageDownloadCompleteAction::StartPassageDrill; + self.passage_download_job = None; + self.screen = AppScreen::PassageDownloadProgress; + return; + } + + self.passage_drill_selection_override = Some(chosen); + } else if self.passage_drill_selection_override.is_none() + && self.config.passage_book != "all" + && self.config.passage_book != "builtin" + { + // Downloads disabled: gracefully fall back to built-in if selected book is unavailable. + if !is_book_cached(&self.config.passage_download_dir, &self.config.passage_book) { + self.passage_drill_selection_override = Some("builtin".to_string()); + } else { + self.passage_drill_selection_override = Some(self.config.passage_book.clone()); + } + } + + self.drill_mode = DrillMode::Passage; + self.drill_scope = DrillScope::Global; + self.start_drill(); + } + + pub fn start_passage_downloads(&mut self) { + let uncached = uncached_books(&self.passage_intro_download_dir); + let uncached_keys: std::collections::HashSet<&str> = + uncached.iter().map(|b| b.key).collect(); + self.passage_download_queue = GUTENBERG_BOOKS + .iter() + .enumerate() + .filter_map(|(i, book)| uncached_keys.contains(book.key).then_some(i)) + .collect(); + self.passage_intro_download_total = self.passage_download_queue.len(); + self.passage_intro_downloaded = 0; + self.passage_intro_downloading = self.passage_intro_download_total > 0; + self.passage_intro_download_bytes = 0; + self.passage_intro_download_bytes_total = 0; + self.passage_download_job = None; + } + + pub fn start_passage_downloads_from_settings(&mut self) { + self.go_to_passage_intro(); + self.passage_download_action = PassageDownloadCompleteAction::ReturnToSettings; + self.start_passage_downloads(); + if !self.passage_intro_downloading { + self.go_to_settings(); + } + } + + pub fn process_passage_download_tick(&mut self) { + if !self.passage_intro_downloading { + return; + } + + if self.passage_download_job.is_none() { + let Some(book_index) = self.passage_download_queue.pop() else { + self.passage_intro_downloading = false; + self.passage_intro_current_book.clear(); + match self.passage_download_action { + PassageDownloadCompleteAction::StartPassageDrill => self.start_passage_drill(), + PassageDownloadCompleteAction::ReturnToSettings => self.go_to_settings(), + } + return; + }; + self.spawn_passage_download_job(book_index); + return; + } + + let mut finished = false; + if let Some(job) = self.passage_download_job.as_mut() { + self.passage_intro_download_bytes = job.downloaded_bytes.load(Ordering::Relaxed); + self.passage_intro_download_bytes_total = job.total_bytes.load(Ordering::Relaxed); + finished = job.done.load(Ordering::Relaxed); + } + + if !finished { + return; + } + + if let Some(mut job) = self.passage_download_job.take() { + if let Some(handle) = job.handle.take() { + let _ = handle.join(); + } + if job.success.load(Ordering::Relaxed) { + self.passage_intro_downloaded = self.passage_intro_downloaded.saturating_add(1); + } else { + // Skip failed book and continue queue without hanging. + self.passage_intro_downloaded = self.passage_intro_downloaded.saturating_add(1); + } + } + + if self.passage_intro_downloaded >= self.passage_intro_download_total { + self.passage_intro_downloading = false; + self.passage_intro_current_book.clear(); + self.passage_intro_download_bytes = 0; + self.passage_intro_download_bytes_total = 0; + match self.passage_download_action { + PassageDownloadCompleteAction::StartPassageDrill => self.start_passage_drill(), + PassageDownloadCompleteAction::ReturnToSettings => self.go_to_settings(), + } + } + } + + fn spawn_passage_download_job(&mut self, book_index: usize) { + let Some(book) = GUTENBERG_BOOKS.get(book_index) else { + return; + }; + + self.passage_intro_current_book = book.title.to_string(); + self.passage_intro_download_bytes = 0; + self.passage_intro_download_bytes_total = 0; + + let downloaded_bytes = Arc::new(AtomicU64::new(0)); + let total_bytes = Arc::new(AtomicU64::new(0)); + let done = Arc::new(AtomicBool::new(false)); + let success = Arc::new(AtomicBool::new(false)); + + let dl_clone = Arc::clone(&downloaded_bytes); + let total_clone = Arc::clone(&total_bytes); + let done_clone = Arc::clone(&done); + let success_clone = Arc::clone(&success); + + let cache_dir = self.passage_intro_download_dir.clone(); + let book_ref: &'static crate::generator::passage::GutenbergBook = + &GUTENBERG_BOOKS[book_index]; + + let handle = thread::spawn(move || { + let ok = download_book_to_cache_with_progress( + cache_dir.as_str(), + book_ref, + |downloaded, total| { + dl_clone.store(downloaded, Ordering::Relaxed); + if let Some(total) = total { + total_clone.store(total, Ordering::Relaxed); + } + }, + ); + + success_clone.store(ok, Ordering::Relaxed); + done_clone.store(true, Ordering::Relaxed); + }); + + self.passage_download_job = Some(PassageDownloadJob { + downloaded_bytes, + total_bytes, + done, + success, + handle: Some(handle), + }); + } + pub fn settings_cycle_forward(&mut self) { match self.settings_selected { 0 => { @@ -579,6 +908,20 @@ impl App { let next = (idx + 1) % langs.len(); self.config.code_language = langs[next].to_string(); } + 4 => { + self.config.passage_downloads_enabled = !self.config.passage_downloads_enabled; + } + 5 => { + // Editable text field handled directly in key handler. + } + 6 => { + self.config.passage_paragraphs_per_book = + match self.config.passage_paragraphs_per_book { + 0 => 1, + n if n >= 500 => 0, + n => n + 25, + }; + } _ => {} } } @@ -615,6 +958,20 @@ impl App { let next = if idx == 0 { langs.len() - 1 } else { idx - 1 }; self.config.code_language = langs[next].to_string(); } + 4 => { + self.config.passage_downloads_enabled = !self.config.passage_downloads_enabled; + } + 5 => { + // Editable text field handled directly in key handler. + } + 6 => { + self.config.passage_paragraphs_per_book = + match self.config.passage_paragraphs_per_book { + 0 => 500, + 1 => 0, + n => n.saturating_sub(25).max(1), + }; + } _ => {} } } diff --git a/src/config.rs b/src/config.rs index 1303843..4d87264 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,6 +16,16 @@ pub struct Config { pub word_count: usize, #[serde(default = "default_code_language")] pub code_language: String, + #[serde(default = "default_passage_book")] + pub passage_book: String, + #[serde(default = "default_passage_downloads_enabled")] + pub passage_downloads_enabled: bool, + #[serde(default = "default_passage_download_dir")] + pub passage_download_dir: String, + #[serde(default = "default_passage_paragraphs_per_book")] + pub passage_paragraphs_per_book: usize, + #[serde(default = "default_passage_onboarding_done")] + pub passage_onboarding_done: bool, } fn default_target_wpm() -> u32 { @@ -33,6 +43,26 @@ fn default_word_count() -> usize { fn default_code_language() -> String { "rust".to_string() } +fn default_passage_book() -> String { + "all".to_string() +} +fn default_passage_downloads_enabled() -> bool { + false +} +fn default_passage_download_dir() -> String { + dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("keydr") + .join("passages") + .to_string_lossy() + .to_string() +} +fn default_passage_paragraphs_per_book() -> usize { + 100 +} +fn default_passage_onboarding_done() -> bool { + false +} impl Default for Config { fn default() -> Self { @@ -42,6 +72,11 @@ impl Default for Config { keyboard_layout: default_keyboard_layout(), word_count: default_word_count(), code_language: default_code_language(), + passage_book: default_passage_book(), + passage_downloads_enabled: default_passage_downloads_enabled(), + passage_download_dir: default_passage_download_dir(), + passage_paragraphs_per_book: default_passage_paragraphs_per_book(), + passage_onboarding_done: default_passage_onboarding_done(), } } } diff --git a/src/generator/cache.rs b/src/generator/cache.rs index 7cd917f..e4c0709 100644 --- a/src/generator/cache.rs +++ b/src/generator/cache.rs @@ -1,4 +1,6 @@ use std::fs; +#[cfg(feature = "network")] +use std::io::Read; use std::path::PathBuf; pub struct DiskCache { @@ -53,3 +55,43 @@ pub fn fetch_url(url: &str) -> Option { pub fn fetch_url(_url: &str) -> Option { None } + +#[cfg(feature = "network")] +pub fn fetch_url_bytes_with_progress(url: &str, mut on_progress: F) -> Option> +where + F: FnMut(u64, Option), +{ + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .ok()?; + let mut response = client.get(url).send().ok()?; + if !response.status().is_success() { + return None; + } + + let total = response.content_length(); + let mut out: Vec = Vec::new(); + let mut buf = [0u8; 16 * 1024]; + let mut downloaded = 0u64; + + loop { + let n = response.read(&mut buf).ok()?; + if n == 0 { + break; + } + out.extend_from_slice(&buf[..n]); + downloaded = downloaded.saturating_add(n as u64); + on_progress(downloaded, total); + } + + Some(out) +} + +#[cfg(not(feature = "network"))] +pub fn fetch_url_bytes_with_progress(_url: &str, _on_progress: F) -> Option> +where + F: FnMut(u64, Option), +{ + None +} diff --git a/src/generator/passage.rs b/src/generator/passage.rs index 8e579d4..6dc879a 100644 --- a/src/generator/passage.rs +++ b/src/generator/passage.rs @@ -1,12 +1,14 @@ +use std::fs; +use std::path::PathBuf; + use rand::Rng; use rand::rngs::SmallRng; use crate::engine::filter::CharFilter; use crate::generator::TextGenerator; -use crate::generator::cache::{DiskCache, fetch_url}; +use crate::generator::cache::fetch_url_bytes_with_progress; const PASSAGES: &[&str] = &[ - // Classic literature & speeches "the quick brown fox jumps over the lazy dog and then runs across the field while the sun sets behind the distant hills", "it was the best of times it was the worst of times it was the age of wisdom it was the age of foolishness", "in the beginning there was nothing but darkness and then the light appeared slowly spreading across the vast empty space", @@ -17,94 +19,137 @@ const PASSAGES: &[&str] = &[ "all that glitters is not gold and not all those who wander are lost for the old that is strong does not wither", "the river flowed quietly through the green valley and the mountains rose high on either side covered with trees and snow", "a long time ago in a land far away there lived a wise king who ruled his people with kindness and justice", - "the rain fell steadily on the roof making a soft drumming sound that filled the room and made everything feel calm", - "she opened the door and stepped outside into the cool morning air breathing deeply as the first light of dawn appeared", - "he picked up the book and began to read turning the pages slowly as the story drew him deeper and deeper into its world", - "the stars shone brightly in the clear night sky and the moon cast a silver light over the sleeping town below", - "they gathered around the fire telling stories and laughing while the wind howled outside and the snow piled up against the door", - // Pride and Prejudice - "it is a truth universally acknowledged that a single man in possession of a good fortune must be in want of a wife", - "there is a stubbornness about me that never can bear to be frightened at the will of others my courage always rises at every attempt to intimidate me", - "i could easily forgive his pride if he had not mortified mine but vanity not love has been my folly", - // Alice in Wonderland - "alice was beginning to get very tired of sitting by her sister on the bank and of having nothing to do", - "who in the world am i that is the great puzzle she said as she looked around the strange room with wonder", - "but i dont want to go among mad people alice remarked oh you cant help that said the cat were all mad here", - // Great Gatsby - "in my younger and more vulnerable years my father gave me some advice that i have been turning over in my mind ever since", - "so we beat on boats against the current borne back ceaselessly into the past dreaming of that green light", - // Sherlock Holmes - "when you have eliminated the impossible whatever remains however improbable must be the truth my dear watson", - "the world is full of obvious things which nobody by any chance ever observes but which are perfectly visible", - // Moby Dick - "call me ishmael some years ago having little or no money in my purse and nothing particular to interest me on shore", - "it is not down on any map because true places never are and the voyage was long and the sea was deep", - // 1984 - "it was a bright cold day in april and the clocks were striking thirteen winston smith his chin nuzzled into his breast", - "who controls the past controls the future and who controls the present controls the past said the voice from the screen", - // Walden - "i went to the woods because i wished to live deliberately to front only the essential facts of life", - "the mass of men lead lives of quiet desperation and go to the grave with the song still in them", - // Science & philosophy - "the only way to do great work is to love what you do and if you have not found it yet keep looking and do not settle", - "imagination is more important than knowledge for while knowledge defines all we currently know imagination points to what we might discover", - "the important thing is not to stop questioning for curiosity has its own reason for existing in this wonderful universe", - "we are all in the gutter but some of us are looking at the stars and dreaming of worlds beyond our own", - "the greatest glory in living lies not in never falling but in rising every time we fall and trying once more", - // Nature & observation - "the autumn wind scattered golden leaves across the garden as the last rays of sunlight painted the clouds in shades of orange and pink", - "deep in the forest where the ancient trees stood tall and silent a small stream wound its way through moss covered stones", - "the ocean stretched endlessly before them its surface catching the light of the setting sun in a thousand shimmering reflections", - "morning mist hung low over the meadow as the first birds began their chorus and dew drops sparkled like diamonds on every blade of grass", - "the mountain peak stood above the clouds its snow covered summit glowing pink and gold in the light of the early morning sun", - // Everyday wisdom - "the best time to plant a tree was twenty years ago and the second best time is now so do not wait any longer to begin", - "a journey of a thousand miles begins with a single step and every great achievement started with the decision to try", - "the more that you read the more things you will know and the more that you learn the more places you will go", - "in three words i can sum up everything i have learned about life it goes on and so must we with hope", - "happiness is not something ready made it comes from your own actions and your choices shape the life you live", - "do not go where the path may lead but go instead where there is no path and leave a trail for others to follow", - "success is not final failure is not fatal it is the courage to continue that counts in the end", - "be yourself because everyone else is already taken and the world needs what only you can bring to it", - "life is what happens when you are busy making other plans so enjoy the journey along the way", - "the secret of getting ahead is getting started and the secret of getting started is breaking your tasks into small steps", ]; -/// Gutenberg book IDs for popular public domain works -const GUTENBERG_IDS: &[(u32, &str)] = &[ - (1342, "pride_and_prejudice"), - (11, "alice_in_wonderland"), - (1661, "sherlock_holmes"), - (84, "frankenstein"), - (1952, "yellow_wallpaper"), - (2701, "moby_dick"), - (74, "tom_sawyer"), - (345, "dracula"), - (1232, "prince"), - (76, "huckleberry_finn"), - (5200, "metamorphosis"), - (2542, "aesop_fables"), - (174, "dorian_gray"), - (98, "tale_two_cities"), - (1080, "modest_proposal"), - (219, "heart_of_darkness"), - (4300, "ulysses"), - (28054, "brothers_karamazov"), - (2554, "crime_and_punishment"), - (55, "oz"), +pub struct GutenbergBook { + pub id: u32, + pub key: &'static str, + pub title: &'static str, +} + +pub const GUTENBERG_BOOKS: &[GutenbergBook] = &[ + GutenbergBook { + id: 1342, + key: "pride_prejudice", + title: "Pride and Prejudice", + }, + GutenbergBook { + id: 11, + key: "alice_wonderland", + title: "Alice in Wonderland", + }, + GutenbergBook { + id: 1661, + key: "sherlock_holmes", + title: "Sherlock Holmes", + }, + GutenbergBook { + id: 84, + key: "frankenstein", + title: "Frankenstein", + }, + GutenbergBook { + id: 2701, + key: "moby_dick", + title: "Moby Dick", + }, + GutenbergBook { + id: 98, + key: "tale_two_cities", + title: "A Tale of Two Cities", + }, + GutenbergBook { + id: 2554, + key: "crime_punishment", + title: "Crime and Punishment", + }, ]; +pub fn passage_options() -> Vec<(&'static str, String)> { + let mut out = vec![ + ("all", "All (Built-in + all books)".to_string()), + ("builtin", "Built-in passages only".to_string()), + ]; + for book in GUTENBERG_BOOKS { + out.push((book.key, format!("Book: {}", book.title))); + } + out +} + +pub fn is_valid_passage_book(book: &str) -> bool { + book == "all" || book == "builtin" || GUTENBERG_BOOKS.iter().any(|b| b.key == book) +} + +pub fn uncached_books(cache_dir: &str) -> Vec<&'static GutenbergBook> { + GUTENBERG_BOOKS + .iter() + .filter(|book| !cache_file(cache_dir, book.key).exists()) + .collect() +} + +pub fn book_by_key(key: &str) -> Option<&'static GutenbergBook> { + GUTENBERG_BOOKS.iter().find(|b| b.key == key) +} + +pub fn is_book_cached(cache_dir: &str, key: &str) -> bool { + cache_file(cache_dir, key).exists() +} + +pub fn download_book_to_cache_with_progress( + cache_dir: &str, + book: &GutenbergBook, + mut on_progress: F, +) -> bool +where + F: FnMut(u64, Option), +{ + let _ = fs::create_dir_all(cache_dir); + let url = format!( + "https://www.gutenberg.org/cache/epub/{}/pg{}.txt", + book.id, book.id + ); + if let Some(bytes) = fetch_url_bytes_with_progress(&url, |downloaded, total| { + on_progress(downloaded, total); + }) { + return fs::write(cache_file(cache_dir, book.key), bytes).is_ok(); + } + false +} + +fn cache_file(cache_dir: &str, key: &str) -> PathBuf { + PathBuf::from(cache_dir).join(format!("{key}.txt")) +} + pub struct PassageGenerator { - fetched_passages: Vec, + fetched_passages: Vec<(String, String)>, rng: SmallRng, + selection: String, + cache_dir: String, + paragraph_limit: usize, + _downloads_enabled: bool, last_source: String, } impl PassageGenerator { - pub fn new(rng: SmallRng) -> Self { + pub fn new( + rng: SmallRng, + selection: &str, + cache_dir: &str, + paragraph_limit: usize, + downloads_enabled: bool, + ) -> Self { + let selected = if is_valid_passage_book(selection) { + selection.to_string() + } else { + "all".to_string() + }; let mut generator = Self { fetched_passages: Vec::new(), rng, + selection: selected, + cache_dir: cache_dir.to_string(), + paragraph_limit, + _downloads_enabled: downloads_enabled, last_source: "Built-in passage library".to_string(), }; generator.load_cached_passages(); @@ -116,43 +161,15 @@ impl PassageGenerator { } fn load_cached_passages(&mut self) { - if let Some(cache) = DiskCache::new("passages") { - for &(_, name) in GUTENBERG_IDS { - if let Some(content) = cache.get(name) { - let paragraphs = extract_paragraphs(&content); - self.fetched_passages.extend(paragraphs); + let _ = fs::create_dir_all(&self.cache_dir); + for book in relevant_books(&self.selection) { + if let Ok(content) = fs::read_to_string(cache_file(&self.cache_dir, book.key)) { + for para in extract_paragraphs(&content, self.paragraph_limit) { + self.fetched_passages.push((para, book.title.to_string())); } } } } - - fn try_fetch_gutenberg(&mut self) { - let cache = match DiskCache::new("passages") { - Some(c) => c, - None => return, - }; - - // Pick a random book that we haven't cached yet - let uncached: Vec<(u32, &str)> = GUTENBERG_IDS - .iter() - .filter(|(_, name)| cache.get(name).is_none()) - .copied() - .collect(); - - if uncached.is_empty() { - return; - } - - let idx = self.rng.gen_range(0..uncached.len()); - let (book_id, name) = uncached[idx]; - let url = format!("https://www.gutenberg.org/cache/epub/{book_id}/pg{book_id}.txt"); - - if let Some(content) = fetch_url(&url) { - cache.put(name, &content); - let paragraphs = extract_paragraphs(&content); - self.fetched_passages.extend(paragraphs); - } - } } impl TextGenerator for PassageGenerator { @@ -160,39 +177,48 @@ impl TextGenerator for PassageGenerator { &mut self, _filter: &CharFilter, _focused: Option, - _word_count: usize, + word_count: usize, ) -> String { - // Opportunistically fetch Gutenberg passages for source variety. - if self.fetched_passages.len() < 50 && self.rng.gen_bool(0.35) { - self.try_fetch_gutenberg(); + let use_builtin = self.selection == "all" || self.selection == "builtin"; + let total = (if use_builtin { PASSAGES.len() } else { 0 }) + self.fetched_passages.len(); + + if total == 0 { + let idx = self.rng.gen_range(0..PASSAGES.len()); + self.last_source = "Built-in passage library (fallback)".to_string(); + return normalize_keyboard_text(PASSAGES[idx]); + } + let idx = self.rng.gen_range(0..total); + if use_builtin && idx < PASSAGES.len() { + self.last_source = "Built-in passage library".to_string(); + return fit_to_word_target(&normalize_keyboard_text(PASSAGES[idx]), word_count); } - let total_passages = PASSAGES.len() + self.fetched_passages.len(); - - if total_passages == 0 { - self.last_source = "Built-in passage library".to_string(); - return PASSAGES[0].to_string(); - } - - // Randomly mix embedded and fetched passages. - let idx = self.rng.gen_range(0..total_passages); - - if idx < PASSAGES.len() { - self.last_source = "Built-in passage library".to_string(); - PASSAGES[idx].to_string() + let fetched_idx = if use_builtin { + idx - PASSAGES.len() } else { - let fetched_idx = idx - PASSAGES.len(); - self.last_source = "Project Gutenberg (cached)".to_string(); - self.fetched_passages[fetched_idx % self.fetched_passages.len()].clone() - } + idx + }; + let (text, source) = &self.fetched_passages[fetched_idx % self.fetched_passages.len()]; + self.last_source = format!("Project Gutenberg ({source})"); + fit_to_word_target(text, word_count) } } -/// Extract readable paragraphs from Gutenberg text, skipping header/footer -fn extract_paragraphs(text: &str) -> Vec { - let mut paragraphs = Vec::new(); +fn relevant_books(selection: &str) -> Vec<&'static GutenbergBook> { + if selection == "all" || selection == "builtin" { + return GUTENBERG_BOOKS.iter().collect(); + } + GUTENBERG_BOOKS + .iter() + .filter(|book| book.key == selection) + .collect() +} - // Find the start of actual content (after Gutenberg header) +fn extract_paragraphs(text: &str, limit: usize) -> Vec { + const MIN_WORDS: usize = 12; + const MAX_WORDS: usize = 42; + + let mut paragraphs = Vec::new(); let start_markers = ["*** START OF", "***START OF"]; let end_markers = ["*** END OF", "***END OF"]; @@ -200,52 +226,150 @@ fn extract_paragraphs(text: &str) -> Vec { .iter() .filter_map(|marker| text.find(marker)) .min() - .map(|pos| { - // Find the end of the header line - text[pos..].find('\n').map(|nl| pos + nl + 1).unwrap_or(pos) - }) + .map(|pos| text[pos..].find('\n').map(|nl| pos + nl + 1).unwrap_or(pos)) .unwrap_or(0); - let content_end = end_markers .iter() .filter_map(|marker| text.find(marker)) .min() .unwrap_or(text.len()); + let normalized = normalize_keyboard_text( + &text[content_start..content_end] + .replace("\r\n", "\n") + .replace('\r', "\n"), + ); - let content = &text[content_start..content_end]; + for para in normalized.split("\n\n") { + let raw = para.trim_matches('\n'); + if raw.is_empty() { + continue; + } - // Split into paragraphs (double newline separated) - for para in content.split("\r\n\r\n").chain(content.split("\n\n")) { - let cleaned: String = para - .lines() - .map(|l| l.trim()) - .collect::>() - .join(" ") + let has_letters = raw.chars().any(|c| c.is_alphabetic()); + let has_only_supported_controls = raw .chars() - .filter(|c| { - c.is_ascii_alphanumeric() || c.is_ascii_whitespace() || c.is_ascii_punctuation() - }) - .collect::() - .to_lowercase(); + .all(|c| !c.is_control() || c == '\n' || c == '\t'); + let word_count = raw.split_whitespace().count(); + if !has_letters || !has_only_supported_controls || word_count < MIN_WORDS { + continue; + } - let word_count = cleaned.split_whitespace().count(); - if word_count >= 15 && word_count <= 60 { - // Keep only the alpha/space portions for typing - let typing_text: String = cleaned - .chars() - .filter(|c| c.is_ascii_lowercase() || *c == ' ') - .collect::() - .split_whitespace() - .collect::>() - .join(" "); - - if typing_text.split_whitespace().count() >= 10 { - paragraphs.push(typing_text); - } + if word_count <= MAX_WORDS { + paragraphs.push(raw.to_string()); + } else { + paragraphs.extend(split_into_sentence_chunks(raw, MIN_WORDS, MAX_WORDS)); } } - // Take at most 100 paragraphs per book - paragraphs.truncate(100); + if limit > 0 { + paragraphs.truncate(limit); + } paragraphs } + +fn normalize_keyboard_text(text: &str) -> String { + text.chars() + .map(|c| match c { + '\u{2018}' | '\u{2019}' | '\u{201B}' | '\u{2032}' => '\'', + '\u{201C}' | '\u{201D}' | '\u{201F}' | '\u{2033}' => '"', + '\u{2013}' | '\u{2014}' | '\u{2015}' | '\u{2212}' => '-', + '\u{2026}' => '.', + '\u{00A0}' => ' ', + _ => c, + }) + .collect() +} + +fn fit_to_word_target(text: &str, target_words: usize) -> String { + if target_words == 0 { + return text.to_string(); + } + let words: Vec<&str> = text.split_whitespace().collect(); + if words.is_empty() { + return text.to_string(); + } + // Keep passages slightly longer than target at most. + let keep = target_words.saturating_mul(6) / 5; + if words.len() <= keep.max(1) { + return text.to_string(); + } + words[..keep.max(1)].join(" ") +} + +fn split_into_sentence_chunks(text: &str, min_words: usize, max_words: usize) -> Vec { + let mut sentences: Vec = Vec::new(); + let mut start = 0usize; + for (idx, ch) in text.char_indices() { + if matches!(ch, '.' | '!' | '?') { + let end = idx + ch.len_utf8(); + let s = text[start..end].trim(); + if !s.is_empty() { + sentences.push(s.to_string()); + } + start = end; + } + } + let tail = text[start..].trim(); + if !tail.is_empty() { + sentences.push(tail.to_string()); + } + + let mut chunks: Vec = Vec::new(); + let mut current = String::new(); + let mut current_words = 0usize; + + for sentence in sentences { + let w = sentence.split_whitespace().count(); + if w == 0 { + continue; + } + if w > max_words { + if current_words >= min_words { + chunks.push(current.trim().to_string()); + } + current.clear(); + current_words = 0; + chunks.extend(split_long_by_words(&sentence, min_words, max_words)); + continue; + } + + if current_words == 0 { + current = sentence; + current_words = w; + } else if current_words + w <= max_words { + current.push(' '); + current.push_str(&sentence); + current_words += w; + } else { + if current_words >= min_words { + chunks.push(current.trim().to_string()); + } + current = sentence; + current_words = w; + } + } + + if current_words >= min_words { + chunks.push(current.trim().to_string()); + } + + chunks +} + +fn split_long_by_words(sentence: &str, min_words: usize, max_words: usize) -> Vec { + let words: Vec<&str> = sentence.split_whitespace().collect(); + let mut out: Vec = Vec::new(); + let mut i = 0usize; + while i < words.len() { + let end = (i + max_words).min(words.len()); + let chunk = words[i..end].join(" "); + if chunk.split_whitespace().count() >= min_words { + out.push(chunk); + } else if let Some(last) = out.last_mut() { + last.push(' '); + last.push_str(&chunk); + } + i = end; + } + out +} diff --git a/src/main.rs b/src/main.rs index 3b69258..0b9f623 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,7 +31,7 @@ use ratatui::widgets::{Block, Paragraph, Widget}; use app::{App, AppScreen, DrillMode}; use engine::skill_tree::DrillScope; use event::{AppEvent, EventHandler}; -use session::result::DrillResult; +use generator::passage::passage_options; use ui::components::dashboard::Dashboard; use ui::components::keyboard_diagram::KeyboardDiagram; use ui::components::skill_tree::{SkillTreeWidget, detail_line_count, selectable_branches}; @@ -117,6 +117,12 @@ fn run_app( match events.next()? { AppEvent::Key(key) => handle_key(app, key), AppEvent::Tick => { + if (app.screen == AppScreen::PassageIntro + || app.screen == AppScreen::PassageDownloadProgress) + && app.passage_intro_downloading + { + app.process_passage_download_tick(); + } // Fallback: clear depressed keys after 150ms if no Release event received if let Some(last) = app.last_key_time { if last.elapsed() > Duration::from_millis(150) && !app.depressed_keys.is_empty() @@ -175,6 +181,9 @@ fn handle_key(app: &mut App, key: KeyEvent) { AppScreen::Settings => handle_settings_key(app, key), AppScreen::SkillTree => handle_skill_tree_key(app, key), AppScreen::CodeLanguageSelect => handle_code_language_key(app, key), + AppScreen::PassageBookSelect => handle_passage_book_key(app, key), + AppScreen::PassageIntro => handle_passage_intro_key(app, key), + AppScreen::PassageDownloadProgress => handle_passage_download_progress_key(app, key), } } @@ -190,9 +199,7 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) { app.go_to_code_language_select(); } KeyCode::Char('3') => { - app.drill_mode = DrillMode::Passage; - app.drill_scope = DrillScope::Global; - app.start_drill(); + app.go_to_passage_book_select(); } KeyCode::Char('t') => app.go_to_skill_tree(), KeyCode::Char('s') => app.go_to_stats(), @@ -209,9 +216,7 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) { app.go_to_code_language_select(); } 2 => { - app.drill_mode = DrillMode::Passage; - app.drill_scope = DrillScope::Global; - app.start_drill(); + app.go_to_passage_book_select(); } 3 => app.go_to_skill_tree(), 4 => app.go_to_stats(), @@ -242,18 +247,8 @@ fn handle_drill_key(app: &mut App, key: KeyEvent) { match key.code { KeyCode::Esc => { let has_progress = app.drill.as_ref().is_some_and(|d| d.cursor > 0); - if has_progress && app.drill_mode != DrillMode::Adaptive { - // Non-adaptive: show result screen for partial drill - if let Some(ref drill) = app.drill { - let result = DrillResult::from_drill( - drill, - &app.drill_events, - app.drill_mode.as_str(), - app.drill_mode.is_ranked(), - ); - app.last_result = Some(result); - } - app.screen = AppScreen::DrillResult; + if has_progress { + app.finish_partial_drill(); } else { app.go_to_menu(); } @@ -347,6 +342,22 @@ fn handle_stats_key(app: &mut App, key: KeyEvent) { } fn handle_settings_key(app: &mut App, key: KeyEvent) { + if app.settings_editing_download_dir { + match key.code { + KeyCode::Esc => { + app.settings_editing_download_dir = false; + } + KeyCode::Backspace => { + app.config.passage_download_dir.pop(); + } + KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + app.config.passage_download_dir.push(ch); + } + _ => {} + } + return; + } + match key.code { KeyCode::Esc => { let _ = app.config.save(); @@ -358,15 +369,30 @@ fn handle_settings_key(app: &mut App, key: KeyEvent) { } } KeyCode::Down | KeyCode::Char('j') => { - if app.settings_selected < 3 { + if app.settings_selected < 7 { app.settings_selected += 1; } } - KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => { - app.settings_cycle_forward(); + KeyCode::Enter => { + if app.settings_selected == 5 { + app.settings_editing_download_dir = true; + } else if app.settings_selected == 7 { + app.start_passage_downloads_from_settings(); + } else { + app.settings_cycle_forward(); + } + } + KeyCode::Right | KeyCode::Char('l') => { + if app.settings_selected < 5 { + app.settings_cycle_forward(); + } else if app.settings_selected == 6 { + app.settings_cycle_forward(); + } } KeyCode::Left | KeyCode::Char('h') => { - app.settings_cycle_backward(); + if app.settings_selected < 5 || app.settings_selected == 6 { + app.settings_cycle_backward(); + } } _ => {} } @@ -422,6 +448,135 @@ fn start_code_drill(app: &mut App, langs: &[&str]) { } } +fn handle_passage_book_key(app: &mut App, key: KeyEvent) { + let options = passage_options(); + match key.code { + KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(), + KeyCode::Up | KeyCode::Char('k') => { + app.passage_book_selected = app.passage_book_selected.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + if app.passage_book_selected + 1 < options.len() { + app.passage_book_selected += 1; + } + } + KeyCode::Char(ch) if ch.is_ascii_digit() => { + let idx = (ch as usize).saturating_sub('1' as usize); + if idx < options.len() { + app.passage_book_selected = idx; + confirm_passage_book_and_continue(app, &options); + } + } + KeyCode::Enter => { + confirm_passage_book_and_continue(app, &options); + } + _ => {} + } +} + +fn confirm_passage_book_and_continue(app: &mut App, options: &[(&'static str, String)]) { + if app.passage_book_selected >= options.len() { + return; + } + app.config.passage_book = options[app.passage_book_selected].0.to_string(); + let _ = app.config.save(); + + if app.config.passage_onboarding_done { + app.start_passage_drill(); + } else { + app.go_to_passage_intro(); + } +} + +fn handle_passage_intro_key(app: &mut App, key: KeyEvent) { + const INTRO_FIELDS: usize = 4; + + if app.passage_intro_downloading { + return; + } + + match key.code { + KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(), + KeyCode::Up | KeyCode::Char('k') => { + app.passage_intro_selected = app.passage_intro_selected.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + if app.passage_intro_selected + 1 < INTRO_FIELDS { + app.passage_intro_selected += 1; + } + } + KeyCode::Left | KeyCode::Char('h') => match app.passage_intro_selected { + 0 => app.passage_intro_downloads_enabled = !app.passage_intro_downloads_enabled, + 2 => { + app.passage_intro_paragraph_limit = match app.passage_intro_paragraph_limit { + 0 => 500, + 1 => 0, + n => n.saturating_sub(25).max(1), + }; + } + _ => {} + }, + KeyCode::Right | KeyCode::Char('l') => match app.passage_intro_selected { + 0 => app.passage_intro_downloads_enabled = !app.passage_intro_downloads_enabled, + 2 => { + app.passage_intro_paragraph_limit = match app.passage_intro_paragraph_limit { + 0 => 1, + n if n >= 500 => 0, + n => n + 25, + }; + } + _ => {} + }, + KeyCode::Backspace => match app.passage_intro_selected { + 1 => { + app.passage_intro_download_dir.pop(); + } + 2 => { + app.passage_intro_paragraph_limit /= 10; + } + _ => {} + }, + KeyCode::Char(ch) => match app.passage_intro_selected { + 1 if !key.modifiers.contains(KeyModifiers::CONTROL) => { + app.passage_intro_download_dir.push(ch); + } + 2 if ch.is_ascii_digit() => { + let digit = (ch as u8 - b'0') as usize; + app.passage_intro_paragraph_limit = app + .passage_intro_paragraph_limit + .saturating_mul(10) + .saturating_add(digit) + .min(50_000); + } + _ => {} + }, + KeyCode::Enter => { + if app.passage_intro_selected == 0 { + app.passage_intro_downloads_enabled = !app.passage_intro_downloads_enabled; + return; + } + if app.passage_intro_selected != 3 { + return; + } + + app.config.passage_downloads_enabled = app.passage_intro_downloads_enabled; + app.config.passage_download_dir = app.passage_intro_download_dir.clone(); + app.config.passage_paragraphs_per_book = app.passage_intro_paragraph_limit; + app.config.passage_onboarding_done = true; + let _ = app.config.save(); + app.start_passage_drill(); + } + _ => {} + } +} + +fn handle_passage_download_progress_key(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(), + _ => {} + } +} + fn handle_skill_tree_key(app: &mut App, key: KeyEvent) { const DETAIL_SCROLL_STEP: usize = 10; let max_scroll = skill_tree_detail_max_scroll(app); @@ -504,7 +659,9 @@ fn skill_tree_detail_max_scroll(app: &App) -> usize { ]) .split(inner); let detail_height = layout.get(2).map(|r| r.height as usize).unwrap_or(0); - let selected = app.skill_tree_selected.min(branches.len().saturating_sub(1)); + let selected = app + .skill_tree_selected + .min(branches.len().saturating_sub(1)); let total_lines = detail_line_count(branches[selected]); total_lines.saturating_sub(detail_height) } @@ -524,6 +681,9 @@ fn render(frame: &mut ratatui::Frame, app: &App) { AppScreen::Settings => render_settings(frame, app), AppScreen::SkillTree => render_skill_tree(frame, app), AppScreen::CodeLanguageSelect => render_code_language_select(frame, app), + AppScreen::PassageBookSelect => render_passage_book_select(frame, app), + AppScreen::PassageIntro => render_passage_intro(frame, app), + AppScreen::PassageDownloadProgress => render_passage_download_progress(frame, app), } } @@ -545,12 +705,11 @@ fn render_menu(frame: &mut ratatui::Frame, app: &App) { } else { String::new() }; + let total_keys = app.skill_tree.total_unique_keys; + let unlocked = app.skill_tree.total_unlocked_count(); + let mastered = app.skill_tree.total_confident_keys(&app.key_stats); let header_info = format!( - " Level {} | Score {:.0} | {}/{} keys{}", - crate::engine::scoring::level_from_score(app.profile.total_score), - app.profile.total_score, - app.skill_tree.total_unlocked_count(), - app.skill_tree.total_unique_keys, + " Key Progress {unlocked}/{total_keys} ({mastered} mastered){}", streak_text, ); let header = Paragraph::new(Line::from(vec![ @@ -716,7 +875,12 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) { " Passage source " }; let source_info = Paragraph::new(Line::from(vec![ - Span::styled(label, Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD)), + Span::styled( + label, + Style::default() + .fg(colors.accent()) + .add_modifier(Modifier::BOLD), + ), Span::styled(source, Style::default().fg(colors.text_pending())), ])); frame.render_widget(source_info, main_layout[idx]); @@ -778,6 +942,9 @@ fn render_stats(frame: &mut ratatui::Frame, app: &App) { &app.key_stats, app.stats_tab, app.config.target_wpm, + app.skill_tree.total_unlocked_count(), + app.skill_tree.total_confident_keys(&app.key_stats), + app.skill_tree.total_unique_keys, app.theme, app.history_selected, app.history_confirm_delete, @@ -814,6 +981,30 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) { format!("{}", app.config.word_count), ), ("Code Language".to_string(), current_lang.clone()), + ( + "Passage Downloads".to_string(), + if app.config.passage_downloads_enabled { + "On".to_string() + } else { + "Off".to_string() + }, + ), + ( + "Passage Download Dir".to_string(), + app.config.passage_download_dir.clone(), + ), + ( + "Paragraphs per Book".to_string(), + if app.config.passage_paragraphs_per_book == 0 { + "Whole book".to_string() + } else { + format!("{}", app.config.passage_paragraphs_per_book) + }, + ), + ( + "Download Passages Now".to_string(), + "Run downloader".to_string(), + ), ]; let layout = Layout::default() @@ -847,7 +1038,11 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) { let indicator = if is_selected { " > " } else { " " }; let label_text = format!("{indicator}{label}:"); - let value_text = format!(" < {value} >"); + let value_text = if i == 7 { + format!(" [ {value} ]") + } else { + format!(" < {value} >") + }; let label_style = Style::default() .fg(if is_selected { @@ -867,17 +1062,40 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) { colors.text_pending() }); - let lines = vec![ - Line::from(Span::styled(label_text, label_style)), - Line::from(Span::styled(value_text, value_style)), - ]; + let lines = if i == 5 { + let path_line = if app.settings_editing_download_dir && is_selected { + format!(" {value}_") + } else { + format!(" {value}") + }; + vec![ + Line::from(Span::styled( + if app.settings_editing_download_dir && is_selected { + format!("{indicator}{label}: (editing)") + } else { + label_text + }, + label_style, + )), + Line::from(Span::styled(path_line, value_style)), + ] + } else { + vec![ + Line::from(Span::styled(label_text, label_style)), + Line::from(Span::styled(value_text, value_style)), + ] + }; Paragraph::new(lines).render(field_layout[i], frame.buffer_mut()); } let _ = (available_themes, languages_all); let footer = Paragraph::new(Line::from(Span::styled( - " [ESC] Save & back [Enter/arrows] Change value", + if app.settings_editing_download_dir { + " Editing path: [Type/Backspace] Modify [ESC] Done editing" + } else { + " [ESC] Save & back [Enter/arrows] Change value [Enter on path] Edit dir" + }, Style::default().fg(colors.accent()), ))); footer.render(layout[3], frame.buffer_mut()); @@ -931,6 +1149,238 @@ fn render_code_language_select(frame: &mut ratatui::Frame, app: &App) { Paragraph::new(lines).render(inner, frame.buffer_mut()); } +fn render_passage_book_select(frame: &mut ratatui::Frame, app: &App) { + let area = frame.area(); + let colors = &app.theme.colors; + let centered = ui::layout::centered_rect(60, 70, area); + + let block = Block::bordered() + .title(" Select Passage Source ") + .border_style(Style::default().fg(colors.accent())) + .style(Style::default().bg(colors.bg())); + let inner = block.inner(centered); + block.render(centered, frame.buffer_mut()); + + let options = passage_options(); + let mut lines: Vec = vec![Line::from("")]; + for (i, (_, label)) in options.iter().enumerate() { + let is_selected = i == app.passage_book_selected; + let indicator = if is_selected { " > " } else { " " }; + let style = if is_selected { + Style::default() + .fg(colors.accent()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(colors.fg()) + }; + lines.push(Line::from(Span::styled( + format!("{indicator}[{}] {label}", i + 1), + style, + ))); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " [Up/Down] Navigate [Enter] Confirm [ESC] Back", + Style::default().fg(colors.text_pending()), + ))); + Paragraph::new(lines).render(inner, frame.buffer_mut()); +} + +fn render_passage_intro(frame: &mut ratatui::Frame, app: &App) { + let area = frame.area(); + let colors = &app.theme.colors; + let centered = ui::layout::centered_rect(75, 80, area); + + let block = Block::bordered() + .title(" Passage Downloads Setup ") + .border_style(Style::default().fg(colors.accent())) + .style(Style::default().bg(colors.bg())); + let inner = block.inner(centered); + block.render(centered, frame.buffer_mut()); + + let paragraphs_value = if app.passage_intro_paragraph_limit == 0 { + "whole book".to_string() + } else { + app.passage_intro_paragraph_limit.to_string() + }; + + let fields = vec![ + ( + "Enable network downloads", + if app.passage_intro_downloads_enabled { + "On".to_string() + } else { + "Off".to_string() + }, + ), + ("Download directory", app.passage_intro_download_dir.clone()), + ("Paragraphs per book (0 = whole)", paragraphs_value), + ("Start passage drill", "Confirm".to_string()), + ]; + + let mut lines = vec![ + Line::from(Span::styled( + "Configure passage source settings before your first passage drill.", + Style::default() + .fg(colors.fg()) + .add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + "Downloads are lazy: books are fetched only when first needed.", + Style::default().fg(colors.text_pending()), + )), + Line::from(Span::styled( + "If you exit without confirming, this dialog will appear again next time.", + Style::default().fg(colors.text_pending()), + )), + Line::from(""), + ]; + + for (i, (label, value)) in fields.iter().enumerate() { + let is_selected = i == app.passage_intro_selected; + let indicator = if is_selected { " > " } else { " " }; + let label_style = if is_selected { + Style::default() + .fg(colors.accent()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(colors.fg()) + }; + let value_style = if is_selected { + Style::default().fg(colors.focused_key()) + } else { + Style::default().fg(colors.text_pending()) + }; + + lines.push(Line::from(Span::styled( + format!("{indicator}{label}"), + label_style, + ))); + if i == 1 { + lines.push(Line::from(Span::styled(format!(" {value}"), value_style))); + } else if i == 3 { + lines.push(Line::from(Span::styled( + format!(" [{value}]"), + value_style, + ))); + } else { + lines.push(Line::from(Span::styled( + format!(" < {value} >"), + value_style, + ))); + } + lines.push(Line::from("")); + } + + if app.passage_intro_downloading { + let total_books = app.passage_intro_download_total.max(1); + let done_books = app.passage_intro_downloaded.min(total_books); + let total_bytes = app.passage_intro_download_bytes_total; + let done_bytes = app + .passage_intro_download_bytes + .min(total_bytes.max(app.passage_intro_download_bytes)); + let width = 30usize; + let fill = if total_bytes > 0 { + ((done_bytes as usize).saturating_mul(width)) / (total_bytes as usize) + } else { + 0 + }; + let bar = format!( + "{}{}", + "=".repeat(fill), + " ".repeat(width.saturating_sub(fill)) + ); + let progress_text = if total_bytes > 0 { + format!(" Downloading current book: [{bar}] {done_bytes}/{total_bytes} bytes") + } else { + format!(" Downloading current book: {done_bytes} bytes") + }; + lines.push(Line::from(Span::styled( + progress_text, + Style::default() + .fg(colors.accent()) + .add_modifier(Modifier::BOLD), + ))); + if !app.passage_intro_current_book.is_empty() { + lines.push(Line::from(Span::styled( + format!( + " Current: {} (book {}/{})", + app.passage_intro_current_book, + done_books.saturating_add(1).min(total_books), + total_books + ), + Style::default().fg(colors.text_pending()), + ))); + } + } else { + lines.push(Line::from(Span::styled( + " [Up/Down] Navigate [Left/Right] Adjust [Type/Backspace] Edit [Enter] Confirm", + Style::default().fg(colors.text_pending()), + ))); + lines.push(Line::from(Span::styled( + " [ESC] Cancel", + Style::default().fg(colors.text_pending()), + ))); + } + + Paragraph::new(lines).render(inner, frame.buffer_mut()); +} + +fn render_passage_download_progress(frame: &mut ratatui::Frame, app: &App) { + let area = frame.area(); + let colors = &app.theme.colors; + let centered = ui::layout::centered_rect(60, 35, area); + + let block = Block::bordered() + .title(" Downloading Passage Source ") + .border_style(Style::default().fg(colors.accent())) + .style(Style::default().bg(colors.bg())); + let inner = block.inner(centered); + block.render(centered, frame.buffer_mut()); + + let total_bytes = app.passage_intro_download_bytes_total; + let done_bytes = app + .passage_intro_download_bytes + .min(total_bytes.max(app.passage_intro_download_bytes)); + let width = 36usize; + let fill = if total_bytes > 0 { + ((done_bytes as usize).saturating_mul(width)) / (total_bytes as usize) + } else { + 0 + }; + let bar = format!( + "{}{}", + "=".repeat(fill), + " ".repeat(width.saturating_sub(fill)) + ); + + let book_name = if app.passage_intro_current_book.is_empty() { + "Preparing download...".to_string() + } else { + app.passage_intro_current_book.clone() + }; + + let lines = vec![ + Line::from(Span::styled( + format!(" Book: {book_name}"), + Style::default() + .fg(colors.fg()) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + if total_bytes > 0 { + format!(" [{bar}] {done_bytes}/{total_bytes} bytes") + } else { + format!(" Downloaded: {done_bytes} bytes") + }, + Style::default().fg(colors.accent()), + )), + ]; + + Paragraph::new(lines).render(inner, frame.buffer_mut()); +} + fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); let centered = ui::layout::centered_rect(70, 90, area); diff --git a/src/session/drill.rs b/src/session/drill.rs index 1601c68..1c849a1 100644 --- a/src/session/drill.rs +++ b/src/session/drill.rs @@ -3,6 +3,12 @@ use std::time::Instant; use crate::session::input::CharStatus; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SyntheticSpan { + pub start: usize, + pub end: usize, +} + pub struct DrillState { pub target: Vec, pub input: Vec, @@ -10,6 +16,7 @@ pub struct DrillState { pub started_at: Option, pub finished_at: Option, pub typo_flags: HashSet, + pub synthetic_spans: Vec, } impl DrillState { @@ -21,6 +28,7 @@ impl DrillState { started_at: None, finished_at: None, typo_flags: HashSet::new(), + synthetic_spans: Vec::new(), } } @@ -158,4 +166,72 @@ mod tests { assert_eq!(drill.typo_count(), 1); assert!(drill.typo_flags.contains(&0)); } + + #[test] + fn test_wrong_enter_skips_line_and_backspace_collapses() { + let mut drill = DrillState::new("abcd\nef"); + input::process_char(&mut drill, 'a'); + assert_eq!(drill.cursor, 1); + + // Wrong newline while expecting 'b' should skip to next line start. + input::process_char(&mut drill, '\n'); + assert_eq!(drill.cursor, 5); // index after '\n' + assert!(drill.typo_count() >= 4); + for pos in 1..5 { + assert!(drill.typo_flags.contains(&pos)); + } + + // Backspacing at jump boundary collapses span to a single typo. + input::process_backspace(&mut drill); + assert_eq!(drill.cursor, 1); + assert_eq!(drill.typo_count(), 1); + assert!(drill.typo_flags.contains(&1)); + } + + #[test] + fn test_wrong_tab_skips_tab_width_and_backspace_collapses() { + let mut drill = DrillState::new("abcdef"); + input::process_char(&mut drill, 'a'); + assert_eq!(drill.cursor, 1); + + // Tab jumps 4 chars (or to end of line). + input::process_char(&mut drill, '\t'); + assert_eq!(drill.cursor, 5); + for pos in 1..5 { + assert!(drill.typo_flags.contains(&pos)); + } + + input::process_backspace(&mut drill); + assert_eq!(drill.cursor, 1); + assert_eq!(drill.typo_count(), 1); + assert!(drill.typo_flags.contains(&1)); + } + + #[test] + fn test_wrong_tab_near_line_end_clamps_to_end_of_line() { + let mut drill = DrillState::new("ab\ncd"); + input::process_char(&mut drill, 'a'); + input::process_char(&mut drill, 'b'); + // At newline position, a wrong tab should consume just this line remainder. + input::process_char(&mut drill, '\t'); + assert_eq!(drill.cursor, 3); + assert_eq!(drill.typo_count(), 1); + } + + #[test] + fn test_nested_synthetic_spans_collapse_to_single_error() { + let mut drill = DrillState::new("abcd\nefgh"); + input::process_char(&mut drill, 'a'); + input::process_char(&mut drill, '\n'); + let after_newline = drill.cursor; + input::process_char(&mut drill, '\t'); + assert!(drill.typo_count() > 1); + + input::process_backspace(&mut drill); // collapse tab span + assert_eq!(drill.cursor, after_newline); + input::process_backspace(&mut drill); // collapse newline span + assert_eq!(drill.cursor, 1); + assert_eq!(drill.typo_count(), 1); + assert!(drill.typo_flags.contains(&1)); + } } diff --git a/src/session/input.rs b/src/session/input.rs index 54f98e5..1505758 100644 --- a/src/session/input.rs +++ b/src/session/input.rs @@ -1,6 +1,6 @@ use std::time::Instant; -use crate::session::drill::DrillState; +use crate::session::drill::{DrillState, SyntheticSpan}; #[derive(Clone, Debug)] pub enum CharStatus { @@ -38,11 +38,16 @@ pub fn process_char(drill: &mut DrillState, ch: char) -> Option if correct { drill.input.push(CharStatus::Correct); + drill.cursor += 1; + } else if ch == '\n' { + apply_newline_span(drill, ch); + } else if ch == '\t' { + apply_tab_span(drill, ch); } else { drill.input.push(CharStatus::Incorrect(ch)); drill.typo_flags.insert(drill.cursor); + drill.cursor += 1; } - drill.cursor += 1; if drill.is_complete() { drill.finished_at = Some(Instant::now()); @@ -52,8 +57,91 @@ pub fn process_char(drill: &mut DrillState, ch: char) -> Option } pub fn process_backspace(drill: &mut DrillState) { - if drill.cursor > 0 { - drill.cursor -= 1; - drill.input.pop(); + if drill.cursor == 0 { + return; } + + if let Some(span) = drill + .synthetic_spans + .last() + .copied() + .filter(|s| s.end == drill.cursor) + { + let span_len = span.end.saturating_sub(span.start); + if span_len > 0 { + let has_chained_prev = drill + .synthetic_spans + .iter() + .rev() + .nth(1) + .is_some_and(|prev| prev.end == span.start); + let new_len = drill.input.len().saturating_sub(span_len); + drill.input.truncate(new_len); + drill.cursor = span.start; + for pos in span.start..span.end { + drill.typo_flags.remove(&pos); + } + if !has_chained_prev { + drill.typo_flags.insert(span.start); + } + drill.synthetic_spans.pop(); + return; + } + } + + drill.cursor -= 1; + drill.input.pop(); +} + +fn apply_newline_span(drill: &mut DrillState, typed: char) { + let start = drill.cursor; + let line_end = drill.target[start..] + .iter() + .position(|&c| c == '\n') + .map(|offset| start + offset + 1) + .unwrap_or(drill.target.len()); + let end = line_end.max(start + 1).min(drill.target.len()); + apply_synthetic_span(drill, start, end, typed, None); +} + +fn apply_tab_span(drill: &mut DrillState, typed: char) { + let start = drill.cursor; + let line_end = drill.target[start..] + .iter() + .position(|&c| c == '\n') + .map(|offset| start + offset) + .unwrap_or(drill.target.len()); + let mut end = (start + 4).min(line_end); + if end <= start { + end = (start + 1).min(drill.target.len()); + } + let first_actual = drill.target.get(start).copied(); + apply_synthetic_span(drill, start, end, typed, first_actual); +} + +fn apply_synthetic_span( + drill: &mut DrillState, + start: usize, + end: usize, + typed: char, + first_actual: Option, +) { + if start >= end || start >= drill.target.len() { + drill.input.push(CharStatus::Incorrect(typed)); + drill.typo_flags.insert(drill.cursor); + drill.cursor += 1; + return; + } + + for idx in start..end { + let actual = if idx == start { + first_actual.unwrap_or(typed) + } else { + drill.target[idx] + }; + drill.input.push(CharStatus::Incorrect(actual)); + drill.typo_flags.insert(idx); + } + drill.cursor = end; + drill.synthetic_spans.push(SyntheticSpan { start, end }); } diff --git a/src/session/result.rs b/src/session/result.rs index 4b17cd4..41684b2 100644 --- a/src/session/result.rs +++ b/src/session/result.rs @@ -19,6 +19,10 @@ pub struct DrillResult { pub drill_mode: String, #[serde(default = "default_true")] pub ranked: bool, + #[serde(default)] + pub partial: bool, + #[serde(default = "default_completion_percent")] + pub completion_percent: f64, } fn default_drill_mode() -> String { @@ -29,6 +33,10 @@ fn default_true() -> bool { true } +fn default_completion_percent() -> f64 { + 100.0 +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct KeyTime { pub key: char, @@ -42,6 +50,7 @@ impl DrillResult { events: &[KeystrokeEvent], drill_mode: &str, ranked: bool, + partial: bool, ) -> Self { let per_key_times: Vec = events .windows(2) @@ -75,6 +84,8 @@ impl DrillResult { per_key_times, drill_mode: drill_mode.to_string(), ranked, + partial, + completion_percent: (drill.progress() * 100.0).clamp(0.0, 100.0), } } } diff --git a/src/ui/components/activity_heatmap.rs b/src/ui/components/activity_heatmap.rs index 8cf90cf..d3573c7 100644 --- a/src/ui/components/activity_heatmap.rs +++ b/src/ui/components/activity_heatmap.rs @@ -42,7 +42,7 @@ impl Widget for ActivityHeatmap<'_> { // Count sessions per day let mut day_counts: HashMap = HashMap::new(); - for result in self.history { + for result in self.history.iter().filter(|r| !r.partial) { let date = result.timestamp.date_naive(); *day_counts.entry(date).or_insert(0) += 1; } @@ -126,8 +126,10 @@ impl Widget for ActivityHeatmap<'_> { } let count = day_counts.get(&date).copied().unwrap_or(0); - let (ch, color) = intensity_cell(count, colors); - buf.set_string(x, y, &ch.to_string(), Style::default().fg(color)); + let color = intensity_cell_bg(count, colors); + // Fill both columns so low-activity cells render as blocks instead of glyphs. + // This avoids cursor-like artifacts in some terminal fonts. + buf.set_string(x, y, " ", Style::default().bg(color).fg(colors.bg())); } current_date += chrono::Duration::weeks(1); @@ -147,13 +149,13 @@ fn scale_color(base: Color, factor: f64) -> Color { } } -fn intensity_cell(count: usize, colors: &crate::ui::theme::ThemeColors) -> (char, Color) { +fn intensity_cell_bg(count: usize, colors: &crate::ui::theme::ThemeColors) -> Color { let success = colors.success(); match count { - 0 => ('·', colors.accent_dim()), - 1..=2 => ('▪', scale_color(success, 0.4)), - 3..=5 => ('▪', scale_color(success, 0.65)), - 6..=15 => ('█', scale_color(success, 0.85)), - _ => ('█', success), + 0 => scale_color(colors.accent_dim(), 0.35), + 1..=2 => scale_color(success, 0.35), + 3..=5 => scale_color(success, 0.6), + 6..=15 => scale_color(success, 0.8), + _ => success, } } diff --git a/src/ui/components/branch_progress_list.rs b/src/ui/components/branch_progress_list.rs index 47403e7..da2a9df 100644 --- a/src/ui/components/branch_progress_list.rs +++ b/src/ui/components/branch_progress_list.rs @@ -92,19 +92,31 @@ impl Widget for BranchProgressList<'_> { let total = self.skill_tree.total_unique_keys; let unlocked = self.skill_tree.total_unlocked_count(); let mastered = self.skill_tree.total_confident_keys(self.key_stats); - let (m_bar, u_bar, e_bar) = compact_dual_bar_parts(mastered, unlocked, total, 12); + let left_pad = if area.width >= 90 { + 3 + } else if area.width >= 70 { + 2 + } else if area.width >= 55 { + 1 + } else { + 0 + }; + let right_pad = if area.width >= 75 { 2 } else { 0 }; + let label = format!("{}Overall Key Progress ", " ".repeat(left_pad)); + let suffix = format!( + " {unlocked}/{total} unlocked ({mastered} mastered){}", + " ".repeat(right_pad) + ); + let reserved = label.len() + suffix.len(); + let bar_width = (area.width as usize).saturating_sub(reserved).max(6); + let (m_bar, u_bar, e_bar) = + compact_dual_bar_parts(mastered, unlocked, total, bar_width); lines.push(Line::from(vec![ - Span::styled( - format!(" {:<14}", "Overall"), - Style::default().fg(colors.fg()), - ), + Span::styled(label, Style::default().fg(colors.fg())), Span::styled(m_bar, Style::default().fg(colors.text_correct())), Span::styled(u_bar, Style::default().fg(colors.accent())), Span::styled(e_bar, Style::default().fg(colors.text_pending())), - Span::styled( - format!(" {unlocked}/{total}"), - Style::default().fg(colors.text_pending()), - ), + Span::styled(suffix, Style::default().fg(colors.text_pending())), ])); } diff --git a/src/ui/components/skill_tree.rs b/src/ui/components/skill_tree.rs index c90065b..bc9b5f5 100644 --- a/src/ui/components/skill_tree.rs +++ b/src/ui/components/skill_tree.rs @@ -146,10 +146,7 @@ impl SkillTreeWidget<'_> { .fg(colors.accent()) .add_modifier(Modifier::BOLD), ), - BranchStatus::Available => ( - " ", - Style::default().fg(colors.fg()), - ), + BranchStatus::Available => (" ", Style::default().fg(colors.fg())), BranchStatus::Locked => (" ", Style::default().fg(colors.text_pending())), }; @@ -359,7 +356,11 @@ impl SkillTreeWidget<'_> { } let max_scroll = lines.len().saturating_sub(visible_height); let scroll = self.detail_scroll.min(max_scroll); - let visible_lines: Vec = lines.into_iter().skip(scroll).take(visible_height).collect(); + let visible_lines: Vec = lines + .into_iter() + .skip(scroll) + .take(visible_height) + .collect(); let paragraph = Paragraph::new(visible_lines); paragraph.render(area, buf); } diff --git a/src/ui/components/stats_dashboard.rs b/src/ui/components/stats_dashboard.rs index a5eefe0..7dd44fa 100644 --- a/src/ui/components/stats_dashboard.rs +++ b/src/ui/components/stats_dashboard.rs @@ -2,8 +2,8 @@ use ratatui::buffer::Buffer; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Paragraph, Widget}; -use std::collections::BTreeSet; +use ratatui::widgets::{Block, Clear, Paragraph, Widget}; +use std::collections::{BTreeSet, HashMap}; use crate::engine::key_stats::KeyStatsStore; use crate::keyboard::model::KeyboardModel; @@ -16,6 +16,9 @@ pub struct StatsDashboard<'a> { pub key_stats: &'a KeyStatsStore, pub active_tab: usize, pub target_wpm: u32, + pub overall_unlocked: usize, + pub overall_mastered: usize, + pub overall_total: usize, pub theme: &'a Theme, pub history_selected: usize, pub history_confirm_delete: bool, @@ -28,6 +31,9 @@ impl<'a> StatsDashboard<'a> { key_stats: &'a KeyStatsStore, active_tab: usize, target_wpm: u32, + overall_unlocked: usize, + overall_mastered: usize, + overall_total: usize, theme: &'a Theme, history_selected: usize, history_confirm_delete: bool, @@ -38,6 +44,9 @@ impl<'a> StatsDashboard<'a> { key_stats, active_tab, target_wpm, + overall_unlocked, + overall_mastered, + overall_total, theme, history_selected, history_confirm_delete, @@ -125,6 +134,7 @@ impl Widget for StatsDashboard<'_> { let idx = self.history.len().saturating_sub(self.history_selected); let dialog_text = format!("Delete session #{idx}? (y/n)"); + Clear.render(dialog_area, buf); let dialog = Paragraph::new(vec![ Line::from(""), Line::from(Span::styled( @@ -132,6 +142,7 @@ impl Widget for StatsDashboard<'_> { Style::default().fg(colors.fg()), )), ]) + .style(Style::default().bg(colors.bg())) .block( Block::bordered() .title(" Confirm ") @@ -158,7 +169,7 @@ impl StatsDashboard<'_> { fn render_activity_tab(&self, area: Rect, buf: &mut Buffer) { let layout = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Min(9), Constraint::Length(4)]) + .constraints([Constraint::Min(9), Constraint::Length(6)]) .split(area); ActivityHeatmap::new(self.history, self.theme).render(layout[0], buf); self.render_activity_stats(layout[1], buf); @@ -393,6 +404,10 @@ impl StatsDashboard<'_> { buf.set_string(x, y, &ch.to_string(), Style::default().fg(color)); } } + if bar_height == 0 { + let y = inner.y + inner.height - 1; + buf.set_string(x, y, "▁", Style::default().fg(colors.text_pending())); + } // WPM label on top row if bar_spacing >= 3 { @@ -527,22 +542,19 @@ impl StatsDashboard<'_> { buf, ); - // Level progress - let total_score: f64 = self - .history - .iter() - .map(|r| r.wpm * r.accuracy / 100.0) - .sum(); - let level = ((total_score / 100.0).sqrt() as u32).max(1); - let next_level_score = ((level + 1) as f64).powi(2) * 100.0; - let current_level_score = (level as f64).powi(2) * 100.0; - let level_pct = ((total_score - current_level_score) - / (next_level_score - current_level_score)) - .clamp(0.0, 1.0); - let level_label = format!(" Lvl {level} ({:.0}%)", level_pct * 100.0); + // Overall key progress (unlocked coverage + mastered detail). + let key_pct = if self.overall_total > 0 { + self.overall_unlocked as f64 / self.overall_total as f64 + } else { + 0.0 + }; + let level_label = format!( + " Keys: {}/{} ({} mastered)", + self.overall_unlocked, self.overall_total, self.overall_mastered + ); render_text_bar( &level_label, - level_pct, + key_pct, colors.focused_key(), colors.bar_empty(), layout[2], @@ -566,7 +578,7 @@ impl StatsDashboard<'_> { table_block.render(area, buf); let header = Line::from(vec![Span::styled( - " # WPM Raw Acc% Time Date Mode", + " # WPM Raw Acc% Time Date Mode Ranked Partial", Style::default() .fg(colors.accent()) .add_modifier(Modifier::BOLD), @@ -575,7 +587,7 @@ impl StatsDashboard<'_> { let mut lines = vec![ header, Line::from(Span::styled( - " ─────────────────────────────────────────────", + " ─────────────────────────────────────────────────────────────────────", Style::default().fg(colors.border()), )), ]; @@ -601,9 +613,15 @@ impl StatsDashboard<'_> { " " }; - let mode_str = if result.ranked { "" } else { " (unranked)" }; + let rank_str = if result.ranked { "yes" } else { "no" }; + let partial_pct = if result.partial { + result.completion_percent + } else { + 100.0 + }; + let partial_str = format!("{:>6.0}%", partial_pct); let row = format!( - " {wpm_indicator}{idx_str} {wpm_str} {raw_str} {acc_str} {time_str:>6} {date_str} {mode}{mode_str}", + " {wpm_indicator}{idx_str} {wpm_str} {raw_str} {acc_str} {time_str:>6} {date_str} {mode:<9} {rank_str:<6} {partial_str:>7}", mode = result.drill_mode, ); @@ -618,6 +636,8 @@ impl StatsDashboard<'_> { let is_selected = i == self.history_selected; let style = if is_selected { Style::default().fg(acc_color).bg(colors.accent_dim()) + } else if result.partial { + Style::default().fg(colors.warning()) } else if !result.ranked { // Muted styling for unranked drills Style::default().fg(colors.text_pending()) @@ -846,7 +866,7 @@ impl StatsDashboard<'_> { .filter(|(_, s)| s.sample_count > 0) .map(|(&ch, s)| (ch, s.filtered_time_ms)) .collect(); - key_times.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); + key_times.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap().then_with(|| a.0.cmp(&b.0))); let max_time = key_times.first().map(|(_, t)| *t).unwrap_or(1.0); @@ -893,7 +913,7 @@ impl StatsDashboard<'_> { .filter(|(_, s)| s.sample_count > 0) .map(|(&ch, s)| (ch, s.filtered_time_ms)) .collect(); - key_times.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap()); + key_times.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap().then_with(|| a.0.cmp(&b.0))); let max_time = key_times.last().map(|(_, t)| *t).unwrap_or(1.0); @@ -955,7 +975,7 @@ impl StatsDashboard<'_> { }) .collect(); - key_accuracies.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap()); + key_accuracies.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap().then_with(|| a.0.cmp(&b.0))); if key_accuracies.is_empty() { buf.set_string( @@ -1027,7 +1047,7 @@ impl StatsDashboard<'_> { }) .collect(); - key_accuracies.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); + key_accuracies.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap().then_with(|| a.0.cmp(&b.0))); if key_accuracies.is_empty() { buf.set_string( @@ -1078,14 +1098,19 @@ impl StatsDashboard<'_> { let inner = block.inner(area); block.render(area, buf); + let mut day_counts: HashMap = HashMap::new(); let mut active_days: BTreeSet = BTreeSet::new(); - for r in self.history { - active_days.insert(r.timestamp.date_naive()); + for r in self.history.iter().filter(|r| !r.partial) { + let day = r.timestamp.date_naive(); + active_days.insert(day); + *day_counts.entry(day).or_insert(0) += 1; } let (current_streak, best_streak) = compute_streaks(&active_days); let active_days_count = active_days.len(); + let mut top_days: Vec<(chrono::NaiveDate, usize)> = day_counts.into_iter().collect(); + top_days.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| b.0.cmp(&a.0))); - let lines = vec![Line::from(vec![ + let mut lines = vec![Line::from(vec![ Span::styled(" Current: ", Style::default().fg(colors.fg())), Span::styled( format!("{current_streak}d"), @@ -1096,7 +1121,9 @@ impl StatsDashboard<'_> { Span::styled(" Best: ", Style::default().fg(colors.fg())), Span::styled( format!("{best_streak}d"), - Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD), + Style::default() + .fg(colors.accent()) + .add_modifier(Modifier::BOLD), ), Span::styled(" Active Days: ", Style::default().fg(colors.fg())), Span::styled( @@ -1104,9 +1131,24 @@ impl StatsDashboard<'_> { Style::default().fg(colors.text_pending()), ), ])]; + + let top_days_text = if top_days.is_empty() { + " Top Days: none".to_string() + } else { + let parts: Vec = top_days + .iter() + .take(3) + .map(|(d, c)| format!("{} ({})", d.format("%Y-%m-%d"), c)) + .collect(); + format!(" Top Days: {}", parts.join(" | ")) + }; + lines.push(Line::from(Span::styled( + top_days_text, + Style::default().fg(colors.text_pending()), + ))); + Paragraph::new(lines).render(inner, buf); } - } fn accuracy_color(accuracy: f64, colors: &crate::ui::theme::ThemeColors) -> ratatui::style::Color {