Skip to content

Commit bcb66b3

Browse files
committed
Properly added result
1 parent b94dcf0 commit bcb66b3

7 files changed

Lines changed: 355 additions & 0 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,5 @@ result
1010
# Editor
1111
.vim/
1212
.nvimrc
13+
14+
!/src/components/result

src/components/result/accuracy.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
use super::fraction::Fraction;
2+
use std::collections::HashMap;
3+
use tuirealm::ratatui::crossterm::event::KeyEvent;
4+
5+
#[derive(Clone, Debug, PartialEq)]
6+
pub struct Data {
7+
pub overall: Fraction,
8+
pub per_key: HashMap<KeyEvent, Fraction>,
9+
}

src/components/result/component.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
use rand::{seq::SliceRandom, thread_rng};
2+
use tuirealm::{
3+
event::{Key, KeyModifiers},
4+
Component, Event, NoUserEvent,
5+
};
6+
7+
use super::ResultsComponent;
8+
use crate::messages::Msg;
9+
10+
impl Component<Msg, NoUserEvent> for ResultsComponent {
11+
fn on(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
12+
match ev {
13+
Event::Keyboard(key)
14+
if key.code == Key::Char('q') && key.modifiers == KeyModifiers::NONE =>
15+
{
16+
Some(Msg::AppClose)
17+
}
18+
19+
Event::Keyboard(key)
20+
if key.code == Key::Char('r') && key.modifiers == KeyModifiers::NONE =>
21+
{
22+
Some(Msg::RestartTest)
23+
}
24+
25+
Event::Keyboard(key)
26+
if key.code == Key::Char('p') && key.modifiers == KeyModifiers::NONE =>
27+
{
28+
if self.results.missed_words.is_empty() {
29+
return None;
30+
}
31+
// repeat each missed word 5 times
32+
let mut practice_words: Vec<String> = (self.results.missed_words)
33+
.iter()
34+
.flat_map(|w: &String| vec![w.clone(); 5])
35+
.collect();
36+
37+
practice_words.shuffle(&mut thread_rng());
38+
39+
Some(Msg::StartTest(practice_words))
40+
}
41+
_ => None,
42+
}
43+
}
44+
}

src/components/result/fraction.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
use std::cmp;
2+
use std::fmt;
3+
4+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
5+
pub struct Fraction {
6+
pub numerator: usize,
7+
pub denominator: usize,
8+
}
9+
10+
impl Fraction {
11+
pub fn new(numerator: usize, denominator: usize) -> Self {
12+
Self {
13+
numerator,
14+
denominator,
15+
}
16+
}
17+
}
18+
19+
impl From<Fraction> for f64 {
20+
fn from(fraction: Fraction) -> Self {
21+
if fraction.denominator == 0 {
22+
0.0
23+
} else {
24+
fraction.numerator as f64 / fraction.denominator as f64
25+
}
26+
}
27+
}
28+
29+
impl cmp::Ord for Fraction {
30+
fn cmp(&self, other: &Self) -> cmp::Ordering {
31+
f64::from(*self)
32+
.partial_cmp(&f64::from(*other))
33+
.unwrap_or(cmp::Ordering::Equal)
34+
}
35+
}
36+
37+
impl cmp::PartialOrd for Fraction {
38+
fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> {
39+
Some(self.cmp(other))
40+
}
41+
}
42+
43+
impl fmt::Display for Fraction {
44+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45+
write!(f, "{}/{}", self.numerator, self.denominator)
46+
}
47+
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
use tuirealm::ratatui::crossterm::event::{KeyCode as CrosstermKeyCode, KeyEvent};
2+
use tuirealm::ratatui::{
3+
layout::{Constraint, Direction, Layout, Rect},
4+
symbols::Marker,
5+
text::{Line, Span, Text},
6+
widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, Paragraph, Widget},
7+
};
8+
use tuirealm::{
9+
command::{Cmd, CmdResult},
10+
AttrValue, Attribute, Frame, MockComponent, State,
11+
};
12+
13+
use super::{
14+
Fraction, ResultsComponent, WORDS_PER_MINUTE_MOVING_AVERAGE_WIDTH, WORDS_PER_MINUTE_PER_CPS,
15+
};
16+
17+
impl MockComponent for ResultsComponent {
18+
fn view(&mut self, frame: &mut Frame, area: Rect) {
19+
let buffer = frame.buffer_mut();
20+
21+
buffer.set_style(area, self.theme.default);
22+
23+
// Chunks
24+
let chunks = Layout::default()
25+
.direction(Direction::Vertical)
26+
.constraints([Constraint::Min(1), Constraint::Length(1)])
27+
.split(area);
28+
29+
let result_chunks = Layout::default()
30+
.direction(Direction::Vertical)
31+
.margin(1) // Graph looks tremendously better with just a little margin
32+
.constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)])
33+
.split(chunks[0]);
34+
35+
let info_chunks = Layout::default()
36+
.direction(Direction::Horizontal)
37+
.constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)])
38+
.split(result_chunks[0]);
39+
40+
// Handling the incomplete tests
41+
// TODO: Show a better screen here
42+
let msg = if self.results.missed_words.is_empty() {
43+
"Press 'q' to quit or 'r' for another test"
44+
} else {
45+
"Press 'q' to quit, 'r' for another test or 'p' to practice missed words"
46+
};
47+
48+
let exit = Span::styled(msg, self.theme.results_restart_prompt);
49+
buffer.set_span(chunks[1].x, chunks[1].y, &exit, chunks[1].width);
50+
51+
// Sections
52+
let mut overview_text = Text::styled("", self.theme.results_overview);
53+
overview_text.extend([
54+
Line::from(format!(
55+
"Adjusted WPM: {:.1}",
56+
self.results.timing.overall_cps
57+
* WORDS_PER_MINUTE_PER_CPS
58+
* f64::from(self.results.accuracy.overall)
59+
)),
60+
Line::from(format!(
61+
"Accuracy: {:.1}%",
62+
f64::from(self.results.accuracy.overall) * 100f64
63+
)),
64+
Line::from(format!(
65+
"Raw WPM: {:.1}",
66+
self.results.timing.overall_cps * WORDS_PER_MINUTE_PER_CPS
67+
)),
68+
Line::from(format!(
69+
"Correct Keypresses: {}",
70+
self.results.accuracy.overall
71+
)),
72+
]);
73+
let overview = Paragraph::new(overview_text).block(
74+
Block::default()
75+
.title(Span::styled("Overview", self.theme.title))
76+
.borders(Borders::ALL)
77+
.border_type(self.theme.border_type)
78+
.border_style(self.theme.results_overview_border),
79+
);
80+
overview.render(info_chunks[0], buffer);
81+
82+
let mut worst_keys: Vec<(&KeyEvent, &Fraction)> = self
83+
.results
84+
.accuracy
85+
.per_key
86+
.iter()
87+
.filter(|(key, _)| matches!(key.code, CrosstermKeyCode::Char(_)))
88+
.collect();
89+
90+
// Unstable because we don't care about order, just results
91+
worst_keys.sort_unstable_by_key(|x| x.1);
92+
93+
let mut worst_text = Text::styled("", self.theme.results_worst_keys);
94+
worst_text.extend(
95+
worst_keys
96+
.iter()
97+
.filter_map(|(key, acc)| {
98+
if let CrosstermKeyCode::Char(character) = key.code {
99+
let key_accuracy = f64::from(**acc) * 100.0;
100+
if key_accuracy != 100.0 {
101+
Some(format!("- {} at {:.1}% accuracy", character, key_accuracy))
102+
} else {
103+
None
104+
}
105+
} else {
106+
None
107+
}
108+
})
109+
.take(5)
110+
.map(Line::from),
111+
);
112+
113+
let worst = Paragraph::new(worst_text).block(
114+
Block::default()
115+
.title(Span::styled("Worst Keys", self.theme.title))
116+
.borders(Borders::ALL)
117+
.border_type(self.theme.border_type)
118+
.border_style(self.theme.results_worst_keys_border),
119+
);
120+
121+
worst.render(info_chunks[1], buffer);
122+
123+
let words_per_minute_sliding_moving_average: Vec<(f64, f64)> = self
124+
.results
125+
.timing
126+
.per_event
127+
.windows(WORDS_PER_MINUTE_MOVING_AVERAGE_WIDTH)
128+
.enumerate()
129+
.map(|(i, window): (usize, &[f64])| {
130+
(
131+
(i + WORDS_PER_MINUTE_MOVING_AVERAGE_WIDTH) as f64,
132+
window.len() as f64 / window.iter().copied().sum::<f64>()
133+
* WORDS_PER_MINUTE_PER_CPS,
134+
)
135+
})
136+
.collect();
137+
138+
// Render the chart if possible.
139+
if !words_per_minute_sliding_moving_average.is_empty() {
140+
let minimum_average = words_per_minute_sliding_moving_average
141+
.iter()
142+
.map(|(_, x)| x)
143+
.fold(f64::INFINITY, |a: f64, &b: &f64| a.min(b));
144+
145+
let maximum_average = words_per_minute_sliding_moving_average
146+
.iter()
147+
.map(|(_, x)| x)
148+
.fold(f64::NEG_INFINITY, |a: f64, &b: &f64| a.max(b));
149+
150+
let wpm_datasets = vec![Dataset::default()
151+
.name("WPM")
152+
.marker(Marker::Braille)
153+
.graph_type(GraphType::Line)
154+
.style(self.theme.results_chart)
155+
.data(&words_per_minute_sliding_moving_average)];
156+
157+
let y_label_minimum = minimum_average as u16;
158+
let y_label_maximum = (maximum_average as u16).max(y_label_minimum + 6);
159+
160+
let wpm_chart = Chart::new(wpm_datasets)
161+
.block(Block::default().title(vec![Span::styled("Chart", self.theme.title)]))
162+
.x_axis(
163+
Axis::default()
164+
.title(Span::styled("Keypresses", self.theme.results_chart_x))
165+
.bounds([0.0, self.results.timing.per_event.len() as f64]),
166+
)
167+
.y_axis(
168+
Axis::default()
169+
.title(Span::styled(
170+
"WPM (10-keypress rolling average)",
171+
self.theme.results_chart_y,
172+
))
173+
.bounds([minimum_average, maximum_average])
174+
.labels(
175+
(y_label_minimum..y_label_maximum)
176+
.step_by(5)
177+
.map(|n| Span::raw(format!("{}", n)))
178+
.collect::<Vec<_>>(),
179+
),
180+
);
181+
wpm_chart.render(result_chunks[1], buffer);
182+
}
183+
}
184+
185+
// DEFAULT IMPLEMENTATIONS ROUGHLY
186+
fn query(&self, _attr: Attribute) -> Option<AttrValue> {
187+
None
188+
}
189+
190+
fn attr(&mut self, _attr: Attribute, _value: AttrValue) {}
191+
192+
fn state(&self) -> State {
193+
State::None
194+
}
195+
196+
fn perform(&mut self, _cmd: Cmd) -> CmdResult {
197+
CmdResult::None
198+
}
199+
}

src/components/result/mod.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
use crate::calculate;
2+
use crate::components::test::event::TestEvent;
3+
use crate::components::test::Test;
4+
use crate::config::Theme;
5+
6+
pub mod accuracy;
7+
pub mod component;
8+
pub mod fraction;
9+
pub mod mock_component;
10+
pub mod timing;
11+
12+
pub use accuracy::Data as AccuracyData;
13+
pub use fraction::Fraction;
14+
pub use timing::Data as TimingData;
15+
16+
// Convert CPS to WPM (clicks per second)
17+
pub const WORDS_PER_MINUTE_PER_CPS: f64 = 12.0;
18+
19+
// Width of the moving average window for the WPM chart
20+
pub const WORDS_PER_MINUTE_MOVING_AVERAGE_WIDTH: usize = 10;
21+
22+
#[derive(Clone, Debug, PartialEq)]
23+
pub struct Results {
24+
pub timing: TimingData,
25+
pub accuracy: AccuracyData,
26+
pub missed_words: Vec<String>,
27+
}
28+
29+
impl From<&Test> for Results {
30+
fn from(test: &Test) -> Self {
31+
let events: Vec<&TestEvent> = test.words.iter().flat_map(|w| w.events.iter()).collect();
32+
33+
Self {
34+
timing: calculate::timing(&events),
35+
accuracy: calculate::accuracy(&events),
36+
missed_words: calculate::missed_words(test),
37+
}
38+
}
39+
}
40+
41+
pub struct ResultsComponent {
42+
pub results: Results,
43+
pub theme: Theme,
44+
}

src/components/result/timing.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
use std::collections::HashMap;
2+
use tuirealm::ratatui::crossterm::event::KeyEvent;
3+
4+
#[derive(Clone, Debug, PartialEq)]
5+
pub struct Data {
6+
// Instead of storing WPM, we store CPS (clicks per second)
7+
pub overall_cps: f64,
8+
pub per_event: Vec<f64>,
9+
pub per_key: HashMap<KeyEvent, f64>,
10+
}

0 commit comments

Comments
 (0)