diff --git a/Cargo.lock b/Cargo.lock index 327ce37..587b63f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,7 +65,7 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" dependencies = [ - "hermit-abi", + "hermit-abi 0.1.19", "libc", "winapi", ] @@ -129,9 +129,9 @@ checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" [[package]] name = "castaway" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a17ed5635fc8536268e5d4de1e22e81ac34419e5f052d4d51f4e01dcc263fcc" +checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" dependencies = [ "rustversion", ] @@ -168,13 +168,14 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.7.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +checksum = "6050c3a16ddab2e412160b31f2c871015704239bca62f72f6e5f0be631d3f644" dependencies = [ "castaway", "cfg-if", "itoa", + "rustversion", "ryu", "static_assertions", ] @@ -197,15 +198,15 @@ checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" [[package]] name = "crossterm" -version = "0.27.0" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags 2.4.2", "crossterm_winapi", - "libc", - "mio", + "mio 1.0.2", "parking_lot", + "rustix", "signal-hook", "signal-hook-mio", "winapi", @@ -393,9 +394,9 @@ dependencies = [ [[package]] name = "heck" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" @@ -406,6 +407,12 @@ dependencies = [ "libc", ] +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + [[package]] name = "http" version = "0.2.12" @@ -498,10 +505,14 @@ dependencies = [ ] [[package]] -name = "indoc" -version = "2.0.4" +name = "instability" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e186cfbae8084e513daff4240b4797e342f988cecda4fb6c939150f96315fd8" +checksum = "b23a0c8dfe501baac4adf6ebbfa6eddf8f0c07f56b058cc1288017e32397846c" +dependencies = [ + "quote", + "syn 2.0.48", +] [[package]] name = "ipnet" @@ -511,9 +522,9 @@ checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" [[package]] name = "itertools" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] @@ -541,15 +552,15 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.153" +version = "0.2.158" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" [[package]] name = "linux-raw-sys" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "lock_api" @@ -604,11 +615,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" dependencies = [ "libc", - "log", "wasi", "windows-sys 0.48.0", ] +[[package]] +name = "mio" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "log", + "wasi", + "windows-sys 0.52.0", +] + [[package]] name = "mockall" version = "0.12.1" @@ -653,6 +676,7 @@ dependencies = [ "structopt", "tempfile", "tokio", + "tui-input", "url", "uuid", "version_check", @@ -911,21 +935,22 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.26.0" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "154b85ef15a5d1719bcaa193c3c81fe645cd120c156874cd660fe49fd21d1373" +checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" dependencies = [ "bitflags 2.4.2", "cassowary", "compact_str", "crossterm", - "indoc", + "instability", "itertools", "lru", "paste", - "stability", "strum", + "strum_macros", "unicode-segmentation", + "unicode-truncate", "unicode-width", ] @@ -986,9 +1011,9 @@ checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" [[package]] name = "rustix" -version = "0.38.31" +version = "0.38.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea3e1a662af26cd7a3ba09c0297a31af215563ecf42817c98df621387f4e949" +checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" dependencies = [ "bitflags 2.4.2", "errno", @@ -1127,12 +1152,12 @@ dependencies = [ [[package]] name = "signal-hook-mio" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", - "mio", + "mio 1.0.2", "signal-hook", ] @@ -1189,16 +1214,6 @@ dependencies = [ "serde", ] -[[package]] -name = "stability" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebd1b177894da2a2d9120208c3386066af06a488255caabc5de8ddca22dbc3ce" -dependencies = [ - "quote", - "syn 1.0.109", -] - [[package]] name = "static_assertions" version = "1.1.0" @@ -1246,11 +1261,11 @@ dependencies = [ [[package]] name = "strum_macros" -version = "0.26.1" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a3417fc93d76740d974a01654a09777cb500428cc874ca9f45edfe0c4d4cd18" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "proc-macro2", "quote", "rustversion", @@ -1377,7 +1392,7 @@ dependencies = [ "backtrace", "bytes", "libc", - "mio", + "mio 0.8.10", "pin-project-lite", "signal-hook-registry", "socket2", @@ -1470,6 +1485,16 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-input" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd137780d743c103a391e06fe952487f914b299a4fe2c3626677f6a6339a7c6b" +dependencies = [ + "ratatui", + "unicode-width", +] + [[package]] name = "typed-builder" version = "0.18.1" @@ -1518,10 +1543,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" [[package]] -name = "unicode-width" -version = "0.1.11" +name = "unicode-truncate" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" [[package]] name = "url" diff --git a/Cargo.toml b/Cargo.toml index ac138f4..d758e31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,16 +7,18 @@ edition = "2021" [dependencies] aho-corasick = { version = "1.1.2", optional = true } -crossterm = { version = "0.27.0", optional = true} +crossterm = { version = "0.28.1", optional = true} once_cell = { version = "1.19.0", optional = true} openssh = { version = "0.10.3", features = ["native-mux"], default-features = false, optional = true} paste = { version = "1.0.15", optional = true } -ratatui = { version = "0.26.0", optional = true} +ratatui = { version = "0.28.1", optional = true} reqwest = { version = "0.11.25", features = ["blocking", "json"], optional = true } serde = { version = "1.0.196", features = ["derive"], optional = true } serde_json = { version = "1.0.113", optional = true} structopt = { version = "0.3.26", optional = true} tokio = { version = "1.36.0", features = ["rt"], optional = true} +# ratatui/crossterm dependency version must match with musichhoard's ratatui/crossterm +tui-input = { version = "0.10.1", optional = true } url = { version = "2.5.0" } uuid = { version = "1.7.0" } @@ -35,7 +37,7 @@ database-json = ["serde", "serde_json"] library-beets = [] library-beets-ssh = ["openssh", "tokio"] musicbrainz = ["paste", "reqwest", "serde", "serde_json"] -tui = ["aho-corasick", "crossterm", "once_cell", "ratatui"] +tui = ["aho-corasick", "crossterm", "once_cell", "ratatui", "tui-input"] [[bin]] name = "musichoard" diff --git a/src/external/database/json/mod.rs b/src/external/database/json/mod.rs index 924220e..126a4bd 100644 --- a/src/external/database/json/mod.rs +++ b/src/external/database/json/mod.rs @@ -5,13 +5,14 @@ pub mod backend; #[cfg(test)] use mockall::automock; -use crate::core::{ - collection::Collection, - interface::database::{IDatabase, LoadError, SaveError}, +use crate::{ + core::{ + collection::Collection, + interface::database::{IDatabase, LoadError, SaveError}, + }, + external::database::serde::{deserialize::DeserializeDatabase, serialize::SerializeDatabase}, }; -use super::serde::{deserialize::DeserializeDatabase, serialize::SerializeDatabase}; - impl From for LoadError { fn from(err: serde_json::Error) -> LoadError { LoadError::SerDeError(err.to_string()) diff --git a/src/external/musicbrainz/api/search/artist.rs b/src/external/musicbrainz/api/search/artist.rs index 6ad7a41..7d0f805 100644 --- a/src/external/musicbrainz/api/search/artist.rs +++ b/src/external/musicbrainz/api/search/artist.rs @@ -4,11 +4,12 @@ use serde::Deserialize; use crate::{ collection::{artist::ArtistId, musicbrainz::Mbid}, - external::musicbrainz::api::SerdeMbid, + external::musicbrainz::api::{ + search::query::{impl_term, EmptyQuery, EmptyQueryJoin, Query, QueryJoin}, + SerdeMbid, + }, }; -use super::query::{impl_term, EmptyQuery, EmptyQueryJoin, Query, QueryJoin}; - pub enum SearchArtist<'a> { String(&'a str), } diff --git a/src/external/musicbrainz/api/search/release_group.rs b/src/external/musicbrainz/api/search/release_group.rs index 4192630..ae9394e 100644 --- a/src/external/musicbrainz/api/search/release_group.rs +++ b/src/external/musicbrainz/api/search/release_group.rs @@ -8,12 +8,11 @@ use crate::{ musicbrainz::Mbid, }, external::musicbrainz::api::{ + search::query::{impl_term, EmptyQuery, EmptyQueryJoin, Query, QueryJoin}, ApiDisplay, SerdeAlbumDate, SerdeAlbumPrimaryType, SerdeAlbumSecondaryType, SerdeMbid, }, }; -use super::query::{impl_term, EmptyQuery, EmptyQueryJoin, Query, QueryJoin}; - pub enum SearchReleaseGroup<'a> { String(&'a str), Arid(&'a Mbid), diff --git a/src/tui/app/machine/browse_state.rs b/src/tui/app/machine/browse_state.rs index 2e71f73..06c8cfb 100644 --- a/src/tui/app/machine/browse_state.rs +++ b/src/tui/app/machine/browse_state.rs @@ -1,17 +1,14 @@ use crate::tui::app::{ machine::{App, AppInner, AppMachine}, selection::{Delta, ListSelection}, - AppPublic, AppState, IAppInteractBrowse, + AppPublicState, AppState, IAppInteractBrowse, }; pub struct BrowseState; impl AppMachine { pub fn browse_state(inner: AppInner) -> Self { - AppMachine { - inner, - state: BrowseState, - } + AppMachine::new(inner, BrowseState) } } @@ -21,12 +18,9 @@ impl From> for App { } } -impl<'a> From<&'a mut AppMachine> for AppPublic<'a> { - fn from(machine: &'a mut AppMachine) -> Self { - AppPublic { - inner: (&mut machine.inner).into(), - state: AppState::Browse(()), - } +impl<'a> From<&'a mut BrowseState> for AppPublicState<'a> { + fn from(_state: &'a mut BrowseState) -> Self { + AppState::Browse(()) } } diff --git a/src/tui/app/machine/critical_state.rs b/src/tui/app/machine/critical_state.rs index 9242cbc..b6268c3 100644 --- a/src/tui/app/machine/critical_state.rs +++ b/src/tui/app/machine/critical_state.rs @@ -1,20 +1,23 @@ use crate::tui::app::{ machine::{App, AppInner, AppMachine}, - AppPublic, AppState, + AppPublicState, AppState, }; pub struct CriticalState { string: String, } +impl CriticalState { + fn new>(string: S) -> Self { + CriticalState { + string: string.into(), + } + } +} + impl AppMachine { pub fn critical_state>(inner: AppInner, string: S) -> Self { - AppMachine { - inner, - state: CriticalState { - string: string.into(), - }, - } + AppMachine::new(inner, CriticalState::new(string)) } } @@ -24,11 +27,8 @@ impl From> for App { } } -impl<'a> From<&'a mut AppMachine> for AppPublic<'a> { - fn from(machine: &'a mut AppMachine) -> Self { - AppPublic { - inner: (&mut machine.inner).into(), - state: AppState::Critical(&machine.state.string), - } +impl<'a> From<&'a mut CriticalState> for AppPublicState<'a> { + fn from(state: &'a mut CriticalState) -> Self { + AppState::Critical(&state.string) } } diff --git a/src/tui/app/machine/error_state.rs b/src/tui/app/machine/error_state.rs index 2150b2d..0ff6561 100644 --- a/src/tui/app/machine/error_state.rs +++ b/src/tui/app/machine/error_state.rs @@ -1,20 +1,23 @@ use crate::tui::app::{ machine::{App, AppInner, AppMachine}, - AppPublic, AppState, IAppInteractError, + AppPublicState, AppState, IAppInteractError, }; pub struct ErrorState { string: String, } +impl ErrorState { + fn new>(string: S) -> Self { + ErrorState { + string: string.into(), + } + } +} + impl AppMachine { pub fn error_state>(inner: AppInner, string: S) -> Self { - AppMachine { - inner, - state: ErrorState { - string: string.into(), - }, - } + AppMachine::new(inner, ErrorState::new(string)) } } @@ -24,12 +27,9 @@ impl From> for App { } } -impl<'a> From<&'a mut AppMachine> for AppPublic<'a> { - fn from(machine: &'a mut AppMachine) -> Self { - AppPublic { - inner: (&mut machine.inner).into(), - state: AppState::Error(&machine.state.string), - } +impl<'a> From<&'a mut ErrorState> for AppPublicState<'a> { + fn from(state: &'a mut ErrorState) -> Self { + AppState::Error(&state.string) } } diff --git a/src/tui/app/machine/fetch_state.rs b/src/tui/app/machine/fetch_state.rs index 78d09eb..da01165 100644 --- a/src/tui/app/machine/fetch_state.rs +++ b/src/tui/app/machine/fetch_state.rs @@ -14,15 +14,13 @@ use musichoard::collection::{ use crate::tui::{ app::{ - machine::{App, AppInner, AppMachine}, - AppPublic, AppState, IAppEventFetch, IAppInteractFetch, MatchStateInfo, + machine::{match_state::MatchState, App, AppInner, AppMachine}, + AppPublicState, AppState, IAppEventFetch, IAppInteractFetch, MatchStateInfo, }, event::{Event, EventSender}, lib::interface::musicbrainz::{self, Error as MbError, IMusicBrainz}, }; -use super::match_state::MatchState; - pub struct FetchState { fetch_rx: FetchReceiver, } @@ -40,7 +38,7 @@ pub type FetchReceiver = mpsc::Receiver; impl AppMachine { fn fetch_state(inner: AppInner, state: FetchState) -> Self { - AppMachine { inner, state } + AppMachine::new(inner, state) } pub fn app_fetch_new(inner: AppInner) -> App { @@ -168,12 +166,9 @@ impl From> for App { } } -impl<'a> From<&'a mut AppMachine> for AppPublic<'a> { - fn from(machine: &'a mut AppMachine) -> Self { - AppPublic { - inner: (&mut machine.inner).into(), - state: AppState::Fetch(()), - } +impl<'a> From<&'a mut FetchState> for AppPublicState<'a> { + fn from(_state: &'a mut FetchState) -> Self { + AppState::Fetch(()) } } diff --git a/src/tui/app/machine/info_state.rs b/src/tui/app/machine/info_state.rs index a4e5ef6..9dd7ddc 100644 --- a/src/tui/app/machine/info_state.rs +++ b/src/tui/app/machine/info_state.rs @@ -1,16 +1,13 @@ use crate::tui::app::{ machine::{App, AppInner, AppMachine}, - AppPublic, AppState, IAppInteractInfo, + AppPublicState, AppState, IAppInteractInfo, }; pub struct InfoState; impl AppMachine { pub fn info_state(inner: AppInner) -> Self { - AppMachine { - inner, - state: InfoState, - } + AppMachine::new(inner, InfoState) } } @@ -20,12 +17,9 @@ impl From> for App { } } -impl<'a> From<&'a mut AppMachine> for AppPublic<'a> { - fn from(machine: &'a mut AppMachine) -> Self { - AppPublic { - inner: (&mut machine.inner).into(), - state: AppState::Info(()), - } +impl<'a> From<&'a mut InfoState> for AppPublicState<'a> { + fn from(_state: &'a mut InfoState) -> Self { + AppState::Info(()) } } diff --git a/src/tui/app/machine/input.rs b/src/tui/app/machine/input.rs new file mode 100644 index 0000000..0494235 --- /dev/null +++ b/src/tui/app/machine/input.rs @@ -0,0 +1,99 @@ +use tui_input::backend::crossterm::EventHandler; + +use crate::tui::app::{machine::App, AppMode, AppState, IAppInput, InputEvent, InputPublic}; + +#[derive(Default)] +pub struct Input(tui_input::Input); + +impl<'app> From<&'app Input> for InputPublic<'app> { + fn from(value: &'app Input) -> Self { + &value.0 + } +} + +impl From for AppMode { + fn from(mut app: App) -> Self { + if let Some(input) = app.input_mut().take() { + AppMode::Input(AppInputMode::new(input, app)) + } else { + AppMode::State(app) + } + } +} + +pub struct AppInputMode { + input: Input, + app: App, +} + +impl AppInputMode { + pub fn new(input: Input, app: App) -> Self { + AppInputMode { input, app } + } +} + +impl IAppInput for AppInputMode { + type APP = App; + + fn input(mut self, input: InputEvent) -> Self::APP { + self.input + .0 + .handle_event(&crossterm::event::Event::Key(input.into())); + self.app.input_mut().replace(self.input); + self.app + } + + fn confirm(mut self) -> Self::APP { + if let AppState::Match(state) = &mut self.app { + state.submit_input(self.input); + } + self.app + } + + fn cancel(self) -> Self::APP { + self.app + } +} + +#[cfg(test)] +mod tests { + use crate::tui::app::{ + machine::tests::{events, mb_api, music_hoard_init}, + IApp, + }; + + use super::*; + + fn input_event(c: char) -> InputEvent { + crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char(c), + crossterm::event::KeyModifiers::empty(), + ) + .into() + } + + #[test] + fn handle_input() { + let mut app = App::new(music_hoard_init(vec![]), mb_api(), events()); + app.input_mut().replace(Input::default()); + + let input = app.mode().unwrap_input(); + let app = input.input(input_event('H')); + + let input = app.mode().unwrap_input(); + let app = input.input(input_event('e')); + + let input = app.mode().unwrap_input(); + let app = input.input(input_event('l')); + + let input = app.mode().unwrap_input(); + let app = input.input(input_event('l')); + + let input = app.mode().unwrap_input(); + let app = input.input(input_event('o')); + + assert_eq!(app.input_ref().as_ref().unwrap().0.value(), "Hello"); + + app.mode().unwrap_input().confirm().unwrap_browse(); + } +} diff --git a/src/tui/app/machine/match_state.rs b/src/tui/app/machine/match_state.rs index d695c39..efcf7b7 100644 --- a/src/tui/app/machine/match_state.rs +++ b/src/tui/app/machine/match_state.rs @@ -1,13 +1,11 @@ use std::cmp; use crate::tui::app::{ - machine::{App, AppInner, AppMachine}, - AlbumMatches, AppPublic, AppState, ArtistMatches, IAppInteractMatch, MatchOption, + machine::{fetch_state::FetchState, input::Input, App, AppInner, AppMachine}, + AlbumMatches, AppPublicState, AppState, ArtistMatches, IAppInteractMatch, MatchOption, MatchStateInfo, MatchStatePublic, WidgetState, }; -use super::fetch_state::FetchState; - impl ArtistMatches { fn len(&self) -> usize { self.list.len() @@ -16,6 +14,14 @@ impl ArtistMatches { fn push_cannot_have_mbid(&mut self) { self.list.push(MatchOption::CannotHaveMbid) } + + fn push_manual_input_mbid(&mut self) { + self.list.push(MatchOption::ManualInputMbid) + } + + fn is_manual_input_mbid(&self, index: usize) -> bool { + self.list.get(index) == Some(&MatchOption::ManualInputMbid) + } } impl AlbumMatches { @@ -26,6 +32,14 @@ impl AlbumMatches { fn push_cannot_have_mbid(&mut self) { self.list.push(MatchOption::CannotHaveMbid) } + + fn push_manual_input_mbid(&mut self) { + self.list.push(MatchOption::ManualInputMbid) + } + + fn is_manual_input_mbid(&self, index: usize) -> bool { + self.list.get(index) == Some(&MatchOption::ManualInputMbid) + } } impl MatchStateInfo { @@ -36,12 +50,26 @@ impl MatchStateInfo { } } - pub fn push_cannot_have_mbid(&mut self) { + fn push_cannot_have_mbid(&mut self) { match self { Self::Artist(a) => a.push_cannot_have_mbid(), Self::Album(a) => a.push_cannot_have_mbid(), } } + + fn push_manual_input_mbid(&mut self) { + match self { + Self::Artist(a) => a.push_manual_input_mbid(), + Self::Album(a) => a.push_manual_input_mbid(), + } + } + + fn is_manual_input_mbid(&self, index: usize) -> bool { + match self { + Self::Artist(a) => a.is_manual_input_mbid(index), + Self::Album(a) => a.is_manual_input_mbid(index), + } + } } pub struct MatchState { @@ -56,6 +84,7 @@ impl MatchState { if let Some(ref mut current) = current { state.list.select(Some(0)); current.push_cannot_have_mbid(); + current.push_manual_input_mbid(); } MatchState { current, @@ -67,8 +96,10 @@ impl MatchState { impl AppMachine { pub fn match_state(inner: AppInner, state: MatchState) -> Self { - AppMachine { inner, state } + AppMachine::new(inner, state) } + + pub fn submit_input(&mut self, _input: Input) {} } impl From> for App { @@ -77,15 +108,12 @@ impl From> for App { } } -impl<'a> From<&'a mut AppMachine> for AppPublic<'a> { - fn from(machine: &'a mut AppMachine) -> Self { - AppPublic { - inner: (&mut machine.inner).into(), - state: AppState::Match(MatchStatePublic { - info: machine.state.current.as_ref().map(Into::into), - state: &mut machine.state.state, - }), - } +impl<'a> From<&'a mut MatchState> for AppPublicState<'a> { + fn from(state: &'a mut MatchState) -> Self { + AppState::Match(MatchStatePublic { + info: state.current.as_ref().map(Into::into), + state: &mut state.state, + }) } } @@ -115,7 +143,20 @@ impl IAppInteractMatch for AppMachine { self.into() } - fn select(self) -> Self::APP { + fn select(mut self) -> Self::APP { + if let Some(index) = self.state.state.list.selected() { + // selected() implies current exists + if self + .state + .current + .as_ref() + .unwrap() + .is_manual_input_mbid(index) + { + self.input.replace(Input::default()); + return self.into(); + } + } AppMachine::app_fetch_next(self.inner, self.state.fetch) } @@ -136,7 +177,7 @@ mod tests { use crate::tui::{ app::{ machine::tests::{inner, music_hoard}, - IAppAccess, + IApp, IAppAccess, IAppInput, }, lib::interface::musicbrainz::Match, }; @@ -221,6 +262,7 @@ mod tests { match_state(Some(album_match.clone())), ); album_match.push_cannot_have_mbid(); + album_match.push_manual_input_mbid(); let mut widget_state = WidgetState::default(); widget_state.list.select(Some(0)); @@ -244,6 +286,7 @@ mod tests { let matches = AppMachine::match_state(inner(music_hoard(vec![])), app_matches); matches_info.push_cannot_have_mbid(); + matches_info.push_manual_input_mbid(); let mut widget_state = WidgetState::default(); widget_state.list.select(Some(0)); @@ -267,12 +310,19 @@ mod tests { assert_eq!(matches.state.current.as_ref(), Some(&matches_info)); assert_eq!(matches.state.state.list.selected(), Some(2)); + // Next is ManualInputMbid let matches = matches.next_match().unwrap_match(); assert_eq!(matches.state.current.as_ref(), Some(&matches_info)); - assert_eq!(matches.state.state.list.selected(), Some(2)); + assert_eq!(matches.state.state.list.selected(), Some(3)); - // And it's done + let matches = matches.next_match().unwrap_match(); + + assert_eq!(matches.state.current.as_ref(), Some(&matches_info)); + assert_eq!(matches.state.state.list.selected(), Some(3)); + + // Go prev_match first as selecting on manual input does not go back to fetch. + let matches = matches.prev_match().unwrap_match(); matches.select().unwrap_fetch(); } @@ -294,6 +344,7 @@ mod tests { match_state(Some(album_match.clone())), ); album_match.push_cannot_have_mbid(); + album_match.push_manual_input_mbid(); let mut widget_state = WidgetState::default(); widget_state.list.select(Some(0)); @@ -311,4 +362,21 @@ mod tests { let matches = AppMachine::match_state(inner(music_hoard(vec![])), match_state(None)); matches.select().unwrap_browse(); } + + #[test] + fn select_manual_input() { + let matches = + AppMachine::match_state(inner(music_hoard(vec![])), match_state(Some(album_match()))); + + // album_match has two matches which means that the fourth option should be manual input. + let matches = matches.next_match().unwrap_match(); + let matches = matches.next_match().unwrap_match(); + let matches = matches.next_match().unwrap_match(); + let matches = matches.next_match().unwrap_match(); + + let app = matches.select(); + + let input = app.mode().unwrap_input(); + input.confirm().unwrap_match(); + } } diff --git a/src/tui/app/machine/mod.rs b/src/tui/app/machine/mod.rs index 525832e..f7c5d08 100644 --- a/src/tui/app/machine/mod.rs +++ b/src/tui/app/machine/mod.rs @@ -3,6 +3,7 @@ mod critical_state; mod error_state; mod fetch_state; mod info_state; +mod input; mod match_state; mod reload_state; mod search_state; @@ -10,7 +11,10 @@ mod search_state; use std::sync::{Arc, Mutex}; use crate::tui::{ - app::{selection::Selection, AppPublic, AppPublicInner, AppState, IApp, IAppAccess}, + app::{ + selection::Selection, AppMode, AppPublic, AppPublicInner, AppPublicState, AppState, IApp, + IAppAccess, IAppBase, IAppState, + }, event::EventSender, lib::{interface::musicbrainz::IMusicBrainz, IMusicHoard}, }; @@ -20,12 +24,11 @@ use critical_state::CriticalState; use error_state::ErrorState; use fetch_state::FetchState; use info_state::InfoState; +use input::{AppInputMode, Input}; use match_state::MatchState; use reload_state::ReloadState; use search_state::SearchState; -use super::IAppBase; - pub type App = AppState< AppMachine, AppMachine, @@ -40,6 +43,7 @@ pub type App = AppState< pub struct AppMachine { inner: AppInner, state: STATE, + input: Option, } pub struct AppInner { @@ -50,6 +54,36 @@ pub struct AppInner { events: EventSender, } +macro_rules! app_field_ref { + ($app:ident, $field:ident) => { + match $app { + AppState::Browse(state) => &state.$field, + AppState::Info(state) => &state.$field, + AppState::Reload(state) => &state.$field, + AppState::Search(state) => &state.$field, + AppState::Fetch(state) => &state.$field, + AppState::Match(state) => &state.$field, + AppState::Error(state) => &state.$field, + AppState::Critical(state) => &state.$field, + } + }; +} + +macro_rules! app_field_mut { + ($app:ident, $field:ident) => { + match $app { + AppState::Browse(state) => &mut state.$field, + AppState::Info(state) => &mut state.$field, + AppState::Reload(state) => &mut state.$field, + AppState::Search(state) => &mut state.$field, + AppState::Fetch(state) => &mut state.$field, + AppState::Match(state) => &mut state.$field, + AppState::Error(state) => &mut state.$field, + AppState::Critical(state) => &mut state.$field, + } + }; +} + impl App { pub fn new( mut music_hoard: MH, @@ -70,29 +104,20 @@ impl App { } fn inner_ref(&self) -> &AppInner { - match self { - AppState::Browse(browse_state) => &browse_state.inner, - AppState::Info(info_state) => &info_state.inner, - AppState::Reload(reload_state) => &reload_state.inner, - AppState::Search(search_state) => &search_state.inner, - AppState::Fetch(fetch_state) => &fetch_state.inner, - AppState::Match(match_state) => &match_state.inner, - AppState::Error(error_state) => &error_state.inner, - AppState::Critical(critical_state) => &critical_state.inner, - } + app_field_ref!(self, inner) } fn inner_mut(&mut self) -> &mut AppInner { - match self { - AppState::Browse(browse_state) => &mut browse_state.inner, - AppState::Info(info_state) => &mut info_state.inner, - AppState::Reload(reload_state) => &mut reload_state.inner, - AppState::Search(search_state) => &mut search_state.inner, - AppState::Fetch(fetch_state) => &mut fetch_state.inner, - AppState::Match(match_state) => &mut match_state.inner, - AppState::Error(error_state) => &mut error_state.inner, - AppState::Critical(critical_state) => &mut critical_state.inner, - } + app_field_mut!(self, inner) + } + + #[cfg(test)] + fn input_ref(&self) -> &Option { + app_field_ref!(self, input) + } + + fn input_mut(&mut self) -> &mut Option { + app_field_mut!(self, input) } } @@ -105,6 +130,7 @@ impl IApp for App { type MatchState = AppMachine; type ErrorState = AppMachine; type CriticalState = AppMachine; + type InputMode = AppInputMode; fn is_running(&self) -> bool { self.inner_ref().running @@ -115,20 +141,13 @@ impl IApp for App { self } - fn state( - self, - ) -> AppState< - Self::BrowseState, - Self::InfoState, - Self::ReloadState, - Self::SearchState, - Self::FetchState, - Self::MatchState, - Self::ErrorState, - Self::CriticalState, - > { + fn state(self) -> IAppState!() { self } + + fn mode(self) -> AppMode { + self.into() + } } impl> IAppBase for T { @@ -142,14 +161,14 @@ impl> IAppBase for T { impl IAppAccess for App { fn get(&mut self) -> AppPublic { match self { - AppState::Browse(browse) => browse.into(), - AppState::Info(info) => info.into(), - AppState::Reload(reload) => reload.into(), - AppState::Search(search) => search.into(), - AppState::Fetch(fetch) => fetch.into(), - AppState::Match(matches) => matches.into(), - AppState::Error(error) => error.into(), - AppState::Critical(critical) => critical.into(), + AppState::Browse(state) => state.into(), + AppState::Info(state) => state.into(), + AppState::Reload(state) => state.into(), + AppState::Search(state) => state.into(), + AppState::Fetch(state) => state.into(), + AppState::Match(state) => state.into(), + AppState::Error(state) => state.into(), + AppState::Critical(state) => state.into(), } } } @@ -180,6 +199,29 @@ impl<'a> From<&'a mut AppInner> for AppPublicInner<'a> { } } +impl AppMachine { + pub fn new(inner: AppInner, state: State) -> Self { + AppMachine { + inner, + state, + input: None, + } + } +} + +impl<'a, State> From<&'a mut AppMachine> for AppPublic<'a> +where + &'a mut State: Into>, +{ + fn from(machine: &'a mut AppMachine) -> Self { + AppPublic { + inner: (&mut machine.inner).into(), + state: (&mut machine.state).into(), + input: machine.input.as_ref().map(Into::into), + } + } +} + #[cfg(test)] mod tests { use std::sync::mpsc; @@ -187,64 +229,100 @@ mod tests { use musichoard::collection::Collection; use crate::tui::{ - app::{AppState, IApp, IAppInteractBrowse}, + app::{AppState, IApp, IAppInput, IAppInteractBrowse}, lib::{interface::musicbrainz::MockIMusicBrainz, MockIMusicHoard}, EventChannel, }; use super::*; - impl AppState { - pub fn unwrap_browse(self) -> BS { + impl AppMode { + fn unwrap_state(self) -> StateMode { + match self { + AppMode::State(state) => state, + _ => panic!(), + } + } + + pub fn unwrap_input(self) -> InputMode { + match self { + AppMode::Input(input) => input, + _ => panic!(), + } + } + } + + impl< + BrowseState, + InfoState, + ReloadState, + SearchState, + FetchState, + MatchState, + ErrorState, + CriticalState, + > + AppState< + BrowseState, + InfoState, + ReloadState, + SearchState, + FetchState, + MatchState, + ErrorState, + CriticalState, + > + { + pub fn unwrap_browse(self) -> BrowseState { match self { AppState::Browse(browse) => browse, _ => panic!(), } } - pub fn unwrap_info(self) -> IS { + pub fn unwrap_info(self) -> InfoState { match self { AppState::Info(info) => info, _ => panic!(), } } - pub fn unwrap_reload(self) -> RS { + pub fn unwrap_reload(self) -> ReloadState { match self { AppState::Reload(reload) => reload, _ => panic!(), } } - pub fn unwrap_search(self) -> SS { + pub fn unwrap_search(self) -> SearchState { match self { AppState::Search(search) => search, _ => panic!(), } } - pub fn unwrap_fetch(self) -> FS { + pub fn unwrap_fetch(self) -> FetchState { match self { AppState::Fetch(fetch) => fetch, _ => panic!(), } } - pub fn unwrap_match(self) -> MS { + pub fn unwrap_match(self) -> MatchState { match self { AppState::Match(matches) => matches, _ => panic!(), } } - pub fn unwrap_error(self) -> ES { + pub fn unwrap_error(self) -> ErrorState { match self { AppState::Error(error) => error, _ => panic!(), } } - pub fn unwrap_critical(self) -> CS { + pub fn unwrap_critical(self) -> CriticalState { match self { AppState::Critical(critical) => critical, _ => panic!(), @@ -259,7 +337,7 @@ mod tests { music_hoard } - fn music_hoard_init(collection: Collection) -> MockIMusicHoard { + pub fn music_hoard_init(collection: Collection) -> MockIMusicHoard { let mut music_hoard = music_hoard(collection); music_hoard @@ -270,11 +348,11 @@ mod tests { music_hoard } - fn mb_api() -> MockIMusicBrainz { + pub fn mb_api() -> MockIMusicBrainz { MockIMusicBrainz::new() } - fn events() -> EventSender { + pub fn events() -> EventSender { EventChannel::new().sender() } @@ -286,6 +364,33 @@ mod tests { AppInner::new(music_hoard, mb_api, events()) } + #[test] + fn input_mode() { + let app = App::new(music_hoard_init(vec![]), mb_api(), events()); + assert!(app.is_running()); + + let mode = app.mode(); + assert!(matches!(mode, AppMode::State(_))); + + let state = mode.unwrap_state(); + assert!(matches!(state, AppState::Browse(_))); + + let mut app = state; + app.input_mut().replace(Input::default()); + + let public = app.get(); + assert!(public.input.is_some()); + + let mode = app.mode(); + assert!(matches!(mode, AppMode::Input(_))); + + let mut app = mode.unwrap_input().cancel(); + assert!(matches!(app, AppState::Browse(_))); + + let public = app.get(); + assert!(public.input.is_none()); + } + #[test] fn state_browse() { let mut app = App::new(music_hoard_init(vec![]), mb_api(), events()); @@ -384,7 +489,7 @@ mod tests { let (_, rx) = mpsc::channel(); let inner = app.unwrap_browse().inner; let state = FetchState::new(rx); - app = AppMachine { inner, state }.into(); + app = AppMachine::new(inner, state).into(); let state = app.state(); assert!(matches!(state, AppState::Fetch(_))); @@ -403,7 +508,7 @@ mod tests { } #[test] - fn state_matches() { + fn state_match() { let mut app = App::new(music_hoard_init(vec![]), mb_api(), events()); assert!(app.is_running()); diff --git a/src/tui/app/machine/reload_state.rs b/src/tui/app/machine/reload_state.rs index 4c9323f..25def5b 100644 --- a/src/tui/app/machine/reload_state.rs +++ b/src/tui/app/machine/reload_state.rs @@ -1,17 +1,14 @@ use crate::tui::app::{ machine::{App, AppInner, AppMachine}, selection::KeySelection, - AppPublic, AppState, IAppInteractReload, + AppPublicState, AppState, IAppInteractReload, }; pub struct ReloadState; impl AppMachine { pub fn reload_state(inner: AppInner) -> Self { - AppMachine { - inner, - state: ReloadState, - } + AppMachine::new(inner, ReloadState) } } @@ -20,12 +17,10 @@ impl From> for App { AppState::Reload(machine) } } -impl<'a> From<&'a mut AppMachine> for AppPublic<'a> { - fn from(machine: &'a mut AppMachine) -> Self { - AppPublic { - inner: (&mut machine.inner).into(), - state: AppState::Reload(()), - } + +impl<'a> From<&'a mut ReloadState> for AppPublicState<'a> { + fn from(_state: &'a mut ReloadState) -> Self { + AppState::Reload(()) } } diff --git a/src/tui/app/machine/search_state.rs b/src/tui/app/machine/search_state.rs index a658dd1..dbcb9d8 100644 --- a/src/tui/app/machine/search_state.rs +++ b/src/tui/app/machine/search_state.rs @@ -6,7 +6,7 @@ use musichoard::collection::{album::Album, artist::Artist, track::Track}; use crate::tui::app::{ machine::{App, AppInner, AppMachine}, selection::{ListSelection, SelectionState}, - AppPublic, AppState, Category, IAppInteractSearch, + AppPublicState, AppState, Category, IAppInteractSearch, }; // Unlikely that this covers all possible strings, but it should at least cover strings @@ -31,16 +31,19 @@ struct SearchStateMemo { char: bool, } +impl SearchState { + fn new(orig: ListSelection) -> Self { + SearchState { + string: String::new(), + orig, + memo: vec![], + } + } +} + impl AppMachine { pub fn search_state(inner: AppInner, orig: ListSelection) -> Self { - AppMachine { - inner, - state: SearchState { - string: String::new(), - orig, - memo: vec![], - }, - } + AppMachine::new(inner, SearchState::new(orig)) } } @@ -50,12 +53,9 @@ impl From> for App { } } -impl<'a> From<&'a mut AppMachine> for AppPublic<'a> { - fn from(machine: &'a mut AppMachine) -> Self { - AppPublic { - inner: (&mut machine.inner).into(), - state: AppState::Search(&machine.state.string), - } +impl<'a> From<&'a mut SearchState> for AppPublicState<'a> { + fn from(state: &'a mut SearchState) -> Self { + AppState::Search(&state.string) } } diff --git a/src/tui/app/mod.rs b/src/tui/app/mod.rs index 999baec..dd6b3a9 100644 --- a/src/tui/app/mod.rs +++ b/src/tui/app/mod.rs @@ -8,26 +8,30 @@ use musichoard::collection::{album::AlbumMeta, artist::ArtistMeta, Collection}; use crate::tui::lib::interface::musicbrainz::Match; -pub enum AppState< - BrowseState, - InfoState, - ReloadState, - SearchState, - FetchState, - MatchState, - ErrorState, - CriticalState, -> { - Browse(BrowseState), - Info(InfoState), - Reload(ReloadState), - Search(SearchState), - Fetch(FetchState), - Match(MatchState), - Error(ErrorState), - Critical(CriticalState), +pub enum AppState { + Browse(B), + Info(I), + Reload(R), + Search(S), + Fetch(F), + Match(M), + Error(E), + Critical(C), } +pub enum AppMode { + State(StateMode), + Input(InputMode), +} + +macro_rules! IAppState { + () => { + AppState + }; +} +use IAppState; + pub trait IApp { type BrowseState: IAppBase + IAppInteractBrowse; type InfoState: IAppBase + IAppInteractInfo; @@ -39,23 +43,15 @@ pub trait IApp { type MatchState: IAppBase + IAppInteractMatch; type ErrorState: IAppBase + IAppInteractError; type CriticalState: IAppBase; + type InputMode: IAppInput; fn is_running(&self) -> bool; fn force_quit(self) -> Self; + fn state(self) -> IAppState!(); + #[allow(clippy::type_complexity)] - fn state( - self, - ) -> AppState< - Self::BrowseState, - Self::InfoState, - Self::ReloadState, - Self::SearchState, - Self::FetchState, - Self::MatchState, - Self::ErrorState, - Self::CriticalState, - >; + fn mode(self) -> AppMode; } pub trait IAppBase { @@ -129,6 +125,28 @@ pub trait IAppInteractMatch { fn abort(self) -> Self::APP; } +pub struct InputEvent(crossterm::event::KeyEvent); + +impl From for InputEvent { + fn from(value: crossterm::event::KeyEvent) -> Self { + InputEvent(value) + } +} + +impl From for crossterm::event::KeyEvent { + fn from(value: InputEvent) -> Self { + value.0 + } +} + +pub trait IAppInput { + type APP: IApp; + + fn input(self, input: InputEvent) -> Self::APP; + fn confirm(self) -> Self::APP; + fn cancel(self) -> Self::APP; +} + pub trait IAppInteractError { type APP: IApp; @@ -146,6 +164,7 @@ pub trait IAppAccess { pub struct AppPublic<'app> { pub inner: AppPublicInner<'app>, pub state: AppPublicState<'app>, + pub input: Option>, } pub struct AppPublicInner<'app> { @@ -153,10 +172,13 @@ pub struct AppPublicInner<'app> { pub selection: &'app mut Selection, } +pub type InputPublic<'app> = &'app tui_input::Input; + #[derive(Clone, Debug, PartialEq, Eq)] pub enum MatchOption { Match(Match), CannotHaveMbid, + ManualInputMbid, } impl From> for MatchOption { @@ -203,7 +225,7 @@ pub struct MatchStatePublic<'app> { pub type AppPublicState<'app> = AppState<(), (), (), &'app str, (), MatchStatePublic<'app>, &'app str, &'app str>; -impl AppState { +impl AppState { pub fn is_search(&self) -> bool { matches!(self, AppState::Search(_)) } diff --git a/src/tui/handler.rs b/src/tui/handler.rs index 2efc614..9adea18 100644 --- a/src/tui/handler.rs +++ b/src/tui/handler.rs @@ -5,14 +5,13 @@ use mockall::automock; use crate::tui::{ app::{ - AppState, Delta, IApp, IAppInteractBrowse, IAppInteractError, IAppInteractFetch, - IAppInteractInfo, IAppInteractMatch, IAppInteractReload, IAppInteractSearch, + AppMode, AppState, Delta, IApp, IAppBase, IAppEventFetch, IAppInput, IAppInteractBrowse, + IAppInteractError, IAppInteractFetch, IAppInteractInfo, IAppInteractMatch, + IAppInteractReload, IAppInteractSearch, }, event::{Event, EventError, EventReceiver}, }; -use super::app::{IAppBase, IAppEventFetch}; - #[cfg_attr(test, automock)] pub trait IEventHandler { fn handle_next_event(&self, app: APP) -> Result; @@ -30,6 +29,8 @@ trait IEventHandlerPrivate { fn handle_critical_key_event(app: ::CriticalState, key_event: KeyEvent) -> APP; fn handle_fetch_result_ready_event(app: APP) -> APP; + + fn handle_input_key_event>(app: Input, key_event: KeyEvent) -> APP; } pub struct EventHandler { @@ -62,36 +63,45 @@ impl IEventHandlerPrivate for EventHandler { }; } - match app.state() { - AppState::Browse(browse_state) => { - Self::handle_browse_key_event(browse_state, key_event) - } - AppState::Info(info_state) => Self::handle_info_key_event(info_state, key_event), - AppState::Reload(reload_state) => { - Self::handle_reload_key_event(reload_state, key_event) - } - AppState::Search(search_state) => { - Self::handle_search_key_event(search_state, key_event) - } - AppState::Fetch(fetch_state) => Self::handle_fetch_key_event(fetch_state, key_event), - AppState::Match(match_state) => Self::handle_match_key_event(match_state, key_event), - AppState::Error(error_state) => Self::handle_error_key_event(error_state, key_event), - AppState::Critical(critical_state) => { - Self::handle_critical_key_event(critical_state, key_event) - } + match app.mode() { + AppMode::Input(input_mode) => Self::handle_input_key_event(input_mode, key_event), + AppMode::State(state_mode) => match state_mode { + AppState::Browse(browse_state) => { + Self::handle_browse_key_event(browse_state, key_event) + } + AppState::Info(info_state) => Self::handle_info_key_event(info_state, key_event), + AppState::Reload(reload_state) => { + Self::handle_reload_key_event(reload_state, key_event) + } + AppState::Search(search_state) => { + Self::handle_search_key_event(search_state, key_event) + } + AppState::Fetch(fetch_state) => { + Self::handle_fetch_key_event(fetch_state, key_event) + } + AppState::Match(match_state) => { + Self::handle_match_key_event(match_state, key_event) + } + AppState::Error(error_state) => { + Self::handle_error_key_event(error_state, key_event) + } + AppState::Critical(critical_state) => { + Self::handle_critical_key_event(critical_state, key_event) + } + }, } } fn handle_fetch_result_ready_event(app: APP) -> APP { match app.state() { - AppState::Browse(browse_state) => browse_state.no_op(), - AppState::Info(info_state) => info_state.no_op(), - AppState::Reload(reload_state) => reload_state.no_op(), - AppState::Search(search_state) => search_state.no_op(), + AppState::Browse(state) => state.no_op(), + AppState::Info(state) => state.no_op(), + AppState::Reload(state) => state.no_op(), + AppState::Search(state) => state.no_op(), AppState::Fetch(fetch_state) => fetch_state.fetch_result_ready(), - AppState::Match(match_state) => match_state.no_op(), - AppState::Error(error_state) => error_state.no_op(), - AppState::Critical(critical_state) => critical_state.no_op(), + AppState::Match(state) => state.no_op(), + AppState::Error(state) => state.no_op(), + AppState::Critical(state) => state.no_op(), } } @@ -175,6 +185,13 @@ impl IEventHandlerPrivate for EventHandler { } fn handle_fetch_key_event(app: ::FetchState, key_event: KeyEvent) -> APP { + if key_event.modifiers == KeyModifiers::CONTROL { + return match key_event.code { + KeyCode::Char('g') | KeyCode::Char('G') => app.abort(), + _ => app.no_op(), + }; + } + match key_event.code { // Abort. KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => app.abort(), @@ -184,6 +201,13 @@ impl IEventHandlerPrivate for EventHandler { } fn handle_match_key_event(app: ::MatchState, key_event: KeyEvent) -> APP { + if key_event.modifiers == KeyModifiers::CONTROL { + return match key_event.code { + KeyCode::Char('g') | KeyCode::Char('G') => app.abort(), + _ => app.no_op(), + }; + } + match key_event.code { // Abort. KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => app.abort(), @@ -205,5 +229,22 @@ impl IEventHandlerPrivate for EventHandler { // No action is allowed. app.no_op() } + + fn handle_input_key_event>(app: Input, key_event: KeyEvent) -> APP { + if key_event.modifiers == KeyModifiers::CONTROL { + match key_event.code { + KeyCode::Char('g') | KeyCode::Char('G') => return app.cancel(), + _ => {} + }; + } + + match key_event.code { + // Return. + KeyCode::Esc => app.cancel(), + KeyCode::Enter => app.confirm(), + // Othey keys. + _ => app.input(key_event.into()), + } + } } // GRCOV_EXCL_STOP diff --git a/src/tui/listener.rs b/src/tui/listener.rs index 1bcbbe2..f49a147 100644 --- a/src/tui/listener.rs +++ b/src/tui/listener.rs @@ -4,7 +4,7 @@ use std::thread; #[cfg(test)] use mockall::automock; -use super::event::{Event, EventError, EventSender}; +use crate::tui::event::{Event, EventError, EventSender}; #[cfg_attr(test, automock)] pub trait IEventListener { diff --git a/src/tui/ui/browse_state.rs b/src/tui/ui/browse_state.rs index 22df107..e01845c 100644 --- a/src/tui/ui/browse_state.rs +++ b/src/tui/ui/browse_state.rs @@ -28,10 +28,14 @@ pub struct TrackArea { pub info: Rect, } -pub struct FrameArea { +pub struct BrowseArea { pub artist: ArtistArea, pub album: AlbumArea, pub track: TrackArea, +} + +pub struct FrameArea { + pub browse: BrowseArea, pub minibuffer: Rect, } @@ -91,14 +95,16 @@ impl FrameArea { }; FrameArea { - artist: ArtistArea { list: artist_list }, - album: AlbumArea { - list: album_list, - info: album_info, - }, - track: TrackArea { - list: track_list, - info: track_info, + browse: BrowseArea { + artist: ArtistArea { list: artist_list }, + album: AlbumArea { + list: album_list, + info: album_info, + }, + track: TrackArea { + list: track_list, + info: track_info, + }, }, minibuffer, } diff --git a/src/tui/ui/display.rs b/src/tui/ui/display.rs index 0ff6eb6..1e8fc1c 100644 --- a/src/tui/ui/display.rs +++ b/src/tui/ui/display.rs @@ -137,6 +137,7 @@ impl UiDisplay { match_artist.score, ), MatchOption::CannotHaveMbid => Self::display_cannot_have_mbid().to_string(), + MatchOption::ManualInputMbid => Self::display_manual_input_mbid().to_string(), } } @@ -153,12 +154,17 @@ impl UiDisplay { match_album.score, ), MatchOption::CannotHaveMbid => Self::display_cannot_have_mbid().to_string(), + MatchOption::ManualInputMbid => Self::display_manual_input_mbid().to_string(), } } fn display_cannot_have_mbid() -> &'static str { "-- Cannot have a MusicBrainz Identifier --" } + + fn display_manual_input_mbid() -> &'static str { + "-- Manually enter a MusicBrainz Identifier --" + } } #[cfg(test)] diff --git a/src/tui/ui/input.rs b/src/tui/ui/input.rs new file mode 100644 index 0000000..3a4995f --- /dev/null +++ b/src/tui/ui/input.rs @@ -0,0 +1,20 @@ +use ratatui::{layout::Rect, widgets::Paragraph, Frame}; + +use crate::tui::app::InputPublic; + +pub struct InputOverlay; + +impl InputOverlay { + pub fn paragraph<'a>(text: &str) -> Paragraph<'a> { + Paragraph::new(format!(" {text}")) + } + + pub fn place_cursor(input: InputPublic, area: Rect, frame: &mut Frame) { + let width = area.width.max(4) - 4; // keep 2 for borders, 1 for left-pad, and 1 for cursor + let scroll = input.visual_scroll(width as usize); + frame.set_cursor_position(( + area.x + ((input.visual_cursor()).max(scroll) - scroll) as u16 + 2, + area.y + 1, + )) + } +} diff --git a/src/tui/ui/minibuffer.rs b/src/tui/ui/minibuffer.rs index 95ac560..7db8361 100644 --- a/src/tui/ui/minibuffer.rs +++ b/src/tui/ui/minibuffer.rs @@ -57,13 +57,16 @@ impl Minibuffer<'_> { columns, }, AppState::Fetch(()) => Minibuffer { - paragraphs: vec![Paragraph::new("fetching..."), Paragraph::new("q: abort")], + paragraphs: vec![ + Paragraph::new("fetching..."), + Paragraph::new("ctrl+g: abort"), + ], columns: 2, }, AppState::Match(public) => Minibuffer { paragraphs: vec![ Paragraph::new(UiDisplay::display_matching_info(public.info)), - Paragraph::new("q: abort"), + Paragraph::new("ctrl+g: abort"), ], columns: 2, }, diff --git a/src/tui/ui/mod.rs b/src/tui/ui/mod.rs index 89c191a..b87ddfa 100644 --- a/src/tui/ui/mod.rs +++ b/src/tui/ui/mod.rs @@ -3,6 +3,7 @@ mod display; mod error_state; mod fetch_state; mod info_state; +mod input; mod match_state; mod minibuffer; mod overlay; @@ -10,12 +11,16 @@ mod reload_state; mod style; mod widgets; +use browse_state::BrowseArea; use ratatui::{layout::Rect, widgets::Paragraph, Frame}; use musichoard::collection::{album::Album, Collection}; use crate::tui::{ - app::{AppPublicState, AppState, Category, IAppAccess, MatchStateInfo, Selection, WidgetState}, + app::{ + AppPublicState, AppState, Category, IAppAccess, InputPublic, MatchStateInfo, Selection, + WidgetState, + }, ui::{ browse_state::{ AlbumArea, AlbumState, ArtistArea, ArtistState, FrameArea, TrackArea, TrackState, @@ -24,6 +29,7 @@ use crate::tui::{ error_state::ErrorOverlay, fetch_state::FetchOverlay, info_state::{AlbumOverlay, ArtistOverlay}, + input::InputOverlay, match_state::MatchOverlay, minibuffer::Minibuffer, overlay::{OverlayBuilder, OverlaySize}, @@ -64,11 +70,10 @@ impl Ui { fn render_browse_frame( artists: &Collection, selection: &mut Selection, - state: &AppPublicState, + areas: BrowseArea, frame: &mut Frame, ) { let active = selection.category(); - let areas = FrameArea::new(frame.size()); let artist_state = ArtistState::new( active == Category::Artist, @@ -101,12 +106,10 @@ impl Ui { ); Self::render_track_column(track_state, areas.track, frame); - - Self::render_minibuffer(state, areas.minibuffer, frame); } fn render_info_overlay(artists: &Collection, selection: &mut Selection, frame: &mut Frame) { - let area = OverlayBuilder::default().build(frame.size()); + let area = OverlayBuilder::default().build(frame.area()); if selection.category() == Category::Artist { let overlay = ArtistOverlay::new(artists, &selection.widget_state_artist().list); @@ -126,13 +129,13 @@ impl Ui { let area = OverlayBuilder::default() .with_width(OverlaySize::Value(39)) .with_height(OverlaySize::Value(4)) - .build(frame.size()); + .build(frame.area()); let reload_text = ReloadOverlay::paragraph(); UiWidget::render_overlay_widget("Reload", reload_text, area, false, frame); } fn render_fetch_overlay(frame: &mut Frame) { - let area = OverlayBuilder::default().build(frame.size()); + let area = OverlayBuilder::default().build(frame.area()); let fetch_text = FetchOverlay::paragraph(); UiWidget::render_overlay_widget("Fetching", fetch_text, area, false, frame) } @@ -142,15 +145,25 @@ impl Ui { state: &mut WidgetState, frame: &mut Frame, ) { - let area = OverlayBuilder::default().build(frame.size()); + let area = OverlayBuilder::default().build(frame.area()); let st = MatchOverlay::new(info, state); UiWidget::render_overlay_list_widget(&st.matching, st.list, st.state, true, area, frame) } + fn render_input_overlay(input: InputPublic, frame: &mut Frame) { + let area = OverlayBuilder::default() + .with_width(OverlaySize::MarginFactor(4)) + .with_height(OverlaySize::Value(3)) + .build(frame.area()); + let input_text = InputOverlay::paragraph(input.value()); + UiWidget::render_overlay_widget("Input", input_text, area, false, frame); + InputOverlay::place_cursor(input, area, frame); + } + fn render_error_overlay>(title: S, msg: S, frame: &mut Frame) { let area = OverlayBuilder::default() .with_height(OverlaySize::Value(4)) - .build(frame.size()); + .build(frame.area()); let error_text = ErrorOverlay::paragraph(msg.as_ref()); UiWidget::render_overlay_widget(title.as_ref(), error_text, area, true, frame); } @@ -164,7 +177,11 @@ impl IUi for Ui { let selection = app.inner.selection; let state = app.state; - Self::render_browse_frame(collection, selection, &state, frame); + let areas = FrameArea::new(frame.area()); + + Self::render_browse_frame(collection, selection, areas.browse, frame); + Self::render_minibuffer(&state, areas.minibuffer, frame); + match state { AppState::Info(()) => Self::render_info_overlay(collection, selection, frame), AppState::Reload(()) => Self::render_reload_overlay(frame), @@ -174,6 +191,10 @@ impl IUi for Ui { AppState::Critical(msg) => Self::render_error_overlay("Critical Error", msg, frame), _ => {} } + + if let Some(input) = app.input { + Self::render_input_overlay(input, frame); + } } } @@ -214,6 +235,7 @@ mod tests { AppState::Error(s) => AppState::Error(s), AppState::Critical(s) => AppState::Critical(s), }, + input: self.input, } } } @@ -231,12 +253,14 @@ mod tests { fn artist_matches(matching: ArtistMeta, list: Vec>) -> MatchStateInfo { let mut list: Vec> = list.into_iter().map(Into::into).collect(); list.push(MatchOption::CannotHaveMbid); + list.push(MatchOption::ManualInputMbid); MatchStateInfo::artist(matching, list) } fn album_matches(matching: AlbumMeta, list: Vec>) -> MatchStateInfo { let mut list: Vec> = list.into_iter().map(Into::into).collect(); list.push(MatchOption::CannotHaveMbid); + list.push(MatchOption::ManualInputMbid); MatchStateInfo::album(matching, list) } @@ -246,6 +270,7 @@ mod tests { let mut app = AppPublic { inner: public_inner(collection, selection), state: AppState::Browse(()), + input: None, }; terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); @@ -324,6 +349,7 @@ mod tests { info: None, state: &mut widget_state, }), + input: None, }; terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); } @@ -353,8 +379,13 @@ mod tests { info: Some(&artist_matches), state: &mut widget_state, }), + input: None, }; terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); + + let input = tui_input::Input::default(); + app.input = Some(&input); + terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); } #[test] @@ -387,7 +418,12 @@ mod tests { info: Some(&album_matches), state: &mut widget_state, }), + input: None, }; terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); + + let input = tui_input::Input::default(); + app.input = Some(&input); + terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); } }