From 9deffc3d1dcb162d7e34d2a412166f0a427035bb Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Sun, 22 Feb 2026 07:36:34 +0000 Subject: [PATCH] Import/export feature for config and data --- Cargo.lock | 1 + Cargo.toml | 3 + .../2026-02-20-import-export-feature-plan.md | 188 +++++ src/app.rs | 322 +++++++- src/config.rs | 9 +- src/engine/skill_tree.rs | 23 +- src/generator/phonetic.rs | 59 +- src/main.rs | 735 +++++++++++++++--- src/session/result.rs | 116 ++- src/store/json_store.rs | 305 +++++++- src/store/schema.rs | 15 + src/ui/components/dashboard.rs | 9 +- src/ui/components/keyboard_diagram.rs | 23 +- src/ui/components/stats_dashboard.rs | 24 +- src/ui/components/stats_sidebar.rs | 10 + 15 files changed, 1717 insertions(+), 125 deletions(-) create mode 100644 docs/plans/2026-02-20-import-export-feature-plan.md diff --git a/Cargo.lock b/Cargo.lock index cf487b6..a93b037 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1088,6 +1088,7 @@ dependencies = [ "rust-embed", "serde", "serde_json", + "tempfile", "thiserror 2.0.18", "toml", ] diff --git a/Cargo.toml b/Cargo.toml index 5d87005..9ff9109 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,9 @@ anyhow = "1.0" thiserror = "2.0" reqwest = { version = "0.12", features = ["blocking"], optional = true } +[dev-dependencies] +tempfile = "3" + [features] default = ["network"] network = ["reqwest"] diff --git a/docs/plans/2026-02-20-import-export-feature-plan.md b/docs/plans/2026-02-20-import-export-feature-plan.md new file mode 100644 index 0000000..55b7704 --- /dev/null +++ b/docs/plans/2026-02-20-import-export-feature-plan.md @@ -0,0 +1,188 @@ +# Import/Export Feature Plan + +## Context + +Users need a way to back up and transfer their keydr data between machines. Currently, data is spread across `~/.config/keydr/config.toml` (config) and `~/.local/share/keydr/*.json` (profile, key stats, drill history). This feature adds Export and Import actions to the Settings page, producing/consuming a single combined JSON file. + +## Export Format + +Canonical filename: `keydr-export-2026-02-21.json` (date is `Utc::now()`). + +```json +{ + "keydr_export_version": 1, + "exported_at": "2026-02-21T12:00:00Z", + "config": { ... }, + "profile": { ... }, + "key_stats": { ... }, + "ranked_key_stats": { ... }, + "drill_history": { ... } +} +``` + +- `exported_at` uses `DateTime` (chrono, serialized as RFC3339). +- On import, `keydr_export_version` is checked: if it does not equal the current supported version (1), import is rejected with the error `"Unsupported export version: {v} (expected 1)"`. Future versions can add migration functions as needed. + +## Import Scope + +Import applies **everything except machine-local path fields**: +- **Imported**: target_wpm, theme, keyboard_layout, word_count, code_language, passage_book, download toggle booleans, snippets_per_repo, paragraphs_per_book, onboarding flags, and all progress data (profile, key stats, drill history). +- **Preserved from current config**: `code_download_dir`, `passage_download_dir` (machine-local paths stay as-is). +- Theme and keyboard_layout are imported as-is. If the imported theme is unavailable on the target machine, `Theme::load()` falls back to `terminal-default` and the success message includes a note: `"Imported successfully (theme '{name}' not found, using default)"`. + +## Changes + +### 1. Add export data struct (`src/store/schema.rs`) + +Add an `ExportData` struct with all the fields above, deriving `Serialize`/`Deserialize`. Include `keydr_export_version: u32` and `exported_at: DateTime` metadata fields. + +### 2. Add export/import methods to `JsonStore` (`src/store/json_store.rs`) + +- `export_all(&self, config: &Config) -> Result` — loads all data files and bundles with config into `ExportData`. +- `import_all(&self, data: &ExportData) -> Result<()>` — **transactional two-phase write** with best-effort rollback: + 1. **Stage phase**: write each data file to a `.tmp` sibling (profile.json.tmp, key_stats.json.tmp, etc.). If any `.tmp` write fails, delete all `.tmp` files created so far and return an error. Originals are untouched. + 2. **Commit phase**: for each file, rename the existing original to `.bak`, then rename `.tmp` to final. If any rename fails mid-sequence, **rollback**: restore all `.bak` files back to their original names and clean up remaining `.tmp` files. After successful commit, delete all `.bak` files. + + **Contract**: this is best-effort, not strictly atomic. If the process is killed or the disk fails during the commit phase, `.bak` files may be left behind. On next app startup, if `.bak` files are detected in the data directory, show a warning in the status message: `"Recovery files found from interrupted import. Data may be inconsistent — consider re-importing."` and clean up the `.bak` files. + +### 3. Add config validation on import (`src/config.rs`) + +Add a `Config::validate(&mut self, valid_language_keys: &[&str])` method that: +- Clamps `target_wpm` to 10..=200 +- Clamps `word_count` to 5..=100 +- Calls `normalize_code_language()` for code language validation +- Falls back to defaults for unrecognized theme names (via `Theme::load()` fallback, already handled) + +This is called after merging imported config fields, before saving. + +### 4. Add status message enum and app state fields (`src/app.rs`) + +Add a structured status type: +```rust +pub enum StatusKind { Success, Error } +pub struct StatusMessage { pub kind: StatusKind, pub text: String } +``` + +New fields on `App`: +- `pub settings_confirm_import: bool` — controls the import warning dialog +- `pub settings_export_conflict: bool` — controls the export overwrite conflict dialog +- `pub settings_status_message: Option` — transient status, cleared on next keypress +- `pub settings_export_path: String` — editable export destination path +- `pub settings_import_path: String` — editable import source path +- `pub settings_editing_export_path: bool` — whether export path is being edited +- `pub settings_editing_import_path: bool` — whether import path is being edited + +**Invariant**: at most one modal/edit state is active at a time. When entering any modal (confirm_import, export_conflict) or edit mode, clear all other modal/edit flags first. + +Default export path: `dirs::download_dir()` / `keydr-export-{YYYY-MM-DD}.json`. +Default import path: same canonical filename (`dirs::download_dir()` / `keydr-export-{YYYY-MM-DD}.json`), editable. + +If `dirs::download_dir()` returns `None`, fall back to `dirs::home_dir()`, then `"."`. On export, if the parent directory of the target path doesn't exist, return an error `"Directory does not exist: {parent}"` rather than silently creating it. + +### 5. Add app methods (`src/app.rs`) + +- `export_data()` — builds `ExportData` from current state, writes JSON to `settings_export_path` via **atomic write** (write to `.tmp` in same directory, then rename to final path). If file already exists at that path, sets `settings_export_conflict = true` instead of writing. Sets `StatusMessage` on success/error. +- `export_data_overwrite()` — calls the same atomic-write logic without the existence check. The rename atomically replaces the old file; no pre-delete needed. +- `export_data_rename()` — delegates to `next_available_path()`, a free function that implements **conditional suffix normalization**: strips a trailing `-N` suffix only when the base file (without suffix) exists in the same directory. This prevents accidental stripping of intrinsic name components (e.g. date segments like `-01`). Then scans for the lowest unused `-N` suffix. Works for any filename. E.g. if `my-backup.json` and `my-backup-1.json` exist, picks `my-backup-2.json`. If called with `my-backup-1.json` (and `my-backup.json` exists), normalizes to `my-backup` then picks `-2`. Updates `settings_export_path` and writes via atomic write. +- `import_data()` — reads file at `settings_import_path`, validates `keydr_export_version` (reject if != 1 with error message), calls `store.import_all()`, then reloads all in-memory state (config with path fields preserved, profile, key_stats, ranked_key_stats, drill_history, skill_tree). Calls `Config::validate()` and `Config::save()`. Checks if imported theme loaded successfully and appends fallback note to success message if not. Sets `StatusMessage` on success/error. + +### 6. Add settings entries (`src/main.rs` — `render_settings`) + +Add four new rows at the bottom of the settings field list: + +- **"Export Path"** — editable path field, shows `settings_export_path` (same pattern as Code Download Dir) +- **"Export Data"** — action button, label: `"Export now"` +- **"Import Path"** — editable path field, shows `settings_import_path` +- **"Import Data"** — action button, label: `"Import now"` + +Update `MAX_SETTINGS` accordingly in `handle_settings_key`. + +### 7. Handle key input (`src/main.rs` — `handle_settings_key`) + +**Priority order at top of function:** + +1. If `settings_status_message.is_some()` — any keypress clears it and returns (message dismissed). +2. If `settings_export_conflict` — handle conflict dialog: + - `'d'` → `export_data_overwrite()`, clear conflict flag + - `'r'` → `export_data_rename()`, clear conflict flag + - `Esc` → clear conflict flag + - Return early. +3. If `settings_confirm_import` — handle import confirmation: + - `'y'` → `import_data()`, clear flag + - `'n'` / `Esc` → clear flag + - Return early. +4. If editing export/import path — handle typing (same pattern as `settings_editing_download_dir`). + +For the Enter handler on the new indices: +- Export Path → enter editing mode (clear other edit/modal flags first) +- Export Data → call `export_data()` +- Import Path → enter editing mode (clear other edit/modal flags first) +- Import Data → set `settings_confirm_import = true` (clear other flags first) + +Add new indices to the exclusion lists for left/right cycling. + +### 8. Render dialogs (`src/main.rs` — `render_settings`) + +**Import confirmation dialog** (when `settings_confirm_import` is true): +- Dialog size: ~52x7, centered +- Border title: `" Confirm Import "`, border color: `colors.error()` +- Line 1: `"This will erase your current data."` +- Line 2: `"Export first if you want to keep it."` +- Line 3: `"Proceed? (y/n)"` + +**Export conflict dialog** (when `settings_export_conflict` is true): +- Dialog size: ~52x7, centered +- Border title: `" File Exists "`, border color: `colors.error()` +- Line 1: `"A file already exists at this path."` +- Line 2: `"[d] Overwrite [r] Rename [Esc] Cancel"` + +**Status message dialog** (when `settings_status_message` is `Some`): +- Small centered dialog showing the message text +- `StatusKind::Success` → accent color border. `StatusKind::Error` → error color border. +- Footer: `"Press any key"` + +Dialog rendering priority: status message > export conflict > import confirmation (only one shown at a time). + +### 9. Automated tests (`src/store/json_store.rs` or new test module) + +Add tests for: +- **Round-trip**: export then import produces identical data +- **Transactional safety (supplemental)**: use a `tempdir`, write valid data, then import into a read-only tempdir and verify original files are unchanged +- **Staged write failure**: `import_all` with a poisoned `ExportData` (e.g. containing data that serializes but whose target path is manipulated to fail) verifies `.tmp` cleanup and original file preservation — this provides deterministic failure coverage without platform-dependent permission tricks +- **Version rejection**: import with `keydr_export_version: 99` returns error containing `"Unsupported export version"` +- **Config validation**: import with out-of-range values (target_wpm=0, word_count=999) gets clamped to valid ranges +- **Smart rename suffix**: create files `stem.json`, `stem-1.json` in a tempdir, verify rename picks `stem-2.json`; also test with custom (non-canonical) filenames +- **Modal invariant**: verify that setting any modal/edit flag clears all others + +## Key Files to Modify + +| File | Changes | +|------|---------| +| `src/store/schema.rs` | Add `ExportData` struct | +| `src/store/json_store.rs` | Add `export_all()`, transactional `import_all()` with rollback, `.bak` cleanup on startup, tests | +| `src/app.rs` | Add `StatusKind`/`StatusMessage`, state fields, export/import/rename methods, `.bak` check on init | +| `src/main.rs` | Settings UI entries, key handling, 3 dialog types, path editing | +| `src/config.rs` | Add `validate()` method | + +## Deferred / Out of Scope + +- **Settings enum refactor**: The hard-coded index pattern is pre-existing across the entire settings system. Refactoring to an enum/action map is worthwhile but out of scope for this feature. +- **Splitting config into portable vs machine-local structs**: Handled pragmatically by preserving path fields during import rather than restructuring Config. +- **IO abstraction for injectable writers**: The existing codebase uses direct `fs` calls throughout. Adding a trait-based abstraction for testability is a larger refactor. We use a poisoned-data test and a supplemental read-only tempdir test instead. + +## Verification + +1. `cargo build` — compiles without errors +2. `cargo test` — all new tests pass (round-trip, staged failure, version rejection, validation, rename suffix, modal invariant) +3. Launch app → Settings → verify Export Path / Export Data / Import Path / Import Data rows appear +4. Edit export path → verify typing/backspace works +5. Export → verify JSON file created at specified path with correct structure +6. Export again same day → verify conflict dialog appears; `d` overwrites atomically, `r` renames to `-1` +7. Export a third time → verify `r` renames to `-2` (smart suffix increment) +8. Export with custom filename → verify rename appends `-1` correctly +9. Import with bad version → verify error: `"Unsupported export version: 99 (expected 1)"` +10. Import → verify warning dialog appears; `n`/`Esc` cancels without changes +11. Import → `y` → verify data loaded, config preferences updated, paths preserved +12. Import with unavailable theme → verify success message includes fallback note +13. Verify only one modal/edit state can be active: e.g. while editing export path, pressing a key that would open import confirm does not open it +14. Round-trip: export, change settings, do a drill, import the export, verify original state restored diff --git a/src/app.rs b/src/app.rs index ac0b802..ac4e898 100644 --- a/src/app.rs +++ b/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>, } +#[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::().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, pub passage_drill_selection_override: Option, + pub last_passage_drill_selection: Option, 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, + pub last_code_drill_language: Option, 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, + pub settings_confirm_import: bool, + pub settings_export_conflict: bool, + pub settings_status_message: Option, + 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, 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; diff --git a/src/config.rs b/src/config.rs index c1fe858..991994b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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]) { diff --git a/src/engine/skill_tree.rs b/src/engine/skill_tree.rs index 5d22399..8e00d3d 100644 --- a/src/engine/skill_tree.rs +++ b/src/engine/skill_tree.rs @@ -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 { - 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 { - 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 = 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 = 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')); } diff --git a/src/generator/phonetic.rs b/src/generator/phonetic.rs index b15d0ed..c3cbb71 100644 --- a/src/generator/phonetic.rs +++ b/src/generator/phonetic.rs @@ -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 = 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}" + ); + } +} diff --git a/src/main.rs b/src/main.rs index 80c86a3..bc84cbb 100644 --- a/src/main.rs +++ b/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::>(), ) .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 = pack_hint_lines(&footer_hints, layout[3].width as usize) + let footer_lines: Vec = 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 { diff --git a/src/session/result.rs b/src/session/result.rs index 41684b2..b93ec34 100644 --- a/src/session/result.rs +++ b/src/session/result.rs @@ -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 = 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 = 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); + } +} diff --git a/src/store/json_store.rs b/src/store/json_store.rs index 2dcce44..f772fe3 100644 --- a/src/store/json_store.rs +++ b/src/store/json_store.rs @@ -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 { + 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 = 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()); + } + } diff --git a/src/store/schema.rs b/src/store/schema.rs index 9756e38..37a66e3 100644 --- a/src/store/schema.rs +++ b/src/store/schema.rs @@ -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, + pub config: Config, + pub profile: ProfileData, + pub key_stats: KeyStatsData, + pub ranked_key_stats: KeyStatsData, + pub drill_history: DrillHistoryData, +} diff --git a/src/ui/components/dashboard.rs b/src/ui/components/dashboard.rs index 083ba12..b6f2f74 100644 --- a/src/ui/components/dashboard.rs +++ b/src/ui/components/dashboard.rs @@ -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); } diff --git a/src/ui/components/keyboard_diagram.rs b/src/ui/components/keyboard_diagram.rs index b8f8e03..7d7f7dd 100644 --- a/src/ui/components/keyboard_diagram.rs +++ b/src/ui/components/keyboard_diagram.rs @@ -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); diff --git a/src/ui/components/stats_dashboard.rs b/src/ui/components/stats_dashboard.rs index db31dbe..76d89cd 100644 --- a/src/ui/components/stats_dashboard.rs +++ b/src/ui/components/stats_dashboard.rs @@ -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) -> (usize, usize) { if active_days.is_empty() { return (0, 0); diff --git a/src/ui/components/stats_sidebar.rs b/src/ui/components/stats_sidebar.rs index 5ff6e54..af2d4c5 100644 --- a/src/ui/components/stats_sidebar.rs +++ b/src/ui/components/stats_sidebar.rs @@ -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())),