Import/export feature for config and data
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
188
docs/plans/2026-02-20-import-export-feature-plan.md
Normal file
188
docs/plans/2026-02-20-import-export-feature-plan.md
Normal 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
|
||||||
322
src/app.rs
322
src/app.rs
@@ -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;
|
||||||
|
|||||||
@@ -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]) {
|
||||||
|
|||||||
@@ -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'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
719
src/main.rs
719
src/main.rs
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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())),
|
||||||
|
|||||||
Reference in New Issue
Block a user