From 14a0567fa1a212621df4af5b7d57dcfab2cdff23 Mon Sep 17 00:00:00 2001 From: Wojciech Kozlowski Date: Thu, 13 Apr 2023 14:09:59 +0200 Subject: [PATCH] Add a TUI to the binary (#17) Closes #14 Reviewed-on: https://git.wojciechkozlowski.eu/wojtek/musichoard/pulls/17 --- .gitignore | 1 + Cargo.lock | 159 ++++++++- Cargo.toml | 16 +- README.md | 21 +- src/collection/mod.rs | 184 ++++++++++ src/database/json.rs | 35 +- src/database/mod.rs | 53 ++- src/lib.rs | 98 +---- src/library/beets.rs | 17 +- src/library/mod.rs | 27 +- src/main.rs | 75 +++- src/testlib.rs | 168 +++++++++ src/tui/app.rs | 791 +++++++++++++++++++++++++++++++++++++++++ src/tui/event.rs | 145 ++++++++ src/tui/handler.rs | 74 ++++ src/tui/listener.rs | 49 +++ src/tui/mod.rs | 303 ++++++++++++++++ src/tui/ui.rs | 360 +++++++++++++++++++ tests/database/json.rs | 2 +- 19 files changed, 2404 insertions(+), 174 deletions(-) create mode 100644 src/collection/mod.rs create mode 100644 src/testlib.rs create mode 100644 src/tui/app.rs create mode 100644 src/tui/event.rs create mode 100644 src/tui/handler.rs create mode 100644 src/tui/listener.rs create mode 100644 src/tui/mod.rs create mode 100644 src/tui/ui.rs diff --git a/.gitignore b/.gitignore index ea8c4bf..eadf8f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +/codecov diff --git a/Cargo.lock b/Cargo.lock index abee485..31f9a84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,12 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "cc" version = "1.0.79" @@ -70,6 +76,31 @@ dependencies = [ "vec_map", ] +[[package]] +name = "crossterm" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84cda67535339806297f1b331d6dd6320470d2a0fe65381e79ee9e156dd3d13" +dependencies = [ + "bitflags", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae1b35a484aa10e07fe0638d02301c5ad24de82d310ccbd2f3693da5f09bf1c" +dependencies = [ + "winapi", +] + [[package]] name = "difflib" version = "0.4.0" @@ -210,12 +241,43 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd550e73688e6d578f0ac2119e32b797a327631a42f9433e59d02e139c8df60d" +[[package]] +name = "lock_api" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if", +] + [[package]] name = "memchr" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +[[package]] +name = "mio" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + [[package]] name = "mockall" version = "0.11.4" @@ -247,8 +309,10 @@ dependencies = [ name = "musichoard" version = "0.1.0" dependencies = [ + "crossterm", "mockall", "once_cell", + "ratatui", "serde", "serde_json", "structopt", @@ -277,6 +341,29 @@ version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9069cbb9f99e3a5083476ccb29ceb1de18b9118cafa53e90c9551235de2b9521" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "windows-sys", +] + [[package]] name = "predicates" version = "2.1.5" @@ -349,6 +436,28 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "ratatui" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcc0d032bccba900ee32151ec0265667535c230169f5a011154cdcd984e16829" +dependencies = [ + "bitflags", + "cassowary", + "crossterm", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_syscall" version = "0.3.5" @@ -395,6 +504,12 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + [[package]] name = "serde" version = "1.0.159" @@ -426,6 +541,42 @@ dependencies = [ "serde", ] +[[package]] +name = "signal-hook" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732768f1176d21d09e076c23a93123d40bba92d50c4058da34d45c8de8e682b9" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "smallvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" + [[package]] name = "strsim" version = "0.8.0" @@ -486,7 +637,7 @@ checksum = "b9fbec84f381d5795b08656e4912bec604d162bff9291d6189a78f4c8ab87998" dependencies = [ "cfg-if", "fastrand", - "redox_syscall", + "redox_syscall 0.3.5", "rustix", "windows-sys", ] @@ -545,6 +696,12 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 155e270..485339f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,12 +6,14 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -structopt = "0.3" -uuid = { version = "1.3", features = ["serde"] } +crossterm = "0.26.1" +serde = { version = "1.0.159", features = ["derive"] } +serde_json = "1.0.95" +structopt = "0.3.26" +ratatui = "0.20.1" +uuid = { version = "1.3.0", features = ["serde"] } [dev-dependencies] -mockall = "0.11" -once_cell = "1.17" -tempfile = "3.5" +mockall = "0.11.4" +once_cell = "1.17.1" +tempfile = "3.5.0" diff --git a/README.md b/README.md index 067a845..8acd0c8 100644 --- a/README.md +++ b/README.md @@ -12,21 +12,26 @@ cargo install grcov ### Generating Code Coverage ```sh -cargo clean +env CARGO_TARGET_DIR=codecov \ + cargo clean env RUSTFLAGS="-C instrument-coverage" \ - LLVM_PROFILE_FILE="target/debug/profraw/musichoard-%p-%m.profraw" \ + LLVM_PROFILE_FILE="codecov/debug/profraw/musichoard-%p-%m.profraw" \ + CARGO_TARGET_DIR=codecov \ cargo test -grcov target/debug/profraw \ - --binary-path ./target/debug/ \ +grcov codecov/debug/profraw \ + --binary-path ./codecov/debug/ \ --output-types html \ --source-dir . \ --ignore-not-existing \ --ignore "tests/*" \ --ignore "src/main.rs" \ - --excl-start "mod tests \{" \ - --output-path ./target/debug/coverage/ -xdg-open target/debug/coverage/index.html + --excl-start "mod tests \{|GRCOV_EXCL_START" \ + --excl-stop "GRCOV_EXCL_STOP" \ + --output-path ./codecov/debug/coverage/ +xdg-open codecov/debug/coverage/index.html ``` -Note that some changes may not be visible until `target/debug/coverage` is removed and the `grcov` +Note that some changes may not be visible until `codecov/debug/coverage` is removed and the `grcov` command is rerun. + +For most cases `cargo clean` can be replaced with `rm -rf ./codecov/debug/{coverage,profraw}`. diff --git a/src/collection/mod.rs b/src/collection/mod.rs new file mode 100644 index 0000000..62352ad --- /dev/null +++ b/src/collection/mod.rs @@ -0,0 +1,184 @@ +//! Module for managing the music collection, i.e. "The Music Hoard". + +use std::fmt; + +use crate::{ + database::{self, Database}, + library::{self, Library, Query}, + Artist, +}; + +/// The collection type. +pub type Collection = Vec; + +/// Error type for collection manager. +#[derive(Debug, PartialEq, Eq)] +pub enum Error { + /// The [`CollectionManager`] failed to read/write from/to the library. + LibraryError(String), + /// The [`CollectionManager`] failed to read/write from/to the database. + DatabaseError(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + Self::LibraryError(ref s) => write!(f, "failed to read/write from/to the library: {s}"), + Self::DatabaseError(ref s) => { + write!(f, "failed to read/write from/to the database: {s}") + } + } + } +} + +impl From for Error { + fn from(err: library::Error) -> Error { + Error::LibraryError(err.to_string()) + } +} + +impl From for Error { + fn from(err: database::Error) -> Error { + Error::DatabaseError(err.to_string()) + } +} + +pub trait CollectionManager { + /// Rescan the library and integrate any updates into the collection. + fn rescan_library(&mut self) -> Result<(), Error>; + + /// Save the collection state to the database. + fn save_to_database(&mut self) -> Result<(), Error>; + + /// Get the current collection. + fn get_collection(&self) -> &Collection; +} + +/// The collection manager. It is responsible for pulling information from both the library and the +/// database, ensuring its consistent and writing back any changes. +pub struct MhCollectionManager { + library: Box, + database: Box, + collection: Collection, +} + +impl MhCollectionManager { + /// Create a new [`CollectionManager`] with the provided [`Library`] and [`Database`]. + pub fn new( + library: Box, + database: Box, + ) -> Self { + MhCollectionManager { + library, + database, + collection: vec![], + } + } +} + +impl CollectionManager for MhCollectionManager { + fn rescan_library(&mut self) -> Result<(), Error> { + self.collection = self.library.list(&Query::default())?; + Ok(()) + } + + fn save_to_database(&mut self) -> Result<(), Error> { + self.database.write(&self.collection)?; + Ok(()) + } + + fn get_collection(&self) -> &Collection { + &self.collection + } +} + +#[cfg(test)] +mod tests { + use mockall::predicate; + + use crate::{ + database::{self, MockDatabase}, + library::{self, MockLibrary, Query}, + tests::COLLECTION, + }; + + use super::{CollectionManager, Error, MhCollectionManager}; + + #[test] + fn read_get_write() { + let mut library = MockLibrary::new(); + let mut database = MockDatabase::new(); + + let library_input = Query::default(); + let library_result = Ok(COLLECTION.to_owned()); + + let database_input = COLLECTION.to_owned(); + let database_result = Ok(()); + + library + .expect_list() + .with(predicate::eq(library_input)) + .times(1) + .return_once(|_| library_result); + + database + .expect_write() + .with(predicate::eq(database_input)) + .times(1) + .return_once(|_| database_result); + + let mut collection_manager = + MhCollectionManager::new(Box::new(library), Box::new(database)); + + collection_manager.rescan_library().unwrap(); + assert_eq!(collection_manager.get_collection(), &*COLLECTION); + collection_manager.save_to_database().unwrap(); + } + + #[test] + fn library_error() { + let mut library = MockLibrary::new(); + let database = MockDatabase::new(); + + let library_result = Err(library::Error::InvalidData(String::from("invalid data"))); + + library + .expect_list() + .times(1) + .return_once(|_| library_result); + + let mut collection_manager = + MhCollectionManager::new(Box::new(library), Box::new(database)); + + let actual_err = collection_manager.rescan_library().unwrap_err(); + let expected_err = Error::LibraryError( + library::Error::InvalidData(String::from("invalid data")).to_string(), + ); + + assert_eq!(actual_err, expected_err); + assert_eq!(actual_err.to_string(), expected_err.to_string()); + } + + #[test] + fn database_error() { + let library = MockLibrary::new(); + let mut database = MockDatabase::new(); + + let database_result = Err(database::Error::IoError(String::from("I/O error"))); + + database + .expect_write() + .times(1) + .return_once(|_| database_result); + + let mut collection_manager = + MhCollectionManager::new(Box::new(library), Box::new(database)); + + let actual_err = collection_manager.save_to_database().unwrap_err(); + let expected_err = + Error::DatabaseError(database::Error::IoError(String::from("I/O error")).to_string()); + + assert_eq!(actual_err, expected_err); + assert_eq!(actual_err.to_string(), expected_err.to_string()); + } +} diff --git a/src/database/json.rs b/src/database/json.rs index e89ee7b..52955f8 100644 --- a/src/database/json.rs +++ b/src/database/json.rs @@ -3,13 +3,18 @@ use std::fs; use std::path::{Path, PathBuf}; -use serde::de::DeserializeOwned; -use serde::Serialize; +use crate::collection::Collection; #[cfg(test)] use mockall::automock; -use super::{Database, DatabaseRead, DatabaseWrite}; +use super::{Database, Error}; + +impl From for Error { + fn from(err: serde_json::Error) -> Error { + Error::SerDeError(err.to_string()) + } +} /// Trait for the JSON database backend. #[cfg_attr(test, automock)] @@ -23,47 +28,37 @@ pub trait JsonDatabaseBackend { /// JSON database. pub struct JsonDatabase { - backend: Box, + backend: Box, } impl JsonDatabase { - /// Create a new JSON database with the provided backend, e.g. [JsonDatabaseFileBackend]. - pub fn new(backend: Box) -> Self { + /// Create a new JSON database with the provided backend, e.g. [`JsonDatabaseFileBackend`]. + pub fn new(backend: Box) -> Self { JsonDatabase { backend } } } -impl DatabaseRead for JsonDatabase { - fn read(&self, collection: &mut D) -> Result<(), std::io::Error> - where - D: DeserializeOwned, - { +impl Database for JsonDatabase { + fn read(&self, collection: &mut Collection) -> Result<(), Error> { let serialized = self.backend.read()?; *collection = serde_json::from_str(&serialized)?; Ok(()) } -} -impl DatabaseWrite for JsonDatabase { - fn write(&mut self, collection: &S) -> Result<(), std::io::Error> - where - S: Serialize, - { + fn write(&mut self, collection: &Collection) -> Result<(), Error> { let serialized = serde_json::to_string(&collection)?; self.backend.write(&serialized)?; Ok(()) } } -impl Database for JsonDatabase {} - /// JSON database backend that uses a local file for persistent storage. pub struct JsonDatabaseFileBackend { path: PathBuf, } impl JsonDatabaseFileBackend { - /// Create a [JsonDatabaseFileBackend] that will read/write to the provided path. + /// Create a [`JsonDatabaseFileBackend`] that will read/write to the provided path. pub fn new(path: &Path) -> Self { JsonDatabaseFileBackend { path: path.to_path_buf(), diff --git a/src/database/mod.rs b/src/database/mod.rs index dea333d..28aff71 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,25 +1,46 @@ //! Module for storing MusicHoard data in a database. -use serde::de::DeserializeOwned; -use serde::Serialize; +use std::fmt; + +#[cfg(test)] +use mockall::automock; + +use crate::collection::Collection; pub mod json; -/// Trait for database reads. -pub trait DatabaseRead { +/// Error type for database calls. +#[derive(Debug)] +pub enum Error { + /// The database experienced an I/O error. + IoError(String), + /// The database experienced a (de)serialisation error. + SerDeError(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + Self::IoError(ref s) => write!(f, "the database experienced an I/O error: {s}"), + Self::SerDeError(ref s) => { + write!(f, "the database experienced a (de)serialisation error: {s}") + } + } + } +} + +impl From for Error { + fn from(err: std::io::Error) -> Error { + Error::IoError(err.to_string()) + } +} + +/// Trait for interacting with the database. +#[cfg_attr(test, automock)] +pub trait Database { /// Read collection from the database. - fn read(&self, collection: &mut D) -> Result<(), std::io::Error> - where - D: DeserializeOwned; -} + fn read(&self, collection: &mut Collection) -> Result<(), Error>; -/// Trait for database writes. -pub trait DatabaseWrite { /// Write collection to the database. - fn write(&mut self, collection: &S) -> Result<(), std::io::Error> - where - S: Serialize; + fn write(&mut self, collection: &Collection) -> Result<(), Error>; } - -/// Trait for database reads and writes. -pub trait Database: DatabaseRead + DatabaseWrite {} diff --git a/src/lib.rs b/src/lib.rs index d387c1c..06a807b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; +pub mod collection; pub mod database; pub mod library; @@ -53,97 +54,14 @@ pub struct Artist { } #[cfg(test)] -mod tests { - use super::*; +#[macro_use] +mod testlib; +#[cfg(test)] +mod tests { use once_cell::sync::Lazy; - pub static COLLECTION: Lazy> = Lazy::new(|| { - vec![ - Artist { - id: ArtistId { - name: "album_artist a".to_string(), - }, - albums: vec![ - Album { - id: AlbumId { - year: 1998, - title: "album_title a.a".to_string(), - }, - tracks: vec![ - Track { - number: 1, - title: "track a.a.1".to_string(), - artist: vec!["artist a.a.1".to_string()], - format: TrackFormat::Flac, - }, - Track { - number: 2, - title: "track a.a.2".to_string(), - artist: vec![ - "artist a.a.2.1".to_string(), - "artist a.a.2.2".to_string(), - ], - format: TrackFormat::Flac, - }, - Track { - number: 3, - title: "track a.a.3".to_string(), - artist: vec!["artist a.a.3".to_string()], - format: TrackFormat::Flac, - }, - ], - }, - Album { - id: AlbumId { - year: 2015, - title: "album_title a.b".to_string(), - }, - tracks: vec![ - Track { - number: 1, - title: "track a.b.1".to_string(), - artist: vec!["artist a.b.1".to_string()], - format: TrackFormat::Mp3, - }, - Track { - number: 2, - title: "track a.b.2".to_string(), - artist: vec!["artist a.b.2".to_string()], - format: TrackFormat::Flac, - }, - ], - }, - ], - }, - Artist { - id: ArtistId { - name: "album_artist b.a".to_string(), - }, - albums: vec![Album { - id: AlbumId { - year: 2003, - title: "album_title b.a".to_string(), - }, - tracks: vec![ - Track { - number: 1, - title: "track b.a.1".to_string(), - artist: vec!["artist b.a.1".to_string()], - format: TrackFormat::Mp3, - }, - Track { - number: 2, - title: "track b.a.2".to_string(), - artist: vec![ - "artist b.a.2.1".to_string(), - "artist b.a.2.2".to_string(), - ], - format: TrackFormat::Mp3, - }, - ], - }], - }, - ] - }); + use super::*; + + pub static COLLECTION: Lazy> = Lazy::new(|| collection!()); } diff --git a/src/library/beets.rs b/src/library/beets.rs index d8efb3c..e9c1f2e 100644 --- a/src/library/beets.rs +++ b/src/library/beets.rs @@ -95,7 +95,7 @@ pub trait BeetsLibraryExecutor { /// Beets library. pub struct BeetsLibrary { - executor: Box, + executor: Box, } trait LibraryPrivate { @@ -110,8 +110,8 @@ trait LibraryPrivate { } impl BeetsLibrary { - /// Create a new beets library with the provided executor, e.g. [BeetsLibraryCommandExecutor]. - pub fn new(executor: Box) -> BeetsLibrary { + /// Create a new beets library with the provided executor, e.g. [`BeetsLibraryCommandExecutor`]. + pub fn new(executor: Box) -> BeetsLibrary { BeetsLibrary { executor } } } @@ -247,7 +247,7 @@ pub struct BeetsLibraryCommandExecutor { } impl BeetsLibraryCommandExecutor { - /// Create a new [BeetsLibraryCommandExecutor] that uses the provided beets executable. + /// Create a new [`BeetsLibraryCommandExecutor`] that uses the provided beets executable. pub fn new(bin: &str) -> Self { BeetsLibraryCommandExecutor { bin: bin.to_string(), @@ -263,7 +263,7 @@ impl BeetsLibraryCommandExecutor { } impl Default for BeetsLibraryCommandExecutor { - /// Create a new [BeetsLibraryCommandExecutor] that uses the system's default beets executable. + /// Create a new [`BeetsLibraryCommandExecutor`] that uses the system's default beets executable. fn default() -> Self { BeetsLibraryCommandExecutor::new("beet") } @@ -404,13 +404,14 @@ mod tests { // Putting the last track first will make the entire artist come first in the output. expected.rotate_right(1); - // Same applies to that artists' albums, but here the artist has only one album. - assert_eq!(expected[0].albums.len(), 1); + // Same applies to that artists' albums. + expected[0].albums.rotate_right(1); // Same applies to that album's tracks. expected[0].albums[0].tracks.rotate_right(1); - // And the (now) second album's tracks first track comes last. + // And the original first album's (now the first album of the second artist) tracks first + // track comes last. expected[1].albums[0].tracks.rotate_left(1); let mut executor = MockBeetsLibraryExecutor::new(); diff --git a/src/library/mod.rs b/src/library/mod.rs index 3c1584b..3ade40e 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -1,12 +1,16 @@ //! Module for interacting with the music library. -use std::{num::ParseIntError, str::Utf8Error}; +use std::{fmt, num::ParseIntError, str::Utf8Error}; + +#[cfg(test)] +use mockall::automock; use crate::Artist; pub mod beets; /// A single query option. +#[derive(Debug, PartialEq, Eq)] pub enum QueryOption { /// Inclusive query. Include(T), @@ -17,26 +21,26 @@ pub enum QueryOption { } impl QueryOption { - /// Return `true` if [QueryOption] is not [QueryOption::None]. + /// Return `true` if [`QueryOption`] is not [`QueryOption::None`]. pub fn is_some(&self) -> bool { !matches!(self, QueryOption::None) } - /// Return `true` if [QueryOption] is [QueryOption::None]. + /// Return `true` if [`QueryOption`] is [`QueryOption::None`]. pub fn is_none(&self) -> bool { matches!(self, QueryOption::None) } } impl Default for QueryOption { - /// Create a [QueryOption::None] for type `T`. + /// Create a [`QueryOption::None`] for type `T`. fn default() -> Self { Self::None } } /// Options for refining library queries. -#[derive(Default)] +#[derive(Debug, Default, PartialEq, Eq)] pub struct Query { album_artist: QueryOption, album_year: QueryOption, @@ -111,6 +115,18 @@ pub enum Error { Utf8Error(String), } +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + Self::CmdExecError(ref s) => write!(f, "the library failed to execute a command: {s}"), + Self::InvalidData(ref s) => write!(f, "the library received invalid data: {s}"), + Self::IoError(ref s) => write!(f, "the library experienced an I/O error: {s}"), + Self::ParseIntError(ref s) => write!(f, "the library received an invalid integer: {s}"), + Self::Utf8Error(ref s) => write!(f, "the library received invalid UTF-8: {s}"), + } + } +} + impl From for Error { fn from(err: std::io::Error) -> Error { Error::IoError(err.to_string()) @@ -130,6 +146,7 @@ impl From for Error { } /// Trait for interacting with the music library. +#[cfg_attr(test, automock)] pub trait Library { /// List lirbary items that match the a specific query. fn list(&mut self, query: &Query) -> Result, Error>; diff --git a/src/main.rs b/src/main.rs index 069d0b6..827dbda 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,23 +1,27 @@ +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; +use std::io; use std::path::PathBuf; use structopt::StructOpt; use musichoard::{ - database::{ - json::{JsonDatabase, JsonDatabaseFileBackend}, - DatabaseWrite, - }, - library::{ - beets::{BeetsLibrary, BeetsLibraryCommandExecutor}, - Library, Query, - }, + collection::MhCollectionManager, + database::json::{JsonDatabase, JsonDatabaseFileBackend}, + library::beets::{BeetsLibrary, BeetsLibraryCommandExecutor}, +}; + +mod tui; +use tui::{ + app::App, event::EventChannel, handler::TuiEventHandler, listener::TuiEventListener, ui::Ui, + Tui, }; #[derive(StructOpt)] struct Opt { #[structopt( short = "b", - long = "beets-config", + long = "beets", name = "beets config file path", parse(from_os_str) )] @@ -34,21 +38,56 @@ struct Opt { } fn main() { + // Create the application. let opt = Opt::from_args(); - let mut beets = BeetsLibrary::new(Box::new( + let beets = BeetsLibrary::new(Box::new( BeetsLibraryCommandExecutor::default().config(opt.beets_config_file_path.as_deref()), )); - let collection = beets - .list(&Query::new()) - .expect("failed to query the library"); - - let mut database = JsonDatabase::new(Box::new(JsonDatabaseFileBackend::new( + let database = JsonDatabase::new(Box::new(JsonDatabaseFileBackend::new( &opt.database_file_path, ))); - database - .write(&collection) - .expect("failed to write to the database"); + let collection_manager = MhCollectionManager::new(Box::new(beets), Box::new(database)); + + // Initialize the terminal user interface. + let backend = CrosstermBackend::new(io::stdout()); + let terminal = Terminal::new(backend).expect("failed to initialise terminal"); + + let channel = EventChannel::new(); + let listener = TuiEventListener::new(channel.sender()); + let handler = TuiEventHandler::new(channel.receiver()); + + let ui = Ui::new(); + + let app = App::new(Box::new(collection_manager)).expect("failed to initialise app"); + + // Run the TUI application. + Tui::run(terminal, app, ui, handler, listener).expect("failed to run tui"); +} + +#[cfg(test)] +#[macro_use] +mod testlib; + +#[cfg(test)] +mod tests { + use mockall::mock; + use once_cell::sync::Lazy; + + use musichoard::collection::{self, Collection, CollectionManager}; + use musichoard::*; + + pub static COLLECTION: Lazy> = Lazy::new(|| collection!()); + + mock! { + pub CollectionManager {} + + impl CollectionManager for CollectionManager { + fn rescan_library(&mut self) -> Result<(), collection::Error>; + fn save_to_database(&mut self) -> Result<(), collection::Error>; + fn get_collection(&self) -> &Collection; + } + } } diff --git a/src/testlib.rs b/src/testlib.rs new file mode 100644 index 0000000..cd45baa --- /dev/null +++ b/src/testlib.rs @@ -0,0 +1,168 @@ +macro_rules! collection { + () => { + vec![ + Artist { + id: ArtistId { + name: "album_artist a".to_string(), + }, + albums: vec![ + Album { + id: AlbumId { + year: 1998, + title: "album_title a.a".to_string(), + }, + tracks: vec![ + Track { + number: 1, + title: "track a.a.1".to_string(), + artist: vec!["artist a.a.1".to_string()], + format: TrackFormat::Flac, + }, + Track { + number: 2, + title: "track a.a.2".to_string(), + artist: vec![ + "artist a.a.2.1".to_string(), + "artist a.a.2.2".to_string(), + ], + format: TrackFormat::Mp3, + }, + Track { + number: 3, + title: "track a.a.3".to_string(), + artist: vec!["artist a.a.3".to_string()], + format: TrackFormat::Flac, + }, + ], + }, + Album { + id: AlbumId { + year: 2015, + title: "album_title a.b".to_string(), + }, + tracks: vec![ + Track { + number: 1, + title: "track a.b.1".to_string(), + artist: vec!["artist a.b.1".to_string()], + format: TrackFormat::Flac, + }, + Track { + number: 2, + title: "track a.b.2".to_string(), + artist: vec!["artist a.b.2".to_string()], + format: TrackFormat::Flac, + }, + ], + }, + ], + }, + Artist { + id: ArtistId { + name: "album_artist b".to_string(), + }, + albums: vec![ + Album { + id: AlbumId { + year: 2003, + title: "album_title b.a".to_string(), + }, + tracks: vec![ + Track { + number: 1, + title: "track b.a.1".to_string(), + artist: vec!["artist b.a.1".to_string()], + format: TrackFormat::Mp3, + }, + Track { + number: 2, + title: "track b.a.2".to_string(), + artist: vec![ + "artist b.a.2.1".to_string(), + "artist b.a.2.2".to_string(), + ], + format: TrackFormat::Mp3, + }, + ], + }, + Album { + id: AlbumId { + year: 2008, + title: "album_title b.b".to_string(), + }, + tracks: vec![ + Track { + number: 1, + title: "track b.b.1".to_string(), + artist: vec!["artist b.b.1".to_string()], + format: TrackFormat::Flac, + }, + Track { + number: 2, + title: "track b.b.2".to_string(), + artist: vec![ + "artist b.b.2.1".to_string(), + "artist b.b.2.2".to_string(), + ], + format: TrackFormat::Mp3, + }, + ], + }, + ], + }, + Artist { + id: ArtistId { + name: "album_artist c".to_string(), + }, + albums: vec![ + Album { + id: AlbumId { + year: 1985, + title: "album_title c.a".to_string(), + }, + tracks: vec![ + Track { + number: 1, + title: "track c.a.1".to_string(), + artist: vec!["artist c.a.1".to_string()], + format: TrackFormat::Mp3, + }, + Track { + number: 2, + title: "track c.a.2".to_string(), + artist: vec![ + "artist c.a.2.1".to_string(), + "artist c.a.2.2".to_string(), + ], + format: TrackFormat::Mp3, + }, + ], + }, + Album { + id: AlbumId { + year: 2018, + title: "album_title c.b".to_string(), + }, + tracks: vec![ + Track { + number: 1, + title: "track c.b.1".to_string(), + artist: vec!["artist c.b.1".to_string()], + format: TrackFormat::Flac, + }, + Track { + number: 2, + title: "track c.b.2".to_string(), + artist: vec![ + "artist c.b.2.1".to_string(), + "artist c.b.2.2".to_string(), + ], + format: TrackFormat::Flac, + }, + ], + }, + ], + }, + ] + }; +} diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 0000000..3dccf0c --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,791 @@ +use musichoard::{collection::CollectionManager, Album, AlbumId, Artist, ArtistId, Track}; + +use super::Error; + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum Category { + Artist, + Album, + Track, +} + +struct TrackSelection { + index: usize, +} + +impl TrackSelection { + fn initialise(tracks: &Vec) -> Option { + if !tracks.is_empty() { + Some(TrackSelection { index: 0 }) + } else { + None + } + } + + fn increment(&mut self, tracks: &[Track]) { + if let Some(result) = self.index.checked_add(1) { + if result < tracks.len() { + self.index = result; + } + } + } + + fn decrement(&mut self, _tracks: &[Track]) { + if let Some(result) = self.index.checked_sub(1) { + self.index = result; + } + } +} + +struct AlbumSelection { + index: usize, + track: Option, +} + +impl AlbumSelection { + fn initialise(albums: &Vec) -> Option { + if !albums.is_empty() { + Some(AlbumSelection { + index: 0, + track: TrackSelection::initialise(&albums[0].tracks), + }) + } else { + None + } + } + + fn increment(&mut self, albums: &[Album]) { + if let Some(result) = self.index.checked_add(1) { + if result < albums.len() { + self.index = result; + self.track = TrackSelection::initialise(&albums[self.index].tracks); + } + } + } + + fn decrement(&mut self, albums: &[Album]) { + if let Some(result) = self.index.checked_sub(1) { + self.index = result; + self.track = TrackSelection::initialise(&albums[self.index].tracks); + } + } +} + +struct ArtistSelection { + index: usize, + album: Option, +} + +impl ArtistSelection { + fn initialise(artists: &Vec) -> Option { + if !artists.is_empty() { + Some(ArtistSelection { + index: 0, + album: AlbumSelection::initialise(&artists[0].albums), + }) + } else { + None + } + } + + fn increment(&mut self, artists: &[Artist]) { + if let Some(result) = self.index.checked_add(1) { + if result < artists.len() { + self.index = result; + self.album = AlbumSelection::initialise(&artists[self.index].albums); + } + } + } + + fn decrement(&mut self, artists: &[Artist]) { + if let Some(result) = self.index.checked_sub(1) { + self.index = result; + self.album = AlbumSelection::initialise(&artists[self.index].albums); + } + } +} + +struct Selection { + active: Category, + artist: Option, +} + +pub struct App { + collection_manager: Box, + selection: Selection, + running: bool, +} + +impl App { + pub fn new(mut collection_manager: Box) -> Result { + collection_manager.rescan_library()?; + let selection = Selection { + active: Category::Artist, + artist: ArtistSelection::initialise(collection_manager.get_collection()), + }; + Ok(App { + collection_manager, + selection, + running: true, + }) + } + + pub fn is_running(&self) -> bool { + self.running + } + + pub fn increment_category(&mut self) { + self.selection.active = match self.selection.active { + Category::Artist => Category::Album, + Category::Album => Category::Track, + Category::Track => Category::Track, + }; + } + + pub fn decrement_category(&mut self) { + self.selection.active = match self.selection.active { + Category::Artist => Category::Artist, + Category::Album => Category::Artist, + Category::Track => Category::Album, + }; + } + + pub fn increment_selection(&mut self) { + match self.selection.active { + Category::Artist => self.increment_artist_selection(), + Category::Album => self.increment_album_selection(), + Category::Track => self.increment_track_selection(), + } + } + + pub fn decrement_selection(&mut self) { + match self.selection.active { + Category::Artist => self.decrement_artist_selection(), + Category::Album => self.decrement_album_selection(), + Category::Track => self.decrement_track_selection(), + } + } + + fn increment_artist_selection(&mut self) { + if let Some(ref mut artist_selection) = self.selection.artist { + let artists = &self.collection_manager.get_collection(); + artist_selection.increment(artists); + } + } + + fn decrement_artist_selection(&mut self) { + if let Some(ref mut artist_selection) = self.selection.artist { + let artists = &self.collection_manager.get_collection(); + artist_selection.decrement(artists); + } + } + + fn increment_album_selection(&mut self) { + if let Some(ref mut artist_selection) = self.selection.artist { + if let Some(ref mut album_selection) = artist_selection.album { + let artists = &self.collection_manager.get_collection(); + let albums = &artists[artist_selection.index].albums; + album_selection.increment(albums); + } + } + } + + fn decrement_album_selection(&mut self) { + if let Some(ref mut artist_selection) = self.selection.artist { + if let Some(ref mut album_selection) = artist_selection.album { + let artists = &self.collection_manager.get_collection(); + let albums = &artists[artist_selection.index].albums; + album_selection.decrement(albums); + } + } + } + + fn increment_track_selection(&mut self) { + if let Some(ref mut artist_selection) = self.selection.artist { + if let Some(ref mut album_selection) = artist_selection.album { + if let Some(ref mut track_selection) = album_selection.track { + let artists = &self.collection_manager.get_collection(); + let albums = &artists[artist_selection.index].albums; + let tracks = &albums[album_selection.index].tracks; + track_selection.increment(tracks); + } + } + } + } + + fn decrement_track_selection(&mut self) { + if let Some(ref mut artist_selection) = self.selection.artist { + if let Some(ref mut album_selection) = artist_selection.album { + if let Some(ref mut track_selection) = album_selection.track { + let artists = &self.collection_manager.get_collection(); + let albums = &artists[artist_selection.index].albums; + let tracks = &albums[album_selection.index].tracks; + track_selection.decrement(tracks); + } + } + } + } + + pub fn get_active_category(&self) -> Category { + self.selection.active + } + + fn get_artists(&self) -> &Vec { + self.collection_manager.get_collection() + } + + fn get_albums(&self) -> Option<&Vec> { + let artists = self.get_artists(); + if let Some(artist_index) = self.selected_artist() { + Some(&artists[artist_index].albums) + } else { + None + } + } + + fn get_tracks(&self) -> Option<&Vec> { + if let Some(albums) = self.get_albums() { + if let Some(album_index) = self.selected_album() { + Some(&albums[album_index].tracks) + } else { + None + } + } else { + None + } + } + + pub fn get_artist_ids(&self) -> Vec<&ArtistId> { + let artists = self.get_artists(); + artists.iter().map(|a| &a.id).collect() + } + + pub fn get_album_ids(&self) -> Vec<&AlbumId> { + if let Some(albums) = self.get_albums() { + albums.iter().map(|a| &a.id).collect() + } else { + vec![] + } + } + + pub fn get_track_ids(&self) -> Vec<&Track> { + if let Some(tracks) = self.get_tracks() { + tracks.iter().collect() + } else { + vec![] + } + } + + pub fn selected_artist(&self) -> Option { + self.selection.artist.as_ref().map(|s| s.index) + } + + pub fn selected_album(&self) -> Option { + if let Some(ref artist_selection) = self.selection.artist { + artist_selection.album.as_ref().map(|s| s.index) + } else { + None + } + } + + pub fn selected_track(&self) -> Option { + if let Some(ref artist_selection) = self.selection.artist { + if let Some(ref album_selection) = artist_selection.album { + album_selection.track.as_ref().map(|s| s.index) + } else { + None + } + } else { + None + } + } + + pub fn quit(&mut self) { + self.running = false; + } +} + +#[cfg(test)] +mod tests { + use crate::tests::{MockCollectionManager, COLLECTION}; + + use super::*; + + #[test] + fn test_track_selection() { + let tracks = &COLLECTION[0].albums[0].tracks; + assert!(tracks.len() > 1); + + let empty = TrackSelection::initialise(&vec![]); + assert!(empty.is_none()); + + let sel = TrackSelection::initialise(tracks); + assert!(sel.is_some()); + + let mut sel = sel.unwrap(); + assert_eq!(sel.index, 0); + + sel.decrement(tracks); + assert_eq!(sel.index, 0); + + sel.increment(tracks); + assert_eq!(sel.index, 1); + + sel.decrement(tracks); + assert_eq!(sel.index, 0); + + for _ in 0..(tracks.len() + 5) { + sel.increment(tracks); + } + assert_eq!(sel.index, tracks.len() - 1); + + // Artifical test case to verify upper limit. + let mut sel = TrackSelection { + index: std::usize::MAX, + }; + assert_eq!(sel.index, std::usize::MAX); + + sel.increment(&vec![]); + assert_eq!(sel.index, std::usize::MAX); + } + + #[test] + fn test_album_selection() { + let albums = &COLLECTION[0].albums; + assert!(albums.len() > 1); + + let empty = AlbumSelection::initialise(&vec![]); + assert!(empty.is_none()); + + let sel = AlbumSelection::initialise(albums); + assert!(sel.is_some()); + + let mut sel = sel.unwrap(); + assert_eq!(sel.index, 0); + assert!(sel.track.is_some()); + assert_eq!(sel.track.as_ref().unwrap().index, 0); + + sel.track + .as_mut() + .unwrap() + .increment(&albums[sel.index].tracks); + assert_eq!(sel.index, 0); + assert!(sel.track.is_some()); + assert_eq!(sel.track.as_ref().unwrap().index, 1); + + // Verify that decrement that doesn't change index does not reset track. + sel.decrement(albums); + assert_eq!(sel.index, 0); + assert!(sel.track.is_some()); + assert_eq!(sel.track.as_ref().unwrap().index, 1); + + sel.increment(albums); + assert_eq!(sel.index, 1); + assert!(sel.track.is_some()); + assert_eq!(sel.track.as_ref().unwrap().index, 0); + + sel.decrement(albums); + assert_eq!(sel.index, 0); + assert!(sel.track.is_some()); + assert_eq!(sel.track.as_ref().unwrap().index, 0); + + for _ in 0..(albums.len() + 5) { + sel.increment(albums); + } + assert_eq!(sel.index, albums.len() - 1); + assert!(sel.track.is_some()); + assert_eq!(sel.track.as_ref().unwrap().index, 0); + + sel.track + .as_mut() + .unwrap() + .increment(&albums[sel.index].tracks); + assert_eq!(sel.index, albums.len() - 1); + assert!(sel.track.is_some()); + assert_eq!(sel.track.as_ref().unwrap().index, 1); + + // Verify that increment that doesn't change index does not reset track. + sel.increment(albums); + assert_eq!(sel.index, albums.len() - 1); + assert!(sel.track.is_some()); + assert_eq!(sel.track.as_ref().unwrap().index, 1); + + // Artifical test case to verify upper limit. + let mut sel = AlbumSelection { + index: std::usize::MAX, + track: Some(TrackSelection { index: 1 }), + }; + assert_eq!(sel.index, std::usize::MAX); + assert!(sel.track.is_some()); + assert_eq!(sel.track.as_ref().unwrap().index, 1); + + sel.increment(&vec![]); + assert_eq!(sel.index, std::usize::MAX); + assert!(sel.track.is_some()); + assert_eq!(sel.track.as_ref().unwrap().index, 1); + } + + #[test] + fn test_artist_selection() { + let artists = &COLLECTION; + assert!(artists.len() > 1); + + let empty = ArtistSelection::initialise(&vec![]); + assert!(empty.is_none()); + + let sel = ArtistSelection::initialise(artists); + assert!(sel.is_some()); + + let mut sel = sel.unwrap(); + assert_eq!(sel.index, 0); + assert!(sel.album.is_some()); + assert_eq!(sel.album.as_ref().unwrap().index, 0); + + sel.album + .as_mut() + .unwrap() + .increment(&artists[sel.index].albums); + assert_eq!(sel.index, 0); + assert!(sel.album.is_some()); + assert_eq!(sel.album.as_ref().unwrap().index, 1); + + // Verify that decrement that doesn't change index does not reset album. + sel.decrement(artists); + assert_eq!(sel.index, 0); + assert!(sel.album.is_some()); + assert_eq!(sel.album.as_ref().unwrap().index, 1); + + sel.increment(artists); + assert_eq!(sel.index, 1); + assert!(sel.album.is_some()); + assert_eq!(sel.album.as_ref().unwrap().index, 0); + + sel.decrement(artists); + assert_eq!(sel.index, 0); + assert!(sel.album.is_some()); + assert_eq!(sel.album.as_ref().unwrap().index, 0); + + for _ in 0..(artists.len() + 5) { + sel.increment(artists); + } + assert_eq!(sel.index, artists.len() - 1); + assert!(sel.album.is_some()); + assert_eq!(sel.album.as_ref().unwrap().index, 0); + + sel.album + .as_mut() + .unwrap() + .increment(&artists[sel.index].albums); + assert_eq!(sel.index, artists.len() - 1); + assert!(sel.album.is_some()); + assert_eq!(sel.album.as_ref().unwrap().index, 1); + + // Verify that increment that doesn't change index does not reset album. + sel.increment(artists); + assert_eq!(sel.index, artists.len() - 1); + assert!(sel.album.is_some()); + assert_eq!(sel.album.as_ref().unwrap().index, 1); + + // Artifical test case to verify upper limit. + let mut sel = ArtistSelection { + index: std::usize::MAX, + album: Some(AlbumSelection { + index: 1, + track: None, + }), + }; + assert_eq!(sel.index, std::usize::MAX); + assert!(sel.album.is_some()); + assert_eq!(sel.album.as_ref().unwrap().index, 1); + + sel.increment(&vec![]); + assert_eq!(sel.index, std::usize::MAX); + assert!(sel.album.is_some()); + assert_eq!(sel.album.as_ref().unwrap().index, 1); + } + + #[test] + fn app_running() { + let mut collection_manager = MockCollectionManager::new(); + + collection_manager + .expect_rescan_library() + .times(1) + .return_once(|| Ok(())); + collection_manager + .expect_get_collection() + .return_const(COLLECTION.to_owned()); + + let mut app = App::new(Box::new(collection_manager)).unwrap(); + assert!(app.is_running()); + + app.quit(); + assert!(!app.is_running()); + } + + #[test] + fn app_modifiers() { + let mut collection_manager = MockCollectionManager::new(); + + collection_manager + .expect_rescan_library() + .times(1) + .return_once(|| Ok(())); + collection_manager + .expect_get_collection() + .return_const(COLLECTION.to_owned()); + + let mut app = App::new(Box::new(collection_manager)).unwrap(); + assert!(app.is_running()); + + assert!(!app.get_artist_ids().is_empty()); + assert!(!app.get_album_ids().is_empty()); + assert!(!app.get_track_ids().is_empty()); + + assert_eq!(app.get_active_category(), Category::Artist); + assert_eq!(app.selected_artist(), Some(0)); + assert_eq!(app.selected_album(), Some(0)); + assert_eq!(app.selected_track(), Some(0)); + + app.increment_selection(); + assert_eq!(app.get_active_category(), Category::Artist); + assert_eq!(app.selected_artist(), Some(1)); + assert_eq!(app.selected_album(), Some(0)); + assert_eq!(app.selected_track(), Some(0)); + + app.increment_category(); + assert_eq!(app.get_active_category(), Category::Album); + assert_eq!(app.selected_artist(), Some(1)); + assert_eq!(app.selected_album(), Some(0)); + assert_eq!(app.selected_track(), Some(0)); + + app.increment_selection(); + assert_eq!(app.get_active_category(), Category::Album); + assert_eq!(app.selected_artist(), Some(1)); + assert_eq!(app.selected_album(), Some(1)); + assert_eq!(app.selected_track(), Some(0)); + + app.increment_category(); + assert_eq!(app.get_active_category(), Category::Track); + assert_eq!(app.selected_artist(), Some(1)); + assert_eq!(app.selected_album(), Some(1)); + assert_eq!(app.selected_track(), Some(0)); + + app.increment_selection(); + assert_eq!(app.get_active_category(), Category::Track); + assert_eq!(app.selected_artist(), Some(1)); + assert_eq!(app.selected_album(), Some(1)); + assert_eq!(app.selected_track(), Some(1)); + + app.increment_category(); + assert_eq!(app.get_active_category(), Category::Track); + assert_eq!(app.selected_artist(), Some(1)); + assert_eq!(app.selected_album(), Some(1)); + assert_eq!(app.selected_track(), Some(1)); + + app.decrement_selection(); + assert_eq!(app.get_active_category(), Category::Track); + assert_eq!(app.selected_artist(), Some(1)); + assert_eq!(app.selected_album(), Some(1)); + assert_eq!(app.selected_track(), Some(0)); + + app.increment_selection(); + app.decrement_category(); + assert_eq!(app.get_active_category(), Category::Album); + assert_eq!(app.selected_artist(), Some(1)); + assert_eq!(app.selected_album(), Some(1)); + assert_eq!(app.selected_track(), Some(1)); + + app.decrement_selection(); + assert_eq!(app.get_active_category(), Category::Album); + assert_eq!(app.selected_artist(), Some(1)); + assert_eq!(app.selected_album(), Some(0)); + assert_eq!(app.selected_track(), Some(0)); + + app.increment_selection(); + app.decrement_category(); + assert_eq!(app.get_active_category(), Category::Artist); + assert_eq!(app.selected_artist(), Some(1)); + assert_eq!(app.selected_album(), Some(1)); + assert_eq!(app.selected_track(), Some(0)); + + app.decrement_selection(); + assert_eq!(app.get_active_category(), Category::Artist); + assert_eq!(app.selected_artist(), Some(0)); + assert_eq!(app.selected_album(), Some(0)); + assert_eq!(app.selected_track(), Some(0)); + + app.increment_category(); + app.increment_selection(); + app.decrement_category(); + app.decrement_selection(); + app.decrement_category(); + assert_eq!(app.get_active_category(), Category::Artist); + assert_eq!(app.selected_artist(), Some(0)); + assert_eq!(app.selected_album(), Some(1)); + assert_eq!(app.selected_track(), Some(0)); + } + + #[test] + fn app_no_tracks() { + let mut collection_manager = MockCollectionManager::new(); + let mut collection = COLLECTION.to_owned(); + collection[0].albums[0].tracks = vec![]; + + collection_manager + .expect_rescan_library() + .times(1) + .return_once(|| Ok(())); + collection_manager + .expect_get_collection() + .return_const(collection); + + let mut app = App::new(Box::new(collection_manager)).unwrap(); + assert!(app.is_running()); + + assert!(!app.get_artist_ids().is_empty()); + assert!(!app.get_album_ids().is_empty()); + assert!(app.get_track_ids().is_empty()); + + assert_eq!(app.get_active_category(), Category::Artist); + assert_eq!(app.selected_artist(), Some(0)); + assert_eq!(app.selected_album(), Some(0)); + assert_eq!(app.selected_track(), None); + + app.increment_category(); + app.increment_category(); + + app.increment_selection(); + assert_eq!(app.get_active_category(), Category::Track); + assert_eq!(app.selected_artist(), Some(0)); + assert_eq!(app.selected_album(), Some(0)); + assert_eq!(app.selected_track(), None); + + app.decrement_selection(); + assert_eq!(app.get_active_category(), Category::Track); + assert_eq!(app.selected_artist(), Some(0)); + assert_eq!(app.selected_album(), Some(0)); + assert_eq!(app.selected_track(), None); + } + + #[test] + fn app_no_albums() { + let mut collection_manager = MockCollectionManager::new(); + let mut collection = COLLECTION.to_owned(); + collection[0].albums = vec![]; + + collection_manager + .expect_rescan_library() + .times(1) + .return_once(|| Ok(())); + collection_manager + .expect_get_collection() + .return_const(collection); + + let mut app = App::new(Box::new(collection_manager)).unwrap(); + assert!(app.is_running()); + + assert!(!app.get_artist_ids().is_empty()); + assert!(app.get_album_ids().is_empty()); + assert!(app.get_track_ids().is_empty()); + + assert_eq!(app.get_active_category(), Category::Artist); + assert_eq!(app.selected_artist(), Some(0)); + assert_eq!(app.selected_album(), None); + assert_eq!(app.selected_track(), None); + + app.increment_category(); + + app.increment_selection(); + assert_eq!(app.get_active_category(), Category::Album); + assert_eq!(app.selected_artist(), Some(0)); + assert_eq!(app.selected_album(), None); + assert_eq!(app.selected_track(), None); + + app.decrement_selection(); + assert_eq!(app.get_active_category(), Category::Album); + assert_eq!(app.selected_artist(), Some(0)); + assert_eq!(app.selected_album(), None); + assert_eq!(app.selected_track(), None); + + app.increment_category(); + + app.increment_selection(); + assert_eq!(app.get_active_category(), Category::Track); + assert_eq!(app.selected_artist(), Some(0)); + assert_eq!(app.selected_album(), None); + assert_eq!(app.selected_track(), None); + + app.decrement_selection(); + assert_eq!(app.get_active_category(), Category::Track); + assert_eq!(app.selected_artist(), Some(0)); + assert_eq!(app.selected_album(), None); + assert_eq!(app.selected_track(), None); + } + + #[test] + fn app_no_artists() { + let mut collection_manager = MockCollectionManager::new(); + let collection = vec![]; + + collection_manager + .expect_rescan_library() + .times(1) + .return_once(|| Ok(())); + collection_manager + .expect_get_collection() + .return_const(collection); + + let mut app = App::new(Box::new(collection_manager)).unwrap(); + assert!(app.is_running()); + + assert!(app.get_artist_ids().is_empty()); + assert!(app.get_album_ids().is_empty()); + assert!(app.get_track_ids().is_empty()); + + assert_eq!(app.get_active_category(), Category::Artist); + assert_eq!(app.selected_artist(), None); + assert_eq!(app.selected_album(), None); + assert_eq!(app.selected_track(), None); + + app.increment_selection(); + assert_eq!(app.get_active_category(), Category::Artist); + assert_eq!(app.selected_artist(), None); + assert_eq!(app.selected_album(), None); + assert_eq!(app.selected_track(), None); + + app.decrement_selection(); + assert_eq!(app.get_active_category(), Category::Artist); + assert_eq!(app.selected_artist(), None); + assert_eq!(app.selected_album(), None); + assert_eq!(app.selected_track(), None); + + app.increment_category(); + + app.increment_selection(); + assert_eq!(app.get_active_category(), Category::Album); + assert_eq!(app.selected_artist(), None); + assert_eq!(app.selected_album(), None); + assert_eq!(app.selected_track(), None); + + app.decrement_selection(); + assert_eq!(app.get_active_category(), Category::Album); + assert_eq!(app.selected_artist(), None); + assert_eq!(app.selected_album(), None); + assert_eq!(app.selected_track(), None); + + app.increment_category(); + + app.increment_selection(); + assert_eq!(app.get_active_category(), Category::Track); + assert_eq!(app.selected_artist(), None); + assert_eq!(app.selected_album(), None); + assert_eq!(app.selected_track(), None); + + app.decrement_selection(); + assert_eq!(app.get_active_category(), Category::Track); + assert_eq!(app.selected_artist(), None); + assert_eq!(app.selected_album(), None); + assert_eq!(app.selected_track(), None); + } +} diff --git a/src/tui/event.rs b/src/tui/event.rs new file mode 100644 index 0000000..0cb188f --- /dev/null +++ b/src/tui/event.rs @@ -0,0 +1,145 @@ +use crossterm::event::{KeyEvent, MouseEvent}; +use std::fmt; +use std::sync::mpsc; + +#[derive(Debug)] +pub enum EventError { + Send(Event), + Recv, + Io(std::io::Error), +} + +impl fmt::Display for EventError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + Self::Send(ref e) => write!(f, "failed to send event: {e:?}"), + Self::Recv => write!(f, "receive event call failed"), + Self::Io(ref e) => { + write!(f, "an I/O error was triggered during event handling: {e}") + } + } + } +} + +impl From> for EventError { + fn from(err: mpsc::SendError) -> EventError { + EventError::Send(err.0) + } +} + +impl From for EventError { + fn from(_: mpsc::RecvError) -> EventError { + EventError::Recv + } +} + +#[derive(Clone, Copy, Debug)] +pub enum Event { + Key(KeyEvent), + Mouse(MouseEvent), + Resize(u16, u16), +} + +pub struct EventChannel { + sender: mpsc::Sender, + receiver: mpsc::Receiver, +} + +pub struct EventSender { + sender: mpsc::Sender, +} + +pub struct EventReceiver { + receiver: mpsc::Receiver, +} + +impl EventChannel { + pub fn new() -> Self { + let (sender, receiver) = mpsc::channel(); + EventChannel { sender, receiver } + } + + pub fn sender(&self) -> EventSender { + EventSender { + sender: self.sender.clone(), + } + } + + pub fn receiver(self) -> EventReceiver { + EventReceiver { + receiver: self.receiver, + } + } +} + +impl EventSender { + pub fn send(&self, event: Event) -> Result<(), EventError> { + Ok(self.sender.send(event)?) + } +} + +impl EventReceiver { + pub fn recv(&self) -> Result { + Ok(self.receiver.recv()?) + } +} + +#[cfg(test)] +mod tests { + use std::io; + + use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; + + use super::{Event, EventChannel, EventError}; + + #[test] + fn event_sender() { + let channel = EventChannel::new(); + let sender = channel.sender(); + let receiver = channel.receiver(); + let event = Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::empty())); + + let result = sender.send(event); + assert!(result.is_ok()); + + drop(receiver); + let result = sender.send(event); + assert!(result.is_err()); + } + + #[test] + fn event_receiver() { + let channel = EventChannel::new(); + let sender = channel.sender(); + let receiver = channel.receiver(); + let event = Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::empty())); + + sender.send(event).unwrap(); + let result = receiver.recv(); + assert!(result.is_ok()); + + drop(sender); + let result = receiver.recv(); + assert!(result.is_err()); + } + + #[test] + fn errors() { + let send_err = EventError::Send(Event::Key(KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::empty(), + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + })); + let recv_err = EventError::Recv; + let io_err = EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "interrupted")); + + assert!(!send_err.to_string().is_empty()); + assert!(!recv_err.to_string().is_empty()); + assert!(!io_err.to_string().is_empty()); + + assert!(!format!("{:?}", send_err).is_empty()); + assert!(!format!("{:?}", recv_err).is_empty()); + assert!(!format!("{:?}", io_err).is_empty()); + } +} diff --git a/src/tui/handler.rs b/src/tui/handler.rs new file mode 100644 index 0000000..8de4cb7 --- /dev/null +++ b/src/tui/handler.rs @@ -0,0 +1,74 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +#[cfg(test)] +use mockall::automock; + +use super::{ + app::App, + event::{Event, EventError, EventReceiver}, +}; + +#[cfg_attr(test, automock)] +pub trait EventHandler { + fn handle_next_event(&self, app: &mut App) -> Result<(), EventError>; +} + +trait EventHandlerPrivate { + fn handle_key_event(app: &mut App, key_event: KeyEvent); +} + +pub struct TuiEventHandler { + events: EventReceiver, +} + +// GRCOV_EXCL_START +impl TuiEventHandler { + pub fn new(events: EventReceiver) -> Self { + TuiEventHandler { events } + } +} + +impl EventHandler for TuiEventHandler { + fn handle_next_event(&self, app: &mut App) -> Result<(), EventError> { + match self.events.recv()? { + Event::Key(key_event) => Self::handle_key_event(app, key_event), + Event::Mouse(_) => {} + Event::Resize(_, _) => {} + }; + Ok(()) + } +} + +impl EventHandlerPrivate for TuiEventHandler { + 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') => { + app.quit(); + } + // Exit application on `Ctrl-C`. + KeyCode::Char('c') | KeyCode::Char('C') => { + if key_event.modifiers == KeyModifiers::CONTROL { + app.quit(); + } + } + // Category change. + KeyCode::Left => { + app.decrement_category(); + } + KeyCode::Right => { + app.increment_category(); + } + // Selection change. + KeyCode::Up => { + app.decrement_selection(); + } + KeyCode::Down => { + app.increment_selection(); + } + // Other keys. + _ => {} + } + } +} +// GRCOV_EXCL_STOP diff --git a/src/tui/listener.rs b/src/tui/listener.rs new file mode 100644 index 0000000..53c78da --- /dev/null +++ b/src/tui/listener.rs @@ -0,0 +1,49 @@ +use crossterm::event::{self, Event as CrosstermEvent}; +use std::thread; + +#[cfg(test)] +use mockall::automock; + +use super::event::{Event, EventError, EventSender}; + +#[cfg_attr(test, automock)] +pub trait EventListener { + fn spawn(self) -> thread::JoinHandle; +} + +pub struct TuiEventListener { + events: EventSender, +} + +// GRCOV_EXCL_START +impl TuiEventListener { + pub fn new(events: EventSender) -> Self { + TuiEventListener { events } + } +} + +impl EventListener for TuiEventListener { + fn spawn(self) -> thread::JoinHandle { + thread::spawn(move || { + loop { + // Put this inside an if event::poll {...} if the display needs to be refreshed on a + // periodic basis. See + // https://github.com/tui-rs-revival/rust-tui-template/blob/master/src/event.rs. + match event::read() { + Ok(event) => { + if let Err(err) = match event { + CrosstermEvent::Key(e) => self.events.send(Event::Key(e)), + CrosstermEvent::Mouse(e) => self.events.send(Event::Mouse(e)), + CrosstermEvent::Resize(w, h) => self.events.send(Event::Resize(w, h)), + _ => unimplemented!(), + } { + return err; + } + } + Err(err) => return EventError::Io(err), + }; + } + }) + } +} +// GRCOV_EXCL_STOP diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..b3c3313 --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,303 @@ +use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; +use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; +use musichoard::collection; +use ratatui::backend::Backend; +use ratatui::Terminal; +use std::io; + +pub mod app; +pub mod event; +pub mod handler; +pub mod listener; +pub mod ui; + +use self::app::App; +use self::event::EventError; +use self::handler::EventHandler; +use self::listener::EventListener; +use self::ui::Ui; + +#[derive(Debug, PartialEq, Eq)] +pub enum Error { + Collection(String), + Io(String), + Event(String), + ListenerPanic, +} + +impl From for Error { + fn from(err: collection::Error) -> Error { + Error::Collection(err.to_string()) + } +} + +impl From for Error { + fn from(err: io::Error) -> Error { + Error::Io(err.to_string()) + } +} + +impl From for Error { + fn from(err: EventError) -> Error { + Error::Event(err.to_string()) + } +} + +pub struct Tui { + terminal: Terminal, +} + +impl Tui { + fn init(&mut self) -> Result<(), Error> { + self.terminal.hide_cursor()?; + self.terminal.clear()?; + Ok(()) + } + + fn exit(&mut self) -> Result<(), Error> { + self.terminal.show_cursor()?; + Ok(()) + } + + #[allow(unused_must_use)] + fn exit_suppress_errors(&mut self) { + self.exit(); + } + + fn main_loop(&mut self, mut app: App, ui: Ui, handler: impl EventHandler) -> Result<(), Error> { + while app.is_running() { + self.terminal.draw(|frame| ui.render(&app, frame))?; + handler.handle_next_event(&mut app)?; + } + + Ok(()) + } + + fn main( + term: Terminal, + app: App, + ui: Ui, + handler: impl EventHandler, + listener: impl EventListener, + ) -> Result<(), Error> { + let mut tui = Tui { terminal: term }; + + tui.init()?; + + let listener_handle = listener.spawn(); + let result = tui.main_loop(app, ui, handler); + + match result { + Ok(_) => { + tui.exit()?; + Ok(()) + } + Err(err) => { + // We want to call exit before handling the result to reset the terminal. Therefore, + // we suppress exit errors (if any) to not mask the original error. + tui.exit_suppress_errors(); + + if listener_handle.is_finished() { + match listener_handle.join() { + Ok(err) => return Err(err.into()), + // Calling std::panic::resume_unwind(err) as recommended by the Rust docs + // will not produce an error message. The panic error message is printed at + // the location of the panic which at the time is hidden by the TUI. + Err(_) => return Err(Error::ListenerPanic), + } + } + + Err(err) + } + } + } + + // GRCOV_EXCL_START + fn enable() -> Result<(), Error> { + terminal::enable_raw_mode()?; + crossterm::execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?; + Ok(()) + } + + fn disable() -> Result<(), Error> { + terminal::disable_raw_mode()?; + crossterm::execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?; + Ok(()) + } + + #[allow(unused_must_use)] + fn disable_suppress_errors() { + Self::disable(); + } + + pub fn run( + term: Terminal, + app: App, + ui: Ui, + handler: impl EventHandler, + listener: impl EventListener, + ) -> Result<(), Error> { + Self::enable()?; + let result = Self::main(term, app, ui, handler, listener); + match result { + Ok(_) => { + Self::disable()?; + Ok(()) + } + Err(err) => { + // We want to call disable before handling the result to reset the terminal. + // Therefore, we suppress disable errors (if any) to not mask the original error. + Self::disable_suppress_errors(); + Err(err) + } + } + } + // GRCOV_EXCL_STOP +} + +#[cfg(test)] +mod tests { + use std::{io, thread}; + + use musichoard::collection::{self, Collection}; + use ratatui::{backend::TestBackend, Terminal}; + + use crate::tests::{MockCollectionManager, COLLECTION}; + + use super::{ + app::App, event::EventError, handler::MockEventHandler, listener::MockEventListener, + ui::Ui, Error, Tui, + }; + + pub fn terminal() -> Terminal { + let backend = TestBackend::new(150, 30); + Terminal::new(backend).unwrap() + } + + pub fn app(collection: Collection) -> App { + let mut collection_manager = MockCollectionManager::new(); + + collection_manager + .expect_rescan_library() + .returning(|| Ok(())); + collection_manager + .expect_get_collection() + .return_const(collection); + + App::new(Box::new(collection_manager)).unwrap() + } + + fn listener() -> MockEventListener { + let mut listener = MockEventListener::new(); + listener.expect_spawn().return_once(|| { + thread::spawn(|| { + thread::park(); + return EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "unparked")); + }) + }); + listener + } + + fn handler() -> MockEventHandler { + let mut handler = MockEventHandler::new(); + handler.expect_handle_next_event().return_once(|app| { + app.quit(); + Ok(()) + }); + handler + } + + #[test] + fn run() { + let terminal = terminal(); + let app = app(COLLECTION.to_owned()); + let ui = Ui::new(); + + let listener = listener(); + let handler = handler(); + + let result = Tui::main(terminal, app, ui, handler, listener); + assert!(result.is_ok()); + } + + #[test] + fn event_error() { + let terminal = terminal(); + let app = app(COLLECTION.to_owned()); + let ui = Ui::new(); + + let listener = listener(); + + let mut handler = MockEventHandler::new(); + handler + .expect_handle_next_event() + .return_once(|_| Err(EventError::Recv)); + + let result = Tui::main(terminal, app, ui, handler, listener); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + Error::Event(EventError::Recv.to_string()) + ); + } + + #[test] + fn listener_error() { + let terminal = terminal(); + let app = app(COLLECTION.to_owned()); + let ui = Ui::new(); + + let error = EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "error")); + let listener_handle: thread::JoinHandle = thread::spawn(|| error); + while !listener_handle.is_finished() {} + + let mut listener = MockEventListener::new(); + listener.expect_spawn().return_once(|| listener_handle); + + let mut handler = MockEventHandler::new(); + handler + .expect_handle_next_event() + .return_once(|_| Err(EventError::Recv)); + + let result = Tui::main(terminal, app, ui, handler, listener); + assert!(result.is_err()); + + let error = EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "error")); + assert_eq!(result.unwrap_err(), Error::Event(error.to_string())); + } + + #[test] + fn listener_panic() { + let terminal = terminal(); + let app = app(COLLECTION.to_owned()); + let ui = Ui::new(); + + let listener_handle: thread::JoinHandle = thread::spawn(|| panic!()); + while !listener_handle.is_finished() {} + + let mut listener = MockEventListener::new(); + listener.expect_spawn().return_once(|| listener_handle); + + let mut handler = MockEventHandler::new(); + handler + .expect_handle_next_event() + .return_once(|_| Err(EventError::Recv)); + + let result = Tui::main(terminal, app, ui, handler, listener); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), Error::ListenerPanic); + } + + #[test] + fn errors() { + let collection_err: Error = collection::Error::DatabaseError(String::from("")).into(); + let io_err: Error = io::Error::new(io::ErrorKind::Interrupted, "error").into(); + let event_err: Error = EventError::Recv.into(); + let listener_err = Error::ListenerPanic; + + assert!(!format!("{:?}", collection_err).is_empty()); + assert!(!format!("{:?}", io_err).is_empty()); + assert!(!format!("{:?}", event_err).is_empty()); + assert!(!format!("{:?}", listener_err).is_empty()); + } +} diff --git a/src/tui/ui.rs b/src/tui/ui.rs new file mode 100644 index 0000000..448c900 --- /dev/null +++ b/src/tui/ui.rs @@ -0,0 +1,360 @@ +use musichoard::TrackFormat; +use ratatui::{ + backend::Backend, + layout::{Alignment, Rect}, + style::{Color, Style}, + widgets::{Block, BorderType, Borders, List, ListItem, ListState, Paragraph}, + Frame, +}; + +use super::app::{App, Category}; + +struct ArtistArea { + list: Rect, +} + +struct AlbumArea { + list: Rect, + info: Rect, +} + +struct TrackArea { + list: Rect, + info: Rect, +} + +struct FrameAreas { + artists: ArtistArea, + albums: AlbumArea, + tracks: TrackArea, +} + +struct SelectionList<'a> { + list: List<'a>, + state: ListState, +} + +struct ArtistState<'a> { + list: SelectionList<'a>, + active: bool, +} + +struct AlbumState<'a> { + list: SelectionList<'a>, + info: Paragraph<'a>, + active: bool, +} + +struct TrackState<'a> { + list: SelectionList<'a>, + info: Paragraph<'a>, + active: bool, +} + +struct AppState<'a> { + artists: ArtistState<'a>, + albums: AlbumState<'a>, + tracks: TrackState<'a>, +} + +pub struct Ui {} + +impl Ui { + pub fn new() -> Self { + Ui {} + } + + fn construct_areas(frame: Rect) -> FrameAreas { + let width_one_third = frame.width / 3; + let height_one_third = frame.height / 3; + + let panel_width = width_one_third; + let panel_width_last = frame.width - 2 * panel_width; + let panel_height_top = frame.height - height_one_third; + let panel_height_bottom = height_one_third; + + let artist_list = Rect { + x: frame.x, + y: frame.y, + width: panel_width, + height: frame.height, + }; + + let album_list = Rect { + x: artist_list.x + artist_list.width, + y: frame.y, + width: panel_width, + height: panel_height_top, + }; + + let album_info = Rect { + x: album_list.x, + y: album_list.y + album_list.height, + width: album_list.width, + height: panel_height_bottom, + }; + + let track_list = Rect { + x: album_list.x + album_list.width, + y: frame.y, + width: panel_width_last, + height: panel_height_top, + }; + + let track_info = Rect { + x: track_list.x, + y: track_list.y + track_list.height, + width: track_list.width, + height: panel_height_bottom, + }; + + FrameAreas { + artists: ArtistArea { list: artist_list }, + albums: AlbumArea { + list: album_list, + info: album_info, + }, + tracks: TrackArea { + list: track_list, + info: track_info, + }, + } + } + + fn construct_artist_list(app: &App) -> ArtistState { + let artists = app.get_artist_ids(); + let list = List::new( + artists + .iter() + .map(|id| ListItem::new(id.name.as_str())) + .collect::>(), + ); + + let selected_artist = app.selected_artist(); + + let mut state = ListState::default(); + state.select(selected_artist); + + let active = app.get_active_category() == Category::Artist; + + ArtistState { + list: SelectionList { list, state }, + active, + } + } + + fn construct_album_list(app: &App) -> AlbumState { + let albums = app.get_album_ids(); + let list = List::new( + albums + .iter() + .map(|id| ListItem::new(id.title.as_str())) + .collect::>(), + ); + + let selected_album = app.selected_album(); + + let mut state = ListState::default(); + state.select(selected_album); + + let active = app.get_active_category() == Category::Album; + + let album = selected_album.map(|i| albums[i]); + let info = Paragraph::new(format!( + "Title: {}\n\ + Year: {}", + album.map(|a| a.title.as_str()).unwrap_or(""), + album + .map(|a| a.year.to_string()) + .unwrap_or_else(|| "".to_string()), + )); + + AlbumState { + list: SelectionList { list, state }, + info, + active, + } + } + + fn construct_track_list(app: &App) -> TrackState { + let tracks = app.get_track_ids(); + let list = List::new( + tracks + .iter() + .map(|id| ListItem::new(id.title.as_str())) + .collect::>(), + ); + + let selected_track = app.selected_track(); + + let mut state = ListState::default(); + state.select(selected_track); + + let active = app.get_active_category() == Category::Track; + + let track = selected_track.map(|i| tracks[i]); + let info = Paragraph::new(format!( + "Track: {}\n\ + Title: {}\n\ + Artist: {}\n\ + Format: {}", + track + .map(|t| t.number.to_string()) + .unwrap_or_else(|| "".to_string()), + track.map(|t| t.title.as_str()).unwrap_or(""), + track + .map(|t| t.artist.join("; ")) + .unwrap_or_else(|| "".to_string()), + track + .map(|t| match t.format { + TrackFormat::Flac => "FLAC", + TrackFormat::Mp3 => "MP3", + }) + .unwrap_or(""), + )); + + TrackState { + list: SelectionList { list, state }, + info, + active, + } + } + + fn construct_app_state(app: &App) -> AppState { + AppState { + artists: Self::construct_artist_list(app), + albums: Self::construct_album_list(app), + tracks: Self::construct_track_list(app), + } + } + + fn style(_active: bool) -> Style { + Style::default().fg(Color::White).bg(Color::Black) + } + + fn block_style(active: bool) -> Style { + Self::style(active) + } + + fn highlight_style(active: bool) -> Style { + if active { + Style::default().fg(Color::White).bg(Color::DarkGray) + } else { + Self::style(false) + } + } + + fn block<'a>(title: &str, active: bool) -> Block<'a> { + Block::default() + .title_alignment(Alignment::Center) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(Self::block_style(active)) + .title(format!(" {title} ")) + } + + fn render_list_widget( + title: &str, + mut list: SelectionList, + active: bool, + area: Rect, + frame: &mut Frame<'_, B>, + ) { + frame.render_stateful_widget( + list.list + .highlight_style(Self::highlight_style(active)) + .highlight_symbol(">> ") + .style(Self::style(active)) + .block(Self::block(title, active)), + area, + &mut list.state, + ); + } + + fn render_info_widget( + title: &str, + paragraph: Paragraph, + active: bool, + area: Rect, + frame: &mut Frame<'_, B>, + ) { + frame.render_widget( + paragraph + .style(Self::style(active)) + .block(Self::block(title, active)), + area, + ); + } + + fn render_artist_column( + state: ArtistState, + area: ArtistArea, + frame: &mut Frame<'_, B>, + ) { + Self::render_list_widget("Artists", state.list, state.active, area.list, frame); + } + + fn render_album_column( + state: AlbumState, + area: AlbumArea, + frame: &mut Frame<'_, B>, + ) { + Self::render_list_widget("Albums", state.list, state.active, area.list, frame); + Self::render_info_widget("Album info", state.info, state.active, area.info, frame); + } + + fn render_track_column( + state: TrackState, + area: TrackArea, + frame: &mut Frame<'_, B>, + ) { + Self::render_list_widget("Tracks", state.list, state.active, area.list, frame); + Self::render_info_widget("Track info", state.info, state.active, area.info, frame); + } + + pub fn render(&self, app: &App, frame: &mut Frame<'_, B>) { + let areas = Self::construct_areas(frame.size()); + let app_state = Self::construct_app_state(app); + + Self::render_artist_column(app_state.artists, areas.artists, frame); + Self::render_album_column(app_state.albums, areas.albums, frame); + Self::render_track_column(app_state.tracks, areas.tracks, frame); + } +} + +#[cfg(test)] +mod tests { + // This is UI so the only sensible unit test is to run the code through various app states. + + use crate::{ + tests::COLLECTION, + tui::tests::{app, terminal}, + }; + + use super::Ui; + + #[test] + fn empty() { + let mut terminal = terminal(); + let app = app(vec![]); + let ui = Ui::new(); + + terminal.draw(|frame| ui.render(&app, frame)).unwrap(); + } + + #[test] + fn collection() { + let mut terminal = terminal(); + let mut app = app(COLLECTION.to_owned()); + let ui = Ui::new(); + + terminal.draw(|frame| ui.render(&app, frame)).unwrap(); + + // Change the track (which has a different track format). + app.increment_category(); + app.increment_category(); + app.increment_selection(); + + terminal.draw(|frame| ui.render(&app, frame)).unwrap(); + } +} diff --git a/tests/database/json.rs b/tests/database/json.rs index a31996d..e28d870 100644 --- a/tests/database/json.rs +++ b/tests/database/json.rs @@ -3,7 +3,7 @@ use std::{fs, path::PathBuf}; use musichoard::{ database::{ json::{JsonDatabase, JsonDatabaseFileBackend}, - DatabaseRead, DatabaseWrite, + Database, }, Artist, };