1use crate::{
2 actions::{self, Action},
3 builders::lexicon_builder::Lexicon,
4 config::{Config, Mode},
5 constants::db_file,
6 db::Db,
7 error::AppError,
8 handler::AppHandler,
9 input::{Input, InputContext},
10 leaderboard::Leaderboard,
11 log_debug, log_error, log_info,
12 menu::{Menu, MenuAction},
13 modal::Modal,
14 notify_error, notify_info, theme,
15 tracker::Tracker,
16 tui,
17};
18use anyhow::Result;
19use crossterm::event::{self, Event, KeyEventKind};
20use crossterm::execute;
21use ratatui::{Terminal, prelude::Backend};
22use std::io::stdout;
23use std::time::Duration;
24
25pub fn run<B: Backend>(terminal: &mut Terminal<B>, config: &Config) -> anyhow::Result<()> {
26 let mut input = Input::new();
27 let mut app = App::new(config);
28
29 theme::init_from_config(config)?;
30
31 terminal.draw(|frame| {
38 let _ = tui::renderer::draw_ui(frame, &mut app);
39 })?;
40 app.needs_redraw = false;
41
42 log_info!("The config: {config:?}");
43 loop {
44 if app.should_quit {
45 break;
46 }
47
48 let poll_duration = app.get_poll_duration();
49
50 if event::poll(poll_duration)? {
51 match event::read()? {
52 Event::Key(event) if event.kind == KeyEventKind::Press => {
53 let input_ctx = app.resolve_input_context();
54 let input_result = input.handle(event, input_ctx);
55 if !input_result.skip_debounce && app.handle_debounce() {
56 continue;
57 }
58 actions::handle_action(&mut app, input_result.action)?;
59 app.mark_needs_redraw();
60 }
61 Event::Resize(_, _) => {
62 app.mark_needs_redraw();
63 }
64 _ => {}
65 }
66 }
67
68 app.tracker.try_metrics_update();
69 if app.tracker.check_completion() {
70 app.try_save_results();
71 app.mark_needs_redraw();
72 }
73
74 if app.tracker.is_typing() {
75 app.mark_needs_redraw();
76 }
77
78 let current_count = crate::notifications::count();
82 if current_count != app.last_notification_count {
83 log_debug!("Notification count changed, trigger redraw!");
84 app.mark_needs_redraw();
85 app.last_notification_count = current_count;
86 }
87
88 if app.take_needs_redraw() {
89 terminal.draw(|frame| {
90 let _ = tui::renderer::draw_ui(frame, &mut app);
92 })?;
93 }
94 }
95
96 Ok(())
97}
98
99pub struct App {
100 pub db: Option<Db>,
101 pub config: Config,
102 pub menu: Menu,
103 pub modal: Option<Modal>,
104 pub leaderboard: Option<Leaderboard>,
105 pub handler: AppHandler,
106 pub lexicon: Lexicon,
107 pub tracker: Tracker,
108 should_quit: bool,
109 needs_redraw: bool,
110 last_notification_count: usize,
111}
112
113impl App {
114 pub fn new(config: &Config) -> Self {
115 let lexicon = Lexicon::new(config).unwrap();
116 #[allow(unused_mut)]
117 let mut tracker = Tracker::new(lexicon.words.clone(), config.current_mode());
118
119 #[cfg(debug_assertions)]
120 if config.cli.show_results {
121 Self::force_show_results_screen(&mut tracker);
122 }
123
124 let db = match Db::new(db_file()) {
125 Ok(db) => Some(db),
126 Err(err) => {
127 log_error!("DB: Failed to initialize local database with: {err}");
128 notify_error!("Faled to initialize Local Database");
129 None
130 }
131 };
132
133 Self {
134 db,
135 config: config.clone(),
136 menu: Menu::new(),
137 modal: None,
138 leaderboard: None,
139 handler: AppHandler,
140 tracker,
141 lexicon,
142 should_quit: false,
143 needs_redraw: true,
144 last_notification_count: 0,
145 }
146 }
147
148 pub fn quit(&mut self) -> Result<(), AppError> {
149 self.sync_global_changes()?;
150 self.should_quit = true;
151 Ok(())
152 }
153
154 fn mark_needs_redraw(&mut self) {
156 self.needs_redraw = true;
157 }
158
159 fn take_needs_redraw(&mut self) -> bool {
161 let needs = self.needs_redraw;
162 self.needs_redraw = false;
163 needs
164 }
165
166 fn get_poll_duration(&self) -> Duration {
168 let ctx = self.resolve_input_context();
169 match ctx {
170 InputContext::Typing => Duration::from_millis(75),
171 InputContext::Menu { .. } | InputContext::Modal | InputContext::Leaderboard => {
172 Duration::from_millis(100)
173 }
174 InputContext::Idle => Duration::from_millis(250),
175 InputContext::Completed => Duration::from_millis(1000),
176 }
177 }
178
179 pub fn redo(&mut self) -> Result<(), AppError> {
180 self.tracker
181 .reset(self.lexicon.words.clone(), self.config.current_mode());
182 Ok(())
183 }
184
185 pub fn restart(&mut self) -> Result<(), AppError> {
186 self.lexicon.regenerate(&self.config)?;
193 self.tracker
194 .reset(self.lexicon.words.clone(), self.config.current_mode());
195 Ok(())
196 }
197
198 pub fn try_save_results(&mut self) {
199 if !self.config.can_save_results() {
200 log_info!("DB: Not saving test result to local database due to `--no-save` flag");
202 return;
203 }
204
205 if !self.should_save_results() {
206 notify_info!("Test invalid - too short")
207 }
208
209 let Some(db) = &mut self.db else {
210 log_debug!("DB: No database availabe, skipping saving results");
211 notify_error!("Could not save results");
212 return;
213 };
214
215 if let Err(err) = db.write(&self.config, &self.tracker) {
218 log_error!("DB: Failed trying to save results with error: {err}");
219 notify_error!("Could not save results")
220 };
221 }
222
223 fn should_save_results(&self) -> bool {
224 const MIN_TIME_FOR_SAVING: usize = if cfg!(debug_assertions) { 1 } else { 15 };
225 const MIN_WORDS_FOR_SAVING: usize = if cfg!(debug_assertions) { 1 } else { 10 };
226 match self.config.current_mode() {
227 Mode::Time(duration) => duration >= MIN_TIME_FOR_SAVING,
228 Mode::Words(count) => count >= MIN_WORDS_FOR_SAVING,
229 }
230 }
231
232 pub(crate) fn try_preview(&mut self) -> Result<(), AppError> {
234 let is_theme_preview = self
235 .menu
236 .current_item()
237 .map(|item| {
238 item.has_preview && matches!(item.action, MenuAction::Action(Action::SetTheme(_)))
239 })
240 .unwrap_or(false);
241
242 if !is_theme_preview {
243 theme::cancel_theme_preview();
244 }
245
246 if let Some(item) = self.menu.current_item() {
247 if item.has_preview {
248 match &item.action {
249 MenuAction::Action(Action::SetTheme(name)) => theme::set_as_preview_theme(name),
250 MenuAction::Action(Action::SetCursorVariant(variant)) => {
251 let _ = execute!(stdout(), variant.to_crossterm());
252 Ok(())
253 }
254 _ => Ok(()),
255 }?;
256 }
257 }
258 Ok(())
259 }
260
261 pub(crate) fn restore_cursor_style(&self) {
262 use crossterm::execute;
263 use std::io::stdout;
264
265 let current_variant = self.config.current_cursor_variant();
266 let _ = execute!(stdout(), current_variant.to_crossterm());
267 }
268
269 fn sync_global_changes(&mut self) -> Result<(), AppError> {
270 let theme = theme::current_theme();
272 log_debug!("The current theme: {theme:?}");
273 self.config.change_theme(theme);
274 self.config.persist()?;
275 Ok(())
276 }
277
278 fn resolve_input_context(&self) -> InputContext {
282 if self.modal.is_some() {
283 InputContext::Modal
284 } else if self.leaderboard.as_ref().is_some_and(|l| l.is_open()) {
285 InputContext::Leaderboard
286 } else if self.menu.is_open() {
287 InputContext::Menu {
288 searching: self.menu.is_searching(),
289 }
290 } else if self.tracker.is_complete() {
291 InputContext::Completed
292 } else if self.tracker.in_progress() {
293 InputContext::Typing
294 } else {
295 InputContext::Idle
296 }
297 }
298
299 fn handle_debounce(&self) -> bool {
300 if self.tracker.is_complete() {
301 if let Some(end_time) = self.tracker.end_time {
302 if end_time.elapsed() < Duration::from_millis(500) {
303 return true;
304 }
305 }
306 }
307 false
308 }
309
310 #[cfg(debug_assertions)]
311 fn force_show_results_screen(tracker: &mut Tracker) {
312 tracker.start_typing();
313 let test_chars = "hello world test";
314 for c in test_chars.chars() {
315 let _ = tracker.type_char(c);
316 }
317 tracker.complete();
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use crate::config::Config;
325
326 #[test]
327 fn test_command_palette_pause_resume() {
328 let config = Config::default();
329 let mut app = App::new(&config);
330 app.config
331 .change_mode(crate::config::Mode::with_words(2))
332 .unwrap();
333
334 app.handler.handle_input(&mut app, 'a').unwrap();
335 app.handler.handle_input(&mut app, 'n').unwrap();
336 app.handler.handle_input(&mut app, 'o').unwrap();
337 app.handler.handle_input(&mut app, 't').unwrap();
338 app.handler.handle_input(&mut app, 'h').unwrap();
339 app.handler.handle_input(&mut app, 'e').unwrap();
340 app.handler.handle_input(&mut app, 'r').unwrap();
341
342 app.handler.handle_command_palette_toggle(&mut app).unwrap();
343 app.handler
344 .handle_menu_update_search(&mut app, "s".to_string())
345 .unwrap();
346 assert!(app.tracker.is_paused());
347
348 app.handler.handle_menu_exit_search(&mut app).unwrap();
351 assert!(app.tracker.is_resuming());
352
353 app.handler.handle_input(&mut app, ' ').unwrap();
354 }
355}