booktyping_core/
app.rs

1use std::{collections::HashSet as Set, error};
2
3use chrono::{serde::ts_microseconds, DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::config::AppConfig;
7
8pub const DEFAULT_TEXT_WIDTH_PERCENT: u16 = 60;
9pub const FULL_TEXT_WIDTH_PERCENT: u16 = 95;
10const STARTING_SAMPLE_SIZE: usize = 100;
11
12pub type AppResult<T> = std::result::Result<T, Box<dyn error::Error>>;
13
14pub struct App {
15	pub running: bool,
16	pub book_lines: Vec<String>,
17	pub line_index: Vec<(usize, usize)>,
18	pub sample_start_index: usize,
19	pub sample_len: usize,
20	start_time: DateTime<Utc>,
21	pub display_line: usize,
22	pub text_width_percent: u16,
23	pub terminal_width: u16,
24	pub full_text_width: bool,
25	pub text: Text,
26}
27
28pub struct Text {
29	pub text: String,
30	pub cur_char: usize,
31	sample_log: Vec<Test>,
32	keypress_log: Vec<KeyPress>,
33	save: Box<dyn Fn(Vec<Test>, Vec<KeyPress>) -> AppResult<()>>,
34}
35
36impl Text {
37	pub fn new<F>(text: String, sample_log: Vec<Test>, save: F, cur_char: usize) -> Self
38	where
39		F: Fn(Vec<Test>, Vec<KeyPress>) -> AppResult<()> + 'static, {
40		Self {
41			text,
42			cur_char,
43			sample_log,
44			save: Box::new(save),
45			keypress_log: Default::default(),
46		}
47	}
48}
49
50impl App {
51	pub fn new(terminal_width: u16, text: Text) -> Self {
52		let mut ret = Self {
53			text,
54			terminal_width,
55			running: true,
56			text_width_percent: DEFAULT_TEXT_WIDTH_PERCENT,
57			full_text_width: false,
58			start_time: Default::default(),
59			sample_len: Default::default(),
60			sample_start_index: Default::default(),
61			book_lines: Default::default(),
62			line_index: Default::default(),
63			display_line: Default::default(),
64		};
65
66		let _ = ret.get_next_sample();
67		ret.generate_lines();
68
69		ret
70	}
71
72	pub fn quit(&mut self) -> AppResult<()> {
73		self.running = false;
74		(self.text.save)(self.text.sample_log.clone(), self.text.keypress_log.clone())
75	}
76
77	pub fn handle_char(&mut self, config: &AppConfig, c: char) -> AppResult<()> {
78		fn is_correct_char(myopia: bool, c: char, correct_char: char) -> bool {
79			// TODO: generate mistakable versions of words. Only generate existing words from e450k (+ vim spell file if flag is provided). Errors in capitalization are only allowed on the first char of each word.
80			let similar_char_groups = &[Set::from([',', '.', ';']), Set::from([';', ':']), Set::from(['n', 'h']), Set::from(['i', 'l', '1'])];
81
82			match myopia {
83				true => {
84					for char_group in similar_char_groups {
85						if char_group.contains(&c) || char_group.contains(&correct_char) {
86							return true;
87						}
88					}
89
90					c.to_lowercase().collect::<String>() == correct_char.to_lowercase().collect::<String>()
91				}
92				false => c == correct_char,
93			}
94		}
95		let actual_char = self.text.text.chars().nth(self.sample_start_index + self.text.cur_char).unwrap();
96		let correct = is_correct_char(config.myopia, c, actual_char);
97
98		if correct {
99			self.text.cur_char += 1
100		}
101		if !correct || self.text.cur_char == self.sample_len {
102			self.text.sample_log.push(Test {
103				succeeded: correct,
104				start_index: self.sample_start_index,
105				end_index: self.sample_start_index + self.text.cur_char,
106				started: self.start_time,
107				completed: Utc::now(),
108			});
109			self.start_time = Utc::now();
110			self.get_next_sample()?;
111
112			self.text.cur_char = 0;
113		}
114
115		let log_entry = &KeyPress { correct, key: c, time: Utc::now() };
116		self.text.keypress_log.push(log_entry.clone());
117
118		let &(cur_line, _) = self.line_index.get(self.sample_start_index + self.text.cur_char).unwrap();
119		self.display_line = cur_line;
120		Ok(())
121	}
122
123	pub fn generate_lines(&mut self) {
124		let max_line_len = (self.terminal_width as f64 * (self.text_width_percent as f64 / 100.0)) as usize;
125		let mut lines = Vec::new();
126		let mut line_index: Vec<(usize, usize)> = Vec::new();
127		let mut line = "".to_owned();
128		let mut word = "".to_owned();
129		let mut row_i = 0;
130		let mut column_i = 0;
131
132		for c in self.text.text.chars() {
133			word.push(c);
134			if c == ' ' {
135				if line.len() + word.len() < max_line_len {
136					line.push_str(&word);
137				} else {
138					lines.push(line);
139					line = word.to_owned();
140					row_i += 1;
141					column_i = 0;
142				}
143				for _ in 0..word.len() {
144					line_index.push((row_i, column_i));
145					column_i += 1;
146				}
147				word = "".to_owned();
148			}
149		}
150		if line.len() + word.len() < max_line_len {
151			line.push_str(&word);
152			lines.push(line);
153		} else {
154			lines.push(line);
155			lines.push(word.clone());
156			row_i += 1;
157		}
158		for _ in 0..word.len() {
159			line_index.push((row_i, column_i));
160			column_i += 1;
161		}
162
163		self.book_lines = lines;
164		self.display_line = line_index.get(self.sample_start_index).unwrap().0; // TODO allow for resize while scrolled
165		self.line_index = line_index;
166	}
167
168	fn get_next_sample(&mut self) -> AppResult<()> {
169		let tests = &self.text.sample_log;
170
171		let mut start_index = 0;
172		for t in tests {
173			if t.succeeded && t.end_index > start_index {
174				start_index = t.end_index;
175			}
176		}
177
178		let avg_50 = tests.iter().map(|t| t.end_index - t.start_index).filter(|&len| len > 5).rev().take(50).sum::<usize>() / 50;
179		let max_10 = tests
180			.iter()
181			.map(|t| t.end_index - t.start_index)
182			.filter(|&len| len > 5)
183			.rev()
184			.take(10)
185			.max()
186			.unwrap_or(STARTING_SAMPLE_SIZE);
187		let best = usize::max(avg_50, max_10) + 5;
188
189		let wrong_num = tests
190			.iter()
191			.rev()
192			.take_while(|t| !t.succeeded)
193			.map(|t| t.end_index - t.start_index)
194			.filter(|&len| len > 5)
195			.count();
196
197		let full = self.text.text.chars().skip(start_index).take(best).collect::<String>();
198
199		let len = full.split_whitespace().rev().skip(usize::max(wrong_num, 1)).collect::<Vec<_>>().join(" ").len() + 1;
200
201		self.sample_start_index = usize::min(start_index, self.text.text.len() - 1);
202		self.sample_len = usize::min(len, self.text.text.len() - start_index - 1);
203		self.start_time = Utc::now();
204		Ok(())
205	}
206
207	pub fn get_rolling_average(&self) -> AppResult<usize> {
208		Ok(self
209			.text
210			.sample_log
211			.iter()
212			.map(|t| t.end_index - t.start_index)
213			.filter(|&len| len > 5)
214			.rev()
215			.take(10)
216			.sum::<usize>()
217			/ 10)
218	}
219}
220
221#[derive(Serialize, Deserialize, Clone)]
222pub struct KeyPress {
223	correct: bool,
224	key: char,
225	#[serde(with = "ts_microseconds")]
226	time: DateTime<Utc>,
227}
228
229#[derive(Serialize, Deserialize, Clone)]
230pub struct Test {
231	succeeded: bool,
232	start_index: usize,
233	end_index: usize,
234	#[serde(with = "ts_microseconds")]
235	started: DateTime<Utc>,
236	#[serde(with = "ts_microseconds")]
237	completed: DateTime<Utc>,
238}