use std::{
io::{self, Stdout},
time::Duration,
};
use anyhow::{Context, Result};
use crossterm::{
event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend};
use tokio::{sync::mpsc, task, time};
use crate::{
app::{App, FormField, InputMode, StatusKind},
database::repository::{NewTodoDraft, TodoRepository},
ui,
};
pub type CrosstermTerminal = Terminal<CrosstermBackend<Stdout>>;
pub fn init_terminal() -> Result<CrosstermTerminal> {
enable_raw_mode().context("failed to enable raw mode")?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen).context("failed to enter alternate screen")?;
let backend = CrosstermBackend::new(stdout);
Terminal::new(backend).context("failed to create terminal")
}
pub fn restore_terminal(terminal: &mut CrosstermTerminal) -> Result<()> {
disable_raw_mode().context("failed to disable raw mode")?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)
.context("failed to leave alternate screen")?;
terminal.show_cursor().context("failed to show cursor")
}
pub async fn run_app(
terminal: &mut CrosstermTerminal,
mut app: App,
repo: TodoRepository,
) -> Result<()> {
let mut receiver = spawn_event_reader()?;
let mut ticker = time::interval(Duration::from_millis(200));
let size = terminal.size().context("failed to get terminal size")?;
app.set_terminal_size((size.width, size.height));
while !app.should_quit {
terminal
.draw(|frame: &mut ratatui::Frame<'_>| ui::draw(frame, &app))
.context("failed to draw UI")?;
tokio::select! {
maybe_event = receiver.recv() => {
if let Some(event) = maybe_event {
handle_event(event, &mut app, &repo).await?;
} else {
break;
}
}
_ = ticker.tick() => {}
}
}
Ok(())
}
fn spawn_event_reader() -> Result<mpsc::UnboundedReceiver<Event>> {
let (tx, rx) = mpsc::unbounded_channel();
task::spawn(async move {
loop {
if tx.is_closed() {
break;
}
let event = task::spawn_blocking(|| {
if crossterm::event::poll(Duration::from_millis(100))? {
crossterm::event::read().map(Some)
} else {
Ok(None)
}
})
.await;
match event {
Ok(Ok(Some(evt))) => {
if tx.send(evt).is_err() {
break;
}
}
Ok(Ok(None)) => continue,
Ok(Err(_)) | Err(_) => break,
}
}
});
Ok(rx)
}
async fn handle_event(event: Event, app: &mut App, repo: &TodoRepository) -> Result<()> {
match event {
Event::Key(key) if key.kind == KeyEventKind::Release => Ok(()),
Event::Key(key) => {
if let Some(command) = match app.mode {
InputMode::Normal => handle_normal_key(key, app),
InputMode::Adding => handle_add_key(key, app),
} {
execute_command(command, app, repo).await?;
}
Ok(())
}
Event::Resize(width, height) => {
use std::io::Write;
let open_or_create = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("resize.log");
let mut file = open_or_create.unwrap();
write!(file, "Terminal resized to {}x{}\n", width, height).unwrap();
app.set_terminal_size((width, height));
Ok(())
}
_ => Ok(()),
}
}
fn handle_normal_key(key: KeyEvent, app: &mut App) -> Option<Command> {
if key.kind == KeyEventKind::Release {
return None;
}
match key.code {
KeyCode::Char(c) if c == ' ' => app.selected_todo().map(|todo| Command::Toggle {
id: todo.id,
target: !todo.completed,
}),
KeyCode::Char(c) => match c.to_ascii_lowercase() {
'q' => {
app.should_quit = true;
None
}
'j' => {
app.next();
None
}
'k' => {
app.previous();
None
}
'a' => {
app.enter_add_mode();
None
}
'd' => app.selected_todo_id().map(Command::Delete),
'r' => Some(Command::Refresh),
_ => None,
},
KeyCode::Down => {
app.next();
None
}
KeyCode::Up => {
app.previous();
None
}
KeyCode::Tab => {
app.cycle_filter();
None
}
KeyCode::Enter => app.selected_todo().map(|todo| Command::Toggle {
id: todo.id,
target: !todo.completed,
}),
_ => None,
}
}
fn handle_add_key(key: KeyEvent, app: &mut App) -> Option<Command> {
if key.kind == KeyEventKind::Release {
return None;
}
match key.code {
KeyCode::Esc => {
app.reset_form();
app.exit_add_mode();
app.set_status(StatusKind::Info, "Cancelled new todo");
None
}
KeyCode::Enter => {
if let Some(draft) = app.consume_form() {
Some(Command::Add(draft))
} else {
app.set_status(StatusKind::Error, "Title cannot be empty");
None
}
}
KeyCode::Backspace => {
app.erase_char();
None
}
KeyCode::Tab => {
app.toggle_field();
None
}
KeyCode::Up => {
app.increase_priority();
None
}
KeyCode::Down => {
app.decrease_priority();
None
}
KeyCode::Char(ch) => {
if key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::ALT)
{
return None;
}
if !ch.is_control() && app.active_field != FormField::Priority {
app.push_char(ch);
} else if app.active_field == FormField::Priority {
match ch {
'1' => app.set_priority_low(),
'2' => app.set_priority_medium(),
'3' => app.set_priority_high(),
_ => {}
}
}
None
}
_ => None,
}
}
#[derive(Debug)]
enum Command {
Refresh,
Toggle { id: i32, target: bool },
Delete(i32),
Add(NewTodoDraft),
}
async fn execute_command(command: Command, app: &mut App, repo: &TodoRepository) -> Result<()> {
app.set_loading(true);
let outcome = match command {
Command::Refresh => {
refresh(app, repo).await?;
app.set_status(StatusKind::Info, "Todo list refreshed");
Ok(())
}
Command::Toggle { id, target } => {
repo.set_completed(id, target)
.await
.with_context(|| format!("failed to update todo #{id}"))?;
refresh(app, repo).await?;
app.set_status(
StatusKind::Success,
if target {
"Marked as complete"
} else {
"Marked as active"
},
);
Ok(())
}
Command::Delete(id) => {
repo.delete(id)
.await
.with_context(|| format!("failed to delete todo #{id}"))?;
refresh(app, repo).await?;
app.set_status(StatusKind::Success, format!("Deleted todo #{id}"));
Ok(())
}
Command::Add(draft) => {
app.exit_add_mode();
match repo.insert(&draft).await {
Ok(inserted) => {
refresh(app, repo).await?;
app.reset_form();
app.set_status(
StatusKind::Success,
format!("Added '{}'", inserted.title.trim()),
);
Ok(())
}
Err(err) => {
app.mode = InputMode::Adding;
app.active_field = FormField::Title;
app.form = draft;
Err(err)
}
}
}
};
app.set_loading(false);
if let Err(err) = outcome {
app.set_status(StatusKind::Error, format!("{}", err));
}
Ok(())
}
async fn refresh(app: &mut App, repo: &TodoRepository) -> Result<()> {
let todos = repo.list_all().await?;
app.set_todos(todos);
Ok(())
}