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