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 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 =
MusicBrainzHttp::new(MUSICHOARD_HTTP_USER_AGENT).expect("failed to initialise HTTP client");
let client = MusicBrainzClient::new(http);
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 handler = EventHandler::new(channel.receiver());
// Run the TUI application.
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 {
AppMachine::browse(self.inner).into()
}
fn no_op(self) -> Self::APP {
self.into()
}
}
#[cfg(test)]

View File

@ -1,5 +1,8 @@
use std::{
sync::{mpsc, Arc, Mutex},
sync::{
mpsc::{self, TryRecvError},
Arc, Mutex,
},
thread, time,
};
@ -11,10 +14,8 @@ use musichoard::collection::{
use crate::tui::{
app::{
machine::{App, AppInner, AppMachine},
AppMatchesInfo, AppPublic, AppState, IAppInteractFetch,
},
lib::interface::musicbrainz::{Error as MbError, IMusicBrainz},
machine::{App, AppInner, AppMachine}, AppMatchesInfo, AppPublic, AppState, IAppEventFetch, IAppInteractFetch
}, event::{Event, EventSender}, lib::interface::musicbrainz::{Error as MbError, IMusicBrainz}
};
use super::matches::AppMatches;
@ -24,6 +25,10 @@ pub struct AppFetch {
}
impl AppMachine<AppFetch> {
fn fetch(inner: AppInner, state: AppFetch) -> Self {
AppMachine { inner, state }
}
pub fn app_fetch_new(inner: AppInner) -> App {
let coll = inner.music_hoard.get_collection();
let artist = match inner.selection.state_artist(coll) {
@ -36,14 +41,16 @@ impl AppMachine<AppFetch> {
match artist.meta.musicbrainz {
Some(ref arid) => {
let musicbrainz = Arc::clone(&inner.musicbrainz);
let events = inner.events.clone();
let arid = arid.mbid().clone();
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 => {
let musicbrainz = Arc::clone(&inner.musicbrainz);
let events = inner.events.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 {
match fetch.fetch_rx.recv() {
match fetch.fetch_rx.try_recv() {
Ok(fetch_result) => match fetch_result {
Ok(mut next_match) => {
next_match.push_cannot_have_mbid();
let current = Some(next_match);
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(_) => {
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 FetchResult = Result<AppMatchesInfo, FetchError>;
type FetchSender = mpsc::Sender<FetchResult>;
@ -113,11 +132,13 @@ trait IAppInteractFetchPrivate {
fn fetch_artist(
musicbrainz: Arc<Mutex<dyn IMusicBrainz + Send>>,
fetch_tx: FetchSender,
events: EventSender,
artist: ArtistMeta,
);
fn fetch_albums(
musicbrainz: Arc<Mutex<dyn IMusicBrainz + Send>>,
fetch_tx: FetchSender,
events: EventSender,
arid: Mbid,
albums: Vec<AlbumMeta>,
);
@ -127,16 +148,22 @@ impl IAppInteractFetchPrivate for AppMachine<AppFetch> {
fn fetch_artist(
musicbrainz: Arc<Mutex<dyn IMusicBrainz + Send>>,
fetch_tx: FetchSender,
events: EventSender,
artist: ArtistMeta,
) {
let result = musicbrainz.lock().unwrap().search_artist(&artist);
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(
musicbrainz: Arc<Mutex<dyn IMusicBrainz + Send>>,
fetch_tx: FetchSender,
events: EventSender,
arid: Mbid,
albums: Vec<AlbumMeta>,
) {
@ -154,6 +181,12 @@ impl IAppInteractFetchPrivate for AppMachine<AppFetch> {
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() {
thread::sleep(time::Duration::from_secs(1));
}

View File

@ -11,6 +11,7 @@ use std::sync::{Arc, Mutex};
use crate::tui::{
app::{selection::Selection, AppPublic, AppPublicInner, AppState, IAppAccess, IAppInteract},
event::EventSender,
lib::{interface::musicbrainz::IMusicBrainz, IMusicHoard},
};
@ -44,15 +45,17 @@ pub struct AppInner {
music_hoard: Box<dyn IMusicHoard>,
musicbrainz: Arc<Mutex<dyn IMusicBrainz + Send>>,
selection: Selection,
events: EventSender,
}
impl App {
pub fn new<MH: IMusicHoard + 'static, MB: IMusicBrainz + Send + 'static>(
mut music_hoard: MH,
musicbrainz: MB,
events: EventSender,
) -> Self {
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 {
Ok(()) => AppMachine::browse(inner).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>(
music_hoard: MH,
musicbrainz: MB,
events: EventSender,
) -> Self {
let selection = Selection::new(music_hoard.get_collection());
AppInner {
@ -144,6 +148,7 @@ impl AppInner {
music_hoard: Box::new(music_hoard),
musicbrainz: Arc::new(Mutex::new(musicbrainz)),
selection,
events,
}
}
}
@ -165,7 +170,7 @@ mod tests {
use crate::tui::{
app::{AppState, IAppInteract, IAppInteractBrowse},
lib::{interface::musicbrainz::MockIMusicBrainz, MockIMusicHoard},
lib::{interface::musicbrainz::MockIMusicBrainz, MockIMusicHoard}, EventChannel,
};
use super::*;
@ -250,17 +255,21 @@ mod tests {
MockIMusicBrainz::new()
}
fn events() -> EventSender {
EventChannel::new().sender()
}
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 {
AppInner::new(music_hoard, mb_api)
AppInner::new(music_hoard, mb_api, events())
}
#[test]
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());
let state = app.state();
@ -276,7 +285,7 @@ mod tests {
#[test]
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());
app = app.unwrap_browse().show_info_overlay();
@ -294,7 +303,7 @@ mod tests {
#[test]
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());
app = app.unwrap_browse().show_reload_menu();
@ -312,7 +321,7 @@ mod tests {
#[test]
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());
app = app.unwrap_browse().begin_search();
@ -349,7 +358,7 @@ mod tests {
#[test]
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());
app = AppMachine::error(app.unwrap_browse().inner, "get rekt").into();
@ -367,7 +376,7 @@ mod tests {
#[test]
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());
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"))));
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());
app.unwrap_critical();
}

View File

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

View File

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

View File

@ -12,6 +12,8 @@ use crate::tui::{
event::{Event, EventError, EventReceiver},
};
use super::app::IAppEventFetch;
#[cfg_attr(test, automock)]
pub trait IEventHandler<APP: IAppInteract> {
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_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_fetch_result_ready_event(app: APP) -> APP;
}
pub struct EventHandler {
@ -41,11 +45,11 @@ impl EventHandler {
}
impl<APP: IAppInteract> IEventHandler<APP> for EventHandler {
fn handle_next_event(&self, mut app: APP) -> Result<APP, EventError> {
match self.events.recv()? {
Event::Key(key_event) => app = Self::handle_key_event(app, key_event),
};
Ok(app)
fn handle_next_event(&self, app: APP) -> Result<APP, EventError> {
Ok(match self.events.recv()? {
Event::Key(key_event) => Self::handle_key_event(app, key_event),
Event::FetchResultReady => Self::handle_fetch_result_ready_event(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 {
match key_event.code {
// Exit application on `ESC` or `q`.

View File

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