use std::io::{self, Write};
use crossterm::{
event::{self, Event, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use crate::{
app::App,
error::{AppError, Result},
terminal::keymap::action_for_key,
ui::{draw, prepare},
};
pub(crate) fn run(mut app: App) -> Result<()> {
let mut session = TerminalSession::new(io::stdout())?;
session.run(&mut app)
}
struct TerminalSession<W: Write> {
terminal: Terminal<CrosstermBackend<W>>,
}
impl<W: Write> TerminalSession<W> {
fn new(writer: W) -> Result<Self> {
let backend = CrosstermBackend::new(writer);
let terminal =
Terminal::new(backend).map_err(|err| AppError::io("failed to create terminal", err))?;
Ok(Self { terminal })
}
fn run(&mut self, app: &mut App) -> Result<()> {
self.enter()?;
let result = self.event_loop(app);
let cleanup_result = self.restore();
Cleanup::finish(result, cleanup_result)
}
fn enter(&mut self) -> Result<()> {
enable_raw_mode().map_err(|err| AppError::io("failed to enable raw mode", err))?;
if let Err(err) = execute!(self.terminal.backend_mut(), EnterAlternateScreen) {
let enter_error = AppError::io("failed to enter alternate screen", err);
let cleanup_result = self.restore_backend();
return Cleanup::finish(Err(enter_error), cleanup_result);
}
Ok(())
}
fn event_loop(&mut self, app: &mut App) -> Result<()> {
loop {
self.terminal
.draw(|frame| {
prepare(app, frame.area());
draw(frame, app);
})
.map_err(|err| AppError::io("failed to draw UI", err))?;
if app.should_quit() {
return Ok(());
}
match event::read().map_err(|err| AppError::io("failed to read terminal event", err))? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
if let Some(action) = action_for_key(key) {
app.apply(action);
}
}
Event::Resize(_, _) => {}
_ => {}
}
}
}
fn restore(&mut self) -> Result<()> {
let mut cleanup = Cleanup::default();
cleanup.push(self.restore_backend());
cleanup.push(
self.terminal
.show_cursor()
.map_err(|err| AppError::io("failed to show cursor", err)),
);
cleanup.into_result()
}
fn restore_backend(&mut self) -> Result<()> {
let mut cleanup = Cleanup::default();
if let Err(err) = disable_raw_mode() {
cleanup.push_error(AppError::io("failed to disable raw mode", err));
}
if let Err(err) = execute!(self.terminal.backend_mut(), LeaveAlternateScreen) {
cleanup.push_error(AppError::io("failed to leave alternate screen", err));
}
cleanup.into_result()
}
}
#[derive(Default)]
struct Cleanup {
errors: Vec<String>,
}
impl Cleanup {
fn push(&mut self, result: Result<()>) {
if let Err(err) = result {
self.push_error(err);
}
}
fn push_error(&mut self, err: AppError) {
self.errors.push(err.to_string());
}
fn into_result(self) -> Result<()> {
if self.errors.is_empty() {
Ok(())
} else {
Err(AppError::message(self.errors.join("\n")))
}
}
fn finish(primary: Result<()>, cleanup: Result<()>) -> Result<()> {
match (primary, cleanup) {
(Ok(()), Ok(())) => Ok(()),
(Err(err), Ok(())) | (Ok(()), Err(err)) => Err(err),
(Err(primary), Err(cleanup)) => Err(AppError::message(format!(
"{primary}\ncleanup also failed:\n{cleanup}"
))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn combine_results_returns_cleanup_only_error() {
assert_eq!(
Cleanup::finish(Ok(()), Err(AppError::message("cleanup")))
.expect_err("cleanup should fail")
.to_string(),
"cleanup"
);
}
#[test]
fn combine_results_returns_primary_and_cleanup_errors() {
assert_eq!(
Cleanup::finish(
Err(AppError::message("primary")),
Err(AppError::message("cleanup"))
)
.expect_err("combined error should fail")
.to_string(),
"primary\ncleanup also failed:\ncleanup"
);
}
#[test]
fn collect_cleanup_result_accumulates_multiple_failures() {
let mut cleanup = Cleanup::default();
cleanup.push(Err(AppError::message("first")));
cleanup.push(Ok(()));
cleanup.push(Err(AppError::message("second")));
assert_eq!(
cleanup
.into_result()
.expect_err("cleanup should fail")
.to_string(),
"first\nsecond"
);
}
}