jag

typing_rust

public

A small typing test program in Rust

Star0
Watch0

Commit 1040e777706c

tarball

Jaglekon <58274093+Jaglekon@users.noreply.github.com>2026-09-26 14:35 UTC

3 files changed+374-174
added time
Modified file: src/main.rs+222 additions−174 deletions
@@ -1,12 +1,13 @@
1+mod text_combine;
2+use text_combine::TextCombine;
3+
4+mod timer;
5+use timer::Timer;
6+
17 use std::{
2- io,
3- time::{
4- Duration,
5- Instant,
6- }
8+ fmt::format, io, time::Duration,
79 };
810
9-use rand::prelude::*;
1011
1112 use crossterm::event::{
1213 self,
@@ -19,13 +20,13 @@ use crossterm::event::{
1920
2021 use ratatui::{
2122 DefaultTerminal,
23+ Frame,
2224 buffer::Buffer,
23- layout::Rect,
24- style::{Stylize, Modifier},
25+ layout::{Constraint, Direction, Rect, Layout, Alignment},
26+ style::{Modifier, Stylize},
2527 symbols::border,
2628 text::{Line, Text},
27- widgets::{Block, Widget, Paragraph, Wrap},
28- Frame,
29+ widgets::{Block, Clear, Paragraph, Widget, Wrap},
2930 };
3031
3132 fn split_at_char(input: &str, n: usize) -> (&str, &str) {
@@ -40,123 +41,28 @@ fn split_at_char(input: &str, n: usize) -> (&str, &str) {
4041 }
4142
4243
43-#[derive(Debug)]
44-struct TextCombine {
45- text: String,
46- len: usize,
47- pos: usize,
48- err_n: usize,
49- err_s: String,
50-}
51-
52-impl TextCombine {
53- fn new() -> Self {
54- let words = [
55- "hello",
56- "how",
57- "to",
58- "back",
59- "big",
60- "small",
61- "something",
62- "glad",
63- "general",
64- "tall",
65- "nail",
66- "whatever",
67- "joke",
68- "feral",
69- "apple",
70- "plow"
71- ];
72-
73- /*let text = words
74- .choose_multiple(&mut rand::rng(), 10)
75- .cloned()
76- .collect::<Vec<_>>()
77- .join(" ");*/
78- let mut rng = rand::rng();
79-
80- let text = (0..60)
81- .filter_map(|_| words.choose(&mut rng))
82- .cloned()
83- .collect::<Vec<_>>()
84- .join(" ");
85-
86- let len = text.chars().count();
87-
88- Self {
89- text,
90- len,
91- pos: 0,
92- err_n: 0,
93- err_s: String::new(),
94- }
95-
96- }
97-
98- fn curr_char(&self) -> Option<char> {
99- self.pos_char(self.real_pos())
100- }
101-
102- fn pos_char(&self, pos: usize) -> Option<char> {
103- self
104- .text
105- .chars()
106- .nth(pos)
107- }
108-
109- fn real_pos(&self) -> usize {
110- self.pos + self.err_n
111- }
112-
113- fn back(&mut self) {
114- if self.err_s.len() > 0 {
115- self.err_s.pop() ;
116- } else if self.err_n > 0 {
117- self.err_n -= 1;
118- } else if self.pos > 0 {
119- self.pos -= 1;
120- }
121- }
122-}
123-
124-#[derive(Debug)]
125-pub struct Timer {
126- start: Instant,
127- duration: Duration,
128-}
129-
130-impl Timer {
131- fn new(duration: Duration) -> Self {
132- Self {
133- start: Instant::now(),
134- duration,
135- }
136- }
137-
138- fn remaining (&self) -> Duration {
139- self.duration.saturating_sub(self.elapsed())
140- }
141-
142- fn elapsed (&self) -> Duration {
143- self.start.elapsed()
144- }
44+#[derive(Debug, PartialEq)]
45+enum Status {
46+ Running,
47+ Menu,
48+ Exit,
14549 }
14650
14751 #[derive(Debug)]
14852 pub struct TypingTest {
149- status: bool,
53+ status: Status,
15054 errors: usize,
15155 typing: TextCombine,
15256 words: usize,
15357 timer: Timer,
15458 }
15559
60+use Status::*;
61+
15662 impl Default for TypingTest {
15763 fn default() -> Self {
15864 Self {
159- status: true,
65+ status: Running,
16066 errors: 0,
16167 words: 0,
16268 typing: TextCombine::new(),
@@ -167,73 +73,65 @@ impl Default for TypingTest {
16773
16874 impl Widget for &TypingTest {
16975 fn render(self, area: Rect, buf: &mut Buffer) {
170- let title = Line::from(vec![
171- " WPM ".into(),
172- format!("{:4.1}" ,(self.words as f64 / self.timer.elapsed().as_secs_f64()) * 60.0).into(),
173- " // Time ".into(),
174- format!("{:2}", (self.timer.remaining().as_secs())).into(),
175- " // Mistakes ".into(),
176- self.errors.to_string().into(),
177- (" ").into()
178- ]);
179- let bottom = Line::from(vec![
180- " Exit ".into(),
181- "<Esc> ".into(),
182- ]);
76+ match self.status {
77+ Running => self.render_game(area, buf),
78+ Menu => self.render_menu(area, buf),
79+ Exit => {},
80+ }
81+ }
82+}
18383
184- let block = Block::bordered()
185- .title(title.centered().bold())
186- .title_bottom(bottom.centered().bold())
187- .border_set(border::THICK);
188-
189- let (first, rest) =
190- split_at_char(self.typing.text.as_str(), self.typing.pos);
19184
192- let (second, rest2) =
193- split_at_char(rest, self.typing.err_n);
85+impl TypingTest {
19486
195- let (third, fourth) =
196- split_at_char(rest2, 1);
87+ fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
19788
198-
89+ while self.status != Exit {
90+ self.game(terminal)?;
91+ self.menu(terminal)?;
92+ }
19993
200- let text = Line::from(vec![
201- first.green().into(),
202- second.red().into(),
203- self.typing.err_s.clone().red().into(),
204- if third.len() != 0 {
205- third
206- } else {
207- " "
208- }
209- .add_modifier(Modifier::UNDERLINED | Modifier::BOLD)
210- .into(),
211- fourth.into(),
212- ]);
94+ Ok(())
95+ }
21396
97+ fn menu(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
98+ while self.status == Menu {
99+ terminal.draw(|frame| self.draw(frame))?;
100+ self.handle_events()?;
101+ }
214102
215- Paragraph::new(text)
216- .wrap(Wrap {trim: true})
217- .block(block)
218- .render(area, buf);
103+ Ok(())
219104 }
220-}
221-
105+
106+ fn game(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
107+
108+ if self.status != Running {
109+ return Ok(());
110+ }
222111
223-impl TypingTest {
112+ terminal.draw(|frame| self.draw(frame))?;
113+ self.handle_events()?;
224114
225- fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
115+ self.timer.start();
226116
227- while self.status {
228- terminal.draw(|frame| self.draw(frame))?;
117+ while self.status == Running {
229118 if poll(Duration::from_millis(100))? {
230119 self.handle_events()?;
231120 }
121+ terminal.draw(|frame| self.draw(frame))?;
122+ self.check_time();
232123 }
233-
124+
234125 Ok(())
126+
235127 }
236-
128+
129+ fn check_time(&mut self) {
130+ if self.timer.remaining().is_zero() {
131+ self.status = Menu;
132+ }
133+ }
134+
237135 fn draw(&self, frame: &mut Frame) {
238136 frame.render_widget(self, frame.area());
239137 }
@@ -241,14 +139,18 @@ impl TypingTest {
241139 fn handle_events(&mut self) -> io::Result<()>{
242140 match event::read()? {
243141 Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
244- self.handle_key_event(key_event);
142+ match self.status {
143+ Running => self.handle_game_event(key_event),
144+ Menu => self.handle_menu_event(key_event),
145+ Exit => {},
146+ }
245147 },
246148 _ => {},
247149 }
248150 Ok(())
249151 }
250152
251- fn handle_key_event(&mut self, key_event: KeyEvent) {
153+ fn handle_game_event(&mut self, key_event: KeyEvent) {
252154 match key_event.code {
253155 KeyCode::Esc => self.exit(),
254156 KeyCode::Backspace => self.back(),
@@ -257,6 +159,14 @@ impl TypingTest {
257159 }
258160 }
259161
162+ fn handle_menu_event(&mut self, key_event: KeyEvent) {
163+ match key_event.code {
164+ KeyCode::Esc => self.exit(),
165+ KeyCode::Enter => self.start(),
166+ _ => {}
167+ }
168+ }
169+
260170 fn back(&mut self) {
261171
262172 if let Some(pos) = self.typing
@@ -270,7 +180,7 @@ impl TypingTest {
270180
271181 fn handle_char(&mut self, c: char) {
272182
273- if self.typing.real_pos() >= self.typing.len {
183+ if self.typing.real_pos() >= self.typing.get_len() {
274184 return;
275185 }
276186
@@ -279,19 +189,19 @@ impl TypingTest {
279189 {
280190
281191 if curr == c
282- && self.typing.err_n == 0
283- && self.typing.err_s.len() == 0
192+ && self.typing.get_err_n() == 0
193+ && self.typing.get_err_s().len() == 0
284194 {
285- self.typing.pos += 1;
195+ self.typing.pos_increment();
286196 if c == ' ' {
287197 self.words += 1;
288198 }
289199 } else if c != ' ' {
290200 if curr != ' ' {
291- self.typing.err_n += 1;
201+ self.typing.err_n_increment();
292202 self.errors += 1;
293- } else if self.typing.err_s.len() < 16 {
294- self.typing.err_s.push(c);
203+ } else if self.typing.get_err_s().len() < 16 {
204+ self.typing.push_err_s(c);
295205 self.errors += 1;
296206 }
297207 }
@@ -300,8 +210,146 @@ impl TypingTest {
300210
301211 }
302212
213+ fn render_menu(&self, area: Rect, buf: &mut Buffer) {
214+ self.render_game(area, buf);
215+
216+ let title = Line::from(" Menu ");
217+ let bottom = Line::from( vec![
218+ " New Game ".into(),
219+ "<Enter> ".into(),
220+ "Exit ".into(),
221+ "<Esc> ".into(),
222+ ]);
223+
224+ let block = Block::bordered()
225+ .title(title.centered().bold())
226+ .title_bottom(bottom.centered().bold())
227+ .border_set(border::THICK);
228+
229+ let area_menu = area.centered(
230+ Constraint::Length(40),
231+ Constraint::Length(10),
232+
233+ );
234+
235+ let block_half = Block::bordered().border_set(border::PLAIN);
236+
237+ let split = Layout::default()
238+ .direction(Direction::Horizontal)
239+ .constraints([
240+ Constraint::Percentage(50),
241+ Constraint::Percentage(50),
242+ ])
243+ .split(block.inner(area_menu));
244+
245+ let wpm = Text::from(vec![
246+ "WPM".into(),
247+ format!("{:4.1}",self.get_wpm()).into(),
248+ ])
249+ .centered()
250+ .bold();
251+
252+ let accuarcy = Text::from(vec![
253+ "Accuracy".into(),
254+ format!("{:3.1}", self.get_accuracy()).into(),
255+ ])
256+ .centered()
257+ .bold();
258+
259+ let wpm_area = block_half.inner(split[0]).centered(
260+ Constraint::Length(wpm.width() as u16),
261+ Constraint::Length(2),
262+ );
263+
264+ let accuarcy_area = block_half.inner(split[1]).centered(
265+ Constraint::Length(accuarcy.width() as u16),
266+ Constraint::Length(2),
267+ );
268+
269+
270+
271+ Clear.render(area_menu, buf);
272+ block_half.clone().render(split[0], buf);
273+ wpm.render(wpm_area, buf);
274+ block_half.render(split[1], buf);
275+ accuarcy.render(accuarcy_area, buf);
276+ block.render(area_menu, buf);
277+
278+ }
279+
280+ fn render_game(&self, area: Rect, buf: &mut Buffer) {
281+ let title = Line::from(vec![
282+ " WPM ".into(),
283+ format!("{:4.1}" , self.get_wpm()).into(),
284+ " // Time ".into(),
285+ if self.timer.remaining().as_secs() < 10 {
286+ format!("{:.1}", self.timer.remaining().as_millis() as f64 / 1000.0).into()
287+ } else {
288+ format!("{:2} ", (self.timer.remaining().as_secs())).into()
289+ },
290+ " // Mistakes ".into(),
291+ self.errors.to_string().into(),
292+ (" ").into()
293+ ]);
294+ let bottom = Line::from(vec![
295+ " Exit ".into(),
296+ "<Esc> ".into(),
297+ ]);
298+
299+ let block = Block::bordered()
300+ .title(title.centered().bold())
301+ .title_bottom(bottom.centered().bold())
302+ .border_set(border::THICK);
303+
304+ let (first, rest) =
305+ split_at_char(self.typing.get_text(), self.typing.get_pos());
306+
307+ let (second, rest2) =
308+ split_at_char(rest, self.typing.get_err_n());
309+
310+ let (third, fourth) =
311+ split_at_char(rest2, 1);
312+
313+
314+
315+ let text = Line::from(vec![
316+ first.green().into(),
317+ second.red().into(),
318+ self.typing.get_err_s().red().into(),
319+ if third.len() != 0 {
320+ third
321+ } else {
322+ " "
323+ }
324+ .add_modifier(Modifier::UNDERLINED | Modifier::BOLD)
325+ .into(),
326+ fourth.into(),
327+ ]);
328+
329+
330+ Paragraph::new(text)
331+ .wrap(Wrap {trim: true})
332+ .block(block)
333+ .render(area, buf);
334+ }
335+
336+
337+ fn get_wpm(&self) -> f64 {
338+ (((self.typing.get_pos() + 1) as f64 / 5.0)
339+ / self.timer.elapsed().as_secs_f64())
340+ * 60.0
341+ }
342+
343+ fn get_accuracy(&self) -> f64 {
344+ (1.0 - self.errors as f64 / (self.typing.get_pos() + self.errors) as f64) * 100.0
345+ }
346+
347+ fn start(&mut self) {
348+ *self = Self::default();
349+ }
350+
303351 fn exit(&mut self) {
304- self.status = false;
352+ self.status = Exit;
305353 }
306354 }
307355
Added file: src/text_combine.rs+118 additions
@@ -0,0 +1,118 @@
1+use rand::prelude::*;
2+
3+#[derive(Debug)]
4+pub struct TextCombine {
5+ text: String,
6+ len: usize,
7+ pos: usize,
8+ err_n: usize,
9+ err_s: String,
10+}
11+
12+impl TextCombine {
13+ pub fn new() -> Self {
14+ let words = [
15+ "hello",
16+ "how",
17+ "to",
18+ "back",
19+ "big",
20+ "small",
21+ "something",
22+ "glad",
23+ "general",
24+ "tall",
25+ "nail",
26+ "whatever",
27+ "joke",
28+ "feral",
29+ "apple",
30+ "plow"
31+ ];
32+
33+ /*let text = words
34+ .choose_multiple(&mut rand::rng(), 10)
35+ .cloned()
36+ .collect::<Vec<_>>()
37+ .join(" ");*/
38+ let mut rng = rand::rng();
39+
40+ let text = (0..60)
41+ .filter_map(|_| words.choose(&mut rng))
42+ .cloned()
43+ .collect::<Vec<_>>()
44+ .join(" ");
45+
46+ let len = text.chars().count();
47+
48+ Self {
49+ text,
50+ len,
51+ pos: 0,
52+ err_n: 0,
53+ err_s: String::new(),
54+ }
55+
56+ }
57+
58+ pub fn get_text(&self) -> &str {
59+ &self.text
60+ }
61+
62+ pub fn get_pos(&self) -> usize {
63+ self.pos
64+ }
65+
66+ pub fn pos_increment(&mut self) {
67+ if self.pos < self.len {
68+ self.pos += 1;
69+ }
70+ }
71+
72+ pub fn err_n_increment(&mut self) {
73+ if self.real_pos() < self.len {
74+ self.err_n += 1;
75+ }
76+ }
77+
78+ pub fn get_err_n(&self) -> usize {
79+ self.err_n
80+ }
81+
82+ pub fn get_err_s(&self) -> &str {
83+ &self.err_s
84+ }
85+
86+ pub fn push_err_s(&mut self, c: char) {
87+ self.err_s.push(c);
88+ }
89+
90+ pub fn get_len(&self) -> usize {
91+ self.len
92+ }
93+
94+ pub fn curr_char(&self) -> Option<char> {
95+ self.pos_char(self.real_pos())
96+ }
97+
98+ pub fn pos_char(&self, pos: usize) -> Option<char> {
99+ self
100+ .text
101+ .chars()
102+ .nth(pos)
103+ }
104+
105+ pub fn real_pos(&self) -> usize {
106+ self.pos + self.err_n
107+ }
108+
109+ pub fn back(&mut self) {
110+ if self.err_s.len() > 0 {
111+ self.err_s.pop() ;
112+ } else if self.err_n > 0 {
113+ self.err_n -= 1;
114+ } else if self.pos > 0 {
115+ self.pos -= 1;
116+ }
117+ }
118+}
Added file: src/timer.rs+34 additions
@@ -0,0 +1,34 @@
1+use std::time::{
2+ Duration,
3+ Instant,
4+ };
5+
6+#[derive(Debug)]
7+pub struct Timer {
8+ start: Option<Instant>,
9+ duration: Duration,
10+}
11+
12+impl Timer {
13+ pub fn new(duration: Duration) -> Self {
14+ Self {
15+ start: None,
16+ duration,
17+ }
18+ }
19+
20+ pub fn start(&mut self) {
21+ self.start = Some(Instant::now());
22+ }
23+
24+ pub fn remaining (&self) -> Duration {
25+ self.duration.saturating_sub(self.elapsed())
26+ }
27+
28+ pub fn elapsed (&self) -> Duration {
29+ match self.start {
30+ Some(time) => time.elapsed(),
31+ None => Duration::ZERO,
32+ }
33+ }
34+}