A small typing test program in Rust
Commit 1040e777706c
tarball3 files changed+374-174
@@ -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 = words74
- .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
- self104
- .text105
- .chars()106
- .nth(pos) 107
- }108
-109
- fn real_pos(&self) -> usize {110
- self.pos + self.err_n111
- }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
- third206
- } 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
+ third321
+ } 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.0341
+ }342
+343
+ fn get_accuracy(&self) -> f64 {344
+ (1.0 - self.errors as f64 / (self.typing.get_pos() + self.errors) as f64) * 100.0345
+ }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
@@ -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 = words34
+ .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.text60
+ }61
+62
+ pub fn get_pos(&self) -> usize {63
+ self.pos64
+ }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_n80
+ }81
+82
+ pub fn get_err_s(&self) -> &str {83
+ &self.err_s84
+ }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.len92
+ }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
+ self100
+ .text101
+ .chars()102
+ .nth(pos) 103
+ }104
+105
+ pub fn real_pos(&self) -> usize {106
+ self.pos + self.err_n107
+ }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
+}@@ -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
+}