diff --git a/src/core/collection/album.rs b/src/core/collection/album.rs index 5eb412e..926d2b2 100644 --- a/src/core/collection/album.rs +++ b/src/core/collection/album.rs @@ -21,6 +21,12 @@ pub struct AlbumId { pub title: String, } +impl Album { + pub fn get_sort_key(&self) -> &AlbumId { + &self.id + } +} + impl PartialOrd for Album { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) diff --git a/src/core/collection/artist.rs b/src/core/collection/artist.rs index bef3684..35d25dd 100644 --- a/src/core/collection/artist.rs +++ b/src/core/collection/artist.rs @@ -102,7 +102,7 @@ impl Artist { } } - fn get_sort_key(&self) -> &ArtistId { + pub fn get_sort_key(&self) -> &ArtistId { self.sort.as_ref().unwrap_or(&self.id) } diff --git a/src/core/collection/track.rs b/src/core/collection/track.rs index 3b35cf0..0fc4e1b 100644 --- a/src/core/collection/track.rs +++ b/src/core/collection/track.rs @@ -24,6 +24,12 @@ pub struct Quality { pub bitrate: u32, } +impl Track { + pub fn get_sort_key(&self) -> &TrackId { + &self.id + } +} + /// The track file format. #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)] pub enum Format { diff --git a/src/core/musichoard/musichoard.rs b/src/core/musichoard/musichoard.rs index 66a7d6b..041569b 100644 --- a/src/core/musichoard/musichoard.rs +++ b/src/core/musichoard/musichoard.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, mem}; +use std::collections::HashMap; use paste::paste; @@ -20,6 +20,9 @@ pub struct MusicHoard { collection: Collection, library: LIB, database: DB, + // There is no database cache since the database contains the entirety of the `collection` + // itself. Therefore, [`collection`] also represents the last state of the database. + library_cache: HashMap, } /// Phantom type for when a library implementation is not needed. @@ -117,6 +120,7 @@ impl MusicHoard { collection: vec![], library, database, + library_cache: HashMap::new(), } } @@ -189,34 +193,17 @@ impl MusicHoard { } } - fn merge_with_primary(&mut self, primary: HashMap) { - let collection = mem::take(&mut self.collection); - self.collection = Self::merge_collections(primary, collection); - } - - fn merge_with_secondary>(&mut self, secondary: SEC) { - let primary_map: HashMap = self - .collection - .drain(..) - .map(|a| (a.id.clone(), a)) - .collect(); - self.collection = Self::merge_collections(primary_map, secondary); - } - - fn merge_collections>( - mut primary: HashMap, - secondary: SEC, - ) -> Collection { - for secondary_artist in secondary.into_iter() { + fn merge_collections(&mut self) { + let mut primary = self.library_cache.clone(); + for secondary_artist in self.collection.drain(..) { if let Some(ref mut primary_artist) = primary.get_mut(&secondary_artist.id) { primary_artist.merge_in_place(secondary_artist); } else { primary.insert(secondary_artist.id.clone(), secondary_artist); } } - let mut collection: Collection = primary.into_values().collect(); - Self::sort_artists(&mut collection); - collection + self.collection.extend(primary.into_values()); + Self::sort_artists(&mut self.collection); } fn items_to_artists(items: Vec) -> Result, Error> { @@ -307,10 +294,10 @@ impl MusicHoard { /// Rescan the library and merge with the in-memory collection. pub fn rescan_library(&mut self) -> Result<(), Error> { let items = self.library.list(&Query::new())?; - let mut library_collection = Self::items_to_artists(items)?; - Self::sort_albums_and_tracks(library_collection.values_mut()); + self.library_cache = Self::items_to_artists(items)?; + Self::sort_albums_and_tracks(self.library_cache.values_mut()); - self.merge_with_primary(library_collection); + self.merge_collections(); Ok(()) } } @@ -318,10 +305,10 @@ impl MusicHoard { impl MusicHoard { /// Load the database and merge with the in-memory collection. pub fn load_from_database(&mut self) -> Result<(), Error> { - let mut database_collection = self.database.load()?; - Self::sort_albums_and_tracks(database_collection.iter_mut()); + self.collection = self.database.load()?; + Self::sort_albums_and_tracks(self.collection.iter_mut()); - self.merge_with_secondary(database_collection); + self.merge_collections(); Ok(()) } @@ -729,25 +716,32 @@ mod tests { let mut expected = FULL_COLLECTION.to_owned(); expected.sort_unstable(); - let merged = MusicHoard::::merge_collections( - left.clone() - .into_iter() - .map(|a| (a.id.clone(), a)) - .collect(), - right.clone(), - ); - assert_eq!(expected, merged); - - // The merge is completely non-overlapping so it should be commutative. - let merged = MusicHoard::::merge_collections( - right + let mut mh = MusicHoard { + collection: right.clone(), + library_cache: left .clone() .into_iter() .map(|a| (a.id.clone(), a)) .collect(), - left.clone(), - ); - assert_eq!(expected, merged); + ..Default::default() + }; + + mh.merge_collections(); + assert_eq!(expected, mh.collection); + + // The merge is completely non-overlapping so it should be commutative. + let mut mh = MusicHoard { + collection: left.clone(), + library_cache: right + .clone() + .into_iter() + .map(|a| (a.id.clone(), a)) + .collect(), + ..Default::default() + }; + + mh.merge_collections(); + assert_eq!(expected, mh.collection); } #[test] @@ -760,25 +754,32 @@ mod tests { let mut expected = FULL_COLLECTION.to_owned(); expected.sort_unstable(); - let merged = MusicHoard::::merge_collections( - left.clone() - .into_iter() - .map(|a| (a.id.clone(), a)) - .collect(), - right.clone(), - ); - assert_eq!(expected, merged); - - // The merge does not overwrite any data so it should be commutative. - let merged = MusicHoard::::merge_collections( - right + let mut mh = MusicHoard { + collection: right.clone(), + library_cache: left .clone() .into_iter() .map(|a| (a.id.clone(), a)) .collect(), - left.clone(), - ); - assert_eq!(expected, merged); + ..Default::default() + }; + + mh.merge_collections(); + assert_eq!(expected, mh.collection); + + // The merge does not overwrite any data so it should be commutative. + let mut mh = MusicHoard { + collection: left.clone(), + library_cache: right + .clone() + .into_iter() + .map(|a| (a.id.clone(), a)) + .collect(), + ..Default::default() + }; + + mh.merge_collections(); + assert_eq!(expected, mh.collection); } #[test] @@ -804,27 +805,32 @@ mod tests { expected.last_mut().as_mut().unwrap().sort = artist_sort.clone(); expected.rotate_right(1); - let merged = MusicHoard::::merge_collections( - left.clone() - .into_iter() - .map(|a| (a.id.clone(), a)) - .collect(), - right.clone(), - ); - assert_eq!(expected.len(), merged.len()); - assert_eq!(expected, merged); - - // The merge overwrites the sort data, but no data is erased so it should be commutative. - let merged = MusicHoard::::merge_collections( - right + let mut mh = MusicHoard { + collection: right.clone(), + library_cache: left .clone() .into_iter() .map(|a| (a.id.clone(), a)) .collect(), - left.clone(), - ); - assert_eq!(expected.len(), merged.len()); - assert_eq!(expected, merged); + ..Default::default() + }; + + mh.merge_collections(); + assert_eq!(expected, mh.collection); + + // The merge overwrites the sort data, but no data is erased so it should be commutative. + let mut mh = MusicHoard { + collection: left.clone(), + library_cache: right + .clone() + .into_iter() + .map(|a| (a.id.clone(), a)) + .collect(), + ..Default::default() + }; + + mh.merge_collections(); + assert_eq!(expected, mh.collection); } #[test] diff --git a/src/main.rs b/src/main.rs index 94207ba..75423d0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,7 +20,7 @@ use musichoard::{ MusicHoardBuilder, NoDatabase, NoLibrary, }; -use tui::{event::EventChannel, handler::EventHandler, listener::EventListener, ui::Ui, Tui}; +use tui::{App, EventChannel, EventHandler, EventListener, Tui, Ui}; #[derive(StructOpt)] struct Opt { @@ -67,10 +67,11 @@ fn with(builder: MusicHoardBuilder) { let listener = EventListener::new(channel.sender()); let handler = EventHandler::new(channel.receiver()); - let ui = Ui::new(music_hoard).expect("failed to initialise ui"); + let app = App::new(music_hoard); + let ui = Ui; // Run the TUI application. - Tui::run(terminal, ui, handler, listener).expect("failed to run tui"); + Tui::run(terminal, app, ui, handler, listener).expect("failed to run tui"); } fn with_database(db_opt: DbOpt, builder: MusicHoardBuilder) { diff --git a/src/tui/app/app.rs b/src/tui/app/app.rs new file mode 100644 index 0000000..0b23320 --- /dev/null +++ b/src/tui/app/app.rs @@ -0,0 +1,653 @@ +#![allow(clippy::module_inception)] + +use musichoard::collection::Collection; + +use crate::tui::{ + app::selection::{ActiveSelection, Selection}, + lib::IMusicHoard, +}; + +pub enum AppState { + Browse(BS), + Info(IS), + Reload(RS), + Error(ES), +} + +impl AppState { + fn is_browse(&self) -> bool { + matches!(self, AppState::Browse(_)) + } + + fn is_info(&self) -> bool { + matches!(self, AppState::Info(_)) + } + + fn is_reload(&self) -> bool { + matches!(self, AppState::Reload(_)) + } + + fn is_error(&self) -> bool { + matches!(self, AppState::Error(_)) + } +} + +pub trait IAppInteract { + type BS: IAppInteractBrowse; + type IS: IAppInteractInfo; + type RS: IAppInteractReload; + type ES: IAppInteractError; + + fn is_running(&self) -> bool; + fn quit(&mut self); + fn force_quit(&mut self); + + fn save(&mut self); + + fn state(&mut self) -> AppState<&mut Self::BS, &mut Self::IS, &mut Self::RS, &mut Self::ES>; +} + +pub trait IAppInteractBrowse { + fn increment_category(&mut self); + fn decrement_category(&mut self); + fn increment_selection(&mut self); + fn decrement_selection(&mut self); + + fn show_info_overlay(&mut self); + + fn show_reload_menu(&mut self); +} + +pub trait IAppInteractInfo { + fn hide_info_overlay(&mut self); +} + +pub trait IAppInteractReload { + fn reload_library(&mut self); + fn reload_database(&mut self); + fn hide_reload_menu(&mut self); +} + +pub trait IAppInteractError { + fn dismiss_error(&mut self); +} + +// It would be preferable to have a getter for each field separately. However, the selection field +// needs to be mutably accessible requiring a mutable borrow of the entire struct if behind a trait. +// This in turn complicates simultaneous field access since only a single mutable borrow is allowed. +// Therefore, all fields are grouped into a single struct and returned as a batch. +pub trait IAppAccess { + fn get(&mut self) -> AppPublic; +} + +pub struct AppPublic<'app> { + pub collection: &'app Collection, + pub selection: &'app mut Selection, + pub state: &'app AppState<(), (), (), String>, +} + +pub struct App { + running: bool, + music_hoard: MH, + selection: Selection, + state: AppState<(), (), (), String>, +} + +impl App { + pub fn new(mut music_hoard: MH) -> Self { + let state = match Self::init(&mut music_hoard) { + Ok(()) => AppState::Browse(()), + Err(err) => AppState::Error(err.to_string()), + }; + let selection = Selection::new(music_hoard.get_collection()); + App { + running: true, + music_hoard, + selection, + state, + } + } + + fn init(music_hoard: &mut MH) -> Result<(), musichoard::Error> { + music_hoard.load_from_database()?; + music_hoard.rescan_library()?; + Ok(()) + } +} + +impl IAppInteract for App { + type BS = Self; + type IS = Self; + type RS = Self; + type ES = Self; + + fn is_running(&self) -> bool { + self.running + } + + fn quit(&mut self) { + if !self.state.is_error() { + self.running = false; + } + } + + fn force_quit(&mut self) { + self.running = false; + } + + fn save(&mut self) { + if let Err(err) = self.music_hoard.save_to_database() { + self.state = AppState::Error(err.to_string()); + } + } + + fn state(&mut self) -> AppState<&mut Self::BS, &mut Self::IS, &mut Self::RS, &mut Self::ES> { + match self.state { + AppState::Browse(_) => AppState::Browse(self), + AppState::Info(_) => AppState::Info(self), + AppState::Reload(_) => AppState::Reload(self), + AppState::Error(_) => AppState::Error(self), + } + } +} + +impl IAppInteractBrowse for App { + fn increment_category(&mut self) { + self.selection.increment_category(); + } + + fn decrement_category(&mut self) { + self.selection.decrement_category(); + } + + fn increment_selection(&mut self) { + self.selection + .increment_selection(self.music_hoard.get_collection()); + } + + fn decrement_selection(&mut self) { + self.selection + .decrement_selection(self.music_hoard.get_collection()); + } + + fn show_info_overlay(&mut self) { + assert!(self.state.is_browse()); + self.state = AppState::Info(()); + } + + fn show_reload_menu(&mut self) { + assert!(self.state.is_browse()); + self.state = AppState::Reload(()); + } +} + +impl IAppInteractInfo for App { + fn hide_info_overlay(&mut self) { + assert!(self.state.is_info()); + self.state = AppState::Browse(()); + } +} + +impl IAppInteractReload for App { + fn reload_library(&mut self) { + let previous = ActiveSelection::get(self.music_hoard.get_collection(), &self.selection); + let result = self.music_hoard.rescan_library(); + self.refresh(previous, result); + } + + fn reload_database(&mut self) { + let previous = ActiveSelection::get(self.music_hoard.get_collection(), &self.selection); + let result = self.music_hoard.load_from_database(); + self.refresh(previous, result); + } + + fn hide_reload_menu(&mut self) { + assert!(self.state.is_reload()); + self.state = AppState::Browse(()); + } +} + +trait IAppInteractReloadPrivate { + fn refresh(&mut self, previous: ActiveSelection, result: Result<(), musichoard::Error>); +} + +impl IAppInteractReloadPrivate for App { + fn refresh(&mut self, previous: ActiveSelection, result: Result<(), musichoard::Error>) { + assert!(self.state.is_reload()); + match result { + Ok(()) => { + self.selection + .select(self.music_hoard.get_collection(), previous); + self.state = AppState::Browse(()) + } + Err(err) => self.state = AppState::Error(err.to_string()), + } + } +} + +impl IAppInteractError for App { + fn dismiss_error(&mut self) { + assert!(self.state.is_error()); + self.state = AppState::Browse(()); + } +} + +impl IAppAccess for App { + fn get(&mut self) -> AppPublic { + AppPublic { + collection: self.music_hoard.get_collection(), + selection: &mut self.selection, + state: &self.state, + } + } +} + +#[cfg(test)] +mod tests { + use crate::tui::{app::selection::Category, lib::MockIMusicHoard, testmod::COLLECTION}; + + use super::*; + + fn music_hoard(collection: Collection) -> MockIMusicHoard { + let mut music_hoard = MockIMusicHoard::new(); + + music_hoard + .expect_load_from_database() + .times(1) + .return_once(|| Ok(())); + music_hoard + .expect_rescan_library() + .times(1) + .return_once(|| Ok(())); + music_hoard.expect_get_collection().return_const(collection); + + music_hoard + } + + #[test] + fn running_quit() { + let mut app = App::new(music_hoard(COLLECTION.to_owned())); + assert!(app.is_running()); + + app.quit(); + assert!(!app.is_running()); + } + + #[test] + fn error_quit() { + let mut app = App::new(music_hoard(COLLECTION.to_owned())); + assert!(app.is_running()); + + app.state = AppState::Error(String::from("get rekt")); + + app.quit(); + assert!(app.is_running()); + + app.dismiss_error(); + + app.quit(); + assert!(!app.is_running()); + } + + #[test] + fn running_force_quit() { + let mut app = App::new(music_hoard(COLLECTION.to_owned())); + assert!(app.is_running()); + + app.force_quit(); + assert!(!app.is_running()); + } + + #[test] + fn error_force_quit() { + let mut app = App::new(music_hoard(COLLECTION.to_owned())); + assert!(app.is_running()); + + app.state = AppState::Error(String::from("get rekt")); + + app.force_quit(); + assert!(!app.is_running()); + } + + #[test] + fn save() { + let mut music_hoard = music_hoard(COLLECTION.to_owned()); + + music_hoard + .expect_save_to_database() + .times(1) + .return_once(|| Ok(())); + + let mut app = App::new(music_hoard); + + app.save(); + assert!(app.state.is_browse()); + } + + #[test] + fn save_error() { + let mut music_hoard = music_hoard(COLLECTION.to_owned()); + + music_hoard + .expect_save_to_database() + .times(1) + .return_once(|| Err(musichoard::Error::DatabaseError(String::from("get rekt")))); + + let mut app = App::new(music_hoard); + + app.save(); + + assert!(app.state.is_error()); + } + + #[test] + fn init_error() { + let mut music_hoard = MockIMusicHoard::new(); + + music_hoard + .expect_load_from_database() + .times(1) + .return_once(|| Err(musichoard::Error::DatabaseError(String::from("get rekt")))); + music_hoard.expect_get_collection().return_const(vec![]); + + let app = App::new(music_hoard); + + assert!(app.is_running()); + assert!(app.state.is_error()); + } + + #[test] + fn modifiers() { + let mut app = App::new(music_hoard(COLLECTION.to_owned())); + assert!(app.is_running()); + + assert_eq!(app.selection.active, Category::Artist); + assert_eq!(app.selection.artist.state.selected(), Some(0)); + assert_eq!(app.selection.artist.album.state.selected(), Some(0)); + assert_eq!(app.selection.artist.album.track.state.selected(), Some(0)); + + app.increment_selection(); + assert_eq!(app.selection.active, Category::Artist); + assert_eq!(app.selection.artist.state.selected(), Some(1)); + assert_eq!(app.selection.artist.album.state.selected(), Some(0)); + assert_eq!(app.selection.artist.album.track.state.selected(), Some(0)); + + app.increment_category(); + assert_eq!(app.selection.active, Category::Album); + assert_eq!(app.selection.artist.state.selected(), Some(1)); + assert_eq!(app.selection.artist.album.state.selected(), Some(0)); + assert_eq!(app.selection.artist.album.track.state.selected(), Some(0)); + + app.increment_selection(); + assert_eq!(app.selection.active, Category::Album); + assert_eq!(app.selection.artist.state.selected(), Some(1)); + assert_eq!(app.selection.artist.album.state.selected(), Some(1)); + assert_eq!(app.selection.artist.album.track.state.selected(), Some(0)); + + app.increment_category(); + assert_eq!(app.selection.active, Category::Track); + assert_eq!(app.selection.artist.state.selected(), Some(1)); + assert_eq!(app.selection.artist.album.state.selected(), Some(1)); + assert_eq!(app.selection.artist.album.track.state.selected(), Some(0)); + + app.increment_selection(); + assert_eq!(app.selection.active, Category::Track); + assert_eq!(app.selection.artist.state.selected(), Some(1)); + assert_eq!(app.selection.artist.album.state.selected(), Some(1)); + assert_eq!(app.selection.artist.album.track.state.selected(), Some(1)); + + app.increment_category(); + assert_eq!(app.selection.active, Category::Track); + assert_eq!(app.selection.artist.state.selected(), Some(1)); + assert_eq!(app.selection.artist.album.state.selected(), Some(1)); + assert_eq!(app.selection.artist.album.track.state.selected(), Some(1)); + + app.decrement_selection(); + assert_eq!(app.selection.active, Category::Track); + assert_eq!(app.selection.artist.state.selected(), Some(1)); + assert_eq!(app.selection.artist.album.state.selected(), Some(1)); + assert_eq!(app.selection.artist.album.track.state.selected(), Some(0)); + + app.increment_selection(); + app.decrement_category(); + assert_eq!(app.selection.active, Category::Album); + assert_eq!(app.selection.artist.state.selected(), Some(1)); + assert_eq!(app.selection.artist.album.state.selected(), Some(1)); + assert_eq!(app.selection.artist.album.track.state.selected(), Some(1)); + + app.decrement_selection(); + assert_eq!(app.selection.active, Category::Album); + assert_eq!(app.selection.artist.state.selected(), Some(1)); + assert_eq!(app.selection.artist.album.state.selected(), Some(0)); + assert_eq!(app.selection.artist.album.track.state.selected(), Some(0)); + + app.increment_selection(); + app.decrement_category(); + assert_eq!(app.selection.active, Category::Artist); + assert_eq!(app.selection.artist.state.selected(), Some(1)); + assert_eq!(app.selection.artist.album.state.selected(), Some(1)); + assert_eq!(app.selection.artist.album.track.state.selected(), Some(0)); + + app.decrement_selection(); + assert_eq!(app.selection.active, Category::Artist); + assert_eq!(app.selection.artist.state.selected(), Some(0)); + assert_eq!(app.selection.artist.album.state.selected(), Some(0)); + assert_eq!(app.selection.artist.album.track.state.selected(), Some(0)); + + app.increment_category(); + app.increment_selection(); + app.decrement_category(); + app.decrement_selection(); + app.decrement_category(); + assert_eq!(app.selection.active, Category::Artist); + assert_eq!(app.selection.artist.state.selected(), Some(0)); + assert_eq!(app.selection.artist.album.state.selected(), Some(1)); + assert_eq!(app.selection.artist.album.track.state.selected(), Some(0)); + } + + #[test] + fn no_tracks() { + let mut collection = COLLECTION.to_owned(); + collection[0].albums[0].tracks = vec![]; + + let mut app = App::new(music_hoard(collection)); + assert!(app.is_running()); + + assert_eq!(app.selection.active, Category::Artist); + assert_eq!(app.selection.artist.state.selected(), Some(0)); + assert_eq!(app.selection.artist.album.state.selected(), Some(0)); + assert_eq!(app.selection.artist.album.track.state.selected(), None); + + app.increment_category(); + app.increment_category(); + + app.increment_selection(); + assert_eq!(app.selection.active, Category::Track); + assert_eq!(app.selection.artist.state.selected(), Some(0)); + assert_eq!(app.selection.artist.album.state.selected(), Some(0)); + assert_eq!(app.selection.artist.album.track.state.selected(), None); + + app.decrement_selection(); + assert_eq!(app.selection.active, Category::Track); + assert_eq!(app.selection.artist.state.selected(), Some(0)); + assert_eq!(app.selection.artist.album.state.selected(), Some(0)); + assert_eq!(app.selection.artist.album.track.state.selected(), None); + } + + #[test] + fn no_albums() { + let mut collection = COLLECTION.to_owned(); + collection[0].albums = vec![]; + + let mut app = App::new(music_hoard(collection)); + assert!(app.is_running()); + + assert_eq!(app.selection.active, Category::Artist); + assert_eq!(app.selection.artist.state.selected(), Some(0)); + assert_eq!(app.selection.artist.album.state.selected(), None); + assert_eq!(app.selection.artist.album.track.state.selected(), None); + + app.increment_category(); + + app.increment_selection(); + assert_eq!(app.selection.active, Category::Album); + assert_eq!(app.selection.artist.state.selected(), Some(0)); + assert_eq!(app.selection.artist.album.state.selected(), None); + assert_eq!(app.selection.artist.album.track.state.selected(), None); + + app.decrement_selection(); + assert_eq!(app.selection.active, Category::Album); + assert_eq!(app.selection.artist.state.selected(), Some(0)); + assert_eq!(app.selection.artist.album.state.selected(), None); + assert_eq!(app.selection.artist.album.track.state.selected(), None); + + app.increment_category(); + + app.increment_selection(); + assert_eq!(app.selection.active, Category::Track); + assert_eq!(app.selection.artist.state.selected(), Some(0)); + assert_eq!(app.selection.artist.album.state.selected(), None); + assert_eq!(app.selection.artist.album.track.state.selected(), None); + + app.decrement_selection(); + assert_eq!(app.selection.active, Category::Track); + assert_eq!(app.selection.artist.state.selected(), Some(0)); + assert_eq!(app.selection.artist.album.state.selected(), None); + assert_eq!(app.selection.artist.album.track.state.selected(), None); + } + + #[test] + fn no_artists() { + let mut app = App::new(music_hoard(vec![])); + assert!(app.is_running()); + + assert_eq!(app.selection.active, Category::Artist); + assert_eq!(app.selection.artist.state.selected(), None); + assert_eq!(app.selection.artist.album.state.selected(), None); + assert_eq!(app.selection.artist.album.track.state.selected(), None); + + app.increment_selection(); + assert_eq!(app.selection.active, Category::Artist); + assert_eq!(app.selection.artist.state.selected(), None); + assert_eq!(app.selection.artist.album.state.selected(), None); + assert_eq!(app.selection.artist.album.track.state.selected(), None); + + app.decrement_selection(); + assert_eq!(app.selection.active, Category::Artist); + assert_eq!(app.selection.artist.state.selected(), None); + assert_eq!(app.selection.artist.album.state.selected(), None); + assert_eq!(app.selection.artist.album.track.state.selected(), None); + + app.increment_category(); + + app.increment_selection(); + assert_eq!(app.selection.active, Category::Album); + assert_eq!(app.selection.artist.state.selected(), None); + assert_eq!(app.selection.artist.album.state.selected(), None); + assert_eq!(app.selection.artist.album.track.state.selected(), None); + + app.decrement_selection(); + assert_eq!(app.selection.active, Category::Album); + assert_eq!(app.selection.artist.state.selected(), None); + assert_eq!(app.selection.artist.album.state.selected(), None); + assert_eq!(app.selection.artist.album.track.state.selected(), None); + + app.increment_category(); + + app.increment_selection(); + assert_eq!(app.selection.active, Category::Track); + assert_eq!(app.selection.artist.state.selected(), None); + assert_eq!(app.selection.artist.album.state.selected(), None); + assert_eq!(app.selection.artist.album.track.state.selected(), None); + + app.decrement_selection(); + assert_eq!(app.selection.active, Category::Track); + assert_eq!(app.selection.artist.state.selected(), None); + assert_eq!(app.selection.artist.album.state.selected(), None); + assert_eq!(app.selection.artist.album.track.state.selected(), None); + } + + #[test] + fn info_overlay() { + let mut app = App::new(music_hoard(COLLECTION.to_owned())); + assert!(app.state().is_browse()); + + app.show_info_overlay(); + assert!(app.state().is_info()); + + app.hide_info_overlay(); + assert!(app.state().is_browse()); + } + + #[test] + fn reload_hide_menu() { + let mut app = App::new(music_hoard(COLLECTION.to_owned())); + assert!(app.state().is_browse()); + + app.show_reload_menu(); + assert!(app.state().is_reload()); + + app.hide_reload_menu(); + assert!(app.state().is_browse()); + } + + #[test] + fn reload_database() { + let mut music_hoard = music_hoard(COLLECTION.to_owned()); + + music_hoard + .expect_load_from_database() + .times(1) + .return_once(|| Ok(())); + + let mut app = App::new(music_hoard); + assert!(app.state().is_browse()); + + app.show_reload_menu(); + assert!(app.state().is_reload()); + + app.reload_database(); + assert!(app.state().is_browse()); + } + + #[test] + fn reload_library() { + let mut music_hoard = music_hoard(COLLECTION.to_owned()); + + music_hoard + .expect_rescan_library() + .times(1) + .return_once(|| Ok(())); + + let mut app = App::new(music_hoard); + assert!(app.state().is_browse()); + + app.show_reload_menu(); + assert!(app.state().is_reload()); + + app.reload_library(); + assert!(app.state().is_browse()); + } + + #[test] + fn reload_error() { + let mut music_hoard = music_hoard(COLLECTION.to_owned()); + + music_hoard + .expect_load_from_database() + .times(1) + .return_once(|| Err(musichoard::Error::DatabaseError(String::from("get rekt")))); + + let mut app = App::new(music_hoard); + assert!(app.state().is_browse()); + + app.show_reload_menu(); + assert!(app.state().is_reload()); + + app.reload_database(); + assert!(app.state().is_error()); + + app.dismiss_error(); + assert!(app.state().is_browse()); + } +} diff --git a/src/tui/app/mod.rs b/src/tui/app/mod.rs new file mode 100644 index 0000000..57892fa --- /dev/null +++ b/src/tui/app/mod.rs @@ -0,0 +1,2 @@ +pub mod app; +pub mod selection; diff --git a/src/tui/app/selection.rs b/src/tui/app/selection.rs new file mode 100644 index 0000000..c51c22b --- /dev/null +++ b/src/tui/app/selection.rs @@ -0,0 +1,601 @@ +use musichoard::collection::{ + album::{Album, AlbumId}, + artist::{Artist, ArtistId}, + track::{Track, TrackId}, + Collection, +}; +use ratatui::widgets::ListState; + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum Category { + Artist, + Album, + Track, +} + +pub struct Selection { + pub active: Category, + pub artist: ArtistSelection, +} + +#[derive(Clone, Debug)] +pub struct ArtistSelection { + pub state: ListState, + pub album: AlbumSelection, +} + +impl PartialEq for ArtistSelection { + fn eq(&self, other: &Self) -> bool { + self.state.selected().eq(&other.state.selected()) && self.album.eq(&other.album) + } +} + +#[derive(Clone, Debug)] +pub struct AlbumSelection { + pub state: ListState, + pub track: TrackSelection, +} + +impl PartialEq for AlbumSelection { + fn eq(&self, other: &Self) -> bool { + self.state.selected().eq(&other.state.selected()) && self.track.eq(&other.track) + } +} + +#[derive(Clone, Debug)] +pub struct TrackSelection { + pub state: ListState, +} + +impl PartialEq for TrackSelection { + fn eq(&self, other: &Self) -> bool { + self.state.selected().eq(&other.state.selected()) + } +} + +impl Selection { + pub fn new(artists: &[Artist]) -> Self { + Selection { + active: Category::Artist, + artist: ArtistSelection::initialise(artists), + } + } + + pub fn select(&mut self, artists: &[Artist], selected: ActiveSelection) { + self.artist.reinitialise(artists, selected.artist); + } + + pub fn increment_category(&mut self) { + self.active = match self.active { + Category::Artist => Category::Album, + Category::Album => Category::Track, + Category::Track => Category::Track, + }; + } + + pub fn decrement_category(&mut self) { + self.active = match self.active { + Category::Artist => Category::Artist, + Category::Album => Category::Artist, + Category::Track => Category::Album, + }; + } + + pub fn increment_selection(&mut self, collection: &Collection) { + match self.active { + Category::Artist => self.increment_artist(collection), + Category::Album => self.increment_album(collection), + Category::Track => self.increment_track(collection), + } + } + + pub fn decrement_selection(&mut self, collection: &Collection) { + match self.active { + Category::Artist => self.decrement_artist(collection), + Category::Album => self.decrement_album(collection), + Category::Track => self.decrement_track(collection), + } + } + + fn increment_artist(&mut self, artists: &[Artist]) { + self.artist.increment(artists); + } + + fn decrement_artist(&mut self, artists: &[Artist]) { + self.artist.decrement(artists); + } + + fn increment_album(&mut self, artists: &[Artist]) { + self.artist.increment_album(artists); + } + + fn decrement_album(&mut self, artists: &[Artist]) { + self.artist.decrement_album(artists); + } + + fn increment_track(&mut self, artists: &[Artist]) { + self.artist.increment_track(artists); + } + + fn decrement_track(&mut self, artists: &[Artist]) { + self.artist.decrement_track(artists); + } +} + +impl ArtistSelection { + fn initialise(artists: &[Artist]) -> Self { + let mut selection = ArtistSelection { + state: ListState::default(), + album: AlbumSelection::initialise(&[]), + }; + selection.reinitialise(artists, None); + selection + } + + fn reinitialise(&mut self, artists: &[Artist], active: Option) { + if let Some(active) = active { + let result = artists.binary_search_by(|a| a.get_sort_key().cmp(&active.artist_id)); + match result { + Ok(index) => self.reinitialise_with_index(artists, index, active.album), + Err(index) => self.reinitialise_with_index(artists, index, None), + } + } else { + self.reinitialise_with_index(artists, 0, None) + } + } + + fn reinitialise_with_index( + &mut self, + artists: &[Artist], + index: usize, + active_album: Option, + ) { + if artists.is_empty() { + self.state.select(None); + self.album = AlbumSelection::initialise(&[]); + } else if index >= artists.len() { + let end = artists.len() - 1; + self.state.select(Some(end)); + self.album = AlbumSelection::initialise(&artists[end].albums); + } else { + self.state.select(Some(index)); + self.album + .reinitialise(&artists[index].albums, active_album); + } + } + + fn increment(&mut self, artists: &[Artist]) { + if let Some(index) = self.state.selected() { + if let Some(result) = index.checked_add(1) { + if result < artists.len() { + self.state.select(Some(result)); + self.album = AlbumSelection::initialise(&artists[result].albums); + } + } + } + } + + fn increment_album(&mut self, artists: &[Artist]) { + if let Some(index) = self.state.selected() { + self.album.increment(&artists[index].albums); + } + } + + fn increment_track(&mut self, artists: &[Artist]) { + if let Some(index) = self.state.selected() { + self.album.increment_track(&artists[index].albums); + } + } + + fn decrement(&mut self, artists: &[Artist]) { + if let Some(index) = self.state.selected() { + if let Some(result) = index.checked_sub(1) { + self.state.select(Some(result)); + self.album = AlbumSelection::initialise(&artists[result].albums); + } + } + } + + fn decrement_album(&mut self, artists: &[Artist]) { + if let Some(index) = self.state.selected() { + self.album.decrement(&artists[index].albums); + } + } + + fn decrement_track(&mut self, artists: &[Artist]) { + if let Some(index) = self.state.selected() { + self.album.decrement_track(&artists[index].albums); + } + } +} + +impl AlbumSelection { + fn initialise(albums: &[Album]) -> Self { + let mut selection = AlbumSelection { + state: ListState::default(), + track: TrackSelection::initialise(&[]), + }; + selection.reinitialise(albums, None); + selection + } + + fn reinitialise(&mut self, albums: &[Album], album: Option) { + if let Some(album) = album { + let result = albums.binary_search_by(|a| a.get_sort_key().cmp(&album.album_id)); + match result { + Ok(index) => self.reinitialise_with_index(albums, index, album.track), + Err(index) => self.reinitialise_with_index(albums, index, None), + } + } else { + self.reinitialise_with_index(albums, 0, None) + } + } + + fn reinitialise_with_index( + &mut self, + albums: &[Album], + index: usize, + active_track: Option, + ) { + if albums.is_empty() { + self.state.select(None); + self.track = TrackSelection::initialise(&[]); + } else if index >= albums.len() { + let end = albums.len() - 1; + self.state.select(Some(end)); + self.track = TrackSelection::initialise(&albums[end].tracks); + } else { + self.state.select(Some(index)); + self.track.reinitialise(&albums[index].tracks, active_track); + } + } + + fn increment(&mut self, albums: &[Album]) { + if let Some(index) = self.state.selected() { + if let Some(result) = index.checked_add(1) { + if result < albums.len() { + self.state.select(Some(result)); + self.track = TrackSelection::initialise(&albums[result].tracks); + } + } + } + } + + fn increment_track(&mut self, albums: &[Album]) { + if let Some(index) = self.state.selected() { + self.track.increment(&albums[index].tracks); + } + } + + fn decrement(&mut self, albums: &[Album]) { + if let Some(index) = self.state.selected() { + if let Some(result) = index.checked_sub(1) { + self.state.select(Some(result)); + self.track = TrackSelection::initialise(&albums[result].tracks); + } + } + } + + fn decrement_track(&mut self, albums: &[Album]) { + if let Some(index) = self.state.selected() { + self.track.decrement(&albums[index].tracks); + } + } +} + +impl TrackSelection { + fn initialise(tracks: &[Track]) -> Self { + let mut selection = TrackSelection { + state: ListState::default(), + }; + selection.reinitialise(tracks, None); + selection + } + + fn reinitialise(&mut self, tracks: &[Track], track: Option) { + if let Some(track) = track { + let result = tracks.binary_search_by(|t| t.get_sort_key().cmp(&track.track_id)); + match result { + Ok(index) | Err(index) => self.reinitialise_with_index(tracks, index), + } + } else { + self.reinitialise_with_index(tracks, 0) + } + } + + fn reinitialise_with_index(&mut self, tracks: &[Track], index: usize) { + if tracks.is_empty() { + self.state.select(None); + } else if index >= tracks.len() { + self.state.select(Some(tracks.len() - 1)); + } else { + self.state.select(Some(index)); + } + } + + fn increment(&mut self, tracks: &[Track]) { + if let Some(index) = self.state.selected() { + if let Some(result) = index.checked_add(1) { + if result < tracks.len() { + self.state.select(Some(result)); + } + } + } + } + + fn decrement(&mut self, _tracks: &[Track]) { + if let Some(index) = self.state.selected() { + if let Some(result) = index.checked_sub(1) { + self.state.select(Some(result)); + } + } + } +} + +pub struct ActiveSelection { + artist: Option, +} + +struct ActiveArtist { + artist_id: ArtistId, + album: Option, +} + +struct ActiveAlbum { + album_id: AlbumId, + track: Option, +} + +struct ActiveTrack { + track_id: TrackId, +} + +impl ActiveSelection { + pub fn get(collection: &Collection, selection: &Selection) -> Self { + ActiveSelection { + artist: ActiveArtist::get(collection, &selection.artist), + } + } +} + +impl ActiveArtist { + fn get(artists: &[Artist], selection: &ArtistSelection) -> Option { + selection.state.selected().map(|index| { + let artist = &artists[index]; + ActiveArtist { + artist_id: artist.get_sort_key().clone(), + album: ActiveAlbum::get(&artist.albums, &selection.album), + } + }) + } +} + +impl ActiveAlbum { + fn get(albums: &[Album], selection: &AlbumSelection) -> Option { + selection.state.selected().map(|index| { + let album = &albums[index]; + ActiveAlbum { + album_id: album.get_sort_key().clone(), + track: ActiveTrack::get(&album.tracks, &selection.track), + } + }) + } +} + +impl ActiveTrack { + fn get(tracks: &[Track], selection: &TrackSelection) -> Option { + selection.state.selected().map(|index| { + let track = &tracks[index]; + ActiveTrack { + track_id: track.get_sort_key().clone(), + } + }) + } +} + +#[cfg(test)] +mod tests { + use crate::tui::testmod::COLLECTION; + + use super::*; + + #[test] + fn track_selection() { + let tracks = &COLLECTION[0].albums[0].tracks; + assert!(tracks.len() > 1); + + let empty = TrackSelection::initialise(&[]); + assert_eq!(empty.state.selected(), None); + + let mut sel = TrackSelection::initialise(tracks); + assert_eq!(sel.state.selected(), Some(0)); + + sel.decrement(tracks); + assert_eq!(sel.state.selected(), Some(0)); + + sel.increment(tracks); + assert_eq!(sel.state.selected(), Some(1)); + + sel.decrement(tracks); + assert_eq!(sel.state.selected(), Some(0)); + + for _ in 0..(tracks.len() + 5) { + sel.increment(tracks); + } + assert_eq!(sel.state.selected(), Some(tracks.len() - 1)); + + // Re-initialise. + let expected = sel.clone(); + let active_track = ActiveTrack::get(tracks, &sel); + sel.reinitialise(tracks, active_track); + assert_eq!(sel, expected); + + // Re-initialise out-of-bounds. + let mut expected = sel.clone(); + expected.decrement(tracks); + let active_track = ActiveTrack::get(tracks, &sel); + sel.reinitialise(&tracks[..(tracks.len() - 1)], active_track); + assert_eq!(sel, expected); + + // Re-initialise empty. + let expected = TrackSelection::initialise(&[]); + let active_track = ActiveTrack::get(tracks, &sel); + sel.reinitialise(&[], active_track); + assert_eq!(sel, expected); + + // Artifical test case to verify upper limit. + sel.state.select(Some(std::usize::MAX)); + assert_eq!(sel.state.selected(), Some(std::usize::MAX)); + + sel.increment(&[]); + assert_eq!(sel.state.selected(), Some(std::usize::MAX)); + } + + #[test] + fn album_selection() { + let albums = &COLLECTION[0].albums; + assert!(albums.len() > 1); + + let empty = AlbumSelection::initialise(&[]); + assert_eq!(empty.state.selected(), None); + + let mut sel = AlbumSelection::initialise(albums); + assert_eq!(sel.state.selected(), Some(0)); + assert_eq!(sel.track.state.selected(), Some(0)); + + sel.increment_track(albums); + assert_eq!(sel.state.selected(), Some(0)); + assert_eq!(sel.track.state.selected(), Some(1)); + + // Verify that decrement that doesn't change index does not reset track. + sel.decrement(albums); + assert_eq!(sel.state.selected(), Some(0)); + assert_eq!(sel.track.state.selected(), Some(1)); + + sel.increment(albums); + assert_eq!(sel.state.selected(), Some(1)); + assert_eq!(sel.track.state.selected(), Some(0)); + + sel.decrement(albums); + assert_eq!(sel.state.selected(), Some(0)); + assert_eq!(sel.track.state.selected(), Some(0)); + + for _ in 0..(albums.len() + 5) { + sel.increment(albums); + } + assert_eq!(sel.state.selected(), Some(albums.len() - 1)); + assert_eq!(sel.track.state.selected(), Some(0)); + + sel.increment_track(albums); + assert_eq!(sel.state.selected(), Some(albums.len() - 1)); + assert_eq!(sel.track.state.selected(), Some(1)); + + // Verify that increment that doesn't change index does not reset track. + sel.increment(albums); + assert_eq!(sel.state.selected(), Some(albums.len() - 1)); + assert_eq!(sel.track.state.selected(), Some(1)); + + // Re-initialise. + let expected = sel.clone(); + let active_album = ActiveAlbum::get(albums, &sel); + sel.reinitialise(albums, active_album); + assert_eq!(sel, expected); + + // Re-initialise out-of-bounds. + let mut expected = sel.clone(); + expected.decrement(albums); + let active_album = ActiveAlbum::get(albums, &sel); + sel.reinitialise(&albums[..(albums.len() - 1)], active_album); + assert_eq!(sel, expected); + + // Re-initialise empty. + let expected = AlbumSelection::initialise(&[]); + let active_album = ActiveAlbum::get(albums, &sel); + sel.reinitialise(&[], active_album); + assert_eq!(sel, expected); + + // Artifical test case to verify upper limit. + sel.state.select(Some(std::usize::MAX)); + sel.track.state.select(Some(1)); + assert_eq!(sel.state.selected(), Some(std::usize::MAX)); + assert_eq!(sel.track.state.selected(), Some(1)); + + sel.increment(&[]); + assert_eq!(sel.state.selected(), Some(std::usize::MAX)); + assert_eq!(sel.track.state.selected(), Some(1)); + } + + #[test] + fn artist_selection() { + let artists = &COLLECTION; + assert!(artists.len() > 1); + + let empty = ArtistSelection::initialise(&[]); + assert_eq!(empty.state.selected(), None); + + let mut sel = ArtistSelection::initialise(artists); + assert_eq!(sel.state.selected(), Some(0)); + assert_eq!(sel.album.state.selected(), Some(0)); + + sel.increment_album(artists); + assert_eq!(sel.state.selected(), Some(0)); + assert_eq!(sel.album.state.selected(), Some(1)); + + // Verify that decrement that doesn't change index does not reset album. + sel.decrement(artists); + assert_eq!(sel.state.selected(), Some(0)); + assert_eq!(sel.album.state.selected(), Some(1)); + + sel.increment(artists); + assert_eq!(sel.state.selected(), Some(1)); + assert_eq!(sel.album.state.selected(), Some(0)); + + sel.decrement(artists); + assert_eq!(sel.state.selected(), Some(0)); + assert_eq!(sel.album.state.selected(), Some(0)); + + for _ in 0..(artists.len() + 5) { + sel.increment(artists); + } + assert_eq!(sel.state.selected(), Some(artists.len() - 1)); + assert_eq!(sel.album.state.selected(), Some(0)); + + sel.increment_album(artists); + assert_eq!(sel.state.selected(), Some(artists.len() - 1)); + assert_eq!(sel.album.state.selected(), Some(1)); + + // Verify that increment that doesn't change index does not reset album. + sel.increment(artists); + assert_eq!(sel.state.selected(), Some(artists.len() - 1)); + assert_eq!(sel.album.state.selected(), Some(1)); + + // Re-initialise. + let expected = sel.clone(); + let active_artist = ActiveArtist::get(artists, &sel); + sel.reinitialise(artists, active_artist); + assert_eq!(sel, expected); + + // Re-initialise out-of-bounds. + let mut expected = sel.clone(); + expected.decrement(artists); + let active_artist = ActiveArtist::get(artists, &sel); + sel.reinitialise(&artists[..(artists.len() - 1)], active_artist); + assert_eq!(sel, expected); + + // Re-initialise empty. + let expected = ArtistSelection::initialise(&[]); + let active_artist = ActiveArtist::get(artists, &sel); + sel.reinitialise(&[], active_artist); + assert_eq!(sel, expected); + + // Artifical test case to verify upper limit. + sel.state.select(Some(std::usize::MAX)); + sel.album.state.select(Some(1)); + assert_eq!(sel.state.selected(), Some(std::usize::MAX)); + assert_eq!(sel.album.state.selected(), Some(1)); + + sel.increment(&[]); + assert_eq!(sel.state.selected(), Some(std::usize::MAX)); + assert_eq!(sel.album.state.selected(), Some(1)); + } +} diff --git a/src/tui/event.rs b/src/tui/event.rs index b05da5f..494357f 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -2,14 +2,11 @@ use crossterm::event::{KeyEvent, MouseEvent}; use std::fmt; use std::sync::mpsc; -use crate::tui::ui::UiError; - #[derive(Debug)] pub enum EventError { Send(Event), Recv, Io(std::io::Error), - Ui(String), } impl fmt::Display for EventError { @@ -20,9 +17,6 @@ impl fmt::Display for EventError { Self::Io(ref e) => { write!(f, "an I/O error was triggered during event handling: {e}") } - Self::Ui(ref s) => { - write!(f, "the UI returned an error during event handling: {s}") - } } } } @@ -39,12 +33,6 @@ impl From for EventError { } } -impl From for EventError { - fn from(err: UiError) -> EventError { - EventError::Ui(err.to_string()) - } -} - #[derive(Clone, Copy, Debug)] pub enum Event { Key(KeyEvent), @@ -102,8 +90,6 @@ mod tests { use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; - use crate::tui::ui::UiError; - use super::*; #[test] @@ -147,16 +133,13 @@ mod tests { })); let recv_err = EventError::Recv; let io_err = EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "interrupted")); - let ui_err: EventError = UiError::Lib(String::from("lib error")).into(); assert!(!send_err.to_string().is_empty()); assert!(!recv_err.to_string().is_empty()); assert!(!io_err.to_string().is_empty()); - assert!(!ui_err.to_string().is_empty()); assert!(!format!("{:?}", send_err).is_empty()); assert!(!format!("{:?}", recv_err).is_empty()); assert!(!format!("{:?}", io_err).is_empty()); - assert!(!format!("{:?}", ui_err).is_empty()); } } diff --git a/src/tui/handler.rs b/src/tui/handler.rs index 2c65098..226d00c 100644 --- a/src/tui/handler.rs +++ b/src/tui/handler.rs @@ -4,18 +4,24 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use mockall::automock; use crate::tui::{ + app::app::{ + AppState, IAppInteract, IAppInteractBrowse, IAppInteractError, IAppInteractInfo, + IAppInteractReload, + }, event::{Event, EventError, EventReceiver}, - ui::IUi, }; #[cfg_attr(test, automock)] -pub trait IEventHandler { - fn handle_next_event(&self, ui: &mut UI) -> Result<(), EventError>; +pub trait IEventHandler { + fn handle_next_event(&self, app: &mut APP) -> Result<(), EventError>; } -trait IEventHandlerPrivate { - fn handle_key_event(ui: &mut UI, key_event: KeyEvent) -> Result<(), EventError>; - fn quit(ui: &mut UI) -> Result<(), EventError>; +trait IEventHandlerPrivate { + fn handle_key_event(app: &mut APP, key_event: KeyEvent); + fn handle_browse_key_event(app: &mut ::BS, key_event: KeyEvent); + fn handle_info_key_event(app: &mut ::IS, key_event: KeyEvent); + fn handle_reload_key_event(app: &mut ::RS, key_event: KeyEvent); + fn handle_error_key_event(app: &mut ::ES, key_event: KeyEvent); } pub struct EventHandler { @@ -29,10 +35,10 @@ impl EventHandler { } } -impl IEventHandler for EventHandler { - fn handle_next_event(&self, ui: &mut UI) -> Result<(), EventError> { +impl IEventHandler for EventHandler { + fn handle_next_event(&self, app: &mut APP) -> Result<(), EventError> { match self.events.recv()? { - Event::Key(key_event) => Self::handle_key_event(ui, key_event)?, + Event::Key(key_event) => Self::handle_key_event(app, key_event), Event::Mouse(_) => {} Event::Resize(_, _) => {} }; @@ -40,48 +46,78 @@ impl IEventHandler for EventHandler { } } -impl IEventHandlerPrivate for EventHandler { - fn handle_key_event(ui: &mut UI, key_event: KeyEvent) -> Result<(), EventError> { +impl IEventHandlerPrivate for EventHandler { + fn handle_key_event(app: &mut APP, key_event: KeyEvent) { match key_event.code { // Exit application on `ESC` or `q`. KeyCode::Esc | KeyCode::Char('q') => { - Self::quit(ui)?; + app.save(); + app.quit(); } // Exit application on `Ctrl-C`. KeyCode::Char('c') | KeyCode::Char('C') => { if key_event.modifiers == KeyModifiers::CONTROL { - Self::quit(ui)?; + app.force_quit(); } } - // Category change. - KeyCode::Left => { - ui.decrement_category(); - } - KeyCode::Right => { - ui.increment_category(); - } - // Selection change. - KeyCode::Up => { - ui.decrement_selection(); - } - KeyCode::Down => { - ui.increment_selection(); - } - // Toggle overlay. - KeyCode::Char('m') | KeyCode::Char('M') => { - ui.toggle_overlay(); - } - // Other keys. - _ => {} + _ => match app.state() { + AppState::Browse(browse) => { + >::handle_browse_key_event(browse, key_event); + } + AppState::Info(info) => { + >::handle_info_key_event(info, key_event); + } + AppState::Reload(reload) => { + >::handle_reload_key_event(reload, key_event); + } + AppState::Error(error) => { + >::handle_error_key_event(error, key_event); + } + }, } - - Ok(()) } - fn quit(ui: &mut UI) -> Result<(), EventError> { - ui.quit(); - ui.save()?; - Ok(()) + fn handle_browse_key_event(app: &mut ::BS, key_event: KeyEvent) { + match key_event.code { + // Category change. + KeyCode::Left => app.decrement_category(), + KeyCode::Right => app.increment_category(), + // Selection change. + KeyCode::Up => app.decrement_selection(), + KeyCode::Down => app.increment_selection(), + // Toggle overlay. + KeyCode::Char('m') | KeyCode::Char('M') => app.show_info_overlay(), + // Toggle Reload + KeyCode::Char('g') | KeyCode::Char('G') => app.show_reload_menu(), + // Othey keys. + _ => {} + } + } + + fn handle_info_key_event(app: &mut ::IS, key_event: KeyEvent) { + match key_event.code { + // Toggle overlay. + KeyCode::Char('m') | KeyCode::Char('M') => app.hide_info_overlay(), + // Othey keys. + _ => {} + } + } + + fn handle_reload_key_event(app: &mut ::RS, key_event: KeyEvent) { + match key_event.code { + // Reload keys. + KeyCode::Char('l') | KeyCode::Char('L') => app.reload_library(), + KeyCode::Char('d') | KeyCode::Char('D') => app.reload_database(), + // Return. + KeyCode::Char('g') | KeyCode::Char('G') => app.hide_reload_menu(), + // Othey keys. + _ => {} + } + } + + fn handle_error_key_event(app: &mut ::ES, _key_event: KeyEvent) { + // Any key dismisses the error. + app.dismiss_error(); } } // GRCOV_EXCL_STOP diff --git a/src/tui/mod.rs b/src/tui/mod.rs index c4584da..1b4b663 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,9 +1,15 @@ -pub mod event; -pub mod handler; -pub mod listener; -pub mod ui; - +mod app; +mod event; +mod handler; mod lib; +mod listener; +mod ui; + +pub use app::app::App; +pub use event::EventChannel; +pub use handler::EventHandler; +pub use listener::EventListener; +pub use ui::Ui; use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; @@ -12,25 +18,21 @@ use ratatui::Terminal; use std::io; use std::marker::PhantomData; -use self::event::EventError; -use self::handler::IEventHandler; -use self::listener::IEventListener; -use self::ui::IUi; +use crate::tui::{ + app::app::{IAppAccess, IAppInteract}, + event::EventError, + handler::IEventHandler, + listener::IEventListener, + ui::IUi, +}; #[derive(Debug, PartialEq, Eq)] pub enum Error { - Lib(String), Io(String), Event(String), ListenerPanic, } -impl From for Error { - fn from(err: musichoard::Error) -> Error { - Error::Lib(err.to_string()) - } -} - impl From for Error { fn from(err: io::Error) -> Error { Error::Io(err.to_string()) @@ -43,12 +45,12 @@ impl From for Error { } } -pub struct Tui { +pub struct Tui { terminal: Terminal, - _phantom: PhantomData, + _phantom: PhantomData<(UI, APP)>, } -impl Tui { +impl Tui { fn init(&mut self) -> Result<(), Error> { self.terminal.hide_cursor()?; self.terminal.clear()?; @@ -65,10 +67,15 @@ impl Tui { self.exit(); } - fn main_loop(&mut self, mut ui: UI, handler: impl IEventHandler) -> Result<(), Error> { - while ui.is_running() { - self.terminal.draw(|frame| ui.render(frame))?; - handler.handle_next_event(&mut ui)?; + fn main_loop( + &mut self, + mut app: APP, + _ui: UI, + handler: impl IEventHandler, + ) -> Result<(), Error> { + while app.is_running() { + self.terminal.draw(|frame| UI::render(&mut app, frame))?; + handler.handle_next_event(&mut app)?; } Ok(()) @@ -76,8 +83,9 @@ impl Tui { fn main( term: Terminal, + app: APP, ui: UI, - handler: impl IEventHandler, + handler: impl IEventHandler, listener: impl IEventListener, ) -> Result<(), Error> { let mut tui = Tui { @@ -88,7 +96,7 @@ impl Tui { tui.init()?; let listener_handle = listener.spawn(); - let result = tui.main_loop(ui, handler); + let result = tui.main_loop(app, ui, handler); match result { Ok(_) => { @@ -135,12 +143,13 @@ impl Tui { pub fn run( term: Terminal, + app: APP, ui: UI, - handler: impl IEventHandler, + handler: impl IEventHandler, listener: impl IEventListener, ) -> Result<(), Error> { Self::enable()?; - let result = Self::main(term, ui, handler, listener); + let result = Self::main(term, app, ui, handler, listener); match result { Ok(_) => { Self::disable()?; @@ -169,7 +178,8 @@ mod tests { use musichoard::collection::Collection; use crate::tui::{ - handler::MockIEventHandler, lib::MockIMusicHoard, listener::MockIEventListener, ui::Ui, + app::app::App, handler::MockIEventHandler, lib::MockIMusicHoard, + listener::MockIEventListener, ui::Ui, }; use super::*; @@ -180,7 +190,7 @@ mod tests { Terminal::new(backend).unwrap() } - pub fn music_hoard(collection: Collection) -> MockIMusicHoard { + fn music_hoard(collection: Collection) -> MockIMusicHoard { let mut music_hoard = MockIMusicHoard::new(); music_hoard.expect_load_from_database().returning(|| Ok(())); @@ -190,8 +200,8 @@ mod tests { music_hoard } - pub fn ui(collection: Collection) -> Ui { - Ui::new(music_hoard(collection)).unwrap() + fn app(collection: Collection) -> App { + App::new(music_hoard(collection)) } fn listener() -> MockIEventListener { @@ -205,12 +215,12 @@ mod tests { listener } - fn handler() -> MockIEventHandler> { + fn handler() -> MockIEventHandler> { let mut handler = MockIEventHandler::new(); handler .expect_handle_next_event() - .return_once(|ui: &mut Ui| { - ui.quit(); + .return_once(|app: &mut App| { + app.quit(); Ok(()) }); handler @@ -219,19 +229,21 @@ mod tests { #[test] fn run() { let terminal = terminal(); - let ui = ui(COLLECTION.to_owned()); + let app = app(COLLECTION.to_owned()); + let ui = Ui; let listener = listener(); let handler = handler(); - let result = Tui::main(terminal, ui, handler, listener); + let result = Tui::main(terminal, app, ui, handler, listener); assert!(result.is_ok()); } #[test] fn event_error() { let terminal = terminal(); - let ui = ui(COLLECTION.to_owned()); + let app = app(COLLECTION.to_owned()); + let ui = Ui; let listener = listener(); @@ -240,7 +252,7 @@ mod tests { .expect_handle_next_event() .return_once(|_| Err(EventError::Recv)); - let result = Tui::main(terminal, ui, handler, listener); + let result = Tui::main(terminal, app, ui, handler, listener); assert!(result.is_err()); assert_eq!( result.unwrap_err(), @@ -251,7 +263,8 @@ mod tests { #[test] fn listener_error() { let terminal = terminal(); - let ui = ui(COLLECTION.to_owned()); + 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 = thread::spawn(|| error); @@ -265,7 +278,7 @@ mod tests { .expect_handle_next_event() .return_once(|_| Err(EventError::Recv)); - let result = Tui::main(terminal, ui, handler, listener); + let result = Tui::main(terminal, app, ui, handler, listener); assert!(result.is_err()); let error = EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "error")); @@ -275,7 +288,8 @@ mod tests { #[test] fn listener_panic() { let terminal = terminal(); - let ui = ui(COLLECTION.to_owned()); + let app = app(COLLECTION.to_owned()); + let ui = Ui; let listener_handle: thread::JoinHandle = thread::spawn(|| panic!()); while !listener_handle.is_finished() {} @@ -288,19 +302,17 @@ mod tests { .expect_handle_next_event() .return_once(|_| Err(EventError::Recv)); - let result = Tui::main(terminal, ui, handler, listener); + let result = Tui::main(terminal, app, ui, handler, listener); assert!(result.is_err()); assert_eq!(result.unwrap_err(), Error::ListenerPanic); } #[test] fn errors() { - let lib_err: Error = musichoard::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!("{:?}", lib_err).is_empty()); assert!(!format!("{:?}", io_err).is_empty()); assert!(!format!("{:?}", event_err).is_empty()); assert!(!format!("{:?}", listener_err).is_empty()); diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 826a184..5265afa 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -1,5 +1,3 @@ -use std::fmt; - use musichoard::collection::{ album::Album, artist::Artist, @@ -10,279 +8,17 @@ use ratatui::{ backend::Backend, layout::{Alignment, Rect}, style::{Color, Style}, - widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph}, + widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}, Frame, }; -use crate::tui::{lib::IMusicHoard, Error}; - -#[derive(Debug)] -pub enum UiError { - Lib(String), -} - -impl fmt::Display for UiError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match *self { - Self::Lib(ref s) => write!(f, "the musichoard library returned an error: {s}"), - } - } -} - -impl From for UiError { - fn from(err: musichoard::Error) -> UiError { - UiError::Lib(err.to_string()) - } -} +use crate::tui::app::{ + app::{AppState, IAppAccess}, + selection::{Category, Selection}, +}; pub trait IUi { - fn is_running(&self) -> bool; - fn quit(&mut self); - - fn save(&mut self) -> Result<(), UiError>; - - fn increment_category(&mut self); - fn decrement_category(&mut self); - - fn increment_selection(&mut self); - fn decrement_selection(&mut self); - - fn toggle_overlay(&mut self); - fn render(&mut self, frame: &mut Frame<'_, B>); -} - -struct TrackSelection { - state: ListState, -} - -struct AlbumSelection { - state: ListState, - track: TrackSelection, -} - -struct ArtistSelection { - state: ListState, - album: AlbumSelection, -} - -impl TrackSelection { - fn initialise(tracks: Option<&[Track]>) -> Self { - let mut state = ListState::default(); - if let Some(tracks) = tracks { - state.select(if !tracks.is_empty() { Some(0) } else { None }); - } else { - state.select(None); - }; - TrackSelection { state } - } - - fn increment(&mut self, tracks: &[Track]) { - if let Some(index) = self.state.selected() { - if let Some(result) = index.checked_add(1) { - if result < tracks.len() { - self.state.select(Some(result)); - } - } - } - } - - fn decrement(&mut self, _tracks: &[Track]) { - if let Some(index) = self.state.selected() { - if let Some(result) = index.checked_sub(1) { - self.state.select(Some(result)); - } - } - } -} - -impl AlbumSelection { - fn initialise(albums: Option<&[Album]>) -> Self { - let mut state = ListState::default(); - let track: TrackSelection; - if let Some(albums) = albums { - state.select(if !albums.is_empty() { Some(0) } else { None }); - track = TrackSelection::initialise(albums.first().map(|a| a.tracks.as_slice())); - } else { - state.select(None); - track = TrackSelection::initialise(None); - } - AlbumSelection { state, track } - } - - fn increment(&mut self, albums: &[Album]) { - if let Some(index) = self.state.selected() { - if let Some(result) = index.checked_add(1) { - if result < albums.len() { - self.state.select(Some(result)); - self.track = TrackSelection::initialise(Some(&albums[result].tracks)); - } - } - } - } - - fn increment_track(&mut self, albums: &[Album]) { - if let Some(index) = self.state.selected() { - self.track.increment(&albums[index].tracks); - } - } - - fn decrement(&mut self, albums: &[Album]) { - if let Some(index) = self.state.selected() { - if let Some(result) = index.checked_sub(1) { - self.state.select(Some(result)); - self.track = TrackSelection::initialise(Some(&albums[result].tracks)); - } - } - } - - fn decrement_track(&mut self, albums: &[Album]) { - if let Some(index) = self.state.selected() { - self.track.decrement(&albums[index].tracks); - } - } -} - -impl ArtistSelection { - fn initialise(artists: Option<&[Artist]>) -> Self { - let mut state = ListState::default(); - let album: AlbumSelection; - if let Some(artists) = artists { - state.select(if !artists.is_empty() { Some(0) } else { None }); - album = AlbumSelection::initialise(artists.first().map(|a| a.albums.as_slice())); - } else { - state.select(None); - album = AlbumSelection::initialise(None); - } - ArtistSelection { state, album } - } - - fn increment(&mut self, artists: &[Artist]) { - if let Some(index) = self.state.selected() { - if let Some(result) = index.checked_add(1) { - if result < artists.len() { - self.state.select(Some(result)); - self.album = AlbumSelection::initialise(Some(&artists[result].albums)); - } - } - } - } - - fn increment_album(&mut self, artists: &[Artist]) { - if let Some(index) = self.state.selected() { - self.album.increment(&artists[index].albums); - } - } - - fn increment_track(&mut self, artists: &[Artist]) { - if let Some(index) = self.state.selected() { - self.album.increment_track(&artists[index].albums); - } - } - - fn decrement(&mut self, artists: &[Artist]) { - if let Some(index) = self.state.selected() { - if let Some(result) = index.checked_sub(1) { - self.state.select(Some(result)); - self.album = AlbumSelection::initialise(Some(&artists[result].albums)); - } - } - } - - fn decrement_album(&mut self, artists: &[Artist]) { - if let Some(index) = self.state.selected() { - self.album.decrement(&artists[index].albums); - } - } - - fn decrement_track(&mut self, artists: &[Artist]) { - if let Some(index) = self.state.selected() { - self.album.decrement_track(&artists[index].albums); - } - } -} - -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -pub enum Category { - Artist, - Album, - Track, -} - -struct Selection { - active: Category, - artist: ArtistSelection, -} - -impl Selection { - fn new(artists: Option<&[Artist]>) -> Self { - Selection { - active: Category::Artist, - artist: ArtistSelection::initialise(artists), - } - } - - fn increment_category(&mut self) { - self.active = match self.active { - Category::Artist => Category::Album, - Category::Album => Category::Track, - Category::Track => Category::Track, - }; - } - - fn decrement_category(&mut self) { - self.active = match self.active { - Category::Artist => Category::Artist, - Category::Album => Category::Artist, - Category::Track => Category::Album, - }; - } - - fn increment_selection(&mut self, collection: &Collection) { - match self.active { - Category::Artist => self.increment_artist(collection), - Category::Album => self.increment_album(collection), - Category::Track => self.increment_track(collection), - } - } - - fn decrement_selection(&mut self, collection: &Collection) { - match self.active { - Category::Artist => self.decrement_artist(collection), - Category::Album => self.decrement_album(collection), - Category::Track => self.decrement_track(collection), - } - } - - fn increment_artist(&mut self, artists: &[Artist]) { - self.artist.increment(artists); - } - - fn decrement_artist(&mut self, artists: &[Artist]) { - self.artist.decrement(artists); - } - - fn increment_album(&mut self, artists: &[Artist]) { - self.artist.increment_album(artists); - } - - fn decrement_album(&mut self, artists: &[Artist]) { - self.artist.decrement_album(artists); - } - - fn increment_track(&mut self, artists: &[Artist]) { - self.artist.increment_track(artists); - } - - fn decrement_track(&mut self, artists: &[Artist]) { - self.artist.decrement_track(artists); - } -} - -pub struct Ui { - music_hoard: MH, - selection: Selection, - overlay: bool, - running: bool, + fn render(app: &mut APP, frame: &mut Frame<'_, B>); } struct ArtistArea { @@ -364,25 +100,59 @@ impl FrameArea { } } -struct OverlayArea { - artist: Rect, +enum OverlaySize { + MarginFactor(u16), + Value(u16), } -impl OverlayArea { - fn new(frame: Rect) -> Self { - let margin_factor = 8; +impl Default for OverlaySize { + fn default() -> Self { + OverlaySize::MarginFactor(8) + } +} - let width_margin = frame.width / margin_factor; - let height_margin = frame.height / margin_factor; +impl OverlaySize { + fn get(&self, full: u16) -> (u16, u16) { + match self { + OverlaySize::MarginFactor(margin_factor) => { + let margin = full / margin_factor; + (margin, full - (2 * margin)) + } + OverlaySize::Value(value) => { + let margin = (full - value) / 2; + (margin, *value) + } + } + } +} - let artist = Rect { - x: width_margin, - y: height_margin, - width: frame.width - (2 * width_margin), - height: frame.height - (2 * height_margin), - }; +#[derive(Default)] +struct OverlayBuilder { + width: OverlaySize, + height: OverlaySize, +} - OverlayArea { artist } +impl OverlayBuilder { + fn with_width(mut self, width: OverlaySize) -> OverlayBuilder { + self.width = width; + self + } + + fn with_height(mut self, height: OverlaySize) -> OverlayBuilder { + self.height = height; + self + } + + fn build(self, frame: Rect) -> Rect { + let (x, width) = self.width.get(frame.width); + let (y, height) = self.height.get(frame.height); + + Rect { + x, + y, + width, + height, + } } } @@ -536,41 +306,36 @@ impl<'a, 'b> TrackState<'a, 'b> { } } -impl Ui { - pub fn new(mut music_hoard: MH) -> Result { - music_hoard.load_from_database()?; - music_hoard.rescan_library()?; - let selection = Selection::new(Some(music_hoard.get_collection())); - Ok(Ui { - music_hoard, - selection, - overlay: false, - running: true, - }) +pub struct Ui; + +impl Ui { + fn style(_active: bool, error: bool) -> Style { + let style = Style::default().bg(Color::Black); + if error { + style.fg(Color::Red) + } else { + style.fg(Color::White) + } } - fn style(_active: bool) -> Style { - Style::default().fg(Color::White).bg(Color::Black) - } - - fn block_style(active: bool) -> Style { - Self::style(active) + fn block_style(active: bool, error: bool) -> Style { + Self::style(active, error) } fn highlight_style(active: bool) -> Style { if active { Style::default().fg(Color::White).bg(Color::DarkGray) } else { - Self::style(false) + Self::style(false, false) } } - fn block<'a>(title: &str, active: bool) -> Block<'a> { + fn block<'a>(title: &str, active: bool, error: bool) -> Block<'a> { Block::default() .title_alignment(Alignment::Center) .borders(Borders::ALL) .border_type(BorderType::Rounded) - .style(Self::block_style(active)) + .style(Self::block_style(active, error)) .title(format!(" {title} ")) } @@ -585,8 +350,8 @@ impl Ui { frame.render_stateful_widget( list.highlight_style(Self::highlight_style(active)) .highlight_symbol(">> ") - .style(Self::style(active)) - .block(Self::block(title, active)), + .style(Self::style(active, false)) + .block(Self::block(title, active, false)), area, list_state, ); @@ -601,8 +366,8 @@ impl Ui { ) { frame.render_widget( paragraph - .style(Self::style(active)) - .block(Self::block(title, active)), + .style(Self::style(active, false)) + .block(Self::block(title, active, false)), area, ); } @@ -611,13 +376,14 @@ impl Ui { title: &str, paragraph: Paragraph, area: Rect, + error: bool, frame: &mut Frame<'_, B>, ) { frame.render_widget(Clear, area); frame.render_widget( paragraph - .style(Self::style(true)) - .block(Self::block(title, true)), + .style(Self::style(true, error)) + .block(Self::block(title, true, error)), area, ); } @@ -636,12 +402,15 @@ impl Ui { Self::render_info_widget("Track info", st.info, st.active, ar.info, fr); } - fn render_collection(&mut self, frame: &mut Frame<'_, B>) { - let active = self.selection.active; + fn render_collection( + artists: &Collection, + selection: &mut Selection, + frame: &mut Frame<'_, B>, + ) { + let active = selection.active; let areas = FrameArea::new(frame.size()); - let artists = self.music_hoard.get_collection(); - let artist_selection = &mut self.selection.artist; + let artist_selection = &mut selection.artist; let artist_state = ArtistState::new( active == Category::Artist, artists, @@ -681,524 +450,129 @@ impl Ui { Self::render_track_column(track_state, areas.track, frame); } - fn render_overlay(&mut self, frame: &mut Frame<'_, B>) { - let areas = OverlayArea::new(frame.size()); - - let artists = self.music_hoard.get_collection(); - let artist_selection = &mut self.selection.artist; + fn render_info_overlay( + artists: &Collection, + selection: &mut Selection, + frame: &mut Frame<'_, B>, + ) { + let area = OverlayBuilder::default().build(frame.size()); + let artist_selection = &mut selection.artist; let artist_overlay = ArtistOverlay::new(artists, &artist_selection.state); - Self::render_overlay_widget("Artist", artist_overlay.properties, areas.artist, frame); + + Self::render_overlay_widget("Artist", artist_overlay.properties, area, false, frame); + } + + fn render_reload_overlay(frame: &mut Frame<'_, B>) { + let area = OverlayBuilder::default() + .with_width(OverlaySize::Value(39)) + .with_height(OverlaySize::Value(4)) + .build(frame.size()); + + let reload_text = Paragraph::new( + "d: database\n\ + l: library", + ) + .alignment(Alignment::Center); + + Self::render_overlay_widget("Reload", reload_text, area, false, frame); + } + + fn render_error_overlay, B: Backend>(msg: S, frame: &mut Frame<'_, B>) { + let area = OverlayBuilder::default() + .with_height(OverlaySize::Value(4)) + .build(frame.size()); + + let error_text = Paragraph::new(msg.as_ref()) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + + Self::render_overlay_widget("Error", error_text, area, true, frame); } } -impl IUi for Ui { - fn is_running(&self) -> bool { - self.running - } +impl IUi for Ui { + fn render(app: &mut APP, frame: &mut Frame<'_, B>) { + let app = app.get(); - fn quit(&mut self) { - self.running = false; - } + let collection = app.collection; + let selection = app.selection; - fn save(&mut self) -> Result<(), UiError> { - self.music_hoard.save_to_database()?; - Ok(()) - } - - fn increment_category(&mut self) { - self.selection.increment_category(); - } - - fn decrement_category(&mut self) { - self.selection.decrement_category(); - } - - fn increment_selection(&mut self) { - self.selection - .increment_selection(self.music_hoard.get_collection()); - } - - fn decrement_selection(&mut self) { - self.selection - .decrement_selection(self.music_hoard.get_collection()); - } - - fn toggle_overlay(&mut self) { - self.overlay = !self.overlay; - } - - fn render(&mut self, frame: &mut Frame<'_, B>) { - self.render_collection(frame); - if self.overlay { - self.render_overlay(frame); + Self::render_collection(collection, selection, frame); + match app.state { + AppState::Info(_) => Self::render_info_overlay(collection, selection, frame), + AppState::Reload(_) => Self::render_reload_overlay(frame), + AppState::Error(ref msg) => Self::render_error_overlay(msg, frame), + _ => {} } } } #[cfg(test)] mod tests { - use crate::tui::lib::MockIMusicHoard; - use crate::tui::testmod::COLLECTION; - use crate::tui::tests::{terminal, ui}; + use crate::tui::{app::app::AppPublic, testmod::COLLECTION, tests::terminal}; use super::*; - pub fn music_hoard(collection: Collection) -> MockIMusicHoard { - let mut music_hoard = MockIMusicHoard::new(); - - music_hoard - .expect_load_from_database() - .times(1) - .return_once(|| Ok(())); - music_hoard - .expect_rescan_library() - .times(1) - .return_once(|| Ok(())); - music_hoard.expect_get_collection().return_const(collection); - - music_hoard - } - - #[test] - fn test_track_selection() { - let tracks = &COLLECTION[0].albums[0].tracks; - assert!(tracks.len() > 1); - - let empty = TrackSelection::initialise(None); - assert_eq!(empty.state.selected(), None); - - let empty = TrackSelection::initialise(Some(&[])); - assert_eq!(empty.state.selected(), None); - - let mut sel = TrackSelection::initialise(Some(tracks)); - assert_eq!(sel.state.selected(), Some(0)); - - sel.decrement(tracks); - assert_eq!(sel.state.selected(), Some(0)); - - sel.increment(tracks); - assert_eq!(sel.state.selected(), Some(1)); - - sel.decrement(tracks); - assert_eq!(sel.state.selected(), Some(0)); - - for _ in 0..(tracks.len() + 5) { - sel.increment(tracks); + // Automock does not support returning types with generic lifetimes. + impl IAppAccess for AppPublic<'_> { + fn get(&mut self) -> AppPublic { + AppPublic { + collection: self.collection, + selection: self.selection, + state: self.state, + } } - assert_eq!(sel.state.selected(), Some(tracks.len() - 1)); - - // Artifical test case to verify upper limit. - sel.state.select(Some(std::usize::MAX)); - assert_eq!(sel.state.selected(), Some(std::usize::MAX)); - - sel.increment(&[]); - assert_eq!(sel.state.selected(), Some(std::usize::MAX)); } - #[test] - fn test_album_selection() { - let albums = &COLLECTION[0].albums; - assert!(albums.len() > 1); + fn draw_test_suite(collection: &Collection, selection: &mut Selection) { + let mut terminal = terminal(); - let empty = AlbumSelection::initialise(None); - assert_eq!(empty.state.selected(), None); + let mut app = AppPublic { + collection, + selection, + state: &AppState::Browse(()), + }; + terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); - let empty = AlbumSelection::initialise(Some(&[])); - assert_eq!(empty.state.selected(), None); + app.state = &AppState::Info(()); + terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); - let mut sel = AlbumSelection::initialise(Some(albums)); - assert_eq!(sel.state.selected(), Some(0)); - assert_eq!(sel.track.state.selected(), Some(0)); + app.state = &AppState::Reload(()); + terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); - sel.increment_track(albums); - assert_eq!(sel.state.selected(), Some(0)); - assert_eq!(sel.track.state.selected(), Some(1)); - - // Verify that decrement that doesn't change index does not reset track. - sel.decrement(albums); - assert_eq!(sel.state.selected(), Some(0)); - assert_eq!(sel.track.state.selected(), Some(1)); - - sel.increment(albums); - assert_eq!(sel.state.selected(), Some(1)); - assert_eq!(sel.track.state.selected(), Some(0)); - - sel.decrement(albums); - assert_eq!(sel.state.selected(), Some(0)); - assert_eq!(sel.track.state.selected(), Some(0)); - - for _ in 0..(albums.len() + 5) { - sel.increment(albums); - } - assert_eq!(sel.state.selected(), Some(albums.len() - 1)); - assert_eq!(sel.track.state.selected(), Some(0)); - - sel.increment_track(albums); - assert_eq!(sel.state.selected(), Some(albums.len() - 1)); - assert_eq!(sel.track.state.selected(), Some(1)); - - // Verify that increment that doesn't change index does not reset track. - sel.increment(albums); - assert_eq!(sel.state.selected(), Some(albums.len() - 1)); - assert_eq!(sel.track.state.selected(), Some(1)); - - // Artifical test case to verify upper limit. - sel.state.select(Some(std::usize::MAX)); - sel.track.state.select(Some(1)); - assert_eq!(sel.state.selected(), Some(std::usize::MAX)); - assert_eq!(sel.track.state.selected(), Some(1)); - - sel.increment(&[]); - assert_eq!(sel.state.selected(), Some(std::usize::MAX)); - assert_eq!(sel.track.state.selected(), Some(1)); + let binding = AppState::Error(String::from("get rekt scrub")); + app.state = &binding; + terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); } - #[test] - fn test_artist_selection() { - let artists = &COLLECTION; - assert!(artists.len() > 1); - - let empty = ArtistSelection::initialise(None); - assert_eq!(empty.state.selected(), None); - - let empty = ArtistSelection::initialise(Some(&[])); - assert_eq!(empty.state.selected(), None); - - let mut sel = ArtistSelection::initialise(Some(artists)); - assert_eq!(sel.state.selected(), Some(0)); - assert_eq!(sel.album.state.selected(), Some(0)); - - sel.increment_album(artists); - assert_eq!(sel.state.selected(), Some(0)); - assert_eq!(sel.album.state.selected(), Some(1)); - - // Verify that decrement that doesn't change index does not reset album. - sel.decrement(artists); - assert_eq!(sel.state.selected(), Some(0)); - assert_eq!(sel.album.state.selected(), Some(1)); - - sel.increment(artists); - assert_eq!(sel.state.selected(), Some(1)); - assert_eq!(sel.album.state.selected(), Some(0)); - - sel.decrement(artists); - assert_eq!(sel.state.selected(), Some(0)); - assert_eq!(sel.album.state.selected(), Some(0)); - - for _ in 0..(artists.len() + 5) { - sel.increment(artists); - } - assert_eq!(sel.state.selected(), Some(artists.len() - 1)); - assert_eq!(sel.album.state.selected(), Some(0)); - - sel.increment_album(artists); - assert_eq!(sel.state.selected(), Some(artists.len() - 1)); - assert_eq!(sel.album.state.selected(), Some(1)); - - // Verify that increment that doesn't change index does not reset album. - sel.increment(artists); - assert_eq!(sel.state.selected(), Some(artists.len() - 1)); - assert_eq!(sel.album.state.selected(), Some(1)); - - // Artifical test case to verify upper limit. - sel.state.select(Some(std::usize::MAX)); - sel.album.state.select(Some(1)); - assert_eq!(sel.state.selected(), Some(std::usize::MAX)); - assert_eq!(sel.album.state.selected(), Some(1)); - - sel.increment(&[]); - assert_eq!(sel.state.selected(), Some(std::usize::MAX)); - assert_eq!(sel.album.state.selected(), Some(1)); - } - - #[test] - fn ui_running() { - let mut ui = Ui::new(music_hoard(COLLECTION.to_owned())).unwrap(); - assert!(ui.is_running()); - - ui.quit(); - assert!(!ui.is_running()); - } - - #[test] - fn ui_save() { - let mut music_hoard = music_hoard(COLLECTION.to_owned()); - - music_hoard - .expect_save_to_database() - .times(1) - .return_once(|| Ok(())); - - let mut ui = Ui::new(music_hoard).unwrap(); - - let result = ui.save(); - assert!(result.is_ok()); - } - - #[test] - fn ui_modifiers() { - let mut ui = Ui::new(music_hoard(COLLECTION.to_owned())).unwrap(); - assert!(ui.is_running()); - - assert_eq!(ui.selection.active, Category::Artist); - assert_eq!(ui.selection.artist.state.selected(), Some(0)); - assert_eq!(ui.selection.artist.album.state.selected(), Some(0)); - assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); - - ui.increment_selection(); - assert_eq!(ui.selection.active, Category::Artist); - assert_eq!(ui.selection.artist.state.selected(), Some(1)); - assert_eq!(ui.selection.artist.album.state.selected(), Some(0)); - assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); - - ui.increment_category(); - assert_eq!(ui.selection.active, Category::Album); - assert_eq!(ui.selection.artist.state.selected(), Some(1)); - assert_eq!(ui.selection.artist.album.state.selected(), Some(0)); - assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); - - ui.increment_selection(); - assert_eq!(ui.selection.active, Category::Album); - assert_eq!(ui.selection.artist.state.selected(), Some(1)); - assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); - assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); - - ui.increment_category(); - assert_eq!(ui.selection.active, Category::Track); - assert_eq!(ui.selection.artist.state.selected(), Some(1)); - assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); - assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); - - ui.increment_selection(); - assert_eq!(ui.selection.active, Category::Track); - assert_eq!(ui.selection.artist.state.selected(), Some(1)); - assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); - assert_eq!(ui.selection.artist.album.track.state.selected(), Some(1)); - - ui.increment_category(); - assert_eq!(ui.selection.active, Category::Track); - assert_eq!(ui.selection.artist.state.selected(), Some(1)); - assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); - assert_eq!(ui.selection.artist.album.track.state.selected(), Some(1)); - - ui.decrement_selection(); - assert_eq!(ui.selection.active, Category::Track); - assert_eq!(ui.selection.artist.state.selected(), Some(1)); - assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); - assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); - - ui.increment_selection(); - ui.decrement_category(); - assert_eq!(ui.selection.active, Category::Album); - assert_eq!(ui.selection.artist.state.selected(), Some(1)); - assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); - assert_eq!(ui.selection.artist.album.track.state.selected(), Some(1)); - - ui.decrement_selection(); - assert_eq!(ui.selection.active, Category::Album); - assert_eq!(ui.selection.artist.state.selected(), Some(1)); - assert_eq!(ui.selection.artist.album.state.selected(), Some(0)); - assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); - - ui.increment_selection(); - ui.decrement_category(); - assert_eq!(ui.selection.active, Category::Artist); - assert_eq!(ui.selection.artist.state.selected(), Some(1)); - assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); - assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); - - ui.decrement_selection(); - assert_eq!(ui.selection.active, Category::Artist); - assert_eq!(ui.selection.artist.state.selected(), Some(0)); - assert_eq!(ui.selection.artist.album.state.selected(), Some(0)); - assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); - - ui.increment_category(); - ui.increment_selection(); - ui.decrement_category(); - ui.decrement_selection(); - ui.decrement_category(); - assert_eq!(ui.selection.active, Category::Artist); - assert_eq!(ui.selection.artist.state.selected(), Some(0)); - assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); - assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); - } - - #[test] - fn app_no_tracks() { - let mut collection = COLLECTION.to_owned(); - collection[0].albums[0].tracks = vec![]; - - let mut app = Ui::new(music_hoard(collection)).unwrap(); - assert!(app.is_running()); - - assert_eq!(app.selection.active, Category::Artist); - assert_eq!(app.selection.artist.state.selected(), Some(0)); - assert_eq!(app.selection.artist.album.state.selected(), Some(0)); - assert_eq!(app.selection.artist.album.track.state.selected(), None); - - app.increment_category(); - app.increment_category(); - - app.increment_selection(); - assert_eq!(app.selection.active, Category::Track); - assert_eq!(app.selection.artist.state.selected(), Some(0)); - assert_eq!(app.selection.artist.album.state.selected(), Some(0)); - assert_eq!(app.selection.artist.album.track.state.selected(), None); - - app.decrement_selection(); - assert_eq!(app.selection.active, Category::Track); - assert_eq!(app.selection.artist.state.selected(), Some(0)); - assert_eq!(app.selection.artist.album.state.selected(), Some(0)); - assert_eq!(app.selection.artist.album.track.state.selected(), None); - } - - #[test] - fn app_no_albums() { - let mut collection = COLLECTION.to_owned(); - collection[0].albums = vec![]; - - let mut app = Ui::new(music_hoard(collection)).unwrap(); - assert!(app.is_running()); - - assert_eq!(app.selection.active, Category::Artist); - assert_eq!(app.selection.artist.state.selected(), Some(0)); - assert_eq!(app.selection.artist.album.state.selected(), None); - assert_eq!(app.selection.artist.album.track.state.selected(), None); - - app.increment_category(); - - app.increment_selection(); - assert_eq!(app.selection.active, Category::Album); - assert_eq!(app.selection.artist.state.selected(), Some(0)); - assert_eq!(app.selection.artist.album.state.selected(), None); - assert_eq!(app.selection.artist.album.track.state.selected(), None); - - app.decrement_selection(); - assert_eq!(app.selection.active, Category::Album); - assert_eq!(app.selection.artist.state.selected(), Some(0)); - assert_eq!(app.selection.artist.album.state.selected(), None); - assert_eq!(app.selection.artist.album.track.state.selected(), None); - - app.increment_category(); - - app.increment_selection(); - assert_eq!(app.selection.active, Category::Track); - assert_eq!(app.selection.artist.state.selected(), Some(0)); - assert_eq!(app.selection.artist.album.state.selected(), None); - assert_eq!(app.selection.artist.album.track.state.selected(), None); - - app.decrement_selection(); - assert_eq!(app.selection.active, Category::Track); - assert_eq!(app.selection.artist.state.selected(), Some(0)); - assert_eq!(app.selection.artist.album.state.selected(), None); - assert_eq!(app.selection.artist.album.track.state.selected(), None); - } - - #[test] - fn app_no_artists() { - let mut app = Ui::new(music_hoard(vec![])).unwrap(); - assert!(app.is_running()); - - assert_eq!(app.selection.active, Category::Artist); - assert_eq!(app.selection.artist.state.selected(), None); - assert_eq!(app.selection.artist.album.state.selected(), None); - assert_eq!(app.selection.artist.album.track.state.selected(), None); - - app.increment_selection(); - assert_eq!(app.selection.active, Category::Artist); - assert_eq!(app.selection.artist.state.selected(), None); - assert_eq!(app.selection.artist.album.state.selected(), None); - assert_eq!(app.selection.artist.album.track.state.selected(), None); - - app.decrement_selection(); - assert_eq!(app.selection.active, Category::Artist); - assert_eq!(app.selection.artist.state.selected(), None); - assert_eq!(app.selection.artist.album.state.selected(), None); - assert_eq!(app.selection.artist.album.track.state.selected(), None); - - app.increment_category(); - - app.increment_selection(); - assert_eq!(app.selection.active, Category::Album); - assert_eq!(app.selection.artist.state.selected(), None); - assert_eq!(app.selection.artist.album.state.selected(), None); - assert_eq!(app.selection.artist.album.track.state.selected(), None); - - app.decrement_selection(); - assert_eq!(app.selection.active, Category::Album); - assert_eq!(app.selection.artist.state.selected(), None); - assert_eq!(app.selection.artist.album.state.selected(), None); - assert_eq!(app.selection.artist.album.track.state.selected(), None); - - app.increment_category(); - - app.increment_selection(); - assert_eq!(app.selection.active, Category::Track); - assert_eq!(app.selection.artist.state.selected(), None); - assert_eq!(app.selection.artist.album.state.selected(), None); - assert_eq!(app.selection.artist.album.track.state.selected(), None); - - app.decrement_selection(); - assert_eq!(app.selection.active, Category::Track); - assert_eq!(app.selection.artist.state.selected(), None); - assert_eq!(app.selection.artist.album.state.selected(), None); - assert_eq!(app.selection.artist.album.track.state.selected(), None); - } - - // This is UI so the only sensible unit test is to run the code through various app states. - #[test] fn empty() { - let mut terminal = terminal(); - let mut ui = ui(vec![]); + let artists: Vec = vec![]; + let mut selection = Selection::new(&artists); - terminal.draw(|frame| ui.render(frame)).unwrap(); + draw_test_suite(&artists, &mut selection); } #[test] fn collection() { - let mut terminal = terminal(); - let mut ui = ui(COLLECTION.to_owned()); + let artists = &COLLECTION; + let mut selection = Selection::new(artists); - terminal.draw(|frame| ui.render(frame)).unwrap(); + draw_test_suite(artists, &mut selection); // Change the track (which has a different track format). - ui.increment_category(); - ui.increment_category(); - ui.increment_selection(); + selection.increment_category(); + selection.increment_category(); + selection.increment_selection(artists); - terminal.draw(|frame| ui.render(frame)).unwrap(); - } - - #[test] - fn overlay() { - let mut terminal = terminal(); - let mut ui = ui(COLLECTION.to_owned()); - - terminal.draw(|frame| ui.render(frame)).unwrap(); - - ui.toggle_overlay(); - - terminal.draw(|frame| ui.render(frame)).unwrap(); + draw_test_suite(artists, &mut selection); // Change the artist (which has a multi-link entry). - ui.increment_selection(); + selection.decrement_category(); + selection.decrement_category(); + selection.increment_selection(artists); - terminal.draw(|frame| ui.render(frame)).unwrap(); - - ui.toggle_overlay(); - - terminal.draw(|frame| ui.render(frame)).unwrap(); - } - - #[test] - fn errors() { - let ui_err: UiError = musichoard::Error::DatabaseError(String::from("get rekt")).into(); - - assert!(!ui_err.to_string().is_empty()); - - assert!(!format!("{:?}", ui_err).is_empty()); + draw_test_suite(artists, &mut selection); } }