Provide search functionality through the TUI (#134)
Closes #24 Reviewed-on: #134
This commit is contained in:
parent
c4dc0d173b
commit
84a2cc83ca
@ -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
59
Cargo.lock
generated
@ -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"
|
||||
|
@ -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"
|
||||
|
15
README.md
15
README.md
@ -32,6 +32,7 @@ grcov codecov/debug/profraw \
|
||||
--output-types html \
|
||||
--source-dir . \
|
||||
--ignore-not-existing \
|
||||
--ignore "build.rs" \
|
||||
--ignore "tests/*" \
|
||||
--ignore "src/main.rs" \
|
||||
--ignore "src/bin/musichoard-edit.rs" \
|
||||
@ -45,3 +46,17 @@ Note that some changes may not be visible until `codecov/debug/coverage` is remo
|
||||
command is rerun.
|
||||
|
||||
For most cases `cargo clean` can be replaced with `rm -rf ./codecov/debug/{coverage,profraw}`.
|
||||
|
||||
## Benchmarks
|
||||
|
||||
### Pre-requisites
|
||||
|
||||
``` sh
|
||||
rustup toolchain install nightly
|
||||
```
|
||||
|
||||
### Running benchmarks
|
||||
|
||||
``` sh
|
||||
env BEETSDIR=./ rustup run nightly cargo bench --all-features --all-targets
|
||||
```
|
||||
|
5
build.rs
Normal file
5
build.rs
Normal file
@ -0,0 +1,5 @@
|
||||
fn main() {
|
||||
if let Some(true) = version_check::is_feature_flaggable() {
|
||||
println!("cargo:rustc-cfg=nightly");
|
||||
}
|
||||
}
|
@ -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\":{}\
|
||||
|
@ -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")
|
||||
]
|
||||
});
|
||||
|
@ -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"),
|
||||
|
@ -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());
|
||||
|
||||
|
@ -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};
|
||||
|
18
src/tests.rs
18
src/tests.rs
@ -3,7 +3,7 @@ macro_rules! library_collection {
|
||||
vec![
|
||||
Artist {
|
||||
id: ArtistId {
|
||||
name: "album_artist a".to_string(),
|
||||
name: "Album_Artist ‘A’".to_string(),
|
||||
},
|
||||
sort: None,
|
||||
musicbrainz: None,
|
||||
@ -98,7 +98,7 @@ macro_rules! library_collection {
|
||||
},
|
||||
Artist {
|
||||
id: ArtistId {
|
||||
name: "album_artist b".to_string(),
|
||||
name: "Album_Artist ‘B’".to_string(),
|
||||
},
|
||||
sort: None,
|
||||
musicbrainz: None,
|
||||
@ -240,9 +240,11 @@ macro_rules! library_collection {
|
||||
},
|
||||
Artist {
|
||||
id: ArtistId {
|
||||
name: "album_artist c".to_string(),
|
||||
name: "The Album_Artist ‘C’".to_string(),
|
||||
},
|
||||
sort: None,
|
||||
sort: Some(ArtistId {
|
||||
name: "Album_Artist ‘C’, The".to_string(),
|
||||
}),
|
||||
musicbrainz: None,
|
||||
properties: HashMap::new(),
|
||||
albums: vec![
|
||||
@ -316,7 +318,7 @@ macro_rules! library_collection {
|
||||
},
|
||||
Artist {
|
||||
id: ArtistId {
|
||||
name: "album_artist d".to_string(),
|
||||
name: "Album_Artist ‘D’".to_string(),
|
||||
},
|
||||
sort: None,
|
||||
musicbrainz: None,
|
||||
@ -400,7 +402,7 @@ macro_rules! full_collection {
|
||||
let mut iter = collection.iter_mut();
|
||||
|
||||
let artist_a = iter.next().unwrap();
|
||||
assert_eq!(artist_a.id.name, "album_artist a");
|
||||
assert_eq!(artist_a.id.name, "Album_Artist ‘A’");
|
||||
|
||||
artist_a.musicbrainz = Some(
|
||||
MusicBrainz::new(
|
||||
@ -421,7 +423,7 @@ macro_rules! full_collection {
|
||||
]);
|
||||
|
||||
let artist_b = iter.next().unwrap();
|
||||
assert_eq!(artist_b.id.name, "album_artist b");
|
||||
assert_eq!(artist_b.id.name, "Album_Artist ‘B’");
|
||||
|
||||
artist_b.musicbrainz = Some(
|
||||
MusicBrainz::new(
|
||||
@ -443,7 +445,7 @@ macro_rules! full_collection {
|
||||
]);
|
||||
|
||||
let artist_c = iter.next().unwrap();
|
||||
assert_eq!(artist_c.id.name, "album_artist c");
|
||||
assert_eq!(artist_c.id.name, "The Album_Artist ‘C’");
|
||||
|
||||
artist_c.musicbrainz = Some(
|
||||
MusicBrainz::new(
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
208
src/tui/app/machine/browse.rs
Normal file
208
src/tui/app/machine/browse.rs
Normal 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();
|
||||
}
|
||||
}
|
59
src/tui/app/machine/critical.rs
Normal file
59
src/tui/app/machine/critical.rs
Normal 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();
|
||||
}
|
||||
}
|
59
src/tui/app/machine/error.rs
Normal file
59
src/tui/app/machine/error.rs
Normal 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();
|
||||
}
|
||||
}
|
66
src/tui/app/machine/info.rs
Normal file
66
src/tui/app/machine/info.rs
Normal 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
335
src/tui/app/machine/mod.rs
Normal 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();
|
||||
}
|
||||
}
|
144
src/tui/app/machine/reload.rs
Normal file
144
src/tui/app/machine/reload.rs
Normal 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();
|
||||
}
|
||||
}
|
526
src/tui/app/machine/search.rs
Normal file
526
src/tui/app/machine/search.rs
Normal 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)))
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct IdSelection {
|
||||
artist: Option<IdSelectArtist>,
|
||||
}
|
||||
|
||||
struct IdSelectArtist {
|
||||
artist_id: ArtistId,
|
||||
album: Option<ActiveAlbum>,
|
||||
album: Option<IdSelectAlbum>,
|
||||
}
|
||||
|
||||
struct ActiveAlbum {
|
||||
struct IdSelectAlbum {
|
||||
album_id: AlbumId,
|
||||
track: Option<ActiveTrack>,
|
||||
track: Option<IdSelectTrack>,
|
||||
}
|
||||
|
||||
struct ActiveTrack {
|
||||
struct IdSelectTrack {
|
||||
track_id: TrackId,
|
||||
}
|
||||
|
||||
impl ActiveSelection {
|
||||
impl IdSelection {
|
||||
pub fn get(collection: &Collection, selection: &Selection) -> Self {
|
||||
ActiveSelection {
|
||||
artist: ActiveArtist::get(collection, &selection.artist),
|
||||
IdSelection {
|
||||
artist: IdSelectArtist::get(collection, &selection.artist),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveArtist {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
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') => {
|
||||
if key_event.modifiers == KeyModifiers::CONTROL {
|
||||
app.force_quit();
|
||||
KeyCode::Char('c') | KeyCode::Char('C') => return app.force_quit(),
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
}
|
||||
_ => match app.state() {
|
||||
|
||||
match app.state() {
|
||||
AppState::Browse(browse) => {
|
||||
<Self as IEventHandlerPrivate<APP>>::handle_browse_key_event(browse, key_event);
|
||||
<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);
|
||||
<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);
|
||||
<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);
|
||||
<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,
|
||||
);
|
||||
<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) {
|
||||
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_error_key_event(app: <APP as IAppInteract>::ES, _key_event: KeyEvent) -> APP {
|
||||
// Any key dismisses the error.
|
||||
app.dismiss_error();
|
||||
app.dismiss_error()
|
||||
}
|
||||
|
||||
fn handle_critical_key_event(_app: &mut <APP as IAppInteract>::CS, _key_event: KeyEvent) {
|
||||
fn handle_critical_key_event(app: <APP as IAppInteract>::CS, _key_event: KeyEvent) -> APP {
|
||||
// No action is allowed.
|
||||
app.no_op()
|
||||
}
|
||||
}
|
||||
// GRCOV_EXCL_STOP
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
inner: AppPublicInner {
|
||||
collection,
|
||||
selection,
|
||||
state: &AppState::Browse(()),
|
||||
},
|
||||
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();
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user