use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; use musichoard::collection; use ratatui::backend::Backend; use ratatui::Terminal; use std::io; use std::marker::PhantomData; pub mod app; pub mod event; pub mod handler; pub mod listener; pub mod ui; use self::app::App; use self::event::EventError; use self::handler::EventHandler; use self::listener::EventListener; use self::ui::Ui; #[derive(Debug, PartialEq, Eq)] pub enum Error { Collection(String), Io(String), Event(String), ListenerPanic, } impl From for Error { fn from(err: collection::Error) -> Error { Error::Collection(err.to_string()) } } impl From for Error { fn from(err: io::Error) -> Error { Error::Io(err.to_string()) } } impl From for Error { fn from(err: EventError) -> Error { Error::Event(err.to_string()) } } pub struct Tui { terminal: Terminal, _phantom: PhantomData, } impl Tui { fn init(&mut self) -> Result<(), Error> { self.terminal.hide_cursor()?; self.terminal.clear()?; Ok(()) } fn exit(&mut self) -> Result<(), Error> { self.terminal.show_cursor()?; Ok(()) } #[allow(unused_must_use)] fn exit_suppress_errors(&mut self) { self.exit(); } fn main_loop( &mut self, mut app: APP, ui: Ui, handler: impl EventHandler, ) -> Result<(), Error> { while app.is_running() { self.terminal.draw(|frame| ui.render(&app, frame))?; handler.handle_next_event(&mut app)?; } Ok(()) } fn main( term: Terminal, app: APP, ui: Ui, handler: impl EventHandler, listener: impl EventListener, ) -> Result<(), Error> { let mut tui = Tui { terminal: term, _phantom: PhantomData, }; tui.init()?; let listener_handle = listener.spawn(); let result = tui.main_loop(app, ui, handler); match result { Ok(_) => { tui.exit()?; Ok(()) } Err(err) => { // We want to call exit before handling the result to reset the terminal. Therefore, // we suppress exit errors (if any) to not mask the original error. tui.exit_suppress_errors(); if listener_handle.is_finished() { match listener_handle.join() { Ok(err) => return Err(err.into()), // Calling std::panic::resume_unwind(err) as recommended by the Rust docs // will not produce an error message. The panic error message is printed at // the location of the panic which at the time is hidden by the TUI. Err(_) => return Err(Error::ListenerPanic), } } Err(err) } } } // GRCOV_EXCL_START fn enable() -> Result<(), Error> { terminal::enable_raw_mode()?; crossterm::execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?; Ok(()) } fn disable() -> Result<(), Error> { terminal::disable_raw_mode()?; crossterm::execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?; Ok(()) } #[allow(unused_must_use)] fn disable_suppress_errors() { Self::disable(); } pub fn run( term: Terminal, app: APP, ui: Ui, handler: impl EventHandler, listener: impl EventListener, ) -> Result<(), Error> { Self::enable()?; let result = Self::main(term, app, ui, handler, listener); match result { Ok(_) => { Self::disable()?; Ok(()) } Err(err) => { // We want to call disable before handling the result to reset the terminal. // Therefore, we suppress disable errors (if any) to not mask the original error. Self::disable_suppress_errors(); Err(err) } } } // GRCOV_EXCL_STOP } #[cfg(test)] mod tests { use std::{io, thread}; use musichoard::collection::{self, Collection}; use ratatui::{backend::TestBackend, Terminal}; use crate::tests::{MockCollectionManager, COLLECTION}; use super::{ app::{App, TuiApp}, event::EventError, handler::MockEventHandler, listener::MockEventListener, ui::Ui, Error, Tui, }; pub fn terminal() -> Terminal { let backend = TestBackend::new(150, 30); Terminal::new(backend).unwrap() } pub fn app(collection: Collection) -> TuiApp { let mut collection_manager = MockCollectionManager::new(); collection_manager .expect_rescan_library() .returning(|| Ok(())); collection_manager .expect_get_collection() .return_const(collection); TuiApp::new(collection_manager).unwrap() } fn listener() -> MockEventListener { let mut listener = MockEventListener::new(); listener.expect_spawn().return_once(|| { thread::spawn(|| { thread::park(); return EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "unparked")); }) }); listener } fn handler() -> MockEventHandler> { let mut handler = MockEventHandler::new(); handler.expect_handle_next_event().return_once( |app: &mut TuiApp| { app.quit(); Ok(()) }, ); handler } #[test] fn run() { let terminal = terminal(); let app = app(COLLECTION.to_owned()); let ui = Ui::new(); let listener = listener(); let handler = handler(); let result = Tui::main(terminal, app, ui, handler, listener); assert!(result.is_ok()); } #[test] fn event_error() { let terminal = terminal(); let app = app(COLLECTION.to_owned()); let ui = Ui::new(); let listener = listener(); let mut handler = MockEventHandler::new(); handler .expect_handle_next_event() .return_once(|_| Err(EventError::Recv)); let result = Tui::main(terminal, app, ui, handler, listener); assert!(result.is_err()); assert_eq!( result.unwrap_err(), Error::Event(EventError::Recv.to_string()) ); } #[test] fn listener_error() { let terminal = terminal(); let app = app(COLLECTION.to_owned()); let ui = Ui::new(); let error = EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "error")); let listener_handle: thread::JoinHandle = thread::spawn(|| error); while !listener_handle.is_finished() {} let mut listener = MockEventListener::new(); listener.expect_spawn().return_once(|| listener_handle); let mut handler = MockEventHandler::new(); handler .expect_handle_next_event() .return_once(|_| Err(EventError::Recv)); let result = Tui::main(terminal, app, ui, handler, listener); assert!(result.is_err()); let error = EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "error")); assert_eq!(result.unwrap_err(), Error::Event(error.to_string())); } #[test] fn listener_panic() { let terminal = terminal(); let app = app(COLLECTION.to_owned()); let ui = Ui::new(); let listener_handle: thread::JoinHandle = thread::spawn(|| panic!()); while !listener_handle.is_finished() {} let mut listener = MockEventListener::new(); listener.expect_spawn().return_once(|| listener_handle); let mut handler = MockEventHandler::new(); handler .expect_handle_next_event() .return_once(|_| Err(EventError::Recv)); let result = Tui::main(terminal, app, ui, handler, listener); assert!(result.is_err()); assert_eq!(result.unwrap_err(), Error::ListenerPanic); } #[test] fn errors() { let collection_err: Error = collection::Error::DatabaseError(String::from("")).into(); let io_err: Error = io::Error::new(io::ErrorKind::Interrupted, "error").into(); let event_err: Error = EventError::Recv.into(); let listener_err = Error::ListenerPanic; assert!(!format!("{:?}", collection_err).is_empty()); assert!(!format!("{:?}", io_err).is_empty()); assert!(!format!("{:?}", event_err).is_empty()); assert!(!format!("{:?}", listener_err).is_empty()); } }