Proof of principle working
Some checks failed
Cargo CI / Build and Test (pull_request) Failing after 2m29s
Cargo CI / Lint (pull_request) Failing after 1m2s

This commit is contained in:
Wojciech Kozlowski 2024-09-02 21:07:42 +02:00
parent 7925d8ce91
commit 3f1845ed23
8 changed files with 120 additions and 41 deletions

View File

@ -79,18 +79,19 @@ fn with<Database: IDatabase + 'static, Library: ILibrary + 'static>(
let backend = CrosstermBackend::new(io::stdout()); let backend = CrosstermBackend::new(io::stdout());
let terminal = Terminal::new(backend).expect("failed to initialise terminal"); let terminal = Terminal::new(backend).expect("failed to initialise terminal");
let channel = EventChannel::new();
let listener = EventListener::new(channel.sender());
let handler = EventHandler::new(channel.receiver());
let http = let http =
MusicBrainzHttp::new(MUSICHOARD_HTTP_USER_AGENT).expect("failed to initialise HTTP client"); MusicBrainzHttp::new(MUSICHOARD_HTTP_USER_AGENT).expect("failed to initialise HTTP client");
let client = MusicBrainzClient::new(http); let client = MusicBrainzClient::new(http);
let musicbrainz = MusicBrainz::new(client); let musicbrainz = MusicBrainz::new(client);
let app = App::new(music_hoard, musicbrainz); let channel = EventChannel::new();
let listener = EventListener::new(channel.sender());
let app = App::new(music_hoard, musicbrainz, channel.sender());
let ui = Ui; let ui = Ui;
let handler = EventHandler::new(channel.receiver());
// Run the TUI application. // Run the TUI application.
Tui::run(terminal, app, ui, handler, listener).expect("failed to run tui"); Tui::run(terminal, app, ui, handler, listener).expect("failed to run tui");
} }

View File

@ -39,6 +39,10 @@ impl IAppInteractError for AppMachine<AppError> {
fn dismiss_error(self) -> Self::APP { fn dismiss_error(self) -> Self::APP {
AppMachine::browse(self.inner).into() AppMachine::browse(self.inner).into()
} }
fn no_op(self) -> Self::APP {
self.into()
}
} }
#[cfg(test)] #[cfg(test)]

View File

@ -1,5 +1,8 @@
use std::{ use std::{
sync::{mpsc, Arc, Mutex}, sync::{
mpsc::{self, TryRecvError},
Arc, Mutex,
},
thread, time, thread, time,
}; };
@ -11,10 +14,8 @@ use musichoard::collection::{
use crate::tui::{ use crate::tui::{
app::{ app::{
machine::{App, AppInner, AppMachine}, machine::{App, AppInner, AppMachine}, AppMatchesInfo, AppPublic, AppState, IAppEventFetch, IAppInteractFetch
AppMatchesInfo, AppPublic, AppState, IAppInteractFetch, }, event::{Event, EventSender}, lib::interface::musicbrainz::{Error as MbError, IMusicBrainz}
},
lib::interface::musicbrainz::{Error as MbError, IMusicBrainz},
}; };
use super::matches::AppMatches; use super::matches::AppMatches;
@ -24,6 +25,10 @@ pub struct AppFetch {
} }
impl AppMachine<AppFetch> { impl AppMachine<AppFetch> {
fn fetch(inner: AppInner, state: AppFetch) -> Self {
AppMachine { inner, state }
}
pub fn app_fetch_new(inner: AppInner) -> App { pub fn app_fetch_new(inner: AppInner) -> App {
let coll = inner.music_hoard.get_collection(); let coll = inner.music_hoard.get_collection();
let artist = match inner.selection.state_artist(coll) { let artist = match inner.selection.state_artist(coll) {
@ -36,14 +41,16 @@ impl AppMachine<AppFetch> {
match artist.meta.musicbrainz { match artist.meta.musicbrainz {
Some(ref arid) => { Some(ref arid) => {
let musicbrainz = Arc::clone(&inner.musicbrainz); let musicbrainz = Arc::clone(&inner.musicbrainz);
let events = inner.events.clone();
let arid = arid.mbid().clone(); let arid = arid.mbid().clone();
let albums = artist.albums.iter().map(|a| &a.meta).cloned().collect(); let albums = artist.albums.iter().map(|a| &a.meta).cloned().collect();
thread::spawn(|| Self::fetch_albums(musicbrainz, fetch_tx, arid, albums)); thread::spawn(|| Self::fetch_albums(musicbrainz, fetch_tx, events, arid, albums));
} }
None => { None => {
let musicbrainz = Arc::clone(&inner.musicbrainz); let musicbrainz = Arc::clone(&inner.musicbrainz);
let events = inner.events.clone();
let artist = artist.meta.clone(); let artist = artist.meta.clone();
thread::spawn(|| Self::fetch_artist(musicbrainz, fetch_tx, artist)); thread::spawn(|| Self::fetch_artist(musicbrainz, fetch_tx, events, artist));
} }
}; };
@ -52,23 +59,27 @@ impl AppMachine<AppFetch> {
} }
pub fn app_fetch_next(inner: AppInner, fetch: AppFetch, first: bool) -> App { pub fn app_fetch_next(inner: AppInner, fetch: AppFetch, first: bool) -> App {
match fetch.fetch_rx.recv() { match fetch.fetch_rx.try_recv() {
Ok(fetch_result) => match fetch_result { Ok(fetch_result) => match fetch_result {
Ok(mut next_match) => { Ok(mut next_match) => {
next_match.push_cannot_have_mbid(); next_match.push_cannot_have_mbid();
let current = Some(next_match); let current = Some(next_match);
AppMachine::matches(inner, AppMatches::new(current, fetch)).into() AppMachine::matches(inner, AppMatches::new(current, fetch)).into()
} }
Err(err) => AppMachine::error(inner, format!("fetch failed: {err}")).into(), Err(fetch_err) => {
}, AppMachine::error(inner, format!("fetch failed: {fetch_err}")).into()
// only happens when the sender disconnects which means it finished its job
Err(_) => {
if first {
AppMachine::matches(inner, AppMatches::empty(fetch)).into()
} else {
AppMachine::browse(inner).into()
} }
} },
Err(try_recv_err) => match try_recv_err {
TryRecvError::Disconnected => {
if first {
AppMachine::matches(inner, AppMatches::empty(fetch)).into()
} else {
AppMachine::browse(inner).into()
}
}
TryRecvError::Empty => AppMachine::fetch(inner, fetch).into(),
},
} }
} }
} }
@ -104,6 +115,14 @@ impl IAppInteractFetch for AppMachine<AppFetch> {
} }
} }
impl IAppEventFetch for AppMachine<AppFetch> {
type APP = App;
fn fetch_result_ready(self) -> Self::APP {
Self::app_fetch_next(self.inner, self.state, false)
}
}
type FetchError = MbError; type FetchError = MbError;
type FetchResult = Result<AppMatchesInfo, FetchError>; type FetchResult = Result<AppMatchesInfo, FetchError>;
type FetchSender = mpsc::Sender<FetchResult>; type FetchSender = mpsc::Sender<FetchResult>;
@ -113,11 +132,13 @@ trait IAppInteractFetchPrivate {
fn fetch_artist( fn fetch_artist(
musicbrainz: Arc<Mutex<dyn IMusicBrainz + Send>>, musicbrainz: Arc<Mutex<dyn IMusicBrainz + Send>>,
fetch_tx: FetchSender, fetch_tx: FetchSender,
events: EventSender,
artist: ArtistMeta, artist: ArtistMeta,
); );
fn fetch_albums( fn fetch_albums(
musicbrainz: Arc<Mutex<dyn IMusicBrainz + Send>>, musicbrainz: Arc<Mutex<dyn IMusicBrainz + Send>>,
fetch_tx: FetchSender, fetch_tx: FetchSender,
events: EventSender,
arid: Mbid, arid: Mbid,
albums: Vec<AlbumMeta>, albums: Vec<AlbumMeta>,
); );
@ -127,16 +148,22 @@ impl IAppInteractFetchPrivate for AppMachine<AppFetch> {
fn fetch_artist( fn fetch_artist(
musicbrainz: Arc<Mutex<dyn IMusicBrainz + Send>>, musicbrainz: Arc<Mutex<dyn IMusicBrainz + Send>>,
fetch_tx: FetchSender, fetch_tx: FetchSender,
events: EventSender,
artist: ArtistMeta, artist: ArtistMeta,
) { ) {
let result = musicbrainz.lock().unwrap().search_artist(&artist); let result = musicbrainz.lock().unwrap().search_artist(&artist);
let result = result.map(|list| AppMatchesInfo::artist(artist, list)); let result = result.map(|list| AppMatchesInfo::artist(artist, list));
fetch_tx.send(result).ok(); if fetch_tx.send(result).is_ok() {
// If this send fails the event listener is dead. Don't panic as this function runs
// in a detached thread so this might be happening during normal shut down.
events.send(Event::FetchResultReady).ok();
}
} }
fn fetch_albums( fn fetch_albums(
musicbrainz: Arc<Mutex<dyn IMusicBrainz + Send>>, musicbrainz: Arc<Mutex<dyn IMusicBrainz + Send>>,
fetch_tx: FetchSender, fetch_tx: FetchSender,
events: EventSender,
arid: Mbid, arid: Mbid,
albums: Vec<AlbumMeta>, albums: Vec<AlbumMeta>,
) { ) {
@ -154,6 +181,12 @@ impl IAppInteractFetchPrivate for AppMachine<AppFetch> {
return; return;
} }
if events.send(Event::FetchResultReady).is_err() {
// If this send fails the event listener is dead. Don't panic as this function runs
// in a detached thread so this might be happening during normal shut down.
return;
}
if album_iter.peek().is_some() { if album_iter.peek().is_some() {
thread::sleep(time::Duration::from_secs(1)); thread::sleep(time::Duration::from_secs(1));
} }

View File

@ -11,6 +11,7 @@ use std::sync::{Arc, Mutex};
use crate::tui::{ use crate::tui::{
app::{selection::Selection, AppPublic, AppPublicInner, AppState, IAppAccess, IAppInteract}, app::{selection::Selection, AppPublic, AppPublicInner, AppState, IAppAccess, IAppInteract},
event::EventSender,
lib::{interface::musicbrainz::IMusicBrainz, IMusicHoard}, lib::{interface::musicbrainz::IMusicBrainz, IMusicHoard},
}; };
@ -44,15 +45,17 @@ pub struct AppInner {
music_hoard: Box<dyn IMusicHoard>, music_hoard: Box<dyn IMusicHoard>,
musicbrainz: Arc<Mutex<dyn IMusicBrainz + Send>>, musicbrainz: Arc<Mutex<dyn IMusicBrainz + Send>>,
selection: Selection, selection: Selection,
events: EventSender,
} }
impl App { impl App {
pub fn new<MH: IMusicHoard + 'static, MB: IMusicBrainz + Send + 'static>( pub fn new<MH: IMusicHoard + 'static, MB: IMusicBrainz + Send + 'static>(
mut music_hoard: MH, mut music_hoard: MH,
musicbrainz: MB, musicbrainz: MB,
events: EventSender,
) -> Self { ) -> Self {
let init_result = Self::init(&mut music_hoard); let init_result = Self::init(&mut music_hoard);
let inner = AppInner::new(music_hoard, musicbrainz); let inner = AppInner::new(music_hoard, musicbrainz, events);
match init_result { match init_result {
Ok(()) => AppMachine::browse(inner).into(), Ok(()) => AppMachine::browse(inner).into(),
Err(err) => AppMachine::critical(inner, err.to_string()).into(), Err(err) => AppMachine::critical(inner, err.to_string()).into(),
@ -137,6 +140,7 @@ impl AppInner {
pub fn new<MH: IMusicHoard + 'static, MB: IMusicBrainz + Send + 'static>( pub fn new<MH: IMusicHoard + 'static, MB: IMusicBrainz + Send + 'static>(
music_hoard: MH, music_hoard: MH,
musicbrainz: MB, musicbrainz: MB,
events: EventSender,
) -> Self { ) -> Self {
let selection = Selection::new(music_hoard.get_collection()); let selection = Selection::new(music_hoard.get_collection());
AppInner { AppInner {
@ -144,6 +148,7 @@ impl AppInner {
music_hoard: Box::new(music_hoard), music_hoard: Box::new(music_hoard),
musicbrainz: Arc::new(Mutex::new(musicbrainz)), musicbrainz: Arc::new(Mutex::new(musicbrainz)),
selection, selection,
events,
} }
} }
} }
@ -165,7 +170,7 @@ mod tests {
use crate::tui::{ use crate::tui::{
app::{AppState, IAppInteract, IAppInteractBrowse}, app::{AppState, IAppInteract, IAppInteractBrowse},
lib::{interface::musicbrainz::MockIMusicBrainz, MockIMusicHoard}, lib::{interface::musicbrainz::MockIMusicBrainz, MockIMusicHoard}, EventChannel,
}; };
use super::*; use super::*;
@ -250,17 +255,21 @@ mod tests {
MockIMusicBrainz::new() MockIMusicBrainz::new()
} }
fn events() -> EventSender {
EventChannel::new().sender()
}
pub fn inner(music_hoard: MockIMusicHoard) -> AppInner { pub fn inner(music_hoard: MockIMusicHoard) -> AppInner {
AppInner::new(music_hoard, mb_api()) AppInner::new(music_hoard, mb_api(), events())
} }
pub fn inner_with_mb(music_hoard: MockIMusicHoard, mb_api: MockIMusicBrainz) -> AppInner { pub fn inner_with_mb(music_hoard: MockIMusicHoard, mb_api: MockIMusicBrainz) -> AppInner {
AppInner::new(music_hoard, mb_api) AppInner::new(music_hoard, mb_api, events())
} }
#[test] #[test]
fn state_browse() { fn state_browse() {
let mut app = App::new(music_hoard_init(vec![]), mb_api()); let mut app = App::new(music_hoard_init(vec![]), mb_api(), events());
assert!(app.is_running()); assert!(app.is_running());
let state = app.state(); let state = app.state();
@ -276,7 +285,7 @@ mod tests {
#[test] #[test]
fn state_info() { fn state_info() {
let mut app = App::new(music_hoard_init(vec![]), mb_api()); let mut app = App::new(music_hoard_init(vec![]), mb_api(), events());
assert!(app.is_running()); assert!(app.is_running());
app = app.unwrap_browse().show_info_overlay(); app = app.unwrap_browse().show_info_overlay();
@ -294,7 +303,7 @@ mod tests {
#[test] #[test]
fn state_reload() { fn state_reload() {
let mut app = App::new(music_hoard_init(vec![]), mb_api()); let mut app = App::new(music_hoard_init(vec![]), mb_api(), events());
assert!(app.is_running()); assert!(app.is_running());
app = app.unwrap_browse().show_reload_menu(); app = app.unwrap_browse().show_reload_menu();
@ -312,7 +321,7 @@ mod tests {
#[test] #[test]
fn state_search() { fn state_search() {
let mut app = App::new(music_hoard_init(vec![]), mb_api()); let mut app = App::new(music_hoard_init(vec![]), mb_api(), events());
assert!(app.is_running()); assert!(app.is_running());
app = app.unwrap_browse().begin_search(); app = app.unwrap_browse().begin_search();
@ -349,7 +358,7 @@ mod tests {
#[test] #[test]
fn state_error() { fn state_error() {
let mut app = App::new(music_hoard_init(vec![]), mb_api()); let mut app = App::new(music_hoard_init(vec![]), mb_api(), events());
assert!(app.is_running()); assert!(app.is_running());
app = AppMachine::error(app.unwrap_browse().inner, "get rekt").into(); app = AppMachine::error(app.unwrap_browse().inner, "get rekt").into();
@ -367,7 +376,7 @@ mod tests {
#[test] #[test]
fn state_critical() { fn state_critical() {
let mut app = App::new(music_hoard_init(vec![]), mb_api()); let mut app = App::new(music_hoard_init(vec![]), mb_api(), events());
assert!(app.is_running()); assert!(app.is_running());
app = AppMachine::critical(app.unwrap_browse().inner, "get rekt").into(); app = AppMachine::critical(app.unwrap_browse().inner, "get rekt").into();
@ -393,7 +402,7 @@ mod tests {
.return_once(|| Err(musichoard::Error::LibraryError(String::from("get rekt")))); .return_once(|| Err(musichoard::Error::LibraryError(String::from("get rekt"))));
music_hoard.expect_get_collection().return_const(vec![]); music_hoard.expect_get_collection().return_const(vec![]);
let app = App::new(music_hoard, mb_api()); let app = App::new(music_hoard, mb_api(), events());
assert!(app.is_running()); assert!(app.is_running());
app.unwrap_critical(); app.unwrap_critical();
} }

View File

@ -24,7 +24,7 @@ pub trait IAppInteract {
type IS: IAppInteractInfo<APP = Self>; type IS: IAppInteractInfo<APP = Self>;
type RS: IAppInteractReload<APP = Self>; type RS: IAppInteractReload<APP = Self>;
type SS: IAppInteractSearch<APP = Self>; type SS: IAppInteractSearch<APP = Self>;
type FS: IAppInteractFetch<APP = Self>; type FS: IAppInteractFetch<APP = Self> + IAppEventFetch<APP = Self>;
type MS: IAppInteractMatches<APP = Self>; type MS: IAppInteractMatches<APP = Self>;
type ES: IAppInteractError<APP = Self>; type ES: IAppInteractError<APP = Self>;
type CS: IAppInteractCritical<APP = Self>; type CS: IAppInteractCritical<APP = Self>;
@ -98,6 +98,12 @@ pub trait IAppInteractFetch {
fn no_op(self) -> Self::APP; fn no_op(self) -> Self::APP;
} }
pub trait IAppEventFetch {
type APP: IAppInteract;
fn fetch_result_ready(self) -> Self::APP;
}
pub trait IAppInteractMatches { pub trait IAppInteractMatches {
type APP: IAppInteract; type APP: IAppInteract;
@ -114,6 +120,8 @@ pub trait IAppInteractError {
type APP: IAppInteract; type APP: IAppInteract;
fn dismiss_error(self) -> Self::APP; fn dismiss_error(self) -> Self::APP;
fn no_op(self) -> Self::APP;
} }
pub trait IAppInteractCritical { pub trait IAppInteractCritical {

View File

@ -36,6 +36,7 @@ impl From<mpsc::RecvError> for EventError {
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub enum Event { pub enum Event {
Key(KeyEvent), Key(KeyEvent),
FetchResultReady,
} }
pub struct EventChannel { pub struct EventChannel {
@ -43,6 +44,7 @@ pub struct EventChannel {
receiver: mpsc::Receiver<Event>, receiver: mpsc::Receiver<Event>,
} }
#[derive(Clone)]
pub struct EventSender { pub struct EventSender {
sender: mpsc::Sender<Event>, sender: mpsc::Sender<Event>,
} }

View File

@ -12,6 +12,8 @@ use crate::tui::{
event::{Event, EventError, EventReceiver}, event::{Event, EventError, EventReceiver},
}; };
use super::app::IAppEventFetch;
#[cfg_attr(test, automock)] #[cfg_attr(test, automock)]
pub trait IEventHandler<APP: IAppInteract> { pub trait IEventHandler<APP: IAppInteract> {
fn handle_next_event(&self, app: APP) -> Result<APP, EventError>; fn handle_next_event(&self, app: APP) -> Result<APP, EventError>;
@ -27,6 +29,8 @@ trait IEventHandlerPrivate<APP: IAppInteract> {
fn handle_matches_key_event(app: <APP as IAppInteract>::MS, key_event: KeyEvent) -> APP; fn handle_matches_key_event(app: <APP as IAppInteract>::MS, key_event: KeyEvent) -> APP;
fn handle_error_key_event(app: <APP as IAppInteract>::ES, key_event: KeyEvent) -> APP; fn handle_error_key_event(app: <APP as IAppInteract>::ES, key_event: KeyEvent) -> APP;
fn handle_critical_key_event(app: <APP as IAppInteract>::CS, key_event: KeyEvent) -> APP; fn handle_critical_key_event(app: <APP as IAppInteract>::CS, key_event: KeyEvent) -> APP;
fn handle_fetch_result_ready_event(app: APP) -> APP;
} }
pub struct EventHandler { pub struct EventHandler {
@ -41,11 +45,11 @@ impl EventHandler {
} }
impl<APP: IAppInteract> IEventHandler<APP> for EventHandler { impl<APP: IAppInteract> IEventHandler<APP> for EventHandler {
fn handle_next_event(&self, mut app: APP) -> Result<APP, EventError> { fn handle_next_event(&self, app: APP) -> Result<APP, EventError> {
match self.events.recv()? { Ok(match self.events.recv()? {
Event::Key(key_event) => app = Self::handle_key_event(app, key_event), Event::Key(key_event) => Self::handle_key_event(app, key_event),
}; Event::FetchResultReady => Self::handle_fetch_result_ready_event(app),
Ok(app) })
} }
} }
@ -87,6 +91,19 @@ impl<APP: IAppInteract> IEventHandlerPrivate<APP> for EventHandler {
} }
} }
fn handle_fetch_result_ready_event(app: APP) -> APP {
match app.state() {
AppState::Browse(browse) => browse.no_op(),
AppState::Info(info) => info.no_op(),
AppState::Reload(reload) => reload.no_op(),
AppState::Search(search) => search.no_op(),
AppState::Fetch(fetch) => fetch.fetch_result_ready(),
AppState::Matches(matches) => matches.no_op(),
AppState::Error(error) => error.no_op(),
AppState::Critical(critical) => critical.no_op(),
}
}
fn handle_browse_key_event(app: <APP as IAppInteract>::BS, key_event: KeyEvent) -> APP { fn handle_browse_key_event(app: <APP as IAppInteract>::BS, key_event: KeyEvent) -> APP {
match key_event.code { match key_event.code {
// Exit application on `ESC` or `q`. // Exit application on `ESC` or `q`.

View File

@ -174,6 +174,7 @@ mod testmod;
mod tests { mod tests {
use std::{io, thread}; use std::{io, thread};
use event::EventSender;
use lib::interface::musicbrainz::MockIMusicBrainz; use lib::interface::musicbrainz::MockIMusicBrainz;
use ratatui::{backend::TestBackend, Terminal}; use ratatui::{backend::TestBackend, Terminal};
@ -202,8 +203,12 @@ mod tests {
music_hoard music_hoard
} }
fn events() -> EventSender {
EventChannel::new().sender()
}
fn app(collection: Collection) -> App { fn app(collection: Collection) -> App {
App::new(music_hoard(collection), MockIMusicBrainz::new()) App::new(music_hoard(collection), MockIMusicBrainz::new(), events())
} }
fn listener() -> MockIEventListener { fn listener() -> MockIEventListener {