2023-04-13 14:09:59 +02:00
|
|
|
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
|
|
|
|
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
|
|
|
|
use musichoard::collection;
|
|
|
|
use ratatui::backend::Backend;
|
|
|
|
use ratatui::Terminal;
|
|
|
|
use std::io;
|
2023-04-13 15:29:14 +02:00
|
|
|
use std::marker::PhantomData;
|
2023-04-13 14:09:59 +02:00
|
|
|
|
|
|
|
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<collection::Error> for Error {
|
|
|
|
fn from(err: collection::Error) -> Error {
|
|
|
|
Error::Collection(err.to_string())
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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())
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-04-13 15:29:14 +02:00
|
|
|
pub struct Tui<B: Backend, APP> {
|
2023-04-13 14:09:59 +02:00
|
|
|
terminal: Terminal<B>,
|
2023-04-13 15:29:14 +02:00
|
|
|
_phantom: PhantomData<APP>,
|
2023-04-13 14:09:59 +02:00
|
|
|
}
|
|
|
|
|
2023-04-13 15:29:14 +02:00
|
|
|
impl<B: Backend, APP: App> Tui<B, APP> {
|
2023-04-13 14:09:59 +02:00
|
|
|
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();
|
|
|
|
}
|
|
|
|
|
2023-04-13 15:29:14 +02:00
|
|
|
fn main_loop(
|
|
|
|
&mut self,
|
|
|
|
mut app: APP,
|
|
|
|
ui: Ui<APP>,
|
|
|
|
handler: impl EventHandler<APP>,
|
|
|
|
) -> Result<(), Error> {
|
2023-04-13 14:09:59 +02:00
|
|
|
while app.is_running() {
|
|
|
|
self.terminal.draw(|frame| ui.render(&app, frame))?;
|
|
|
|
handler.handle_next_event(&mut app)?;
|
|
|
|
}
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
}
|
|
|
|
|
|
|
|
fn main(
|
|
|
|
term: Terminal<B>,
|
2023-04-13 15:29:14 +02:00
|
|
|
app: APP,
|
|
|
|
ui: Ui<APP>,
|
|
|
|
handler: impl EventHandler<APP>,
|
2023-04-13 14:09:59 +02:00
|
|
|
listener: impl EventListener,
|
|
|
|
) -> Result<(), Error> {
|
2023-04-13 15:29:14 +02:00
|
|
|
let mut tui = Tui {
|
|
|
|
terminal: term,
|
|
|
|
_phantom: PhantomData,
|
|
|
|
};
|
2023-04-13 14:09:59 +02:00
|
|
|
|
|
|
|
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<B>,
|
2023-04-13 15:29:14 +02:00
|
|
|
app: APP,
|
|
|
|
ui: Ui<APP>,
|
|
|
|
handler: impl EventHandler<APP>,
|
2023-04-13 14:09:59 +02:00
|
|
|
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::{
|
2023-04-13 15:29:14 +02:00
|
|
|
app::{App, TuiApp},
|
|
|
|
event::EventError,
|
|
|
|
handler::MockEventHandler,
|
|
|
|
listener::MockEventListener,
|
|
|
|
ui::Ui,
|
|
|
|
Error, Tui,
|
2023-04-13 14:09:59 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
pub fn terminal() -> Terminal<TestBackend> {
|
|
|
|
let backend = TestBackend::new(150, 30);
|
|
|
|
Terminal::new(backend).unwrap()
|
|
|
|
}
|
|
|
|
|
2023-04-13 15:29:14 +02:00
|
|
|
pub fn app(collection: Collection) -> TuiApp<MockCollectionManager> {
|
2023-04-13 14:09:59 +02:00
|
|
|
let mut collection_manager = MockCollectionManager::new();
|
|
|
|
|
|
|
|
collection_manager
|
|
|
|
.expect_rescan_library()
|
|
|
|
.returning(|| Ok(()));
|
|
|
|
collection_manager
|
|
|
|
.expect_get_collection()
|
|
|
|
.return_const(collection);
|
|
|
|
|
2023-04-13 15:29:14 +02:00
|
|
|
TuiApp::new(collection_manager).unwrap()
|
2023-04-13 14:09:59 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2023-04-13 15:29:14 +02:00
|
|
|
fn handler() -> MockEventHandler<TuiApp<MockCollectionManager>> {
|
2023-04-13 14:09:59 +02:00
|
|
|
let mut handler = MockEventHandler::new();
|
2023-04-13 15:29:14 +02:00
|
|
|
handler.expect_handle_next_event().return_once(
|
|
|
|
|app: &mut TuiApp<MockCollectionManager>| {
|
|
|
|
app.quit();
|
|
|
|
Ok(())
|
|
|
|
},
|
|
|
|
);
|
2023-04-13 14:09:59 +02:00
|
|
|
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<EventError> = 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<EventError> = 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());
|
|
|
|
}
|
|
|
|
}
|