First one-shot pass
This commit is contained in:
2119
Cargo.lock
generated
Normal file
2119
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
Cargo.toml
13
Cargo.toml
@@ -2,5 +2,18 @@
|
|||||||
name = "keydr"
|
name = "keydr"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
description = "Terminal typing tutor with adaptive learning"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
ratatui = { version = "0.30", features = ["crossterm_0_28"] }
|
||||||
|
crossterm = "0.28"
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
toml = "0.8"
|
||||||
|
rand = { version = "0.8", features = ["small_rng"] }
|
||||||
|
dirs = "6.0"
|
||||||
|
rust-embed = "8.5"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
anyhow = "1.0"
|
||||||
|
thiserror = "2.0"
|
||||||
|
|||||||
23
assets/themes/catppuccin-latte.toml
Normal file
23
assets/themes/catppuccin-latte.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "catppuccin-latte"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
bg = "#eff1f5"
|
||||||
|
fg = "#4c4f69"
|
||||||
|
text_correct = "#40a02b"
|
||||||
|
text_incorrect = "#d20f39"
|
||||||
|
text_incorrect_bg = "#f5c2cf"
|
||||||
|
text_pending = "#9ca0b0"
|
||||||
|
text_cursor_bg = "#dc8a78"
|
||||||
|
text_cursor_fg = "#eff1f5"
|
||||||
|
focused_key = "#df8e1d"
|
||||||
|
accent = "#1e66f5"
|
||||||
|
accent_dim = "#ccd0da"
|
||||||
|
border = "#ccd0da"
|
||||||
|
border_focused = "#1e66f5"
|
||||||
|
header_bg = "#e6e9ef"
|
||||||
|
header_fg = "#4c4f69"
|
||||||
|
bar_filled = "#1e66f5"
|
||||||
|
bar_empty = "#e6e9ef"
|
||||||
|
error = "#d20f39"
|
||||||
|
warning = "#df8e1d"
|
||||||
|
success = "#40a02b"
|
||||||
23
assets/themes/catppuccin-mocha.toml
Normal file
23
assets/themes/catppuccin-mocha.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "catppuccin-mocha"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
bg = "#1e1e2e"
|
||||||
|
fg = "#cdd6f4"
|
||||||
|
text_correct = "#a6e3a1"
|
||||||
|
text_incorrect = "#f38ba8"
|
||||||
|
text_incorrect_bg = "#45273a"
|
||||||
|
text_pending = "#585b70"
|
||||||
|
text_cursor_bg = "#f5e0dc"
|
||||||
|
text_cursor_fg = "#1e1e2e"
|
||||||
|
focused_key = "#f9e2af"
|
||||||
|
accent = "#89b4fa"
|
||||||
|
accent_dim = "#45475a"
|
||||||
|
border = "#45475a"
|
||||||
|
border_focused = "#89b4fa"
|
||||||
|
header_bg = "#313244"
|
||||||
|
header_fg = "#cdd6f4"
|
||||||
|
bar_filled = "#89b4fa"
|
||||||
|
bar_empty = "#313244"
|
||||||
|
error = "#f38ba8"
|
||||||
|
warning = "#f9e2af"
|
||||||
|
success = "#a6e3a1"
|
||||||
23
assets/themes/dracula.toml
Normal file
23
assets/themes/dracula.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "dracula"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
bg = "#282a36"
|
||||||
|
fg = "#f8f8f2"
|
||||||
|
text_correct = "#50fa7b"
|
||||||
|
text_incorrect = "#ff5555"
|
||||||
|
text_incorrect_bg = "#44242a"
|
||||||
|
text_pending = "#6272a4"
|
||||||
|
text_cursor_bg = "#f1fa8c"
|
||||||
|
text_cursor_fg = "#282a36"
|
||||||
|
focused_key = "#f1fa8c"
|
||||||
|
accent = "#bd93f9"
|
||||||
|
accent_dim = "#44475a"
|
||||||
|
border = "#44475a"
|
||||||
|
border_focused = "#bd93f9"
|
||||||
|
header_bg = "#44475a"
|
||||||
|
header_fg = "#f8f8f2"
|
||||||
|
bar_filled = "#bd93f9"
|
||||||
|
bar_empty = "#44475a"
|
||||||
|
error = "#ff5555"
|
||||||
|
warning = "#f1fa8c"
|
||||||
|
success = "#50fa7b"
|
||||||
23
assets/themes/gruvbox-dark.toml
Normal file
23
assets/themes/gruvbox-dark.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "gruvbox-dark"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
bg = "#282828"
|
||||||
|
fg = "#ebdbb2"
|
||||||
|
text_correct = "#b8bb26"
|
||||||
|
text_incorrect = "#fb4934"
|
||||||
|
text_incorrect_bg = "#462726"
|
||||||
|
text_pending = "#665c54"
|
||||||
|
text_cursor_bg = "#fabd2f"
|
||||||
|
text_cursor_fg = "#282828"
|
||||||
|
focused_key = "#fabd2f"
|
||||||
|
accent = "#83a598"
|
||||||
|
accent_dim = "#3c3836"
|
||||||
|
border = "#504945"
|
||||||
|
border_focused = "#83a598"
|
||||||
|
header_bg = "#3c3836"
|
||||||
|
header_fg = "#ebdbb2"
|
||||||
|
bar_filled = "#83a598"
|
||||||
|
bar_empty = "#3c3836"
|
||||||
|
error = "#fb4934"
|
||||||
|
warning = "#fabd2f"
|
||||||
|
success = "#b8bb26"
|
||||||
23
assets/themes/nord.toml
Normal file
23
assets/themes/nord.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "nord"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
bg = "#2e3440"
|
||||||
|
fg = "#eceff4"
|
||||||
|
text_correct = "#a3be8c"
|
||||||
|
text_incorrect = "#bf616a"
|
||||||
|
text_incorrect_bg = "#3f2e31"
|
||||||
|
text_pending = "#4c566a"
|
||||||
|
text_cursor_bg = "#ebcb8b"
|
||||||
|
text_cursor_fg = "#2e3440"
|
||||||
|
focused_key = "#ebcb8b"
|
||||||
|
accent = "#88c0d0"
|
||||||
|
accent_dim = "#3b4252"
|
||||||
|
border = "#4c566a"
|
||||||
|
border_focused = "#88c0d0"
|
||||||
|
header_bg = "#3b4252"
|
||||||
|
header_fg = "#eceff4"
|
||||||
|
bar_filled = "#88c0d0"
|
||||||
|
bar_empty = "#3b4252"
|
||||||
|
error = "#bf616a"
|
||||||
|
warning = "#ebcb8b"
|
||||||
|
success = "#a3be8c"
|
||||||
23
assets/themes/one-dark.toml
Normal file
23
assets/themes/one-dark.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "one-dark"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
bg = "#282c34"
|
||||||
|
fg = "#abb2bf"
|
||||||
|
text_correct = "#98c379"
|
||||||
|
text_incorrect = "#e06c75"
|
||||||
|
text_incorrect_bg = "#3e2a2d"
|
||||||
|
text_pending = "#5c6370"
|
||||||
|
text_cursor_bg = "#e5c07b"
|
||||||
|
text_cursor_fg = "#282c34"
|
||||||
|
focused_key = "#e5c07b"
|
||||||
|
accent = "#61afef"
|
||||||
|
accent_dim = "#3e4451"
|
||||||
|
border = "#3e4451"
|
||||||
|
border_focused = "#61afef"
|
||||||
|
header_bg = "#21252b"
|
||||||
|
header_fg = "#abb2bf"
|
||||||
|
bar_filled = "#61afef"
|
||||||
|
bar_empty = "#21252b"
|
||||||
|
error = "#e06c75"
|
||||||
|
warning = "#e5c07b"
|
||||||
|
success = "#98c379"
|
||||||
23
assets/themes/solarized-dark.toml
Normal file
23
assets/themes/solarized-dark.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "solarized-dark"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
bg = "#002b36"
|
||||||
|
fg = "#839496"
|
||||||
|
text_correct = "#859900"
|
||||||
|
text_incorrect = "#dc322f"
|
||||||
|
text_incorrect_bg = "#2a1a1a"
|
||||||
|
text_pending = "#586e75"
|
||||||
|
text_cursor_bg = "#b58900"
|
||||||
|
text_cursor_fg = "#002b36"
|
||||||
|
focused_key = "#b58900"
|
||||||
|
accent = "#268bd2"
|
||||||
|
accent_dim = "#073642"
|
||||||
|
border = "#586e75"
|
||||||
|
border_focused = "#268bd2"
|
||||||
|
header_bg = "#073642"
|
||||||
|
header_fg = "#93a1a1"
|
||||||
|
bar_filled = "#268bd2"
|
||||||
|
bar_empty = "#073642"
|
||||||
|
error = "#dc322f"
|
||||||
|
warning = "#b58900"
|
||||||
|
success = "#859900"
|
||||||
23
assets/themes/tokyo-night.toml
Normal file
23
assets/themes/tokyo-night.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "tokyo-night"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
bg = "#1a1b26"
|
||||||
|
fg = "#c0caf5"
|
||||||
|
text_correct = "#9ece6a"
|
||||||
|
text_incorrect = "#f7768e"
|
||||||
|
text_incorrect_bg = "#3b2232"
|
||||||
|
text_pending = "#565f89"
|
||||||
|
text_cursor_bg = "#e0af68"
|
||||||
|
text_cursor_fg = "#1a1b26"
|
||||||
|
focused_key = "#e0af68"
|
||||||
|
accent = "#7aa2f7"
|
||||||
|
accent_dim = "#292e42"
|
||||||
|
border = "#3b4261"
|
||||||
|
border_focused = "#7aa2f7"
|
||||||
|
header_bg = "#24283b"
|
||||||
|
header_fg = "#c0caf5"
|
||||||
|
bar_filled = "#7aa2f7"
|
||||||
|
bar_empty = "#24283b"
|
||||||
|
error = "#f7768e"
|
||||||
|
warning = "#e0af68"
|
||||||
|
success = "#9ece6a"
|
||||||
249
src/app.rs
Normal file
249
src/app.rs
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
use rand::rngs::SmallRng;
|
||||||
|
use rand::SeedableRng;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::engine::filter::CharFilter;
|
||||||
|
use crate::engine::key_stats::KeyStatsStore;
|
||||||
|
use crate::engine::letter_unlock::LetterUnlock;
|
||||||
|
use crate::engine::scoring;
|
||||||
|
use crate::generator::code_syntax::CodeSyntaxGenerator;
|
||||||
|
use crate::generator::passage::PassageGenerator;
|
||||||
|
use crate::generator::phonetic::PhoneticGenerator;
|
||||||
|
use crate::generator::TextGenerator;
|
||||||
|
use crate::generator::transition_table::TransitionTable;
|
||||||
|
|
||||||
|
use crate::session::input::{self, KeystrokeEvent};
|
||||||
|
use crate::session::lesson::LessonState;
|
||||||
|
use crate::session::result::LessonResult;
|
||||||
|
use crate::store::json_store::JsonStore;
|
||||||
|
use crate::store::schema::{KeyStatsData, LessonHistoryData, ProfileData};
|
||||||
|
use crate::ui::components::menu::Menu;
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum AppScreen {
|
||||||
|
Menu,
|
||||||
|
Lesson,
|
||||||
|
LessonResult,
|
||||||
|
StatsDashboard,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
Settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum LessonMode {
|
||||||
|
Adaptive,
|
||||||
|
Code,
|
||||||
|
Passage,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
pub screen: AppScreen,
|
||||||
|
pub lesson_mode: LessonMode,
|
||||||
|
pub lesson: Option<LessonState>,
|
||||||
|
pub lesson_events: Vec<KeystrokeEvent>,
|
||||||
|
pub last_result: Option<LessonResult>,
|
||||||
|
pub lesson_history: Vec<LessonResult>,
|
||||||
|
pub menu: Menu<'static>,
|
||||||
|
pub theme: &'static Theme,
|
||||||
|
pub config: Config,
|
||||||
|
pub key_stats: KeyStatsStore,
|
||||||
|
pub letter_unlock: LetterUnlock,
|
||||||
|
pub profile: ProfileData,
|
||||||
|
pub store: Option<JsonStore>,
|
||||||
|
pub should_quit: bool,
|
||||||
|
rng: SmallRng,
|
||||||
|
transition_table: TransitionTable,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let config = Config::load().unwrap_or_default();
|
||||||
|
let loaded_theme = Theme::load(&config.theme).unwrap_or_default();
|
||||||
|
let theme: &'static Theme = Box::leak(Box::new(loaded_theme));
|
||||||
|
let menu = Menu::new(theme);
|
||||||
|
|
||||||
|
let store = JsonStore::new().ok();
|
||||||
|
|
||||||
|
let (key_stats, letter_unlock, profile, lesson_history) = if let Some(ref s) = store {
|
||||||
|
let ksd = s.load_key_stats();
|
||||||
|
let pd = s.load_profile();
|
||||||
|
let lhd = s.load_lesson_history();
|
||||||
|
|
||||||
|
let lu = if pd.unlocked_letters.is_empty() {
|
||||||
|
LetterUnlock::new()
|
||||||
|
} else {
|
||||||
|
LetterUnlock::from_included(pd.unlocked_letters.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
(ksd.stats, lu, pd, lhd.lessons)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
KeyStatsStore::default(),
|
||||||
|
LetterUnlock::new(),
|
||||||
|
ProfileData::default(),
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut key_stats_with_target = key_stats;
|
||||||
|
key_stats_with_target.target_cpm = config.target_cpm();
|
||||||
|
|
||||||
|
let transition_table = TransitionTable::build_english();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
screen: AppScreen::Menu,
|
||||||
|
lesson_mode: LessonMode::Adaptive,
|
||||||
|
lesson: None,
|
||||||
|
lesson_events: Vec::new(),
|
||||||
|
last_result: None,
|
||||||
|
lesson_history,
|
||||||
|
menu,
|
||||||
|
theme,
|
||||||
|
config,
|
||||||
|
key_stats: key_stats_with_target,
|
||||||
|
letter_unlock,
|
||||||
|
profile,
|
||||||
|
store,
|
||||||
|
should_quit: false,
|
||||||
|
rng: SmallRng::from_entropy(),
|
||||||
|
transition_table,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_lesson(&mut self) {
|
||||||
|
let text = self.generate_text();
|
||||||
|
self.lesson = Some(LessonState::new(&text));
|
||||||
|
self.lesson_events.clear();
|
||||||
|
self.screen = AppScreen::Lesson;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_text(&mut self) -> String {
|
||||||
|
let word_count = self.config.word_count;
|
||||||
|
let mode = self.lesson_mode;
|
||||||
|
|
||||||
|
match mode {
|
||||||
|
LessonMode::Adaptive => {
|
||||||
|
let filter = CharFilter::new(self.letter_unlock.included.clone());
|
||||||
|
let focused = self.letter_unlock.focused;
|
||||||
|
let table = self.transition_table.clone();
|
||||||
|
let rng = SmallRng::from_rng(&mut self.rng).unwrap();
|
||||||
|
let mut generator = PhoneticGenerator::new(table, rng);
|
||||||
|
generator.generate(&filter, focused, word_count)
|
||||||
|
}
|
||||||
|
LessonMode::Code => {
|
||||||
|
let filter = CharFilter::new(('a'..='z').collect());
|
||||||
|
let lang = self
|
||||||
|
.config
|
||||||
|
.code_languages
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "rust".to_string());
|
||||||
|
let rng = SmallRng::from_rng(&mut self.rng).unwrap();
|
||||||
|
let mut generator = CodeSyntaxGenerator::new(rng, &lang);
|
||||||
|
generator.generate(&filter, None, word_count)
|
||||||
|
}
|
||||||
|
LessonMode::Passage => {
|
||||||
|
let filter = CharFilter::new(('a'..='z').collect());
|
||||||
|
let mut generator = PassageGenerator::new();
|
||||||
|
generator.generate(&filter, None, word_count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn type_char(&mut self, ch: char) {
|
||||||
|
if let Some(ref mut lesson) = self.lesson {
|
||||||
|
if let Some(event) = input::process_char(lesson, ch) {
|
||||||
|
self.lesson_events.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if lesson.is_complete() {
|
||||||
|
self.finish_lesson();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn backspace(&mut self) {
|
||||||
|
if let Some(ref mut lesson) = self.lesson {
|
||||||
|
input::process_backspace(lesson);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_lesson(&mut self) {
|
||||||
|
if let Some(ref lesson) = self.lesson {
|
||||||
|
let result = LessonResult::from_lesson(lesson, &self.lesson_events);
|
||||||
|
|
||||||
|
if self.lesson_mode == LessonMode::Adaptive {
|
||||||
|
for kt in &result.per_key_times {
|
||||||
|
if kt.correct {
|
||||||
|
self.key_stats.update_key(kt.key, kt.time_ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.letter_unlock.update(&self.key_stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
let complexity = scoring::compute_complexity(self.letter_unlock.unlocked_count());
|
||||||
|
let score = scoring::compute_score(&result, complexity);
|
||||||
|
self.profile.total_score += score;
|
||||||
|
self.profile.total_lessons += 1;
|
||||||
|
self.profile.unlocked_letters = self.letter_unlock.included.clone();
|
||||||
|
|
||||||
|
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
||||||
|
if self.profile.last_practice_date.as_deref() != Some(&today) {
|
||||||
|
if let Some(ref last) = self.profile.last_practice_date {
|
||||||
|
let yesterday = (chrono::Utc::now() - chrono::Duration::days(1))
|
||||||
|
.format("%Y-%m-%d")
|
||||||
|
.to_string();
|
||||||
|
if last == &yesterday {
|
||||||
|
self.profile.streak_days += 1;
|
||||||
|
} else {
|
||||||
|
self.profile.streak_days = 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.profile.streak_days = 1;
|
||||||
|
}
|
||||||
|
self.profile.best_streak =
|
||||||
|
self.profile.best_streak.max(self.profile.streak_days);
|
||||||
|
self.profile.last_practice_date = Some(today);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.lesson_history.push(result.clone());
|
||||||
|
if self.lesson_history.len() > 500 {
|
||||||
|
self.lesson_history.remove(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.last_result = Some(result);
|
||||||
|
self.screen = AppScreen::LessonResult;
|
||||||
|
|
||||||
|
self.save_data();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_data(&self) {
|
||||||
|
if let Some(ref store) = self.store {
|
||||||
|
let _ = store.save_profile(&self.profile);
|
||||||
|
let _ = store.save_key_stats(&KeyStatsData {
|
||||||
|
schema_version: 1,
|
||||||
|
stats: self.key_stats.clone(),
|
||||||
|
});
|
||||||
|
let _ = store.save_lesson_history(&LessonHistoryData {
|
||||||
|
schema_version: 1,
|
||||||
|
lessons: self.lesson_history.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn retry_lesson(&mut self) {
|
||||||
|
self.start_lesson();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn go_to_menu(&mut self) {
|
||||||
|
self.screen = AppScreen::Menu;
|
||||||
|
self.lesson = None;
|
||||||
|
self.lesson_events.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn go_to_stats(&mut self) {
|
||||||
|
self.screen = AppScreen::StatsDashboard;
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/config.rs
Normal file
82
src/config.rs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(default = "default_target_wpm")]
|
||||||
|
pub target_wpm: u32,
|
||||||
|
#[serde(default = "default_theme")]
|
||||||
|
pub theme: String,
|
||||||
|
#[serde(default = "default_keyboard_layout")]
|
||||||
|
pub keyboard_layout: String,
|
||||||
|
#[serde(default = "default_code_languages")]
|
||||||
|
pub code_languages: Vec<String>,
|
||||||
|
#[serde(default = "default_word_count")]
|
||||||
|
pub word_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_target_wpm() -> u32 {
|
||||||
|
35
|
||||||
|
}
|
||||||
|
fn default_theme() -> String {
|
||||||
|
"catppuccin-mocha".to_string()
|
||||||
|
}
|
||||||
|
fn default_keyboard_layout() -> String {
|
||||||
|
"qwerty".to_string()
|
||||||
|
}
|
||||||
|
fn default_code_languages() -> Vec<String> {
|
||||||
|
vec!["rust".to_string()]
|
||||||
|
}
|
||||||
|
fn default_word_count() -> usize {
|
||||||
|
20
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
target_wpm: default_target_wpm(),
|
||||||
|
theme: default_theme(),
|
||||||
|
keyboard_layout: default_keyboard_layout(),
|
||||||
|
code_languages: default_code_languages(),
|
||||||
|
word_count: default_word_count(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Result<Self> {
|
||||||
|
let path = Self::config_path();
|
||||||
|
if path.exists() {
|
||||||
|
let content = fs::read_to_string(&path)?;
|
||||||
|
let config: Config = toml::from_str(&content)?;
|
||||||
|
Ok(config)
|
||||||
|
} else {
|
||||||
|
Ok(Config::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn save(&self) -> Result<()> {
|
||||||
|
let path = Self::config_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let content = toml::to_string_pretty(self)?;
|
||||||
|
fs::write(&path, content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path() -> PathBuf {
|
||||||
|
dirs::config_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("keydr")
|
||||||
|
.join("config.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn target_cpm(&self) -> f64 {
|
||||||
|
self.target_wpm as f64 * 5.0
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/engine/filter.rs
Normal file
20
src/engine/filter.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
pub struct CharFilter {
|
||||||
|
pub allowed: Vec<char>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CharFilter {
|
||||||
|
pub fn new(allowed: Vec<char>) -> Self {
|
||||||
|
Self { allowed }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_allowed(&self, ch: char) -> bool {
|
||||||
|
self.allowed.contains(&ch) || ch == ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn filter_text(&self, text: &str) -> String {
|
||||||
|
text.chars()
|
||||||
|
.filter(|&ch| self.is_allowed(ch))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/engine/key_stats.rs
Normal file
120
src/engine/key_stats.rs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
const EMA_ALPHA: f64 = 0.1;
|
||||||
|
const DEFAULT_TARGET_CPM: f64 = 175.0;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct KeyStat {
|
||||||
|
pub filtered_time_ms: f64,
|
||||||
|
pub best_time_ms: f64,
|
||||||
|
pub confidence: f64,
|
||||||
|
pub sample_count: usize,
|
||||||
|
pub recent_times: Vec<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for KeyStat {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
filtered_time_ms: 1000.0,
|
||||||
|
best_time_ms: f64::MAX,
|
||||||
|
confidence: 0.0,
|
||||||
|
sample_count: 0,
|
||||||
|
recent_times: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct KeyStatsStore {
|
||||||
|
pub stats: HashMap<char, KeyStat>,
|
||||||
|
pub target_cpm: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for KeyStatsStore {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
stats: HashMap::new(),
|
||||||
|
target_cpm: DEFAULT_TARGET_CPM,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyStatsStore {
|
||||||
|
pub fn update_key(&mut self, key: char, time_ms: f64) {
|
||||||
|
let stat = self.stats.entry(key).or_default();
|
||||||
|
stat.sample_count += 1;
|
||||||
|
|
||||||
|
if stat.sample_count == 1 {
|
||||||
|
stat.filtered_time_ms = time_ms;
|
||||||
|
} else {
|
||||||
|
stat.filtered_time_ms =
|
||||||
|
EMA_ALPHA * time_ms + (1.0 - EMA_ALPHA) * stat.filtered_time_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
stat.best_time_ms = stat.best_time_ms.min(stat.filtered_time_ms);
|
||||||
|
|
||||||
|
let target_time_ms = 60000.0 / self.target_cpm;
|
||||||
|
stat.confidence = target_time_ms / stat.filtered_time_ms;
|
||||||
|
|
||||||
|
stat.recent_times.push(time_ms);
|
||||||
|
if stat.recent_times.len() > 30 {
|
||||||
|
stat.recent_times.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_confidence(&self, key: char) -> f64 {
|
||||||
|
self.stats
|
||||||
|
.get(&key)
|
||||||
|
.map(|s| s.confidence)
|
||||||
|
.unwrap_or(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn get_stat(&self, key: char) -> Option<&KeyStat> {
|
||||||
|
self.stats.get(&key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_initial_confidence_is_zero() {
|
||||||
|
let store = KeyStatsStore::default();
|
||||||
|
assert_eq!(store.get_confidence('a'), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_key_creates_stat() {
|
||||||
|
let mut store = KeyStatsStore::default();
|
||||||
|
store.update_key('e', 300.0);
|
||||||
|
assert!(store.get_confidence('e') > 0.0);
|
||||||
|
assert_eq!(store.stats.get(&'e').unwrap().sample_count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ema_converges() {
|
||||||
|
let mut store = KeyStatsStore::default();
|
||||||
|
// Type key fast many times - confidence should increase
|
||||||
|
for _ in 0..50 {
|
||||||
|
store.update_key('t', 200.0);
|
||||||
|
}
|
||||||
|
let conf = store.get_confidence('t');
|
||||||
|
// At 175 CPM target, target_time = 60000/175 = 342.8ms
|
||||||
|
// With 200ms typing time, confidence = 342.8/200 = 1.71
|
||||||
|
assert!(conf > 1.0, "confidence should be > 1.0 for fast typing, got {conf}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_slow_typing_low_confidence() {
|
||||||
|
let mut store = KeyStatsStore::default();
|
||||||
|
for _ in 0..50 {
|
||||||
|
store.update_key('a', 1000.0);
|
||||||
|
}
|
||||||
|
let conf = store.get_confidence('a');
|
||||||
|
// target_time = 342.8ms, typing at 1000ms -> conf = 342.8/1000 = 0.34
|
||||||
|
assert!(conf < 1.0, "confidence should be < 1.0 for slow typing, got {conf}");
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/engine/learning_rate.rs
Normal file
59
src/engine/learning_rate.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn polynomial_regression(times: &[f64]) -> Option<f64> {
|
||||||
|
if times.len() < 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let n = times.len();
|
||||||
|
let xs: Vec<f64> = (0..n).map(|i| i as f64).collect();
|
||||||
|
|
||||||
|
let x_mean: f64 = xs.iter().sum::<f64>() / n as f64;
|
||||||
|
let y_mean: f64 = times.iter().sum::<f64>() / n as f64;
|
||||||
|
|
||||||
|
let mut ss_xy = 0.0;
|
||||||
|
let mut ss_xx = 0.0;
|
||||||
|
let mut ss_yy = 0.0;
|
||||||
|
|
||||||
|
for i in 0..n {
|
||||||
|
let dx = xs[i] - x_mean;
|
||||||
|
let dy = times[i] - y_mean;
|
||||||
|
ss_xy += dx * dy;
|
||||||
|
ss_xx += dx * dx;
|
||||||
|
ss_yy += dy * dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ss_xx < 1e-10 || ss_yy < 1e-10 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let slope = ss_xy / ss_xx;
|
||||||
|
let r_squared = (ss_xy * ss_xy) / (ss_xx * ss_yy);
|
||||||
|
|
||||||
|
if r_squared < 0.5 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let predicted_next = y_mean + slope * (n as f64 - x_mean);
|
||||||
|
Some(predicted_next.max(0.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn learning_rate_description(times: &[f64]) -> &'static str {
|
||||||
|
match polynomial_regression(times) {
|
||||||
|
Some(predicted) => {
|
||||||
|
if times.is_empty() {
|
||||||
|
return "No data";
|
||||||
|
}
|
||||||
|
let current = times.last().unwrap();
|
||||||
|
let improvement = (current - predicted) / current * 100.0;
|
||||||
|
if improvement > 5.0 {
|
||||||
|
"Improving"
|
||||||
|
} else if improvement < -5.0 {
|
||||||
|
"Slowing down"
|
||||||
|
} else {
|
||||||
|
"Steady"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => "Not enough data",
|
||||||
|
}
|
||||||
|
}
|
||||||
151
src/engine/letter_unlock.rs
Normal file
151
src/engine/letter_unlock.rs
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
use crate::engine::key_stats::KeyStatsStore;
|
||||||
|
|
||||||
|
pub const FREQUENCY_ORDER: &[char] = &[
|
||||||
|
'e', 't', 'a', 'o', 'i', 'n', 's', 'h', 'r', 'd', 'l', 'c', 'u', 'm', 'w', 'f', 'g', 'y',
|
||||||
|
'p', 'b', 'v', 'k', 'j', 'x', 'q', 'z',
|
||||||
|
];
|
||||||
|
|
||||||
|
const MIN_LETTERS: usize = 6;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct LetterUnlock {
|
||||||
|
pub included: Vec<char>,
|
||||||
|
pub focused: Option<char>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LetterUnlock {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let included = FREQUENCY_ORDER[..MIN_LETTERS].to_vec();
|
||||||
|
Self {
|
||||||
|
included,
|
||||||
|
focused: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_included(included: Vec<char>) -> Self {
|
||||||
|
let mut lu = Self {
|
||||||
|
included,
|
||||||
|
focused: None,
|
||||||
|
};
|
||||||
|
lu.focused = None;
|
||||||
|
lu
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(&mut self, stats: &KeyStatsStore) {
|
||||||
|
let all_confident = self
|
||||||
|
.included
|
||||||
|
.iter()
|
||||||
|
.all(|&ch| stats.get_confidence(ch) >= 1.0);
|
||||||
|
|
||||||
|
if all_confident {
|
||||||
|
for &letter in FREQUENCY_ORDER {
|
||||||
|
if !self.included.contains(&letter) {
|
||||||
|
self.included.push(letter);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while self.included.len() < MIN_LETTERS {
|
||||||
|
for &letter in FREQUENCY_ORDER {
|
||||||
|
if !self.included.contains(&letter) {
|
||||||
|
self.included.push(letter);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.focused = self
|
||||||
|
.included
|
||||||
|
.iter()
|
||||||
|
.filter(|&&ch| stats.get_confidence(ch) < 1.0)
|
||||||
|
.min_by(|&&a, &&b| {
|
||||||
|
stats
|
||||||
|
.get_confidence(a)
|
||||||
|
.partial_cmp(&stats.get_confidence(b))
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
})
|
||||||
|
.copied();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn is_unlocked(&self, ch: char) -> bool {
|
||||||
|
self.included.contains(&ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unlocked_count(&self) -> usize {
|
||||||
|
self.included.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn total_letters(&self) -> usize {
|
||||||
|
FREQUENCY_ORDER.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn progress(&self) -> f64 {
|
||||||
|
self.unlocked_count() as f64 / self.total_letters() as f64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LetterUnlock {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::engine::key_stats::KeyStatsStore;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_initial_unlock_has_min_letters() {
|
||||||
|
let lu = LetterUnlock::new();
|
||||||
|
assert_eq!(lu.unlocked_count(), 6);
|
||||||
|
assert_eq!(&lu.included, &['e', 't', 'a', 'o', 'i', 'n']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_unlock_without_confidence() {
|
||||||
|
let mut lu = LetterUnlock::new();
|
||||||
|
let stats = KeyStatsStore::default();
|
||||||
|
lu.update(&stats);
|
||||||
|
assert_eq!(lu.unlocked_count(), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unlock_when_all_confident() {
|
||||||
|
let mut lu = LetterUnlock::new();
|
||||||
|
let mut stats = KeyStatsStore::default();
|
||||||
|
// Make all included keys confident by typing fast
|
||||||
|
for &ch in &['e', 't', 'a', 'o', 'i', 'n'] {
|
||||||
|
for _ in 0..50 {
|
||||||
|
stats.update_key(ch, 200.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lu.update(&stats);
|
||||||
|
assert_eq!(lu.unlocked_count(), 7);
|
||||||
|
assert!(lu.included.contains(&'s'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_focused_key_is_weakest() {
|
||||||
|
let mut lu = LetterUnlock::new();
|
||||||
|
let mut stats = KeyStatsStore::default();
|
||||||
|
// Make most keys confident except 'o'
|
||||||
|
for &ch in &['e', 't', 'a', 'i', 'n'] {
|
||||||
|
for _ in 0..50 {
|
||||||
|
stats.update_key(ch, 200.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats.update_key('o', 1000.0); // slow on 'o'
|
||||||
|
lu.update(&stats);
|
||||||
|
assert_eq!(lu.focused, Some('o'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_progress_ratio() {
|
||||||
|
let lu = LetterUnlock::new();
|
||||||
|
let expected = 6.0 / 26.0;
|
||||||
|
assert!((lu.progress() - expected).abs() < 0.001);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/engine/mod.rs
Normal file
5
src/engine/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod filter;
|
||||||
|
pub mod key_stats;
|
||||||
|
pub mod learning_rate;
|
||||||
|
pub mod letter_unlock;
|
||||||
|
pub mod scoring;
|
||||||
45
src/engine/scoring.rs
Normal file
45
src/engine/scoring.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
use crate::session::result::LessonResult;
|
||||||
|
|
||||||
|
pub fn compute_score(result: &LessonResult, complexity: f64) -> f64 {
|
||||||
|
let speed = result.cpm;
|
||||||
|
let errors = result.incorrect as f64;
|
||||||
|
let length = result.total_chars as f64;
|
||||||
|
(speed * complexity) / (errors + 1.0) * (length / 50.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compute_complexity(unlocked_count: usize) -> f64 {
|
||||||
|
(unlocked_count as f64 / 26.0).max(0.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn level_from_score(total_score: f64) -> u32 {
|
||||||
|
let level = (total_score / 100.0).sqrt() as u32;
|
||||||
|
level.max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn score_to_next_level(total_score: f64) -> f64 {
|
||||||
|
let current_level = level_from_score(total_score);
|
||||||
|
let next_level_score = ((current_level + 1) as f64).powi(2) * 100.0;
|
||||||
|
next_level_score - total_score
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_level_starts_at_one() {
|
||||||
|
assert_eq!(level_from_score(0.0), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_level_increases_with_score() {
|
||||||
|
assert!(level_from_score(1000.0) > level_from_score(100.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_complexity_scales_with_letters() {
|
||||||
|
assert!(compute_complexity(26) > compute_complexity(6));
|
||||||
|
assert!((compute_complexity(26) - 1.0).abs() < 0.001);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/event.rs
Normal file
49
src/event.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
use std::sync::mpsc;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crossterm::event::{self, Event, KeyEvent};
|
||||||
|
|
||||||
|
pub enum AppEvent {
|
||||||
|
Key(KeyEvent),
|
||||||
|
Tick,
|
||||||
|
Resize(#[allow(dead_code)] u16, #[allow(dead_code)] u16),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EventHandler {
|
||||||
|
rx: mpsc::Receiver<AppEvent>,
|
||||||
|
_tx: mpsc::Sender<AppEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventHandler {
|
||||||
|
pub fn new(tick_rate: Duration) -> Self {
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let _tx = tx.clone();
|
||||||
|
|
||||||
|
thread::spawn(move || loop {
|
||||||
|
if event::poll(tick_rate).unwrap_or(false) {
|
||||||
|
match event::read() {
|
||||||
|
Ok(Event::Key(key)) => {
|
||||||
|
if tx.send(AppEvent::Key(key)).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::Resize(w, h)) => {
|
||||||
|
if tx.send(AppEvent::Resize(w, h)).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
} else if tx.send(AppEvent::Tick).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self { rx, _tx }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next(&self) -> anyhow::Result<AppEvent> {
|
||||||
|
Ok(self.rx.recv()?)
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/generator/code_syntax.rs
Normal file
122
src/generator/code_syntax.rs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
use rand::rngs::SmallRng;
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
|
use crate::engine::filter::CharFilter;
|
||||||
|
use crate::generator::TextGenerator;
|
||||||
|
|
||||||
|
pub struct CodeSyntaxGenerator {
|
||||||
|
rng: SmallRng,
|
||||||
|
language: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CodeSyntaxGenerator {
|
||||||
|
pub fn new(rng: SmallRng, language: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
rng,
|
||||||
|
language: language.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rust_snippets() -> Vec<&'static str> {
|
||||||
|
vec![
|
||||||
|
"fn main() { println!(\"hello\"); }",
|
||||||
|
"let mut x = 0; x += 1;",
|
||||||
|
"for i in 0..10 { println!(\"{}\", i); }",
|
||||||
|
"if x > 0 { return true; }",
|
||||||
|
"match val { Some(x) => x, None => 0 }",
|
||||||
|
"struct Point { x: f64, y: f64 }",
|
||||||
|
"impl Point { fn new(x: f64, y: f64) -> Self { Self { x, y } } }",
|
||||||
|
"let v: Vec<i32> = vec![1, 2, 3];",
|
||||||
|
"fn add(a: i32, b: i32) -> i32 { a + b }",
|
||||||
|
"use std::collections::HashMap;",
|
||||||
|
"pub fn process(input: &str) -> Result<String, Error> { Ok(input.to_string()) }",
|
||||||
|
"let result = items.iter().filter(|x| x > &0).map(|x| x * 2).collect::<Vec<_>>();",
|
||||||
|
"enum Color { Red, Green, Blue }",
|
||||||
|
"trait Display { fn show(&self) -> String; }",
|
||||||
|
"while let Some(item) = stack.pop() { process(item); }",
|
||||||
|
"#[derive(Debug, Clone)] struct Config { name: String, value: i32 }",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn python_snippets() -> Vec<&'static str> {
|
||||||
|
vec![
|
||||||
|
"def main(): print(\"hello\")",
|
||||||
|
"for i in range(10): print(i)",
|
||||||
|
"if x > 0: return True",
|
||||||
|
"class Point: def __init__(self, x, y): self.x = x",
|
||||||
|
"import os; path = os.path.join(\"a\", \"b\")",
|
||||||
|
"result = [x * 2 for x in items if x > 0]",
|
||||||
|
"with open(\"file.txt\") as f: data = f.read()",
|
||||||
|
"def add(a: int, b: int) -> int: return a + b",
|
||||||
|
"try: result = process(data) except ValueError as e: print(e)",
|
||||||
|
"from collections import defaultdict",
|
||||||
|
"lambda x: x * 2 + 1",
|
||||||
|
"dict_comp = {k: v for k, v in pairs.items()}",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn javascript_snippets() -> Vec<&'static str> {
|
||||||
|
vec![
|
||||||
|
"const x = 42; console.log(x);",
|
||||||
|
"function add(a, b) { return a + b; }",
|
||||||
|
"const arr = [1, 2, 3].map(x => x * 2);",
|
||||||
|
"if (x > 0) { return true; }",
|
||||||
|
"for (let i = 0; i < 10; i++) { console.log(i); }",
|
||||||
|
"class Point { constructor(x, y) { this.x = x; this.y = y; } }",
|
||||||
|
"const { name, age } = person;",
|
||||||
|
"async function fetch(url) { const res = await get(url); return res.json(); }",
|
||||||
|
"const obj = { ...defaults, ...overrides };",
|
||||||
|
"try { parse(data); } catch (e) { console.error(e); }",
|
||||||
|
"export default function handler(req, res) { res.send(\"ok\"); }",
|
||||||
|
"const result = items.filter(x => x > 0).reduce((a, b) => a + b, 0);",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn go_snippets() -> Vec<&'static str> {
|
||||||
|
vec![
|
||||||
|
"func main() { fmt.Println(\"hello\") }",
|
||||||
|
"for i := 0; i < 10; i++ { fmt.Println(i) }",
|
||||||
|
"if err != nil { return err }",
|
||||||
|
"type Point struct { X float64; Y float64 }",
|
||||||
|
"func add(a, b int) int { return a + b }",
|
||||||
|
"import \"fmt\"",
|
||||||
|
"result := make([]int, 0, 10)",
|
||||||
|
"switch val { case 1: return \"one\" default: return \"other\" }",
|
||||||
|
"go func() { ch <- result }()",
|
||||||
|
"defer file.Close()",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_snippets(&self) -> Vec<&'static str> {
|
||||||
|
match self.language.as_str() {
|
||||||
|
"rust" => Self::rust_snippets(),
|
||||||
|
"python" => Self::python_snippets(),
|
||||||
|
"javascript" | "js" => Self::javascript_snippets(),
|
||||||
|
"go" => Self::go_snippets(),
|
||||||
|
_ => Self::rust_snippets(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextGenerator for CodeSyntaxGenerator {
|
||||||
|
fn generate(
|
||||||
|
&mut self,
|
||||||
|
_filter: &CharFilter,
|
||||||
|
_focused: Option<char>,
|
||||||
|
word_count: usize,
|
||||||
|
) -> String {
|
||||||
|
let snippets = self.get_snippets();
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let target_words = word_count;
|
||||||
|
let mut current_words = 0;
|
||||||
|
|
||||||
|
while current_words < target_words {
|
||||||
|
let idx = self.rng.gen_range(0..snippets.len());
|
||||||
|
let snippet = snippets[idx];
|
||||||
|
current_words += snippet.split_whitespace().count();
|
||||||
|
result.push(snippet);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.join(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/generator/github_code.rs
Normal file
41
src/generator/github_code.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use crate::engine::filter::CharFilter;
|
||||||
|
use crate::generator::TextGenerator;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct GitHubCodeGenerator {
|
||||||
|
cached_snippets: Vec<String>,
|
||||||
|
current_idx: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GitHubCodeGenerator {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
cached_snippets: Vec::new(),
|
||||||
|
current_idx: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GitHubCodeGenerator {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextGenerator for GitHubCodeGenerator {
|
||||||
|
fn generate(
|
||||||
|
&mut self,
|
||||||
|
_filter: &CharFilter,
|
||||||
|
_focused: Option<char>,
|
||||||
|
_word_count: usize,
|
||||||
|
) -> String {
|
||||||
|
if self.cached_snippets.is_empty() {
|
||||||
|
return "// GitHub code fetching not yet configured. Use settings to add a repository."
|
||||||
|
.to_string();
|
||||||
|
}
|
||||||
|
let snippet = self.cached_snippets[self.current_idx % self.cached_snippets.len()].clone();
|
||||||
|
self.current_idx += 1;
|
||||||
|
snippet
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/generator/mod.rs
Normal file
12
src/generator/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
pub mod code_syntax;
|
||||||
|
pub mod github_code;
|
||||||
|
pub mod passage;
|
||||||
|
pub mod phonetic;
|
||||||
|
pub mod transition_table;
|
||||||
|
|
||||||
|
use crate::engine::filter::CharFilter;
|
||||||
|
|
||||||
|
pub trait TextGenerator {
|
||||||
|
fn generate(&mut self, filter: &CharFilter, focused: Option<char>, word_count: usize)
|
||||||
|
-> String;
|
||||||
|
}
|
||||||
49
src/generator/passage.rs
Normal file
49
src/generator/passage.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
use crate::engine::filter::CharFilter;
|
||||||
|
use crate::generator::TextGenerator;
|
||||||
|
|
||||||
|
const PASSAGES: &[&str] = &[
|
||||||
|
"the quick brown fox jumps over the lazy dog and then runs across the field while the sun sets behind the distant hills",
|
||||||
|
"it was the best of times it was the worst of times it was the age of wisdom it was the age of foolishness",
|
||||||
|
"in the beginning there was nothing but darkness and then the light appeared slowly spreading across the vast empty space",
|
||||||
|
"she walked along the narrow path through the forest listening to the birds singing in the trees above her head",
|
||||||
|
"the old man sat on the bench watching the children play in the park while the autumn leaves fell softly around him",
|
||||||
|
"there is nothing either good or bad but thinking makes it so for the mind is its own place and in itself can make a heaven of hell",
|
||||||
|
"to be or not to be that is the question whether it is nobler in the mind to suffer the slings and arrows of outrageous fortune",
|
||||||
|
"all that glitters is not gold and not all those who wander are lost for the old that is strong does not wither",
|
||||||
|
"the river flowed quietly through the green valley and the mountains rose high on either side covered with trees and snow",
|
||||||
|
"a long time ago in a land far away there lived a wise king who ruled his people with kindness and justice",
|
||||||
|
"the rain fell steadily on the roof making a soft drumming sound that filled the room and made everything feel calm",
|
||||||
|
"she opened the door and stepped outside into the cool morning air breathing deeply as the first light of dawn appeared",
|
||||||
|
"he picked up the book and began to read turning the pages slowly as the story drew him deeper and deeper into its world",
|
||||||
|
"the stars shone brightly in the clear night sky and the moon cast a silver light over the sleeping town below",
|
||||||
|
"they gathered around the fire telling stories and laughing while the wind howled outside and the snow piled up against the door",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub struct PassageGenerator {
|
||||||
|
current_idx: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PassageGenerator {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { current_idx: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PassageGenerator {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextGenerator for PassageGenerator {
|
||||||
|
fn generate(
|
||||||
|
&mut self,
|
||||||
|
_filter: &CharFilter,
|
||||||
|
_focused: Option<char>,
|
||||||
|
_word_count: usize,
|
||||||
|
) -> String {
|
||||||
|
let passage = PASSAGES[self.current_idx % PASSAGES.len()];
|
||||||
|
self.current_idx += 1;
|
||||||
|
passage.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
169
src/generator/phonetic.rs
Normal file
169
src/generator/phonetic.rs
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
use rand::rngs::SmallRng;
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
|
use crate::engine::filter::CharFilter;
|
||||||
|
use crate::generator::transition_table::TransitionTable;
|
||||||
|
use crate::generator::TextGenerator;
|
||||||
|
|
||||||
|
pub struct PhoneticGenerator {
|
||||||
|
table: TransitionTable,
|
||||||
|
rng: SmallRng,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PhoneticGenerator {
|
||||||
|
pub fn new(table: TransitionTable, rng: SmallRng) -> Self {
|
||||||
|
Self { table, rng }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick_weighted_from(
|
||||||
|
rng: &mut SmallRng,
|
||||||
|
options: &[(char, f64)],
|
||||||
|
filter: &CharFilter,
|
||||||
|
) -> Option<char> {
|
||||||
|
let filtered: Vec<(char, f64)> = options
|
||||||
|
.iter()
|
||||||
|
.filter(|(ch, _)| filter.is_allowed(*ch))
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if filtered.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let total: f64 = filtered.iter().map(|(_, w)| w).sum();
|
||||||
|
if total <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut roll = rng.gen_range(0.0..total);
|
||||||
|
for (ch, weight) in &filtered {
|
||||||
|
roll -= weight;
|
||||||
|
if roll <= 0.0 {
|
||||||
|
return Some(*ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(filtered.last().unwrap().0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_word(&mut self, filter: &CharFilter, focused: Option<char>) -> String {
|
||||||
|
let min_len = 3;
|
||||||
|
let max_len = 10;
|
||||||
|
let mut word = String::new();
|
||||||
|
|
||||||
|
let start_char = if let Some(focus) = focused {
|
||||||
|
if self.rng.gen_bool(0.4) {
|
||||||
|
let probs = self.table.get_next_probs(' ', focus).cloned();
|
||||||
|
if let Some(probs) = probs {
|
||||||
|
let filtered: Vec<(char, f64)> = probs
|
||||||
|
.iter()
|
||||||
|
.filter(|(ch, _)| filter.is_allowed(*ch))
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
if !filtered.is_empty() {
|
||||||
|
word.push(focus);
|
||||||
|
Self::pick_weighted_from(&mut self.rng, &filtered, filter)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Some(focus)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if word.is_empty() {
|
||||||
|
let starters: Vec<(char, f64)> = filter
|
||||||
|
.allowed
|
||||||
|
.iter()
|
||||||
|
.map(|&ch| {
|
||||||
|
(
|
||||||
|
ch,
|
||||||
|
if ch == 'e' || ch == 't' || ch == 'a' {
|
||||||
|
3.0
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if let Some(ch) = Self::pick_weighted_from(&mut self.rng, &starters, filter) {
|
||||||
|
word.push(ch);
|
||||||
|
} else {
|
||||||
|
return "the".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ch) = start_char {
|
||||||
|
word.push(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
while word.len() < max_len {
|
||||||
|
let chars: Vec<char> = word.chars().collect();
|
||||||
|
let len = chars.len();
|
||||||
|
|
||||||
|
let (prev, curr) = if len >= 2 {
|
||||||
|
(chars[len - 2], chars[len - 1])
|
||||||
|
} else {
|
||||||
|
(' ', chars[len - 1])
|
||||||
|
};
|
||||||
|
|
||||||
|
let space_prob = 1.3f64.powi(word.len() as i32 - min_len as i32);
|
||||||
|
if word.len() >= min_len
|
||||||
|
&& self
|
||||||
|
.rng
|
||||||
|
.gen_bool((space_prob / (space_prob + 5.0)).min(0.8))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let probs = self.table.get_next_probs(prev, curr).cloned();
|
||||||
|
if let Some(probs) = probs {
|
||||||
|
if let Some(next) = Self::pick_weighted_from(&mut self.rng, &probs, filter) {
|
||||||
|
word.push(next);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let vowels: Vec<(char, f64)> = ['a', 'e', 'i', 'o', 'u']
|
||||||
|
.iter()
|
||||||
|
.filter(|&&v| filter.is_allowed(v))
|
||||||
|
.map(|&v| (v, 1.0))
|
||||||
|
.collect();
|
||||||
|
if let Some(v) = Self::pick_weighted_from(&mut self.rng, &vowels, filter) {
|
||||||
|
word.push(v);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if word.is_empty() {
|
||||||
|
"the".to_string()
|
||||||
|
} else {
|
||||||
|
word
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextGenerator for PhoneticGenerator {
|
||||||
|
fn generate(
|
||||||
|
&mut self,
|
||||||
|
filter: &CharFilter,
|
||||||
|
focused: Option<char>,
|
||||||
|
word_count: usize,
|
||||||
|
) -> String {
|
||||||
|
let mut words: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for _ in 0..word_count {
|
||||||
|
words.push(self.generate_word(filter, focused));
|
||||||
|
}
|
||||||
|
|
||||||
|
words.join(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/generator/transition_table.rs
Normal file
98
src/generator/transition_table.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct TransitionTable {
|
||||||
|
pub transitions: HashMap<(char, char), Vec<(char, f64)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransitionTable {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
transitions: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add(&mut self, prev: char, curr: char, next: char, weight: f64) {
|
||||||
|
self.transitions
|
||||||
|
.entry((prev, curr))
|
||||||
|
.or_default()
|
||||||
|
.push((next, weight));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_next_probs(&self, prev: char, curr: char) -> Option<&Vec<(char, f64)>> {
|
||||||
|
self.transitions.get(&(prev, curr))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_english() -> Self {
|
||||||
|
let mut table = Self::new();
|
||||||
|
|
||||||
|
let common_patterns: &[(&str, f64)] = &[
|
||||||
|
("the", 10.0), ("and", 8.0), ("ing", 7.0), ("tion", 6.0), ("ent", 5.0),
|
||||||
|
("ion", 5.0), ("her", 4.0), ("for", 4.0), ("are", 4.0), ("his", 4.0),
|
||||||
|
("hat", 3.0), ("tha", 3.0), ("ere", 3.0), ("ate", 3.0), ("ith", 3.0),
|
||||||
|
("ver", 3.0), ("all", 3.0), ("not", 3.0), ("ess", 3.0), ("est", 3.0),
|
||||||
|
("rea", 3.0), ("sta", 3.0), ("ted", 3.0), ("com", 3.0), ("con", 3.0),
|
||||||
|
("oun", 2.5), ("pro", 2.5), ("oth", 2.5), ("igh", 2.5), ("ore", 2.5),
|
||||||
|
("our", 2.5), ("ine", 2.5), ("ove", 2.5), ("ome", 2.5), ("use", 2.5),
|
||||||
|
("ble", 2.0), ("ful", 2.0), ("ous", 2.0), ("str", 2.0), ("tri", 2.0),
|
||||||
|
("ght", 2.0), ("whi", 2.0), ("who", 2.0), ("hen", 2.0), ("ter", 2.0),
|
||||||
|
("man", 2.0), ("men", 2.0), ("ner", 2.0), ("per", 2.0), ("pre", 2.0),
|
||||||
|
("ran", 2.0), ("lin", 2.0), ("kin", 2.0), ("din", 2.0), ("sin", 2.0),
|
||||||
|
("out", 2.0), ("ind", 2.0), ("ith", 2.0), ("ber", 2.0), ("der", 2.0),
|
||||||
|
("end", 2.0), ("hin", 2.0), ("old", 2.0), ("ear", 2.0), ("ain", 2.0),
|
||||||
|
("ant", 2.0), ("urn", 2.0), ("ell", 2.0), ("ill", 2.0), ("ade", 2.0),
|
||||||
|
("igh", 2.0), ("ong", 2.0), ("ung", 2.0), ("ast", 2.0), ("ist", 2.0),
|
||||||
|
("ust", 2.0), ("ost", 2.0), ("ard", 2.0), ("ord", 2.0), ("art", 2.0),
|
||||||
|
("ort", 2.0), ("ect", 2.0), ("act", 2.0), ("ack", 2.0), ("ick", 2.0),
|
||||||
|
("ock", 2.0), ("uck", 2.0), ("ash", 2.0), ("ish", 2.0), ("ush", 2.0),
|
||||||
|
("anc", 1.5), ("enc", 1.5), ("inc", 1.5), ("onc", 1.5), ("unc", 1.5),
|
||||||
|
("unt", 1.5), ("int", 1.5), ("ont", 1.5), ("ent", 1.5), ("ment", 1.5),
|
||||||
|
("ness", 1.5), ("less", 1.5), ("able", 1.5), ("ible", 1.5), ("ting", 1.5),
|
||||||
|
("ring", 1.5), ("sing", 1.5), ("king", 1.5), ("ning", 1.5), ("ling", 1.5),
|
||||||
|
("wing", 1.5), ("ding", 1.5), ("ping", 1.5), ("ging", 1.5), ("ving", 1.5),
|
||||||
|
("bing", 1.5), ("ming", 1.5), ("fing", 1.0), ("hing", 1.0), ("cing", 1.0),
|
||||||
|
];
|
||||||
|
|
||||||
|
for &(pattern, weight) in common_patterns {
|
||||||
|
let chars: Vec<char> = pattern.chars().collect();
|
||||||
|
for window in chars.windows(3) {
|
||||||
|
table.add(window[0], window[1], window[2], weight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let vowels = ['a', 'e', 'i', 'o', 'u'];
|
||||||
|
let consonants = [
|
||||||
|
'b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'n', 'p', 'r', 's', 't', 'v',
|
||||||
|
'w', 'x', 'y', 'z',
|
||||||
|
];
|
||||||
|
|
||||||
|
for &c in &consonants {
|
||||||
|
for &v in &vowels {
|
||||||
|
table.add(' ', c, v, 1.0);
|
||||||
|
table.add(v, c, 'e', 0.5);
|
||||||
|
for &v2 in &vowels {
|
||||||
|
table.add(c, v, v2.to_ascii_lowercase(), 0.3);
|
||||||
|
}
|
||||||
|
for &c2 in &consonants {
|
||||||
|
table.add(v, c, c2, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for &v in &vowels {
|
||||||
|
for &c in &consonants {
|
||||||
|
table.add(' ', v, c, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TransitionTable {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/keyboard/finger.rs
Normal file
50
src/keyboard/finger.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Hand {
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Finger {
|
||||||
|
Pinky,
|
||||||
|
Ring,
|
||||||
|
Middle,
|
||||||
|
Index,
|
||||||
|
Thumb,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct FingerAssignment {
|
||||||
|
pub hand: Hand,
|
||||||
|
pub finger: Finger,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FingerAssignment {
|
||||||
|
pub fn new(hand: Hand, finger: Finger) -> Self {
|
||||||
|
Self { hand, finger }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn qwerty_finger(ch: char) -> FingerAssignment {
|
||||||
|
use Finger::*;
|
||||||
|
use Hand::*;
|
||||||
|
|
||||||
|
match ch {
|
||||||
|
'q' | 'a' | 'z' | '1' => FingerAssignment::new(Left, Pinky),
|
||||||
|
'w' | 's' | 'x' | '2' => FingerAssignment::new(Left, Ring),
|
||||||
|
'e' | 'd' | 'c' | '3' => FingerAssignment::new(Left, Middle),
|
||||||
|
'r' | 'f' | 'v' | 't' | 'g' | 'b' | '4' | '5' => FingerAssignment::new(Left, Index),
|
||||||
|
'y' | 'h' | 'n' | 'u' | 'j' | 'm' | '6' | '7' => FingerAssignment::new(Right, Index),
|
||||||
|
'i' | 'k' | ',' | '8' => FingerAssignment::new(Right, Middle),
|
||||||
|
'o' | 'l' | '.' | '9' => FingerAssignment::new(Right, Ring),
|
||||||
|
'p' | ';' | '/' | '0' | '-' | '=' | '[' | ']' | '\'' | '\\' => {
|
||||||
|
FingerAssignment::new(Right, Pinky)
|
||||||
|
}
|
||||||
|
' ' => FingerAssignment::new(Right, Thumb),
|
||||||
|
_ => FingerAssignment::new(Right, Index),
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/keyboard/layout.rs
Normal file
51
src/keyboard/layout.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct KeyboardLayout {
|
||||||
|
pub name: String,
|
||||||
|
pub rows: Vec<Vec<char>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyboardLayout {
|
||||||
|
pub fn qwerty() -> Self {
|
||||||
|
Self {
|
||||||
|
name: "QWERTY".to_string(),
|
||||||
|
rows: vec![
|
||||||
|
vec!['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
|
||||||
|
vec!['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
|
||||||
|
vec!['z', 'x', 'c', 'v', 'b', 'n', 'm'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn dvorak() -> Self {
|
||||||
|
Self {
|
||||||
|
name: "Dvorak".to_string(),
|
||||||
|
rows: vec![
|
||||||
|
vec!['\'', ',', '.', 'p', 'y', 'f', 'g', 'c', 'r', 'l'],
|
||||||
|
vec!['a', 'o', 'e', 'u', 'i', 'd', 'h', 't', 'n', 's'],
|
||||||
|
vec![';', 'q', 'j', 'k', 'x', 'b', 'm', 'w', 'v', 'z'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn colemak() -> Self {
|
||||||
|
Self {
|
||||||
|
name: "Colemak".to_string(),
|
||||||
|
rows: vec![
|
||||||
|
vec!['q', 'w', 'f', 'p', 'g', 'j', 'l', 'u', 'y'],
|
||||||
|
vec!['a', 'r', 's', 't', 'd', 'h', 'n', 'e', 'i', 'o'],
|
||||||
|
vec!['z', 'x', 'c', 'v', 'b', 'k', 'm'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for KeyboardLayout {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::qwerty()
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/keyboard/mod.rs
Normal file
2
src/keyboard/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod finger;
|
||||||
|
pub mod layout;
|
||||||
410
src/main.rs
410
src/main.rs
@@ -1,3 +1,409 @@
|
|||||||
fn main() {
|
mod app;
|
||||||
println!("Hello, world!");
|
mod config;
|
||||||
|
mod engine;
|
||||||
|
mod event;
|
||||||
|
mod generator;
|
||||||
|
mod keyboard;
|
||||||
|
mod session;
|
||||||
|
mod store;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Parser;
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use crossterm::execute;
|
||||||
|
use crossterm::terminal::{
|
||||||
|
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
||||||
|
};
|
||||||
|
use ratatui::backend::CrosstermBackend;
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout};
|
||||||
|
use ratatui::style::{Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Paragraph};
|
||||||
|
use ratatui::Terminal;
|
||||||
|
|
||||||
|
use app::{App, AppScreen, LessonMode};
|
||||||
|
use session::result::LessonResult;
|
||||||
|
use event::{AppEvent, EventHandler};
|
||||||
|
use ui::components::dashboard::Dashboard;
|
||||||
|
use ui::components::keyboard_diagram::KeyboardDiagram;
|
||||||
|
use ui::components::progress_bar::ProgressBar;
|
||||||
|
use ui::components::stats_dashboard::StatsDashboard;
|
||||||
|
use ui::components::stats_sidebar::StatsSidebar;
|
||||||
|
use ui::components::typing_area::TypingArea;
|
||||||
|
use ui::layout::AppLayout;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "keydr", version, about = "Terminal typing tutor with adaptive learning")]
|
||||||
|
struct Cli {
|
||||||
|
#[arg(short, long, help = "Theme name")]
|
||||||
|
theme: Option<String>,
|
||||||
|
|
||||||
|
#[arg(short, long, help = "Keyboard layout (qwerty, dvorak, colemak)")]
|
||||||
|
layout: Option<String>,
|
||||||
|
|
||||||
|
#[arg(short, long, help = "Number of words per lesson")]
|
||||||
|
words: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
|
||||||
|
if let Some(words) = cli.words {
|
||||||
|
app.config.word_count = words;
|
||||||
|
}
|
||||||
|
if let Some(theme_name) = cli.theme {
|
||||||
|
if let Some(theme) = ui::theme::Theme::load(&theme_name) {
|
||||||
|
let theme: &'static ui::theme::Theme = Box::leak(Box::new(theme));
|
||||||
|
app.theme = theme;
|
||||||
|
app.menu.theme = theme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
let events = EventHandler::new(Duration::from_millis(100));
|
||||||
|
|
||||||
|
let result = run_app(&mut terminal, &mut app, &events);
|
||||||
|
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
|
if let Err(err) = result {
|
||||||
|
eprintln!("Error: {err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_app(
|
||||||
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
|
app: &mut App,
|
||||||
|
events: &EventHandler,
|
||||||
|
) -> Result<()> {
|
||||||
|
loop {
|
||||||
|
terminal.draw(|frame| render(frame, app))?;
|
||||||
|
|
||||||
|
match events.next()? {
|
||||||
|
AppEvent::Key(key) => handle_key(app, key),
|
||||||
|
AppEvent::Tick => {}
|
||||||
|
AppEvent::Resize(_, _) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.should_quit {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(app: &mut App, key: KeyEvent) {
|
||||||
|
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
|
||||||
|
app.should_quit = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match app.screen {
|
||||||
|
AppScreen::Menu => handle_menu_key(app, key),
|
||||||
|
AppScreen::Lesson => handle_lesson_key(app, key),
|
||||||
|
AppScreen::LessonResult => handle_result_key(app, key),
|
||||||
|
AppScreen::StatsDashboard => handle_stats_key(app, key),
|
||||||
|
AppScreen::Settings => handle_settings_key(app, key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_menu_key(app: &mut App, key: KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
|
||||||
|
KeyCode::Char('1') => {
|
||||||
|
app.lesson_mode = LessonMode::Adaptive;
|
||||||
|
app.start_lesson();
|
||||||
|
}
|
||||||
|
KeyCode::Char('2') => {
|
||||||
|
app.lesson_mode = LessonMode::Code;
|
||||||
|
app.start_lesson();
|
||||||
|
}
|
||||||
|
KeyCode::Char('3') => {
|
||||||
|
app.lesson_mode = LessonMode::Passage;
|
||||||
|
app.start_lesson();
|
||||||
|
}
|
||||||
|
KeyCode::Char('s') => app.go_to_stats(),
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => app.menu.prev(),
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => app.menu.next(),
|
||||||
|
KeyCode::Enter => match app.menu.selected {
|
||||||
|
0 => {
|
||||||
|
app.lesson_mode = LessonMode::Adaptive;
|
||||||
|
app.start_lesson();
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
app.lesson_mode = LessonMode::Code;
|
||||||
|
app.start_lesson();
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
app.lesson_mode = LessonMode::Passage;
|
||||||
|
app.start_lesson();
|
||||||
|
}
|
||||||
|
3 => app.go_to_stats(),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_lesson_key(app: &mut App, key: KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
let has_progress = app.lesson.as_ref().is_some_and(|l| l.cursor > 0);
|
||||||
|
if has_progress {
|
||||||
|
if let Some(ref lesson) = app.lesson {
|
||||||
|
let result = LessonResult::from_lesson(lesson, &app.lesson_events);
|
||||||
|
app.last_result = Some(result);
|
||||||
|
}
|
||||||
|
app.screen = AppScreen::LessonResult;
|
||||||
|
} else {
|
||||||
|
app.go_to_menu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => app.backspace(),
|
||||||
|
KeyCode::Char(ch) => app.type_char(ch),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_result_key(app: &mut App, key: KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('r') => app.retry_lesson(),
|
||||||
|
KeyCode::Char('q') | KeyCode::Esc => app.go_to_menu(),
|
||||||
|
KeyCode::Char('s') => app.go_to_stats(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_stats_key(app: &mut App, key: KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_settings_key(app: &mut App, key: KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => app.go_to_menu(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(frame: &mut ratatui::Frame, app: &App) {
|
||||||
|
let area = frame.area();
|
||||||
|
let colors = &app.theme.colors;
|
||||||
|
|
||||||
|
let bg = Block::default().style(Style::default().bg(colors.bg()));
|
||||||
|
frame.render_widget(bg, area);
|
||||||
|
|
||||||
|
match app.screen {
|
||||||
|
AppScreen::Menu => render_menu(frame, app),
|
||||||
|
AppScreen::Lesson => render_lesson(frame, app),
|
||||||
|
AppScreen::LessonResult => render_result(frame, app),
|
||||||
|
AppScreen::StatsDashboard => render_stats(frame, app),
|
||||||
|
AppScreen::Settings => render_settings(frame, app),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_menu(frame: &mut ratatui::Frame, app: &App) {
|
||||||
|
let area = frame.area();
|
||||||
|
let colors = &app.theme.colors;
|
||||||
|
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(0),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let streak_text = if app.profile.streak_days > 0 {
|
||||||
|
format!(" | {} day streak", app.profile.streak_days)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let header_info = format!(
|
||||||
|
" Level {} | Score {:.0} | {}/{} letters{}",
|
||||||
|
crate::engine::scoring::level_from_score(app.profile.total_score),
|
||||||
|
app.profile.total_score,
|
||||||
|
app.letter_unlock.unlocked_count(),
|
||||||
|
app.letter_unlock.total_letters(),
|
||||||
|
streak_text,
|
||||||
|
);
|
||||||
|
let header = Paragraph::new(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
" keydr ",
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.header_fg())
|
||||||
|
.bg(colors.header_bg())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
&*header_info,
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.text_pending())
|
||||||
|
.bg(colors.header_bg()),
|
||||||
|
),
|
||||||
|
]))
|
||||||
|
.style(Style::default().bg(colors.header_bg()));
|
||||||
|
frame.render_widget(header, layout[0]);
|
||||||
|
|
||||||
|
let menu_area = ui::layout::centered_rect(50, 80, layout[1]);
|
||||||
|
frame.render_widget(&app.menu, menu_area);
|
||||||
|
|
||||||
|
let footer = Paragraph::new(Line::from(vec![Span::styled(
|
||||||
|
" [1-3] Start [s] Stats [q] Quit ",
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
)]));
|
||||||
|
frame.render_widget(footer, layout[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_lesson(frame: &mut ratatui::Frame, app: &App) {
|
||||||
|
let area = frame.area();
|
||||||
|
let colors = &app.theme.colors;
|
||||||
|
|
||||||
|
if let Some(ref lesson) = app.lesson {
|
||||||
|
let app_layout = AppLayout::new(area);
|
||||||
|
|
||||||
|
let mode_name = match app.lesson_mode {
|
||||||
|
LessonMode::Adaptive => "Adaptive",
|
||||||
|
LessonMode::Code => "Code",
|
||||||
|
LessonMode::Passage => "Passage",
|
||||||
|
};
|
||||||
|
let header_title = format!(" {mode_name} Practice ");
|
||||||
|
let focus_text = if let Some(focused) = app.letter_unlock.focused {
|
||||||
|
format!(" | Focus: '{focused}'")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let header = Paragraph::new(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
&*header_title,
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.header_fg())
|
||||||
|
.bg(colors.header_bg())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
&*focus_text,
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.focused_key())
|
||||||
|
.bg(colors.header_bg()),
|
||||||
|
),
|
||||||
|
]))
|
||||||
|
.style(Style::default().bg(colors.header_bg()));
|
||||||
|
frame.render_widget(header, app_layout.header);
|
||||||
|
|
||||||
|
let main_layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Min(5),
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Length(4),
|
||||||
|
])
|
||||||
|
.split(app_layout.main);
|
||||||
|
|
||||||
|
let typing = TypingArea::new(lesson, app.theme);
|
||||||
|
frame.render_widget(typing, main_layout[0]);
|
||||||
|
|
||||||
|
let progress = ProgressBar::new(
|
||||||
|
"Letter Progress",
|
||||||
|
app.letter_unlock.progress(),
|
||||||
|
app.theme,
|
||||||
|
);
|
||||||
|
frame.render_widget(progress, main_layout[1]);
|
||||||
|
|
||||||
|
let kbd = KeyboardDiagram::new(
|
||||||
|
app.letter_unlock.focused,
|
||||||
|
&app.letter_unlock.included,
|
||||||
|
app.theme,
|
||||||
|
);
|
||||||
|
frame.render_widget(kbd, main_layout[2]);
|
||||||
|
|
||||||
|
let sidebar = StatsSidebar::new(lesson, app.theme);
|
||||||
|
frame.render_widget(sidebar, app_layout.sidebar);
|
||||||
|
|
||||||
|
let footer = Paragraph::new(Line::from(Span::styled(
|
||||||
|
" [ESC] End lesson [Backspace] Delete ",
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
)));
|
||||||
|
frame.render_widget(footer, app_layout.footer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_result(frame: &mut ratatui::Frame, app: &App) {
|
||||||
|
let area = frame.area();
|
||||||
|
|
||||||
|
if let Some(ref result) = app.last_result {
|
||||||
|
let centered = ui::layout::centered_rect(60, 70, area);
|
||||||
|
let dashboard = Dashboard::new(result, app.theme);
|
||||||
|
frame.render_widget(dashboard, centered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_stats(frame: &mut ratatui::Frame, app: &App) {
|
||||||
|
let area = frame.area();
|
||||||
|
let dashboard = StatsDashboard::new(&app.lesson_history, app.theme);
|
||||||
|
frame.render_widget(dashboard, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_settings(frame: &mut ratatui::Frame, app: &App) {
|
||||||
|
let area = frame.area();
|
||||||
|
let colors = &app.theme.colors;
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Settings ")
|
||||||
|
.border_style(Style::default().fg(colors.accent()));
|
||||||
|
|
||||||
|
let target_wpm = format!(" Target WPM: {}", app.config.target_wpm);
|
||||||
|
let theme_name = format!(" Theme: {}", app.config.theme);
|
||||||
|
let layout_name = format!(" Layout: {}", app.config.keyboard_layout);
|
||||||
|
let languages = format!(" Languages: {}", app.config.code_languages.join(", "));
|
||||||
|
|
||||||
|
let lines = vec![
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" Settings coming soon...",
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
)),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
&*target_wpm,
|
||||||
|
Style::default().fg(colors.fg()),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
&*theme_name,
|
||||||
|
Style::default().fg(colors.fg()),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
&*layout_name,
|
||||||
|
Style::default().fg(colors.fg()),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
&*languages,
|
||||||
|
Style::default().fg(colors.fg()),
|
||||||
|
)),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" [ESC] Back",
|
||||||
|
Style::default().fg(colors.accent()),
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(lines).block(block);
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
}
|
}
|
||||||
|
|||||||
58
src/session/input.rs
Normal file
58
src/session/input.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use crate::session::lesson::LessonState;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum CharStatus {
|
||||||
|
Correct,
|
||||||
|
Incorrect(char),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct KeystrokeEvent {
|
||||||
|
pub expected: char,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub actual: char,
|
||||||
|
pub timestamp: Instant,
|
||||||
|
pub correct: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process_char(lesson: &mut LessonState, ch: char) -> Option<KeystrokeEvent> {
|
||||||
|
if lesson.is_complete() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if lesson.started_at.is_none() {
|
||||||
|
lesson.started_at = Some(Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected = lesson.target[lesson.cursor];
|
||||||
|
let correct = ch == expected;
|
||||||
|
|
||||||
|
let event = KeystrokeEvent {
|
||||||
|
expected,
|
||||||
|
actual: ch,
|
||||||
|
timestamp: Instant::now(),
|
||||||
|
correct,
|
||||||
|
};
|
||||||
|
|
||||||
|
if correct {
|
||||||
|
lesson.input.push(CharStatus::Correct);
|
||||||
|
} else {
|
||||||
|
lesson.input.push(CharStatus::Incorrect(ch));
|
||||||
|
}
|
||||||
|
lesson.cursor += 1;
|
||||||
|
|
||||||
|
if lesson.is_complete() {
|
||||||
|
lesson.finished_at = Some(Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process_backspace(lesson: &mut LessonState) {
|
||||||
|
if lesson.cursor > 0 {
|
||||||
|
lesson.cursor -= 1;
|
||||||
|
lesson.input.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/session/lesson.rs
Normal file
108
src/session/lesson.rs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use crate::session::input::CharStatus;
|
||||||
|
|
||||||
|
pub struct LessonState {
|
||||||
|
pub target: Vec<char>,
|
||||||
|
pub input: Vec<CharStatus>,
|
||||||
|
pub cursor: usize,
|
||||||
|
pub started_at: Option<Instant>,
|
||||||
|
pub finished_at: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LessonState {
|
||||||
|
pub fn new(text: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
target: text.chars().collect(),
|
||||||
|
input: Vec::new(),
|
||||||
|
cursor: 0,
|
||||||
|
started_at: None,
|
||||||
|
finished_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_complete(&self) -> bool {
|
||||||
|
self.cursor >= self.target.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn elapsed_secs(&self) -> f64 {
|
||||||
|
match (self.started_at, self.finished_at) {
|
||||||
|
(Some(start), Some(end)) => end.duration_since(start).as_secs_f64(),
|
||||||
|
(Some(start), None) => start.elapsed().as_secs_f64(),
|
||||||
|
_ => 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn correct_count(&self) -> usize {
|
||||||
|
self.input
|
||||||
|
.iter()
|
||||||
|
.filter(|s| matches!(s, CharStatus::Correct))
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn incorrect_count(&self) -> usize {
|
||||||
|
self.input
|
||||||
|
.iter()
|
||||||
|
.filter(|s| matches!(s, CharStatus::Incorrect(_)))
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wpm(&self) -> f64 {
|
||||||
|
let elapsed = self.elapsed_secs();
|
||||||
|
if elapsed < 0.1 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let chars = self.correct_count() as f64;
|
||||||
|
(chars / 5.0) / (elapsed / 60.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn accuracy(&self) -> f64 {
|
||||||
|
let total = self.input.len();
|
||||||
|
if total == 0 {
|
||||||
|
return 100.0;
|
||||||
|
}
|
||||||
|
(self.correct_count() as f64 / total as f64) * 100.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cpm(&self) -> f64 {
|
||||||
|
let elapsed = self.elapsed_secs();
|
||||||
|
if elapsed < 0.1 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
self.correct_count() as f64 / (elapsed / 60.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn progress(&self) -> f64 {
|
||||||
|
if self.target.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
self.cursor as f64 / self.target.len() as f64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_lesson() {
|
||||||
|
let lesson = LessonState::new("hello");
|
||||||
|
assert_eq!(lesson.target.len(), 5);
|
||||||
|
assert_eq!(lesson.cursor, 0);
|
||||||
|
assert!(!lesson.is_complete());
|
||||||
|
assert_eq!(lesson.progress(), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_accuracy_starts_at_100() {
|
||||||
|
let lesson = LessonState::new("test");
|
||||||
|
assert_eq!(lesson.accuracy(), 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_lesson_progress() {
|
||||||
|
let lesson = LessonState::new("");
|
||||||
|
assert!(lesson.is_complete());
|
||||||
|
assert_eq!(lesson.progress(), 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/session/mod.rs
Normal file
3
src/session/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod input;
|
||||||
|
pub mod lesson;
|
||||||
|
pub mod result;
|
||||||
53
src/session/result.rs
Normal file
53
src/session/result.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::session::input::KeystrokeEvent;
|
||||||
|
use crate::session::lesson::LessonState;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct LessonResult {
|
||||||
|
pub wpm: f64,
|
||||||
|
pub cpm: f64,
|
||||||
|
pub accuracy: f64,
|
||||||
|
pub correct: usize,
|
||||||
|
pub incorrect: usize,
|
||||||
|
pub total_chars: usize,
|
||||||
|
pub elapsed_secs: f64,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
pub per_key_times: Vec<KeyTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct KeyTime {
|
||||||
|
pub key: char,
|
||||||
|
pub time_ms: f64,
|
||||||
|
pub correct: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LessonResult {
|
||||||
|
pub fn from_lesson(lesson: &LessonState, events: &[KeystrokeEvent]) -> Self {
|
||||||
|
let per_key_times: Vec<KeyTime> = events
|
||||||
|
.windows(2)
|
||||||
|
.map(|pair| {
|
||||||
|
let dt = pair[1].timestamp.duration_since(pair[0].timestamp);
|
||||||
|
KeyTime {
|
||||||
|
key: pair[1].expected,
|
||||||
|
time_ms: dt.as_secs_f64() * 1000.0,
|
||||||
|
correct: pair[1].correct,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
wpm: lesson.wpm(),
|
||||||
|
cpm: lesson.cpm(),
|
||||||
|
accuracy: lesson.accuracy(),
|
||||||
|
correct: lesson.correct_count(),
|
||||||
|
incorrect: lesson.incorrect_count(),
|
||||||
|
total_chars: lesson.target.len(),
|
||||||
|
elapsed_secs: lesson.elapsed_secs(),
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
per_key_times,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/store/json_store.rs
Normal file
75
src/store/json_store.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
|
|
||||||
|
use crate::store::schema::{KeyStatsData, LessonHistoryData, ProfileData};
|
||||||
|
|
||||||
|
pub struct JsonStore {
|
||||||
|
base_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonStore {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
let base_dir = dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("keydr");
|
||||||
|
fs::create_dir_all(&base_dir)?;
|
||||||
|
Ok(Self { base_dir })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_path(&self, name: &str) -> PathBuf {
|
||||||
|
self.base_dir.join(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load<T: DeserializeOwned + Default>(&self, name: &str) -> T {
|
||||||
|
let path = self.file_path(name);
|
||||||
|
if path.exists() {
|
||||||
|
match fs::read_to_string(&path) {
|
||||||
|
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
||||||
|
Err(_) => T::default(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
T::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save<T: Serialize>(&self, name: &str, data: &T) -> Result<()> {
|
||||||
|
let path = self.file_path(name);
|
||||||
|
let tmp_path = path.with_extension("tmp");
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(data)?;
|
||||||
|
let mut file = fs::File::create(&tmp_path)?;
|
||||||
|
file.write_all(json.as_bytes())?;
|
||||||
|
file.sync_all()?;
|
||||||
|
|
||||||
|
fs::rename(&tmp_path, &path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_profile(&self) -> ProfileData {
|
||||||
|
self.load("profile.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_profile(&self, data: &ProfileData) -> Result<()> {
|
||||||
|
self.save("profile.json", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_key_stats(&self) -> KeyStatsData {
|
||||||
|
self.load("key_stats.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_key_stats(&self, data: &KeyStatsData) -> Result<()> {
|
||||||
|
self.save("key_stats.json", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_lesson_history(&self) -> LessonHistoryData {
|
||||||
|
self.load("lesson_history.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_lesson_history(&self, data: &LessonHistoryData) -> Result<()> {
|
||||||
|
self.save("lesson_history.json", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/store/mod.rs
Normal file
2
src/store/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod json_store;
|
||||||
|
pub mod schema;
|
||||||
61
src/store/schema.rs
Normal file
61
src/store/schema.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::engine::key_stats::KeyStatsStore;
|
||||||
|
use crate::session::result::LessonResult;
|
||||||
|
|
||||||
|
const SCHEMA_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileData {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub unlocked_letters: Vec<char>,
|
||||||
|
pub total_score: f64,
|
||||||
|
pub total_lessons: u32,
|
||||||
|
pub streak_days: u32,
|
||||||
|
pub best_streak: u32,
|
||||||
|
pub last_practice_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProfileData {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
unlocked_letters: Vec::new(),
|
||||||
|
total_score: 0.0,
|
||||||
|
total_lessons: 0,
|
||||||
|
streak_days: 0,
|
||||||
|
best_streak: 0,
|
||||||
|
last_practice_date: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct KeyStatsData {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub stats: KeyStatsStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for KeyStatsData {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
stats: KeyStatsStore::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct LessonHistoryData {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub lessons: Vec<LessonResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LessonHistoryData {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
lessons: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/ui/components/chart.rs
Normal file
67
src/ui/components/chart.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::Style;
|
||||||
|
use ratatui::symbols;
|
||||||
|
use ratatui::widgets::{Axis, Block, Chart, Dataset, GraphType, Widget};
|
||||||
|
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
|
pub struct WpmChart<'a> {
|
||||||
|
pub data: &'a [(f64, f64)],
|
||||||
|
pub theme: &'a Theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> WpmChart<'a> {
|
||||||
|
pub fn new(data: &'a [(f64, f64)], theme: &'a Theme) -> Self {
|
||||||
|
Self { data, theme }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for WpmChart<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
if self.data.is_empty() {
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" WPM Over Time ")
|
||||||
|
.border_style(Style::default().fg(colors.border()));
|
||||||
|
block.render(area, buf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_x = self.data.last().map(|(x, _)| *x).unwrap_or(1.0);
|
||||||
|
let max_y = self
|
||||||
|
.data
|
||||||
|
.iter()
|
||||||
|
.map(|(_, y)| *y)
|
||||||
|
.fold(0.0f64, f64::max)
|
||||||
|
.max(10.0);
|
||||||
|
|
||||||
|
let dataset = Dataset::default()
|
||||||
|
.marker(symbols::Marker::Braille)
|
||||||
|
.graph_type(GraphType::Line)
|
||||||
|
.style(Style::default().fg(colors.accent()))
|
||||||
|
.data(self.data);
|
||||||
|
|
||||||
|
let chart = Chart::new(vec![dataset])
|
||||||
|
.block(
|
||||||
|
Block::bordered()
|
||||||
|
.title(" WPM Over Time ")
|
||||||
|
.border_style(Style::default().fg(colors.border())),
|
||||||
|
)
|
||||||
|
.x_axis(
|
||||||
|
Axis::default()
|
||||||
|
.title("Lesson")
|
||||||
|
.style(Style::default().fg(colors.text_pending()))
|
||||||
|
.bounds([0.0, max_x]),
|
||||||
|
)
|
||||||
|
.y_axis(
|
||||||
|
Axis::default()
|
||||||
|
.title("WPM")
|
||||||
|
.style(Style::default().fg(colors.text_pending()))
|
||||||
|
.bounds([0.0, max_y * 1.1]),
|
||||||
|
);
|
||||||
|
|
||||||
|
chart.render(area, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/ui/components/dashboard.rs
Normal file
118
src/ui/components/dashboard.rs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
||||||
|
use ratatui::style::{Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Paragraph, Widget};
|
||||||
|
|
||||||
|
use crate::session::result::LessonResult;
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
|
pub struct Dashboard<'a> {
|
||||||
|
pub result: &'a LessonResult,
|
||||||
|
pub theme: &'a Theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Dashboard<'a> {
|
||||||
|
pub fn new(result: &'a LessonResult, theme: &'a Theme) -> Self {
|
||||||
|
Self { result, theme }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for Dashboard<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Lesson Complete ")
|
||||||
|
.border_style(Style::default().fg(colors.accent()))
|
||||||
|
.style(Style::default().bg(colors.bg()));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
block.render(area, buf);
|
||||||
|
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(0),
|
||||||
|
Constraint::Length(2),
|
||||||
|
])
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
|
let title = Paragraph::new(Line::from(Span::styled(
|
||||||
|
"Results",
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.accent())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)))
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
title.render(layout[0], buf);
|
||||||
|
|
||||||
|
let wpm_text = format!("{:.0} WPM", self.result.wpm);
|
||||||
|
let cpm_text = format!(" ({:.0} CPM)", self.result.cpm);
|
||||||
|
let wpm_line = Line::from(vec![
|
||||||
|
Span::styled(" Speed: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(
|
||||||
|
&*wpm_text,
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.accent())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(&*cpm_text, Style::default().fg(colors.text_pending())),
|
||||||
|
]);
|
||||||
|
Paragraph::new(wpm_line).render(layout[1], buf);
|
||||||
|
|
||||||
|
let acc_color = if self.result.accuracy >= 95.0 {
|
||||||
|
colors.success()
|
||||||
|
} else if self.result.accuracy >= 85.0 {
|
||||||
|
colors.warning()
|
||||||
|
} else {
|
||||||
|
colors.error()
|
||||||
|
};
|
||||||
|
let acc_text = format!("{:.1}%", self.result.accuracy);
|
||||||
|
let acc_detail = format!(
|
||||||
|
" ({}/{} correct)",
|
||||||
|
self.result.correct, self.result.total_chars
|
||||||
|
);
|
||||||
|
let acc_line = Line::from(vec![
|
||||||
|
Span::styled(" Accuracy: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(
|
||||||
|
&*acc_text,
|
||||||
|
Style::default().fg(acc_color).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(&*acc_detail, Style::default().fg(colors.text_pending())),
|
||||||
|
]);
|
||||||
|
Paragraph::new(acc_line).render(layout[2], buf);
|
||||||
|
|
||||||
|
let time_text = format!("{:.1}s", self.result.elapsed_secs);
|
||||||
|
let time_line = Line::from(vec![
|
||||||
|
Span::styled(" Time: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(&*time_text, Style::default().fg(colors.fg())),
|
||||||
|
]);
|
||||||
|
Paragraph::new(time_line).render(layout[3], buf);
|
||||||
|
|
||||||
|
let error_text = format!("{}", self.result.incorrect);
|
||||||
|
let chars_line = Line::from(vec![
|
||||||
|
Span::styled(" Errors: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(
|
||||||
|
&*error_text,
|
||||||
|
Style::default().fg(if self.result.incorrect == 0 {
|
||||||
|
colors.success()
|
||||||
|
} else {
|
||||||
|
colors.error()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
Paragraph::new(chars_line).render(layout[4], buf);
|
||||||
|
|
||||||
|
let help = Paragraph::new(Line::from(vec![
|
||||||
|
Span::styled(" [r] Retry ", Style::default().fg(colors.accent())),
|
||||||
|
Span::styled("[q] Menu ", Style::default().fg(colors.accent())),
|
||||||
|
Span::styled("[s] Stats", Style::default().fg(colors.accent())),
|
||||||
|
]));
|
||||||
|
help.render(layout[6], buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/ui/components/keyboard_diagram.rs
Normal file
86
src/ui/components/keyboard_diagram.rs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::Style;
|
||||||
|
use ratatui::widgets::{Block, Widget};
|
||||||
|
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
|
pub struct KeyboardDiagram<'a> {
|
||||||
|
pub focused_key: Option<char>,
|
||||||
|
pub unlocked_keys: &'a [char],
|
||||||
|
pub theme: &'a Theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> KeyboardDiagram<'a> {
|
||||||
|
pub fn new(
|
||||||
|
focused_key: Option<char>,
|
||||||
|
unlocked_keys: &'a [char],
|
||||||
|
theme: &'a Theme,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
focused_key,
|
||||||
|
unlocked_keys,
|
||||||
|
theme,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROWS: &[&[char]] = &[
|
||||||
|
&['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
|
||||||
|
&['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
|
||||||
|
&['z', 'x', 'c', 'v', 'b', 'n', 'm'],
|
||||||
|
];
|
||||||
|
|
||||||
|
impl Widget for KeyboardDiagram<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Keyboard ")
|
||||||
|
.border_style(Style::default().fg(colors.border()))
|
||||||
|
.style(Style::default().bg(colors.bg()));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
block.render(area, buf);
|
||||||
|
|
||||||
|
if inner.height < 3 || inner.width < 20 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let key_width: u16 = 4;
|
||||||
|
let offsets: &[u16] = &[1, 2, 4];
|
||||||
|
|
||||||
|
for (row_idx, row) in ROWS.iter().enumerate() {
|
||||||
|
let y = inner.y + row_idx as u16;
|
||||||
|
if y >= inner.y + inner.height {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||||
|
|
||||||
|
for (col_idx, &key) in row.iter().enumerate() {
|
||||||
|
let x = inner.x + offset + col_idx as u16 * key_width;
|
||||||
|
if x + 3 > inner.x + inner.width {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_unlocked = self.unlocked_keys.contains(&key);
|
||||||
|
let is_focused = self.focused_key == Some(key);
|
||||||
|
|
||||||
|
let style = if is_focused {
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.bg())
|
||||||
|
.bg(colors.focused_key())
|
||||||
|
} else if is_unlocked {
|
||||||
|
Style::default().fg(colors.fg()).bg(colors.accent_dim())
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.text_pending())
|
||||||
|
.bg(colors.bg())
|
||||||
|
};
|
||||||
|
|
||||||
|
let display = format!("[{key}]");
|
||||||
|
buf.set_string(x, y, &display, style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
150
src/ui/components/menu.rs
Normal file
150
src/ui/components/menu.rs
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
||||||
|
use ratatui::style::{Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Paragraph, Widget};
|
||||||
|
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
|
pub struct MenuItem {
|
||||||
|
pub key: String,
|
||||||
|
pub label: String,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Menu<'a> {
|
||||||
|
pub items: Vec<MenuItem>,
|
||||||
|
pub selected: usize,
|
||||||
|
pub theme: &'a Theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Menu<'a> {
|
||||||
|
pub fn new(theme: &'a Theme) -> Self {
|
||||||
|
Self {
|
||||||
|
items: vec![
|
||||||
|
MenuItem {
|
||||||
|
key: "1".to_string(),
|
||||||
|
label: "Adaptive Practice".to_string(),
|
||||||
|
description: "Phonetic words with adaptive letter unlocking".to_string(),
|
||||||
|
},
|
||||||
|
MenuItem {
|
||||||
|
key: "2".to_string(),
|
||||||
|
label: "Code Practice".to_string(),
|
||||||
|
description: "Practice typing code syntax".to_string(),
|
||||||
|
},
|
||||||
|
MenuItem {
|
||||||
|
key: "3".to_string(),
|
||||||
|
label: "Passage Mode".to_string(),
|
||||||
|
description: "Type passages from books".to_string(),
|
||||||
|
},
|
||||||
|
MenuItem {
|
||||||
|
key: "s".to_string(),
|
||||||
|
label: "Statistics".to_string(),
|
||||||
|
description: "View your typing statistics".to_string(),
|
||||||
|
},
|
||||||
|
MenuItem {
|
||||||
|
key: "c".to_string(),
|
||||||
|
label: "Settings".to_string(),
|
||||||
|
description: "Configure keydr".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selected: 0,
|
||||||
|
theme,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next(&mut self) {
|
||||||
|
self.selected = (self.selected + 1) % self.items.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev(&mut self) {
|
||||||
|
if self.selected > 0 {
|
||||||
|
self.selected -= 1;
|
||||||
|
} else {
|
||||||
|
self.selected = self.items.len() - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for &Menu<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.border_style(Style::default().fg(colors.border()))
|
||||||
|
.style(Style::default().bg(colors.bg()));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
block.render(area, buf);
|
||||||
|
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(5),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Min(0),
|
||||||
|
])
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
|
let title_lines = vec![
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
"keydr",
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.accent())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
"Terminal Typing Tutor",
|
||||||
|
Style::default().fg(colors.fg()),
|
||||||
|
)),
|
||||||
|
Line::from(""),
|
||||||
|
];
|
||||||
|
|
||||||
|
let title = Paragraph::new(title_lines).alignment(Alignment::Center);
|
||||||
|
title.render(layout[0], buf);
|
||||||
|
|
||||||
|
let menu_layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints(
|
||||||
|
self.items
|
||||||
|
.iter()
|
||||||
|
.map(|_| Constraint::Length(3))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.split(layout[2]);
|
||||||
|
|
||||||
|
for (i, item) in self.items.iter().enumerate() {
|
||||||
|
let is_selected = i == self.selected;
|
||||||
|
let indicator = if is_selected { ">" } else { " " };
|
||||||
|
|
||||||
|
let label_text = format!(" {indicator} [{key}] {label}", key = item.key, label = item.label);
|
||||||
|
let desc_text = format!(" {}", item.description);
|
||||||
|
|
||||||
|
let lines = vec![
|
||||||
|
Line::from(Span::styled(
|
||||||
|
&*label_text,
|
||||||
|
Style::default()
|
||||||
|
.fg(if is_selected {
|
||||||
|
colors.accent()
|
||||||
|
} else {
|
||||||
|
colors.fg()
|
||||||
|
})
|
||||||
|
.add_modifier(if is_selected {
|
||||||
|
Modifier::BOLD
|
||||||
|
} else {
|
||||||
|
Modifier::empty()
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
&*desc_text,
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
|
||||||
|
let p = Paragraph::new(lines);
|
||||||
|
if i < menu_layout.len() {
|
||||||
|
p.render(menu_layout[i], buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/ui/components/mod.rs
Normal file
8
src/ui/components/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
pub mod chart;
|
||||||
|
pub mod dashboard;
|
||||||
|
pub mod keyboard_diagram;
|
||||||
|
pub mod menu;
|
||||||
|
pub mod progress_bar;
|
||||||
|
pub mod stats_dashboard;
|
||||||
|
pub mod stats_sidebar;
|
||||||
|
pub mod typing_area;
|
||||||
53
src/ui/components/progress_bar.rs
Normal file
53
src/ui/components/progress_bar.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::Style;
|
||||||
|
use ratatui::widgets::{Block, Widget};
|
||||||
|
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
|
pub struct ProgressBar<'a> {
|
||||||
|
pub label: String,
|
||||||
|
pub ratio: f64,
|
||||||
|
pub theme: &'a Theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ProgressBar<'a> {
|
||||||
|
pub fn new(label: &str, ratio: f64, theme: &'a Theme) -> Self {
|
||||||
|
Self {
|
||||||
|
label: label.to_string(),
|
||||||
|
ratio: ratio.clamp(0.0, 1.0),
|
||||||
|
theme,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for ProgressBar<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(format!(" {} ", self.label))
|
||||||
|
.border_style(Style::default().fg(colors.border()));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
block.render(area, buf);
|
||||||
|
|
||||||
|
if inner.width == 0 || inner.height == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let filled_width = (self.ratio * inner.width as f64) as u16;
|
||||||
|
let label = format!("{:.0}%", self.ratio * 100.0);
|
||||||
|
|
||||||
|
for x in inner.x..inner.x + inner.width {
|
||||||
|
let style = if x < inner.x + filled_width {
|
||||||
|
Style::default().fg(colors.bg()).bg(colors.bar_filled())
|
||||||
|
} else {
|
||||||
|
Style::default().fg(colors.fg()).bg(colors.bar_empty())
|
||||||
|
};
|
||||||
|
buf[(x, inner.y)].set_style(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
let label_x = inner.x + (inner.width.saturating_sub(label.len() as u16)) / 2;
|
||||||
|
buf.set_string(label_x, inner.y, &label, Style::default().fg(colors.fg()));
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/ui/components/stats_dashboard.rs
Normal file
119
src/ui/components/stats_dashboard.rs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
|
use ratatui::style::{Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Paragraph, Widget};
|
||||||
|
|
||||||
|
use crate::session::result::LessonResult;
|
||||||
|
use crate::ui::components::chart::WpmChart;
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
|
pub struct StatsDashboard<'a> {
|
||||||
|
pub history: &'a [LessonResult],
|
||||||
|
pub theme: &'a Theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> StatsDashboard<'a> {
|
||||||
|
pub fn new(history: &'a [LessonResult], theme: &'a Theme) -> Self {
|
||||||
|
Self { history, theme }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for StatsDashboard<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Statistics ")
|
||||||
|
.border_style(Style::default().fg(colors.accent()))
|
||||||
|
.style(Style::default().bg(colors.bg()));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
block.render(area, buf);
|
||||||
|
|
||||||
|
if self.history.is_empty() {
|
||||||
|
let msg = Paragraph::new(Line::from(Span::styled(
|
||||||
|
"No lessons completed yet. Start typing!",
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
)));
|
||||||
|
msg.render(inner, buf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(8),
|
||||||
|
Constraint::Min(10),
|
||||||
|
Constraint::Length(2),
|
||||||
|
])
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
|
let avg_wpm =
|
||||||
|
self.history.iter().map(|r| r.wpm).sum::<f64>() / self.history.len() as f64;
|
||||||
|
let best_wpm = self
|
||||||
|
.history
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.wpm)
|
||||||
|
.fold(0.0f64, f64::max);
|
||||||
|
let avg_accuracy =
|
||||||
|
self.history.iter().map(|r| r.accuracy).sum::<f64>() / self.history.len() as f64;
|
||||||
|
let total_lessons = self.history.len();
|
||||||
|
|
||||||
|
let total_str = format!("{total_lessons}");
|
||||||
|
let avg_wpm_str = format!("{avg_wpm:.0}");
|
||||||
|
let best_wpm_str = format!("{best_wpm:.0}");
|
||||||
|
let avg_acc_str = format!("{avg_accuracy:.1}%");
|
||||||
|
|
||||||
|
let summary = vec![
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Lessons: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(
|
||||||
|
&*total_str,
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.accent())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Avg WPM: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(&*avg_wpm_str, Style::default().fg(colors.accent())),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Best WPM: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(
|
||||||
|
&*best_wpm_str,
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.success())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Avg Accuracy: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(
|
||||||
|
&*avg_acc_str,
|
||||||
|
Style::default().fg(if avg_accuracy >= 95.0 {
|
||||||
|
colors.success()
|
||||||
|
} else {
|
||||||
|
colors.warning()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
Paragraph::new(summary).render(layout[0], buf);
|
||||||
|
|
||||||
|
let chart_data: Vec<(f64, f64)> = self
|
||||||
|
.history
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, r)| (i as f64, r.wpm))
|
||||||
|
.collect();
|
||||||
|
WpmChart::new(&chart_data, self.theme).render(layout[1], buf);
|
||||||
|
|
||||||
|
let help = Paragraph::new(Line::from(Span::styled(
|
||||||
|
" [ESC] Back to menu",
|
||||||
|
Style::default().fg(colors.accent()),
|
||||||
|
)));
|
||||||
|
help.render(layout[2], buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/ui/components/stats_sidebar.rs
Normal file
87
src/ui/components/stats_sidebar.rs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::Style;
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Paragraph, Widget};
|
||||||
|
|
||||||
|
use crate::session::lesson::LessonState;
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
|
pub struct StatsSidebar<'a> {
|
||||||
|
lesson: &'a LessonState,
|
||||||
|
theme: &'a Theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> StatsSidebar<'a> {
|
||||||
|
pub fn new(lesson: &'a LessonState, theme: &'a Theme) -> Self {
|
||||||
|
Self { lesson, theme }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for StatsSidebar<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let wpm = self.lesson.wpm();
|
||||||
|
let accuracy = self.lesson.accuracy();
|
||||||
|
let progress = self.lesson.progress() * 100.0;
|
||||||
|
let correct = self.lesson.correct_count();
|
||||||
|
let incorrect = self.lesson.incorrect_count();
|
||||||
|
let elapsed = self.lesson.elapsed_secs();
|
||||||
|
|
||||||
|
let wpm_str = format!("{wpm:.0}");
|
||||||
|
let acc_str = format!("{accuracy:.1}%");
|
||||||
|
let prog_str = format!("{progress:.0}%");
|
||||||
|
let correct_str = format!("{correct}");
|
||||||
|
let incorrect_str = format!("{incorrect}");
|
||||||
|
let elapsed_str = format!("{elapsed:.1}s");
|
||||||
|
|
||||||
|
let lines = vec![
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("WPM: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(&*wpm_str, Style::default().fg(colors.accent())),
|
||||||
|
]),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Accuracy: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(
|
||||||
|
&*acc_str,
|
||||||
|
Style::default().fg(if accuracy >= 95.0 {
|
||||||
|
colors.success()
|
||||||
|
} else if accuracy >= 85.0 {
|
||||||
|
colors.warning()
|
||||||
|
} else {
|
||||||
|
colors.error()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Progress: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(&*prog_str, Style::default().fg(colors.accent())),
|
||||||
|
]),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Correct: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(&*correct_str, Style::default().fg(colors.success())),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Errors: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(&*incorrect_str, Style::default().fg(colors.error())),
|
||||||
|
]),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Time: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(&*elapsed_str, Style::default().fg(colors.fg())),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Stats ")
|
||||||
|
.border_style(Style::default().fg(colors.border()))
|
||||||
|
.style(Style::default().bg(colors.bg()));
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(lines).block(block);
|
||||||
|
paragraph.render(area, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/ui/components/typing_area.rs
Normal file
61
src/ui/components/typing_area.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::{Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Paragraph, Widget, Wrap};
|
||||||
|
|
||||||
|
use crate::session::input::CharStatus;
|
||||||
|
use crate::session::lesson::LessonState;
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
|
pub struct TypingArea<'a> {
|
||||||
|
lesson: &'a LessonState,
|
||||||
|
theme: &'a Theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> TypingArea<'a> {
|
||||||
|
pub fn new(lesson: &'a LessonState, theme: &'a Theme) -> Self {
|
||||||
|
Self { lesson, theme }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for TypingArea<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
let mut spans: Vec<Span> = Vec::new();
|
||||||
|
|
||||||
|
for (i, &target_ch) in self.lesson.target.iter().enumerate() {
|
||||||
|
if i < self.lesson.cursor {
|
||||||
|
let style = match &self.lesson.input[i] {
|
||||||
|
CharStatus::Correct => Style::default().fg(colors.text_correct()),
|
||||||
|
CharStatus::Incorrect(_) => Style::default()
|
||||||
|
.fg(colors.text_incorrect())
|
||||||
|
.bg(colors.text_incorrect_bg())
|
||||||
|
.add_modifier(Modifier::UNDERLINED),
|
||||||
|
};
|
||||||
|
let display = match &self.lesson.input[i] {
|
||||||
|
CharStatus::Incorrect(actual) => *actual,
|
||||||
|
_ => target_ch,
|
||||||
|
};
|
||||||
|
spans.push(Span::styled(display.to_string(), style));
|
||||||
|
} else if i == self.lesson.cursor {
|
||||||
|
let style = Style::default()
|
||||||
|
.fg(colors.text_cursor_fg())
|
||||||
|
.bg(colors.text_cursor_bg());
|
||||||
|
spans.push(Span::styled(target_ch.to_string(), style));
|
||||||
|
} else {
|
||||||
|
let style = Style::default().fg(colors.text_pending());
|
||||||
|
spans.push(Span::styled(target_ch.to_string(), style));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = Line::from(spans);
|
||||||
|
let block = Block::bordered()
|
||||||
|
.border_style(Style::default().fg(colors.border()))
|
||||||
|
.style(Style::default().bg(colors.bg()));
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(line).block(block).wrap(Wrap { trim: false });
|
||||||
|
|
||||||
|
paragraph.render(area, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/ui/layout.rs
Normal file
53
src/ui/layout.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
|
|
||||||
|
pub struct AppLayout {
|
||||||
|
pub header: Rect,
|
||||||
|
pub main: Rect,
|
||||||
|
pub sidebar: Rect,
|
||||||
|
pub footer: Rect,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppLayout {
|
||||||
|
pub fn new(area: Rect) -> Self {
|
||||||
|
let vertical = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(10),
|
||||||
|
Constraint::Length(3),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let horizontal = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
|
||||||
|
.split(vertical[1]);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
header: vertical[0],
|
||||||
|
main: horizontal[0],
|
||||||
|
sidebar: horizontal[1],
|
||||||
|
footer: vertical[2],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
||||||
|
let vertical = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage((100 - percent_y) / 2),
|
||||||
|
Constraint::Percentage(percent_y),
|
||||||
|
Constraint::Percentage((100 - percent_y) / 2),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage((100 - percent_x) / 2),
|
||||||
|
Constraint::Percentage(percent_x),
|
||||||
|
Constraint::Percentage((100 - percent_x) / 2),
|
||||||
|
])
|
||||||
|
.split(vertical[1])[1]
|
||||||
|
}
|
||||||
3
src/ui/mod.rs
Normal file
3
src/ui/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod components;
|
||||||
|
pub mod layout;
|
||||||
|
pub mod theme;
|
||||||
146
src/ui/theme.rs
Normal file
146
src/ui/theme.rs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use ratatui::style::Color;
|
||||||
|
use rust_embed::Embed;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Embed)]
|
||||||
|
#[folder = "assets/themes/"]
|
||||||
|
struct ThemeAssets;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Theme {
|
||||||
|
pub name: String,
|
||||||
|
pub colors: ThemeColors,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ThemeColors {
|
||||||
|
pub bg: String,
|
||||||
|
pub fg: String,
|
||||||
|
pub text_correct: String,
|
||||||
|
pub text_incorrect: String,
|
||||||
|
pub text_incorrect_bg: String,
|
||||||
|
pub text_pending: String,
|
||||||
|
pub text_cursor_bg: String,
|
||||||
|
pub text_cursor_fg: String,
|
||||||
|
pub focused_key: String,
|
||||||
|
pub accent: String,
|
||||||
|
pub accent_dim: String,
|
||||||
|
pub border: String,
|
||||||
|
pub border_focused: String,
|
||||||
|
pub header_bg: String,
|
||||||
|
pub header_fg: String,
|
||||||
|
pub bar_filled: String,
|
||||||
|
pub bar_empty: String,
|
||||||
|
pub error: String,
|
||||||
|
pub warning: String,
|
||||||
|
pub success: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Theme {
|
||||||
|
pub fn load(name: &str) -> Option<Self> {
|
||||||
|
// Try user themes dir
|
||||||
|
if let Some(config_dir) = dirs::config_dir() {
|
||||||
|
let user_theme_path = config_dir.join("keydr").join("themes").join(format!("{name}.toml"));
|
||||||
|
if let Ok(content) = fs::read_to_string(&user_theme_path) {
|
||||||
|
if let Ok(theme) = toml::from_str::<Theme>(&content) {
|
||||||
|
return Some(theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try bundled themes
|
||||||
|
let filename = format!("{name}.toml");
|
||||||
|
if let Some(file) = ThemeAssets::get(&filename) {
|
||||||
|
if let Ok(content) = std::str::from_utf8(file.data.as_ref()) {
|
||||||
|
if let Ok(theme) = toml::from_str::<Theme>(content) {
|
||||||
|
return Some(theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn available_themes() -> Vec<String> {
|
||||||
|
ThemeAssets::iter()
|
||||||
|
.filter_map(|f| {
|
||||||
|
f.strip_suffix(".toml").map(|n| n.to_string())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Theme {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::load("catppuccin-mocha").unwrap_or_else(|| Self {
|
||||||
|
name: "default".to_string(),
|
||||||
|
colors: ThemeColors::default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ThemeColors {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
bg: "#1e1e2e".to_string(),
|
||||||
|
fg: "#cdd6f4".to_string(),
|
||||||
|
text_correct: "#a6e3a1".to_string(),
|
||||||
|
text_incorrect: "#f38ba8".to_string(),
|
||||||
|
text_incorrect_bg: "#45273a".to_string(),
|
||||||
|
text_pending: "#585b70".to_string(),
|
||||||
|
text_cursor_bg: "#f5e0dc".to_string(),
|
||||||
|
text_cursor_fg: "#1e1e2e".to_string(),
|
||||||
|
focused_key: "#f9e2af".to_string(),
|
||||||
|
accent: "#89b4fa".to_string(),
|
||||||
|
accent_dim: "#45475a".to_string(),
|
||||||
|
border: "#45475a".to_string(),
|
||||||
|
border_focused: "#89b4fa".to_string(),
|
||||||
|
header_bg: "#313244".to_string(),
|
||||||
|
header_fg: "#cdd6f4".to_string(),
|
||||||
|
bar_filled: "#89b4fa".to_string(),
|
||||||
|
bar_empty: "#313244".to_string(),
|
||||||
|
error: "#f38ba8".to_string(),
|
||||||
|
warning: "#f9e2af".to_string(),
|
||||||
|
success: "#a6e3a1".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThemeColors {
|
||||||
|
pub fn parse_color(hex: &str) -> Color {
|
||||||
|
let hex = hex.trim_start_matches('#');
|
||||||
|
if hex.len() == 6 {
|
||||||
|
if let (Ok(r), Ok(g), Ok(b)) = (
|
||||||
|
u8::from_str_radix(&hex[0..2], 16),
|
||||||
|
u8::from_str_radix(&hex[2..4], 16),
|
||||||
|
u8::from_str_radix(&hex[4..6], 16),
|
||||||
|
) {
|
||||||
|
return Color::Rgb(r, g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Color::White
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bg(&self) -> Color { Self::parse_color(&self.bg) }
|
||||||
|
pub fn fg(&self) -> Color { Self::parse_color(&self.fg) }
|
||||||
|
pub fn text_correct(&self) -> Color { Self::parse_color(&self.text_correct) }
|
||||||
|
pub fn text_incorrect(&self) -> Color { Self::parse_color(&self.text_incorrect) }
|
||||||
|
pub fn text_incorrect_bg(&self) -> Color { Self::parse_color(&self.text_incorrect_bg) }
|
||||||
|
pub fn text_pending(&self) -> Color { Self::parse_color(&self.text_pending) }
|
||||||
|
pub fn text_cursor_bg(&self) -> Color { Self::parse_color(&self.text_cursor_bg) }
|
||||||
|
pub fn text_cursor_fg(&self) -> Color { Self::parse_color(&self.text_cursor_fg) }
|
||||||
|
pub fn focused_key(&self) -> Color { Self::parse_color(&self.focused_key) }
|
||||||
|
pub fn accent(&self) -> Color { Self::parse_color(&self.accent) }
|
||||||
|
pub fn accent_dim(&self) -> Color { Self::parse_color(&self.accent_dim) }
|
||||||
|
pub fn border(&self) -> Color { Self::parse_color(&self.border) }
|
||||||
|
pub fn border_focused(&self) -> Color { Self::parse_color(&self.border_focused) }
|
||||||
|
pub fn header_bg(&self) -> Color { Self::parse_color(&self.header_bg) }
|
||||||
|
pub fn header_fg(&self) -> Color { Self::parse_color(&self.header_fg) }
|
||||||
|
pub fn bar_filled(&self) -> Color { Self::parse_color(&self.bar_filled) }
|
||||||
|
pub fn bar_empty(&self) -> Color { Self::parse_color(&self.bar_empty) }
|
||||||
|
pub fn error(&self) -> Color { Self::parse_color(&self.error) }
|
||||||
|
pub fn warning(&self) -> Color { Self::parse_color(&self.warning) }
|
||||||
|
pub fn success(&self) -> Color { Self::parse_color(&self.success) }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user