musichoard/src/tui/mod.rs
Wojciech Kozlowski c015f4c112
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m41s
Cargo CI / Lint (push) Successful in 1m13s
Cargo CI / Build and Test (pull_request) Successful in 3m8s
Cargo CI / Lint (pull_request) Successful in 1m15s
Sort albums by month if two releases of the same artist happen in the same year (#155)
Closes #106

Reviewed-on: #155
2024-03-05 23:24:18 +01:00

317 lines
8.7 KiB
Rust

mod app;
mod event;
mod handler;
mod lib;
mod listener;
mod ui;
pub use app::App;
pub use event::EventChannel;
pub use handler::EventHandler;
pub use listener::EventListener;
pub use ui::Ui;
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::Backend, Terminal};
use std::{io, marker::PhantomData};
use crate::tui::{
app::{IAppAccess, IAppInteract},
event::EventError,
handler::IEventHandler,
listener::IEventListener,
ui::IUi,
};
#[derive(Debug, Eq, PartialEq)]
pub enum Error {
Io(String),
Event(String),
ListenerPanic,
}
impl From<io::Error> for Error {
fn from(err: io::Error) -> Error {
Error::Io(err.to_string())
}
}
impl From<EventError> for Error {
fn from(err: EventError) -> Error {
Error::Event(err.to_string())
}
}
pub struct Tui<B: Backend, UI: IUi, APP: IAppInteract + IAppAccess> {
terminal: Terminal<B>,
_phantom: PhantomData<(UI, APP)>,
}
impl<B: Backend, UI: IUi, APP: IAppInteract + IAppAccess> Tui<B, UI, APP> {
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 IEventHandler<APP>,
) -> Result<(), Error> {
while app.is_running() {
self.terminal.draw(|frame| UI::render(&mut app, frame))?;
app = handler.handle_next_event(app)?;
}
Ok(())
}
fn main(
term: Terminal<B>,
app: APP,
ui: UI,
handler: impl IEventHandler<APP>,
listener: impl IEventListener,
) -> 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. This may be due to the panic simply
// causing the process to abort in which case there is nothing to unwind.
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<B>,
app: APP,
ui: UI,
handler: impl IEventHandler<APP>,
listener: impl IEventListener,
) -> 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 testmod;
#[cfg(test)]
mod tests {
use std::{io, thread};
use ratatui::{backend::TestBackend, Terminal};
use musichoard::collection::Collection;
use crate::tui::{
app::App, handler::MockIEventHandler, lib::MockIMusicHoard, listener::MockIEventListener,
ui::Ui,
};
use super::*;
use testmod::COLLECTION;
pub fn terminal() -> Terminal<TestBackend> {
let backend = TestBackend::new(150, 30);
Terminal::new(backend).unwrap()
}
fn music_hoard(collection: Collection) -> MockIMusicHoard {
let mut music_hoard = MockIMusicHoard::new();
music_hoard.expect_reload_database().returning(|| Ok(()));
music_hoard.expect_rescan_library().returning(|| Ok(()));
music_hoard.expect_get_collection().return_const(collection);
music_hoard
}
fn app(collection: Collection) -> App<MockIMusicHoard> {
App::new(music_hoard(collection))
}
fn listener() -> MockIEventListener {
let mut listener = MockIEventListener::new();
listener.expect_spawn().return_once(|| {
thread::spawn(|| {
thread::park();
EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "unparked"))
})
});
listener
}
fn handler() -> MockIEventHandler<App<MockIMusicHoard>> {
let mut handler = MockIEventHandler::new();
handler
.expect_handle_next_event()
.return_once(|app: App<MockIMusicHoard>| Ok(app.force_quit()));
handler
}
#[test]
fn run() {
let terminal = terminal();
let app = app(COLLECTION.to_owned());
let ui = Ui;
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;
let listener = listener();
let mut handler = MockIEventHandler::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::Recv;
assert_eq!(result.unwrap_err(), Error::Event(error.to_string()));
}
#[test]
fn listener_error() {
let terminal = terminal();
let app = app(COLLECTION.to_owned());
let ui = Ui;
let error = EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "error"));
let listener_handle: thread::JoinHandle<EventError> = thread::spawn(|| error);
while !listener_handle.is_finished() {}
let mut listener = MockIEventListener::new();
listener.expect_spawn().return_once(|| listener_handle);
let mut handler = MockIEventHandler::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;
let listener_handle: thread::JoinHandle<EventError> = thread::spawn(|| panic!());
while !listener_handle.is_finished() {}
let mut listener = MockIEventListener::new();
listener.expect_spawn().return_once(|| listener_handle);
let mut handler = MockIEventHandler::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 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!("{:?}", io_err).is_empty());
assert!(!format!("{:?}", event_err).is_empty());
assert!(!format!("{:?}", listener_err).is_empty());
}
}