Import/export feature for config and data

This commit is contained in:
2026-02-22 07:36:34 +00:00
parent 9cc8a214ad
commit 9deffc3d1d
15 changed files with 1717 additions and 125 deletions

1
Cargo.lock generated
View File

@@ -1088,6 +1088,7 @@ dependencies = [
"rust-embed", "rust-embed",
"serde", "serde",
"serde_json", "serde_json",
"tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"toml", "toml",
] ]

View File

@@ -19,6 +19,9 @@ anyhow = "1.0"
thiserror = "2.0" thiserror = "2.0"
reqwest = { version = "0.12", features = ["blocking"], optional = true } reqwest = { version = "0.12", features = ["blocking"], optional = true }
[dev-dependencies]
tempfile = "3"
[features] [features]
default = ["network"] default = ["network"]
network = ["reqwest"] network = ["reqwest"]

View File

@@ -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<Utc>` (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<Utc>` metadata fields.
### 2. Add export/import methods to `JsonStore` (`src/store/json_store.rs`)
- `export_all(&self, config: &Config) -> Result<ExportData>` — 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<StatusMessage>` — 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

View File

@@ -31,12 +31,13 @@ use crate::generator::phonetic::PhoneticGenerator;
use crate::generator::punctuate; use crate::generator::punctuate;
use crate::generator::transition_table::TransitionTable; use crate::generator::transition_table::TransitionTable;
use crate::keyboard::model::KeyboardModel; use crate::keyboard::model::KeyboardModel;
use crate::keyboard::display::BACKSPACE;
use crate::session::drill::DrillState; use crate::session::drill::DrillState;
use crate::session::input::{self, KeystrokeEvent}; use crate::session::input::{self, KeystrokeEvent};
use crate::session::result::DrillResult; use crate::session::result::DrillResult;
use crate::store::json_store::JsonStore; 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::components::menu::Menu;
use crate::ui::theme::Theme; use crate::ui::theme::Theme;
@@ -110,6 +111,68 @@ struct DownloadJob {
handle: Option<thread::JoinHandle<()>>, 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 { impl DrillMode {
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
@@ -166,6 +229,7 @@ pub struct App {
pub passage_intro_download_bytes_total: u64, pub passage_intro_download_bytes_total: u64,
pub passage_download_queue: Vec<usize>, pub passage_download_queue: Vec<usize>,
pub passage_drill_selection_override: Option<String>, pub passage_drill_selection_override: Option<String>,
pub last_passage_drill_selection: Option<String>,
pub passage_download_action: PassageDownloadCompleteAction, pub passage_download_action: PassageDownloadCompleteAction,
pub code_intro_selected: usize, pub code_intro_selected: usize,
pub code_intro_downloads_enabled: bool, pub code_intro_downloads_enabled: bool,
@@ -179,12 +243,20 @@ pub struct App {
pub code_intro_download_bytes_total: u64, pub code_intro_download_bytes_total: u64,
pub code_download_queue: Vec<(String, usize)>, pub code_download_queue: Vec<(String, usize)>,
pub code_drill_language_override: Option<String>, pub code_drill_language_override: Option<String>,
pub last_code_drill_language: Option<String>,
pub code_download_attempted: bool, pub code_download_attempted: bool,
pub code_download_action: CodeDownloadCompleteAction, pub code_download_action: CodeDownloadCompleteAction,
pub shift_held: bool, pub shift_held: bool,
pub caps_lock: bool, pub caps_lock: bool,
pub keyboard_model: KeyboardModel, pub keyboard_model: KeyboardModel,
pub milestone_queue: VecDeque<KeyMilestonePopup>, 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 keyboard_explorer_selected: Option<char>,
pub explorer_accuracy_cache_overall: Option<(char, usize, usize)>, pub explorer_accuracy_cache_overall: Option<(char, usize, usize)>,
pub explorer_accuracy_cache_ranked: 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_intro_download_bytes_total: 0,
passage_download_queue: Vec::new(), passage_download_queue: Vec::new(),
passage_drill_selection_override: None, passage_drill_selection_override: None,
last_passage_drill_selection: None,
passage_download_action: PassageDownloadCompleteAction::StartPassageDrill, passage_download_action: PassageDownloadCompleteAction::StartPassageDrill,
code_intro_selected: 0, code_intro_selected: 0,
code_intro_downloads_enabled, code_intro_downloads_enabled,
@@ -312,12 +385,20 @@ impl App {
code_intro_download_bytes_total: 0, code_intro_download_bytes_total: 0,
code_download_queue: Vec::new(), code_download_queue: Vec::new(),
code_drill_language_override: None, code_drill_language_override: None,
last_code_drill_language: None,
code_download_attempted: false, code_download_attempted: false,
code_download_action: CodeDownloadCompleteAction::StartCodeDrill, code_download_action: CodeDownloadCompleteAction::StartCodeDrill,
shift_held: false, shift_held: false,
caps_lock: false, caps_lock: false,
keyboard_model, keyboard_model,
milestone_queue: VecDeque::new(), 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, keyboard_explorer_selected: None,
explorer_accuracy_cache_overall: None, explorer_accuracy_cache_overall: None,
explorer_accuracy_cache_ranked: None, explorer_accuracy_cache_ranked: None,
@@ -327,10 +408,215 @@ impl App {
passage_download_job: None, passage_download_job: None,
code_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.start_drill();
app 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) { pub fn start_drill(&mut self) {
let (text, source_info) = self.generate_text(); let (text, source_info) = self.generate_text();
self.drill = Some(DrillState::new(&text)); self.drill = Some(DrillState::new(&text));
@@ -467,6 +753,7 @@ impl App {
.code_drill_language_override .code_drill_language_override
.clone() .clone()
.unwrap_or_else(|| self.config.code_language.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 rng = SmallRng::from_rng(&mut self.rng).unwrap();
let mut generator = CodeSyntaxGenerator::new( let mut generator = CodeSyntaxGenerator::new(
rng, rng,
@@ -484,6 +771,7 @@ impl App {
.passage_drill_selection_override .passage_drill_selection_override
.clone() .clone()
.unwrap_or_else(|| self.config.passage_book.clone()); .unwrap_or_else(|| self.config.passage_book.clone());
self.last_passage_drill_selection = Some(selection.clone());
let mut generator = PassageGenerator::new( let mut generator = PassageGenerator::new(
rng, rng,
&selection, &selection,
@@ -522,6 +810,15 @@ impl App {
pub fn backspace(&mut self) { pub fn backspace(&mut self) {
if let Some(ref mut drill) = self.drill { 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); input::process_backspace(drill);
} }
} }
@@ -629,8 +926,8 @@ impl App {
self.last_result = Some(result); self.last_result = Some(result);
// Adaptive mode auto-continues to next drill (like keybr.com) // Adaptive mode auto-continues unless milestone popups must be shown first.
if self.drill_mode == DrillMode::Adaptive { if self.drill_mode == DrillMode::Adaptive && self.milestone_queue.is_empty() {
self.start_drill(); self.start_drill();
} else { } else {
self.screen = AppScreen::DrillResult; 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) { pub fn go_to_menu(&mut self) {
self.screen = AppScreen::Menu; self.screen = AppScreen::Menu;
self.drill = None; self.drill = None;

View File

@@ -122,7 +122,6 @@ impl Config {
} }
} }
#[allow(dead_code)]
pub fn save(&self) -> Result<()> { pub fn save(&self) -> Result<()> {
let path = Self::config_path(); let path = Self::config_path();
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
@@ -144,6 +143,14 @@ impl Config {
self.target_wpm as f64 * 5.0 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. /// Validate `code_language` against known options, resetting to default if invalid.
/// Call after deserialization to handle stale/renamed keys from old configs. /// Call after deserialization to handle stale/renamed keys from old configs.
pub fn normalize_code_language(&mut self, valid_keys: &[&str]) { pub fn normalize_code_language(&mut self, valid_keys: &[&str]) {

View File

@@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::engine::key_stats::KeyStatsStore; use crate::engine::key_stats::KeyStatsStore;
use crate::keyboard::display::{BACKSPACE, SPACE};
/// Events returned by `SkillTree::update` describing what changed. /// Events returned by `SkillTree::update` describing what changed.
pub struct SkillTreeUpdate { pub struct SkillTreeUpdate {
@@ -278,6 +279,7 @@ pub struct SkillTree {
/// Number of lowercase letters to start with before unlocking one-at-a-time /// Number of lowercase letters to start with before unlocking one-at-a-time
const LOWERCASE_MIN_KEYS: usize = 6; const LOWERCASE_MIN_KEYS: usize = 6;
const ALWAYS_UNLOCKED_KEYS: &[char] = &[SPACE, BACKSPACE];
impl SkillTree { impl SkillTree {
pub fn new(progress: SkillTreeProgress) -> Self { pub fn new(progress: SkillTreeProgress) -> Self {
@@ -297,6 +299,7 @@ impl SkillTree {
} }
} }
} }
all_keys.extend(ALWAYS_UNLOCKED_KEYS.iter().copied());
all_keys.len() all_keys.len()
} }
@@ -341,7 +344,7 @@ impl SkillTree {
} }
fn global_unlocked_keys(&self) -> Vec<char> { 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 { for branch_def in ALL_BRANCHES {
let bp = self.branch_progress(branch_def.id); let bp = self.branch_progress(branch_def.id);
match bp.status { match bp.status {
@@ -370,7 +373,7 @@ impl SkillTree {
} }
fn branch_unlocked_keys(&self, id: BranchId) -> Vec<char> { 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 // Always include a-z background keys
if id != BranchId::Lowercase { if id != BranchId::Lowercase {
@@ -638,6 +641,7 @@ impl SkillTree {
/// Total number of unlocked unique keys across all branches. /// Total number of unlocked unique keys across all branches.
pub fn total_unlocked_count(&self) -> usize { pub fn total_unlocked_count(&self) -> usize {
let mut keys: HashSet<char> = HashSet::new(); let mut keys: HashSet<char> = HashSet::new();
keys.extend(ALWAYS_UNLOCKED_KEYS.iter().copied());
for branch_def in ALL_BRANCHES { for branch_def in ALL_BRANCHES {
let bp = self.branch_progress(branch_def.id); let bp = self.branch_progress(branch_def.id);
match bp.status { match bp.status {
@@ -714,6 +718,11 @@ impl SkillTree {
/// Count of unique confident keys across all branches. /// Count of unique confident keys across all branches.
pub fn total_confident_keys(&self, stats: &KeyStatsStore) -> usize { pub fn total_confident_keys(&self, stats: &KeyStatsStore) -> usize {
let mut keys: HashSet<char> = HashSet::new(); 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 branch_def in ALL_BRANCHES {
for level in branch_def.levels { for level in branch_def.levels {
for &ch in level.keys { for &ch in level.keys {
@@ -772,15 +781,17 @@ mod tests {
#[test] #[test]
fn test_total_unique_keys() { fn test_total_unique_keys() {
let tree = SkillTree::default(); let tree = SkillTree::default();
assert_eq!(tree.total_unique_keys, 96); assert_eq!(tree.total_unique_keys, 98);
} }
#[test] #[test]
fn test_initial_lowercase_unlocked() { fn test_initial_lowercase_unlocked() {
let tree = SkillTree::default(); let tree = SkillTree::default();
let keys = tree.unlocked_keys(DrillScope::Global); let keys = tree.unlocked_keys(DrillScope::Global);
assert_eq!(keys.len(), LOWERCASE_MIN_KEYS); assert_eq!(keys.len(), LOWERCASE_MIN_KEYS + ALWAYS_UNLOCKED_KEYS.len());
assert_eq!(&keys[..6], &['e', 't', 'a', 'o', 'i', 'n']); assert_eq!(&keys[2..8], &['e', 't', 'a', 'o', 'i', 'n']);
assert!(keys.contains(&SPACE));
assert!(keys.contains(&BACKSPACE));
} }
#[test] #[test]
@@ -794,7 +805,7 @@ mod tests {
// Should unlock 7th key ('s') // Should unlock 7th key ('s')
let keys = tree.unlocked_keys(DrillScope::Global); let keys = tree.unlocked_keys(DrillScope::Global);
assert_eq!(keys.len(), 7); assert_eq!(keys.len(), 9);
assert!(keys.contains(&'s')); assert!(keys.contains(&'s'));
} }

View File

@@ -213,10 +213,26 @@ impl TextGenerator for PhoneticGenerator {
for _ in 0..word_count { for _ in 0..word_count {
if use_real_words { 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; let mut picked = None;
for _ in 0..3 { for _ in 0..6 {
let idx = self.rng.gen_range(0..matching_words.len()); 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(); let word = matching_words[idx].clone();
if word != last_word { if word != last_word {
picked = Some(word); picked = Some(word);
@@ -239,3 +255,40 @@ impl TextGenerator for PhoneticGenerator {
words.join(" ") 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}"
);
}
}

View File

@@ -28,7 +28,7 @@ use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget, Wrap}; 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 engine::skill_tree::{DrillScope, find_key_branch};
use keyboard::display::key_display_name; use keyboard::display::key_display_name;
use keyboard::finger::Hand; use keyboard::finger::Hand;
@@ -228,6 +228,12 @@ fn handle_key(app: &mut App, key: KeyEvent) {
return; 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 { match app.screen {
AppScreen::Menu => handle_menu_key(app, key), AppScreen::Menu => handle_menu_key(app, key),
AppScreen::Drill => handle_drill_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) { 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 // Route Enter/Tab as typed characters during active drills
if app.drill.is_some() { if app.drill.is_some() {
match key.code { 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) { fn handle_result_key(app: &mut App, key: KeyEvent) {
if app.history_confirm_delete {
match key.code { 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('r') => app.retry_drill(),
KeyCode::Char('q') | KeyCode::Esc => app.go_to_menu(), KeyCode::Char('q') | KeyCode::Esc => app.go_to_menu(),
KeyCode::Char('s') => app.go_to_stats(), 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,26 +446,79 @@ fn handle_stats_key(app: &mut App, key: KeyEvent) {
} }
fn handle_settings_key(app: &mut App, key: KeyEvent) { fn handle_settings_key(app: &mut App, key: KeyEvent) {
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 { match key.code {
KeyCode::Esc => { KeyCode::Esc => {
app.settings_editing_download_dir = false; app.clear_settings_modals();
} }
KeyCode::Backspace => { KeyCode::Backspace => {
if app.settings_editing_download_dir {
if app.settings_selected == 5 { if app.settings_selected == 5 {
app.config.code_download_dir.pop(); app.config.code_download_dir.pop();
} else if app.settings_selected == 9 { } else if app.settings_selected == 9 {
app.config.passage_download_dir.pop(); 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) => { KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
if app.settings_editing_download_dir {
if app.settings_selected == 5 { if app.settings_selected == 5 {
app.config.code_download_dir.push(ch); app.config.code_download_dir.push(ch);
} else if app.settings_selected == 9 { } else if app.settings_selected == 9 {
app.config.passage_download_dir.push(ch); 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 => { KeyCode::Enter => {
match app.settings_selected { 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(), 7 => app.start_code_downloads_from_settings(),
11 => app.start_passage_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(), _ => app.settings_cycle_forward(),
} }
} }
KeyCode::Right | KeyCode::Char('l') => { KeyCode::Right | KeyCode::Char('l') => {
// Allow cycling for non-text, non-button fields
match app.settings_selected { 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(), _ => app.settings_cycle_forward(),
} }
} }
KeyCode::Left | KeyCode::Char('h') => { KeyCode::Left | KeyCode::Char('h') => {
match app.settings_selected { 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(), _ => 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())); let bg = Block::default().style(Style::default().bg(colors.bg()));
frame.render_widget(bg, area); 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 { match app.screen {
AppScreen::Menu => render_menu(frame, app), AppScreen::Menu => render_menu(frame, app),
AppScreen::Drill => render_drill(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 unlocked = app.skill_tree.total_unlocked_count();
let mastered = app.skill_tree.total_confident_keys(&app.ranked_key_stats); let mastered = app.skill_tree.total_confident_keys(&app.ranked_key_stats);
let header_info = format!( let header_info = format!(
" Key Progress {unlocked}/{total_keys} ({mastered} mastered){}", " Key Progress {unlocked}/{total_keys} ({mastered} mastered) | Target {} WPM{}",
streak_text, app.config.target_wpm, streak_text,
); );
let header = Paragraph::new(Line::from(vec![ let header = Paragraph::new(Line::from(vec![
Span::styled( Span::styled(
@@ -1176,6 +1244,7 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
drill, drill,
app.last_result.as_ref(), app.last_result.as_ref(),
&app.drill_history, &app.drill_history,
app.config.target_wpm,
app.theme, app.theme,
); );
frame.render_widget(sidebar, sidebar_area); 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); 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 { if footer_y < inner.y + inner.height {
let footer_area = Rect::new(inner.x, footer_y, inner.width, 1); let footer_area = Rect::new(inner.x, footer_y, inner.width, 1);
let footer = Paragraph::new(Line::from(Span::styled( 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()), Style::default().fg(colors.text_pending()),
))); )));
frame.render_widget(footer, footer_area); frame.render_widget(footer, footer_area);
@@ -1370,29 +1435,78 @@ fn overlay_keyboard_mode(height: u16) -> u8 {
#[cfg(test)] #[cfg(test)]
mod review_tests { mod review_tests {
use super::*; 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] #[test]
fn milestone_dismiss_matrix_matches_spec() { fn milestone_overlay_blocks_underlying_input() {
assert_eq!( let mut app = App::new();
milestone_dismiss_action(KeyCode::Char('a')), app.screen = AppScreen::Drill;
MilestoneDismissAction::Replay app.drill = Some(crate::session::drill::DrillState::new("abc"));
); app.milestone_queue
assert_eq!( .push_back(crate::app::KeyMilestonePopup {
milestone_dismiss_action(KeyCode::Tab), kind: crate::app::MilestoneKind::Unlock,
MilestoneDismissAction::Replay keys: vec!['a'],
); finger_info: vec![('a', "left pinky".to_string())],
assert_eq!( message: "msg",
milestone_dismiss_action(KeyCode::Enter), });
MilestoneDismissAction::Replay
); let before_cursor = app.drill.as_ref().map(|d| d.cursor).unwrap_or(0);
assert_eq!( handle_key(&mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
milestone_dismiss_action(KeyCode::Backspace), let after_cursor = app.drill.as_ref().map(|d| d.cursor).unwrap_or(0);
MilestoneDismissAction::DismissOnly
); assert_eq!(before_cursor, after_cursor);
assert_eq!( assert!(app.milestone_queue.is_empty());
milestone_dismiss_action(KeyCode::Esc), }
MilestoneDismissAction::EscAndExit
); #[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] #[test]
@@ -1402,6 +1516,268 @@ mod review_tests {
assert_eq!(overlay_keyboard_mode(24), 1); assert_eq!(overlay_keyboard_mode(24), 1);
assert_eq!(overlay_keyboard_mode(25), 2); 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) { 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 centered = ui::layout::centered_rect(60, 70, area);
let dashboard = Dashboard::new(result, app.theme); let dashboard = Dashboard::new(result, app.theme);
frame.render_widget(dashboard, centered); 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(), "Run downloader".to_string(),
false, 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() let layout = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Length(2), Constraint::Length(header_height),
Constraint::Length(fields.len() as u16 * 3), Constraint::Length(field_height),
Constraint::Min(0), Constraint::Length(footer_height),
Constraint::Length(2),
]) ])
.split(inner); .split(inner);
@@ -1536,22 +1964,33 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
))); )));
header.render(layout[0], frame.buffer_mut()); 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() let field_layout = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints( .constraints(
fields visible_fields
.iter() .iter()
.map(|_| Constraint::Length(3)) .map(|_| Constraint::Length(row_height))
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
) )
.split(layout[1]); .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 is_selected = i == app.settings_selected;
let indicator = if is_selected { " > " } else { " " }; let indicator = if is_selected { " > " } else { " " };
let label_text = format!("{indicator}{label}:"); let label_text = format!("{indicator}{label}:");
let 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 { let value_text = if is_button {
format!(" [ {value} ]") format!(" [ {value} ]")
} else { } else {
@@ -1576,15 +2015,20 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
colors.text_pending() 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 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}_") format!(" {value}_")
} else { } else {
format!(" {value}") format!(" {value}")
}; };
vec![ vec![
Line::from(Span::styled( Line::from(Span::styled(
if app.settings_editing_download_dir && is_selected { if is_editing_this_path {
format!("{indicator}{label}: (editing)") format!("{indicator}{label}: (editing)")
} else { } else {
label_text label_text
@@ -1599,25 +2043,130 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
Line::from(Span::styled(value_text, value_style)), Line::from(Span::styled(value_text, value_style)),
] ]
}; };
Paragraph::new(lines).render(field_layout[i], frame.buffer_mut()); Paragraph::new(lines).render(field_layout[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"] vec!["Editing path:", "[Type/Backspace] Modify", "[ESC] Done editing"]
} else { } else {
vec![ vec![
"[ESC] Save & back", "[ESC] Save & back",
"[Enter/arrows] Change value", "[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() .into_iter()
.map(|line| Line::from(Span::styled(line, Style::default().fg(colors.accent())))) .map(|line| Line::from(Span::styled(line, Style::default().fg(colors.accent()))))
.collect(); .collect();
Paragraph::new(footer_lines) Paragraph::new(footer_lines)
.wrap(Wrap { trim: false }) .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 { fn wrapped_line_count(text: &str, width: usize) -> usize {

View File

@@ -1,6 +1,7 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::keyboard::display::BACKSPACE;
use crate::session::drill::DrillState; use crate::session::drill::DrillState;
use crate::session::input::KeystrokeEvent; use crate::session::input::KeystrokeEvent;
@@ -52,17 +53,50 @@ impl DrillResult {
ranked: bool, ranked: bool,
partial: bool, partial: bool,
) -> Self { ) -> Self {
let per_key_times: Vec<KeyTime> = events let mut per_key_times: Vec<KeyTime> = Vec::new();
.windows(2) let mut pending_backspace = false;
.map(|pair| { for pair in events.windows(2) {
let dt = pair[1].timestamp.duration_since(pair[0].timestamp); let prev = &pair[0];
KeyTime { let curr = &pair[1];
key: pair[1].expected, let dt = curr.timestamp.duration_since(prev.timestamp).as_secs_f64() * 1000.0;
time_ms: dt.as_secs_f64() * 1000.0,
correct: pair[1].correct, // 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;
}
}
if curr.actual != BACKSPACE && !curr.correct {
pending_backspace = true;
}
} }
})
.collect();
let total_chars = drill.target.len(); let total_chars = drill.target.len();
let typo_count = drill.typo_flags.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);
}
}

View File

@@ -2,10 +2,14 @@ use std::fs;
use std::io::Write; use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use anyhow::Result; use anyhow::{Result, bail};
use chrono::Utc;
use serde::{Serialize, de::DeserializeOwned}; 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 { pub struct JsonStore {
base_dir: PathBuf, base_dir: PathBuf,
@@ -20,6 +24,12 @@ impl JsonStore {
Ok(Self { base_dir }) 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 { fn file_path(&self, name: &str) -> PathBuf {
self.base_dir.join(name) self.base_dir.join(name)
} }
@@ -89,4 +99,295 @@ impl JsonStore {
pub fn save_drill_history(&self, data: &DrillHistoryData) -> Result<()> { pub fn save_drill_history(&self, data: &DrillHistoryData) -> Result<()> {
self.save("lesson_history.json", data) 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());
}
} }

View File

@@ -1,5 +1,7 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::engine::key_stats::KeyStatsStore; use crate::engine::key_stats::KeyStatsStore;
use crate::engine::skill_tree::SkillTreeProgress; use crate::engine::skill_tree::SkillTreeProgress;
use crate::session::result::DrillResult; 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,
}

View File

@@ -115,9 +115,14 @@ impl Widget for Dashboard<'_> {
Paragraph::new(chars_line).render(layout[4], buf); Paragraph::new(chars_line).render(layout[4], buf);
let help = Paragraph::new(Line::from(vec![ let help = Paragraph::new(Line::from(vec![
Span::styled(
" [c/Enter/Space] Continue ",
Style::default().fg(colors.accent()),
),
Span::styled("[r] Retry ", 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("[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); help.render(layout[6], buf);
} }

View File

@@ -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) // Space bar row (row 4)
let space_y = inner.y + 4; let space_y = inner.y + 4;
if space_y < inner.y + inner.height { if space_y < inner.y + inner.height {
let space_name = display::key_display_name(SPACE); let space_name = display::key_display_name(SPACE);
let space_label = format!("[ {space_name} ]"); let space_label = format!("[ {space_name} ]");
let space_width = space_label.len() as u16; 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 { if space_x + space_width <= inner.x + inner.width {
let is_dep = self.depressed_keys.contains(&SPACE); let is_dep = self.depressed_keys.contains(&SPACE);
let is_next = self.next_key == Some(SPACE); let is_next = self.next_key == Some(SPACE);

View File

@@ -966,7 +966,8 @@ impl StatsDashboard<'_> {
if y >= inner.y + inner.height { if y >= inner.y + inner.height {
break; 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; let label_len = label.len() as u16;
buf.set_string(inner.x, y, &label, Style::default().fg(colors.error())); buf.set_string(inner.x, y, &label, Style::default().fg(colors.error()));
let bar_space = inner.width.saturating_sub(label_len) as usize; let bar_space = inner.width.saturating_sub(label_len) as usize;
@@ -1013,7 +1014,8 @@ impl StatsDashboard<'_> {
if y >= inner.y + inner.height { if y >= inner.y + inner.height {
break; 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; let label_len = label.len() as u16;
buf.set_string(inner.x, y, &label, Style::default().fg(colors.success())); buf.set_string(inner.x, y, &label, Style::default().fg(colors.success()));
let bar_space = inner.width.saturating_sub(label_len) as usize; let bar_space = inner.width.saturating_sub(label_len) as usize;
@@ -1056,6 +1058,7 @@ impl StatsDashboard<'_> {
all_keys.insert(SPACE); all_keys.insert(SPACE);
all_keys.insert(TAB); all_keys.insert(TAB);
all_keys.insert(ENTER); all_keys.insert(ENTER);
all_keys.insert(BACKSPACE);
let mut key_accuracies: Vec<(char, f64)> = all_keys let mut key_accuracies: Vec<(char, f64)> = all_keys
.into_iter() .into_iter()
@@ -1091,7 +1094,8 @@ impl StatsDashboard<'_> {
if y >= inner.y + inner.height { if y >= inner.y + inner.height {
break; 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 label_len = label.len() as u16;
let color = if *acc >= 95.0 { let color = if *acc >= 95.0 {
colors.warning() colors.warning()
@@ -1132,6 +1136,7 @@ impl StatsDashboard<'_> {
all_keys.insert(SPACE); all_keys.insert(SPACE);
all_keys.insert(TAB); all_keys.insert(TAB);
all_keys.insert(ENTER); all_keys.insert(ENTER);
all_keys.insert(BACKSPACE);
let mut key_accuracies: Vec<(char, f64)> = all_keys let mut key_accuracies: Vec<(char, f64)> = all_keys
.into_iter() .into_iter()
@@ -1166,7 +1171,8 @@ impl StatsDashboard<'_> {
if y >= inner.y + inner.height { if y >= inner.y + inner.height {
break; 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 label_len = label.len() as u16;
let color = if *acc >= 98.0 { let color = if *acc >= 98.0 {
colors.success() colors.success()
@@ -1308,6 +1314,16 @@ fn required_kbd_width(key_width: u16, key_step: u16) -> u16 {
max_offset + 12 * key_step + key_width 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) { fn compute_streaks(active_days: &BTreeSet<chrono::NaiveDate>) -> (usize, usize) {
if active_days.is_empty() { if active_days.is_empty() {
return (0, 0); return (0, 0);

View File

@@ -12,6 +12,7 @@ pub struct StatsSidebar<'a> {
drill: &'a DrillState, drill: &'a DrillState,
last_result: Option<&'a DrillResult>, last_result: Option<&'a DrillResult>,
history: &'a [DrillResult], history: &'a [DrillResult],
target_wpm: u32,
theme: &'a Theme, theme: &'a Theme,
} }
@@ -20,12 +21,14 @@ impl<'a> StatsSidebar<'a> {
drill: &'a DrillState, drill: &'a DrillState,
last_result: Option<&'a DrillResult>, last_result: Option<&'a DrillResult>,
history: &'a [DrillResult], history: &'a [DrillResult],
target_wpm: u32,
theme: &'a Theme, theme: &'a Theme,
) -> Self { ) -> Self {
Self { Self {
drill, drill,
last_result, last_result,
history, history,
target_wpm,
theme, theme,
} }
} }
@@ -82,6 +85,13 @@ impl Widget for StatsSidebar<'_> {
Span::styled("WPM: ", Style::default().fg(colors.fg())), Span::styled("WPM: ", Style::default().fg(colors.fg())),
Span::styled(wpm_str, Style::default().fg(colors.accent())), 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(""),
Line::from(vec![ Line::from(vec![
Span::styled("Accuracy: ", Style::default().fg(colors.fg())), Span::styled("Accuracy: ", Style::default().fg(colors.fg())),