Import/export feature for config and data
This commit is contained in:
322
src/app.rs
322
src/app.rs
@@ -31,12 +31,13 @@ use crate::generator::phonetic::PhoneticGenerator;
|
||||
use crate::generator::punctuate;
|
||||
use crate::generator::transition_table::TransitionTable;
|
||||
use crate::keyboard::model::KeyboardModel;
|
||||
use crate::keyboard::display::BACKSPACE;
|
||||
|
||||
use crate::session::drill::DrillState;
|
||||
use crate::session::input::{self, KeystrokeEvent};
|
||||
use crate::session::result::DrillResult;
|
||||
use crate::store::json_store::JsonStore;
|
||||
use crate::store::schema::{DrillHistoryData, KeyStatsData, ProfileData};
|
||||
use crate::store::schema::{DrillHistoryData, ExportData, KeyStatsData, ProfileData, EXPORT_VERSION};
|
||||
use crate::ui::components::menu::Menu;
|
||||
use crate::ui::theme::Theme;
|
||||
|
||||
@@ -110,6 +111,68 @@ struct DownloadJob {
|
||||
handle: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum StatusKind {
|
||||
Success,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StatusMessage {
|
||||
pub kind: StatusKind,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Given a file path, find the next available path by appending/incrementing
|
||||
/// a `-N` numeric suffix before the extension. Strips any existing trailing
|
||||
/// `-N` suffix to normalize before scanning.
|
||||
pub fn next_available_path(path_str: &str) -> String {
|
||||
let path = std::path::Path::new(path_str).to_path_buf();
|
||||
let parent = path.parent().unwrap_or(std::path::Path::new("."));
|
||||
let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("json");
|
||||
let full_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("export");
|
||||
|
||||
// Strip existing trailing -N suffix to get base stem
|
||||
let base_stem = if let Some(pos) = full_stem.rfind('-') {
|
||||
let suffix = &full_stem[pos + 1..];
|
||||
// Only strip if the suffix is a pure positive integer AND the base before
|
||||
// it also exists as a file (i.e., this is our rename suffix, not part of
|
||||
// the original name like a date component)
|
||||
if suffix.parse::<u32>().is_ok() {
|
||||
let candidate_base = &full_stem[..pos];
|
||||
let base_file = parent.join(format!("{candidate_base}.{extension}"));
|
||||
if base_file.exists() {
|
||||
candidate_base
|
||||
} else {
|
||||
full_stem
|
||||
}
|
||||
} else {
|
||||
full_stem
|
||||
}
|
||||
} else {
|
||||
full_stem
|
||||
};
|
||||
|
||||
let mut n = 1u32;
|
||||
loop {
|
||||
let candidate = parent.join(format!("{base_stem}-{n}.{extension}"));
|
||||
if !candidate.exists() {
|
||||
return candidate.to_string_lossy().to_string();
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn default_export_path() -> String {
|
||||
let dir = dirs::download_dir()
|
||||
.or_else(dirs::home_dir)
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
let date = chrono::Utc::now().format("%Y-%m-%d");
|
||||
dir.join(format!("keydr-export-{date}.json"))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
impl DrillMode {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
@@ -166,6 +229,7 @@ pub struct App {
|
||||
pub passage_intro_download_bytes_total: u64,
|
||||
pub passage_download_queue: Vec<usize>,
|
||||
pub passage_drill_selection_override: Option<String>,
|
||||
pub last_passage_drill_selection: Option<String>,
|
||||
pub passage_download_action: PassageDownloadCompleteAction,
|
||||
pub code_intro_selected: usize,
|
||||
pub code_intro_downloads_enabled: bool,
|
||||
@@ -179,12 +243,20 @@ pub struct App {
|
||||
pub code_intro_download_bytes_total: u64,
|
||||
pub code_download_queue: Vec<(String, usize)>,
|
||||
pub code_drill_language_override: Option<String>,
|
||||
pub last_code_drill_language: Option<String>,
|
||||
pub code_download_attempted: bool,
|
||||
pub code_download_action: CodeDownloadCompleteAction,
|
||||
pub shift_held: bool,
|
||||
pub caps_lock: bool,
|
||||
pub keyboard_model: KeyboardModel,
|
||||
pub milestone_queue: VecDeque<KeyMilestonePopup>,
|
||||
pub settings_confirm_import: bool,
|
||||
pub settings_export_conflict: bool,
|
||||
pub settings_status_message: Option<StatusMessage>,
|
||||
pub settings_export_path: String,
|
||||
pub settings_import_path: String,
|
||||
pub settings_editing_export_path: bool,
|
||||
pub settings_editing_import_path: bool,
|
||||
pub keyboard_explorer_selected: Option<char>,
|
||||
pub explorer_accuracy_cache_overall: Option<(char, usize, usize)>,
|
||||
pub explorer_accuracy_cache_ranked: Option<(char, usize, usize)>,
|
||||
@@ -299,6 +371,7 @@ impl App {
|
||||
passage_intro_download_bytes_total: 0,
|
||||
passage_download_queue: Vec::new(),
|
||||
passage_drill_selection_override: None,
|
||||
last_passage_drill_selection: None,
|
||||
passage_download_action: PassageDownloadCompleteAction::StartPassageDrill,
|
||||
code_intro_selected: 0,
|
||||
code_intro_downloads_enabled,
|
||||
@@ -312,12 +385,20 @@ impl App {
|
||||
code_intro_download_bytes_total: 0,
|
||||
code_download_queue: Vec::new(),
|
||||
code_drill_language_override: None,
|
||||
last_code_drill_language: None,
|
||||
code_download_attempted: false,
|
||||
code_download_action: CodeDownloadCompleteAction::StartCodeDrill,
|
||||
shift_held: false,
|
||||
caps_lock: false,
|
||||
keyboard_model,
|
||||
milestone_queue: VecDeque::new(),
|
||||
settings_confirm_import: false,
|
||||
settings_export_conflict: false,
|
||||
settings_status_message: None,
|
||||
settings_export_path: default_export_path(),
|
||||
settings_import_path: default_export_path(),
|
||||
settings_editing_export_path: false,
|
||||
settings_editing_import_path: false,
|
||||
keyboard_explorer_selected: None,
|
||||
explorer_accuracy_cache_overall: None,
|
||||
explorer_accuracy_cache_ranked: None,
|
||||
@@ -327,10 +408,215 @@ impl App {
|
||||
passage_download_job: None,
|
||||
code_download_job: None,
|
||||
};
|
||||
|
||||
// Check for leftover .bak files from interrupted import
|
||||
if let Some(ref s) = app.store
|
||||
&& s.check_interrupted_import()
|
||||
{
|
||||
app.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: "Recovery files found from interrupted import. Data may be inconsistent — consider re-importing.".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
app.start_drill();
|
||||
app
|
||||
}
|
||||
|
||||
/// Clear all import/export modal and edit states.
|
||||
pub fn clear_settings_modals(&mut self) {
|
||||
self.settings_confirm_import = false;
|
||||
self.settings_export_conflict = false;
|
||||
self.settings_editing_export_path = false;
|
||||
self.settings_editing_import_path = false;
|
||||
self.settings_editing_download_dir = false;
|
||||
}
|
||||
|
||||
pub fn export_data(&mut self) {
|
||||
let path = std::path::Path::new(&self.settings_export_path);
|
||||
|
||||
// Check for existing file
|
||||
if path.exists() {
|
||||
self.settings_export_conflict = true;
|
||||
return;
|
||||
}
|
||||
|
||||
self.write_export_to_path();
|
||||
}
|
||||
|
||||
pub fn export_data_overwrite(&mut self) {
|
||||
self.write_export_to_path();
|
||||
}
|
||||
|
||||
pub fn export_data_rename(&mut self) {
|
||||
self.settings_export_path = next_available_path(&self.settings_export_path);
|
||||
self.write_export_to_path();
|
||||
}
|
||||
|
||||
fn write_export_to_path(&mut self) {
|
||||
// Check parent directory exists
|
||||
let path = std::path::Path::new(&self.settings_export_path);
|
||||
if let Some(parent) = path.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
&& !parent.exists()
|
||||
{
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: format!("Directory does not exist: {}", parent.display()),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(ref store) = self.store else {
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: "No data store available".to_string(),
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
let export = store.export_all(&self.config);
|
||||
let json = match serde_json::to_string_pretty(&export) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: format!("Serialization error: {e}"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let path = std::path::Path::new(&self.settings_export_path);
|
||||
let tmp_path = path.with_extension("json.tmp");
|
||||
|
||||
let result = (|| -> anyhow::Result<()> {
|
||||
let mut file = std::fs::File::create(&tmp_path)?;
|
||||
std::io::Write::write_all(&mut file, json.as_bytes())?;
|
||||
file.sync_all()?;
|
||||
std::fs::rename(&tmp_path, path)?;
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Success,
|
||||
text: format!("Exported to {}", self.settings_export_path),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: format!("Export failed: {e}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn import_data(&mut self) {
|
||||
let path = std::path::Path::new(&self.settings_import_path);
|
||||
|
||||
// Read and parse
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: format!("Could not read file: {e}"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let export: ExportData = match serde_json::from_str(&content) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: format!("Invalid export file: {e}"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Version check
|
||||
if export.keydr_export_version != EXPORT_VERSION {
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: format!(
|
||||
"Unsupported export version: {} (expected {})",
|
||||
export.keydr_export_version, EXPORT_VERSION
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Write data files transactionally
|
||||
let Some(ref store) = self.store else {
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: "No data store available".to_string(),
|
||||
});
|
||||
return;
|
||||
};
|
||||
if let Err(e) = store.import_all(&export) {
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Error,
|
||||
text: format!("Import failed: {e}"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge config: import everything except machine-local paths
|
||||
let preserved_code_dir = self.config.code_download_dir.clone();
|
||||
let preserved_passage_dir = self.config.passage_download_dir.clone();
|
||||
self.config = export.config.clone();
|
||||
self.config.code_download_dir = preserved_code_dir;
|
||||
self.config.passage_download_dir = preserved_passage_dir;
|
||||
|
||||
// Validate and save config
|
||||
let valid_keys: Vec<&str> = code_language_options().iter().map(|(k, _)| *k).collect();
|
||||
self.config.validate(&valid_keys);
|
||||
let _ = self.config.save();
|
||||
|
||||
// Reload in-memory state from imported data
|
||||
self.profile = export.profile;
|
||||
self.key_stats = export.key_stats.stats;
|
||||
self.key_stats.target_cpm = self.config.target_cpm();
|
||||
self.ranked_key_stats = export.ranked_key_stats.stats;
|
||||
self.ranked_key_stats.target_cpm = self.config.target_cpm();
|
||||
self.drill_history = export.drill_history.drills;
|
||||
self.skill_tree = SkillTree::new(self.profile.skill_tree.clone());
|
||||
self.keyboard_model = KeyboardModel::from_name(&self.config.keyboard_layout);
|
||||
|
||||
// Check theme availability
|
||||
let theme_name = self.config.theme.clone();
|
||||
let loaded_theme = Theme::load(&theme_name).unwrap_or_default();
|
||||
let theme_fell_back = loaded_theme.name != theme_name;
|
||||
let theme: &'static Theme = Box::leak(Box::new(loaded_theme));
|
||||
self.theme = theme;
|
||||
self.menu = Menu::new(theme);
|
||||
|
||||
if theme_fell_back {
|
||||
self.config.theme = self.theme.name.clone();
|
||||
let _ = self.config.save();
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Success,
|
||||
text: format!(
|
||||
"Imported successfully (theme '{}' not found, using default)",
|
||||
theme_name
|
||||
),
|
||||
});
|
||||
} else {
|
||||
self.settings_status_message = Some(StatusMessage {
|
||||
kind: StatusKind::Success,
|
||||
text: "Imported successfully".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_drill(&mut self) {
|
||||
let (text, source_info) = self.generate_text();
|
||||
self.drill = Some(DrillState::new(&text));
|
||||
@@ -467,6 +753,7 @@ impl App {
|
||||
.code_drill_language_override
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.config.code_language.clone());
|
||||
self.last_code_drill_language = Some(lang.clone());
|
||||
let rng = SmallRng::from_rng(&mut self.rng).unwrap();
|
||||
let mut generator = CodeSyntaxGenerator::new(
|
||||
rng,
|
||||
@@ -484,6 +771,7 @@ impl App {
|
||||
.passage_drill_selection_override
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.config.passage_book.clone());
|
||||
self.last_passage_drill_selection = Some(selection.clone());
|
||||
let mut generator = PassageGenerator::new(
|
||||
rng,
|
||||
&selection,
|
||||
@@ -522,6 +810,15 @@ impl App {
|
||||
|
||||
pub fn backspace(&mut self) {
|
||||
if let Some(ref mut drill) = self.drill {
|
||||
if drill.cursor == 0 {
|
||||
return;
|
||||
}
|
||||
self.drill_events.push(KeystrokeEvent {
|
||||
expected: BACKSPACE,
|
||||
actual: BACKSPACE,
|
||||
timestamp: Instant::now(),
|
||||
correct: true,
|
||||
});
|
||||
input::process_backspace(drill);
|
||||
}
|
||||
}
|
||||
@@ -629,8 +926,8 @@ impl App {
|
||||
|
||||
self.last_result = Some(result);
|
||||
|
||||
// Adaptive mode auto-continues to next drill (like keybr.com)
|
||||
if self.drill_mode == DrillMode::Adaptive {
|
||||
// Adaptive mode auto-continues unless milestone popups must be shown first.
|
||||
if self.drill_mode == DrillMode::Adaptive && self.milestone_queue.is_empty() {
|
||||
self.start_drill();
|
||||
} else {
|
||||
self.screen = AppScreen::DrillResult;
|
||||
@@ -698,6 +995,25 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn continue_drill(&mut self) {
|
||||
self.history_confirm_delete = false;
|
||||
match self.drill_mode {
|
||||
DrillMode::Adaptive => self.start_drill(),
|
||||
DrillMode::Code => {
|
||||
if let Some(lang) = self.last_code_drill_language.clone() {
|
||||
self.code_drill_language_override = Some(lang);
|
||||
}
|
||||
self.start_code_drill();
|
||||
}
|
||||
DrillMode::Passage => {
|
||||
if let Some(selection) = self.last_passage_drill_selection.clone() {
|
||||
self.passage_drill_selection_override = Some(selection);
|
||||
}
|
||||
self.start_passage_drill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn go_to_menu(&mut self) {
|
||||
self.screen = AppScreen::Menu;
|
||||
self.drill = None;
|
||||
|
||||
@@ -122,7 +122,6 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let path = Self::config_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
@@ -144,6 +143,14 @@ impl Config {
|
||||
self.target_wpm as f64 * 5.0
|
||||
}
|
||||
|
||||
/// Clamp and normalize all config values to valid ranges.
|
||||
/// Call after importing config from an external source.
|
||||
pub fn validate(&mut self, valid_language_keys: &[&str]) {
|
||||
self.target_wpm = self.target_wpm.clamp(10, 200);
|
||||
self.word_count = self.word_count.clamp(5, 100);
|
||||
self.normalize_code_language(valid_language_keys);
|
||||
}
|
||||
|
||||
/// Validate `code_language` against known options, resetting to default if invalid.
|
||||
/// Call after deserialization to handle stale/renamed keys from old configs.
|
||||
pub fn normalize_code_language(&mut self, valid_keys: &[&str]) {
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::engine::key_stats::KeyStatsStore;
|
||||
use crate::keyboard::display::{BACKSPACE, SPACE};
|
||||
|
||||
/// Events returned by `SkillTree::update` describing what changed.
|
||||
pub struct SkillTreeUpdate {
|
||||
@@ -278,6 +279,7 @@ pub struct SkillTree {
|
||||
|
||||
/// Number of lowercase letters to start with before unlocking one-at-a-time
|
||||
const LOWERCASE_MIN_KEYS: usize = 6;
|
||||
const ALWAYS_UNLOCKED_KEYS: &[char] = &[SPACE, BACKSPACE];
|
||||
|
||||
impl SkillTree {
|
||||
pub fn new(progress: SkillTreeProgress) -> Self {
|
||||
@@ -297,6 +299,7 @@ impl SkillTree {
|
||||
}
|
||||
}
|
||||
}
|
||||
all_keys.extend(ALWAYS_UNLOCKED_KEYS.iter().copied());
|
||||
all_keys.len()
|
||||
}
|
||||
|
||||
@@ -341,7 +344,7 @@ impl SkillTree {
|
||||
}
|
||||
|
||||
fn global_unlocked_keys(&self) -> Vec<char> {
|
||||
let mut keys = Vec::new();
|
||||
let mut keys = ALWAYS_UNLOCKED_KEYS.to_vec();
|
||||
for branch_def in ALL_BRANCHES {
|
||||
let bp = self.branch_progress(branch_def.id);
|
||||
match bp.status {
|
||||
@@ -370,7 +373,7 @@ impl SkillTree {
|
||||
}
|
||||
|
||||
fn branch_unlocked_keys(&self, id: BranchId) -> Vec<char> {
|
||||
let mut keys = Vec::new();
|
||||
let mut keys = ALWAYS_UNLOCKED_KEYS.to_vec();
|
||||
|
||||
// Always include a-z background keys
|
||||
if id != BranchId::Lowercase {
|
||||
@@ -638,6 +641,7 @@ impl SkillTree {
|
||||
/// Total number of unlocked unique keys across all branches.
|
||||
pub fn total_unlocked_count(&self) -> usize {
|
||||
let mut keys: HashSet<char> = HashSet::new();
|
||||
keys.extend(ALWAYS_UNLOCKED_KEYS.iter().copied());
|
||||
for branch_def in ALL_BRANCHES {
|
||||
let bp = self.branch_progress(branch_def.id);
|
||||
match bp.status {
|
||||
@@ -714,6 +718,11 @@ impl SkillTree {
|
||||
/// Count of unique confident keys across all branches.
|
||||
pub fn total_confident_keys(&self, stats: &KeyStatsStore) -> usize {
|
||||
let mut keys: HashSet<char> = HashSet::new();
|
||||
for &ch in ALWAYS_UNLOCKED_KEYS {
|
||||
if stats.get_confidence(ch) >= 1.0 {
|
||||
keys.insert(ch);
|
||||
}
|
||||
}
|
||||
for branch_def in ALL_BRANCHES {
|
||||
for level in branch_def.levels {
|
||||
for &ch in level.keys {
|
||||
@@ -772,15 +781,17 @@ mod tests {
|
||||
#[test]
|
||||
fn test_total_unique_keys() {
|
||||
let tree = SkillTree::default();
|
||||
assert_eq!(tree.total_unique_keys, 96);
|
||||
assert_eq!(tree.total_unique_keys, 98);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_initial_lowercase_unlocked() {
|
||||
let tree = SkillTree::default();
|
||||
let keys = tree.unlocked_keys(DrillScope::Global);
|
||||
assert_eq!(keys.len(), LOWERCASE_MIN_KEYS);
|
||||
assert_eq!(&keys[..6], &['e', 't', 'a', 'o', 'i', 'n']);
|
||||
assert_eq!(keys.len(), LOWERCASE_MIN_KEYS + ALWAYS_UNLOCKED_KEYS.len());
|
||||
assert_eq!(&keys[2..8], &['e', 't', 'a', 'o', 'i', 'n']);
|
||||
assert!(keys.contains(&SPACE));
|
||||
assert!(keys.contains(&BACKSPACE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -794,7 +805,7 @@ mod tests {
|
||||
|
||||
// Should unlock 7th key ('s')
|
||||
let keys = tree.unlocked_keys(DrillScope::Global);
|
||||
assert_eq!(keys.len(), 7);
|
||||
assert_eq!(keys.len(), 9);
|
||||
assert!(keys.contains(&'s'));
|
||||
}
|
||||
|
||||
|
||||
@@ -213,10 +213,26 @@ impl TextGenerator for PhoneticGenerator {
|
||||
|
||||
for _ in 0..word_count {
|
||||
if use_real_words {
|
||||
// Pick a real word (avoid consecutive duplicates)
|
||||
// Pick a real word (avoid consecutive duplicates).
|
||||
// If focused is set, bias sampling toward words containing that key.
|
||||
let focus = focused.filter(|ch| ch.is_ascii_lowercase());
|
||||
let focused_indices: Vec<usize> = if let Some(ch) = focus {
|
||||
matching_words
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, w)| w.contains(ch).then_some(i))
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut picked = None;
|
||||
for _ in 0..3 {
|
||||
let idx = self.rng.gen_range(0..matching_words.len());
|
||||
for _ in 0..6 {
|
||||
let idx = if !focused_indices.is_empty() && self.rng.gen_bool(0.70) {
|
||||
let j = self.rng.gen_range(0..focused_indices.len());
|
||||
focused_indices[j]
|
||||
} else {
|
||||
self.rng.gen_range(0..matching_words.len())
|
||||
};
|
||||
let word = matching_words[idx].clone();
|
||||
if word != last_word {
|
||||
picked = Some(word);
|
||||
@@ -239,3 +255,40 @@ impl TextGenerator for PhoneticGenerator {
|
||||
words.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rand::SeedableRng;
|
||||
|
||||
#[test]
|
||||
fn focused_key_biases_real_word_sampling() {
|
||||
let dictionary = Dictionary::load();
|
||||
let table = TransitionTable::build_from_words(&dictionary.words_list());
|
||||
let filter = CharFilter::new(('a'..='z').collect());
|
||||
|
||||
let mut focused_gen = PhoneticGenerator::new(
|
||||
table.clone(),
|
||||
Dictionary::load(),
|
||||
SmallRng::seed_from_u64(42),
|
||||
);
|
||||
let focused_text = focused_gen.generate(&filter, Some('k'), 1200);
|
||||
let focused_count = focused_text
|
||||
.split_whitespace()
|
||||
.filter(|w| w.contains('k'))
|
||||
.count();
|
||||
|
||||
let mut baseline_gen =
|
||||
PhoneticGenerator::new(table, Dictionary::load(), SmallRng::seed_from_u64(42));
|
||||
let baseline_text = baseline_gen.generate(&filter, None, 1200);
|
||||
let baseline_count = baseline_text
|
||||
.split_whitespace()
|
||||
.filter(|w| w.contains('k'))
|
||||
.count();
|
||||
|
||||
assert!(
|
||||
focused_count > baseline_count,
|
||||
"focused_count={focused_count}, baseline_count={baseline_count}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
735
src/main.rs
735
src/main.rs
@@ -28,7 +28,7 @@ use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph, Widget, Wrap};
|
||||
|
||||
use app::{App, AppScreen, DrillMode, MilestoneKind};
|
||||
use app::{App, AppScreen, DrillMode, MilestoneKind, StatusKind};
|
||||
use engine::skill_tree::{DrillScope, find_key_branch};
|
||||
use keyboard::display::key_display_name;
|
||||
use keyboard::finger::Hand;
|
||||
@@ -228,6 +228,12 @@ fn handle_key(app: &mut App, key: KeyEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Milestone overlays are modal: any key dismisses exactly one popup and is consumed.
|
||||
if !app.milestone_queue.is_empty() {
|
||||
app.milestone_queue.pop_front();
|
||||
return;
|
||||
}
|
||||
|
||||
match app.screen {
|
||||
AppScreen::Menu => handle_menu_key(app, key),
|
||||
AppScreen::Drill => handle_drill_key(app, key),
|
||||
@@ -304,25 +310,6 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
|
||||
}
|
||||
|
||||
fn handle_drill_key(app: &mut App, key: KeyEvent) {
|
||||
// If a milestone overlay is showing, dismiss it on any key press
|
||||
if !app.milestone_queue.is_empty() {
|
||||
app.milestone_queue.pop_front();
|
||||
|
||||
// Determine what to do with the dismissing key
|
||||
match milestone_dismiss_action(key.code) {
|
||||
MilestoneDismissAction::EscAndExit => {
|
||||
// Esc clears entire queue and exits drill
|
||||
app.milestone_queue.clear();
|
||||
// Fall through to normal Esc handling below
|
||||
}
|
||||
MilestoneDismissAction::Replay => {
|
||||
// Char/Tab/Enter: dismiss and replay into drill
|
||||
// Fall through to normal key handling below
|
||||
}
|
||||
MilestoneDismissAction::DismissOnly => return, // Backspace and others
|
||||
}
|
||||
}
|
||||
|
||||
// Route Enter/Tab as typed characters during active drills
|
||||
if app.drill.is_some() {
|
||||
match key.code {
|
||||
@@ -354,26 +341,33 @@ fn handle_drill_key(app: &mut App, key: KeyEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum MilestoneDismissAction {
|
||||
Replay,
|
||||
DismissOnly,
|
||||
EscAndExit,
|
||||
}
|
||||
|
||||
fn milestone_dismiss_action(code: KeyCode) -> MilestoneDismissAction {
|
||||
match code {
|
||||
KeyCode::Esc => MilestoneDismissAction::EscAndExit,
|
||||
KeyCode::Char(_) | KeyCode::Tab | KeyCode::Enter => MilestoneDismissAction::Replay,
|
||||
_ => MilestoneDismissAction::DismissOnly,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_result_key(app: &mut App, key: KeyEvent) {
|
||||
if app.history_confirm_delete {
|
||||
match key.code {
|
||||
KeyCode::Char('y') => {
|
||||
app.delete_session();
|
||||
app.history_confirm_delete = false;
|
||||
}
|
||||
KeyCode::Char('n') | KeyCode::Esc => {
|
||||
app.history_confirm_delete = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Char('c') | KeyCode::Enter | KeyCode::Char(' ') => app.continue_drill(),
|
||||
KeyCode::Char('r') => app.retry_drill(),
|
||||
KeyCode::Char('q') | KeyCode::Esc => app.go_to_menu(),
|
||||
KeyCode::Char('s') => app.go_to_stats(),
|
||||
KeyCode::Char('x') => {
|
||||
if !app.drill_history.is_empty() {
|
||||
// On result screen, delete always targets the just-completed (most recent) session.
|
||||
app.history_selected = 0;
|
||||
app.history_confirm_delete = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -452,25 +446,78 @@ fn handle_stats_key(app: &mut App, key: KeyEvent) {
|
||||
}
|
||||
|
||||
fn handle_settings_key(app: &mut App, key: KeyEvent) {
|
||||
const MAX_SETTINGS: usize = 11;
|
||||
const MAX_SETTINGS: usize = 15;
|
||||
|
||||
if app.settings_editing_download_dir {
|
||||
// Priority 1: dismiss status message
|
||||
if app.settings_status_message.is_some() {
|
||||
app.settings_status_message = None;
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 2: export conflict dialog
|
||||
if app.settings_export_conflict {
|
||||
match key.code {
|
||||
KeyCode::Char('d') => {
|
||||
app.settings_export_conflict = false;
|
||||
app.export_data_overwrite();
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
app.settings_export_conflict = false;
|
||||
app.export_data_rename();
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
app.settings_export_conflict = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 3: import confirmation dialog
|
||||
if app.settings_confirm_import {
|
||||
match key.code {
|
||||
KeyCode::Char('y') => {
|
||||
app.settings_confirm_import = false;
|
||||
app.import_data();
|
||||
}
|
||||
KeyCode::Char('n') | KeyCode::Esc => {
|
||||
app.settings_confirm_import = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 4: editing a path field
|
||||
if app.settings_editing_download_dir || app.settings_editing_export_path || app.settings_editing_import_path {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
app.settings_editing_download_dir = false;
|
||||
app.clear_settings_modals();
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if app.settings_selected == 5 {
|
||||
app.config.code_download_dir.pop();
|
||||
} else if app.settings_selected == 9 {
|
||||
app.config.passage_download_dir.pop();
|
||||
if app.settings_editing_download_dir {
|
||||
if app.settings_selected == 5 {
|
||||
app.config.code_download_dir.pop();
|
||||
} else if app.settings_selected == 9 {
|
||||
app.config.passage_download_dir.pop();
|
||||
}
|
||||
} else if app.settings_editing_export_path {
|
||||
app.settings_export_path.pop();
|
||||
} else if app.settings_editing_import_path {
|
||||
app.settings_import_path.pop();
|
||||
}
|
||||
}
|
||||
KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
if app.settings_selected == 5 {
|
||||
app.config.code_download_dir.push(ch);
|
||||
} else if app.settings_selected == 9 {
|
||||
app.config.passage_download_dir.push(ch);
|
||||
if app.settings_editing_download_dir {
|
||||
if app.settings_selected == 5 {
|
||||
app.config.code_download_dir.push(ch);
|
||||
} else if app.settings_selected == 9 {
|
||||
app.config.passage_download_dir.push(ch);
|
||||
}
|
||||
} else if app.settings_editing_export_path {
|
||||
app.settings_export_path.push(ch);
|
||||
} else if app.settings_editing_import_path {
|
||||
app.settings_import_path.push(ch);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -495,22 +542,37 @@ fn handle_settings_key(app: &mut App, key: KeyEvent) {
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
match app.settings_selected {
|
||||
5 | 9 => app.settings_editing_download_dir = true,
|
||||
5 | 9 => {
|
||||
app.clear_settings_modals();
|
||||
app.settings_editing_download_dir = true;
|
||||
}
|
||||
7 => app.start_code_downloads_from_settings(),
|
||||
11 => app.start_passage_downloads_from_settings(),
|
||||
12 => {
|
||||
app.clear_settings_modals();
|
||||
app.settings_editing_export_path = true;
|
||||
}
|
||||
13 => app.export_data(),
|
||||
14 => {
|
||||
app.clear_settings_modals();
|
||||
app.settings_editing_import_path = true;
|
||||
}
|
||||
15 => {
|
||||
app.clear_settings_modals();
|
||||
app.settings_confirm_import = true;
|
||||
}
|
||||
_ => app.settings_cycle_forward(),
|
||||
}
|
||||
}
|
||||
KeyCode::Right | KeyCode::Char('l') => {
|
||||
// Allow cycling for non-text, non-button fields
|
||||
match app.settings_selected {
|
||||
5 | 7 | 9 | 11 => {} // text fields or action buttons
|
||||
5 | 7 | 9 | 11 | 12 | 13 | 14 | 15 => {} // path/button fields
|
||||
_ => app.settings_cycle_forward(),
|
||||
}
|
||||
}
|
||||
KeyCode::Left | KeyCode::Char('h') => {
|
||||
match app.settings_selected {
|
||||
5 | 7 | 9 | 11 => {} // text fields or action buttons
|
||||
5 | 7 | 9 | 11 | 12 | 13 | 14 | 15 => {} // path/button fields
|
||||
_ => app.settings_cycle_backward(),
|
||||
}
|
||||
}
|
||||
@@ -935,6 +997,12 @@ fn render(frame: &mut ratatui::Frame, app: &App) {
|
||||
let bg = Block::default().style(Style::default().bg(colors.bg()));
|
||||
frame.render_widget(bg, area);
|
||||
|
||||
// Milestone overlays are modal and shown before the underlying screen.
|
||||
if let Some(milestone) = app.milestone_queue.front() {
|
||||
render_milestone_overlay(frame, app, milestone);
|
||||
return;
|
||||
}
|
||||
|
||||
match app.screen {
|
||||
AppScreen::Menu => render_menu(frame, app),
|
||||
AppScreen::Drill => render_drill(frame, app),
|
||||
@@ -974,8 +1042,8 @@ fn render_menu(frame: &mut ratatui::Frame, app: &App) {
|
||||
let unlocked = app.skill_tree.total_unlocked_count();
|
||||
let mastered = app.skill_tree.total_confident_keys(&app.ranked_key_stats);
|
||||
let header_info = format!(
|
||||
" Key Progress {unlocked}/{total_keys} ({mastered} mastered){}",
|
||||
streak_text,
|
||||
" Key Progress {unlocked}/{total_keys} ({mastered} mastered) | Target {} WPM{}",
|
||||
app.config.target_wpm, streak_text,
|
||||
);
|
||||
let header = Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
@@ -1176,6 +1244,7 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
|
||||
drill,
|
||||
app.last_result.as_ref(),
|
||||
&app.drill_history,
|
||||
app.config.target_wpm,
|
||||
app.theme,
|
||||
);
|
||||
frame.render_widget(sidebar, sidebar_area);
|
||||
@@ -1187,10 +1256,6 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
|
||||
)));
|
||||
frame.render_widget(footer, app_layout.footer);
|
||||
|
||||
// Render milestone overlay if present
|
||||
if let Some(milestone) = app.milestone_queue.front() {
|
||||
render_milestone_overlay(frame, app, milestone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1350,7 +1415,7 @@ fn render_milestone_overlay(
|
||||
if footer_y < inner.y + inner.height {
|
||||
let footer_area = Rect::new(inner.x, footer_y, inner.width, 1);
|
||||
let footer = Paragraph::new(Line::from(Span::styled(
|
||||
" Press any key to continue (Backspace dismisses only)",
|
||||
" Press any key to continue",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)));
|
||||
frame.render_widget(footer, footer_area);
|
||||
@@ -1370,29 +1435,78 @@ fn overlay_keyboard_mode(height: u16) -> u8 {
|
||||
#[cfg(test)]
|
||||
mod review_tests {
|
||||
use super::*;
|
||||
use crate::session::result::DrillResult;
|
||||
use chrono::{TimeDelta, Utc};
|
||||
|
||||
fn test_result(ts_offset_secs: i64) -> DrillResult {
|
||||
DrillResult {
|
||||
wpm: 60.0,
|
||||
cpm: 300.0,
|
||||
accuracy: 98.0,
|
||||
correct: 49,
|
||||
incorrect: 1,
|
||||
total_chars: 50,
|
||||
elapsed_secs: 10.0,
|
||||
timestamp: Utc::now() + TimeDelta::seconds(ts_offset_secs),
|
||||
per_key_times: vec![],
|
||||
drill_mode: "adaptive".to_string(),
|
||||
ranked: true,
|
||||
partial: false,
|
||||
completion_percent: 100.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn milestone_dismiss_matrix_matches_spec() {
|
||||
assert_eq!(
|
||||
milestone_dismiss_action(KeyCode::Char('a')),
|
||||
MilestoneDismissAction::Replay
|
||||
);
|
||||
assert_eq!(
|
||||
milestone_dismiss_action(KeyCode::Tab),
|
||||
MilestoneDismissAction::Replay
|
||||
);
|
||||
assert_eq!(
|
||||
milestone_dismiss_action(KeyCode::Enter),
|
||||
MilestoneDismissAction::Replay
|
||||
);
|
||||
assert_eq!(
|
||||
milestone_dismiss_action(KeyCode::Backspace),
|
||||
MilestoneDismissAction::DismissOnly
|
||||
);
|
||||
assert_eq!(
|
||||
milestone_dismiss_action(KeyCode::Esc),
|
||||
MilestoneDismissAction::EscAndExit
|
||||
);
|
||||
fn milestone_overlay_blocks_underlying_input() {
|
||||
let mut app = App::new();
|
||||
app.screen = AppScreen::Drill;
|
||||
app.drill = Some(crate::session::drill::DrillState::new("abc"));
|
||||
app.milestone_queue
|
||||
.push_back(crate::app::KeyMilestonePopup {
|
||||
kind: crate::app::MilestoneKind::Unlock,
|
||||
keys: vec!['a'],
|
||||
finger_info: vec![('a', "left pinky".to_string())],
|
||||
message: "msg",
|
||||
});
|
||||
|
||||
let before_cursor = app.drill.as_ref().map(|d| d.cursor).unwrap_or(0);
|
||||
handle_key(&mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
||||
let after_cursor = app.drill.as_ref().map(|d| d.cursor).unwrap_or(0);
|
||||
|
||||
assert_eq!(before_cursor, after_cursor);
|
||||
assert!(app.milestone_queue.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn milestone_queue_chains_before_result_actions() {
|
||||
let mut app = App::new();
|
||||
app.screen = AppScreen::DrillResult;
|
||||
app.milestone_queue
|
||||
.push_back(crate::app::KeyMilestonePopup {
|
||||
kind: crate::app::MilestoneKind::Unlock,
|
||||
keys: vec!['a'],
|
||||
finger_info: vec![('a', "left pinky".to_string())],
|
||||
message: "msg1",
|
||||
});
|
||||
app.milestone_queue
|
||||
.push_back(crate::app::KeyMilestonePopup {
|
||||
kind: crate::app::MilestoneKind::Mastery,
|
||||
keys: vec!['a'],
|
||||
finger_info: vec![('a', "left pinky".to_string())],
|
||||
message: "msg2",
|
||||
});
|
||||
|
||||
handle_key(&mut app, KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
|
||||
assert_eq!(app.screen, AppScreen::DrillResult);
|
||||
assert_eq!(app.milestone_queue.len(), 1);
|
||||
|
||||
handle_key(&mut app, KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
|
||||
assert_eq!(app.screen, AppScreen::DrillResult);
|
||||
assert!(app.milestone_queue.is_empty());
|
||||
|
||||
// Now normal result action should apply.
|
||||
handle_key(&mut app, KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
|
||||
assert_eq!(app.screen, AppScreen::Menu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1402,6 +1516,268 @@ mod review_tests {
|
||||
assert_eq!(overlay_keyboard_mode(24), 1);
|
||||
assert_eq!(overlay_keyboard_mode(25), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_delete_shortcut_opens_confirmation_for_latest() {
|
||||
let mut app = App::new();
|
||||
app.screen = AppScreen::DrillResult;
|
||||
app.last_result = Some(test_result(2));
|
||||
app.drill_history = vec![test_result(1), test_result(2)];
|
||||
app.history_selected = 1;
|
||||
|
||||
handle_key(&mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
||||
|
||||
assert!(app.history_confirm_delete);
|
||||
assert_eq!(app.history_selected, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_delete_confirmation_yes_deletes_latest() {
|
||||
let mut app = App::new();
|
||||
app.screen = AppScreen::DrillResult;
|
||||
app.last_result = Some(test_result(3));
|
||||
let older = test_result(1);
|
||||
let newer = test_result(2);
|
||||
app.drill_history = vec![older.clone(), newer.clone()];
|
||||
|
||||
handle_key(&mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
||||
handle_key(&mut app, KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||||
|
||||
assert!(!app.history_confirm_delete);
|
||||
assert_eq!(app.drill_history.len(), 1);
|
||||
assert_eq!(app.drill_history[0].timestamp, older.timestamp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_delete_confirmation_cancel_keeps_history() {
|
||||
let mut app = App::new();
|
||||
app.screen = AppScreen::DrillResult;
|
||||
app.last_result = Some(test_result(2));
|
||||
app.drill_history = vec![test_result(1), test_result(2)];
|
||||
|
||||
handle_key(&mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
||||
handle_key(&mut app, KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
||||
|
||||
assert!(!app.history_confirm_delete);
|
||||
assert_eq!(app.drill_history.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_continue_shortcuts_start_next_drill() {
|
||||
let mut app = App::new();
|
||||
app.screen = AppScreen::DrillResult;
|
||||
app.last_result = Some(test_result(2));
|
||||
|
||||
handle_key(&mut app, KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
||||
assert_eq!(app.screen, AppScreen::Drill);
|
||||
|
||||
app.screen = AppScreen::DrillResult;
|
||||
handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert_eq!(app.screen, AppScreen::Drill);
|
||||
|
||||
app.screen = AppScreen::DrillResult;
|
||||
handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE),
|
||||
);
|
||||
assert_eq!(app.screen, AppScreen::Drill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_continue_code_uses_last_language_params() {
|
||||
let mut app = App::new();
|
||||
app.screen = AppScreen::DrillResult;
|
||||
app.last_result = Some(test_result(2));
|
||||
app.drill_mode = DrillMode::Code;
|
||||
app.config.code_downloads_enabled = false;
|
||||
app.config.code_language = "python".to_string();
|
||||
app.last_code_drill_language = Some("rust".to_string());
|
||||
|
||||
handle_key(&mut app, KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(app.screen, AppScreen::Drill);
|
||||
assert_eq!(app.drill_mode, DrillMode::Code);
|
||||
assert_eq!(app.last_code_drill_language.as_deref(), Some("rust"));
|
||||
}
|
||||
|
||||
/// Helper: count how many settings modal/edit flags are active
|
||||
fn modal_edit_count(app: &App) -> usize {
|
||||
[
|
||||
app.settings_confirm_import,
|
||||
app.settings_export_conflict,
|
||||
app.settings_editing_export_path,
|
||||
app.settings_editing_import_path,
|
||||
app.settings_editing_download_dir,
|
||||
]
|
||||
.iter()
|
||||
.filter(|&&f| f)
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_modal_invariant_enter_export_path_clears_others() {
|
||||
let mut app = App::new();
|
||||
app.screen = AppScreen::Settings;
|
||||
|
||||
// First, activate import confirmation
|
||||
app.settings_selected = 15; // Import Data
|
||||
handle_settings_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
);
|
||||
assert!(app.settings_confirm_import);
|
||||
assert!(modal_edit_count(&app) <= 1);
|
||||
|
||||
// Cancel it
|
||||
handle_settings_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
|
||||
);
|
||||
assert!(!app.settings_confirm_import);
|
||||
|
||||
// Enter export path editing
|
||||
app.settings_selected = 12; // Export Path
|
||||
handle_settings_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
);
|
||||
assert!(app.settings_editing_export_path);
|
||||
assert!(modal_edit_count(&app) <= 1);
|
||||
|
||||
// Esc out
|
||||
handle_settings_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
|
||||
);
|
||||
assert!(!app.settings_editing_export_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_modal_invariant_enter_import_path_clears_others() {
|
||||
let mut app = App::new();
|
||||
app.screen = AppScreen::Settings;
|
||||
|
||||
// Activate export path editing first
|
||||
app.settings_selected = 12;
|
||||
handle_settings_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
);
|
||||
assert!(app.settings_editing_export_path);
|
||||
|
||||
// Esc out, then enter import path editing
|
||||
handle_settings_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
|
||||
);
|
||||
app.settings_selected = 14; // Import Path
|
||||
handle_settings_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
);
|
||||
assert!(app.settings_editing_import_path);
|
||||
assert!(!app.settings_editing_export_path);
|
||||
assert!(modal_edit_count(&app) <= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_confirm_import_dialog_y_n_esc() {
|
||||
let mut app = App::new();
|
||||
app.screen = AppScreen::Settings;
|
||||
|
||||
// Trigger import confirmation
|
||||
app.settings_selected = 15;
|
||||
handle_settings_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
);
|
||||
assert!(app.settings_confirm_import);
|
||||
|
||||
// 'n' cancels
|
||||
handle_settings_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE),
|
||||
);
|
||||
assert!(!app.settings_confirm_import);
|
||||
|
||||
// Trigger again
|
||||
app.settings_selected = 15;
|
||||
handle_settings_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
);
|
||||
assert!(app.settings_confirm_import);
|
||||
|
||||
// Esc cancels
|
||||
handle_settings_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
|
||||
);
|
||||
assert!(!app.settings_confirm_import);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_status_message_dismissed_on_keypress() {
|
||||
let mut app = App::new();
|
||||
app.screen = AppScreen::Settings;
|
||||
|
||||
// Set a status message
|
||||
app.settings_status_message = Some(crate::app::StatusMessage {
|
||||
kind: StatusKind::Success,
|
||||
text: "test".to_string(),
|
||||
});
|
||||
|
||||
// Any keypress should dismiss it
|
||||
handle_settings_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
|
||||
);
|
||||
assert!(app.settings_status_message.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_rename_canonical_filename() {
|
||||
use crate::app::next_available_path;
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let base = dir.path();
|
||||
|
||||
// Create base file
|
||||
let base_path = base.join("keydr-export-2026-01-01.json");
|
||||
std::fs::write(&base_path, "{}").unwrap();
|
||||
|
||||
// First rename: picks -1
|
||||
let result = next_available_path(base_path.to_str().unwrap());
|
||||
assert!(result.ends_with("keydr-export-2026-01-01-1.json"));
|
||||
|
||||
// Create -1
|
||||
std::fs::write(base.join("keydr-export-2026-01-01-1.json"), "{}").unwrap();
|
||||
|
||||
// From base: picks -2
|
||||
let result = next_available_path(base_path.to_str().unwrap());
|
||||
assert!(result.ends_with("keydr-export-2026-01-01-2.json"));
|
||||
|
||||
// From -1 path: normalizes to base stem and picks -2
|
||||
let path_1 = base.join("keydr-export-2026-01-01-1.json");
|
||||
let result = next_available_path(path_1.to_str().unwrap());
|
||||
assert!(result.ends_with("keydr-export-2026-01-01-2.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_rename_custom_filename() {
|
||||
use crate::app::next_available_path;
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let base = dir.path();
|
||||
|
||||
let custom_path = base.join("my-backup.json");
|
||||
std::fs::write(&custom_path, "{}").unwrap();
|
||||
|
||||
let result = next_available_path(custom_path.to_str().unwrap());
|
||||
assert!(result.ends_with("my-backup-1.json"));
|
||||
|
||||
std::fs::write(base.join("my-backup-1.json"), "{}").unwrap();
|
||||
let result = next_available_path(custom_path.to_str().unwrap());
|
||||
assert!(result.ends_with("my-backup-2.json"));
|
||||
}
|
||||
}
|
||||
|
||||
fn render_result(frame: &mut ratatui::Frame, app: &App) {
|
||||
@@ -1411,6 +1787,35 @@ fn render_result(frame: &mut ratatui::Frame, app: &App) {
|
||||
let centered = ui::layout::centered_rect(60, 70, area);
|
||||
let dashboard = Dashboard::new(result, app.theme);
|
||||
frame.render_widget(dashboard, centered);
|
||||
|
||||
if app.history_confirm_delete && !app.drill_history.is_empty() {
|
||||
let colors = &app.theme.colors;
|
||||
let dialog_width = 34u16;
|
||||
let dialog_height = 5u16;
|
||||
let dialog_x = area.x + area.width.saturating_sub(dialog_width) / 2;
|
||||
let dialog_y = area.y + area.height.saturating_sub(dialog_height) / 2;
|
||||
let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
|
||||
|
||||
let idx = app.drill_history.len().saturating_sub(app.history_selected);
|
||||
let dialog_text = format!("Delete session #{idx}? (y/n)");
|
||||
|
||||
frame.render_widget(ratatui::widgets::Clear, dialog_area);
|
||||
let dialog = Paragraph::new(vec![
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
format!(" {dialog_text} "),
|
||||
Style::default().fg(colors.fg()),
|
||||
)),
|
||||
])
|
||||
.style(Style::default().bg(colors.bg()))
|
||||
.block(
|
||||
Block::bordered()
|
||||
.title(" Confirm ")
|
||||
.border_style(Style::default().fg(colors.error()))
|
||||
.style(Style::default().bg(colors.bg())),
|
||||
);
|
||||
frame.render_widget(dialog, dialog_area);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1518,15 +1923,38 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
|
||||
"Run downloader".to_string(),
|
||||
false,
|
||||
),
|
||||
(
|
||||
"Export Path".to_string(),
|
||||
app.settings_export_path.clone(),
|
||||
true, // path field
|
||||
),
|
||||
(
|
||||
"Export Data".to_string(),
|
||||
"Export now".to_string(),
|
||||
false,
|
||||
),
|
||||
(
|
||||
"Import Path".to_string(),
|
||||
app.settings_import_path.clone(),
|
||||
true, // path field
|
||||
),
|
||||
(
|
||||
"Import Data".to_string(),
|
||||
"Import now".to_string(),
|
||||
false,
|
||||
),
|
||||
];
|
||||
|
||||
let header_height = if inner.height > 0 { 1 } else { 0 };
|
||||
let footer_height = if inner.height > header_height { 1 } else { 0 };
|
||||
let field_height = inner.height.saturating_sub(header_height + footer_height);
|
||||
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(fields.len() as u16 * 3),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(header_height),
|
||||
Constraint::Length(field_height),
|
||||
Constraint::Length(footer_height),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
@@ -1536,22 +1964,33 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
|
||||
)));
|
||||
header.render(layout[0], frame.buffer_mut());
|
||||
|
||||
let row_height = 2u16;
|
||||
let visible_rows = (layout[1].height / row_height).max(1) as usize;
|
||||
let max_start = fields.len().saturating_sub(visible_rows);
|
||||
let start = app
|
||||
.settings_selected
|
||||
.saturating_sub(visible_rows.saturating_sub(1))
|
||||
.min(max_start);
|
||||
let end = (start + visible_rows).min(fields.len());
|
||||
let visible_fields = &fields[start..end];
|
||||
|
||||
let field_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(
|
||||
fields
|
||||
visible_fields
|
||||
.iter()
|
||||
.map(|_| Constraint::Length(3))
|
||||
.map(|_| Constraint::Length(row_height))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.split(layout[1]);
|
||||
|
||||
for (i, (label, value, is_path)) in fields.iter().enumerate() {
|
||||
for (row, (label, value, is_path)) in visible_fields.iter().enumerate() {
|
||||
let i = start + row;
|
||||
let is_selected = i == app.settings_selected;
|
||||
let indicator = if is_selected { " > " } else { " " };
|
||||
|
||||
let label_text = format!("{indicator}{label}:");
|
||||
let is_button = i == 7 || i == 11; // Download Code Now, Download Passages Now
|
||||
let is_button = i == 7 || i == 11 || i == 13 || i == 15;
|
||||
let value_text = if is_button {
|
||||
format!(" [ {value} ]")
|
||||
} else {
|
||||
@@ -1576,15 +2015,20 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
|
||||
colors.text_pending()
|
||||
});
|
||||
|
||||
let is_editing_this_path = is_selected && *is_path && (
|
||||
app.settings_editing_download_dir
|
||||
|| app.settings_editing_export_path
|
||||
|| app.settings_editing_import_path
|
||||
);
|
||||
let lines = if *is_path {
|
||||
let path_line = if app.settings_editing_download_dir && is_selected {
|
||||
let path_line = if is_editing_this_path {
|
||||
format!(" {value}_")
|
||||
} else {
|
||||
format!(" {value}")
|
||||
};
|
||||
vec![
|
||||
Line::from(Span::styled(
|
||||
if app.settings_editing_download_dir && is_selected {
|
||||
if is_editing_this_path {
|
||||
format!("{indicator}{label}: (editing)")
|
||||
} else {
|
||||
label_text
|
||||
@@ -1599,25 +2043,130 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
|
||||
Line::from(Span::styled(value_text, value_style)),
|
||||
]
|
||||
};
|
||||
Paragraph::new(lines).render(field_layout[i], frame.buffer_mut());
|
||||
Paragraph::new(lines).render(field_layout[row], frame.buffer_mut());
|
||||
}
|
||||
|
||||
let footer_hints: Vec<&str> = if app.settings_editing_download_dir {
|
||||
let any_path_editing = app.settings_editing_download_dir
|
||||
|| app.settings_editing_export_path
|
||||
|| app.settings_editing_import_path;
|
||||
let footer_hints: Vec<&str> = if any_path_editing {
|
||||
vec!["Editing path:", "[Type/Backspace] Modify", "[ESC] Done editing"]
|
||||
} else {
|
||||
vec![
|
||||
"[ESC] Save & back",
|
||||
"[Enter/arrows] Change value",
|
||||
"[Enter on path] Edit dir",
|
||||
"[Enter on path] Edit",
|
||||
]
|
||||
};
|
||||
let footer_lines: Vec<Line> = pack_hint_lines(&footer_hints, layout[3].width as usize)
|
||||
let footer_lines: Vec<Line> = pack_hint_lines(&footer_hints, layout[2].width as usize)
|
||||
.into_iter()
|
||||
.map(|line| Line::from(Span::styled(line, Style::default().fg(colors.accent()))))
|
||||
.collect();
|
||||
Paragraph::new(footer_lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(layout[3], frame.buffer_mut());
|
||||
.render(layout[2], frame.buffer_mut());
|
||||
|
||||
// --- Overlay dialogs (rendered on top of settings) ---
|
||||
|
||||
// Status message takes highest priority
|
||||
if let Some(ref msg) = app.settings_status_message {
|
||||
let border_color = match msg.kind {
|
||||
StatusKind::Success => colors.accent(),
|
||||
StatusKind::Error => colors.error(),
|
||||
};
|
||||
let title = match msg.kind {
|
||||
StatusKind::Success => " Success ",
|
||||
StatusKind::Error => " Error ",
|
||||
};
|
||||
let dialog_width = 56u16.min(area.width.saturating_sub(4));
|
||||
let dialog_height = 6u16;
|
||||
let dialog_x = area.x + area.width.saturating_sub(dialog_width) / 2;
|
||||
let dialog_y = area.y + area.height.saturating_sub(dialog_height) / 2;
|
||||
let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
|
||||
|
||||
frame.render_widget(ratatui::widgets::Clear, dialog_area);
|
||||
let dialog = Paragraph::new(vec![
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
format!(" {} ", msg.text),
|
||||
Style::default().fg(colors.fg()),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
" Press any key",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)),
|
||||
])
|
||||
.wrap(Wrap { trim: false })
|
||||
.style(Style::default().bg(colors.bg()))
|
||||
.block(
|
||||
Block::bordered()
|
||||
.title(title)
|
||||
.border_style(Style::default().fg(border_color))
|
||||
.style(Style::default().bg(colors.bg())),
|
||||
);
|
||||
frame.render_widget(dialog, dialog_area);
|
||||
} else if app.settings_export_conflict {
|
||||
let dialog_width = 52u16.min(area.width.saturating_sub(4));
|
||||
let dialog_height = 6u16;
|
||||
let dialog_x = area.x + area.width.saturating_sub(dialog_width) / 2;
|
||||
let dialog_y = area.y + area.height.saturating_sub(dialog_height) / 2;
|
||||
let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
|
||||
|
||||
frame.render_widget(ratatui::widgets::Clear, dialog_area);
|
||||
let dialog = Paragraph::new(vec![
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
" A file already exists at this path.",
|
||||
Style::default().fg(colors.fg()),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
" [d] Overwrite [r] Rename [Esc] Cancel",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)),
|
||||
])
|
||||
.style(Style::default().bg(colors.bg()))
|
||||
.block(
|
||||
Block::bordered()
|
||||
.title(" File Exists ")
|
||||
.border_style(Style::default().fg(colors.error()))
|
||||
.style(Style::default().bg(colors.bg())),
|
||||
);
|
||||
frame.render_widget(dialog, dialog_area);
|
||||
} else if app.settings_confirm_import {
|
||||
let dialog_width = 52u16.min(area.width.saturating_sub(4));
|
||||
let dialog_height = 7u16;
|
||||
let dialog_x = area.x + area.width.saturating_sub(dialog_width) / 2;
|
||||
let dialog_y = area.y + area.height.saturating_sub(dialog_height) / 2;
|
||||
let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
|
||||
|
||||
frame.render_widget(ratatui::widgets::Clear, dialog_area);
|
||||
let dialog = Paragraph::new(vec![
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
" This will erase your current data.",
|
||||
Style::default().fg(colors.fg()),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
" Export first if you want to keep it.",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
" Proceed? (y/n)",
|
||||
Style::default().fg(colors.fg()),
|
||||
)),
|
||||
])
|
||||
.style(Style::default().bg(colors.bg()))
|
||||
.block(
|
||||
Block::bordered()
|
||||
.title(" Confirm Import ")
|
||||
.border_style(Style::default().fg(colors.error()))
|
||||
.style(Style::default().bg(colors.bg())),
|
||||
);
|
||||
frame.render_widget(dialog, dialog_area);
|
||||
}
|
||||
}
|
||||
|
||||
fn wrapped_line_count(text: &str, width: usize) -> usize {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::keyboard::display::BACKSPACE;
|
||||
use crate::session::drill::DrillState;
|
||||
use crate::session::input::KeystrokeEvent;
|
||||
|
||||
@@ -52,17 +53,50 @@ impl DrillResult {
|
||||
ranked: bool,
|
||||
partial: bool,
|
||||
) -> Self {
|
||||
let per_key_times: Vec<KeyTime> = events
|
||||
.windows(2)
|
||||
.map(|pair| {
|
||||
let dt = pair[1].timestamp.duration_since(pair[0].timestamp);
|
||||
KeyTime {
|
||||
key: pair[1].expected,
|
||||
time_ms: dt.as_secs_f64() * 1000.0,
|
||||
correct: pair[1].correct,
|
||||
let mut per_key_times: Vec<KeyTime> = Vec::new();
|
||||
let mut pending_backspace = false;
|
||||
for pair in events.windows(2) {
|
||||
let prev = &pair[0];
|
||||
let curr = &pair[1];
|
||||
let dt = curr.timestamp.duration_since(prev.timestamp).as_secs_f64() * 1000.0;
|
||||
|
||||
// Track per-key expected-char timing/accuracy for normal typing keys.
|
||||
// Backspace attempts are tracked separately below.
|
||||
if curr.actual != BACKSPACE {
|
||||
per_key_times.push(KeyTime {
|
||||
key: curr.expected,
|
||||
time_ms: dt,
|
||||
correct: curr.correct,
|
||||
});
|
||||
}
|
||||
|
||||
// Backspace attempt tracking:
|
||||
// - Any incorrect non-backspace key creates a pending backspace need.
|
||||
// - While pending, every next key press is a backspace attempt.
|
||||
// - Backspace press = correct attempt; anything else = incorrect attempt
|
||||
// and the requirement stays pending.
|
||||
if pending_backspace {
|
||||
if curr.actual == BACKSPACE {
|
||||
per_key_times.push(KeyTime {
|
||||
key: BACKSPACE,
|
||||
time_ms: dt,
|
||||
correct: true,
|
||||
});
|
||||
pending_backspace = false;
|
||||
} else {
|
||||
per_key_times.push(KeyTime {
|
||||
key: BACKSPACE,
|
||||
time_ms: dt,
|
||||
correct: false,
|
||||
});
|
||||
pending_backspace = true;
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
if curr.actual != BACKSPACE && !curr.correct {
|
||||
pending_backspace = true;
|
||||
}
|
||||
}
|
||||
|
||||
let total_chars = drill.target.len();
|
||||
let typo_count = drill.typo_flags.len();
|
||||
@@ -89,3 +123,65 @@ impl DrillResult {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn ev(expected: char, actual: char, ms: u64, correct: bool, start: Instant) -> KeystrokeEvent {
|
||||
KeystrokeEvent {
|
||||
expected,
|
||||
actual,
|
||||
timestamp: start + Duration::from_millis(ms),
|
||||
correct,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracks_backspace_success_after_incorrect_key() {
|
||||
let drill = DrillState::new("ab");
|
||||
let t0 = Instant::now();
|
||||
let events = vec![
|
||||
ev('a', 'a', 0, true, t0),
|
||||
ev('b', 'x', 100, false, t0),
|
||||
ev(BACKSPACE, BACKSPACE, 220, true, t0),
|
||||
ev('b', 'b', 350, true, t0),
|
||||
];
|
||||
|
||||
let result = DrillResult::from_drill(&drill, &events, "adaptive", true, false);
|
||||
let backspace: Vec<&KeyTime> = result
|
||||
.per_key_times
|
||||
.iter()
|
||||
.filter(|kt| kt.key == BACKSPACE)
|
||||
.collect();
|
||||
assert_eq!(backspace.len(), 1);
|
||||
assert!(backspace[0].correct);
|
||||
assert!((backspace[0].time_ms - 120.0).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracks_backspace_error_until_user_backspaces() {
|
||||
let drill = DrillState::new("abc");
|
||||
let t0 = Instant::now();
|
||||
let events = vec![
|
||||
ev('a', 'a', 0, true, t0),
|
||||
ev('b', 'x', 100, false, t0),
|
||||
ev('c', 'c', 220, true, t0),
|
||||
ev(BACKSPACE, BACKSPACE, 400, true, t0),
|
||||
];
|
||||
|
||||
let result = DrillResult::from_drill(&drill, &events, "adaptive", true, false);
|
||||
let backspace: Vec<&KeyTime> = result
|
||||
.per_key_times
|
||||
.iter()
|
||||
.filter(|kt| kt.key == BACKSPACE)
|
||||
.collect();
|
||||
assert_eq!(backspace.len(), 2);
|
||||
assert!(!backspace[0].correct);
|
||||
assert!(backspace[1].correct);
|
||||
assert!((backspace[0].time_ms - 120.0).abs() < 0.1);
|
||||
assert!((backspace[1].time_ms - 180.0).abs() < 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,14 @@ use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Result, bail};
|
||||
use chrono::Utc;
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
|
||||
use crate::store::schema::{DrillHistoryData, KeyStatsData, ProfileData};
|
||||
use crate::config::Config;
|
||||
use crate::store::schema::{
|
||||
DrillHistoryData, ExportData, KeyStatsData, ProfileData, EXPORT_VERSION,
|
||||
};
|
||||
|
||||
pub struct JsonStore {
|
||||
base_dir: PathBuf,
|
||||
@@ -20,6 +24,12 @@ impl JsonStore {
|
||||
Ok(Self { base_dir })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn with_base_dir(base_dir: PathBuf) -> Result<Self> {
|
||||
fs::create_dir_all(&base_dir)?;
|
||||
Ok(Self { base_dir })
|
||||
}
|
||||
|
||||
fn file_path(&self, name: &str) -> PathBuf {
|
||||
self.base_dir.join(name)
|
||||
}
|
||||
@@ -89,4 +99,295 @@ impl JsonStore {
|
||||
pub fn save_drill_history(&self, data: &DrillHistoryData) -> Result<()> {
|
||||
self.save("lesson_history.json", data)
|
||||
}
|
||||
|
||||
/// Bundle all persisted data + config into an ExportData struct.
|
||||
pub fn export_all(&self, config: &Config) -> ExportData {
|
||||
let profile = self.load_profile().unwrap_or_default();
|
||||
let key_stats = self.load_key_stats();
|
||||
let ranked_key_stats = self.load_ranked_key_stats();
|
||||
let drill_history = self.load_drill_history();
|
||||
|
||||
ExportData {
|
||||
keydr_export_version: EXPORT_VERSION,
|
||||
exported_at: Utc::now(),
|
||||
config: config.clone(),
|
||||
profile,
|
||||
key_stats,
|
||||
ranked_key_stats,
|
||||
drill_history,
|
||||
}
|
||||
}
|
||||
|
||||
/// Transactional import: two-phase commit with best-effort .bak rollback.
|
||||
///
|
||||
/// Stage phase: write all data to .tmp files. If any fails, clean up and bail.
|
||||
/// Commit phase: for each file, rename original to .bak, then .tmp to final.
|
||||
/// On commit failure, attempt to restore .bak files and clean up .tmp files.
|
||||
/// After success, delete .bak files.
|
||||
pub fn import_all(&self, data: &ExportData) -> Result<()> {
|
||||
if data.keydr_export_version != EXPORT_VERSION {
|
||||
bail!(
|
||||
"Unsupported export version: {} (expected {})",
|
||||
data.keydr_export_version,
|
||||
EXPORT_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
let files: Vec<(&str, String)> = vec![
|
||||
("profile.json", serde_json::to_string_pretty(&data.profile)?),
|
||||
("key_stats.json", serde_json::to_string_pretty(&data.key_stats)?),
|
||||
("key_stats_ranked.json", serde_json::to_string_pretty(&data.ranked_key_stats)?),
|
||||
("lesson_history.json", serde_json::to_string_pretty(&data.drill_history)?),
|
||||
];
|
||||
|
||||
// Stage phase: write .tmp files
|
||||
let mut staged: Vec<PathBuf> = Vec::new();
|
||||
for (name, json) in &files {
|
||||
let tmp_path = self.file_path(name).with_extension("json.tmp");
|
||||
match (|| -> Result<()> {
|
||||
let mut file = fs::File::create(&tmp_path)?;
|
||||
file.write_all(json.as_bytes())?;
|
||||
file.sync_all()?;
|
||||
Ok(())
|
||||
})() {
|
||||
Ok(()) => staged.push(tmp_path),
|
||||
Err(e) => {
|
||||
// Clean up staged .tmp files
|
||||
for tmp in &staged {
|
||||
let _ = fs::remove_file(tmp);
|
||||
}
|
||||
bail!("Import failed during staging: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Commit phase: .bak then rename .tmp to final
|
||||
// Track (final_path, had_original) so rollback can restore absence
|
||||
let mut committed: Vec<(PathBuf, PathBuf, bool)> = Vec::new();
|
||||
for (i, (name, _)) in files.iter().enumerate() {
|
||||
let final_path = self.file_path(name);
|
||||
let bak_path = self.file_path(name).with_extension("json.bak");
|
||||
let tmp_path = &staged[i];
|
||||
let had_original = final_path.exists();
|
||||
|
||||
// Back up existing file if it exists
|
||||
if had_original
|
||||
&& let Err(e) = fs::rename(&final_path, &bak_path)
|
||||
{
|
||||
// Rollback: restore already committed files
|
||||
for (committed_final, committed_bak, committed_had) in &committed {
|
||||
if *committed_had {
|
||||
let _ = fs::rename(committed_bak, committed_final);
|
||||
} else {
|
||||
let _ = fs::remove_file(committed_final);
|
||||
}
|
||||
}
|
||||
// Clean up all .tmp files
|
||||
for tmp in &staged {
|
||||
let _ = fs::remove_file(tmp);
|
||||
}
|
||||
bail!("Import failed during commit (backup): {e}");
|
||||
}
|
||||
|
||||
// Rename .tmp to final
|
||||
if let Err(e) = fs::rename(tmp_path, &final_path) {
|
||||
// Restore this file's backup or remove if it didn't exist
|
||||
if had_original && bak_path.exists() {
|
||||
let _ = fs::rename(&bak_path, &final_path);
|
||||
} else {
|
||||
let _ = fs::remove_file(&final_path);
|
||||
}
|
||||
// Rollback previously committed files
|
||||
for (committed_final, committed_bak, committed_had) in &committed {
|
||||
if *committed_had {
|
||||
let _ = fs::rename(committed_bak, committed_final);
|
||||
} else {
|
||||
let _ = fs::remove_file(committed_final);
|
||||
}
|
||||
}
|
||||
// Clean up remaining .tmp files
|
||||
for tmp in &staged[i + 1..] {
|
||||
let _ = fs::remove_file(tmp);
|
||||
}
|
||||
bail!("Import failed during commit (rename): {e}");
|
||||
}
|
||||
|
||||
committed.push((final_path, bak_path, had_original));
|
||||
}
|
||||
|
||||
// Success: clean up .bak files
|
||||
for (_, bak_path, had_original) in &committed {
|
||||
if *had_original {
|
||||
let _ = fs::remove_file(bak_path);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check for leftover .bak files from an interrupted import.
|
||||
/// Returns true if recovery files were found (and cleaned up).
|
||||
pub fn check_interrupted_import(&self) -> bool {
|
||||
let bak_names = [
|
||||
"profile.json.bak",
|
||||
"key_stats.json.bak",
|
||||
"key_stats_ranked.json.bak",
|
||||
"lesson_history.json.bak",
|
||||
];
|
||||
let mut found = false;
|
||||
for name in &bak_names {
|
||||
let bak_path = self.base_dir.join(name);
|
||||
if bak_path.exists() {
|
||||
found = true;
|
||||
let _ = fs::remove_file(&bak_path);
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::store::schema::EXPORT_VERSION;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_test_store() -> (TempDir, JsonStore) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store = JsonStore::with_base_dir(dir.path().to_path_buf()).unwrap();
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
fn make_test_export(config: &Config) -> ExportData {
|
||||
ExportData {
|
||||
keydr_export_version: EXPORT_VERSION,
|
||||
exported_at: Utc::now(),
|
||||
config: config.clone(),
|
||||
profile: ProfileData::default(),
|
||||
key_stats: KeyStatsData::default(),
|
||||
ranked_key_stats: KeyStatsData::default(),
|
||||
drill_history: DrillHistoryData::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_round_trip_export_import() {
|
||||
let (_dir, store) = make_test_store();
|
||||
let config = Config::default();
|
||||
|
||||
// Save some initial data
|
||||
store.save_profile(&ProfileData::default()).unwrap();
|
||||
|
||||
let export = store.export_all(&config);
|
||||
assert_eq!(export.keydr_export_version, EXPORT_VERSION);
|
||||
|
||||
// Create a second store and import into it
|
||||
let (_dir2, store2) = make_test_store();
|
||||
store2.import_all(&export).unwrap();
|
||||
|
||||
// Verify data matches
|
||||
let imported_profile = store2.load_profile().unwrap();
|
||||
assert_eq!(imported_profile.total_drills, export.profile.total_drills);
|
||||
assert!((imported_profile.total_score - export.profile.total_score).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_rejection() {
|
||||
let (_dir, store) = make_test_store();
|
||||
let config = Config::default();
|
||||
let mut export = make_test_export(&config);
|
||||
export.keydr_export_version = 99;
|
||||
|
||||
let result = store.import_all(&export);
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(err_msg.contains("Unsupported export version"));
|
||||
assert!(err_msg.contains("99"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_clamps_values() {
|
||||
let mut config = Config::default();
|
||||
config.target_wpm = 0;
|
||||
config.word_count = 999;
|
||||
config.code_language = "nonexistent".to_string();
|
||||
|
||||
let valid_keys = vec!["rust", "python", "javascript"];
|
||||
config.validate(&valid_keys);
|
||||
|
||||
assert_eq!(config.target_wpm, 10);
|
||||
assert_eq!(config.word_count, 100);
|
||||
assert_eq!(config.code_language, "rust"); // falls back to default
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_staging_failure_preserves_originals() {
|
||||
let (_dir, store) = make_test_store();
|
||||
|
||||
// Save known good data
|
||||
let mut profile = ProfileData::default();
|
||||
profile.total_drills = 42;
|
||||
store.save_profile(&profile).unwrap();
|
||||
let original_content = fs::read_to_string(store.file_path("profile.json")).unwrap();
|
||||
|
||||
// Now create a store that points to a nonexistent subdir of the same tmpdir
|
||||
// so that staging .tmp writes will fail
|
||||
let bad_dir = _dir.path().join("nonexistent_subdir");
|
||||
let bad_store = JsonStore { base_dir: bad_dir.clone() };
|
||||
let config = Config::default();
|
||||
let export = make_test_export(&config);
|
||||
let result = bad_store.import_all(&export);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Import failed during staging"));
|
||||
|
||||
// Original file in the real store is unchanged
|
||||
let after_content = fs::read_to_string(store.file_path("profile.json")).unwrap();
|
||||
assert_eq!(original_content, after_content);
|
||||
|
||||
// No .tmp files left in the bad dir (dir doesn't exist, so nothing to clean)
|
||||
assert!(!bad_dir.exists());
|
||||
|
||||
// No .tmp files left in the real store dir either
|
||||
let tmp_files: Vec<_> = fs::read_dir(_dir.path())
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("tmp"))
|
||||
.collect();
|
||||
assert!(tmp_files.is_empty(), "no residual .tmp files");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_into_empty_store_then_verify_files_created() {
|
||||
let (_dir, store) = make_test_store();
|
||||
|
||||
// No files initially
|
||||
assert!(!store.file_path("profile.json").exists());
|
||||
|
||||
let config = Config::default();
|
||||
let export = make_test_export(&config);
|
||||
store.import_all(&export).unwrap();
|
||||
|
||||
// All files should now exist
|
||||
assert!(store.file_path("profile.json").exists());
|
||||
assert!(store.file_path("key_stats.json").exists());
|
||||
assert!(store.file_path("key_stats_ranked.json").exists());
|
||||
assert!(store.file_path("lesson_history.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_interrupted_import_detects_bak_files() {
|
||||
let (_dir, store) = make_test_store();
|
||||
|
||||
// No .bak files initially
|
||||
assert!(!store.check_interrupted_import());
|
||||
|
||||
// Create a .bak file
|
||||
fs::write(store.file_path("profile.json.bak"), "{}").unwrap();
|
||||
assert!(store.check_interrupted_import());
|
||||
|
||||
// Should have been cleaned up
|
||||
assert!(!store.file_path("profile.json.bak").exists());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::engine::key_stats::KeyStatsStore;
|
||||
use crate::engine::skill_tree::SkillTreeProgress;
|
||||
use crate::session::result::DrillResult;
|
||||
@@ -69,3 +71,16 @@ impl Default for DrillHistoryData {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const EXPORT_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ExportData {
|
||||
pub keydr_export_version: u32,
|
||||
pub exported_at: DateTime<Utc>,
|
||||
pub config: Config,
|
||||
pub profile: ProfileData,
|
||||
pub key_stats: KeyStatsData,
|
||||
pub ranked_key_stats: KeyStatsData,
|
||||
pub drill_history: DrillHistoryData,
|
||||
}
|
||||
|
||||
@@ -115,9 +115,14 @@ impl Widget for Dashboard<'_> {
|
||||
Paragraph::new(chars_line).render(layout[4], buf);
|
||||
|
||||
let help = Paragraph::new(Line::from(vec![
|
||||
Span::styled(" [r] Retry ", Style::default().fg(colors.accent())),
|
||||
Span::styled(
|
||||
" [c/Enter/Space] Continue ",
|
||||
Style::default().fg(colors.accent()),
|
||||
),
|
||||
Span::styled("[r] Retry ", Style::default().fg(colors.accent())),
|
||||
Span::styled("[q] Menu ", Style::default().fg(colors.accent())),
|
||||
Span::styled("[s] Stats", Style::default().fg(colors.accent())),
|
||||
Span::styled("[s] Stats ", Style::default().fg(colors.accent())),
|
||||
Span::styled("[x] Delete", Style::default().fg(colors.accent())),
|
||||
]));
|
||||
help.render(layout[6], buf);
|
||||
}
|
||||
|
||||
@@ -382,13 +382,34 @@ impl KeyboardDiagram<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
// Compute full keyboard width from rendered rows (including trailing modifier keys),
|
||||
// so the space bar centers relative to the keyboard, not the container.
|
||||
let keyboard_width = self
|
||||
.model
|
||||
.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(row_idx, row)| {
|
||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||
let row_end = offset + row.len() as u16 * key_width;
|
||||
match row_idx {
|
||||
0 => row_end + 6, // [Bksp]
|
||||
2 => row_end + 7, // [Enter]
|
||||
3 => row_end + 6, // [Shft]
|
||||
_ => row_end,
|
||||
}
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.min(inner.width);
|
||||
|
||||
// Space bar row (row 4)
|
||||
let space_y = inner.y + 4;
|
||||
if space_y < inner.y + inner.height {
|
||||
let space_name = display::key_display_name(SPACE);
|
||||
let space_label = format!("[ {space_name} ]");
|
||||
let space_width = space_label.len() as u16;
|
||||
let space_x = inner.x + (inner.width.saturating_sub(space_width)) / 2;
|
||||
let space_x = inner.x + (keyboard_width.saturating_sub(space_width)) / 2;
|
||||
if space_x + space_width <= inner.x + inner.width {
|
||||
let is_dep = self.depressed_keys.contains(&SPACE);
|
||||
let is_next = self.next_key == Some(SPACE);
|
||||
|
||||
@@ -966,7 +966,8 @@ impl StatsDashboard<'_> {
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
let label = format!(" {ch} {time:>4.0}ms ");
|
||||
let key_name = display_key_short_fixed(*ch);
|
||||
let label = format!(" {key_name} {time:>4.0}ms ");
|
||||
let label_len = label.len() as u16;
|
||||
buf.set_string(inner.x, y, &label, Style::default().fg(colors.error()));
|
||||
let bar_space = inner.width.saturating_sub(label_len) as usize;
|
||||
@@ -1013,7 +1014,8 @@ impl StatsDashboard<'_> {
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
let label = format!(" {ch} {time:>4.0}ms ");
|
||||
let key_name = display_key_short_fixed(*ch);
|
||||
let label = format!(" {key_name} {time:>4.0}ms ");
|
||||
let label_len = label.len() as u16;
|
||||
buf.set_string(inner.x, y, &label, Style::default().fg(colors.success()));
|
||||
let bar_space = inner.width.saturating_sub(label_len) as usize;
|
||||
@@ -1056,6 +1058,7 @@ impl StatsDashboard<'_> {
|
||||
all_keys.insert(SPACE);
|
||||
all_keys.insert(TAB);
|
||||
all_keys.insert(ENTER);
|
||||
all_keys.insert(BACKSPACE);
|
||||
|
||||
let mut key_accuracies: Vec<(char, f64)> = all_keys
|
||||
.into_iter()
|
||||
@@ -1091,7 +1094,8 @@ impl StatsDashboard<'_> {
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
let label = format!(" {ch} {acc:>5.1}% ");
|
||||
let key_name = display_key_short_fixed(*ch);
|
||||
let label = format!(" {key_name} {acc:>5.1}% ");
|
||||
let label_len = label.len() as u16;
|
||||
let color = if *acc >= 95.0 {
|
||||
colors.warning()
|
||||
@@ -1132,6 +1136,7 @@ impl StatsDashboard<'_> {
|
||||
all_keys.insert(SPACE);
|
||||
all_keys.insert(TAB);
|
||||
all_keys.insert(ENTER);
|
||||
all_keys.insert(BACKSPACE);
|
||||
|
||||
let mut key_accuracies: Vec<(char, f64)> = all_keys
|
||||
.into_iter()
|
||||
@@ -1166,7 +1171,8 @@ impl StatsDashboard<'_> {
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
let label = format!(" {ch} {acc:>5.1}% ");
|
||||
let key_name = display_key_short_fixed(*ch);
|
||||
let label = format!(" {key_name} {acc:>5.1}% ");
|
||||
let label_len = label.len() as u16;
|
||||
let color = if *acc >= 98.0 {
|
||||
colors.success()
|
||||
@@ -1308,6 +1314,16 @@ fn required_kbd_width(key_width: u16, key_step: u16) -> u16 {
|
||||
max_offset + 12 * key_step + key_width
|
||||
}
|
||||
|
||||
fn display_key_short_fixed(ch: char) -> String {
|
||||
let special = display::key_short_label(ch);
|
||||
let raw = if special.is_empty() {
|
||||
ch.to_string()
|
||||
} else {
|
||||
special.to_string()
|
||||
};
|
||||
format!("{raw:<4}")
|
||||
}
|
||||
|
||||
fn compute_streaks(active_days: &BTreeSet<chrono::NaiveDate>) -> (usize, usize) {
|
||||
if active_days.is_empty() {
|
||||
return (0, 0);
|
||||
|
||||
@@ -12,6 +12,7 @@ pub struct StatsSidebar<'a> {
|
||||
drill: &'a DrillState,
|
||||
last_result: Option<&'a DrillResult>,
|
||||
history: &'a [DrillResult],
|
||||
target_wpm: u32,
|
||||
theme: &'a Theme,
|
||||
}
|
||||
|
||||
@@ -20,12 +21,14 @@ impl<'a> StatsSidebar<'a> {
|
||||
drill: &'a DrillState,
|
||||
last_result: Option<&'a DrillResult>,
|
||||
history: &'a [DrillResult],
|
||||
target_wpm: u32,
|
||||
theme: &'a Theme,
|
||||
) -> Self {
|
||||
Self {
|
||||
drill,
|
||||
last_result,
|
||||
history,
|
||||
target_wpm,
|
||||
theme,
|
||||
}
|
||||
}
|
||||
@@ -82,6 +85,13 @@ impl Widget for StatsSidebar<'_> {
|
||||
Span::styled("WPM: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(wpm_str, Style::default().fg(colors.accent())),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled("Target: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
format!("{} WPM", self.target_wpm),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled("Accuracy: ", Style::default().fg(colors.fg())),
|
||||
|
||||
Reference in New Issue
Block a user