diff --git a/.gitea/workflows/gitea-ci.yaml b/.gitea/workflows/gitea-ci.yaml index 9b25437..0ec458d 100644 --- a/.gitea/workflows/gitea-ci.yaml +++ b/.gitea/workflows/gitea-ci.yaml @@ -32,6 +32,7 @@ jobs: --output-types html --source-dir . --ignore-not-existing + --ignore "build.rs" --ignore "tests/*" --ignore "src/main.rs" --ignore "src/bin/musichoard-edit.rs" diff --git a/Cargo.lock b/Cargo.lock index f662848..e240364 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.16" @@ -226,6 +235,17 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa" +[[package]] +name = "getrandom" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "gimli" version = "0.28.1" @@ -398,10 +418,12 @@ dependencies = [ name = "musichoard" version = "0.1.0" dependencies = [ + "aho-corasick", "crossterm", "mockall", "once_cell", "openssh", + "rand", "ratatui", "serde", "serde_json", @@ -410,6 +432,7 @@ dependencies = [ "tokio", "url", "uuid", + "version_check", ] [[package]] @@ -521,6 +544,12 @@ version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + [[package]] name = "predicates" version = "3.1.0" @@ -589,6 +618,36 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + [[package]] name = "ratatui" version = "0.26.0" diff --git a/Cargo.toml b/Cargo.toml index 3e83004..e188924 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,9 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +aho-corasick = { version = "1.1.2", optional = true } crossterm = { version = "0.27.0", optional = true} +once_cell = { version = "1.19.0", optional = true} openssh = { version = "0.10.3", features = ["native-mux"], default-features = false, optional = true} ratatui = { version = "0.26.0", optional = true} serde = { version = "1.0.196", features = ["derive"], optional = true } @@ -16,9 +18,13 @@ tokio = { version = "1.36.0", features = ["rt"], optional = true} url = { version = "2.5.0" } uuid = { version = "1.7.0" } +[build-dependencies] +version_check = "0.9.4" + [dev-dependencies] mockall = "0.12.1" once_cell = "1.19.0" +rand = "0.8.5" tempfile = "3.10.0" [features] @@ -27,7 +33,7 @@ bin = ["structopt"] database-json = ["serde", "serde_json"] library-beets = [] ssh-library = ["openssh", "tokio"] -tui = ["crossterm", "ratatui"] +tui = ["aho-corasick", "crossterm", "once_cell", "ratatui"] [[bin]] name = "musichoard" diff --git a/README.md b/README.md index 29444e9..f254767 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ grcov codecov/debug/profraw \ --output-types html \ --source-dir . \ --ignore-not-existing \ + --ignore "build.rs" \ --ignore "tests/*" \ --ignore "src/main.rs" \ --ignore "src/bin/musichoard-edit.rs" \ @@ -45,3 +46,17 @@ Note that some changes may not be visible until `codecov/debug/coverage` is remo command is rerun. For most cases `cargo clean` can be replaced with `rm -rf ./codecov/debug/{coverage,profraw}`. + +## Benchmarks + +### Pre-requisites + +``` sh +rustup toolchain install nightly +``` + +### Running benchmarks + +``` sh +env BEETSDIR=./ rustup run nightly cargo bench --all-features --all-targets +``` diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..9936968 --- /dev/null +++ b/build.rs @@ -0,0 +1,5 @@ +fn main() { + if let Some(true) = version_check::is_feature_flaggable() { + println!("cargo:rustc-cfg=nightly"); + } +} diff --git a/src/core/database/json/testmod.rs b/src/core/database/json/testmod.rs index de0a457..c492aed 100644 --- a/src/core/database/json/testmod.rs +++ b/src/core/database/json/testmod.rs @@ -2,7 +2,7 @@ pub static DATABASE_JSON: &str = "{\ \"V20240210\":\ [\ {\ - \"name\":\"album_artist a\",\ + \"name\":\"Album_Artist ‘A’\",\ \"sort\":null,\ \"musicbrainz\":\"https://musicbrainz.org/artist/00000000-0000-0000-0000-000000000000\",\ \"properties\":{\ @@ -11,7 +11,7 @@ pub static DATABASE_JSON: &str = "{\ }\ },\ {\ - \"name\":\"album_artist b\",\ + \"name\":\"Album_Artist ‘B’\",\ \"sort\":null,\ \"musicbrainz\":\"https://musicbrainz.org/artist/11111111-1111-1111-1111-111111111111\",\ \"properties\":{\ @@ -24,13 +24,13 @@ pub static DATABASE_JSON: &str = "{\ }\ },\ {\ - \"name\":\"album_artist c\",\ - \"sort\":null,\ + \"name\":\"The Album_Artist ‘C’\",\ + \"sort\":\"Album_Artist ‘C’, The\",\ \"musicbrainz\":\"https://musicbrainz.org/artist/11111111-1111-1111-1111-111111111111\",\ \"properties\":{}\ },\ {\ - \"name\":\"album_artist d\",\ + \"name\":\"Album_Artist ‘D’\",\ \"sort\":null,\ \"musicbrainz\":null,\ \"properties\":{}\ diff --git a/src/core/library/beets/testmod.rs b/src/core/library/beets/testmod.rs index e52045b..e4f30f9 100644 --- a/src/core/library/beets/testmod.rs +++ b/src/core/library/beets/testmod.rs @@ -2,27 +2,27 @@ use once_cell::sync::Lazy; pub static LIBRARY_BEETS: Lazy> = Lazy::new(|| -> Vec { vec![ - String::from("album_artist a -*^- -*^- 1998 -*^- album_title a.a -*^- 1 -*^- track a.a.1 -*^- artist a.a.1 -*^- FLAC -*^- 992"), - String::from("album_artist a -*^- -*^- 1998 -*^- album_title a.a -*^- 2 -*^- track a.a.2 -*^- artist a.a.2.1; artist a.a.2.2 -*^- MP3 -*^- 320"), - String::from("album_artist a -*^- -*^- 1998 -*^- album_title a.a -*^- 3 -*^- track a.a.3 -*^- artist a.a.3 -*^- FLAC -*^- 1061"), - String::from("album_artist a -*^- -*^- 1998 -*^- album_title a.a -*^- 4 -*^- track a.a.4 -*^- artist a.a.4 -*^- FLAC -*^- 1042"), - String::from("album_artist a -*^- -*^- 2015 -*^- album_title a.b -*^- 1 -*^- track a.b.1 -*^- artist a.b.1 -*^- FLAC -*^- 1004"), - String::from("album_artist a -*^- -*^- 2015 -*^- album_title a.b -*^- 2 -*^- track a.b.2 -*^- artist a.b.2 -*^- FLAC -*^- 1077"), - String::from("album_artist b -*^- -*^- 2003 -*^- album_title b.a -*^- 1 -*^- track b.a.1 -*^- artist b.a.1 -*^- MP3 -*^- 190"), - String::from("album_artist b -*^- -*^- 2003 -*^- album_title b.a -*^- 2 -*^- track b.a.2 -*^- artist b.a.2.1; artist b.a.2.2 -*^- MP3 -*^- 120"), - String::from("album_artist b -*^- -*^- 2008 -*^- album_title b.b -*^- 1 -*^- track b.b.1 -*^- artist b.b.1 -*^- FLAC -*^- 1077"), - String::from("album_artist b -*^- -*^- 2008 -*^- album_title b.b -*^- 2 -*^- track b.b.2 -*^- artist b.b.2.1; artist b.b.2.2 -*^- MP3 -*^- 320"), - String::from("album_artist b -*^- -*^- 2009 -*^- album_title b.c -*^- 1 -*^- track b.c.1 -*^- artist b.c.1 -*^- MP3 -*^- 190"), - String::from("album_artist b -*^- -*^- 2009 -*^- album_title b.c -*^- 2 -*^- track b.c.2 -*^- artist b.c.2.1; artist b.c.2.2 -*^- MP3 -*^- 120"), - String::from("album_artist b -*^- -*^- 2015 -*^- album_title b.d -*^- 1 -*^- track b.d.1 -*^- artist b.d.1 -*^- MP3 -*^- 190"), - String::from("album_artist b -*^- -*^- 2015 -*^- album_title b.d -*^- 2 -*^- track b.d.2 -*^- artist b.d.2.1; artist b.d.2.2 -*^- MP3 -*^- 120"), - String::from("album_artist c -*^- -*^- 1985 -*^- album_title c.a -*^- 1 -*^- track c.a.1 -*^- artist c.a.1 -*^- MP3 -*^- 320"), - String::from("album_artist c -*^- -*^- 1985 -*^- album_title c.a -*^- 2 -*^- track c.a.2 -*^- artist c.a.2.1; artist c.a.2.2 -*^- MP3 -*^- 120"), - String::from("album_artist c -*^- -*^- 2018 -*^- album_title c.b -*^- 1 -*^- track c.b.1 -*^- artist c.b.1 -*^- FLAC -*^- 1041"), - String::from("album_artist c -*^- -*^- 2018 -*^- album_title c.b -*^- 2 -*^- track c.b.2 -*^- artist c.b.2.1; artist c.b.2.2 -*^- FLAC -*^- 756"), - String::from("album_artist d -*^- -*^- 1995 -*^- album_title d.a -*^- 1 -*^- track d.a.1 -*^- artist d.a.1 -*^- MP3 -*^- 120"), - String::from("album_artist d -*^- -*^- 1995 -*^- album_title d.a -*^- 2 -*^- track d.a.2 -*^- artist d.a.2.1; artist d.a.2.2 -*^- MP3 -*^- 120"), - String::from("album_artist d -*^- -*^- 2028 -*^- album_title d.b -*^- 1 -*^- track d.b.1 -*^- artist d.b.1 -*^- FLAC -*^- 841"), - String::from("album_artist d -*^- -*^- 2028 -*^- album_title d.b -*^- 2 -*^- track d.b.2 -*^- artist d.b.2.1; artist d.b.2.2 -*^- FLAC -*^- 756") + String::from("Album_Artist ‘A’ -*^- -*^- 1998 -*^- album_title a.a -*^- 1 -*^- track a.a.1 -*^- artist a.a.1 -*^- FLAC -*^- 992"), + String::from("Album_Artist ‘A’ -*^- -*^- 1998 -*^- album_title a.a -*^- 2 -*^- track a.a.2 -*^- artist a.a.2.1; artist a.a.2.2 -*^- MP3 -*^- 320"), + String::from("Album_Artist ‘A’ -*^- -*^- 1998 -*^- album_title a.a -*^- 3 -*^- track a.a.3 -*^- artist a.a.3 -*^- FLAC -*^- 1061"), + String::from("Album_Artist ‘A’ -*^- -*^- 1998 -*^- album_title a.a -*^- 4 -*^- track a.a.4 -*^- artist a.a.4 -*^- FLAC -*^- 1042"), + String::from("Album_Artist ‘A’ -*^- -*^- 2015 -*^- album_title a.b -*^- 1 -*^- track a.b.1 -*^- artist a.b.1 -*^- FLAC -*^- 1004"), + String::from("Album_Artist ‘A’ -*^- -*^- 2015 -*^- album_title a.b -*^- 2 -*^- track a.b.2 -*^- artist a.b.2 -*^- FLAC -*^- 1077"), + String::from("Album_Artist ‘B’ -*^- -*^- 2003 -*^- album_title b.a -*^- 1 -*^- track b.a.1 -*^- artist b.a.1 -*^- MP3 -*^- 190"), + String::from("Album_Artist ‘B’ -*^- -*^- 2003 -*^- album_title b.a -*^- 2 -*^- track b.a.2 -*^- artist b.a.2.1; artist b.a.2.2 -*^- MP3 -*^- 120"), + String::from("Album_Artist ‘B’ -*^- -*^- 2008 -*^- album_title b.b -*^- 1 -*^- track b.b.1 -*^- artist b.b.1 -*^- FLAC -*^- 1077"), + String::from("Album_Artist ‘B’ -*^- -*^- 2008 -*^- album_title b.b -*^- 2 -*^- track b.b.2 -*^- artist b.b.2.1; artist b.b.2.2 -*^- MP3 -*^- 320"), + String::from("Album_Artist ‘B’ -*^- -*^- 2009 -*^- album_title b.c -*^- 1 -*^- track b.c.1 -*^- artist b.c.1 -*^- MP3 -*^- 190"), + String::from("Album_Artist ‘B’ -*^- -*^- 2009 -*^- album_title b.c -*^- 2 -*^- track b.c.2 -*^- artist b.c.2.1; artist b.c.2.2 -*^- MP3 -*^- 120"), + String::from("Album_Artist ‘B’ -*^- -*^- 2015 -*^- album_title b.d -*^- 1 -*^- track b.d.1 -*^- artist b.d.1 -*^- MP3 -*^- 190"), + String::from("Album_Artist ‘B’ -*^- -*^- 2015 -*^- album_title b.d -*^- 2 -*^- track b.d.2 -*^- artist b.d.2.1; artist b.d.2.2 -*^- MP3 -*^- 120"), + String::from("The Album_Artist ‘C’ -*^- Album_Artist ‘C’, The -*^- 1985 -*^- album_title c.a -*^- 1 -*^- track c.a.1 -*^- artist c.a.1 -*^- MP3 -*^- 320"), + String::from("The Album_Artist ‘C’ -*^- Album_Artist ‘C’, The -*^- 1985 -*^- album_title c.a -*^- 2 -*^- track c.a.2 -*^- artist c.a.2.1; artist c.a.2.2 -*^- MP3 -*^- 120"), + String::from("The Album_Artist ‘C’ -*^- Album_Artist ‘C’, The -*^- 2018 -*^- album_title c.b -*^- 1 -*^- track c.b.1 -*^- artist c.b.1 -*^- FLAC -*^- 1041"), + String::from("The Album_Artist ‘C’ -*^- Album_Artist ‘C’, The -*^- 2018 -*^- album_title c.b -*^- 2 -*^- track c.b.2 -*^- artist c.b.2.1; artist c.b.2.2 -*^- FLAC -*^- 756"), + String::from("Album_Artist ‘D’ -*^- -*^- 1995 -*^- album_title d.a -*^- 1 -*^- track d.a.1 -*^- artist d.a.1 -*^- MP3 -*^- 120"), + String::from("Album_Artist ‘D’ -*^- -*^- 1995 -*^- album_title d.a -*^- 2 -*^- track d.a.2 -*^- artist d.a.2.1; artist d.a.2.2 -*^- MP3 -*^- 120"), + String::from("Album_Artist ‘D’ -*^- -*^- 2028 -*^- album_title d.b -*^- 1 -*^- track d.b.1 -*^- artist d.b.1 -*^- FLAC -*^- 841"), + String::from("Album_Artist ‘D’ -*^- -*^- 2028 -*^- album_title d.b -*^- 2 -*^- track d.b.2 -*^- artist d.b.2.1; artist d.b.2.2 -*^- FLAC -*^- 756") ] }); diff --git a/src/core/library/testmod.rs b/src/core/library/testmod.rs index f86be94..af2ed68 100644 --- a/src/core/library/testmod.rs +++ b/src/core/library/testmod.rs @@ -5,7 +5,7 @@ use crate::core::{collection::track::Format, library::Item}; pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { vec![ Item { - album_artist: String::from("album_artist a"), + album_artist: String::from("Album_Artist ‘A’"), album_artist_sort: None, album_year: 1998, album_title: String::from("album_title a.a"), @@ -16,7 +16,7 @@ pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { track_bitrate: 992, }, Item { - album_artist: String::from("album_artist a"), + album_artist: String::from("Album_Artist ‘A’"), album_artist_sort: None, album_year: 1998, album_title: String::from("album_title a.a"), @@ -30,7 +30,7 @@ pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { track_bitrate: 320, }, Item { - album_artist: String::from("album_artist a"), + album_artist: String::from("Album_Artist ‘A’"), album_artist_sort: None, album_year: 1998, album_title: String::from("album_title a.a"), @@ -41,7 +41,7 @@ pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { track_bitrate: 1061, }, Item { - album_artist: String::from("album_artist a"), + album_artist: String::from("Album_Artist ‘A’"), album_artist_sort: None, album_year: 1998, album_title: String::from("album_title a.a"), @@ -52,7 +52,7 @@ pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { track_bitrate: 1042, }, Item { - album_artist: String::from("album_artist a"), + album_artist: String::from("Album_Artist ‘A’"), album_artist_sort: None, album_year: 2015, album_title: String::from("album_title a.b"), @@ -63,7 +63,7 @@ pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { track_bitrate: 1004, }, Item { - album_artist: String::from("album_artist a"), + album_artist: String::from("Album_Artist ‘A’"), album_artist_sort: None, album_year: 2015, album_title: String::from("album_title a.b"), @@ -74,7 +74,7 @@ pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { track_bitrate: 1077, }, Item { - album_artist: String::from("album_artist b"), + album_artist: String::from("Album_Artist ‘B’"), album_artist_sort: None, album_year: 2003, album_title: String::from("album_title b.a"), @@ -85,7 +85,7 @@ pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { track_bitrate: 190, }, Item { - album_artist: String::from("album_artist b"), + album_artist: String::from("Album_Artist ‘B’"), album_artist_sort: None, album_year: 2003, album_title: String::from("album_title b.a"), @@ -99,7 +99,7 @@ pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { track_bitrate: 120, }, Item { - album_artist: String::from("album_artist b"), + album_artist: String::from("Album_Artist ‘B’"), album_artist_sort: None, album_year: 2008, album_title: String::from("album_title b.b"), @@ -110,7 +110,7 @@ pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { track_bitrate: 1077, }, Item { - album_artist: String::from("album_artist b"), + album_artist: String::from("Album_Artist ‘B’"), album_artist_sort: None, album_year: 2008, album_title: String::from("album_title b.b"), @@ -124,7 +124,7 @@ pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { track_bitrate: 320, }, Item { - album_artist: String::from("album_artist b"), + album_artist: String::from("Album_Artist ‘B’"), album_artist_sort: None, album_year: 2009, album_title: String::from("album_title b.c"), @@ -135,7 +135,7 @@ pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { track_bitrate: 190, }, Item { - album_artist: String::from("album_artist b"), + album_artist: String::from("Album_Artist ‘B’"), album_artist_sort: None, album_year: 2009, album_title: String::from("album_title b.c"), @@ -149,7 +149,7 @@ pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { track_bitrate: 120, }, Item { - album_artist: String::from("album_artist b"), + album_artist: String::from("Album_Artist ‘B’"), album_artist_sort: None, album_year: 2015, album_title: String::from("album_title b.d"), @@ -160,7 +160,7 @@ pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { track_bitrate: 190, }, Item { - album_artist: String::from("album_artist b"), + album_artist: String::from("Album_Artist ‘B’"), album_artist_sort: None, album_year: 2015, album_title: String::from("album_title b.d"), @@ -174,8 +174,8 @@ pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { track_bitrate: 120, }, Item { - album_artist: String::from("album_artist c"), - album_artist_sort: None, + album_artist: String::from("The Album_Artist ‘C’"), + album_artist_sort: Some(String::from("Album_Artist ‘C’, The")), album_year: 1985, album_title: String::from("album_title c.a"), track_number: 1, @@ -185,8 +185,8 @@ pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { track_bitrate: 320, }, Item { - album_artist: String::from("album_artist c"), - album_artist_sort: None, + album_artist: String::from("The Album_Artist ‘C’"), + album_artist_sort: Some(String::from("Album_Artist ‘C’, The")), album_year: 1985, album_title: String::from("album_title c.a"), track_number: 2, @@ -199,8 +199,8 @@ pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { track_bitrate: 120, }, Item { - album_artist: String::from("album_artist c"), - album_artist_sort: None, + album_artist: String::from("The Album_Artist ‘C’"), + album_artist_sort: Some(String::from("Album_Artist ‘C’, The")), album_year: 2018, album_title: String::from("album_title c.b"), track_number: 1, @@ -210,8 +210,8 @@ pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { track_bitrate: 1041, }, Item { - album_artist: String::from("album_artist c"), - album_artist_sort: None, + album_artist: String::from("The Album_Artist ‘C’"), + album_artist_sort: Some(String::from("Album_Artist ‘C’, The")), album_year: 2018, album_title: String::from("album_title c.b"), track_number: 2, @@ -224,7 +224,7 @@ pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { track_bitrate: 756, }, Item { - album_artist: String::from("album_artist d"), + album_artist: String::from("Album_Artist ‘D’"), album_artist_sort: None, album_year: 1995, album_title: String::from("album_title d.a"), @@ -235,7 +235,7 @@ pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { track_bitrate: 120, }, Item { - album_artist: String::from("album_artist d"), + album_artist: String::from("Album_Artist ‘D’"), album_artist_sort: None, album_year: 1995, album_title: String::from("album_title d.a"), @@ -249,7 +249,7 @@ pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { track_bitrate: 120, }, Item { - album_artist: String::from("album_artist d"), + album_artist: String::from("Album_Artist ‘D’"), album_artist_sort: None, album_year: 2028, album_title: String::from("album_title d.b"), @@ -260,7 +260,7 @@ pub static LIBRARY_ITEMS: Lazy> = Lazy::new(|| -> Vec { track_bitrate: 841, }, Item { - album_artist: String::from("album_artist d"), + album_artist: String::from("Album_Artist ‘D’"), album_artist_sort: None, album_year: 2028, album_title: String::from("album_title d.b"), diff --git a/src/core/musichoard/musichoard.rs b/src/core/musichoard/musichoard.rs index 552f542..4c2515a 100644 --- a/src/core/musichoard/musichoard.rs +++ b/src/core/musichoard/musichoard.rs @@ -668,7 +668,7 @@ mod tests { let mut right: Vec = vec![left.last().unwrap().clone()]; assert!(right.first().unwrap() > left.first().unwrap()); - let artist_sort = Some(ArtistId::new("album_artist 0")); + let artist_sort = Some(ArtistId::new("Album_Artist 0")); right[0].sort = artist_sort.clone(); assert!(right.first().unwrap() < left.first().unwrap()); diff --git a/src/main.rs b/src/main.rs index 75423d0..63afadd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,7 @@ +#![cfg_attr(nightly, feature(test))] +#[cfg(nightly)] +extern crate test; + mod tui; use std::{ffi::OsString, fs::OpenOptions, io, path::PathBuf}; diff --git a/src/tests.rs b/src/tests.rs index bfd4383..bbb4de1 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -3,7 +3,7 @@ macro_rules! library_collection { vec![ Artist { id: ArtistId { - name: "album_artist a".to_string(), + name: "Album_Artist ‘A’".to_string(), }, sort: None, musicbrainz: None, @@ -98,7 +98,7 @@ macro_rules! library_collection { }, Artist { id: ArtistId { - name: "album_artist b".to_string(), + name: "Album_Artist ‘B’".to_string(), }, sort: None, musicbrainz: None, @@ -240,9 +240,11 @@ macro_rules! library_collection { }, Artist { id: ArtistId { - name: "album_artist c".to_string(), + name: "The Album_Artist ‘C’".to_string(), }, - sort: None, + sort: Some(ArtistId { + name: "Album_Artist ‘C’, The".to_string(), + }), musicbrainz: None, properties: HashMap::new(), albums: vec![ @@ -316,7 +318,7 @@ macro_rules! library_collection { }, Artist { id: ArtistId { - name: "album_artist d".to_string(), + name: "Album_Artist ‘D’".to_string(), }, sort: None, musicbrainz: None, @@ -400,7 +402,7 @@ macro_rules! full_collection { let mut iter = collection.iter_mut(); let artist_a = iter.next().unwrap(); - assert_eq!(artist_a.id.name, "album_artist a"); + assert_eq!(artist_a.id.name, "Album_Artist ‘A’"); artist_a.musicbrainz = Some( MusicBrainz::new( @@ -421,7 +423,7 @@ macro_rules! full_collection { ]); let artist_b = iter.next().unwrap(); - assert_eq!(artist_b.id.name, "album_artist b"); + assert_eq!(artist_b.id.name, "Album_Artist ‘B’"); artist_b.musicbrainz = Some( MusicBrainz::new( @@ -443,7 +445,7 @@ macro_rules! full_collection { ]); let artist_c = iter.next().unwrap(); - assert_eq!(artist_c.id.name, "album_artist c"); + assert_eq!(artist_c.id.name, "The Album_Artist ‘C’"); artist_c.musicbrainz = Some( MusicBrainz::new( diff --git a/src/tui/app/app.rs b/src/tui/app/app.rs deleted file mode 100644 index 114abca..0000000 --- a/src/tui/app/app.rs +++ /dev/null @@ -1,702 +0,0 @@ -#![allow(clippy::module_inception)] - -use musichoard::collection::Collection; - -use crate::tui::{ - app::selection::{ActiveSelection, Delta, Selection}, - lib::IMusicHoard, -}; - -pub enum AppState { - Browse(BS), - Info(IS), - Reload(RS), - Error(ES), - Critical(CS), -} - -impl AppState { - fn is_browse(&self) -> bool { - matches!(self, AppState::Browse(_)) - } - - fn is_info(&self) -> bool { - matches!(self, AppState::Info(_)) - } - - fn is_reload(&self) -> bool { - matches!(self, AppState::Reload(_)) - } - - fn is_error(&self) -> bool { - matches!(self, AppState::Error(_)) - } -} - -pub trait IAppInteract { - type BS: IAppInteractBrowse; - type IS: IAppInteractInfo; - type RS: IAppInteractReload; - type ES: IAppInteractError; - type CS: IAppInteractCritical; - - fn is_running(&self) -> bool; - fn force_quit(&mut self); - - #[allow(clippy::type_complexity)] - fn state( - &mut self, - ) -> AppState<&mut Self::BS, &mut Self::IS, &mut Self::RS, &mut Self::ES, &mut Self::CS>; -} - -pub trait IAppInteractBrowse { - fn save(&mut self); - fn quit(&mut self); - - fn increment_category(&mut self); - fn decrement_category(&mut self); - fn increment_selection(&mut self, delta: Delta); - fn decrement_selection(&mut self, delta: Delta); - - fn show_info_overlay(&mut self); - - fn show_reload_menu(&mut self); -} - -pub trait IAppInteractInfo { - fn hide_info_overlay(&mut self); -} - -pub trait IAppInteractReload { - fn reload_library(&mut self); - fn reload_database(&mut self); - fn hide_reload_menu(&mut self); -} - -pub trait IAppInteractError { - fn dismiss_error(&mut self); -} - -pub trait IAppInteractCritical {} - -// It would be preferable to have a getter for each field separately. However, the selection field -// needs to be mutably accessible requiring a mutable borrow of the entire struct if behind a trait. -// This in turn complicates simultaneous field access since only a single mutable borrow is allowed. -// Therefore, all fields are grouped into a single struct and returned as a batch. -pub trait IAppAccess { - fn get(&mut self) -> AppPublic; -} - -pub type AppPublicState = AppState<(), (), (), String, String>; - -pub struct AppPublic<'app> { - pub collection: &'app Collection, - pub selection: &'app mut Selection, - pub state: &'app AppPublicState, -} - -pub struct App { - running: bool, - music_hoard: MH, - selection: Selection, - state: AppState<(), (), (), String, String>, -} - -impl App { - pub fn new(mut music_hoard: MH) -> Self { - let state = match Self::init(&mut music_hoard) { - Ok(()) => AppState::Browse(()), - Err(err) => AppState::Critical(err.to_string()), - }; - let selection = Selection::new(music_hoard.get_collection()); - App { - running: true, - music_hoard, - selection, - state, - } - } - - fn init(music_hoard: &mut MH) -> Result<(), musichoard::Error> { - music_hoard.load_from_database()?; - music_hoard.rescan_library()?; - Ok(()) - } -} - -impl IAppInteract for App { - type BS = Self; - type IS = Self; - type RS = Self; - type ES = Self; - type CS = Self; - - fn is_running(&self) -> bool { - self.running - } - - fn force_quit(&mut self) { - self.running = false; - } - - fn state( - &mut self, - ) -> AppState<&mut Self::BS, &mut Self::IS, &mut Self::RS, &mut Self::ES, &mut Self::CS> { - match self.state { - AppState::Browse(_) => AppState::Browse(self), - AppState::Info(_) => AppState::Info(self), - AppState::Reload(_) => AppState::Reload(self), - AppState::Error(_) => AppState::Error(self), - AppState::Critical(_) => AppState::Critical(self), - } - } -} - -impl IAppInteractBrowse for App { - fn quit(&mut self) { - self.running = false; - } - - fn save(&mut self) { - if let Err(err) = self.music_hoard.save_to_database() { - self.state = AppState::Error(err.to_string()); - } - } - - fn increment_category(&mut self) { - self.selection.increment_category(); - } - - fn decrement_category(&mut self) { - self.selection.decrement_category(); - } - - fn increment_selection(&mut self, delta: Delta) { - self.selection - .increment_selection(self.music_hoard.get_collection(), delta); - } - - fn decrement_selection(&mut self, delta: Delta) { - self.selection - .decrement_selection(self.music_hoard.get_collection(), delta); - } - - fn show_info_overlay(&mut self) { - assert!(self.state.is_browse()); - self.state = AppState::Info(()); - } - - fn show_reload_menu(&mut self) { - assert!(self.state.is_browse()); - self.state = AppState::Reload(()); - } -} - -impl IAppInteractInfo for App { - fn hide_info_overlay(&mut self) { - assert!(self.state.is_info()); - self.state = AppState::Browse(()); - } -} - -impl IAppInteractReload for App { - fn reload_library(&mut self) { - let previous = ActiveSelection::get(self.music_hoard.get_collection(), &self.selection); - let result = self.music_hoard.rescan_library(); - self.refresh(previous, result); - } - - fn reload_database(&mut self) { - let previous = ActiveSelection::get(self.music_hoard.get_collection(), &self.selection); - let result = self.music_hoard.load_from_database(); - self.refresh(previous, result); - } - - fn hide_reload_menu(&mut self) { - assert!(self.state.is_reload()); - self.state = AppState::Browse(()); - } -} - -trait IAppInteractReloadPrivate { - fn refresh(&mut self, previous: ActiveSelection, result: Result<(), musichoard::Error>); -} - -impl IAppInteractReloadPrivate for App { - fn refresh(&mut self, previous: ActiveSelection, result: Result<(), musichoard::Error>) { - assert!(self.state.is_reload()); - match result { - Ok(()) => { - self.selection - .select(self.music_hoard.get_collection(), previous); - self.state = AppState::Browse(()) - } - Err(err) => self.state = AppState::Error(err.to_string()), - } - } -} - -impl IAppInteractError for App { - fn dismiss_error(&mut self) { - assert!(self.state.is_error()); - self.state = AppState::Browse(()); - } -} - -impl IAppInteractCritical for App {} - -impl IAppAccess for App { - fn get(&mut self) -> AppPublic { - AppPublic { - collection: self.music_hoard.get_collection(), - selection: &mut self.selection, - state: &self.state, - } - } -} - -#[cfg(test)] -mod tests { - use crate::tui::{app::selection::Category, lib::MockIMusicHoard, testmod::COLLECTION}; - - use super::*; - - fn music_hoard(collection: Collection) -> MockIMusicHoard { - let mut music_hoard = MockIMusicHoard::new(); - - music_hoard - .expect_load_from_database() - .times(1) - .return_once(|| Ok(())); - music_hoard - .expect_rescan_library() - .times(1) - .return_once(|| Ok(())); - music_hoard.expect_get_collection().return_const(collection); - - music_hoard - } - - #[test] - fn running_quit() { - let mut app = App::new(music_hoard(COLLECTION.to_owned())); - assert!(app.is_running()); - - app.quit(); - assert!(!app.is_running()); - } - - #[test] - fn error_quit() { - let mut app = App::new(music_hoard(COLLECTION.to_owned())); - assert!(app.is_running()); - - app.state = AppState::Error(String::from("get rekt")); - - app.dismiss_error(); - - app.quit(); - assert!(!app.is_running()); - } - - #[test] - fn running_force_quit() { - let mut app = App::new(music_hoard(COLLECTION.to_owned())); - assert!(app.is_running()); - - app.force_quit(); - assert!(!app.is_running()); - } - - #[test] - fn error_force_quit() { - let mut app = App::new(music_hoard(COLLECTION.to_owned())); - assert!(app.is_running()); - - app.state = AppState::Error(String::from("get rekt")); - - app.force_quit(); - assert!(!app.is_running()); - } - - #[test] - fn save() { - let mut music_hoard = music_hoard(COLLECTION.to_owned()); - - music_hoard - .expect_save_to_database() - .times(1) - .return_once(|| Ok(())); - - let mut app = App::new(music_hoard); - - app.save(); - assert!(app.state.is_browse()); - } - - #[test] - fn save_error() { - let mut music_hoard = music_hoard(COLLECTION.to_owned()); - - music_hoard - .expect_save_to_database() - .times(1) - .return_once(|| Err(musichoard::Error::DatabaseError(String::from("get rekt")))); - - let mut app = App::new(music_hoard); - - app.save(); - - assert!(app.state.is_error()); - } - - #[test] - fn init_error() { - let mut music_hoard = MockIMusicHoard::new(); - - music_hoard - .expect_load_from_database() - .times(1) - .return_once(|| Err(musichoard::Error::DatabaseError(String::from("get rekt")))); - music_hoard.expect_get_collection().return_const(vec![]); - - let mut app = App::new(music_hoard); - - assert!(app.is_running()); - assert!(matches!(app.state(), AppState::Critical(_))); - } - - #[test] - fn modifiers() { - let mut app = App::new(music_hoard(COLLECTION.to_owned())); - assert!(app.is_running()); - - assert_eq!(app.selection.active, Category::Artist); - assert_eq!(app.selection.artist.state.list.selected(), Some(0)); - assert_eq!(app.selection.artist.album.state.list.selected(), Some(0)); - assert_eq!( - app.selection.artist.album.track.state.list.selected(), - Some(0) - ); - - app.increment_selection(Delta::Line); - assert_eq!(app.selection.active, Category::Artist); - assert_eq!(app.selection.artist.state.list.selected(), Some(1)); - assert_eq!(app.selection.artist.album.state.list.selected(), Some(0)); - assert_eq!( - app.selection.artist.album.track.state.list.selected(), - Some(0) - ); - - app.increment_category(); - assert_eq!(app.selection.active, Category::Album); - assert_eq!(app.selection.artist.state.list.selected(), Some(1)); - assert_eq!(app.selection.artist.album.state.list.selected(), Some(0)); - assert_eq!( - app.selection.artist.album.track.state.list.selected(), - Some(0) - ); - - app.increment_selection(Delta::Line); - assert_eq!(app.selection.active, Category::Album); - assert_eq!(app.selection.artist.state.list.selected(), Some(1)); - assert_eq!(app.selection.artist.album.state.list.selected(), Some(1)); - assert_eq!( - app.selection.artist.album.track.state.list.selected(), - Some(0) - ); - - app.increment_category(); - assert_eq!(app.selection.active, Category::Track); - assert_eq!(app.selection.artist.state.list.selected(), Some(1)); - assert_eq!(app.selection.artist.album.state.list.selected(), Some(1)); - assert_eq!( - app.selection.artist.album.track.state.list.selected(), - Some(0) - ); - - app.increment_selection(Delta::Line); - assert_eq!(app.selection.active, Category::Track); - assert_eq!(app.selection.artist.state.list.selected(), Some(1)); - assert_eq!(app.selection.artist.album.state.list.selected(), Some(1)); - assert_eq!( - app.selection.artist.album.track.state.list.selected(), - Some(1) - ); - - app.increment_category(); - assert_eq!(app.selection.active, Category::Track); - assert_eq!(app.selection.artist.state.list.selected(), Some(1)); - assert_eq!(app.selection.artist.album.state.list.selected(), Some(1)); - assert_eq!( - app.selection.artist.album.track.state.list.selected(), - Some(1) - ); - - app.decrement_selection(Delta::Line); - assert_eq!(app.selection.active, Category::Track); - assert_eq!(app.selection.artist.state.list.selected(), Some(1)); - assert_eq!(app.selection.artist.album.state.list.selected(), Some(1)); - assert_eq!( - app.selection.artist.album.track.state.list.selected(), - Some(0) - ); - - app.increment_selection(Delta::Line); - app.decrement_category(); - assert_eq!(app.selection.active, Category::Album); - assert_eq!(app.selection.artist.state.list.selected(), Some(1)); - assert_eq!(app.selection.artist.album.state.list.selected(), Some(1)); - assert_eq!( - app.selection.artist.album.track.state.list.selected(), - Some(1) - ); - - app.decrement_selection(Delta::Line); - assert_eq!(app.selection.active, Category::Album); - assert_eq!(app.selection.artist.state.list.selected(), Some(1)); - assert_eq!(app.selection.artist.album.state.list.selected(), Some(0)); - assert_eq!( - app.selection.artist.album.track.state.list.selected(), - Some(0) - ); - - app.increment_selection(Delta::Line); - app.decrement_category(); - assert_eq!(app.selection.active, Category::Artist); - assert_eq!(app.selection.artist.state.list.selected(), Some(1)); - assert_eq!(app.selection.artist.album.state.list.selected(), Some(1)); - assert_eq!( - app.selection.artist.album.track.state.list.selected(), - Some(0) - ); - - app.decrement_selection(Delta::Line); - assert_eq!(app.selection.active, Category::Artist); - assert_eq!(app.selection.artist.state.list.selected(), Some(0)); - assert_eq!(app.selection.artist.album.state.list.selected(), Some(0)); - assert_eq!( - app.selection.artist.album.track.state.list.selected(), - Some(0) - ); - - app.increment_category(); - app.increment_selection(Delta::Line); - app.decrement_category(); - app.decrement_selection(Delta::Line); - app.decrement_category(); - assert_eq!(app.selection.active, Category::Artist); - assert_eq!(app.selection.artist.state.list.selected(), Some(0)); - assert_eq!(app.selection.artist.album.state.list.selected(), Some(1)); - assert_eq!( - app.selection.artist.album.track.state.list.selected(), - Some(0) - ); - } - - #[test] - fn no_tracks() { - let mut collection = COLLECTION.to_owned(); - collection[0].albums[0].tracks = vec![]; - - let mut app = App::new(music_hoard(collection)); - assert!(app.is_running()); - - assert_eq!(app.selection.active, Category::Artist); - assert_eq!(app.selection.artist.state.list.selected(), Some(0)); - assert_eq!(app.selection.artist.album.state.list.selected(), Some(0)); - assert_eq!(app.selection.artist.album.track.state.list.selected(), None); - - app.increment_category(); - app.increment_category(); - - app.increment_selection(Delta::Line); - assert_eq!(app.selection.active, Category::Track); - assert_eq!(app.selection.artist.state.list.selected(), Some(0)); - assert_eq!(app.selection.artist.album.state.list.selected(), Some(0)); - assert_eq!(app.selection.artist.album.track.state.list.selected(), None); - - app.decrement_selection(Delta::Line); - assert_eq!(app.selection.active, Category::Track); - assert_eq!(app.selection.artist.state.list.selected(), Some(0)); - assert_eq!(app.selection.artist.album.state.list.selected(), Some(0)); - assert_eq!(app.selection.artist.album.track.state.list.selected(), None); - } - - #[test] - fn no_albums() { - let mut collection = COLLECTION.to_owned(); - collection[0].albums = vec![]; - - let mut app = App::new(music_hoard(collection)); - assert!(app.is_running()); - - assert_eq!(app.selection.active, Category::Artist); - assert_eq!(app.selection.artist.state.list.selected(), Some(0)); - assert_eq!(app.selection.artist.album.state.list.selected(), None); - assert_eq!(app.selection.artist.album.track.state.list.selected(), None); - - app.increment_category(); - - app.increment_selection(Delta::Line); - assert_eq!(app.selection.active, Category::Album); - assert_eq!(app.selection.artist.state.list.selected(), Some(0)); - assert_eq!(app.selection.artist.album.state.list.selected(), None); - assert_eq!(app.selection.artist.album.track.state.list.selected(), None); - - app.decrement_selection(Delta::Line); - assert_eq!(app.selection.active, Category::Album); - assert_eq!(app.selection.artist.state.list.selected(), Some(0)); - assert_eq!(app.selection.artist.album.state.list.selected(), None); - assert_eq!(app.selection.artist.album.track.state.list.selected(), None); - - app.increment_category(); - - app.increment_selection(Delta::Line); - assert_eq!(app.selection.active, Category::Track); - assert_eq!(app.selection.artist.state.list.selected(), Some(0)); - assert_eq!(app.selection.artist.album.state.list.selected(), None); - assert_eq!(app.selection.artist.album.track.state.list.selected(), None); - - app.decrement_selection(Delta::Line); - assert_eq!(app.selection.active, Category::Track); - assert_eq!(app.selection.artist.state.list.selected(), Some(0)); - assert_eq!(app.selection.artist.album.state.list.selected(), None); - assert_eq!(app.selection.artist.album.track.state.list.selected(), None); - } - - #[test] - fn no_artists() { - let mut app = App::new(music_hoard(vec![])); - assert!(app.is_running()); - - assert_eq!(app.selection.active, Category::Artist); - assert_eq!(app.selection.artist.state.list.selected(), None); - assert_eq!(app.selection.artist.album.state.list.selected(), None); - assert_eq!(app.selection.artist.album.track.state.list.selected(), None); - - app.increment_selection(Delta::Line); - assert_eq!(app.selection.active, Category::Artist); - assert_eq!(app.selection.artist.state.list.selected(), None); - assert_eq!(app.selection.artist.album.state.list.selected(), None); - assert_eq!(app.selection.artist.album.track.state.list.selected(), None); - - app.decrement_selection(Delta::Line); - assert_eq!(app.selection.active, Category::Artist); - assert_eq!(app.selection.artist.state.list.selected(), None); - assert_eq!(app.selection.artist.album.state.list.selected(), None); - assert_eq!(app.selection.artist.album.track.state.list.selected(), None); - - app.increment_category(); - - app.increment_selection(Delta::Line); - assert_eq!(app.selection.active, Category::Album); - assert_eq!(app.selection.artist.state.list.selected(), None); - assert_eq!(app.selection.artist.album.state.list.selected(), None); - assert_eq!(app.selection.artist.album.track.state.list.selected(), None); - - app.decrement_selection(Delta::Line); - assert_eq!(app.selection.active, Category::Album); - assert_eq!(app.selection.artist.state.list.selected(), None); - assert_eq!(app.selection.artist.album.state.list.selected(), None); - assert_eq!(app.selection.artist.album.track.state.list.selected(), None); - - app.increment_category(); - - app.increment_selection(Delta::Line); - assert_eq!(app.selection.active, Category::Track); - assert_eq!(app.selection.artist.state.list.selected(), None); - assert_eq!(app.selection.artist.album.state.list.selected(), None); - assert_eq!(app.selection.artist.album.track.state.list.selected(), None); - - app.decrement_selection(Delta::Line); - assert_eq!(app.selection.active, Category::Track); - assert_eq!(app.selection.artist.state.list.selected(), None); - assert_eq!(app.selection.artist.album.state.list.selected(), None); - assert_eq!(app.selection.artist.album.track.state.list.selected(), None); - } - - #[test] - fn info_overlay() { - let mut app = App::new(music_hoard(COLLECTION.to_owned())); - assert!(app.state().is_browse()); - - app.show_info_overlay(); - assert!(app.state().is_info()); - - app.hide_info_overlay(); - assert!(app.state().is_browse()); - } - - #[test] - fn reload_hide_menu() { - let mut app = App::new(music_hoard(COLLECTION.to_owned())); - assert!(app.state().is_browse()); - - app.show_reload_menu(); - assert!(app.state().is_reload()); - - app.hide_reload_menu(); - assert!(app.state().is_browse()); - } - - #[test] - fn reload_database() { - let mut music_hoard = music_hoard(COLLECTION.to_owned()); - - music_hoard - .expect_load_from_database() - .times(1) - .return_once(|| Ok(())); - - let mut app = App::new(music_hoard); - assert!(app.state().is_browse()); - - app.show_reload_menu(); - assert!(app.state().is_reload()); - - app.reload_database(); - assert!(app.state().is_browse()); - } - - #[test] - fn reload_library() { - let mut music_hoard = music_hoard(COLLECTION.to_owned()); - - music_hoard - .expect_rescan_library() - .times(1) - .return_once(|| Ok(())); - - let mut app = App::new(music_hoard); - assert!(app.state().is_browse()); - - app.show_reload_menu(); - assert!(app.state().is_reload()); - - app.reload_library(); - assert!(app.state().is_browse()); - } - - #[test] - fn reload_error() { - let mut music_hoard = music_hoard(COLLECTION.to_owned()); - - music_hoard - .expect_load_from_database() - .times(1) - .return_once(|| Err(musichoard::Error::DatabaseError(String::from("get rekt")))); - - let mut app = App::new(music_hoard); - assert!(app.state().is_browse()); - - app.show_reload_menu(); - assert!(app.state().is_reload()); - - app.reload_database(); - assert!(app.state().is_error()); - - app.dismiss_error(); - assert!(app.state().is_browse()); - } -} diff --git a/src/tui/app/machine/browse.rs b/src/tui/app/machine/browse.rs new file mode 100644 index 0000000..f327ee3 --- /dev/null +++ b/src/tui/app/machine/browse.rs @@ -0,0 +1,208 @@ +use crate::tui::{ + app::{ + machine::{App, AppInner, AppMachine}, + selection::{Delta, ListSelection}, + AppPublic, AppState, IAppInteractBrowse, + }, + lib::IMusicHoard, +}; + +pub struct AppBrowse; + +impl AppMachine { + pub fn browse(inner: AppInner) -> Self { + AppMachine { + inner, + state: AppBrowse, + } + } +} + +impl From> for App { + fn from(machine: AppMachine) -> Self { + AppState::Browse(machine) + } +} + +impl<'a, MH: IMusicHoard> From<&'a mut AppMachine> for AppPublic<'a> { + fn from(machine: &'a mut AppMachine) -> Self { + AppPublic { + inner: (&mut machine.inner).into(), + state: AppState::Browse(()), + } + } +} + +impl IAppInteractBrowse for AppMachine { + type APP = App; + + fn save_and_quit(mut self) -> Self::APP { + match self.inner.music_hoard.save_to_database() { + Ok(_) => { + self.inner.running = false; + self.into() + } + Err(err) => AppMachine::error(self.inner, err.to_string()).into(), + } + } + + fn increment_category(mut self) -> Self::APP { + self.inner.selection.increment_category(); + self.into() + } + + fn decrement_category(mut self) -> Self::APP { + self.inner.selection.decrement_category(); + self.into() + } + + fn increment_selection(mut self, delta: Delta) -> Self::APP { + self.inner + .selection + .increment_selection(self.inner.music_hoard.get_collection(), delta); + self.into() + } + + fn decrement_selection(mut self, delta: Delta) -> Self::APP { + self.inner + .selection + .decrement_selection(self.inner.music_hoard.get_collection(), delta); + self.into() + } + + fn show_info_overlay(self) -> Self::APP { + AppMachine::info(self.inner).into() + } + + fn show_reload_menu(self) -> Self::APP { + AppMachine::reload(self.inner).into() + } + + fn begin_search(mut self) -> Self::APP { + let orig = ListSelection::get(&self.inner.selection); + self.inner + .selection + .reset_artist(self.inner.music_hoard.get_collection()); + AppMachine::search(self.inner, orig).into() + } + + fn no_op(self) -> Self::APP { + self.into() + } +} + +#[cfg(test)] +mod tests { + use crate::tui::{ + app::{ + machine::tests::{inner, music_hoard}, + Category, IAppInteract, + }, + testmod::COLLECTION, + }; + + use super::*; + + #[test] + fn save_and_quit() { + let mut music_hoard = music_hoard(vec![]); + + music_hoard + .expect_save_to_database() + .times(1) + .return_once(|| Ok(())); + + let browse = AppMachine::browse(inner(music_hoard)); + + let app = browse.save_and_quit(); + assert!(!app.is_running()); + app.unwrap_browse(); + } + + #[test] + fn save_and_quit_error() { + let mut music_hoard = music_hoard(vec![]); + + music_hoard + .expect_save_to_database() + .times(1) + .return_once(|| Err(musichoard::Error::DatabaseError(String::from("get rekt")))); + + let browse = AppMachine::browse(inner(music_hoard)); + + let app = browse.save_and_quit(); + assert!(app.is_running()); + app.unwrap_error(); + } + + #[test] + fn increment_decrement() { + let mut browse = AppMachine::browse(inner(music_hoard(COLLECTION.to_owned()))); + let sel = &browse.inner.selection; + assert_eq!(sel.active, Category::Artist); + assert_eq!(sel.artist.state.list.selected(), Some(0)); + + browse = browse.increment_selection(Delta::Line).unwrap_browse(); + let sel = &browse.inner.selection; + assert_eq!(sel.active, Category::Artist); + assert_eq!(sel.artist.state.list.selected(), Some(1)); + + browse = browse.increment_category().unwrap_browse(); + let sel = &browse.inner.selection; + assert_eq!(sel.active, Category::Album); + assert_eq!(sel.artist.state.list.selected(), Some(1)); + assert_eq!(sel.artist.album.state.list.selected(), Some(0)); + + browse = browse.increment_selection(Delta::Line).unwrap_browse(); + let sel = &browse.inner.selection; + assert_eq!(sel.active, Category::Album); + assert_eq!(sel.artist.state.list.selected(), Some(1)); + assert_eq!(sel.artist.album.state.list.selected(), Some(1)); + + browse = browse.decrement_selection(Delta::Line).unwrap_browse(); + let sel = &browse.inner.selection; + assert_eq!(sel.active, Category::Album); + assert_eq!(sel.artist.state.list.selected(), Some(1)); + assert_eq!(sel.artist.album.state.list.selected(), Some(0)); + + browse = browse.decrement_category().unwrap_browse(); + let sel = &browse.inner.selection; + assert_eq!(sel.active, Category::Artist); + assert_eq!(sel.artist.state.list.selected(), Some(1)); + assert_eq!(sel.artist.album.state.list.selected(), Some(0)); + + browse = browse.decrement_selection(Delta::Line).unwrap_browse(); + let sel = &browse.inner.selection; + assert_eq!(sel.active, Category::Artist); + assert_eq!(sel.artist.state.list.selected(), Some(0)); + assert_eq!(sel.artist.album.state.list.selected(), Some(0)); + } + + #[test] + fn show_info_overlay() { + let browse = AppMachine::browse(inner(music_hoard(vec![]))); + let app = browse.show_info_overlay(); + app.unwrap_info(); + } + + #[test] + fn show_reload_menu() { + let browse = AppMachine::browse(inner(music_hoard(vec![]))); + let app = browse.show_reload_menu(); + app.unwrap_reload(); + } + + #[test] + fn begin_search() { + let browse = AppMachine::browse(inner(music_hoard(vec![]))); + let app = browse.begin_search(); + app.unwrap_search(); + } + + #[test] + fn no_op() { + let browse = AppMachine::browse(inner(music_hoard(vec![]))); + let app = browse.no_op(); + app.unwrap_browse(); + } +} diff --git a/src/tui/app/machine/critical.rs b/src/tui/app/machine/critical.rs new file mode 100644 index 0000000..5fe911d --- /dev/null +++ b/src/tui/app/machine/critical.rs @@ -0,0 +1,59 @@ +use crate::tui::{ + app::{ + machine::{App, AppInner, AppMachine}, + AppPublic, AppState, IAppInteractCritical, + }, + lib::IMusicHoard, +}; + +pub struct AppCritical { + string: String, +} + +impl AppMachine { + pub fn critical>(inner: AppInner, string: S) -> Self { + AppMachine { + inner, + state: AppCritical { + string: string.into(), + }, + } + } +} + +impl From> for App { + fn from(machine: AppMachine) -> Self { + AppState::Critical(machine) + } +} + +impl<'a, MH: IMusicHoard> From<&'a mut AppMachine> for AppPublic<'a> { + fn from(machine: &'a mut AppMachine) -> Self { + AppPublic { + inner: (&mut machine.inner).into(), + state: AppState::Critical(&machine.state.string), + } + } +} + +impl IAppInteractCritical for AppMachine { + type APP = App; + + fn no_op(self) -> Self::APP { + self.into() + } +} + +#[cfg(test)] +mod tests { + use crate::tui::app::machine::tests::{inner, music_hoard}; + + use super::*; + + #[test] + fn no_op() { + let critical = AppMachine::critical(inner(music_hoard(vec![])), "get rekt"); + let app = critical.no_op(); + app.unwrap_critical(); + } +} diff --git a/src/tui/app/machine/error.rs b/src/tui/app/machine/error.rs new file mode 100644 index 0000000..63239d5 --- /dev/null +++ b/src/tui/app/machine/error.rs @@ -0,0 +1,59 @@ +use crate::tui::{ + app::{ + machine::{App, AppInner, AppMachine}, + AppPublic, AppState, IAppInteractError, + }, + lib::IMusicHoard, +}; + +pub struct AppError { + string: String, +} + +impl AppMachine { + pub fn error>(inner: AppInner, string: S) -> Self { + AppMachine { + inner, + state: AppError { + string: string.into(), + }, + } + } +} + +impl From> for App { + fn from(machine: AppMachine) -> Self { + AppState::Error(machine) + } +} + +impl<'a, MH: IMusicHoard> From<&'a mut AppMachine> for AppPublic<'a> { + fn from(machine: &'a mut AppMachine) -> Self { + AppPublic { + inner: (&mut machine.inner).into(), + state: AppState::Error(&machine.state.string), + } + } +} + +impl IAppInteractError for AppMachine { + type APP = App; + + fn dismiss_error(self) -> Self::APP { + AppMachine::browse(self.inner).into() + } +} + +#[cfg(test)] +mod tests { + use crate::tui::app::machine::tests::{inner, music_hoard}; + + use super::*; + + #[test] + fn dismiss_error() { + let error = AppMachine::error(inner(music_hoard(vec![])), "get rekt"); + let app = error.dismiss_error(); + app.unwrap_browse(); + } +} diff --git a/src/tui/app/machine/info.rs b/src/tui/app/machine/info.rs new file mode 100644 index 0000000..e6e005d --- /dev/null +++ b/src/tui/app/machine/info.rs @@ -0,0 +1,66 @@ +use crate::tui::{ + app::{ + machine::{App, AppInner, AppMachine}, + AppPublic, AppState, IAppInteractInfo, + }, + lib::IMusicHoard, +}; + +pub struct AppInfo; + +impl AppMachine { + pub fn info(inner: AppInner) -> Self { + AppMachine { + inner, + state: AppInfo, + } + } +} + +impl From> for App { + fn from(machine: AppMachine) -> Self { + AppState::Info(machine) + } +} + +impl<'a, MH: IMusicHoard> From<&'a mut AppMachine> for AppPublic<'a> { + fn from(machine: &'a mut AppMachine) -> Self { + AppPublic { + inner: (&mut machine.inner).into(), + state: AppState::Info(()), + } + } +} + +impl IAppInteractInfo for AppMachine { + type APP = App; + + fn hide_info_overlay(self) -> Self::APP { + AppMachine::browse(self.inner).into() + } + + fn no_op(self) -> Self::APP { + self.into() + } +} + +#[cfg(test)] +mod tests { + use crate::tui::app::machine::tests::{inner, music_hoard}; + + use super::*; + + #[test] + fn hide_info_overlay() { + let info = AppMachine::info(inner(music_hoard(vec![]))); + let app = info.hide_info_overlay(); + app.unwrap_browse(); + } + + #[test] + fn no_op() { + let info = AppMachine::info(inner(music_hoard(vec![]))); + let app = info.no_op(); + app.unwrap_info(); + } +} diff --git a/src/tui/app/machine/mod.rs b/src/tui/app/machine/mod.rs new file mode 100644 index 0000000..d5bea93 --- /dev/null +++ b/src/tui/app/machine/mod.rs @@ -0,0 +1,335 @@ +mod browse; +mod critical; +mod error; +mod info; +mod reload; +mod search; + +use crate::tui::{ + app::{selection::Selection, AppPublic, AppPublicInner, AppState, IAppAccess, IAppInteract}, + lib::IMusicHoard, +}; + +use browse::AppBrowse; +use critical::AppCritical; +use error::AppError; +use info::AppInfo; +use reload::AppReload; +use search::AppSearch; + +pub type App = AppState< + AppMachine, + AppMachine, + AppMachine, + AppMachine, + AppMachine, + AppMachine, +>; + +pub struct AppMachine { + inner: AppInner, + state: STATE, +} + +pub struct AppInner { + running: bool, + music_hoard: MH, + selection: Selection, +} + +impl App { + pub fn new(mut music_hoard: MH) -> Self { + let init_result = Self::init(&mut music_hoard); + let inner = AppInner::new(music_hoard); + match init_result { + Ok(()) => AppMachine::browse(inner).into(), + Err(err) => AppMachine::critical(inner, err.to_string()).into(), + } + } + + fn init(music_hoard: &mut MH) -> Result<(), musichoard::Error> { + music_hoard.load_from_database()?; + music_hoard.rescan_library()?; + Ok(()) + } + + fn inner_ref(&self) -> &AppInner { + match self { + AppState::Browse(browse) => &browse.inner, + AppState::Info(info) => &info.inner, + AppState::Reload(reload) => &reload.inner, + AppState::Search(search) => &search.inner, + AppState::Error(error) => &error.inner, + AppState::Critical(critical) => &critical.inner, + } + } + + fn inner_mut(&mut self) -> &mut AppInner { + match self { + AppState::Browse(browse) => &mut browse.inner, + AppState::Info(info) => &mut info.inner, + AppState::Reload(reload) => &mut reload.inner, + AppState::Search(search) => &mut search.inner, + AppState::Error(error) => &mut error.inner, + AppState::Critical(critical) => &mut critical.inner, + } + } +} + +impl IAppInteract for App { + type BS = AppMachine; + type IS = AppMachine; + type RS = AppMachine; + type SS = AppMachine; + type ES = AppMachine; + type CS = AppMachine; + + fn is_running(&self) -> bool { + self.inner_ref().running + } + + fn force_quit(mut self) -> Self { + self.inner_mut().running = false; + self + } + + fn state(self) -> AppState { + self + } +} + +impl IAppAccess for App { + fn get(&mut self) -> AppPublic { + match self { + AppState::Browse(browse) => browse.into(), + AppState::Info(info) => info.into(), + AppState::Reload(reload) => reload.into(), + AppState::Search(search) => search.into(), + AppState::Error(error) => error.into(), + AppState::Critical(critical) => critical.into(), + } + } +} + +impl AppInner { + pub fn new(music_hoard: MH) -> Self { + let selection = Selection::new(music_hoard.get_collection()); + AppInner { + running: true, + music_hoard, + selection, + } + } +} + +impl<'a, MH: IMusicHoard> From<&'a mut AppInner> for AppPublicInner<'a> { + fn from(inner: &'a mut AppInner) -> Self { + AppPublicInner { + collection: inner.music_hoard.get_collection(), + selection: &mut inner.selection, + } + } +} + +#[cfg(test)] +mod tests { + use musichoard::collection::Collection; + + use crate::tui::{ + app::{AppState, IAppInteract, IAppInteractBrowse}, + lib::MockIMusicHoard, + }; + + use super::*; + + impl AppState { + pub fn unwrap_browse(self) -> BS { + match self { + AppState::Browse(browse) => browse, + _ => panic!(), + } + } + + pub fn unwrap_info(self) -> IS { + match self { + AppState::Info(info) => info, + _ => panic!(), + } + } + + pub fn unwrap_reload(self) -> RS { + match self { + AppState::Reload(reload) => reload, + _ => panic!(), + } + } + + pub fn unwrap_search(self) -> SS { + match self { + AppState::Search(search) => search, + _ => panic!(), + } + } + + pub fn unwrap_error(self) -> ES { + match self { + AppState::Error(error) => error, + _ => panic!(), + } + } + + pub fn unwrap_critical(self) -> CS { + match self { + AppState::Critical(critical) => critical, + _ => panic!(), + } + } + } + + pub fn music_hoard(collection: Collection) -> MockIMusicHoard { + let mut music_hoard = MockIMusicHoard::new(); + music_hoard.expect_get_collection().return_const(collection); + + music_hoard + } + + fn music_hoard_init(collection: Collection) -> MockIMusicHoard { + let mut music_hoard = music_hoard(collection); + + music_hoard + .expect_load_from_database() + .times(1) + .return_once(|| Ok(())); + music_hoard + .expect_rescan_library() + .times(1) + .return_once(|| Ok(())); + + music_hoard + } + + pub fn inner(music_hoard: MockIMusicHoard) -> AppInner { + AppInner::new(music_hoard) + } + + #[test] + fn state_browse() { + let mut app = App::new(music_hoard_init(vec![])); + assert!(app.is_running()); + + let state = app.state(); + matches!(state, AppState::Browse(_)); + app = state; + + let public = app.get(); + matches!(public.state, AppState::Browse(_)); + + let app = app.force_quit(); + assert!(!app.is_running()); + } + + #[test] + fn state_info() { + let mut app = App::new(music_hoard_init(vec![])); + assert!(app.is_running()); + + app = app.unwrap_browse().show_info_overlay(); + + let state = app.state(); + matches!(state, AppState::Info(_)); + app = state; + + let public = app.get(); + matches!(public.state, AppState::Info(_)); + + let app = app.force_quit(); + assert!(!app.is_running()); + } + + #[test] + fn state_reload() { + let mut app = App::new(music_hoard_init(vec![])); + assert!(app.is_running()); + + app = app.unwrap_browse().show_reload_menu(); + + let state = app.state(); + matches!(state, AppState::Reload(_)); + app = state; + + let public = app.get(); + matches!(public.state, AppState::Reload(_)); + + let app = app.force_quit(); + assert!(!app.is_running()); + } + + #[test] + fn state_search() { + let mut app = App::new(music_hoard_init(vec![])); + assert!(app.is_running()); + + app = app.unwrap_browse().begin_search(); + + let state = app.state(); + matches!(state, AppState::Search(_)); + app = state; + + let public = app.get(); + matches!(public.state, AppState::Search("")); + + let app = app.force_quit(); + assert!(!app.is_running()); + } + + #[test] + fn state_error() { + let mut app = App::new(music_hoard_init(vec![])); + assert!(app.is_running()); + + app = AppMachine::error(app.unwrap_browse().inner, "get rekt").into(); + + let state = app.state(); + matches!(state, AppState::Error(_)); + app = state; + + let public = app.get(); + matches!(public.state, AppState::Error("get rekt")); + + app = app.force_quit(); + assert!(!app.is_running()); + } + + #[test] + fn state_critical() { + let mut app = App::new(music_hoard_init(vec![])); + assert!(app.is_running()); + + app = AppMachine::critical(app.unwrap_browse().inner, "get rekt").into(); + + let state = app.state(); + matches!(state, AppState::Critical(_)); + app = state; + + let public = app.get(); + matches!(public.state, AppState::Critical("get rekt")); + + app = app.force_quit(); + assert!(!app.is_running()); + } + + #[test] + fn init_error() { + let mut music_hoard = MockIMusicHoard::new(); + + music_hoard + .expect_load_from_database() + .times(1) + .return_once(|| Err(musichoard::Error::DatabaseError(String::from("get rekt")))); + music_hoard.expect_get_collection().return_const(vec![]); + + let app = App::new(music_hoard); + assert!(app.is_running()); + app.unwrap_critical(); + } +} diff --git a/src/tui/app/machine/reload.rs b/src/tui/app/machine/reload.rs new file mode 100644 index 0000000..79564f8 --- /dev/null +++ b/src/tui/app/machine/reload.rs @@ -0,0 +1,144 @@ +use crate::tui::{ + app::{ + machine::{App, AppInner, AppMachine}, + selection::IdSelection, + AppPublic, AppState, IAppInteractReload, + }, + lib::IMusicHoard, +}; + +pub struct AppReload; + +impl AppMachine { + pub fn reload(inner: AppInner) -> Self { + AppMachine { + inner, + state: AppReload, + } + } +} + +impl From> for App { + fn from(machine: AppMachine) -> Self { + AppState::Reload(machine) + } +} +impl<'a, MH: IMusicHoard> From<&'a mut AppMachine> for AppPublic<'a> { + fn from(machine: &'a mut AppMachine) -> Self { + AppPublic { + inner: (&mut machine.inner).into(), + state: AppState::Reload(()), + } + } +} + +impl IAppInteractReload for AppMachine { + type APP = App; + + fn reload_library(mut self) -> Self::APP { + let previous = IdSelection::get( + self.inner.music_hoard.get_collection(), + &self.inner.selection, + ); + let result = self.inner.music_hoard.rescan_library(); + self.refresh(previous, result) + } + + fn reload_database(mut self) -> Self::APP { + let previous = IdSelection::get( + self.inner.music_hoard.get_collection(), + &self.inner.selection, + ); + let result = self.inner.music_hoard.load_from_database(); + self.refresh(previous, result) + } + + fn hide_reload_menu(self) -> Self::APP { + AppMachine::browse(self.inner).into() + } + + fn no_op(self) -> Self::APP { + self.into() + } +} + +trait IAppInteractReloadPrivate { + fn refresh(self, previous: IdSelection, result: Result<(), musichoard::Error>) -> App; +} + +impl IAppInteractReloadPrivate for AppMachine { + fn refresh(mut self, previous: IdSelection, result: Result<(), musichoard::Error>) -> App { + match result { + Ok(()) => { + self.inner + .selection + .select_by_id(self.inner.music_hoard.get_collection(), previous); + AppMachine::browse(self.inner).into() + } + Err(err) => AppMachine::error(self.inner, err.to_string()).into(), + } + } +} + +#[cfg(test)] +mod tests { + use crate::tui::app::machine::tests::{inner, music_hoard}; + + use super::*; + + #[test] + fn hide_reload_menu() { + let reload = AppMachine::reload(inner(music_hoard(vec![]))); + let app = reload.hide_reload_menu(); + app.unwrap_browse(); + } + + #[test] + fn reload_database() { + let mut music_hoard = music_hoard(vec![]); + + music_hoard + .expect_load_from_database() + .times(1) + .return_once(|| Ok(())); + + let reload = AppMachine::reload(inner(music_hoard)); + let app = reload.reload_database(); + app.unwrap_browse(); + } + + #[test] + fn reload_library() { + let mut music_hoard = music_hoard(vec![]); + + music_hoard + .expect_rescan_library() + .times(1) + .return_once(|| Ok(())); + + let reload = AppMachine::reload(inner(music_hoard)); + let app = reload.reload_library(); + app.unwrap_browse(); + } + + #[test] + fn reload_error() { + let mut music_hoard = music_hoard(vec![]); + + music_hoard + .expect_load_from_database() + .times(1) + .return_once(|| Err(musichoard::Error::DatabaseError(String::from("get rekt")))); + + let reload = AppMachine::reload(inner(music_hoard)); + let app = reload.reload_database(); + app.unwrap_error(); + } + + #[test] + fn no_op() { + let reload = AppMachine::reload(inner(music_hoard(vec![]))); + let app = reload.no_op(); + app.unwrap_reload(); + } +} diff --git a/src/tui/app/machine/search.rs b/src/tui/app/machine/search.rs new file mode 100644 index 0000000..ebde2b8 --- /dev/null +++ b/src/tui/app/machine/search.rs @@ -0,0 +1,526 @@ +use aho_corasick::AhoCorasick; +use once_cell::sync::Lazy; + +use musichoard::collection::artist::Artist; + +use crate::tui::{ + app::{ + machine::{App, AppInner, AppMachine}, + selection::ListSelection, + AppPublic, AppState, IAppInteractSearch, + }, + lib::IMusicHoard, +}; + +// Unlikely that this covers all possible strings, but it should at least cover strings +// relevant for music (at least in English). The list of characters handled is based on +// https://wiki.musicbrainz.org/User:Yurim/Punctuation_and_Special_Characters. +// +// U+2010 hyphen, U+2012 figure dash, U+2013 en dash, U+2014 em dash, U+2015 horizontal bar, U+2018, +// U+2019, U+201C, U+201D, U+2026, U+2212 minus sign +static SPECIAL: [char; 11] = ['‐', '‒', '–', '—', '―', '‘', '’', '“', '”', '…', '−']; +static REPLACE: [&str; 11] = ["-", "-", "-", "-", "-", "'", "'", "\"", "\"", "...", "-"]; +static AC: Lazy = + Lazy::new(|| AhoCorasick::new(SPECIAL.map(|ch| ch.to_string())).unwrap()); + +pub struct AppSearch { + string: String, + orig: ListSelection, + memo: Vec, +} + +struct AppSearchMemo { + index: Option, + char: bool, +} + +impl AppMachine { + pub fn search(inner: AppInner, orig: ListSelection) -> Self { + AppMachine { + inner, + state: AppSearch { + string: String::new(), + orig, + memo: vec![], + }, + } + } +} + +impl From> for App { + fn from(machine: AppMachine) -> Self { + AppState::Search(machine) + } +} + +impl<'a, MH: IMusicHoard> From<&'a mut AppMachine> for AppPublic<'a> { + fn from(machine: &'a mut AppMachine) -> Self { + AppPublic { + inner: (&mut machine.inner).into(), + state: AppState::Search(&machine.state.string), + } + } +} + +impl IAppInteractSearch for AppMachine { + type APP = App; + + fn append_character(mut self, ch: char) -> Self::APP { + self.state.string.push(ch); + let index = self.inner.selection.artist.state.list.selected(); + self.state.memo.push(AppSearchMemo { index, char: true }); + self.incremental_search(false); + self.into() + } + + fn search_next(mut self) -> Self::APP { + if !self.state.string.is_empty() { + let index = self.inner.selection.artist.state.list.selected(); + self.state.memo.push(AppSearchMemo { index, char: false }); + self.incremental_search(true); + } + self.into() + } + + fn step_back(mut self) -> Self::APP { + let collection = self.inner.music_hoard.get_collection(); + if let Some(memo) = self.state.memo.pop() { + if memo.char { + self.state.string.pop(); + } + self.inner.selection.select_artist(collection, memo.index); + } + self.into() + } + + fn finish_search(self) -> Self::APP { + AppMachine::browse(self.inner).into() + } + + fn cancel_search(mut self) -> Self::APP { + self.inner.selection.select_by_list(self.state.orig); + AppMachine::browse(self.inner).into() + } + + fn no_op(self) -> Self::APP { + self.into() + } +} + +trait IAppInteractSearchPrivate { + fn incremental_search(&mut self, next: bool); + fn incremental_search_predicate( + case_sensitive: bool, + char_sensitive: bool, + search_name: &str, + probe: &Artist, + ) -> bool; + + fn is_case_sensitive(artist_name: &str) -> bool; + fn is_char_sensitive(artist_name: &str) -> bool; + fn normalize_search(search: &str, lowercase: bool, asciify: bool) -> String; +} + +impl IAppInteractSearchPrivate for AppMachine { + fn incremental_search(&mut self, next: bool) { + let artists = self.inner.music_hoard.get_collection(); + let artist_name = &self.state.string; + + let sel = &mut self.inner.selection; + if let Some(mut index) = sel.selected_artist() { + let case_sensitive = Self::is_case_sensitive(artist_name); + let char_sensitive = Self::is_char_sensitive(artist_name); + let search = Self::normalize_search(artist_name, !case_sensitive, !char_sensitive); + + if next && ((index + 1) < artists.len()) { + index += 1; + } + let slice = &artists[index..]; + + let result = slice.iter().position(|probe| { + Self::incremental_search_predicate(case_sensitive, char_sensitive, &search, probe) + }); + + if let Some(slice_index) = result { + sel.select_artist(artists, Some(index + slice_index)); + } + } + } + + fn incremental_search_predicate( + case_sensitive: bool, + char_sensitive: bool, + search_name: &str, + probe: &Artist, + ) -> bool { + let name = Self::normalize_search(&probe.id.name, !case_sensitive, !char_sensitive); + let mut result = name.starts_with(search_name); + + if let Some(ref probe_sort) = probe.sort { + if !result { + let name = + Self::normalize_search(&probe_sort.name, !case_sensitive, !char_sensitive); + result = name.starts_with(search_name); + } + } + + result + } + + fn is_case_sensitive(artist_name: &str) -> bool { + artist_name + .chars() + .any(|ch| ch.is_alphabetic() && ch.is_uppercase()) + } + + fn is_char_sensitive(artist_name: &str) -> bool { + // Benchmarking reveals that using AhoCorasick is slower. At a guess, this is likely due to + // a high constant cost of AhoCorasick and the otherwise simple nature of the task. + artist_name.chars().any(|ch| SPECIAL.contains(&ch)) + } + + fn normalize_search(search: &str, lowercase: bool, asciify: bool) -> String { + if asciify { + if lowercase { + AC.replace_all(&search.to_lowercase(), &REPLACE) + } else { + AC.replace_all(search, &REPLACE) + } + } else if lowercase { + search.to_lowercase() + } else { + search.to_owned() + } + } +} + +#[cfg(test)] +mod tests { + use ratatui::widgets::ListState; + + use crate::tui::{ + app::machine::tests::{inner, music_hoard}, + testmod::COLLECTION, + }; + + use super::*; + + fn orig(index: Option) -> ListSelection { + let mut artist = ListState::default(); + artist.select(index); + + ListSelection { + artist, + album: ListState::default(), + track: ListState::default(), + } + } + + #[test] + fn artist_incremental_search() { + // Empty collection. + let mut search = AppMachine::search(inner(music_hoard(vec![])), orig(None)); + assert_eq!(search.inner.selection.artist.state.list.selected(), None); + + search.state.string = String::from("album_artist 'a'"); + search.incremental_search(false); + assert_eq!(search.inner.selection.artist.state.list.selected(), None); + + // Basic test, first element. + let mut search = + AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1))); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0)); + + search.state.string = String::from(""); + search.incremental_search(false); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0)); + + search.state.string = String::from("album_artist "); + search.incremental_search(false); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0)); + + search.state.string = String::from("album_artist 'a'"); + search.incremental_search(false); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0)); + + // Basic test, non-first element. + let mut search = + AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1))); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0)); + + search.state.string = String::from("album_artist "); + search.incremental_search(false); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0)); + + search.state.string = String::from("album_artist 'c'"); + search.incremental_search(false); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2)); + + // Non-lowercase. + let mut search = + AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1))); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0)); + + search.state.string = String::from("Album_Artist "); + search.incremental_search(false); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0)); + + search.state.string = String::from("Album_Artist 'C'"); + search.incremental_search(false); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2)); + + // Non-ascii. + let mut search = + AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1))); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0)); + + search.state.string = String::from("album_artist "); + search.incremental_search(false); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0)); + + search.state.string = String::from("album_artist ‘c’"); + search.incremental_search(false); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2)); + + // Non-lowercase, non-ascii. + let mut search = + AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1))); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0)); + + search.state.string = String::from("Album_Artist "); + search.incremental_search(false); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0)); + + search.state.string = String::from("Album_Artist ‘C’"); + search.incremental_search(false); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2)); + + // Stop at name, not sort name. + let mut search = + AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1))); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0)); + + search.state.string = String::from("the "); + search.incremental_search(false); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2)); + + search.state.string = String::from("the album_artist 'c'"); + search.incremental_search(false); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2)); + + // Search next with common prefix. + let mut search = + AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1))); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0)); + + search.state.string = String::from("album_artist"); + search.incremental_search(false); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0)); + + search.incremental_search(true); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(1)); + + search.incremental_search(true); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2)); + + search.incremental_search(true); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(3)); + + search.incremental_search(true); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(3)); + } + + #[test] + fn search() { + let search = AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(2))); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0)); + + let search = search.append_character('a').unwrap_search(); + let search = search.append_character('l').unwrap_search(); + let search = search.append_character('b').unwrap_search(); + let search = search.append_character('u').unwrap_search(); + let search = search.append_character('m').unwrap_search(); + let search = search.append_character('_').unwrap_search(); + let search = search.append_character('a').unwrap_search(); + let search = search.append_character('r').unwrap_search(); + let search = search.append_character('t').unwrap_search(); + let search = search.append_character('i').unwrap_search(); + let search = search.append_character('s').unwrap_search(); + let search = search.append_character('t').unwrap_search(); + let search = search.append_character(' ').unwrap_search(); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0)); + + let search = search.append_character('\'').unwrap_search(); + let search = search.append_character('c').unwrap_search(); + let search = search.append_character('\'').unwrap_search(); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2)); + + let search = search.step_back().unwrap_search(); + let search = search.step_back().unwrap_search(); + let search = search.step_back().unwrap_search(); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0)); + + let search = search.append_character('\'').unwrap_search(); + let search = search.append_character('b').unwrap_search(); + let search = search.append_character('\'').unwrap_search(); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(1)); + + let app = search.finish_search(); + let browse = app.unwrap_browse(); + assert_eq!(browse.inner.selection.artist.state.list.selected(), Some(1)); + } + + #[test] + fn search_next_step_back() { + let search = AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(2))); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0)); + + let search = search.step_back().unwrap_search(); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0)); + + let search = search.append_character('a').unwrap_search(); + let search = search.search_next().unwrap_search(); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(1)); + + let search = search.search_next().unwrap_search(); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2)); + + let search = search.search_next().unwrap_search(); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(3)); + + let search = search.search_next().unwrap_search(); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(3)); + + let search = search.step_back().unwrap_search(); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(3)); + + let search = search.step_back().unwrap_search(); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2)); + + let search = search.step_back().unwrap_search(); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(1)); + + let search = search.step_back().unwrap_search(); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0)); + + let search = search.step_back().unwrap_search(); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0)); + } + + #[test] + fn cancel_search() { + let search = AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(2))); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0)); + + let search = search.append_character('a').unwrap_search(); + let search = search.append_character('l').unwrap_search(); + let search = search.append_character('b').unwrap_search(); + let search = search.append_character('u').unwrap_search(); + let search = search.append_character('m').unwrap_search(); + let search = search.append_character('_').unwrap_search(); + let search = search.append_character('a').unwrap_search(); + let search = search.append_character('r').unwrap_search(); + let search = search.append_character('t').unwrap_search(); + let search = search.append_character('i').unwrap_search(); + let search = search.append_character('s').unwrap_search(); + let search = search.append_character('t').unwrap_search(); + let search = search.append_character(' ').unwrap_search(); + let search = search.append_character('\'').unwrap_search(); + let search = search.append_character('b').unwrap_search(); + let search = search.append_character('\'').unwrap_search(); + assert_eq!(search.inner.selection.artist.state.list.selected(), Some(1)); + + let browse = search.cancel_search().unwrap_browse(); + assert_eq!(browse.inner.selection.artist.state.list.selected(), Some(2)); + } + + #[test] + fn empty_search() { + let search = AppMachine::search(inner(music_hoard(vec![])), orig(None)); + assert_eq!(search.inner.selection.artist.state.list.selected(), None); + + let search = search.append_character('a').unwrap_search(); + assert_eq!(search.inner.selection.artist.state.list.selected(), None); + + let search = search.search_next().unwrap_search(); + assert_eq!(search.inner.selection.artist.state.list.selected(), None); + + let search = search.step_back().unwrap_search(); + assert_eq!(search.inner.selection.artist.state.list.selected(), None); + + let search = search.step_back().unwrap_search(); + assert_eq!(search.inner.selection.artist.state.list.selected(), None); + + let browse = search.cancel_search().unwrap_browse(); + assert_eq!(browse.inner.selection.artist.state.list.selected(), None); + } + + #[test] + fn no_op() { + let search = AppMachine::search(inner(music_hoard(vec![])), orig(None)); + let app = search.no_op(); + app.unwrap_search(); + } +} + +#[cfg(nightly)] +#[cfg(test)] +mod benches { + // The purpose of these benches was to evaluate the benefit of AhoCorasick over std solutions. + + use rand::Rng; + use test::Bencher; + + use crate::tui::lib::MockIMusicHoard; + + use super::*; + + type Search = AppMachine; + + fn random_utf8_string(len: usize) -> String { + rand::thread_rng() + .sample_iter::(&rand::distributions::Standard) + .take(len) + .collect() + } + + fn random_alpanumeric_string(len: usize) -> String { + rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(len) + .map(char::from) + .collect() + } + + fn generate_sample(f: fn(usize) -> String) -> Vec { + (0..1000).map(|_| f(10)).collect() + } + + #[bench] + fn is_char_sensitive_alphanumeric(b: &mut Bencher) { + let strings = generate_sample(random_alpanumeric_string); + let mut iter = strings.iter().cycle(); + b.iter(|| test::black_box(Search::is_char_sensitive(&iter.next().unwrap()))) + } + + #[bench] + fn is_char_sensitive_utf8(b: &mut Bencher) { + let strings = generate_sample(random_utf8_string); + let mut iter = strings.iter().cycle(); + b.iter(|| test::black_box(Search::is_char_sensitive(&iter.next().unwrap()))) + } + + #[bench] + fn normalize_search_alphanumeric(b: &mut Bencher) { + let strings = generate_sample(random_alpanumeric_string); + let mut iter = strings.iter().cycle(); + b.iter(|| test::black_box(Search::normalize_search(&iter.next().unwrap(), false, true))) + } + + #[bench] + fn normalize_search_utf8(b: &mut Bencher) { + let strings = generate_sample(random_utf8_string); + let mut iter = strings.iter().cycle(); + b.iter(|| test::black_box(Search::normalize_search(&iter.next().unwrap(), false, true))) + } +} diff --git a/src/tui/app/mod.rs b/src/tui/app/mod.rs index 57892fa..66f612a 100644 --- a/src/tui/app/mod.rs +++ b/src/tui/app/mod.rs @@ -1,2 +1,129 @@ -pub mod app; -pub mod selection; +mod machine; +mod selection; + +pub use machine::App; +pub use selection::{Category, Delta, Selection, WidgetState}; + +use musichoard::collection::Collection; + +pub enum AppState { + Browse(BS), + Info(IS), + Reload(RS), + Search(SS), + Error(ES), + Critical(CS), +} + +pub trait IAppInteract { + type BS: IAppInteractBrowse; + type IS: IAppInteractInfo; + type RS: IAppInteractReload; + type SS: IAppInteractSearch; + type ES: IAppInteractError; + type CS: IAppInteractCritical; + + fn is_running(&self) -> bool; + fn force_quit(self) -> Self; + + #[allow(clippy::type_complexity)] + fn state(self) -> AppState; +} + +pub trait IAppInteractBrowse { + type APP: IAppInteract; + + fn save_and_quit(self) -> Self::APP; + + fn increment_category(self) -> Self::APP; + fn decrement_category(self) -> Self::APP; + fn increment_selection(self, delta: Delta) -> Self::APP; + fn decrement_selection(self, delta: Delta) -> Self::APP; + + fn show_info_overlay(self) -> Self::APP; + + fn show_reload_menu(self) -> Self::APP; + + fn begin_search(self) -> Self::APP; + + fn no_op(self) -> Self::APP; +} + +pub trait IAppInteractInfo { + type APP: IAppInteract; + + fn hide_info_overlay(self) -> Self::APP; + + fn no_op(self) -> Self::APP; +} + +pub trait IAppInteractReload { + type APP: IAppInteract; + + fn reload_library(self) -> Self::APP; + fn reload_database(self) -> Self::APP; + fn hide_reload_menu(self) -> Self::APP; + + fn no_op(self) -> Self::APP; +} + +pub trait IAppInteractSearch { + type APP: IAppInteract; + + fn append_character(self, ch: char) -> Self::APP; + fn search_next(self) -> Self::APP; + fn step_back(self) -> Self::APP; + fn finish_search(self) -> Self::APP; + fn cancel_search(self) -> Self::APP; + + fn no_op(self) -> Self::APP; +} + +pub trait IAppInteractError { + type APP: IAppInteract; + + fn dismiss_error(self) -> Self::APP; +} + +pub trait IAppInteractCritical { + type APP: IAppInteract; + + fn no_op(self) -> Self::APP; +} + +// It would be preferable to have a getter for each field separately. However, the selection field +// needs to be mutably accessible requiring a mutable borrow of the entire struct if behind a trait. +// This in turn complicates simultaneous field access since only a single mutable borrow is allowed. +// Therefore, all fields are grouped into a single struct and returned as a batch. +pub trait IAppAccess { + fn get(&mut self) -> AppPublic; +} + +pub struct AppPublic<'app> { + pub inner: AppPublicInner<'app>, + pub state: AppPublicState<'app>, +} + +pub struct AppPublicInner<'app> { + pub collection: &'app Collection, + pub selection: &'app mut Selection, +} + +pub type AppPublicState<'app> = AppState<(), (), (), &'app str, &'app str, &'app str>; + +impl AppState { + pub fn is_search(&self) -> bool { + matches!(self, AppState::Search(_)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn app_is_state() { + let state = AppPublicState::Search("get rekt"); + assert!(state.is_search()); + } +} diff --git a/src/tui/app/selection.rs b/src/tui/app/selection.rs index 6438bd0..6c4db7d 100644 --- a/src/tui/app/selection.rs +++ b/src/tui/app/selection.rs @@ -5,6 +5,7 @@ use musichoard::collection::{ Collection, }; use ratatui::widgets::ListState; +use std::cmp; #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum Category { @@ -69,10 +70,30 @@ impl Selection { } } - pub fn select(&mut self, artists: &[Artist], selected: ActiveSelection) { + pub fn select_by_list(&mut self, selected: ListSelection) { + self.artist.state.list = selected.artist; + self.artist.album.state.list = selected.album; + self.artist.album.track.state.list = selected.track; + } + + pub fn select_by_id(&mut self, artists: &[Artist], selected: IdSelection) { self.artist.reinitialise(artists, selected.artist); } + pub fn select_artist(&mut self, artists: &[Artist], index: Option) { + self.artist.select(artists, index); + } + + pub fn selected_artist(&self) -> Option { + self.artist.selected() + } + + pub fn reset_artist(&mut self, artists: &[Artist]) { + if self.artist.state.list.selected() != Some(0) { + self.select_by_id(artists, IdSelection { artist: None }); + } + } + pub fn increment_category(&mut self) { self.active = match self.active { Category::Artist => Category::Album, @@ -140,7 +161,7 @@ impl ArtistSelection { selection } - fn reinitialise(&mut self, artists: &[Artist], active: Option) { + fn reinitialise(&mut self, artists: &[Artist], active: Option) { if let Some(active) = active { let result = artists.binary_search_by(|a| a.get_sort_key().cmp(&active.artist_id)); match result { @@ -156,7 +177,7 @@ impl ArtistSelection { &mut self, artists: &[Artist], index: usize, - active_album: Option, + active_album: Option, ) { if artists.is_empty() { self.state.list.select(None); @@ -172,16 +193,29 @@ impl ArtistSelection { } } + fn selected(&self) -> Option { + self.state.list.selected() + } + + fn select(&mut self, artists: &[Artist], to: Option) { + match to { + Some(to) => self.select_to(artists, to), + None => self.state.list.select(None), + } + } + + fn select_to(&mut self, artists: &[Artist], mut to: usize) { + to = cmp::min(to, artists.len() - 1); + if self.state.list.selected() != Some(to) { + self.state.list.select(Some(to)); + self.album = AlbumSelection::initialise(&artists[to].albums); + } + } + fn increment_by(&mut self, artists: &[Artist], by: usize) { if let Some(index) = self.state.list.selected() { - let mut result = index.saturating_add(by); - if result >= artists.len() { - result = artists.len() - 1; - } - if self.state.list.selected() != Some(result) { - self.state.list.select(Some(result)); - self.album = AlbumSelection::initialise(&artists[result].albums); - } + let result = index.saturating_add(by); + self.select_to(artists, result); } } @@ -238,7 +272,7 @@ impl AlbumSelection { selection } - fn reinitialise(&mut self, albums: &[Album], album: Option) { + fn reinitialise(&mut self, albums: &[Album], album: Option) { if let Some(album) = album { let result = albums.binary_search_by(|a| a.get_sort_key().cmp(&album.album_id)); match result { @@ -254,7 +288,7 @@ impl AlbumSelection { &mut self, albums: &[Album], index: usize, - active_track: Option, + active_track: Option, ) { if albums.is_empty() { self.state.list.select(None); @@ -322,7 +356,7 @@ impl TrackSelection { selection } - fn reinitialise(&mut self, tracks: &[Track], track: Option) { + fn reinitialise(&mut self, tracks: &[Track], track: Option) { if let Some(track) = track { let result = tracks.binary_search_by(|t| t.get_sort_key().cmp(&track.track_id)); match result { @@ -373,61 +407,77 @@ impl TrackSelection { } } -pub struct ActiveSelection { - artist: Option, +pub struct ListSelection { + pub artist: ListState, + pub album: ListState, + pub track: ListState, } -struct ActiveArtist { - artist_id: ArtistId, - album: Option, -} - -struct ActiveAlbum { - album_id: AlbumId, - track: Option, -} - -struct ActiveTrack { - track_id: TrackId, -} - -impl ActiveSelection { - pub fn get(collection: &Collection, selection: &Selection) -> Self { - ActiveSelection { - artist: ActiveArtist::get(collection, &selection.artist), +impl ListSelection { + pub fn get(selection: &Selection) -> Self { + ListSelection { + artist: selection.artist.state.list.clone(), + album: selection.artist.album.state.list.clone(), + track: selection.artist.album.track.state.list.clone(), } } } -impl ActiveArtist { +pub struct IdSelection { + artist: Option, +} + +struct IdSelectArtist { + artist_id: ArtistId, + album: Option, +} + +struct IdSelectAlbum { + album_id: AlbumId, + track: Option, +} + +struct IdSelectTrack { + track_id: TrackId, +} + +impl IdSelection { + pub fn get(collection: &Collection, selection: &Selection) -> Self { + IdSelection { + artist: IdSelectArtist::get(collection, &selection.artist), + } + } +} + +impl IdSelectArtist { fn get(artists: &[Artist], selection: &ArtistSelection) -> Option { selection.state.list.selected().map(|index| { let artist = &artists[index]; - ActiveArtist { + IdSelectArtist { artist_id: artist.get_sort_key().clone(), - album: ActiveAlbum::get(&artist.albums, &selection.album), + album: IdSelectAlbum::get(&artist.albums, &selection.album), } }) } } -impl ActiveAlbum { +impl IdSelectAlbum { fn get(albums: &[Album], selection: &AlbumSelection) -> Option { selection.state.list.selected().map(|index| { let album = &albums[index]; - ActiveAlbum { + IdSelectAlbum { album_id: album.get_sort_key().clone(), - track: ActiveTrack::get(&album.tracks, &selection.track), + track: IdSelectTrack::get(&album.tracks, &selection.track), } }) } } -impl ActiveTrack { +impl IdSelectTrack { fn get(tracks: &[Track], selection: &TrackSelection) -> Option { selection.state.list.selected().map(|index| { let track = &tracks[index]; - ActiveTrack { + IdSelectTrack { track_id: track.get_sort_key().clone(), } }) @@ -445,7 +495,13 @@ mod tests { let tracks = &COLLECTION[0].albums[0].tracks; assert!(tracks.len() > 1); - let empty = TrackSelection::initialise(&[]); + let mut empty = TrackSelection::initialise(&[]); + assert_eq!(empty.state.list.selected(), None); + + empty.increment(tracks, Delta::Line); + assert_eq!(empty.state.list.selected(), None); + + empty.decrement(tracks, Delta::Line); assert_eq!(empty.state.list.selected(), None); let mut sel = TrackSelection::initialise(tracks); @@ -505,20 +561,20 @@ mod tests { // Re-initialise. let expected = sel.clone(); - let active_track = ActiveTrack::get(tracks, &sel); + let active_track = IdSelectTrack::get(tracks, &sel); sel.reinitialise(tracks, active_track); assert_eq!(sel, expected); // Re-initialise out-of-bounds. let mut expected = sel.clone(); expected.decrement(tracks, Delta::Line); - let active_track = ActiveTrack::get(tracks, &sel); + let active_track = IdSelectTrack::get(tracks, &sel); sel.reinitialise(&tracks[..(tracks.len() - 1)], active_track); assert_eq!(sel, expected); // Re-initialise empty. let expected = TrackSelection::initialise(&[]); - let active_track = ActiveTrack::get(tracks, &sel); + let active_track = IdSelectTrack::get(tracks, &sel); sel.reinitialise(&[], active_track); assert_eq!(sel, expected); } @@ -528,8 +584,17 @@ mod tests { let albums = &COLLECTION[0].albums; assert!(albums.len() > 1); - let empty = AlbumSelection::initialise(&[]); + let mut empty = AlbumSelection::initialise(&[]); assert_eq!(empty.state.list.selected(), None); + assert_eq!(empty.track.state.list.selected(), None); + + empty.increment(albums, Delta::Line); + assert_eq!(empty.state.list.selected(), None); + assert_eq!(empty.track.state.list.selected(), None); + + empty.decrement(albums, Delta::Line); + assert_eq!(empty.state.list.selected(), None); + assert_eq!(empty.track.state.list.selected(), None); let mut sel = AlbumSelection::initialise(albums); assert_eq!(sel.state.list.selected(), Some(0)); @@ -627,20 +692,20 @@ mod tests { // Re-initialise. let expected = sel.clone(); - let active_album = ActiveAlbum::get(albums, &sel); + let active_album = IdSelectAlbum::get(albums, &sel); sel.reinitialise(albums, active_album); assert_eq!(sel, expected); // Re-initialise out-of-bounds. let mut expected = sel.clone(); expected.decrement(albums, Delta::Line); - let active_album = ActiveAlbum::get(albums, &sel); + let active_album = IdSelectAlbum::get(albums, &sel); sel.reinitialise(&albums[..(albums.len() - 1)], active_album); assert_eq!(sel, expected); // Re-initialise empty. let expected = AlbumSelection::initialise(&[]); - let active_album = ActiveAlbum::get(albums, &sel); + let active_album = IdSelectAlbum::get(albums, &sel); sel.reinitialise(&[], active_album); assert_eq!(sel, expected); } @@ -650,8 +715,17 @@ mod tests { let artists = &COLLECTION; assert!(artists.len() > 1); - let empty = ArtistSelection::initialise(&[]); + let mut empty = ArtistSelection::initialise(&[]); assert_eq!(empty.state.list.selected(), None); + assert_eq!(empty.album.state.list.selected(), None); + + empty.increment(artists, Delta::Line); + assert_eq!(empty.state.list.selected(), None); + assert_eq!(empty.album.state.list.selected(), None); + + empty.decrement(artists, Delta::Line); + assert_eq!(empty.state.list.selected(), None); + assert_eq!(empty.album.state.list.selected(), None); let mut sel = ArtistSelection::initialise(artists); assert_eq!(sel.state.list.selected(), Some(0)); @@ -749,21 +823,109 @@ mod tests { // Re-initialise. let expected = sel.clone(); - let active_artist = ActiveArtist::get(artists, &sel); + let active_artist = IdSelectArtist::get(artists, &sel); sel.reinitialise(artists, active_artist); assert_eq!(sel, expected); // Re-initialise out-of-bounds. let mut expected = sel.clone(); expected.decrement(artists, Delta::Line); - let active_artist = ActiveArtist::get(artists, &sel); + let active_artist = IdSelectArtist::get(artists, &sel); sel.reinitialise(&artists[..(artists.len() - 1)], active_artist); assert_eq!(sel, expected); // Re-initialise empty. let expected = ArtistSelection::initialise(&[]); - let active_artist = ActiveArtist::get(artists, &sel); + let active_artist = IdSelectArtist::get(artists, &sel); sel.reinitialise(&[], active_artist); assert_eq!(sel, expected); } + + #[test] + fn selection() { + let mut selection = Selection::new(&COLLECTION); + + assert_eq!(selection.active, Category::Artist); + assert_eq!(selection.artist.state.list.selected(), Some(0)); + assert_eq!(selection.artist.album.state.list.selected(), Some(0)); + assert_eq!(selection.artist.album.track.state.list.selected(), Some(0)); + + selection.increment_selection(&COLLECTION, Delta::Line); + assert_eq!(selection.active, Category::Artist); + assert_eq!(selection.artist.state.list.selected(), Some(1)); + assert_eq!(selection.artist.album.state.list.selected(), Some(0)); + assert_eq!(selection.artist.album.track.state.list.selected(), Some(0)); + + selection.increment_category(); + assert_eq!(selection.active, Category::Album); + assert_eq!(selection.artist.state.list.selected(), Some(1)); + assert_eq!(selection.artist.album.state.list.selected(), Some(0)); + assert_eq!(selection.artist.album.track.state.list.selected(), Some(0)); + + selection.increment_selection(&COLLECTION, Delta::Line); + assert_eq!(selection.active, Category::Album); + assert_eq!(selection.artist.state.list.selected(), Some(1)); + assert_eq!(selection.artist.album.state.list.selected(), Some(1)); + assert_eq!(selection.artist.album.track.state.list.selected(), Some(0)); + + selection.increment_category(); + assert_eq!(selection.active, Category::Track); + assert_eq!(selection.artist.state.list.selected(), Some(1)); + assert_eq!(selection.artist.album.state.list.selected(), Some(1)); + assert_eq!(selection.artist.album.track.state.list.selected(), Some(0)); + + selection.increment_selection(&COLLECTION, Delta::Line); + assert_eq!(selection.active, Category::Track); + assert_eq!(selection.artist.state.list.selected(), Some(1)); + assert_eq!(selection.artist.album.state.list.selected(), Some(1)); + assert_eq!(selection.artist.album.track.state.list.selected(), Some(1)); + + selection.increment_category(); + assert_eq!(selection.active, Category::Track); + assert_eq!(selection.artist.state.list.selected(), Some(1)); + assert_eq!(selection.artist.album.state.list.selected(), Some(1)); + assert_eq!(selection.artist.album.track.state.list.selected(), Some(1)); + + selection.decrement_selection(&COLLECTION, Delta::Line); + assert_eq!(selection.active, Category::Track); + assert_eq!(selection.artist.state.list.selected(), Some(1)); + assert_eq!(selection.artist.album.state.list.selected(), Some(1)); + assert_eq!(selection.artist.album.track.state.list.selected(), Some(0)); + + selection.increment_selection(&COLLECTION, Delta::Line); + selection.decrement_category(); + assert_eq!(selection.active, Category::Album); + assert_eq!(selection.artist.state.list.selected(), Some(1)); + assert_eq!(selection.artist.album.state.list.selected(), Some(1)); + assert_eq!(selection.artist.album.track.state.list.selected(), Some(1)); + + selection.decrement_selection(&COLLECTION, Delta::Line); + assert_eq!(selection.active, Category::Album); + assert_eq!(selection.artist.state.list.selected(), Some(1)); + assert_eq!(selection.artist.album.state.list.selected(), Some(0)); + assert_eq!(selection.artist.album.track.state.list.selected(), Some(0)); + + selection.increment_selection(&COLLECTION, Delta::Line); + selection.decrement_category(); + assert_eq!(selection.active, Category::Artist); + assert_eq!(selection.artist.state.list.selected(), Some(1)); + assert_eq!(selection.artist.album.state.list.selected(), Some(1)); + assert_eq!(selection.artist.album.track.state.list.selected(), Some(0)); + + selection.decrement_selection(&COLLECTION, Delta::Line); + assert_eq!(selection.active, Category::Artist); + assert_eq!(selection.artist.state.list.selected(), Some(0)); + assert_eq!(selection.artist.album.state.list.selected(), Some(0)); + assert_eq!(selection.artist.album.track.state.list.selected(), Some(0)); + + selection.increment_category(); + selection.increment_selection(&COLLECTION, Delta::Line); + selection.decrement_category(); + selection.decrement_selection(&COLLECTION, Delta::Line); + selection.decrement_category(); + assert_eq!(selection.active, Category::Artist); + assert_eq!(selection.artist.state.list.selected(), Some(0)); + assert_eq!(selection.artist.album.state.list.selected(), Some(1)); + assert_eq!(selection.artist.album.track.state.list.selected(), Some(0)); + } } diff --git a/src/tui/handler.rs b/src/tui/handler.rs index 322206e..a9d73d6 100644 --- a/src/tui/handler.rs +++ b/src/tui/handler.rs @@ -5,27 +5,25 @@ use mockall::automock; use crate::tui::{ app::{ - app::{ - AppState, IAppInteract, IAppInteractBrowse, IAppInteractError, IAppInteractInfo, - IAppInteractReload, - }, - selection::Delta, + AppState, Delta, IAppInteract, IAppInteractBrowse, IAppInteractCritical, IAppInteractError, + IAppInteractInfo, IAppInteractReload, IAppInteractSearch, }, event::{Event, EventError, EventReceiver}, }; #[cfg_attr(test, automock)] pub trait IEventHandler { - fn handle_next_event(&self, app: &mut APP) -> Result<(), EventError>; + fn handle_next_event(&self, app: APP) -> Result; } trait IEventHandlerPrivate { - fn handle_key_event(app: &mut APP, key_event: KeyEvent); - fn handle_browse_key_event(app: &mut ::BS, key_event: KeyEvent); - fn handle_info_key_event(app: &mut ::IS, key_event: KeyEvent); - fn handle_reload_key_event(app: &mut ::RS, key_event: KeyEvent); - fn handle_error_key_event(app: &mut ::ES, key_event: KeyEvent); - fn handle_critical_key_event(app: &mut ::CS, key_event: KeyEvent); + fn handle_key_event(app: APP, key_event: KeyEvent) -> APP; + fn handle_browse_key_event(app: ::BS, key_event: KeyEvent) -> APP; + fn handle_info_key_event(app: ::IS, key_event: KeyEvent) -> APP; + fn handle_reload_key_event(app: ::RS, key_event: KeyEvent) -> APP; + fn handle_search_key_event(app: ::SS, key_event: KeyEvent) -> APP; + fn handle_error_key_event(app: ::ES, key_event: KeyEvent) -> APP; + fn handle_critical_key_event(app: ::CS, key_event: KeyEvent) -> APP; } pub struct EventHandler { @@ -40,54 +38,52 @@ impl EventHandler { } impl IEventHandler for EventHandler { - fn handle_next_event(&self, app: &mut APP) -> Result<(), EventError> { + fn handle_next_event(&self, mut app: APP) -> Result { match self.events.recv()? { - Event::Key(key_event) => Self::handle_key_event(app, key_event), + Event::Key(key_event) => app = Self::handle_key_event(app, key_event), Event::Mouse(_) => {} Event::Resize(_, _) => {} }; - Ok(()) + Ok(app) } } impl IEventHandlerPrivate for EventHandler { - fn handle_key_event(app: &mut APP, key_event: KeyEvent) { - match key_event.code { - // Exit application on `Ctrl-C`. - KeyCode::Char('c') | KeyCode::Char('C') => { - if key_event.modifiers == KeyModifiers::CONTROL { - app.force_quit(); - } + fn handle_key_event(app: APP, key_event: KeyEvent) -> APP { + if key_event.modifiers == KeyModifiers::CONTROL { + match key_event.code { + // Exit application on `Ctrl-C`. + KeyCode::Char('c') | KeyCode::Char('C') => return app.force_quit(), + _ => {} + }; + } + + match app.state() { + AppState::Browse(browse) => { + >::handle_browse_key_event(browse, key_event) + } + AppState::Info(info) => { + >::handle_info_key_event(info, key_event) + } + AppState::Reload(reload) => { + >::handle_reload_key_event(reload, key_event) + } + AppState::Search(search) => { + >::handle_search_key_event(search, key_event) + } + AppState::Error(error) => { + >::handle_error_key_event(error, key_event) + } + AppState::Critical(critical) => { + >::handle_critical_key_event(critical, key_event) } - _ => match app.state() { - AppState::Browse(browse) => { - >::handle_browse_key_event(browse, key_event); - } - AppState::Info(info) => { - >::handle_info_key_event(info, key_event); - } - AppState::Reload(reload) => { - >::handle_reload_key_event(reload, key_event); - } - AppState::Error(error) => { - >::handle_error_key_event(error, key_event); - } - AppState::Critical(critical) => { - >::handle_critical_key_event( - critical, key_event, - ); - } - }, } } - fn handle_browse_key_event(app: &mut ::BS, key_event: KeyEvent) { + fn handle_browse_key_event(app: ::BS, key_event: KeyEvent) -> APP { match key_event.code { // Exit application on `ESC` or `q`. - KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => { - app.save(); - app.quit(); - } + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => app.save_and_quit(), // Category change. KeyCode::Left => app.decrement_category(), KeyCode::Right => app.increment_category(), @@ -96,16 +92,24 @@ impl IEventHandlerPrivate for EventHandler { KeyCode::Down => app.increment_selection(Delta::Line), KeyCode::PageUp => app.decrement_selection(Delta::Page), KeyCode::PageDown => app.increment_selection(Delta::Page), - // Toggle overlay. + // Toggle info overlay. KeyCode::Char('m') | KeyCode::Char('M') => app.show_info_overlay(), - // Toggle Reload + // Toggle reload menu. KeyCode::Char('g') | KeyCode::Char('G') => app.show_reload_menu(), + // Toggle search. + KeyCode::Char('s') | KeyCode::Char('S') => { + if key_event.modifiers == KeyModifiers::CONTROL { + app.begin_search() + } else { + app.no_op() + } + } // Othey keys. - _ => {} + _ => app.no_op(), } } - fn handle_info_key_event(app: &mut ::IS, key_event: KeyEvent) { + fn handle_info_key_event(app: ::IS, key_event: KeyEvent) -> APP { match key_event.code { // Toggle overlay. KeyCode::Esc @@ -114,11 +118,11 @@ impl IEventHandlerPrivate for EventHandler { | KeyCode::Char('m') | KeyCode::Char('M') => app.hide_info_overlay(), // Othey keys. - _ => {} + _ => app.no_op(), } } - fn handle_reload_key_event(app: &mut ::RS, key_event: KeyEvent) { + fn handle_reload_key_event(app: ::RS, key_event: KeyEvent) -> APP { match key_event.code { // Reload keys. KeyCode::Char('l') | KeyCode::Char('L') => app.reload_library(), @@ -130,17 +134,38 @@ impl IEventHandlerPrivate for EventHandler { | KeyCode::Char('g') | KeyCode::Char('G') => app.hide_reload_menu(), // Othey keys. - _ => {} + _ => app.no_op(), } } - fn handle_error_key_event(app: &mut ::ES, _key_event: KeyEvent) { - // Any key dismisses the error. - app.dismiss_error(); + fn handle_search_key_event(app: ::SS, key_event: KeyEvent) -> APP { + if key_event.modifiers == KeyModifiers::CONTROL { + return match key_event.code { + KeyCode::Char('s') | KeyCode::Char('S') => app.search_next(), + KeyCode::Char('g') | KeyCode::Char('G') => app.cancel_search(), + _ => app.no_op(), + }; + } + + match key_event.code { + // Add/remove character to search. + KeyCode::Char(ch) => app.append_character(ch), + KeyCode::Backspace => app.step_back(), + // Return. + KeyCode::Esc | KeyCode::Enter => app.finish_search(), + // Othey keys. + _ => app.no_op(), + } } - fn handle_critical_key_event(_app: &mut ::CS, _key_event: KeyEvent) { + fn handle_error_key_event(app: ::ES, _key_event: KeyEvent) -> APP { + // Any key dismisses the error. + app.dismiss_error() + } + + fn handle_critical_key_event(app: ::CS, _key_event: KeyEvent) -> APP { // No action is allowed. + app.no_op() } } // GRCOV_EXCL_STOP diff --git a/src/tui/mod.rs b/src/tui/mod.rs index b48e650..e11acb6 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -5,7 +5,7 @@ mod lib; mod listener; mod ui; -pub use app::app::App; +pub use app::App; pub use event::EventChannel; pub use handler::EventHandler; pub use listener::EventListener; @@ -19,7 +19,7 @@ use std::io; use std::marker::PhantomData; use crate::tui::{ - app::app::{IAppAccess, IAppInteract}, + app::{IAppAccess, IAppInteract}, event::EventError, handler::IEventHandler, listener::IEventListener, @@ -75,7 +75,7 @@ impl Tui { ) -> Result<(), Error> { while app.is_running() { self.terminal.draw(|frame| UI::render(&mut app, frame))?; - handler.handle_next_event(&mut app)?; + app = handler.handle_next_event(app)?; } Ok(()) @@ -178,8 +178,8 @@ mod tests { use musichoard::collection::Collection; use crate::tui::{ - app::app::App, handler::MockIEventHandler, lib::MockIMusicHoard, - listener::MockIEventListener, ui::Ui, + app::App, handler::MockIEventHandler, lib::MockIMusicHoard, listener::MockIEventListener, + ui::Ui, }; use super::*; @@ -219,10 +219,7 @@ mod tests { let mut handler = MockIEventHandler::new(); handler .expect_handle_next_event() - .return_once(|app: &mut App| { - app.force_quit(); - Ok(()) - }); + .return_once(|app: App| Ok(app.force_quit())); handler } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 8ed4686..289035a 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -13,10 +13,7 @@ use ratatui::{ Frame, }; -use crate::tui::app::{ - app::{AppPublicState, AppState, IAppAccess}, - selection::{Category, Selection, WidgetState}, -}; +use crate::tui::app::{AppPublicState, AppState, Category, IAppAccess, Selection, WidgetState}; pub trait IUi { fn render(app: &mut APP, frame: &mut Frame); @@ -354,11 +351,12 @@ struct Minibuffer<'a> { impl Minibuffer<'_> { fn paragraphs(state: &AppPublicState) -> Self { let columns = 3; - let mb = match state { + let mut mb = match state { AppState::Browse(_) => Minibuffer { paragraphs: vec![ Paragraph::new("m: show info overlay"), Paragraph::new("g: show reload menu"), + Paragraph::new("ctrl+s: search artist"), ], columns, }, @@ -374,20 +372,35 @@ impl Minibuffer<'_> { ], columns, }, + AppState::Search(ref s) => Minibuffer { + paragraphs: vec![ + Paragraph::new(format!("I-search: {s}")), + Paragraph::new("ctrl+s: search next".to_string()).alignment(Alignment::Center), + Paragraph::new("ctrl+g: cancel search".to_string()) + .alignment(Alignment::Center), + ], + columns, + }, AppState::Error(_) => Minibuffer { paragraphs: vec![Paragraph::new( "Press any key to dismiss the error message...", )], - columns: 1, + columns: 0, }, AppState::Critical(_) => Minibuffer { paragraphs: vec![Paragraph::new("Press ctrl+c to terminate the program...")], - columns: 1, + columns: 0, }, }; - // Callers will assume this. - assert!(mb.columns >= mb.paragraphs.len() as u16); + if !state.is_search() { + mb.paragraphs = mb + .paragraphs + .into_iter() + .map(|p| p.alignment(Alignment::Center)) + .collect(); + } + mb } } @@ -557,17 +570,13 @@ impl Ui { } fn render_minibuffer(state: &AppPublicState, ar: Rect, fr: &mut Frame) { - let mut mb = Minibuffer::paragraphs(state); - mb.paragraphs = mb - .paragraphs - .into_iter() - .map(|p| p.alignment(Alignment::Center)) - .collect(); + let mb = Minibuffer::paragraphs(state); + let space = 3; let area = Rect { - x: ar.x + 1, + x: ar.x + 1 + space, y: ar.y + 1, - width: ar.width.saturating_sub(2), + width: ar.width.saturating_sub(2 + 2 * space), height: 1, }; @@ -665,16 +674,16 @@ impl IUi for Ui { fn render(app: &mut APP, frame: &mut Frame) { let app = app.get(); - let collection = app.collection; - let selection = app.selection; + let collection = app.inner.collection; + let selection = app.inner.selection; let state = app.state; - Self::render_main_frame(collection, selection, state, frame); + Self::render_main_frame(collection, selection, &state, frame); match state { AppState::Info(_) => Self::render_info_overlay(collection, selection, frame), AppState::Reload(_) => Self::render_reload_overlay(frame), - AppState::Error(ref msg) => Self::render_error_overlay("Error", msg, frame), - AppState::Critical(ref msg) => Self::render_error_overlay("Critical Error", msg, frame), + AppState::Error(msg) => Self::render_error_overlay("Error", msg, frame), + AppState::Critical(msg) => Self::render_error_overlay("Critical Error", msg, frame), _ => {} } } @@ -683,7 +692,7 @@ impl IUi for Ui { #[cfg(test)] mod tests { use crate::tui::{ - app::{app::AppPublic, selection::Delta}, + app::{AppPublic, AppPublicInner, Delta}, testmod::COLLECTION, tests::terminal, }; @@ -694,9 +703,18 @@ mod tests { impl IAppAccess for AppPublic<'_> { fn get(&mut self) -> AppPublic { AppPublic { - collection: self.collection, - selection: self.selection, - state: self.state, + inner: AppPublicInner { + collection: self.inner.collection, + selection: self.inner.selection, + }, + state: match self.state { + AppState::Browse(()) => AppState::Browse(()), + AppState::Info(()) => AppState::Info(()), + AppState::Reload(()) => AppState::Reload(()), + AppState::Search(s) => AppState::Search(s), + AppState::Error(s) => AppState::Error(s), + AppState::Critical(s) => AppState::Critical(s), + }, } } } @@ -705,24 +723,27 @@ mod tests { let mut terminal = terminal(); let mut app = AppPublic { - collection, - selection, - state: &AppState::Browse(()), + inner: AppPublicInner { + collection, + selection, + }, + state: AppState::Browse(()), }; terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); - app.state = &AppState::Info(()); + app.state = AppState::Info(()); terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); - app.state = &AppState::Reload(()); + app.state = AppState::Reload(()); terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); - let binding = AppState::Error(String::from("get rekt scrub")); - app.state = &binding; + app.state = AppState::Search(""); terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); - let binding = AppState::Critical(String::from("get critically rekt scrub")); - app.state = &binding; + app.state = AppState::Error("get rekt scrub"); + terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); + + app.state = AppState::Critical("get critically rekt scrub"); terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); }