Skip to content

Commit

Permalink
feat(ui): add feedback per letter of correct vs incorrect (#90)
Browse files Browse the repository at this point in the history
* Add feedback per letter of correct vs incorrect

Features added:
- only incorrect character in red
- if space is pressed too early, the previous word shows up as still
  partially untyped
- if type after the end of word, the extra letters will show up as
  errors (like in monkeytype)

* ui: clean up and format
  • Loading branch information
glazari committed Oct 3, 2023
1 parent 85acfdc commit c4dbffe
Show file tree
Hide file tree
Showing 2 changed files with 233 additions and 64 deletions.
6 changes: 6 additions & 0 deletions src/test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ impl From<String> for TestWord {
}
}

impl From<&str> for TestWord {
fn from(string: &str) -> Self {
Self::from(string.to_string())
}
}

#[derive(Debug)]
pub struct Test {
pub words: Vec<TestWord>,
Expand Down
291 changes: 227 additions & 64 deletions src/ui.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::config::Theme;

use super::test::{results, Test};
use super::test::{results, Test, TestWord};

use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
Expand All @@ -12,7 +12,6 @@ use ratatui::{
widgets::{Axis, Block, BorderType, Borders, Chart, Dataset, GraphType, Paragraph, Widget},
};
use results::Fraction;
use std::iter;

// Convert CPS to WPM (clicks per second)
const WPM_PER_CPS: f64 = 12.0;
Expand Down Expand Up @@ -101,62 +100,7 @@ impl ThemedWidget for &Test {
input.render(buf);

let target_lines: Vec<Line> = {
let words = iter::empty::<Vec<Span>>()
// already typed words
.chain(self.words[..self.current_word].iter().map(|w| {
vec![Span::styled(
w.text.clone() + " ",
if w.progress == w.text {
theme.prompt_correct
} else {
theme.prompt_incorrect
},
)]
}))
// current word
.chain({
let progress_ind = self.words[self.current_word]
.progress
.len()
.min(self.words[self.current_word].text.len());

let correct = self.words[self.current_word]
.text
.starts_with(&self.words[self.current_word].progress[..]);

let (typed, untyped) =
self.words[self.current_word]
.text
.split_at(ceil_char_boundary(
&self.words[self.current_word].text,
progress_ind,
));

let mut remaining = untyped.chars().chain(iter::once(' '));
let cursor = remaining.next().unwrap();

iter::once(vec![
Span::styled(
typed,
if correct {
theme.prompt_current_correct
} else {
theme.prompt_current_incorrect
},
),
Span::styled(
cursor.to_string(),
theme.prompt_current_untyped.patch(theme.prompt_cursor),
),
Span::styled(remaining.collect::<String>(), theme.prompt_current_untyped),
])
})
// remaining words
.chain(
self.words[self.current_word + 1..]
.iter()
.map(|w| vec![Span::styled(w.text.clone() + " ", theme.prompt_untyped)]),
);
let words = words_to_spans(&self.words, self.current_word, theme);

let mut lines: Vec<Line> = Vec::new();
let mut current_line: Vec<Span> = Vec::new();
Expand Down Expand Up @@ -189,6 +133,141 @@ impl ThemedWidget for &Test {
}
}

fn words_to_spans<'a>(
words: &'a [TestWord],
current_word: usize,
theme: &'a Theme,
) -> Vec<Vec<Span<'a>>> {
let mut spans = Vec::new();

for word in &words[..current_word] {
let parts = split_typed_word(word);
spans.push(word_parts_to_spans(parts, theme));
}

let parts_current = split_current_word(&words[current_word]);
spans.push(word_parts_to_spans(parts_current, theme));

for word in &words[current_word + 1..] {
let parts = vec![(word.text.clone(), Status::Untyped)];
spans.push(word_parts_to_spans(parts, theme));
}
spans
}

#[derive(PartialEq, Clone, Copy, Debug)]
enum Status {
Correct,
Incorrect,
CurrentUntyped,
CurrentCorrect,
CurrentIncorrect,
Cursor,
Untyped,
Overtyped,
}

fn split_current_word(word: &TestWord) -> Vec<(String, Status)> {
let mut parts = Vec::new();
let mut cur_string = String::new();
let mut cur_status = Status::Untyped;

let mut progress = word.progress.chars();
for tc in word.text.chars() {
let p = progress.next();
let status = match p {
None => Status::CurrentUntyped,
Some(c) => match c {
c if c == tc => Status::CurrentCorrect,
_ => Status::CurrentIncorrect,
},
};

if status == cur_status {
cur_string.push(tc);
} else {
if !cur_string.is_empty() {
parts.push((cur_string, cur_status));
cur_string = String::new();
}
cur_string.push(tc);
cur_status = status;

// first currentuntyped is cursor
if status == Status::CurrentUntyped {
parts.push((cur_string, Status::Cursor));
cur_string = String::new();
}
}
}
if !cur_string.is_empty() {
parts.push((cur_string, cur_status));
}
let overtyped = progress.collect::<String>();
if !overtyped.is_empty() {
parts.push((overtyped, Status::Overtyped));
}
parts
}

fn split_typed_word(word: &TestWord) -> Vec<(String, Status)> {
let mut parts = Vec::new();
let mut cur_string = String::new();
let mut cur_status = Status::Untyped;

let mut progress = word.progress.chars();
for tc in word.text.chars() {
let p = progress.next();
let status = match p {
None => Status::Untyped,
Some(c) => match c {
c if c == tc => Status::Correct,
_ => Status::Incorrect,
},
};

if status == cur_status {
cur_string.push(tc);
} else {
if !cur_string.is_empty() {
parts.push((cur_string, cur_status));
cur_string = String::new();
}
cur_string.push(tc);
cur_status = status;
}
}
if !cur_string.is_empty() {
parts.push((cur_string, cur_status));
}

let overtyped = progress.collect::<String>();
if !overtyped.is_empty() {
parts.push((overtyped, Status::Overtyped));
}
parts
}

fn word_parts_to_spans(parts: Vec<(String, Status)>, theme: &Theme) -> Vec<Span<'_>> {
let mut spans = Vec::new();
for (text, status) in parts {
let style = match status {
Status::Correct => theme.prompt_correct,
Status::Incorrect => theme.prompt_incorrect,
Status::Untyped => theme.prompt_untyped,
Status::CurrentUntyped => theme.prompt_current_untyped,
Status::CurrentCorrect => theme.prompt_current_correct,
Status::CurrentIncorrect => theme.prompt_current_incorrect,
Status::Cursor => theme.prompt_current_untyped.patch(theme.prompt_cursor),
Status::Overtyped => theme.prompt_incorrect,
};

spans.push(Span::styled(text, style));
}
spans.push(Span::styled(" ", theme.prompt_untyped));
spans
}

impl ThemedWidget for &results::Results {
fn render(self, area: Rect, buf: &mut Buffer, theme: &Theme) {
buf.set_style(area, theme.default);
Expand Down Expand Up @@ -338,11 +417,95 @@ impl ThemedWidget for &results::Results {
}
}

// FIXME: replace with `str::ceil_char_boundary` when stable
fn ceil_char_boundary(string: &str, index: usize) -> usize {
if string.is_char_boundary(index) {
index
} else {
ceil_char_boundary(string, index + 1)
#[cfg(test)]
mod tests {
use super::*;

mod split_words {
use super::Status::*;
use super::*;

struct TestCase {
word: &'static str,
progress: &'static str,
expected: Vec<(&'static str, Status)>,
}

fn setup(test_case: TestCase) -> (TestWord, Vec<(String, Status)>) {
let mut word = TestWord::from(test_case.word);
word.progress = test_case.progress.to_string();

let expected = test_case
.expected
.iter()
.map(|(s, v)| (s.to_string(), *v))
.collect::<Vec<_>>();

(word, expected)
}

#[test]
fn typed_words_split() {
let cases = vec![
TestCase {
word: "monkeytype",
progress: "monkeytype",
expected: vec![("monkeytype", Correct)],
},
TestCase {
word: "monkeytype",
progress: "monkeXtype",
expected: vec![("monke", Correct), ("y", Incorrect), ("type", Correct)],
},
TestCase {
word: "monkeytype",
progress: "monkeas",
expected: vec![("monke", Correct), ("yt", Incorrect), ("ype", Untyped)],
},
];

for case in cases {
let (word, expected) = setup(case);
let got = split_typed_word(&word);
assert_eq!(got, expected);
}
}

#[test]
fn current_word_split() {
let cases = vec![
TestCase {
word: "monkeytype",
progress: "monkeytype",
expected: vec![("monkeytype", CurrentCorrect)],
},
TestCase {
word: "monkeytype",
progress: "monke",
expected: vec![
("monke", CurrentCorrect),
("y", Cursor),
("type", CurrentUntyped),
],
},
TestCase {
word: "monkeytype",
progress: "monkeXt",
expected: vec![
("monke", CurrentCorrect),
("y", CurrentIncorrect),
("t", CurrentCorrect),
("y", Cursor),
("pe", CurrentUntyped),
],
},
];

for case in cases {
let (word, expected) = setup(case);
let got = split_current_word(&word);
assert_eq!(got, expected);
}
}
}
}

0 comments on commit c4dbffe

Please sign in to comment.