Add shortcut to reload database and/or library (#116)
Closes #105 Reviewed-on: #116
This commit is contained in:
parent
ba85505c9a
commit
e7413ed885
@ -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<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -1,4 +1,4 @@
|
||||
use std::{collections::HashMap, mem};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use paste::paste;
|
||||
|
||||
@ -20,6 +20,9 @@ pub struct MusicHoard<LIB, DB> {
|
||||
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<ArtistId, Artist>,
|
||||
}
|
||||
|
||||
/// Phantom type for when a library implementation is not needed.
|
||||
@ -117,6 +120,7 @@ impl<LIB, DB> MusicHoard<LIB, DB> {
|
||||
collection: vec![],
|
||||
library,
|
||||
database,
|
||||
library_cache: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -189,34 +193,17 @@ impl<LIB, DB> MusicHoard<LIB, DB> {
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_with_primary(&mut self, primary: HashMap<ArtistId, Artist>) {
|
||||
let collection = mem::take(&mut self.collection);
|
||||
self.collection = Self::merge_collections(primary, collection);
|
||||
}
|
||||
|
||||
fn merge_with_secondary<SEC: IntoIterator<Item = Artist>>(&mut self, secondary: SEC) {
|
||||
let primary_map: HashMap<ArtistId, Artist> = self
|
||||
.collection
|
||||
.drain(..)
|
||||
.map(|a| (a.id.clone(), a))
|
||||
.collect();
|
||||
self.collection = Self::merge_collections(primary_map, secondary);
|
||||
}
|
||||
|
||||
fn merge_collections<SEC: IntoIterator<Item = Artist>>(
|
||||
mut primary: HashMap<ArtistId, Artist>,
|
||||
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<Item>) -> Result<HashMap<ArtistId, Artist>, Error> {
|
||||
@ -307,10 +294,10 @@ impl<LIB: ILibrary, DB> MusicHoard<LIB, DB> {
|
||||
/// 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<LIB: ILibrary, DB> MusicHoard<LIB, DB> {
|
||||
impl<LIB, DB: IDatabase> MusicHoard<LIB, DB> {
|
||||
/// 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::<NoLibrary, NoDatabase>::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::<NoLibrary, NoDatabase>::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::<NoLibrary, NoDatabase>::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::<NoLibrary, NoDatabase>::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::<NoLibrary, NoDatabase>::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::<NoLibrary, NoDatabase>::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]
|
||||
|
@ -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<LIB: ILibrary, DB: IDatabase>(builder: MusicHoardBuilder<LIB, DB>) {
|
||||
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<LIB: ILibrary>(db_opt: DbOpt, builder: MusicHoardBuilder<LIB, NoDatabase>) {
|
||||
|
653
src/tui/app/app.rs
Normal file
653
src/tui/app/app.rs
Normal file
@ -0,0 +1,653 @@
|
||||
#![allow(clippy::module_inception)]
|
||||
|
||||
use musichoard::collection::Collection;
|
||||
|
||||
use crate::tui::{
|
||||
app::selection::{ActiveSelection, Selection},
|
||||
lib::IMusicHoard,
|
||||
};
|
||||
|
||||
pub enum AppState<BS, IS, RS, ES> {
|
||||
Browse(BS),
|
||||
Info(IS),
|
||||
Reload(RS),
|
||||
Error(ES),
|
||||
}
|
||||
|
||||
impl<BS, IS, RS, ES> AppState<BS, IS, RS, ES> {
|
||||
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<MH: IMusicHoard> {
|
||||
running: bool,
|
||||
music_hoard: MH,
|
||||
selection: Selection,
|
||||
state: AppState<(), (), (), String>,
|
||||
}
|
||||
|
||||
impl<MH: IMusicHoard> App<MH> {
|
||||
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<MH: IMusicHoard> IAppInteract for App<MH> {
|
||||
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<MH: IMusicHoard> IAppInteractBrowse for App<MH> {
|
||||
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<MH: IMusicHoard> IAppInteractInfo for App<MH> {
|
||||
fn hide_info_overlay(&mut self) {
|
||||
assert!(self.state.is_info());
|
||||
self.state = AppState::Browse(());
|
||||
}
|
||||
}
|
||||
|
||||
impl<MH: IMusicHoard> IAppInteractReload for App<MH> {
|
||||
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<MH: IMusicHoard> IAppInteractReloadPrivate for App<MH> {
|
||||
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<MH: IMusicHoard> IAppInteractError for App<MH> {
|
||||
fn dismiss_error(&mut self) {
|
||||
assert!(self.state.is_error());
|
||||
self.state = AppState::Browse(());
|
||||
}
|
||||
}
|
||||
|
||||
impl<MH: IMusicHoard> IAppAccess for App<MH> {
|
||||
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());
|
||||
}
|
||||
}
|
2
src/tui/app/mod.rs
Normal file
2
src/tui/app/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod app;
|
||||
pub mod selection;
|
601
src/tui/app/selection.rs
Normal file
601
src/tui/app/selection.rs
Normal file
@ -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<ActiveArtist>) {
|
||||
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<ActiveAlbum>,
|
||||
) {
|
||||
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<ActiveAlbum>) {
|
||||
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<ActiveTrack>,
|
||||
) {
|
||||
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<ActiveTrack>) {
|
||||
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<ActiveArtist>,
|
||||
}
|
||||
|
||||
struct ActiveArtist {
|
||||
artist_id: ArtistId,
|
||||
album: Option<ActiveAlbum>,
|
||||
}
|
||||
|
||||
struct ActiveAlbum {
|
||||
album_id: AlbumId,
|
||||
track: Option<ActiveTrack>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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));
|
||||
}
|
||||
}
|
@ -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<mpsc::RecvError> for EventError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UiError> 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());
|
||||
}
|
||||
}
|
||||
|
@ -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<UI> {
|
||||
fn handle_next_event(&self, ui: &mut UI) -> Result<(), EventError>;
|
||||
pub trait IEventHandler<APP: IAppInteract> {
|
||||
fn handle_next_event(&self, app: &mut APP) -> Result<(), EventError>;
|
||||
}
|
||||
|
||||
trait IEventHandlerPrivate<UI> {
|
||||
fn handle_key_event(ui: &mut UI, key_event: KeyEvent) -> Result<(), EventError>;
|
||||
fn quit(ui: &mut UI) -> Result<(), EventError>;
|
||||
trait IEventHandlerPrivate<APP: IAppInteract> {
|
||||
fn handle_key_event(app: &mut APP, key_event: KeyEvent);
|
||||
fn handle_browse_key_event(app: &mut <APP as IAppInteract>::BS, key_event: KeyEvent);
|
||||
fn handle_info_key_event(app: &mut <APP as IAppInteract>::IS, key_event: KeyEvent);
|
||||
fn handle_reload_key_event(app: &mut <APP as IAppInteract>::RS, key_event: KeyEvent);
|
||||
fn handle_error_key_event(app: &mut <APP as IAppInteract>::ES, key_event: KeyEvent);
|
||||
}
|
||||
|
||||
pub struct EventHandler {
|
||||
@ -29,10 +35,10 @@ impl EventHandler {
|
||||
}
|
||||
}
|
||||
|
||||
impl<UI: IUi> IEventHandler<UI> for EventHandler {
|
||||
fn handle_next_event(&self, ui: &mut UI) -> Result<(), EventError> {
|
||||
impl<APP: IAppInteract> IEventHandler<APP> 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<UI: IUi> IEventHandler<UI> for EventHandler {
|
||||
}
|
||||
}
|
||||
|
||||
impl<UI: IUi> IEventHandlerPrivate<UI> for EventHandler {
|
||||
fn handle_key_event(ui: &mut UI, key_event: KeyEvent) -> Result<(), EventError> {
|
||||
impl<APP: IAppInteract> IEventHandlerPrivate<APP> 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) => {
|
||||
<Self as IEventHandlerPrivate<APP>>::handle_browse_key_event(browse, key_event);
|
||||
}
|
||||
AppState::Info(info) => {
|
||||
<Self as IEventHandlerPrivate<APP>>::handle_info_key_event(info, key_event);
|
||||
}
|
||||
AppState::Reload(reload) => {
|
||||
<Self as IEventHandlerPrivate<APP>>::handle_reload_key_event(reload, key_event);
|
||||
}
|
||||
AppState::Error(error) => {
|
||||
<Self as IEventHandlerPrivate<APP>>::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 <APP as IAppInteract>::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 <APP as IAppInteract>::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 <APP as IAppInteract>::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 <APP as IAppInteract>::ES, _key_event: KeyEvent) {
|
||||
// Any key dismisses the error.
|
||||
app.dismiss_error();
|
||||
}
|
||||
}
|
||||
// GRCOV_EXCL_STOP
|
||||
|
100
src/tui/mod.rs
100
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<musichoard::Error> for Error {
|
||||
fn from(err: musichoard::Error) -> Error {
|
||||
Error::Lib(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for Error {
|
||||
fn from(err: io::Error) -> Error {
|
||||
Error::Io(err.to_string())
|
||||
@ -43,12 +45,12 @@ impl From<EventError> for Error {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Tui<B: Backend, UI> {
|
||||
pub struct Tui<B: Backend, UI: IUi, APP: IAppInteract + IAppAccess> {
|
||||
terminal: Terminal<B>,
|
||||
_phantom: PhantomData<UI>,
|
||||
_phantom: PhantomData<(UI, APP)>,
|
||||
}
|
||||
|
||||
impl<B: Backend, UI: IUi> Tui<B, UI> {
|
||||
impl<B: Backend, UI: IUi, APP: IAppInteract + IAppAccess> Tui<B, UI, APP> {
|
||||
fn init(&mut self) -> Result<(), Error> {
|
||||
self.terminal.hide_cursor()?;
|
||||
self.terminal.clear()?;
|
||||
@ -65,10 +67,15 @@ impl<B: Backend, UI: IUi> Tui<B, UI> {
|
||||
self.exit();
|
||||
}
|
||||
|
||||
fn main_loop(&mut self, mut ui: UI, handler: impl IEventHandler<UI>) -> 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<APP>,
|
||||
) -> 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<B: Backend, UI: IUi> Tui<B, UI> {
|
||||
|
||||
fn main(
|
||||
term: Terminal<B>,
|
||||
app: APP,
|
||||
ui: UI,
|
||||
handler: impl IEventHandler<UI>,
|
||||
handler: impl IEventHandler<APP>,
|
||||
listener: impl IEventListener,
|
||||
) -> Result<(), Error> {
|
||||
let mut tui = Tui {
|
||||
@ -88,7 +96,7 @@ impl<B: Backend, UI: IUi> Tui<B, UI> {
|
||||
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<B: Backend, UI: IUi> Tui<B, UI> {
|
||||
|
||||
pub fn run(
|
||||
term: Terminal<B>,
|
||||
app: APP,
|
||||
ui: UI,
|
||||
handler: impl IEventHandler<UI>,
|
||||
handler: impl IEventHandler<APP>,
|
||||
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<MockIMusicHoard> {
|
||||
Ui::new(music_hoard(collection)).unwrap()
|
||||
fn app(collection: Collection) -> App<MockIMusicHoard> {
|
||||
App::new(music_hoard(collection))
|
||||
}
|
||||
|
||||
fn listener() -> MockIEventListener {
|
||||
@ -205,12 +215,12 @@ mod tests {
|
||||
listener
|
||||
}
|
||||
|
||||
fn handler() -> MockIEventHandler<Ui<MockIMusicHoard>> {
|
||||
fn handler() -> MockIEventHandler<App<MockIMusicHoard>> {
|
||||
let mut handler = MockIEventHandler::new();
|
||||
handler
|
||||
.expect_handle_next_event()
|
||||
.return_once(|ui: &mut Ui<MockIMusicHoard>| {
|
||||
ui.quit();
|
||||
.return_once(|app: &mut App<MockIMusicHoard>| {
|
||||
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<EventError> = 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<EventError> = 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());
|
||||
|
962
src/tui/ui.rs
962
src/tui/ui.rs
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user