Provide search functionality through the TUI (#134)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m43s
Cargo CI / Lint (push) Successful in 1m16s
Cargo CI / Build and Test (pull_request) Successful in 1m42s
Cargo CI / Lint (pull_request) Successful in 1m16s

Closes #24

Reviewed-on: #134
This commit is contained in:
Wojciech Kozlowski 2024-02-18 22:12:41 +01:00
parent c4dc0d173b
commit 84a2cc83ca
24 changed files with 2041 additions and 922 deletions

View File

@ -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"

59
Cargo.lock generated
View File

@ -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"

View File

@ -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"

View File

@ -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
```

5
build.rs Normal file
View File

@ -0,0 +1,5 @@
fn main() {
if let Some(true) = version_check::is_feature_flaggable() {
println!("cargo:rustc-cfg=nightly");
}
}

View File

@ -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\":{}\

View File

@ -2,27 +2,27 @@ use once_cell::sync::Lazy;
pub static LIBRARY_BEETS: Lazy<Vec<String>> = Lazy::new(|| -> Vec<String> {
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")
]
});

View File

@ -5,7 +5,7 @@ use crate::core::{collection::track::Format, library::Item};
pub static LIBRARY_ITEMS: Lazy<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
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<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
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<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
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<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
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<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
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<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
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<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
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<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
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<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
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<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
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<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
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<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
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<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
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<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
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<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
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<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
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<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
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<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
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<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
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<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
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<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
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<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
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"),

View File

@ -668,7 +668,7 @@ mod tests {
let mut right: Vec<Artist> = 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());

View File

@ -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};

View File

@ -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(

View File

@ -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<BS, IS, RS, ES, CS> {
Browse(BS),
Info(IS),
Reload(RS),
Error(ES),
Critical(CS),
}
impl<BS, IS, RS, ES, CS> AppState<BS, IS, RS, ES, CS> {
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<MH: IMusicHoard> {
running: bool,
music_hoard: MH,
selection: Selection,
state: AppState<(), (), (), String, String>,
}
impl<MH: IMusicHoard> App<MH> {
pub fn new(mut music_hoard: MH) -> Self {
let state = match Self::init(&mut music_hoard) {
Ok(()) => AppState::Browse(()),
Err(err) => AppState::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<MH: IMusicHoard> IAppInteract for App<MH> {
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<MH: IMusicHoard> IAppInteractBrowse for App<MH> {
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<MH: IMusicHoard> IAppInteractInfo for App<MH> {
fn hide_info_overlay(&mut self) {
assert!(self.state.is_info());
self.state = AppState::Browse(());
}
}
impl<MH: IMusicHoard> IAppInteractReload for App<MH> {
fn reload_library(&mut self) {
let previous = ActiveSelection::get(self.music_hoard.get_collection(), &self.selection);
let result = self.music_hoard.rescan_library();
self.refresh(previous, result);
}
fn reload_database(&mut self) {
let previous = ActiveSelection::get(self.music_hoard.get_collection(), &self.selection);
let result = self.music_hoard.load_from_database();
self.refresh(previous, result);
}
fn hide_reload_menu(&mut self) {
assert!(self.state.is_reload());
self.state = AppState::Browse(());
}
}
trait IAppInteractReloadPrivate {
fn refresh(&mut self, previous: ActiveSelection, result: Result<(), musichoard::Error>);
}
impl<MH: IMusicHoard> IAppInteractReloadPrivate for App<MH> {
fn refresh(&mut self, previous: ActiveSelection, result: Result<(), musichoard::Error>) {
assert!(self.state.is_reload());
match result {
Ok(()) => {
self.selection
.select(self.music_hoard.get_collection(), previous);
self.state = AppState::Browse(())
}
Err(err) => self.state = AppState::Error(err.to_string()),
}
}
}
impl<MH: IMusicHoard> IAppInteractError for App<MH> {
fn dismiss_error(&mut self) {
assert!(self.state.is_error());
self.state = AppState::Browse(());
}
}
impl<MH: IMusicHoard> IAppInteractCritical for App<MH> {}
impl<MH: IMusicHoard> IAppAccess for App<MH> {
fn get(&mut self) -> AppPublic {
AppPublic {
collection: self.music_hoard.get_collection(),
selection: &mut self.selection,
state: &self.state,
}
}
}
#[cfg(test)]
mod tests {
use crate::tui::{app::selection::Category, lib::MockIMusicHoard, testmod::COLLECTION};
use super::*;
fn music_hoard(collection: Collection) -> MockIMusicHoard {
let mut music_hoard = MockIMusicHoard::new();
music_hoard
.expect_load_from_database()
.times(1)
.return_once(|| Ok(()));
music_hoard
.expect_rescan_library()
.times(1)
.return_once(|| Ok(()));
music_hoard.expect_get_collection().return_const(collection);
music_hoard
}
#[test]
fn running_quit() {
let mut app = App::new(music_hoard(COLLECTION.to_owned()));
assert!(app.is_running());
app.quit();
assert!(!app.is_running());
}
#[test]
fn error_quit() {
let mut app = App::new(music_hoard(COLLECTION.to_owned()));
assert!(app.is_running());
app.state = AppState::Error(String::from("get rekt"));
app.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());
}
}

View File

@ -0,0 +1,208 @@
use crate::tui::{
app::{
machine::{App, AppInner, AppMachine},
selection::{Delta, ListSelection},
AppPublic, AppState, IAppInteractBrowse,
},
lib::IMusicHoard,
};
pub struct AppBrowse;
impl<MH: IMusicHoard> AppMachine<MH, AppBrowse> {
pub fn browse(inner: AppInner<MH>) -> Self {
AppMachine {
inner,
state: AppBrowse,
}
}
}
impl<MH: IMusicHoard> From<AppMachine<MH, AppBrowse>> for App<MH> {
fn from(machine: AppMachine<MH, AppBrowse>) -> Self {
AppState::Browse(machine)
}
}
impl<'a, MH: IMusicHoard> From<&'a mut AppMachine<MH, AppBrowse>> for AppPublic<'a> {
fn from(machine: &'a mut AppMachine<MH, AppBrowse>) -> Self {
AppPublic {
inner: (&mut machine.inner).into(),
state: AppState::Browse(()),
}
}
}
impl<MH: IMusicHoard> IAppInteractBrowse for AppMachine<MH, AppBrowse> {
type APP = App<MH>;
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();
}
}

View File

@ -0,0 +1,59 @@
use crate::tui::{
app::{
machine::{App, AppInner, AppMachine},
AppPublic, AppState, IAppInteractCritical,
},
lib::IMusicHoard,
};
pub struct AppCritical {
string: String,
}
impl<MH: IMusicHoard> AppMachine<MH, AppCritical> {
pub fn critical<S: Into<String>>(inner: AppInner<MH>, string: S) -> Self {
AppMachine {
inner,
state: AppCritical {
string: string.into(),
},
}
}
}
impl<MH: IMusicHoard> From<AppMachine<MH, AppCritical>> for App<MH> {
fn from(machine: AppMachine<MH, AppCritical>) -> Self {
AppState::Critical(machine)
}
}
impl<'a, MH: IMusicHoard> From<&'a mut AppMachine<MH, AppCritical>> for AppPublic<'a> {
fn from(machine: &'a mut AppMachine<MH, AppCritical>) -> Self {
AppPublic {
inner: (&mut machine.inner).into(),
state: AppState::Critical(&machine.state.string),
}
}
}
impl<MH: IMusicHoard> IAppInteractCritical for AppMachine<MH, AppCritical> {
type APP = App<MH>;
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();
}
}

View File

@ -0,0 +1,59 @@
use crate::tui::{
app::{
machine::{App, AppInner, AppMachine},
AppPublic, AppState, IAppInteractError,
},
lib::IMusicHoard,
};
pub struct AppError {
string: String,
}
impl<MH: IMusicHoard> AppMachine<MH, AppError> {
pub fn error<S: Into<String>>(inner: AppInner<MH>, string: S) -> Self {
AppMachine {
inner,
state: AppError {
string: string.into(),
},
}
}
}
impl<MH: IMusicHoard> From<AppMachine<MH, AppError>> for App<MH> {
fn from(machine: AppMachine<MH, AppError>) -> Self {
AppState::Error(machine)
}
}
impl<'a, MH: IMusicHoard> From<&'a mut AppMachine<MH, AppError>> for AppPublic<'a> {
fn from(machine: &'a mut AppMachine<MH, AppError>) -> Self {
AppPublic {
inner: (&mut machine.inner).into(),
state: AppState::Error(&machine.state.string),
}
}
}
impl<MH: IMusicHoard> IAppInteractError for AppMachine<MH, AppError> {
type APP = App<MH>;
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();
}
}

View File

@ -0,0 +1,66 @@
use crate::tui::{
app::{
machine::{App, AppInner, AppMachine},
AppPublic, AppState, IAppInteractInfo,
},
lib::IMusicHoard,
};
pub struct AppInfo;
impl<MH: IMusicHoard> AppMachine<MH, AppInfo> {
pub fn info(inner: AppInner<MH>) -> Self {
AppMachine {
inner,
state: AppInfo,
}
}
}
impl<MH: IMusicHoard> From<AppMachine<MH, AppInfo>> for App<MH> {
fn from(machine: AppMachine<MH, AppInfo>) -> Self {
AppState::Info(machine)
}
}
impl<'a, MH: IMusicHoard> From<&'a mut AppMachine<MH, AppInfo>> for AppPublic<'a> {
fn from(machine: &'a mut AppMachine<MH, AppInfo>) -> Self {
AppPublic {
inner: (&mut machine.inner).into(),
state: AppState::Info(()),
}
}
}
impl<MH: IMusicHoard> IAppInteractInfo for AppMachine<MH, AppInfo> {
type APP = App<MH>;
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();
}
}

335
src/tui/app/machine/mod.rs Normal file
View File

@ -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<MH> = AppState<
AppMachine<MH, AppBrowse>,
AppMachine<MH, AppInfo>,
AppMachine<MH, AppReload>,
AppMachine<MH, AppSearch>,
AppMachine<MH, AppError>,
AppMachine<MH, AppCritical>,
>;
pub struct AppMachine<MH: IMusicHoard, STATE> {
inner: AppInner<MH>,
state: STATE,
}
pub struct AppInner<MH: IMusicHoard> {
running: bool,
music_hoard: MH,
selection: Selection,
}
impl<MH: IMusicHoard> App<MH> {
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<MH> {
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<MH> {
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<MH: IMusicHoard> IAppInteract for App<MH> {
type BS = AppMachine<MH, AppBrowse>;
type IS = AppMachine<MH, AppInfo>;
type RS = AppMachine<MH, AppReload>;
type SS = AppMachine<MH, AppSearch>;
type ES = AppMachine<MH, AppError>;
type CS = AppMachine<MH, AppCritical>;
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::BS, Self::IS, Self::RS, Self::SS, Self::ES, Self::CS> {
self
}
}
impl<MH: IMusicHoard> IAppAccess for App<MH> {
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<MH: IMusicHoard> AppInner<MH> {
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<MH>> for AppPublicInner<'a> {
fn from(inner: &'a mut AppInner<MH>) -> 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<BS, IS, RS, SS, ES, CS> AppState<BS, IS, RS, SS, ES, CS> {
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<MockIMusicHoard> {
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();
}
}

View File

@ -0,0 +1,144 @@
use crate::tui::{
app::{
machine::{App, AppInner, AppMachine},
selection::IdSelection,
AppPublic, AppState, IAppInteractReload,
},
lib::IMusicHoard,
};
pub struct AppReload;
impl<MH: IMusicHoard> AppMachine<MH, AppReload> {
pub fn reload(inner: AppInner<MH>) -> Self {
AppMachine {
inner,
state: AppReload,
}
}
}
impl<MH: IMusicHoard> From<AppMachine<MH, AppReload>> for App<MH> {
fn from(machine: AppMachine<MH, AppReload>) -> Self {
AppState::Reload(machine)
}
}
impl<'a, MH: IMusicHoard> From<&'a mut AppMachine<MH, AppReload>> for AppPublic<'a> {
fn from(machine: &'a mut AppMachine<MH, AppReload>) -> Self {
AppPublic {
inner: (&mut machine.inner).into(),
state: AppState::Reload(()),
}
}
}
impl<MH: IMusicHoard> IAppInteractReload for AppMachine<MH, AppReload> {
type APP = App<MH>;
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<MH: IMusicHoard> {
fn refresh(self, previous: IdSelection, result: Result<(), musichoard::Error>) -> App<MH>;
}
impl<MH: IMusicHoard> IAppInteractReloadPrivate<MH> for AppMachine<MH, AppReload> {
fn refresh(mut self, previous: IdSelection, result: Result<(), musichoard::Error>) -> App<MH> {
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();
}
}

View File

@ -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<AhoCorasick> =
Lazy::new(|| AhoCorasick::new(SPECIAL.map(|ch| ch.to_string())).unwrap());
pub struct AppSearch {
string: String,
orig: ListSelection,
memo: Vec<AppSearchMemo>,
}
struct AppSearchMemo {
index: Option<usize>,
char: bool,
}
impl<MH: IMusicHoard> AppMachine<MH, AppSearch> {
pub fn search(inner: AppInner<MH>, orig: ListSelection) -> Self {
AppMachine {
inner,
state: AppSearch {
string: String::new(),
orig,
memo: vec![],
},
}
}
}
impl<MH: IMusicHoard> From<AppMachine<MH, AppSearch>> for App<MH> {
fn from(machine: AppMachine<MH, AppSearch>) -> Self {
AppState::Search(machine)
}
}
impl<'a, MH: IMusicHoard> From<&'a mut AppMachine<MH, AppSearch>> for AppPublic<'a> {
fn from(machine: &'a mut AppMachine<MH, AppSearch>) -> Self {
AppPublic {
inner: (&mut machine.inner).into(),
state: AppState::Search(&machine.state.string),
}
}
}
impl<MH: IMusicHoard> IAppInteractSearch for AppMachine<MH, AppSearch> {
type APP = App<MH>;
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<MH: IMusicHoard> IAppInteractSearchPrivate for AppMachine<MH, AppSearch> {
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<usize>) -> 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<MockIMusicHoard, AppSearch>;
fn random_utf8_string(len: usize) -> String {
rand::thread_rng()
.sample_iter::<char, _>(&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<String> {
(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)))
}
}

View File

@ -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<BS, IS, RS, SS, ES, CS> {
Browse(BS),
Info(IS),
Reload(RS),
Search(SS),
Error(ES),
Critical(CS),
}
pub trait IAppInteract {
type BS: IAppInteractBrowse<APP = Self>;
type IS: IAppInteractInfo<APP = Self>;
type RS: IAppInteractReload<APP = Self>;
type SS: IAppInteractSearch<APP = Self>;
type ES: IAppInteractError<APP = Self>;
type CS: IAppInteractCritical<APP = Self>;
fn is_running(&self) -> bool;
fn force_quit(self) -> Self;
#[allow(clippy::type_complexity)]
fn state(self) -> AppState<Self::BS, Self::IS, Self::RS, Self::SS, Self::ES, Self::CS>;
}
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<BS, IS, RS, SS, ES, CS> AppState<BS, IS, RS, SS, ES, CS> {
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());
}
}

View File

@ -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<usize>) {
self.artist.select(artists, index);
}
pub fn selected_artist(&self) -> Option<usize> {
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<ActiveArtist>) {
fn reinitialise(&mut self, artists: &[Artist], active: Option<IdSelectArtist>) {
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<ActiveAlbum>,
active_album: Option<IdSelectAlbum>,
) {
if artists.is_empty() {
self.state.list.select(None);
@ -172,16 +193,29 @@ impl ArtistSelection {
}
}
fn selected(&self) -> Option<usize> {
self.state.list.selected()
}
fn select(&mut self, artists: &[Artist], to: Option<usize>) {
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<ActiveAlbum>) {
fn reinitialise(&mut self, albums: &[Album], album: Option<IdSelectAlbum>) {
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<ActiveTrack>,
active_track: Option<IdSelectTrack>,
) {
if albums.is_empty() {
self.state.list.select(None);
@ -322,7 +356,7 @@ impl TrackSelection {
selection
}
fn reinitialise(&mut self, tracks: &[Track], track: Option<ActiveTrack>) {
fn reinitialise(&mut self, tracks: &[Track], track: Option<IdSelectTrack>) {
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<ActiveArtist>,
pub struct ListSelection {
pub artist: ListState,
pub album: ListState,
pub track: ListState,
}
struct ActiveArtist {
artist_id: ArtistId,
album: Option<ActiveAlbum>,
}
struct ActiveAlbum {
album_id: AlbumId,
track: Option<ActiveTrack>,
}
struct ActiveTrack {
track_id: TrackId,
}
impl ActiveSelection {
pub fn get(collection: &Collection, selection: &Selection) -> Self {
ActiveSelection {
artist: ActiveArtist::get(collection, &selection.artist),
impl 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<IdSelectArtist>,
}
struct IdSelectArtist {
artist_id: ArtistId,
album: Option<IdSelectAlbum>,
}
struct IdSelectAlbum {
album_id: AlbumId,
track: Option<IdSelectTrack>,
}
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<Self> {
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<Self> {
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<Self> {
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));
}
}

View File

@ -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<APP: IAppInteract> {
fn handle_next_event(&self, app: &mut APP) -> Result<(), EventError>;
fn handle_next_event(&self, app: APP) -> Result<APP, EventError>;
}
trait IEventHandlerPrivate<APP: IAppInteract> {
fn handle_key_event(app: &mut APP, key_event: KeyEvent);
fn handle_browse_key_event(app: &mut <APP as IAppInteract>::BS, key_event: KeyEvent);
fn handle_info_key_event(app: &mut <APP as IAppInteract>::IS, key_event: KeyEvent);
fn handle_reload_key_event(app: &mut <APP as IAppInteract>::RS, key_event: KeyEvent);
fn handle_error_key_event(app: &mut <APP as IAppInteract>::ES, key_event: KeyEvent);
fn handle_critical_key_event(app: &mut <APP as IAppInteract>::CS, key_event: KeyEvent);
fn handle_key_event(app: APP, key_event: KeyEvent) -> APP;
fn handle_browse_key_event(app: <APP as IAppInteract>::BS, key_event: KeyEvent) -> APP;
fn handle_info_key_event(app: <APP as IAppInteract>::IS, key_event: KeyEvent) -> APP;
fn handle_reload_key_event(app: <APP as IAppInteract>::RS, key_event: KeyEvent) -> APP;
fn handle_search_key_event(app: <APP as IAppInteract>::SS, key_event: KeyEvent) -> APP;
fn handle_error_key_event(app: <APP as IAppInteract>::ES, key_event: KeyEvent) -> APP;
fn handle_critical_key_event(app: <APP as IAppInteract>::CS, key_event: KeyEvent) -> APP;
}
pub struct EventHandler {
@ -40,54 +38,52 @@ impl EventHandler {
}
impl<APP: IAppInteract> IEventHandler<APP> for EventHandler {
fn handle_next_event(&self, app: &mut APP) -> Result<(), EventError> {
fn handle_next_event(&self, mut app: APP) -> Result<APP, EventError> {
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<APP: IAppInteract> IEventHandlerPrivate<APP> 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) => {
<Self as IEventHandlerPrivate<APP>>::handle_browse_key_event(browse, key_event)
}
AppState::Info(info) => {
<Self as IEventHandlerPrivate<APP>>::handle_info_key_event(info, key_event)
}
AppState::Reload(reload) => {
<Self as IEventHandlerPrivate<APP>>::handle_reload_key_event(reload, key_event)
}
AppState::Search(search) => {
<Self as IEventHandlerPrivate<APP>>::handle_search_key_event(search, key_event)
}
AppState::Error(error) => {
<Self as IEventHandlerPrivate<APP>>::handle_error_key_event(error, key_event)
}
AppState::Critical(critical) => {
<Self as IEventHandlerPrivate<APP>>::handle_critical_key_event(critical, key_event)
}
_ => match app.state() {
AppState::Browse(browse) => {
<Self as IEventHandlerPrivate<APP>>::handle_browse_key_event(browse, key_event);
}
AppState::Info(info) => {
<Self as IEventHandlerPrivate<APP>>::handle_info_key_event(info, key_event);
}
AppState::Reload(reload) => {
<Self as IEventHandlerPrivate<APP>>::handle_reload_key_event(reload, key_event);
}
AppState::Error(error) => {
<Self as IEventHandlerPrivate<APP>>::handle_error_key_event(error, key_event);
}
AppState::Critical(critical) => {
<Self as IEventHandlerPrivate<APP>>::handle_critical_key_event(
critical, key_event,
);
}
},
}
}
fn handle_browse_key_event(app: &mut <APP as IAppInteract>::BS, key_event: KeyEvent) {
fn handle_browse_key_event(app: <APP as IAppInteract>::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<APP: IAppInteract> IEventHandlerPrivate<APP> 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 <APP as IAppInteract>::IS, key_event: KeyEvent) {
fn handle_info_key_event(app: <APP as IAppInteract>::IS, key_event: KeyEvent) -> APP {
match key_event.code {
// Toggle overlay.
KeyCode::Esc
@ -114,11 +118,11 @@ impl<APP: IAppInteract> IEventHandlerPrivate<APP> for EventHandler {
| KeyCode::Char('m')
| KeyCode::Char('M') => app.hide_info_overlay(),
// Othey keys.
_ => {}
_ => app.no_op(),
}
}
fn handle_reload_key_event(app: &mut <APP as IAppInteract>::RS, key_event: KeyEvent) {
fn handle_reload_key_event(app: <APP as IAppInteract>::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<APP: IAppInteract> IEventHandlerPrivate<APP> for EventHandler {
| KeyCode::Char('g')
| KeyCode::Char('G') => app.hide_reload_menu(),
// Othey keys.
_ => {}
_ => app.no_op(),
}
}
fn handle_error_key_event(app: &mut <APP as IAppInteract>::ES, _key_event: KeyEvent) {
// Any key dismisses the error.
app.dismiss_error();
fn handle_search_key_event(app: <APP as IAppInteract>::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 <APP as IAppInteract>::CS, _key_event: KeyEvent) {
fn handle_error_key_event(app: <APP as IAppInteract>::ES, _key_event: KeyEvent) -> APP {
// Any key dismisses the error.
app.dismiss_error()
}
fn handle_critical_key_event(app: <APP as IAppInteract>::CS, _key_event: KeyEvent) -> APP {
// No action is allowed.
app.no_op()
}
}
// GRCOV_EXCL_STOP

View File

@ -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<B: Backend, UI: IUi, APP: IAppInteract + IAppAccess> Tui<B, UI, APP> {
) -> 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<MockIMusicHoard>| {
app.force_quit();
Ok(())
});
.return_once(|app: App<MockIMusicHoard>| Ok(app.force_quit()));
handler
}

View File

@ -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: IAppAccess>(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: IAppAccess>(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();
}