Passage drill improvements, stats page cleanup
This commit is contained in:
363
src/app.rs
363
src/app.rs
@@ -1,4 +1,7 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
use std::thread;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
@@ -16,7 +19,10 @@ use crate::generator::code_patterns;
|
|||||||
use crate::generator::code_syntax::CodeSyntaxGenerator;
|
use crate::generator::code_syntax::CodeSyntaxGenerator;
|
||||||
use crate::generator::dictionary::Dictionary;
|
use crate::generator::dictionary::Dictionary;
|
||||||
use crate::generator::numbers;
|
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::phonetic::PhoneticGenerator;
|
||||||
use crate::generator::punctuate;
|
use crate::generator::punctuate;
|
||||||
use crate::generator::transition_table::TransitionTable;
|
use crate::generator::transition_table::TransitionTable;
|
||||||
@@ -39,6 +45,9 @@ pub enum AppScreen {
|
|||||||
Settings,
|
Settings,
|
||||||
SkillTree,
|
SkillTree,
|
||||||
CodeLanguageSelect,
|
CodeLanguageSelect,
|
||||||
|
PassageBookSelect,
|
||||||
|
PassageIntro,
|
||||||
|
PassageDownloadProgress,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
@@ -48,6 +57,20 @@ pub enum DrillMode {
|
|||||||
Passage,
|
Passage,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum PassageDownloadCompleteAction {
|
||||||
|
StartPassageDrill,
|
||||||
|
ReturnToSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PassageDownloadJob {
|
||||||
|
downloaded_bytes: Arc<AtomicU64>,
|
||||||
|
total_bytes: Arc<AtomicU64>,
|
||||||
|
done: Arc<AtomicBool>,
|
||||||
|
success: Arc<AtomicBool>,
|
||||||
|
handle: Option<thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
impl DrillMode {
|
impl DrillMode {
|
||||||
pub fn as_str(self) -> &'static str {
|
pub fn as_str(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
@@ -79,6 +102,7 @@ pub struct App {
|
|||||||
pub store: Option<JsonStore>,
|
pub store: Option<JsonStore>,
|
||||||
pub should_quit: bool,
|
pub should_quit: bool,
|
||||||
pub settings_selected: usize,
|
pub settings_selected: usize,
|
||||||
|
pub settings_editing_download_dir: bool,
|
||||||
pub stats_tab: usize,
|
pub stats_tab: usize,
|
||||||
pub depressed_keys: HashSet<char>,
|
pub depressed_keys: HashSet<char>,
|
||||||
pub last_key_time: Option<Instant>,
|
pub last_key_time: Option<Instant>,
|
||||||
@@ -88,12 +112,27 @@ pub struct App {
|
|||||||
pub skill_tree_detail_scroll: usize,
|
pub skill_tree_detail_scroll: usize,
|
||||||
pub drill_source_info: Option<String>,
|
pub drill_source_info: Option<String>,
|
||||||
pub code_language_selected: usize,
|
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<usize>,
|
||||||
|
pub passage_drill_selection_override: Option<String>,
|
||||||
|
pub passage_download_action: PassageDownloadCompleteAction,
|
||||||
pub shift_held: bool,
|
pub shift_held: bool,
|
||||||
pub keyboard_model: KeyboardModel,
|
pub keyboard_model: KeyboardModel,
|
||||||
rng: SmallRng,
|
rng: SmallRng,
|
||||||
transition_table: TransitionTable,
|
transition_table: TransitionTable,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
dictionary: Dictionary,
|
dictionary: Dictionary,
|
||||||
|
passage_download_job: Option<PassageDownloadJob>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -141,6 +180,9 @@ impl App {
|
|||||||
let dictionary = Dictionary::load();
|
let dictionary = Dictionary::load();
|
||||||
let transition_table = TransitionTable::build_from_words(&dictionary.words_list());
|
let transition_table = TransitionTable::build_from_words(&dictionary.words_list());
|
||||||
let keyboard_model = KeyboardModel::from_name(&config.keyboard_layout);
|
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 {
|
let mut app = Self {
|
||||||
screen: AppScreen::Menu,
|
screen: AppScreen::Menu,
|
||||||
@@ -159,6 +201,7 @@ impl App {
|
|||||||
store,
|
store,
|
||||||
should_quit: false,
|
should_quit: false,
|
||||||
settings_selected: 0,
|
settings_selected: 0,
|
||||||
|
settings_editing_download_dir: false,
|
||||||
stats_tab: 0,
|
stats_tab: 0,
|
||||||
depressed_keys: HashSet::new(),
|
depressed_keys: HashSet::new(),
|
||||||
last_key_time: None,
|
last_key_time: None,
|
||||||
@@ -168,11 +211,26 @@ impl App {
|
|||||||
skill_tree_detail_scroll: 0,
|
skill_tree_detail_scroll: 0,
|
||||||
drill_source_info: None,
|
drill_source_info: None,
|
||||||
code_language_selected: 0,
|
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,
|
shift_held: false,
|
||||||
keyboard_model,
|
keyboard_model,
|
||||||
rng: SmallRng::from_entropy(),
|
rng: SmallRng::from_entropy(),
|
||||||
transition_table,
|
transition_table,
|
||||||
dictionary,
|
dictionary,
|
||||||
|
passage_download_job: None,
|
||||||
};
|
};
|
||||||
app.start_drill();
|
app.start_drill();
|
||||||
app
|
app
|
||||||
@@ -325,7 +383,18 @@ impl App {
|
|||||||
DrillMode::Passage => {
|
DrillMode::Passage => {
|
||||||
let filter = CharFilter::new(('a'..='z').collect());
|
let filter = CharFilter::new(('a'..='z').collect());
|
||||||
let rng = SmallRng::from_rng(&mut self.rng).unwrap();
|
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);
|
let text = generator.generate(&filter, None, word_count);
|
||||||
(text, Some(generator.last_source().to_string()))
|
(text, Some(generator.last_source().to_string()))
|
||||||
}
|
}
|
||||||
@@ -334,11 +403,21 @@ impl App {
|
|||||||
|
|
||||||
pub fn type_char(&mut self, ch: char) {
|
pub fn type_char(&mut self, ch: char) {
|
||||||
if let Some(ref mut drill) = self.drill {
|
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);
|
self.drill_events.push(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
if drill.is_complete() {
|
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();
|
self.finish_drill();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,6 +437,7 @@ impl App {
|
|||||||
&self.drill_events,
|
&self.drill_events,
|
||||||
self.drill_mode.as_str(),
|
self.drill_mode.as_str(),
|
||||||
ranked,
|
ranked,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
if ranked {
|
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) {
|
fn save_data(&self) {
|
||||||
if let Some(ref store) = self.store {
|
if let Some(ref store) = self.store {
|
||||||
let _ = store.save_profile(&self.profile);
|
let _ = store.save_profile(&self.profile);
|
||||||
@@ -426,8 +527,16 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn retry_drill(&mut self) {
|
pub fn retry_drill(&mut self) {
|
||||||
|
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();
|
self.start_drill();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn go_to_menu(&mut self) {
|
pub fn go_to_menu(&mut self) {
|
||||||
self.screen = AppScreen::Menu;
|
self.screen = AppScreen::Menu;
|
||||||
@@ -485,6 +594,11 @@ impl App {
|
|||||||
self.skill_tree.update(&self.key_stats);
|
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
|
// Compute score
|
||||||
let complexity = self.skill_tree.complexity();
|
let complexity = self.skill_tree.complexity();
|
||||||
let score = scoring::compute_score(result, complexity);
|
let score = scoring::compute_score(result, complexity);
|
||||||
@@ -544,9 +658,224 @@ impl App {
|
|||||||
|
|
||||||
pub fn go_to_settings(&mut self) {
|
pub fn go_to_settings(&mut self) {
|
||||||
self.settings_selected = 0;
|
self.settings_selected = 0;
|
||||||
|
self.settings_editing_download_dir = false;
|
||||||
self.screen = AppScreen::Settings;
|
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) {
|
pub fn settings_cycle_forward(&mut self) {
|
||||||
match self.settings_selected {
|
match self.settings_selected {
|
||||||
0 => {
|
0 => {
|
||||||
@@ -579,6 +908,20 @@ impl App {
|
|||||||
let next = (idx + 1) % langs.len();
|
let next = (idx + 1) % langs.len();
|
||||||
self.config.code_language = langs[next].to_string();
|
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 };
|
let next = if idx == 0 { langs.len() - 1 } else { idx - 1 };
|
||||||
self.config.code_language = langs[next].to_string();
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ pub struct Config {
|
|||||||
pub word_count: usize,
|
pub word_count: usize,
|
||||||
#[serde(default = "default_code_language")]
|
#[serde(default = "default_code_language")]
|
||||||
pub code_language: String,
|
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 {
|
fn default_target_wpm() -> u32 {
|
||||||
@@ -33,6 +43,26 @@ fn default_word_count() -> usize {
|
|||||||
fn default_code_language() -> String {
|
fn default_code_language() -> String {
|
||||||
"rust".to_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 {
|
impl Default for Config {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
@@ -42,6 +72,11 @@ impl Default for Config {
|
|||||||
keyboard_layout: default_keyboard_layout(),
|
keyboard_layout: default_keyboard_layout(),
|
||||||
word_count: default_word_count(),
|
word_count: default_word_count(),
|
||||||
code_language: default_code_language(),
|
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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
|
#[cfg(feature = "network")]
|
||||||
|
use std::io::Read;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
pub struct DiskCache {
|
pub struct DiskCache {
|
||||||
@@ -53,3 +55,43 @@ pub fn fetch_url(url: &str) -> Option<String> {
|
|||||||
pub fn fetch_url(_url: &str) -> Option<String> {
|
pub fn fetch_url(_url: &str) -> Option<String> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "network")]
|
||||||
|
pub fn fetch_url_bytes_with_progress<F>(url: &str, mut on_progress: F) -> Option<Vec<u8>>
|
||||||
|
where
|
||||||
|
F: FnMut(u64, Option<u64>),
|
||||||
|
{
|
||||||
|
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<u8> = 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<F>(_url: &str, _on_progress: F) -> Option<Vec<u8>>
|
||||||
|
where
|
||||||
|
F: FnMut(u64, Option<u64>),
|
||||||
|
{
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use rand::rngs::SmallRng;
|
use rand::rngs::SmallRng;
|
||||||
|
|
||||||
use crate::engine::filter::CharFilter;
|
use crate::engine::filter::CharFilter;
|
||||||
use crate::generator::TextGenerator;
|
use crate::generator::TextGenerator;
|
||||||
use crate::generator::cache::{DiskCache, fetch_url};
|
use crate::generator::cache::fetch_url_bytes_with_progress;
|
||||||
|
|
||||||
const PASSAGES: &[&str] = &[
|
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",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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
|
pub struct GutenbergBook {
|
||||||
const GUTENBERG_IDS: &[(u32, &str)] = &[
|
pub id: u32,
|
||||||
(1342, "pride_and_prejudice"),
|
pub key: &'static str,
|
||||||
(11, "alice_in_wonderland"),
|
pub title: &'static str,
|
||||||
(1661, "sherlock_holmes"),
|
}
|
||||||
(84, "frankenstein"),
|
|
||||||
(1952, "yellow_wallpaper"),
|
pub const GUTENBERG_BOOKS: &[GutenbergBook] = &[
|
||||||
(2701, "moby_dick"),
|
GutenbergBook {
|
||||||
(74, "tom_sawyer"),
|
id: 1342,
|
||||||
(345, "dracula"),
|
key: "pride_prejudice",
|
||||||
(1232, "prince"),
|
title: "Pride and Prejudice",
|
||||||
(76, "huckleberry_finn"),
|
},
|
||||||
(5200, "metamorphosis"),
|
GutenbergBook {
|
||||||
(2542, "aesop_fables"),
|
id: 11,
|
||||||
(174, "dorian_gray"),
|
key: "alice_wonderland",
|
||||||
(98, "tale_two_cities"),
|
title: "Alice in Wonderland",
|
||||||
(1080, "modest_proposal"),
|
},
|
||||||
(219, "heart_of_darkness"),
|
GutenbergBook {
|
||||||
(4300, "ulysses"),
|
id: 1661,
|
||||||
(28054, "brothers_karamazov"),
|
key: "sherlock_holmes",
|
||||||
(2554, "crime_and_punishment"),
|
title: "Sherlock Holmes",
|
||||||
(55, "oz"),
|
},
|
||||||
|
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<F>(
|
||||||
|
cache_dir: &str,
|
||||||
|
book: &GutenbergBook,
|
||||||
|
mut on_progress: F,
|
||||||
|
) -> bool
|
||||||
|
where
|
||||||
|
F: FnMut(u64, Option<u64>),
|
||||||
|
{
|
||||||
|
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 {
|
pub struct PassageGenerator {
|
||||||
fetched_passages: Vec<String>,
|
fetched_passages: Vec<(String, String)>,
|
||||||
rng: SmallRng,
|
rng: SmallRng,
|
||||||
|
selection: String,
|
||||||
|
cache_dir: String,
|
||||||
|
paragraph_limit: usize,
|
||||||
|
_downloads_enabled: bool,
|
||||||
last_source: String,
|
last_source: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PassageGenerator {
|
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 {
|
let mut generator = Self {
|
||||||
fetched_passages: Vec::new(),
|
fetched_passages: Vec::new(),
|
||||||
rng,
|
rng,
|
||||||
|
selection: selected,
|
||||||
|
cache_dir: cache_dir.to_string(),
|
||||||
|
paragraph_limit,
|
||||||
|
_downloads_enabled: downloads_enabled,
|
||||||
last_source: "Built-in passage library".to_string(),
|
last_source: "Built-in passage library".to_string(),
|
||||||
};
|
};
|
||||||
generator.load_cached_passages();
|
generator.load_cached_passages();
|
||||||
@@ -116,43 +161,15 @@ impl PassageGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn load_cached_passages(&mut self) {
|
fn load_cached_passages(&mut self) {
|
||||||
if let Some(cache) = DiskCache::new("passages") {
|
let _ = fs::create_dir_all(&self.cache_dir);
|
||||||
for &(_, name) in GUTENBERG_IDS {
|
for book in relevant_books(&self.selection) {
|
||||||
if let Some(content) = cache.get(name) {
|
if let Ok(content) = fs::read_to_string(cache_file(&self.cache_dir, book.key)) {
|
||||||
let paragraphs = extract_paragraphs(&content);
|
for para in extract_paragraphs(&content, self.paragraph_limit) {
|
||||||
self.fetched_passages.extend(paragraphs);
|
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 {
|
impl TextGenerator for PassageGenerator {
|
||||||
@@ -160,39 +177,48 @@ impl TextGenerator for PassageGenerator {
|
|||||||
&mut self,
|
&mut self,
|
||||||
_filter: &CharFilter,
|
_filter: &CharFilter,
|
||||||
_focused: Option<char>,
|
_focused: Option<char>,
|
||||||
_word_count: usize,
|
word_count: usize,
|
||||||
) -> String {
|
) -> String {
|
||||||
// Opportunistically fetch Gutenberg passages for source variety.
|
let use_builtin = self.selection == "all" || self.selection == "builtin";
|
||||||
if self.fetched_passages.len() < 50 && self.rng.gen_bool(0.35) {
|
let total = (if use_builtin { PASSAGES.len() } else { 0 }) + self.fetched_passages.len();
|
||||||
self.try_fetch_gutenberg();
|
|
||||||
|
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();
|
let fetched_idx = if use_builtin {
|
||||||
|
idx - 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()
|
|
||||||
} else {
|
} else {
|
||||||
let fetched_idx = idx - PASSAGES.len();
|
idx
|
||||||
self.last_source = "Project Gutenberg (cached)".to_string();
|
};
|
||||||
self.fetched_passages[fetched_idx % self.fetched_passages.len()].clone()
|
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 relevant_books(selection: &str) -> Vec<&'static GutenbergBook> {
|
||||||
fn extract_paragraphs(text: &str) -> Vec<String> {
|
if selection == "all" || selection == "builtin" {
|
||||||
|
return GUTENBERG_BOOKS.iter().collect();
|
||||||
|
}
|
||||||
|
GUTENBERG_BOOKS
|
||||||
|
.iter()
|
||||||
|
.filter(|book| book.key == selection)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_paragraphs(text: &str, limit: usize) -> Vec<String> {
|
||||||
|
const MIN_WORDS: usize = 12;
|
||||||
|
const MAX_WORDS: usize = 42;
|
||||||
|
|
||||||
let mut paragraphs = Vec::new();
|
let mut paragraphs = Vec::new();
|
||||||
|
|
||||||
// Find the start of actual content (after Gutenberg header)
|
|
||||||
let start_markers = ["*** START OF", "***START OF"];
|
let start_markers = ["*** START OF", "***START OF"];
|
||||||
let end_markers = ["*** END OF", "***END OF"];
|
let end_markers = ["*** END OF", "***END OF"];
|
||||||
|
|
||||||
@@ -200,52 +226,150 @@ fn extract_paragraphs(text: &str) -> Vec<String> {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|marker| text.find(marker))
|
.filter_map(|marker| text.find(marker))
|
||||||
.min()
|
.min()
|
||||||
.map(|pos| {
|
.map(|pos| text[pos..].find('\n').map(|nl| pos + nl + 1).unwrap_or(pos))
|
||||||
// Find the end of the header line
|
|
||||||
text[pos..].find('\n').map(|nl| pos + nl + 1).unwrap_or(pos)
|
|
||||||
})
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let content_end = end_markers
|
let content_end = end_markers
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|marker| text.find(marker))
|
.filter_map(|marker| text.find(marker))
|
||||||
.min()
|
.min()
|
||||||
.unwrap_or(text.len());
|
.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)
|
let has_letters = raw.chars().any(|c| c.is_alphabetic());
|
||||||
for para in content.split("\r\n\r\n").chain(content.split("\n\n")) {
|
let has_only_supported_controls = raw
|
||||||
let cleaned: String = para
|
|
||||||
.lines()
|
|
||||||
.map(|l| l.trim())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" ")
|
|
||||||
.chars()
|
.chars()
|
||||||
.filter(|c| {
|
.all(|c| !c.is_control() || c == '\n' || c == '\t');
|
||||||
c.is_ascii_alphanumeric() || c.is_ascii_whitespace() || c.is_ascii_punctuation()
|
let word_count = raw.split_whitespace().count();
|
||||||
})
|
if !has_letters || !has_only_supported_controls || word_count < MIN_WORDS {
|
||||||
.collect::<String>()
|
continue;
|
||||||
.to_lowercase();
|
|
||||||
|
|
||||||
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::<String>()
|
|
||||||
.split_whitespace()
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.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
|
if limit > 0 {
|
||||||
paragraphs.truncate(100);
|
paragraphs.truncate(limit);
|
||||||
|
}
|
||||||
paragraphs
|
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<String> {
|
||||||
|
let mut sentences: Vec<String> = 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<String> = 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<String> {
|
||||||
|
let words: Vec<&str> = sentence.split_whitespace().collect();
|
||||||
|
let mut out: Vec<String> = 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
|
||||||
|
}
|
||||||
|
|||||||
514
src/main.rs
514
src/main.rs
@@ -31,7 +31,7 @@ use ratatui::widgets::{Block, Paragraph, Widget};
|
|||||||
use app::{App, AppScreen, DrillMode};
|
use app::{App, AppScreen, DrillMode};
|
||||||
use engine::skill_tree::DrillScope;
|
use engine::skill_tree::DrillScope;
|
||||||
use event::{AppEvent, EventHandler};
|
use event::{AppEvent, EventHandler};
|
||||||
use session::result::DrillResult;
|
use generator::passage::passage_options;
|
||||||
use ui::components::dashboard::Dashboard;
|
use ui::components::dashboard::Dashboard;
|
||||||
use ui::components::keyboard_diagram::KeyboardDiagram;
|
use ui::components::keyboard_diagram::KeyboardDiagram;
|
||||||
use ui::components::skill_tree::{SkillTreeWidget, detail_line_count, selectable_branches};
|
use ui::components::skill_tree::{SkillTreeWidget, detail_line_count, selectable_branches};
|
||||||
@@ -117,6 +117,12 @@ fn run_app(
|
|||||||
match events.next()? {
|
match events.next()? {
|
||||||
AppEvent::Key(key) => handle_key(app, key),
|
AppEvent::Key(key) => handle_key(app, key),
|
||||||
AppEvent::Tick => {
|
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
|
// Fallback: clear depressed keys after 150ms if no Release event received
|
||||||
if let Some(last) = app.last_key_time {
|
if let Some(last) = app.last_key_time {
|
||||||
if last.elapsed() > Duration::from_millis(150) && !app.depressed_keys.is_empty()
|
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::Settings => handle_settings_key(app, key),
|
||||||
AppScreen::SkillTree => handle_skill_tree_key(app, key),
|
AppScreen::SkillTree => handle_skill_tree_key(app, key),
|
||||||
AppScreen::CodeLanguageSelect => handle_code_language_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();
|
app.go_to_code_language_select();
|
||||||
}
|
}
|
||||||
KeyCode::Char('3') => {
|
KeyCode::Char('3') => {
|
||||||
app.drill_mode = DrillMode::Passage;
|
app.go_to_passage_book_select();
|
||||||
app.drill_scope = DrillScope::Global;
|
|
||||||
app.start_drill();
|
|
||||||
}
|
}
|
||||||
KeyCode::Char('t') => app.go_to_skill_tree(),
|
KeyCode::Char('t') => app.go_to_skill_tree(),
|
||||||
KeyCode::Char('s') => app.go_to_stats(),
|
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();
|
app.go_to_code_language_select();
|
||||||
}
|
}
|
||||||
2 => {
|
2 => {
|
||||||
app.drill_mode = DrillMode::Passage;
|
app.go_to_passage_book_select();
|
||||||
app.drill_scope = DrillScope::Global;
|
|
||||||
app.start_drill();
|
|
||||||
}
|
}
|
||||||
3 => app.go_to_skill_tree(),
|
3 => app.go_to_skill_tree(),
|
||||||
4 => app.go_to_stats(),
|
4 => app.go_to_stats(),
|
||||||
@@ -242,18 +247,8 @@ fn handle_drill_key(app: &mut App, key: KeyEvent) {
|
|||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
let has_progress = app.drill.as_ref().is_some_and(|d| d.cursor > 0);
|
let has_progress = app.drill.as_ref().is_some_and(|d| d.cursor > 0);
|
||||||
if has_progress && app.drill_mode != DrillMode::Adaptive {
|
if has_progress {
|
||||||
// Non-adaptive: show result screen for partial drill
|
app.finish_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;
|
|
||||||
} else {
|
} else {
|
||||||
app.go_to_menu();
|
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) {
|
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 {
|
match key.code {
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
let _ = app.config.save();
|
let _ = app.config.save();
|
||||||
@@ -358,16 +369,31 @@ fn handle_settings_key(app: &mut App, key: KeyEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Down | KeyCode::Char('j') => {
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
if app.settings_selected < 3 {
|
if app.settings_selected < 7 {
|
||||||
app.settings_selected += 1;
|
app.settings_selected += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => {
|
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();
|
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') => {
|
KeyCode::Left | KeyCode::Char('h') => {
|
||||||
|
if app.settings_selected < 5 || app.settings_selected == 6 {
|
||||||
app.settings_cycle_backward();
|
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) {
|
fn handle_skill_tree_key(app: &mut App, key: KeyEvent) {
|
||||||
const DETAIL_SCROLL_STEP: usize = 10;
|
const DETAIL_SCROLL_STEP: usize = 10;
|
||||||
let max_scroll = skill_tree_detail_max_scroll(app);
|
let max_scroll = skill_tree_detail_max_scroll(app);
|
||||||
@@ -504,7 +659,9 @@ fn skill_tree_detail_max_scroll(app: &App) -> usize {
|
|||||||
])
|
])
|
||||||
.split(inner);
|
.split(inner);
|
||||||
let detail_height = layout.get(2).map(|r| r.height as usize).unwrap_or(0);
|
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]);
|
let total_lines = detail_line_count(branches[selected]);
|
||||||
total_lines.saturating_sub(detail_height)
|
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::Settings => render_settings(frame, app),
|
||||||
AppScreen::SkillTree => render_skill_tree(frame, app),
|
AppScreen::SkillTree => render_skill_tree(frame, app),
|
||||||
AppScreen::CodeLanguageSelect => render_code_language_select(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 {
|
} else {
|
||||||
String::new()
|
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!(
|
let header_info = format!(
|
||||||
" Level {} | Score {:.0} | {}/{} keys{}",
|
" Key Progress {unlocked}/{total_keys} ({mastered} mastered){}",
|
||||||
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,
|
|
||||||
streak_text,
|
streak_text,
|
||||||
);
|
);
|
||||||
let header = Paragraph::new(Line::from(vec![
|
let header = Paragraph::new(Line::from(vec![
|
||||||
@@ -716,7 +875,12 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
|
|||||||
" Passage source "
|
" Passage source "
|
||||||
};
|
};
|
||||||
let source_info = Paragraph::new(Line::from(vec![
|
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())),
|
Span::styled(source, Style::default().fg(colors.text_pending())),
|
||||||
]));
|
]));
|
||||||
frame.render_widget(source_info, main_layout[idx]);
|
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.key_stats,
|
||||||
app.stats_tab,
|
app.stats_tab,
|
||||||
app.config.target_wpm,
|
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.theme,
|
||||||
app.history_selected,
|
app.history_selected,
|
||||||
app.history_confirm_delete,
|
app.history_confirm_delete,
|
||||||
@@ -814,6 +981,30 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
|
|||||||
format!("{}", app.config.word_count),
|
format!("{}", app.config.word_count),
|
||||||
),
|
),
|
||||||
("Code Language".to_string(), current_lang.clone()),
|
("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()
|
let layout = Layout::default()
|
||||||
@@ -847,7 +1038,11 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
|
|||||||
let indicator = if is_selected { " > " } else { " " };
|
let indicator = if is_selected { " > " } else { " " };
|
||||||
|
|
||||||
let label_text = format!("{indicator}{label}:");
|
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()
|
let label_style = Style::default()
|
||||||
.fg(if is_selected {
|
.fg(if is_selected {
|
||||||
@@ -867,17 +1062,40 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
|
|||||||
colors.text_pending()
|
colors.text_pending()
|
||||||
});
|
});
|
||||||
|
|
||||||
let lines = vec![
|
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(label_text, label_style)),
|
||||||
Line::from(Span::styled(value_text, value_style)),
|
Line::from(Span::styled(value_text, value_style)),
|
||||||
];
|
]
|
||||||
|
};
|
||||||
Paragraph::new(lines).render(field_layout[i], frame.buffer_mut());
|
Paragraph::new(lines).render(field_layout[i], frame.buffer_mut());
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = (available_themes, languages_all);
|
let _ = (available_themes, languages_all);
|
||||||
|
|
||||||
let footer = Paragraph::new(Line::from(Span::styled(
|
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()),
|
Style::default().fg(colors.accent()),
|
||||||
)));
|
)));
|
||||||
footer.render(layout[3], frame.buffer_mut());
|
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());
|
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<Line> = 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) {
|
fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) {
|
||||||
let area = frame.area();
|
let area = frame.area();
|
||||||
let centered = ui::layout::centered_rect(70, 90, area);
|
let centered = ui::layout::centered_rect(70, 90, area);
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ use std::time::Instant;
|
|||||||
|
|
||||||
use crate::session::input::CharStatus;
|
use crate::session::input::CharStatus;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct SyntheticSpan {
|
||||||
|
pub start: usize,
|
||||||
|
pub end: usize,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct DrillState {
|
pub struct DrillState {
|
||||||
pub target: Vec<char>,
|
pub target: Vec<char>,
|
||||||
pub input: Vec<CharStatus>,
|
pub input: Vec<CharStatus>,
|
||||||
@@ -10,6 +16,7 @@ pub struct DrillState {
|
|||||||
pub started_at: Option<Instant>,
|
pub started_at: Option<Instant>,
|
||||||
pub finished_at: Option<Instant>,
|
pub finished_at: Option<Instant>,
|
||||||
pub typo_flags: HashSet<usize>,
|
pub typo_flags: HashSet<usize>,
|
||||||
|
pub synthetic_spans: Vec<SyntheticSpan>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DrillState {
|
impl DrillState {
|
||||||
@@ -21,6 +28,7 @@ impl DrillState {
|
|||||||
started_at: None,
|
started_at: None,
|
||||||
finished_at: None,
|
finished_at: None,
|
||||||
typo_flags: HashSet::new(),
|
typo_flags: HashSet::new(),
|
||||||
|
synthetic_spans: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,4 +166,72 @@ mod tests {
|
|||||||
assert_eq!(drill.typo_count(), 1);
|
assert_eq!(drill.typo_count(), 1);
|
||||||
assert!(drill.typo_flags.contains(&0));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use crate::session::drill::DrillState;
|
use crate::session::drill::{DrillState, SyntheticSpan};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum CharStatus {
|
pub enum CharStatus {
|
||||||
@@ -38,11 +38,16 @@ pub fn process_char(drill: &mut DrillState, ch: char) -> Option<KeystrokeEvent>
|
|||||||
|
|
||||||
if correct {
|
if correct {
|
||||||
drill.input.push(CharStatus::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 {
|
} else {
|
||||||
drill.input.push(CharStatus::Incorrect(ch));
|
drill.input.push(CharStatus::Incorrect(ch));
|
||||||
drill.typo_flags.insert(drill.cursor);
|
drill.typo_flags.insert(drill.cursor);
|
||||||
}
|
|
||||||
drill.cursor += 1;
|
drill.cursor += 1;
|
||||||
|
}
|
||||||
|
|
||||||
if drill.is_complete() {
|
if drill.is_complete() {
|
||||||
drill.finished_at = Some(Instant::now());
|
drill.finished_at = Some(Instant::now());
|
||||||
@@ -52,8 +57,91 @@ pub fn process_char(drill: &mut DrillState, ch: char) -> Option<KeystrokeEvent>
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn process_backspace(drill: &mut DrillState) {
|
pub fn process_backspace(drill: &mut DrillState) {
|
||||||
if drill.cursor > 0 {
|
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.cursor -= 1;
|
||||||
drill.input.pop();
|
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<char>,
|
||||||
|
) {
|
||||||
|
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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ pub struct DrillResult {
|
|||||||
pub drill_mode: String,
|
pub drill_mode: String,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub ranked: bool,
|
pub ranked: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub partial: bool,
|
||||||
|
#[serde(default = "default_completion_percent")]
|
||||||
|
pub completion_percent: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_drill_mode() -> String {
|
fn default_drill_mode() -> String {
|
||||||
@@ -29,6 +33,10 @@ fn default_true() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_completion_percent() -> f64 {
|
||||||
|
100.0
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct KeyTime {
|
pub struct KeyTime {
|
||||||
pub key: char,
|
pub key: char,
|
||||||
@@ -42,6 +50,7 @@ impl DrillResult {
|
|||||||
events: &[KeystrokeEvent],
|
events: &[KeystrokeEvent],
|
||||||
drill_mode: &str,
|
drill_mode: &str,
|
||||||
ranked: bool,
|
ranked: bool,
|
||||||
|
partial: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let per_key_times: Vec<KeyTime> = events
|
let per_key_times: Vec<KeyTime> = events
|
||||||
.windows(2)
|
.windows(2)
|
||||||
@@ -75,6 +84,8 @@ impl DrillResult {
|
|||||||
per_key_times,
|
per_key_times,
|
||||||
drill_mode: drill_mode.to_string(),
|
drill_mode: drill_mode.to_string(),
|
||||||
ranked,
|
ranked,
|
||||||
|
partial,
|
||||||
|
completion_percent: (drill.progress() * 100.0).clamp(0.0, 100.0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ impl Widget for ActivityHeatmap<'_> {
|
|||||||
|
|
||||||
// Count sessions per day
|
// Count sessions per day
|
||||||
let mut day_counts: HashMap<NaiveDate, usize> = HashMap::new();
|
let mut day_counts: HashMap<NaiveDate, usize> = HashMap::new();
|
||||||
for result in self.history {
|
for result in self.history.iter().filter(|r| !r.partial) {
|
||||||
let date = result.timestamp.date_naive();
|
let date = result.timestamp.date_naive();
|
||||||
*day_counts.entry(date).or_insert(0) += 1;
|
*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 count = day_counts.get(&date).copied().unwrap_or(0);
|
||||||
let (ch, color) = intensity_cell(count, colors);
|
let color = intensity_cell_bg(count, colors);
|
||||||
buf.set_string(x, y, &ch.to_string(), Style::default().fg(color));
|
// 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);
|
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();
|
let success = colors.success();
|
||||||
match count {
|
match count {
|
||||||
0 => ('·', colors.accent_dim()),
|
0 => scale_color(colors.accent_dim(), 0.35),
|
||||||
1..=2 => ('▪', scale_color(success, 0.4)),
|
1..=2 => scale_color(success, 0.35),
|
||||||
3..=5 => ('▪', scale_color(success, 0.65)),
|
3..=5 => scale_color(success, 0.6),
|
||||||
6..=15 => ('█', scale_color(success, 0.85)),
|
6..=15 => scale_color(success, 0.8),
|
||||||
_ => ('█', success),
|
_ => success,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,19 +92,31 @@ impl Widget for BranchProgressList<'_> {
|
|||||||
let total = self.skill_tree.total_unique_keys;
|
let total = self.skill_tree.total_unique_keys;
|
||||||
let unlocked = self.skill_tree.total_unlocked_count();
|
let unlocked = self.skill_tree.total_unlocked_count();
|
||||||
let mastered = self.skill_tree.total_confident_keys(self.key_stats);
|
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![
|
lines.push(Line::from(vec![
|
||||||
Span::styled(
|
Span::styled(label, Style::default().fg(colors.fg())),
|
||||||
format!(" {:<14}", "Overall"),
|
|
||||||
Style::default().fg(colors.fg()),
|
|
||||||
),
|
|
||||||
Span::styled(m_bar, Style::default().fg(colors.text_correct())),
|
Span::styled(m_bar, Style::default().fg(colors.text_correct())),
|
||||||
Span::styled(u_bar, Style::default().fg(colors.accent())),
|
Span::styled(u_bar, Style::default().fg(colors.accent())),
|
||||||
Span::styled(e_bar, Style::default().fg(colors.text_pending())),
|
Span::styled(e_bar, Style::default().fg(colors.text_pending())),
|
||||||
Span::styled(
|
Span::styled(suffix, Style::default().fg(colors.text_pending())),
|
||||||
format!(" {unlocked}/{total}"),
|
|
||||||
Style::default().fg(colors.text_pending()),
|
|
||||||
),
|
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,10 +146,7 @@ impl SkillTreeWidget<'_> {
|
|||||||
.fg(colors.accent())
|
.fg(colors.accent())
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
BranchStatus::Available => (
|
BranchStatus::Available => (" ", Style::default().fg(colors.fg())),
|
||||||
" ",
|
|
||||||
Style::default().fg(colors.fg()),
|
|
||||||
),
|
|
||||||
BranchStatus::Locked => (" ", Style::default().fg(colors.text_pending())),
|
BranchStatus::Locked => (" ", Style::default().fg(colors.text_pending())),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -359,7 +356,11 @@ impl SkillTreeWidget<'_> {
|
|||||||
}
|
}
|
||||||
let max_scroll = lines.len().saturating_sub(visible_height);
|
let max_scroll = lines.len().saturating_sub(visible_height);
|
||||||
let scroll = self.detail_scroll.min(max_scroll);
|
let scroll = self.detail_scroll.min(max_scroll);
|
||||||
let visible_lines: Vec<Line> = lines.into_iter().skip(scroll).take(visible_height).collect();
|
let visible_lines: Vec<Line> = lines
|
||||||
|
.into_iter()
|
||||||
|
.skip(scroll)
|
||||||
|
.take(visible_height)
|
||||||
|
.collect();
|
||||||
let paragraph = Paragraph::new(visible_lines);
|
let paragraph = Paragraph::new(visible_lines);
|
||||||
paragraph.render(area, buf);
|
paragraph.render(area, buf);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use ratatui::buffer::Buffer;
|
|||||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
use ratatui::style::{Modifier, Style};
|
use ratatui::style::{Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Paragraph, Widget};
|
use ratatui::widgets::{Block, Clear, Paragraph, Widget};
|
||||||
use std::collections::BTreeSet;
|
use std::collections::{BTreeSet, HashMap};
|
||||||
|
|
||||||
use crate::engine::key_stats::KeyStatsStore;
|
use crate::engine::key_stats::KeyStatsStore;
|
||||||
use crate::keyboard::model::KeyboardModel;
|
use crate::keyboard::model::KeyboardModel;
|
||||||
@@ -16,6 +16,9 @@ pub struct StatsDashboard<'a> {
|
|||||||
pub key_stats: &'a KeyStatsStore,
|
pub key_stats: &'a KeyStatsStore,
|
||||||
pub active_tab: usize,
|
pub active_tab: usize,
|
||||||
pub target_wpm: u32,
|
pub target_wpm: u32,
|
||||||
|
pub overall_unlocked: usize,
|
||||||
|
pub overall_mastered: usize,
|
||||||
|
pub overall_total: usize,
|
||||||
pub theme: &'a Theme,
|
pub theme: &'a Theme,
|
||||||
pub history_selected: usize,
|
pub history_selected: usize,
|
||||||
pub history_confirm_delete: bool,
|
pub history_confirm_delete: bool,
|
||||||
@@ -28,6 +31,9 @@ impl<'a> StatsDashboard<'a> {
|
|||||||
key_stats: &'a KeyStatsStore,
|
key_stats: &'a KeyStatsStore,
|
||||||
active_tab: usize,
|
active_tab: usize,
|
||||||
target_wpm: u32,
|
target_wpm: u32,
|
||||||
|
overall_unlocked: usize,
|
||||||
|
overall_mastered: usize,
|
||||||
|
overall_total: usize,
|
||||||
theme: &'a Theme,
|
theme: &'a Theme,
|
||||||
history_selected: usize,
|
history_selected: usize,
|
||||||
history_confirm_delete: bool,
|
history_confirm_delete: bool,
|
||||||
@@ -38,6 +44,9 @@ impl<'a> StatsDashboard<'a> {
|
|||||||
key_stats,
|
key_stats,
|
||||||
active_tab,
|
active_tab,
|
||||||
target_wpm,
|
target_wpm,
|
||||||
|
overall_unlocked,
|
||||||
|
overall_mastered,
|
||||||
|
overall_total,
|
||||||
theme,
|
theme,
|
||||||
history_selected,
|
history_selected,
|
||||||
history_confirm_delete,
|
history_confirm_delete,
|
||||||
@@ -125,6 +134,7 @@ impl Widget for StatsDashboard<'_> {
|
|||||||
let idx = self.history.len().saturating_sub(self.history_selected);
|
let idx = self.history.len().saturating_sub(self.history_selected);
|
||||||
let dialog_text = format!("Delete session #{idx}? (y/n)");
|
let dialog_text = format!("Delete session #{idx}? (y/n)");
|
||||||
|
|
||||||
|
Clear.render(dialog_area, buf);
|
||||||
let dialog = Paragraph::new(vec![
|
let dialog = Paragraph::new(vec![
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
@@ -132,6 +142,7 @@ impl Widget for StatsDashboard<'_> {
|
|||||||
Style::default().fg(colors.fg()),
|
Style::default().fg(colors.fg()),
|
||||||
)),
|
)),
|
||||||
])
|
])
|
||||||
|
.style(Style::default().bg(colors.bg()))
|
||||||
.block(
|
.block(
|
||||||
Block::bordered()
|
Block::bordered()
|
||||||
.title(" Confirm ")
|
.title(" Confirm ")
|
||||||
@@ -158,7 +169,7 @@ impl StatsDashboard<'_> {
|
|||||||
fn render_activity_tab(&self, area: Rect, buf: &mut Buffer) {
|
fn render_activity_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||||
let layout = Layout::default()
|
let layout = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([Constraint::Min(9), Constraint::Length(4)])
|
.constraints([Constraint::Min(9), Constraint::Length(6)])
|
||||||
.split(area);
|
.split(area);
|
||||||
ActivityHeatmap::new(self.history, self.theme).render(layout[0], buf);
|
ActivityHeatmap::new(self.history, self.theme).render(layout[0], buf);
|
||||||
self.render_activity_stats(layout[1], 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));
|
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
|
// WPM label on top row
|
||||||
if bar_spacing >= 3 {
|
if bar_spacing >= 3 {
|
||||||
@@ -527,22 +542,19 @@ impl StatsDashboard<'_> {
|
|||||||
buf,
|
buf,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Level progress
|
// Overall key progress (unlocked coverage + mastered detail).
|
||||||
let total_score: f64 = self
|
let key_pct = if self.overall_total > 0 {
|
||||||
.history
|
self.overall_unlocked as f64 / self.overall_total as f64
|
||||||
.iter()
|
} else {
|
||||||
.map(|r| r.wpm * r.accuracy / 100.0)
|
0.0
|
||||||
.sum();
|
};
|
||||||
let level = ((total_score / 100.0).sqrt() as u32).max(1);
|
let level_label = format!(
|
||||||
let next_level_score = ((level + 1) as f64).powi(2) * 100.0;
|
" Keys: {}/{} ({} mastered)",
|
||||||
let current_level_score = (level as f64).powi(2) * 100.0;
|
self.overall_unlocked, self.overall_total, self.overall_mastered
|
||||||
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);
|
|
||||||
render_text_bar(
|
render_text_bar(
|
||||||
&level_label,
|
&level_label,
|
||||||
level_pct,
|
key_pct,
|
||||||
colors.focused_key(),
|
colors.focused_key(),
|
||||||
colors.bar_empty(),
|
colors.bar_empty(),
|
||||||
layout[2],
|
layout[2],
|
||||||
@@ -566,7 +578,7 @@ impl StatsDashboard<'_> {
|
|||||||
table_block.render(area, buf);
|
table_block.render(area, buf);
|
||||||
|
|
||||||
let header = Line::from(vec![Span::styled(
|
let header = Line::from(vec![Span::styled(
|
||||||
" # WPM Raw Acc% Time Date Mode",
|
" # WPM Raw Acc% Time Date Mode Ranked Partial",
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(colors.accent())
|
.fg(colors.accent())
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
@@ -575,7 +587,7 @@ impl StatsDashboard<'_> {
|
|||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
header,
|
header,
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
" ─────────────────────────────────────────────",
|
" ─────────────────────────────────────────────────────────────────────",
|
||||||
Style::default().fg(colors.border()),
|
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!(
|
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,
|
mode = result.drill_mode,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -618,6 +636,8 @@ impl StatsDashboard<'_> {
|
|||||||
let is_selected = i == self.history_selected;
|
let is_selected = i == self.history_selected;
|
||||||
let style = if is_selected {
|
let style = if is_selected {
|
||||||
Style::default().fg(acc_color).bg(colors.accent_dim())
|
Style::default().fg(acc_color).bg(colors.accent_dim())
|
||||||
|
} else if result.partial {
|
||||||
|
Style::default().fg(colors.warning())
|
||||||
} else if !result.ranked {
|
} else if !result.ranked {
|
||||||
// Muted styling for unranked drills
|
// Muted styling for unranked drills
|
||||||
Style::default().fg(colors.text_pending())
|
Style::default().fg(colors.text_pending())
|
||||||
@@ -846,7 +866,7 @@ impl StatsDashboard<'_> {
|
|||||||
.filter(|(_, s)| s.sample_count > 0)
|
.filter(|(_, s)| s.sample_count > 0)
|
||||||
.map(|(&ch, s)| (ch, s.filtered_time_ms))
|
.map(|(&ch, s)| (ch, s.filtered_time_ms))
|
||||||
.collect();
|
.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);
|
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)
|
.filter(|(_, s)| s.sample_count > 0)
|
||||||
.map(|(&ch, s)| (ch, s.filtered_time_ms))
|
.map(|(&ch, s)| (ch, s.filtered_time_ms))
|
||||||
.collect();
|
.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);
|
let max_time = key_times.last().map(|(_, t)| *t).unwrap_or(1.0);
|
||||||
|
|
||||||
@@ -955,7 +975,7 @@ impl StatsDashboard<'_> {
|
|||||||
})
|
})
|
||||||
.collect();
|
.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() {
|
if key_accuracies.is_empty() {
|
||||||
buf.set_string(
|
buf.set_string(
|
||||||
@@ -1027,7 +1047,7 @@ impl StatsDashboard<'_> {
|
|||||||
})
|
})
|
||||||
.collect();
|
.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() {
|
if key_accuracies.is_empty() {
|
||||||
buf.set_string(
|
buf.set_string(
|
||||||
@@ -1078,14 +1098,19 @@ impl StatsDashboard<'_> {
|
|||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
block.render(area, buf);
|
block.render(area, buf);
|
||||||
|
|
||||||
|
let mut day_counts: HashMap<chrono::NaiveDate, usize> = HashMap::new();
|
||||||
let mut active_days: BTreeSet<chrono::NaiveDate> = BTreeSet::new();
|
let mut active_days: BTreeSet<chrono::NaiveDate> = BTreeSet::new();
|
||||||
for r in self.history {
|
for r in self.history.iter().filter(|r| !r.partial) {
|
||||||
active_days.insert(r.timestamp.date_naive());
|
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 (current_streak, best_streak) = compute_streaks(&active_days);
|
||||||
let active_days_count = active_days.len();
|
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(" Current: ", Style::default().fg(colors.fg())),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("{current_streak}d"),
|
format!("{current_streak}d"),
|
||||||
@@ -1096,7 +1121,9 @@ impl StatsDashboard<'_> {
|
|||||||
Span::styled(" Best: ", Style::default().fg(colors.fg())),
|
Span::styled(" Best: ", Style::default().fg(colors.fg())),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("{best_streak}d"),
|
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(" Active Days: ", Style::default().fg(colors.fg())),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
@@ -1104,9 +1131,24 @@ impl StatsDashboard<'_> {
|
|||||||
Style::default().fg(colors.text_pending()),
|
Style::default().fg(colors.text_pending()),
|
||||||
),
|
),
|
||||||
])];
|
])];
|
||||||
|
|
||||||
|
let top_days_text = if top_days.is_empty() {
|
||||||
|
" Top Days: none".to_string()
|
||||||
|
} else {
|
||||||
|
let parts: Vec<String> = 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);
|
Paragraph::new(lines).render(inner, buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn accuracy_color(accuracy: f64, colors: &crate::ui::theme::ThemeColors) -> ratatui::style::Color {
|
fn accuracy_color(accuracy: f64, colors: &crate::ui::theme::ThemeColors) -> ratatui::style::Color {
|
||||||
|
|||||||
Reference in New Issue
Block a user