Skip to content

Commit f36c98d

Browse files
committed
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)
1 parent fad07b2 commit f36c98d

File tree

2 files changed

+239
-57
lines changed

2 files changed

+239
-57
lines changed

src/test/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ impl From<String> for TestWord {
3636
}
3737
}
3838

39+
impl From<&str> for TestWord {
40+
fn from(string: &str) -> Self {
41+
Self::from(string.to_string())
42+
}
43+
}
44+
3945
#[derive(Debug)]
4046
pub struct Test {
4147
pub words: Vec<TestWord>,

src/ui.rs

Lines changed: 233 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::config::Theme;
22

3-
use super::test::{results, Test};
3+
use super::test::{results, Test, TestWord};
44

55
use crossterm::event::KeyCode;
66
use crossterm::event::KeyEvent;
@@ -101,62 +101,7 @@ impl ThemedWidget for &Test {
101101
input.render(buf);
102102

103103
let target_lines: Vec<Line> = {
104-
let words = iter::empty::<Vec<Span>>()
105-
// already typed words
106-
.chain(self.words[..self.current_word].iter().map(|w| {
107-
vec![Span::styled(
108-
w.text.clone() + " ",
109-
if w.progress == w.text {
110-
theme.prompt_correct
111-
} else {
112-
theme.prompt_incorrect
113-
},
114-
)]
115-
}))
116-
// current word
117-
.chain({
118-
let progress_ind = self.words[self.current_word]
119-
.progress
120-
.len()
121-
.min(self.words[self.current_word].text.len());
122-
123-
let correct = self.words[self.current_word]
124-
.text
125-
.starts_with(&self.words[self.current_word].progress[..]);
126-
127-
let (typed, untyped) =
128-
self.words[self.current_word]
129-
.text
130-
.split_at(ceil_char_boundary(
131-
&self.words[self.current_word].text,
132-
progress_ind,
133-
));
134-
135-
let mut remaining = untyped.chars().chain(iter::once(' '));
136-
let cursor = remaining.next().unwrap();
137-
138-
iter::once(vec![
139-
Span::styled(
140-
typed,
141-
if correct {
142-
theme.prompt_current_correct
143-
} else {
144-
theme.prompt_current_incorrect
145-
},
146-
),
147-
Span::styled(
148-
cursor.to_string(),
149-
theme.prompt_current_untyped.patch(theme.prompt_cursor),
150-
),
151-
Span::styled(remaining.collect::<String>(), theme.prompt_current_untyped),
152-
])
153-
})
154-
// remaining words
155-
.chain(
156-
self.words[self.current_word + 1..]
157-
.iter()
158-
.map(|w| vec![Span::styled(w.text.clone() + " ", theme.prompt_untyped)]),
159-
);
104+
let words = words_to_spans(&self.words, self.current_word, theme);
160105

161106
let mut lines: Vec<Line> = Vec::new();
162107
let mut current_line: Vec<Span> = Vec::new();
@@ -189,6 +134,144 @@ impl ThemedWidget for &Test {
189134
}
190135
}
191136

137+
138+
139+
fn words_to_spans<'a>(
140+
words: &'a [TestWord],
141+
current_word: usize,
142+
theme: &'a Theme,
143+
) -> Vec<Vec<Span<'a>>> {
144+
let mut spans = Vec::new();
145+
146+
for word in &words[..current_word] {
147+
let parts = split_typed_word(word);
148+
spans.push(word_parts_to_spans(parts, theme));
149+
}
150+
151+
let parts_current = split_current_word(&words[current_word]);
152+
spans.push(word_parts_to_spans(parts_current, theme));
153+
154+
for word in &words[current_word + 1..] {
155+
let parts = vec![(word.text.clone(), Status::Untyped)];
156+
spans.push(word_parts_to_spans(parts, theme));
157+
}
158+
spans
159+
}
160+
161+
#[derive(PartialEq, Clone, Copy, Debug)]
162+
enum Status {
163+
Correct,
164+
Incorrect,
165+
CurrentUntyped,
166+
CurrentCorrect,
167+
CurrentIncorrect,
168+
Cursor,
169+
Untyped,
170+
Overtyped,
171+
}
172+
fn split_current_word(word: &TestWord) -> Vec<(String, Status)> {
173+
let mut parts = Vec::new();
174+
let mut cur_string = String::new();
175+
let mut cur_status = Status::Untyped;
176+
177+
let mut progress = word.progress.chars();
178+
for tc in word.text.chars() {
179+
let p = progress.next();
180+
let status = match p {
181+
None => Status::CurrentUntyped,
182+
Some(c) => match c {
183+
c if c == tc => Status::CurrentCorrect,
184+
_ => Status::CurrentIncorrect,
185+
},
186+
};
187+
188+
if status == cur_status {
189+
cur_string.push(tc);
190+
} else {
191+
if !cur_string.is_empty() {
192+
parts.push((cur_string, cur_status));
193+
cur_string = String::new();
194+
}
195+
cur_string.push(tc);
196+
cur_status = status;
197+
198+
// first currentuntyped is cursor
199+
if status == Status::CurrentUntyped {
200+
parts.push((cur_string, Status::Cursor));
201+
cur_string = String::new();
202+
}
203+
}
204+
}
205+
if !cur_string.is_empty() {
206+
parts.push((cur_string, cur_status));
207+
}
208+
let overtyped = progress.collect::<String>();
209+
if !overtyped.is_empty() {
210+
parts.push((overtyped, Status::Overtyped));
211+
}
212+
parts
213+
}
214+
215+
fn split_typed_word(word: &TestWord) -> Vec<(String, Status)> {
216+
let mut parts = Vec::new();
217+
let mut cur_string = String::new();
218+
let mut cur_status = Status::Untyped;
219+
220+
let mut progress = word.progress.chars();
221+
for tc in word.text.chars() {
222+
let p = progress.next();
223+
let status = match p {
224+
None => Status::Untyped,
225+
Some(c) => match c {
226+
c if c == tc => Status::Correct,
227+
_ => Status::Incorrect,
228+
},
229+
};
230+
231+
if status == cur_status {
232+
cur_string.push(tc);
233+
} else {
234+
if !cur_string.is_empty() {
235+
parts.push((cur_string, cur_status));
236+
cur_string = String::new();
237+
}
238+
cur_string.push(tc);
239+
cur_status = status;
240+
}
241+
}
242+
if !cur_string.is_empty() {
243+
parts.push((cur_string, cur_status));
244+
}
245+
246+
let overtyped = progress.collect::<String>();
247+
if !overtyped.is_empty() {
248+
parts.push((overtyped, Status::Overtyped));
249+
}
250+
parts
251+
}
252+
253+
254+
255+
fn word_parts_to_spans<'a>(parts: Vec<(String, Status)>, theme: &'a Theme) -> Vec<Span<'a>> {
256+
let mut spans = Vec::new();
257+
for (text, status) in parts {
258+
let style = match status {
259+
Status::Correct => theme.prompt_correct,
260+
Status::Incorrect => theme.prompt_incorrect,
261+
Status::Untyped => theme.prompt_untyped,
262+
Status::CurrentUntyped => theme.prompt_current_untyped,
263+
Status::CurrentCorrect => theme.prompt_current_correct,
264+
Status::CurrentIncorrect => theme.prompt_current_incorrect,
265+
Status::Cursor => theme.prompt_current_untyped.patch(theme.prompt_cursor),
266+
Status::Overtyped => theme.prompt_incorrect,
267+
};
268+
269+
spans.push(Span::styled(text, style));
270+
}
271+
spans.push(Span::styled(" ", theme.prompt_untyped));
272+
spans
273+
}
274+
192275
impl ThemedWidget for &results::Results {
193276
fn render(self, area: Rect, buf: &mut Buffer, theme: &Theme) {
194277
buf.set_style(area, theme.default);
@@ -337,3 +420,96 @@ fn ceil_char_boundary(string: &str, index: usize) -> usize {
337420
ceil_char_boundary(string, index + 1)
338421
}
339422
}
423+
424+
#[cfg(test)]
425+
mod test_ui {
426+
use super::*;
427+
428+
mod split_words {
429+
use super::Status::*;
430+
use super::*;
431+
432+
struct TestCase {
433+
word: &'static str,
434+
progress: &'static str,
435+
expected: Vec<(&'static str, Status)>,
436+
}
437+
438+
fn setup(test_case: TestCase) -> (TestWord, Vec<(String, Status)>) {
439+
let mut word = TestWord::from(test_case.word);
440+
word.progress = test_case.progress.to_string();
441+
442+
let expected = test_case
443+
.expected
444+
.iter()
445+
.map(|(s, v)| (s.to_string(), v.clone()))
446+
.collect::<Vec<_>>();
447+
448+
(word, expected)
449+
}
450+
451+
#[test]
452+
fn typed_words_split() {
453+
let cases = vec![
454+
TestCase {
455+
word: "monkeytype",
456+
progress: "monkeytype",
457+
expected: vec![("monkeytype", Correct)],
458+
},
459+
TestCase {
460+
word: "monkeytype",
461+
progress: "monkeXtype",
462+
expected: vec![("monke", Correct), ("y", Incorrect), ("type", Correct)],
463+
},
464+
TestCase {
465+
word: "monkeytype",
466+
progress: "monkeas",
467+
expected: vec![("monke", Correct), ("yt", Incorrect), ("ype", Untyped)],
468+
},
469+
];
470+
471+
for case in cases {
472+
let (word, expected) = setup(case);
473+
let got = split_typed_word(&word);
474+
assert_eq!(got, expected);
475+
}
476+
}
477+
478+
#[test]
479+
fn current_word_split() {
480+
let cases = vec![
481+
TestCase {
482+
word: "monkeytype",
483+
progress: "monkeytype",
484+
expected: vec![("monkeytype", CurrentCorrect)],
485+
},
486+
TestCase {
487+
word: "monkeytype",
488+
progress: "monke",
489+
expected: vec![
490+
("monke", CurrentCorrect),
491+
("y", Cursor),
492+
("type", CurrentUntyped),
493+
],
494+
},
495+
TestCase {
496+
word: "monkeytype",
497+
progress: "monkeXt",
498+
expected: vec![
499+
("monke", CurrentCorrect),
500+
("y", CurrentIncorrect),
501+
("t", CurrentCorrect),
502+
("y", Cursor),
503+
("pe", CurrentUntyped),
504+
],
505+
},
506+
];
507+
508+
for case in cases {
509+
let (word, expected) = setup(case);
510+
let got = split_current_word(&word);
511+
assert_eq!(got, expected);
512+
}
513+
}
514+
}
515+
}

0 commit comments

Comments
 (0)