From 4303425263583686b061727f9b65cb93afa6a00f Mon Sep 17 00:00:00 2001 From: Wojciech Kozlowski Date: Mon, 10 Apr 2023 22:19:29 +0200 Subject: [PATCH] Initial TUI implementation (untested) --- Cargo.lock | 165 ++++++++++++++++++++- Cargo.toml | 14 +- README.md | 32 +++++ src/collection/mod.rs | 83 +++++++++++ src/database/json.rs | 29 ++-- src/database/mod.rs | 39 +++-- src/lib.rs | 1 + src/library/beets.rs | 10 +- src/library/mod.rs | 20 ++- src/main.rs | 55 ++++--- src/tui/app.rs | 324 +++++++++++++++++++++++++++++++++++++++++ src/tui/event.rs | 119 +++++++++++++++ src/tui/handler.rs | 53 +++++++ src/tui/mod.rs | 129 +++++++++++++++++ src/tui/ui.rs | 327 ++++++++++++++++++++++++++++++++++++++++++ 15 files changed, 1343 insertions(+), 57 deletions(-) create mode 100644 README.md create mode 100644 src/collection/mod.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/mod.rs create mode 100644 src/tui/ui.rs diff --git a/Cargo.lock b/Cargo.lock index 3bea8b4..4e41a8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,12 +22,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + [[package]] name = "bitflags" 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" @@ -55,6 +67,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 = "errno" version = "0.3.0" @@ -153,11 +190,44 @@ 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 = "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 = "musichoard" version = "0.1.0" dependencies = [ + "crossterm", "once_cell", + "ratatui", "serde", "serde_json", "structopt", @@ -171,6 +241,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 = "proc-macro-error" version = "1.0.4" @@ -213,6 +306,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" @@ -242,6 +357,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" @@ -273,6 +394,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" @@ -333,7 +490,7 @@ checksum = "b9fbec84f381d5795b08656e4912bec604d162bff9291d6189a78f4c8ab87998" dependencies = [ "cfg-if", "fastrand", - "redox_syscall", + "redox_syscall 0.3.5", "rustix", "windows-sys", ] @@ -386,6 +543,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 bbacf07..e96676e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,11 +6,13 @@ 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] -once_cell = "1.17" -tempfile = "3.5" +once_cell = "1.17.1" +tempfile = "3.5.0" diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b52fe2 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Music Hoard + +## Code Coverage + +### Pre-requisites + +``` sh +rustup component add llvm-tools-preview +cargo install grcov +``` + +### Generating + +```sh +cargo clean +env RUSTFLAGS="-C instrument-coverage" \ + LLVM_PROFILE_FILE="target/debug/profraw/musichoard-%p-%m.profraw" \ + cargo test +grcov target/debug/profraw \ + --binary-path ./target/debug/ \ + --output-types html \ + --source-dir . \ + --ignore-not-existing \ + --ignore "tests/*" \ + --ignore "src/main.rs" \ + --excl-start "#\[cfg\(test\)\]" \ + --output-path ./target/debug/coverage/ +xdg-open target/debug/coverage/index.html +``` + +Note that some changes may not be visible until `target/debug/coverage` is removed and the `grcov` +command is rerun. diff --git a/src/collection/mod.rs b/src/collection/mod.rs new file mode 100644 index 0000000..7f72d72 --- /dev/null +++ b/src/collection/mod.rs @@ -0,0 +1,83 @@ +//! 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)] +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()) + } +} + +/// 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 CollectionManager { + library: Box, + database: Box, + collection: Collection, +} + +impl CollectionManager { + /// Create a new [`CollectionManager`] with the provided [`Library`] and [`Database`]. + pub fn new( + library: Box, + database: Box, + ) -> Self { + CollectionManager { + library, + database, + collection: vec![], + } + } + + /// Rescan the library and integrate any updates into the collection. + pub fn rescan_library(&mut self) -> Result<(), Error> { + self.collection = self.library.list(&Query::default())?; + Ok(()) + } + + /// Save the collection state to the database. + pub fn save_to_database(&mut self) -> Result<(), Error> { + self.database.write(&self.collection)?; + Ok(()) + } + + /// Get the current collection. + pub fn get_collection(&self) -> &Collection { + &self.collection + } +} diff --git a/src/database/json.rs b/src/database/json.rs index 30496f4..3f0fa14 100644 --- a/src/database/json.rs +++ b/src/database/json.rs @@ -3,10 +3,15 @@ use std::fs; use std::path::{Path, PathBuf}; -use serde::de::DeserializeOwned; -use serde::Serialize; +use crate::collection::Collection; -use super::{Database, DatabaseRead, DatabaseWrite}; +use super::{Database, DatabaseRead, DatabaseWrite, Error}; + +impl From for Error { + fn from(err: serde_json::Error) -> Error { + Error::SerDeError(err.to_string()) + } +} /// Trait for the JSON database backend. pub trait JsonDatabaseBackend { @@ -19,21 +24,18 @@ 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, - { + fn read(&self, collection: &mut Collection) -> Result<(), Error> { let serialized = self.backend.read()?; *collection = serde_json::from_str(&serialized)?; Ok(()) @@ -41,10 +43,7 @@ impl DatabaseRead for JsonDatabase { } 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(()) @@ -59,7 +58,7 @@ pub struct JsonDatabaseFileBackend { } 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..bdfdd51 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,24 +1,47 @@ //! Module for storing MusicHoard data in a database. -use serde::de::DeserializeOwned; -use serde::Serialize; +use std::fmt; + +use crate::collection::Collection; pub mod json; +/// 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 database reads. pub trait DatabaseRead { /// 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. diff --git a/src/lib.rs b/src/lib.rs index d387c1c..405b783 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; diff --git a/src/library/beets.rs b/src/library/beets.rs index 77e71fe..0bdcd52 100644 --- a/src/library/beets.rs +++ b/src/library/beets.rs @@ -91,7 +91,7 @@ pub trait BeetsLibraryExecutor { /// Beets library. pub struct BeetsLibrary { - executor: Box, + executor: Box, } trait LibraryPrivate { @@ -106,8 +106,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 } } } @@ -243,7 +243,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(), @@ -259,7 +259,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") } diff --git a/src/library/mod.rs b/src/library/mod.rs index 3c1584b..1499097 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -1,6 +1,6 @@ //! Module for interacting with the music library. -use std::{num::ParseIntError, str::Utf8Error}; +use std::{num::ParseIntError, str::Utf8Error, fmt}; use crate::Artist; @@ -17,19 +17,19 @@ 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 } @@ -111,6 +111,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()) diff --git a/src/main.rs b/src/main.rs index 069d0b6..149ca59 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,23 +1,30 @@ +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::CollectionManager, + database::json::{JsonDatabase, JsonDatabaseFileBackend}, + library::beets::{BeetsLibrary, BeetsLibraryCommandExecutor}, +}; + +mod tui; +use tui::{ + app::App, + event::{EventChannel, EventListener}, + handler::EventHandler, + 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 +41,33 @@ 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 = CollectionManager::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 = EventListener::new(channel.sender()); + let handler = EventHandler::new(channel.receiver()); + + let ui = Ui::new(); + + let app = App::new(collection_manager).expect("failed to initialise app"); + + let tui = Tui::new(terminal, listener, handler, ui, app); + + // Run the TUI application. + tui.run().expect("failed to run tui"); } diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 0000000..9e2c31a --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,324 @@ +use musichoard::{collection::CollectionManager, Album, AlbumId, Artist, ArtistId, Track}; + +use super::Error; + +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum Category { + Artist, + Album, + Track, +} + +struct TrackSelection { + index: u16, +} + +impl TrackSelection { + fn initialise(tracks: &Vec) -> Option { + if !tracks.is_empty() { + Some(TrackSelection { index: 0 }) + } else { + None + } + } + + fn increment(&mut self, tracks: &Vec) { + if let Some(result) = self.index.checked_add(1) { + if (result as usize) < tracks.len() { + self.index = result; + } + } + } + + fn decrement(&mut self, _tracks: &Vec) { + if let Some(result) = self.index.checked_sub(1) { + self.index = result; + } + } +} + +struct AlbumSelection { + index: u16, + 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: &Vec) { + if let Some(result) = self.index.checked_add(1) { + if (result as usize) < albums.len() { + self.index = result; + self.track = TrackSelection::initialise(&albums[self.index as usize].tracks); + } + } + } + + fn decrement(&mut self, albums: &Vec) { + if let Some(result) = self.index.checked_sub(1) { + self.index = result; + self.track = TrackSelection::initialise(&albums[self.index as usize].tracks); + } + } +} + +struct ArtistSelection { + index: u16, + 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: &Vec) { + if let Some(result) = self.index.checked_add(1) { + if (result as usize) < artists.len() { + self.index = result; + self.album = AlbumSelection::initialise(&artists[self.index as usize].albums); + } + } + } + + fn decrement(&mut self, artists: &Vec) { + if let Some(result) = self.index.checked_sub(1) { + self.index = result; + self.album = AlbumSelection::initialise(&artists[self.index as usize].albums); + } + } +} + +struct Selection { + active: Category, + artist: Option, +} + +pub struct App { + collection_manager: CollectionManager, + selection: Selection, + running: bool, +} + +impl App { + pub fn new(mut collection_manager: CollectionManager) -> 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 as usize].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 as usize].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 as usize].albums; + let tracks = &albums[album_selection.index as usize].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 as usize].albums; + let tracks = &albums[album_selection.index as usize].tracks; + track_selection.decrement(tracks); + } + } + } + } + + pub fn get_active_category(&self) -> Category { + self.selection.active + } + + fn get_artists(&self) -> Option<&Vec> { + Some(&self.collection_manager.get_collection()) + } + + fn get_albums(&self) -> Option<&Vec> { + if let Some(artists) = self.get_artists() { + if let Some(artist_index) = self.selected_artist() { + Some(&artists[artist_index].albums) + } else { + None + } + } 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> { + if let Some(artists) = self.get_artists() { + artists.iter().map(|a| &a.id).collect() + } else { + vec![] + } + } + + 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 { + if let Some(ref artist_selection) = self.selection.artist { + Some(artist_selection.index as usize) + } else { + None + } + } + + pub fn selected_album(&self) -> Option { + if let Some(ref artist_selection) = self.selection.artist { + if let Some(ref album_selection) = artist_selection.album { + Some(album_selection.index as usize) + } else { + None + } + } 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 { + if let Some(ref track_selection) = album_selection.track { + Some(track_selection.index as usize) + } else { + None + } + } else { + None + } + } else { + None + } + } + + pub fn quit(&mut self) { + self.running = false; + } +} diff --git a/src/tui/event.rs b/src/tui/event.rs new file mode 100644 index 0000000..dae98ea --- /dev/null +++ b/src/tui/event.rs @@ -0,0 +1,119 @@ +use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEvent}; +use std::sync::mpsc; +use std::{fmt, thread}; + +#[derive(Debug)] +pub enum EventError { + SendError(Event), + RecvError, + IoError(std::io::Error), +} + +impl fmt::Display for EventError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + Self::SendError(ref e) => write!(f, "failed to send event: {e:?}"), + Self::RecvError => write!(f, "receive event call failed"), + Self::IoError(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::SendError(err.0) + } +} + +impl From for EventError { + fn from(_: mpsc::RecvError) -> EventError { + EventError::RecvError + } +} + +#[derive(Clone, Copy, Debug)] +pub enum Event { + Key(KeyEvent), + Mouse(MouseEvent), + Resize(u16, u16), +} + +pub struct EventChannel { + sender: mpsc::Sender, + receiver: mpsc::Receiver, +} + +#[derive(Clone)] +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()?) + } +} + +pub struct EventListener { + events: EventSender, +} + +impl EventListener { + pub fn new(events: EventSender) -> EventListener { + EventListener { events } + } + + pub 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::IoError(err), + }; + } + }) + } +} diff --git a/src/tui/handler.rs b/src/tui/handler.rs new file mode 100644 index 0000000..5d0effc --- /dev/null +++ b/src/tui/handler.rs @@ -0,0 +1,53 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::{app::App, event::{Event, EventReceiver, EventError}}; + +pub struct EventHandler { + events: EventReceiver, +} + +impl EventHandler { + pub fn new(events: EventReceiver) -> Self { + EventHandler { events } + } + + pub fn handle_next_event(&mut 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(()) + } + + 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. + _ => {} + } + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..9056c46 --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,129 @@ +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 ui; + +use self::app::App; +use self::event::{EventError, EventListener}; +use self::handler::EventHandler; +use self::ui::Ui; + +#[derive(Debug)] +pub enum Error { + CollectionError(String), + IoError(String), + EventError(String), + ListenerPanic, +} + +impl From for Error { + fn from(err: collection::Error) -> Error { + Error::CollectionError(err.to_string()) + } +} + +impl From for Error { + fn from(err: io::Error) -> Error { + Error::IoError(err.to_string()) + } +} + +impl From for Error { + fn from(err: EventError) -> Error { + Error::EventError(err.to_string()) + } +} + +pub struct Tui { + terminal: Terminal, + listener: Option, + handler: EventHandler, + ui: Ui, + app: App, +} + +impl Tui { + pub fn new( + terminal: Terminal, + listener: EventListener, + handler: EventHandler, + ui: Ui, + app: App, + ) -> Self { + Self { + terminal, + listener: Some(listener), + handler, + ui, + app, + } + } + + fn init(&mut self) -> Result<(), Error> { + terminal::enable_raw_mode()?; + crossterm::execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?; + self.terminal.hide_cursor()?; + self.terminal.clear()?; + Ok(()) + } + + fn run_loop(&mut self) -> Result<(), Error> { + while self.app.is_running() { + self.terminal + .draw(|frame| self.ui.render(&mut self.app, frame))?; + self.handler.handle_next_event(&mut self.app)?; + } + + Ok(()) + } + + pub fn run(mut self) -> Result<(), Error> { + self.init()?; + + let listener_handle = self.listener.take().unwrap().spawn(); + let result = self.run_loop(); + + match result { + Ok(_) => { + self.exit()?; + Ok(()) + } + Err(err) => { + // We want to call exit before handling the run_loop result to reset the terminal. + // Therefore, we suppress exit errors (if any) to not mask the original error. + self.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) + } + } + } + + fn exit(&mut self) -> Result<(), Error> { + terminal::disable_raw_mode()?; + crossterm::execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?; + self.terminal.show_cursor()?; + Ok(()) + } + + #[allow(unused_must_use)] + fn exit_suppress_errors(&mut self) { + self.exit(); + } +} diff --git a/src/tui/ui.rs b/src/tui/ui.rs new file mode 100644 index 0000000..86dd56a --- /dev/null +++ b/src/tui/ui.rs @@ -0,0 +1,327 @@ +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(&mut self, app: &App, frame: &mut Frame<'_, B>) { + // This is where you add new widgets. + // See the following resources: + // - https://docs.rs/ratatui/latest/ratatui/widgets/index.html + // - https://github.com/tui-rs-revival/ratatui/tree/master/examples + 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); + } +}