Skip to content
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
83260a2
added new deps: eyre, tuirealm, thiserror
philocalyst Mar 24, 2026
b6f1a13
Refactored result component
philocalyst Mar 24, 2026
6c25db8
Refactored test component
philocalyst Mar 24, 2026
2131ae0
Removed the rather confusing test dir
philocalyst Mar 24, 2026
8c5c4af
added cli file for cli specific things
philocalyst Mar 24, 2026
4d251fc
Using tuilrealm
philocalyst Mar 24, 2026
8cb3020
Dedicated error enum
philocalyst Mar 24, 2026
4f0011a
New main entry ponit
philocalyst Mar 24, 2026
9dc21a6
Dedicated file for supported messages
philocalyst Mar 24, 2026
0894161
Dedicated message processing
philocalyst Mar 24, 2026
ea12dc3
Refactored resource handling
philocalyst Mar 24, 2026
b0ee391
Core types
philocalyst Mar 24, 2026
42b2f23
Kind of psuedo-code refactor towards a model I like
philocalyst Mar 24, 2026
964df5c
Calculate
philocalyst Mar 25, 2026
1424d82
Relinked everything
philocalyst Mar 25, 2026
1119e76
Cargo clippy and cargo check
philocalyst Mar 25, 2026
362b089
Renaming and cleanup
philocalyst Mar 26, 2026
0ab18a7
Handling quit events
philocalyst Mar 26, 2026
1d94738
Fixed response menu issue
philocalyst Mar 26, 2026
3f17690
Handling redraws
philocalyst Mar 26, 2026
2e4b477
Updated deps
philocalyst Mar 26, 2026
b4e8457
Fixed deps
philocalyst Mar 26, 2026
c8eea7d
Formatting
philocalyst Mar 26, 2026
86a5137
Reorg
philocalyst Mar 26, 2026
d5110b2
Fixed redudant to to call
philocalyst Mar 26, 2026
0c8c2a8
Clean reorg
philocalyst Mar 26, 2026
b94dcf0
Fixed to proper usage for mounting
philocalyst Mar 26, 2026
bcb66b3
Properly added result
philocalyst Mar 27, 2026
ff1aed4
Switched to dir-spec
philocalyst Mar 27, 2026
e2d45e6
Switched to an async model
philocalyst Apr 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,268 changes: 1,187 additions & 81 deletions Cargo.lock

Large diffs are not rendered by default.

52 changes: 41 additions & 11 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,51 @@ edition = "2021"
clap = { version = "^4.5", features = ["derive"] }
clap_complete = "^4.5"
dirs = "^5.0"
crossterm = "^0.27"
rust-embed = "^8.2"
toml = "^0.8"
tuirealm = { version = "3.3.0", features = ["crossterm", "derive"] }
eyre = "0.6.12"
thiserror = "2.0.18"
ratatui = "0.30"
rand = {version = "^0.8", features = ["alloc"]}
serde = {version = "^1.0", features = ["derive"]}

[dependencies.ratatui]
version = "^0.25"
[dev-dependencies]
tempfile = "3.27.0"

[dependencies.rand]
version = "^0.8"
features = ["alloc"]
[workspace.lints.rust]
mismatched_lifetime_syntaxes = "deny"
unused_imports = "deny"
unused_must_use = "deny"
dead_code = "deny"
unstable_name_collisions = "deny"
unused_assignments = "deny"
deprecated = "deny"

[dependencies.serde]
version = "^1.0"
features = ["derive"]
[workspace.lints.clippy]
all = { level = "deny", priority = -1 }
perf = { level = "deny", priority = -1 }
nursery = { level = "deny", priority = -1 }
pedantic = { level = "deny", priority = -1 }

[dev-dependencies]
tempfile = "3.27.0"
# deny
filetype_is_file = "deny"
cargo = { level = "deny", priority = -1 }
unwrap_used = "deny"
panic = "deny"
match_like_matches_macro = "deny"
needless_update = "deny"
expect_used = "deny"

# allow
module_name_repetitions = "allow"
must_use_candidate = "allow"
missing_errors_doc = "allow"
empty_docs = "allow"
unnecessary_debug_formatting = "allow"

# allow
significant_drop_tightening = "allow"
missing_panics_doc = "allow"
multiple_crate_versions = "allow"

78 changes: 78 additions & 0 deletions src/calculate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
use crate::types::test_event::is_missed_word_event;
use crate::types::{AccuracyData, Fraction, Test, TestEvent, TimingData};
use std::collections::HashMap;
use tuirealm::ratatui::crossterm::event::KeyEvent;

pub fn timing(events: &[&TestEvent]) -> TimingData {
let mut timing = TimingData {
overall_cps: -1.0,
per_event: Vec::new(),
per_key: HashMap::new(),
};

// map of keys to a two-tuple (total time, clicks) for counting average
let mut keys: HashMap<KeyEvent, (f64, usize)> = HashMap::new();

for win in events.windows(2) {
let event_dur = win[1]
.time
.checked_duration_since(win[0].time)
.map(|d| d.as_secs_f64());

if let Some(event_dur) = event_dur {
timing.per_event.push(event_dur);

let key = keys.entry(win[1].key).or_insert((0.0, 0));
key.0 += event_dur;
key.1 += 1;
}
}

timing.per_key = keys
.into_iter()
.map(|(key, (total, count))| (key, total / count as f64))
.collect();

timing.overall_cps = if timing.per_event.is_empty() {
0.0
} else {
timing.per_event.len() as f64 / timing.per_event.iter().sum::<f64>()
};

timing
}

pub fn accuracy(events: &[&TestEvent]) -> AccuracyData {
let mut acc = AccuracyData {
overall: Fraction::new(0, 0),
per_key: HashMap::new(),
};

events
.iter()
.filter(|event| event.correct.is_some())
.for_each(|event| {
let key = acc
.per_key
.entry(event.key)
.or_insert_with(|| Fraction::new(0, 0));

acc.overall.denominator += 1;
key.denominator += 1;

if event.correct.unwrap_or(false) {
acc.overall.numerator += 1;
key.numerator += 1;
}
});

acc
}

pub fn missed_words(test: &Test) -> Vec<String> {
test.words
.iter()
.filter(|word| word.events.iter().any(is_missed_word_event))
.map(|word| word.text.clone())
.collect()
}
2 changes: 2 additions & 0 deletions src/components/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pub mod result;
pub mod test;
248 changes: 248 additions & 0 deletions src/components/result.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
use rand::{seq::SliceRandom, thread_rng};
use tuirealm::ratatui::crossterm::event::{KeyCode as CrosstermKeyCode, KeyEvent};
use tuirealm::ratatui::{
layout::{Constraint, Direction, Layout, Rect},
symbols::Marker,
text::{Line, Span, Text},
widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, Paragraph, Widget},
};
use tuirealm::{
command::{Cmd, CmdResult},
event::{Event, Key, KeyModifiers, NoUserEvent},
AttrValue, Attribute, Component, Frame, MockComponent, State,
};

use crate::config::Theme;
use crate::messages::Msg;
use crate::types::{Fraction, Results};

// Convert CPS to WPM (clicks per second)
const WORDS_PER_MINUTE_PER_CPS: f64 = 12.0;

// Width of the moving average window for the WPM chart
const WORDS_PER_MINUTE_MOVING_AVERAGE_WIDTH: usize = 10;

pub struct ResultsComponent {
pub results: Results,
pub theme: Theme,
}

impl MockComponent for ResultsComponent {
fn view(&mut self, frame: &mut Frame, area: Rect) {
let buffer = frame.buffer_mut();

buffer.set_style(area, self.theme.default);

// Chunks
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(area);

let result_chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1) // Graph looks tremendously better with just a little margin
.constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)])
.split(chunks[0]);

let info_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)])
.split(result_chunks[0]);

// Handling the incomplete tests
// TODO: Show a better screen here
let msg = if self.results.missed_words.is_empty() {
"Press 'q' to quit or 'r' for another test"
} else {
"Press 'q' to quit, 'r' for another test or 'p' to practice missed words"
};

let exit = Span::styled(msg, self.theme.results_restart_prompt);
buffer.set_span(chunks[1].x, chunks[1].y, &exit, chunks[1].width);

// Sections
let mut overview_text = Text::styled("", self.theme.results_overview);
overview_text.extend([
Line::from(format!(
"Adjusted WPM: {:.1}",
self.results.timing.overall_cps
* WORDS_PER_MINUTE_PER_CPS
* f64::from(self.results.accuracy.overall)
)),
Line::from(format!(
"Accuracy: {:.1}%",
f64::from(self.results.accuracy.overall) * 100f64
)),
Line::from(format!(
"Raw WPM: {:.1}",
self.results.timing.overall_cps * WORDS_PER_MINUTE_PER_CPS
)),
Line::from(format!(
"Correct Keypresses: {}",
self.results.accuracy.overall
)),
]);
let overview = Paragraph::new(overview_text).block(
Block::default()
.title(Span::styled("Overview", self.theme.title))
.borders(Borders::ALL)
.border_type(self.theme.border_type)
.border_style(self.theme.results_overview_border),
);
overview.render(info_chunks[0], buffer);

let mut worst_keys: Vec<(&KeyEvent, &Fraction)> = self
.results
.accuracy
.per_key
.iter()
.filter(|(key, _)| matches!(key.code, CrosstermKeyCode::Char(_)))
.collect();

// Unstable because we don't care about order, just results
worst_keys.sort_unstable_by_key(|x| x.1);

let mut worst_text = Text::styled("", self.theme.results_worst_keys);
worst_text.extend(
worst_keys
.iter()
.filter_map(|(key, acc)| {
if let CrosstermKeyCode::Char(character) = key.code {
let key_accuracy = f64::from(**acc) * 100.0;
if key_accuracy != 100.0 {
Some(format!("- {} at {:.1}% accuracy", character, key_accuracy))
} else {
None
}
} else {
None
}
})
.take(5)
.map(Line::from),
);

let worst = Paragraph::new(worst_text).block(
Block::default()
.title(Span::styled("Worst Keys", self.theme.title))
.borders(Borders::ALL)
.border_type(self.theme.border_type)
.border_style(self.theme.results_worst_keys_border),
);

worst.render(info_chunks[1], buffer);

let words_per_minute_sliding_moving_average: Vec<(f64, f64)> = self
.results
.timing
.per_event
.windows(WORDS_PER_MINUTE_MOVING_AVERAGE_WIDTH)
.enumerate()
.map(|(i, window): (usize, &[f64])| {
(
(i + WORDS_PER_MINUTE_MOVING_AVERAGE_WIDTH) as f64,
window.len() as f64 / window.iter().copied().sum::<f64>()
* WORDS_PER_MINUTE_PER_CPS,
)
})
.collect();

// Render the chart if possible.
if !words_per_minute_sliding_moving_average.is_empty() {
let minimum_average = words_per_minute_sliding_moving_average
.iter()
.map(|(_, x)| x)
.fold(f64::INFINITY, |a: f64, &b: &f64| a.min(b));

let maximum_average = words_per_minute_sliding_moving_average
.iter()
.map(|(_, x)| x)
.fold(f64::NEG_INFINITY, |a: f64, &b: &f64| a.max(b));

let wpm_datasets = vec![Dataset::default()
.name("WPM")
.marker(Marker::Braille)
.graph_type(GraphType::Line)
.style(self.theme.results_chart)
.data(&words_per_minute_sliding_moving_average)];

let y_label_minimum = minimum_average as u16;
let y_label_maximum = (maximum_average as u16).max(y_label_minimum + 6);

let wpm_chart = Chart::new(wpm_datasets)
.block(Block::default().title(vec![Span::styled("Chart", self.theme.title)]))
.x_axis(
Axis::default()
.title(Span::styled("Keypresses", self.theme.results_chart_x))
.bounds([0.0, self.results.timing.per_event.len() as f64]),
)
.y_axis(
Axis::default()
.title(Span::styled(
"WPM (10-keypress rolling average)",
self.theme.results_chart_y,
))
.bounds([minimum_average, maximum_average])
.labels(
(y_label_minimum..y_label_maximum)
.step_by(5)
.map(|n| Span::raw(format!("{}", n)))
.collect::<Vec<_>>(),
),
);
wpm_chart.render(result_chunks[1], buffer);
}
}

// DEFAULT IMPLEMENTATIONS ROUGHLY
fn query(&self, _attr: Attribute) -> Option<AttrValue> {
None
}

fn attr(&mut self, _attr: Attribute, _value: AttrValue) {}

fn state(&self) -> State {
State::None
}

fn perform(&mut self, _cmd: Cmd) -> CmdResult {
CmdResult::None
}
}

impl Component<Msg, NoUserEvent> for ResultsComponent {
fn on(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
match ev {
Event::Keyboard(key)
if key.code == Key::Char('q') && key.modifiers == KeyModifiers::NONE =>
{
Some(Msg::AppClose)
}

Event::Keyboard(key)
if key.code == Key::Char('r') && key.modifiers == KeyModifiers::NONE =>
{
Some(Msg::RestartTest)
}

Event::Keyboard(key)
if key.code == Key::Char('p') && key.modifiers == KeyModifiers::NONE =>
{
if self.results.missed_words.is_empty() {
return None;
}
// repeat each missed word 5 times
let mut practice_words: Vec<String> = (self.results.missed_words)
.iter()
.flat_map(|w: &String| vec![w.clone(); 5])
.collect();

practice_words.shuffle(&mut thread_rng());

Some(Msg::StartTest(practice_words))
}
_ => None,
}
}
}
Loading
Loading