use std::future::Future;
use std::io;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use ratatui::Terminal;
use ratatui::backend::{Backend, CrosstermBackend};
use tokio::sync::mpsc;
use crate::app::App;
use crate::runtime::{FRAME_INTERVAL, event, terminal};
pub(crate) type TuiTerminal = Terminal<CrosstermBackend<io::Stdout>>;
pub(crate) fn backend_err<E: std::error::Error + Send + Sync + 'static>(error: E) -> io::Error {
io::Error::other(error)
}
pub(crate) enum EventResult {
Continue,
Quit,
}
pub async fn run(app: &mut App) -> io::Result<()> {
let terminal_guard = terminal::TerminalGuard::new();
let mut terminal = terminal::setup_terminal(&terminal_guard)?;
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
let shutdown = Arc::new(AtomicBool::new(false));
event::spawn_event_reader(event_tx, shutdown.clone());
let mut tick = tokio::time::interval(FRAME_INTERVAL);
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
run_main_loop(app, &mut terminal, &mut event_rx, &mut tick).await?;
shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
terminal.show_cursor()?;
Ok(())
}
pub async fn run_with_backend<B: Backend>(
app: &mut App,
terminal: &mut Terminal<B>,
event_rx: &mut mpsc::UnboundedReceiver<crossterm::event::Event>,
) -> io::Result<()>
where
B::Error: std::error::Error + Send + Sync + 'static,
{
let mut tick = tokio::time::interval(FRAME_INTERVAL);
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
run_main_loop(app, terminal, event_rx, &mut tick).await
}
async fn run_main_loop<B: Backend>(
app: &mut App,
terminal: &mut Terminal<B>,
event_rx: &mut mpsc::UnboundedReceiver<crossterm::event::Event>,
tick: &mut tokio::time::Interval,
) -> io::Result<()>
where
B::Error: std::error::Error + Send + Sync + 'static,
{
let mut main_loop_state = MainLoopState {
app,
event_rx,
terminal,
tick,
};
run_until_quit(&mut main_loop_state, |state| Box::pin(state.run_cycle())).await
}
struct MainLoopState<'a, B: Backend> {
app: &'a mut App,
event_rx: &'a mut mpsc::UnboundedReceiver<crossterm::event::Event>,
terminal: &'a mut Terminal<B>,
tick: &'a mut tokio::time::Interval,
}
impl<B: Backend> MainLoopState<'_, B>
where
B::Error: std::error::Error + Send + Sync + 'static,
{
async fn run_cycle(&mut self) -> io::Result<EventResult> {
self.app.sessions.sync_from_handles();
render_frame(self.app, self.terminal)?;
event::process_events(self.app, self.terminal, self.event_rx, self.tick).await
}
}
async fn run_until_quit<State, CycleFn>(state: &mut State, mut cycle: CycleFn) -> io::Result<()>
where
CycleFn: for<'state> FnMut(
&'state mut State,
)
-> Pin<Box<dyn Future<Output = io::Result<EventResult>> + 'state>>,
{
loop {
if matches!(cycle(state).await?, EventResult::Quit) {
break;
}
}
Ok(())
}
fn render_frame<B: Backend>(app: &mut App, terminal: &mut Terminal<B>) -> io::Result<()>
where
B::Error: std::error::Error + Send + Sync + 'static,
{
terminal
.draw(|frame| app.draw(frame))
.map_err(backend_err)?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use ratatui::backend::TestBackend;
use tempfile::tempdir;
use super::*;
use crate::db::Database;
struct TestLoopState {
cycle_count: usize,
results: VecDeque<io::Result<EventResult>>,
}
impl TestLoopState {
fn run_cycle(&mut self) -> io::Result<EventResult> {
self.cycle_count += 1;
self.results
.pop_front()
.expect("test should provide one result per cycle")
}
}
fn test_app_clients() -> crate::app::AppClients {
crate::app::AppClients::new().with_agent_availability_probe(std::sync::Arc::new(
crate::infra::agent::StaticAgentAvailabilityProbe {
available_agent_kinds: crate::domain::agent::AgentKind::ALL.to_vec(),
},
))
}
#[tokio::test]
async fn run_until_quit_stops_after_first_quit_result() {
let mut state = TestLoopState {
cycle_count: 0,
results: VecDeque::from([
Ok(EventResult::Continue),
Ok(EventResult::Quit),
Ok(EventResult::Continue),
]),
};
let loop_result = run_until_quit(&mut state, |loop_state| {
Box::pin(async move { loop_state.run_cycle() })
})
.await;
assert!(loop_result.is_ok());
assert_eq!(state.cycle_count, 2);
}
#[tokio::test]
async fn run_until_quit_returns_cycle_error_without_extra_iterations() {
let mut state = TestLoopState {
cycle_count: 0,
results: VecDeque::from([Err(io::Error::other("cycle failed"))]),
};
let loop_result = run_until_quit(&mut state, |loop_state| {
Box::pin(async move { loop_state.run_cycle() })
})
.await;
let error = loop_result.expect_err("loop should return the cycle error");
assert_eq!(error.to_string(), "cycle failed");
assert_eq!(state.cycle_count, 1);
}
async fn new_test_app() -> (App, tempfile::TempDir) {
let base_dir = tempdir().expect("failed to create temp dir");
let base_path = base_dir.path().to_path_buf();
let database = Database::open_in_memory()
.await
.expect("failed to open in-memory db");
let app = App::new_with_clients(
base_path.clone(),
base_path,
None,
database,
test_app_clients(),
)
.await
.expect("failed to build test app");
(app, base_dir)
}
#[tokio::test]
async fn run_with_backend_exits_on_quit_key() {
let (mut app, _base_dir) = new_test_app().await;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("failed to create test terminal");
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
event_tx
.send(Event::Key(KeyEvent::new(
KeyCode::Char('q'),
KeyModifiers::NONE,
)))
.expect("failed to send quit key");
event_tx
.send(Event::Key(KeyEvent::new(
KeyCode::Char('y'),
KeyModifiers::NONE,
)))
.expect("failed to send confirm key");
let result = run_with_backend(&mut app, &mut terminal, &mut event_rx).await;
assert!(
result.is_ok(),
"run_with_backend should exit cleanly on quit"
);
}
}