Skip to main content

termitype/
app.rs

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    // NOTE(ema): this initial draw is needed do to the optimizations around reducing cpu usage on IDLE.
32    // These optmizations caused the first draw to happen after `250ms` which felt incredibly sluggish.
33    // To work around this, a good quick and easy solution is to do an immediate draw before
34    // entering the loop. Probably there's a better way to do this. If future me see this comment...
35    // you are probably thinking: "Who the f*k did this? What a sub-optimal way to handle this".
36    // ...It was you, always has been
37    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        // if the # of active notification changes we must trigger a redraw, otherwise we end up
79        // we infinite duration notification in results  (we don't trigger redraws in `Results`
80        // until a `KeyEvent` or `Action`). This is easiest solution to that problem.
81        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                // TODO: return the click actions
91                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    /// Mark that the app needs to redraw on next iteration
155    fn mark_needs_redraw(&mut self) {
156        self.needs_redraw = true;
157    }
158
159    /// Check if redraw is needed and consume it
160    fn take_needs_redraw(&mut self) -> bool {
161        let needs = self.needs_redraw;
162        self.needs_redraw = false;
163        needs
164    }
165
166    /// Get the appropriate poll duration based on current state
167    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        // NOTE: if we start a new test we want to clear the custom words flag as starting a new
187        //       test is designed to generate a completely new test. If the user want to keep
188        //       the custom words then a `Redo` is the option.
189        // if self.config.cli.words.is_some() {
190        //     self.config.cli.clear_custom_words_flag();
191        // }
192        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            // QUESTION: should we notify here that we are not storing the results due to the option of `no_save`?
201            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        // TODO: check for high scores
216
217        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    // TODO: do this cleanly
233    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        // NOTE: sync the theme changes before quitting.
271        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    // NOTE(ema): this is order dependet which can be dangerous and confusing.
279    // For example, if we put the modal `if` after the menu check it will never reach the modal if
280    // we opened the modal from the menu (as in this case we, currently, keep the menu open.
281    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        // if we are in the command palette we can close by hitting `Esc`,
349        // and hitting `Esc` while searching a menu will trigger `Action::MenuExitSearch`
350        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}