First one-shot pass

This commit is contained in:
2026-02-10 14:29:23 -05:00
parent 739d79d6a2
commit f65e3d8413
48 changed files with 5409 additions and 2 deletions

2119
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,5 +2,18 @@
name = "keydr"
version = "0.1.0"
edition = "2024"
description = "Terminal typing tutor with adaptive learning"
[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"

View 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"

View 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"

View 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"

View 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
View 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"

View 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"

View 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"

View 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
View 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
View 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
View 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
View 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}");
}
}

View 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
View 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
View 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
View 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
View 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()?)
}
}

View 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(" ")
}
}

View 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
View 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
View 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
View 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(" ")
}
}

View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
pub mod finger;
pub mod layout;

View File

@@ -1,3 +1,409 @@
fn main() {
println!("Hello, world!");
mod app;
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
View 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
View 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
View File

@@ -0,0 +1,3 @@
pub mod input;
pub mod lesson;
pub mod result;

53
src/session/result.rs Normal file
View 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
View 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
View File

@@ -0,0 +1,2 @@
pub mod json_store;
pub mod schema;

61
src/store/schema.rs Normal file
View 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(),
}
}
}

View 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);
}
}

View 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);
}
}

View 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
View 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
View 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;

View 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()));
}
}

View 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);
}
}

View 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);
}
}

View 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
View 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
View File

@@ -0,0 +1,3 @@
pub mod components;
pub mod layout;
pub mod theme;

146
src/ui/theme.rs Normal file
View 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) }
}