use crate::{
actions::{self, Action},
builders::lexicon_builder::Lexicon,
config::{Config, Mode},
constants::db_file,
db::Db,
error::AppError,
handler::AppHandler,
input::{Input, InputContext},
leaderboard::Leaderboard,
log_debug, log_error, log_info,
menu::{Menu, MenuAction},
modal::Modal,
notify_error, notify_info, theme,
tracker::Tracker,
tui,
};
use anyhow::Result;
use crossterm::event::{self, Event, KeyEventKind};
use crossterm::execute;
use ratatui::{Terminal, prelude::Backend};
use std::io::stdout;
use std::time::Duration;
pub fn run<B: Backend>(terminal: &mut Terminal<B>, config: &Config) -> anyhow::Result<()> {
let mut input = Input::new();
let mut app = App::new(config);
theme::init_from_config(config)?;
terminal.draw(|frame| {
let _ = tui::renderer::draw_ui(frame, &mut app);
})?;
app.needs_redraw = false;
log_info!("The config: {config:?}");
loop {
if app.should_quit {
break;
}
let poll_duration = app.get_poll_duration();
if event::poll(poll_duration)? {
match event::read()? {
Event::Key(event) if event.kind == KeyEventKind::Press => {
let input_ctx = app.resolve_input_context();
let input_result = input.handle(event, input_ctx);
if !input_result.skip_debounce && app.handle_debounce() {
continue;
}
actions::handle_action(&mut app, input_result.action)?;
app.mark_needs_redraw();
}
Event::Resize(_, _) => {
app.mark_needs_redraw();
}
_ => {}
}
}
app.tracker.try_metrics_update();
if app.tracker.check_completion() {
app.try_save_results();
app.mark_needs_redraw();
}
if app.tracker.is_typing() {
app.mark_needs_redraw();
}
let current_count = crate::notifications::count();
if current_count != app.last_notification_count {
log_debug!("Notification count changed, trigger redraw!");
app.mark_needs_redraw();
app.last_notification_count = current_count;
}
if app.take_needs_redraw() {
terminal.draw(|frame| {
let _ = tui::renderer::draw_ui(frame, &mut app);
})?;
}
}
Ok(())
}
pub struct App {
pub db: Option<Db>,
pub config: Config,
pub menu: Menu,
pub modal: Option<Modal>,
pub leaderboard: Option<Leaderboard>,
pub handler: AppHandler,
pub lexicon: Lexicon,
pub tracker: Tracker,
should_quit: bool,
needs_redraw: bool,
last_notification_count: usize,
}
impl App {
pub fn new(config: &Config) -> Self {
let lexicon = Lexicon::new(config).unwrap();
#[allow(unused_mut)]
let mut tracker = Tracker::new(lexicon.words.clone(), config.current_mode());
#[cfg(debug_assertions)]
if config.cli.show_results {
Self::force_show_results_screen(&mut tracker);
}
let db = match Db::new(db_file()) {
Ok(db) => Some(db),
Err(err) => {
log_error!("DB: Failed to initialize local database with: {err}");
notify_error!("Faled to initialize Local Database");
None
}
};
Self {
db,
config: config.clone(),
menu: Menu::new(),
modal: None,
leaderboard: None,
handler: AppHandler,
tracker,
lexicon,
should_quit: false,
needs_redraw: true,
last_notification_count: 0,
}
}
pub fn quit(&mut self) -> Result<(), AppError> {
self.sync_global_changes()?;
self.should_quit = true;
Ok(())
}
fn mark_needs_redraw(&mut self) {
self.needs_redraw = true;
}
fn take_needs_redraw(&mut self) -> bool {
let needs = self.needs_redraw;
self.needs_redraw = false;
needs
}
fn get_poll_duration(&self) -> Duration {
let ctx = self.resolve_input_context();
match ctx {
InputContext::Typing => Duration::from_millis(75),
InputContext::Menu { .. } | InputContext::Modal | InputContext::Leaderboard => {
Duration::from_millis(100)
}
InputContext::Idle => Duration::from_millis(250),
InputContext::Completed => Duration::from_millis(1000),
}
}
pub fn redo(&mut self) -> Result<(), AppError> {
self.tracker
.reset(self.lexicon.words.clone(), self.config.current_mode());
Ok(())
}
pub fn restart(&mut self) -> Result<(), AppError> {
self.lexicon.regenerate(&self.config)?;
self.tracker
.reset(self.lexicon.words.clone(), self.config.current_mode());
Ok(())
}
pub fn try_save_results(&mut self) {
if !self.config.can_save_results() {
log_info!("DB: Not saving test result to local database due to `--no-save` flag");
return;
}
if !self.should_save_results() {
notify_info!("Test invalid - too short")
}
let Some(db) = &mut self.db else {
log_debug!("DB: No database availabe, skipping saving results");
notify_error!("Could not save results");
return;
};
if let Err(err) = db.write(&self.config, &self.tracker) {
log_error!("DB: Failed trying to save results with error: {err}");
notify_error!("Could not save results")
};
}
fn should_save_results(&self) -> bool {
const MIN_TIME_FOR_SAVING: usize = if cfg!(debug_assertions) { 1 } else { 15 };
const MIN_WORDS_FOR_SAVING: usize = if cfg!(debug_assertions) { 1 } else { 10 };
match self.config.current_mode() {
Mode::Time(duration) => duration >= MIN_TIME_FOR_SAVING,
Mode::Words(count) => count >= MIN_WORDS_FOR_SAVING,
}
}
pub(crate) fn try_preview(&mut self) -> Result<(), AppError> {
let is_theme_preview = self
.menu
.current_item()
.map(|item| {
item.has_preview && matches!(item.action, MenuAction::Action(Action::SetTheme(_)))
})
.unwrap_or(false);
if !is_theme_preview {
theme::cancel_theme_preview();
}
if let Some(item) = self.menu.current_item() {
if item.has_preview {
match &item.action {
MenuAction::Action(Action::SetTheme(name)) => theme::set_as_preview_theme(name),
MenuAction::Action(Action::SetCursorVariant(variant)) => {
let _ = execute!(stdout(), variant.to_crossterm());
Ok(())
}
_ => Ok(()),
}?;
}
}
Ok(())
}
pub(crate) fn restore_cursor_style(&self) {
use crossterm::execute;
use std::io::stdout;
let current_variant = self.config.current_cursor_variant();
let _ = execute!(stdout(), current_variant.to_crossterm());
}
fn sync_global_changes(&mut self) -> Result<(), AppError> {
let theme = theme::current_theme();
log_debug!("The current theme: {theme:?}");
self.config.change_theme(theme);
self.config.persist()?;
Ok(())
}
fn resolve_input_context(&self) -> InputContext {
if self.modal.is_some() {
InputContext::Modal
} else if self.leaderboard.as_ref().is_some_and(|l| l.is_open()) {
InputContext::Leaderboard
} else if self.menu.is_open() {
InputContext::Menu {
searching: self.menu.is_searching(),
}
} else if self.tracker.is_complete() {
InputContext::Completed
} else if self.tracker.in_progress() {
InputContext::Typing
} else {
InputContext::Idle
}
}
fn handle_debounce(&self) -> bool {
if self.tracker.is_complete() {
if let Some(end_time) = self.tracker.end_time {
if end_time.elapsed() < Duration::from_millis(500) {
return true;
}
}
}
false
}
#[cfg(debug_assertions)]
fn force_show_results_screen(tracker: &mut Tracker) {
tracker.start_typing();
let test_chars = "hello world test";
for c in test_chars.chars() {
let _ = tracker.type_char(c);
}
tracker.complete();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
#[test]
fn test_command_palette_pause_resume() {
let config = Config::default();
let mut app = App::new(&config);
app.config
.change_mode(crate::config::Mode::with_words(2))
.unwrap();
app.handler.handle_input(&mut app, 'a').unwrap();
app.handler.handle_input(&mut app, 'n').unwrap();
app.handler.handle_input(&mut app, 'o').unwrap();
app.handler.handle_input(&mut app, 't').unwrap();
app.handler.handle_input(&mut app, 'h').unwrap();
app.handler.handle_input(&mut app, 'e').unwrap();
app.handler.handle_input(&mut app, 'r').unwrap();
app.handler.handle_command_palette_toggle(&mut app).unwrap();
app.handler
.handle_menu_update_search(&mut app, "s".to_string())
.unwrap();
assert!(app.tracker.is_paused());
app.handler.handle_menu_exit_search(&mut app).unwrap();
assert!(app.tracker.is_resuming());
app.handler.handle_input(&mut app, ' ').unwrap();
}
}