Add manual input elements to the app an ui #216

Merged
wojtek merged 17 commits from 188---add-option-for-manual-input-during-fetch into main 2024-09-15 15:20:11 +02:00
23 changed files with 727 additions and 304 deletions

130
Cargo.lock generated
View File

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

View File

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

View File

@ -5,13 +5,14 @@ pub mod backend;
#[cfg(test)]
use mockall::automock;
use crate::core::{
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<serde_json::Error> for LoadError {
fn from(err: serde_json::Error) -> LoadError {
LoadError::SerDeError(err.to_string())

View File

@ -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),
}

View File

@ -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),

View File

@ -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<BrowseState> {
pub fn browse_state(inner: AppInner) -> Self {
AppMachine {
inner,
state: BrowseState,
}
AppMachine::new(inner, BrowseState)
}
}
@ -21,12 +18,9 @@ impl From<AppMachine<BrowseState>> for App {
}
}
impl<'a> From<&'a mut AppMachine<BrowseState>> for AppPublic<'a> {
fn from(machine: &'a mut AppMachine<BrowseState>) -> 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(())
}
}

View File

@ -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<S: Into<String>>(string: S) -> Self {
CriticalState {
string: string.into(),
}
}
}
impl AppMachine<CriticalState> {
pub fn critical_state<S: Into<String>>(inner: AppInner, string: S) -> Self {
AppMachine {
inner,
state: CriticalState {
string: string.into(),
},
}
AppMachine::new(inner, CriticalState::new(string))
}
}
@ -24,11 +27,8 @@ impl From<AppMachine<CriticalState>> for App {
}
}
impl<'a> From<&'a mut AppMachine<CriticalState>> for AppPublic<'a> {
fn from(machine: &'a mut AppMachine<CriticalState>) -> 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)
}
}

View File

@ -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<S: Into<String>>(string: S) -> Self {
ErrorState {
string: string.into(),
}
}
}
impl AppMachine<ErrorState> {
pub fn error_state<S: Into<String>>(inner: AppInner, string: S) -> Self {
AppMachine {
inner,
state: ErrorState {
string: string.into(),
},
}
AppMachine::new(inner, ErrorState::new(string))
}
}
@ -24,12 +27,9 @@ impl From<AppMachine<ErrorState>> for App {
}
}
impl<'a> From<&'a mut AppMachine<ErrorState>> for AppPublic<'a> {
fn from(machine: &'a mut AppMachine<ErrorState>) -> 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)
}
}

View File

@ -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<FetchResult>;
impl AppMachine<FetchState> {
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<AppMachine<FetchState>> for App {
}
}
impl<'a> From<&'a mut AppMachine<FetchState>> for AppPublic<'a> {
fn from(machine: &'a mut AppMachine<FetchState>) -> 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(())
}
}

View File

@ -1,16 +1,13 @@
use crate::tui::app::{
machine::{App, AppInner, AppMachine},
AppPublic, AppState, IAppInteractInfo,
AppPublicState, AppState, IAppInteractInfo,
};
pub struct InfoState;
impl AppMachine<InfoState> {
pub fn info_state(inner: AppInner) -> Self {
AppMachine {
inner,
state: InfoState,
}
AppMachine::new(inner, InfoState)
}
}
@ -20,12 +17,9 @@ impl From<AppMachine<InfoState>> for App {
}
}
impl<'a> From<&'a mut AppMachine<InfoState>> for AppPublic<'a> {
fn from(machine: &'a mut AppMachine<InfoState>) -> 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(())
}
}

View File

@ -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<App> for AppMode<App, AppInputMode> {
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();
}
}

View File

@ -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<MatchState> {
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<AppMachine<MatchState>> for App {
@ -77,15 +108,12 @@ impl From<AppMachine<MatchState>> for App {
}
}
impl<'a> From<&'a mut AppMachine<MatchState>> for AppPublic<'a> {
fn from(machine: &'a mut AppMachine<MatchState>) -> 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<MatchState> {
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();
}
}

View File

@ -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<BrowseState>,
AppMachine<InfoState>,
@ -40,6 +43,7 @@ pub type App = AppState<
pub struct AppMachine<STATE> {
inner: AppInner,
state: STATE,
input: Option<Input>,
}
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<MH: IMusicHoard + 'static, MB: IMusicBrainz + Send + 'static>(
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<Input> {
app_field_ref!(self, input)
}
fn input_mut(&mut self) -> &mut Option<Input> {
app_field_mut!(self, input)
}
}
@ -105,6 +130,7 @@ impl IApp for App {
type MatchState = AppMachine<MatchState>;
type ErrorState = AppMachine<ErrorState>;
type CriticalState = AppMachine<CriticalState>;
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<IAppState!(), Self::InputMode> {
self.into()
}
}
impl<T: Into<App>> IAppBase for T {
@ -142,14 +161,14 @@ impl<T: Into<App>> 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<State> AppMachine<State> {
pub fn new(inner: AppInner, state: State) -> Self {
AppMachine {
inner,
state,
input: None,
}
}
}
impl<'a, State> From<&'a mut AppMachine<State>> for AppPublic<'a>
where
&'a mut State: Into<AppPublicState<'a>>,
{
fn from(machine: &'a mut AppMachine<State>) -> 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<BS, IS, RS, SS, FS, MS, ES, CS> AppState<BS, IS, RS, SS, FS, MS, ES, CS> {
pub fn unwrap_browse(self) -> BS {
impl<StateMode, InputMode> AppMode<StateMode, InputMode> {
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());

View File

@ -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<ReloadState> {
pub fn reload_state(inner: AppInner) -> Self {
AppMachine {
inner,
state: ReloadState,
}
AppMachine::new(inner, ReloadState)
}
}
@ -20,12 +17,10 @@ impl From<AppMachine<ReloadState>> for App {
AppState::Reload(machine)
}
}
impl<'a> From<&'a mut AppMachine<ReloadState>> for AppPublic<'a> {
fn from(machine: &'a mut AppMachine<ReloadState>) -> 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(())
}
}

View File

@ -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,31 +31,31 @@ struct SearchStateMemo {
char: bool,
}
impl AppMachine<SearchState> {
pub fn search_state(inner: AppInner, orig: ListSelection) -> Self {
AppMachine {
inner,
state: SearchState {
impl SearchState {
fn new(orig: ListSelection) -> Self {
SearchState {
string: String::new(),
orig,
memo: vec![],
},
}
}
}
impl AppMachine<SearchState> {
pub fn search_state(inner: AppInner, orig: ListSelection) -> Self {
AppMachine::new(inner, SearchState::new(orig))
}
}
impl From<AppMachine<SearchState>> for App {
fn from(machine: AppMachine<SearchState>) -> Self {
AppState::Search(machine)
}
}
impl<'a> From<&'a mut AppMachine<SearchState>> for AppPublic<'a> {
fn from(machine: &'a mut AppMachine<SearchState>) -> 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)
}
}

View File

@ -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<B, I, R, S, F, M, E, C> {
Browse(B),
Info(I),
Reload(R),
Search(S),
Fetch(F),
Match(M),
Error(E),
Critical(C),
}
pub enum AppMode<StateMode, InputMode> {
State(StateMode),
Input(InputMode),
}
macro_rules! IAppState {
() => {
AppState<Self::BrowseState, Self::InfoState, Self::ReloadState, Self::SearchState,
Self::FetchState, Self::MatchState, Self::ErrorState, Self::CriticalState>
};
}
use IAppState;
pub trait IApp {
type BrowseState: IAppBase<APP = Self> + IAppInteractBrowse<APP = Self>;
type InfoState: IAppBase<APP = Self> + IAppInteractInfo<APP = Self>;
@ -39,23 +43,15 @@ pub trait IApp {
type MatchState: IAppBase<APP = Self> + IAppInteractMatch<APP = Self>;
type ErrorState: IAppBase<APP = Self> + IAppInteractError<APP = Self>;
type CriticalState: IAppBase<APP = Self>;
type InputMode: IAppInput<APP = Self>;
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<IAppState!(), Self::InputMode>;
}
pub trait IAppBase {
@ -129,6 +125,28 @@ pub trait IAppInteractMatch {
fn abort(self) -> Self::APP;
}
pub struct InputEvent(crossterm::event::KeyEvent);
impl From<crossterm::event::KeyEvent> for InputEvent {
fn from(value: crossterm::event::KeyEvent) -> Self {
InputEvent(value)
}
}
impl From<InputEvent> 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<InputPublic<'app>>,
}
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<T> {
Match(Match<T>),
CannotHaveMbid,
ManualInputMbid,
}
impl<T> From<Match<T>> for MatchOption<T> {
@ -203,7 +225,7 @@ pub struct MatchStatePublic<'app> {
pub type AppPublicState<'app> =
AppState<(), (), (), &'app str, (), MatchStatePublic<'app>, &'app str, &'app str>;
impl<BS, IS, RS, SS, FS, MS, ES, CS> AppState<BS, IS, RS, SS, FS, MS, ES, CS> {
impl<B, I, R, S, F, M, E, C> AppState<B, I, R, S, F, M, E, C> {
pub fn is_search(&self) -> bool {
matches!(self, AppState::Search(_))
}

View File

@ -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<APP: IApp> {
fn handle_next_event(&self, app: APP) -> Result<APP, EventError>;
@ -30,6 +29,8 @@ trait IEventHandlerPrivate<APP: IApp> {
fn handle_critical_key_event(app: <APP as IApp>::CriticalState, key_event: KeyEvent) -> APP;
fn handle_fetch_result_ready_event(app: APP) -> APP;
fn handle_input_key_event<Input: IAppInput<APP = APP>>(app: Input, key_event: KeyEvent) -> APP;
}
pub struct EventHandler {
@ -62,7 +63,9 @@ impl<APP: IApp> IEventHandlerPrivate<APP> for EventHandler {
};
}
match app.state() {
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)
}
@ -73,25 +76,32 @@ impl<APP: IApp> IEventHandlerPrivate<APP> for EventHandler {
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::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<APP: IApp> IEventHandlerPrivate<APP> for EventHandler {
}
fn handle_fetch_key_event(app: <APP as IApp>::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<APP: IApp> IEventHandlerPrivate<APP> for EventHandler {
}
fn handle_match_key_event(app: <APP as IApp>::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<APP: IApp> IEventHandlerPrivate<APP> for EventHandler {
// No action is allowed.
app.no_op()
}
fn handle_input_key_event<Input: IAppInput<APP = APP>>(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

View File

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

View File

@ -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,6 +95,7 @@ impl FrameArea {
};
FrameArea {
browse: BrowseArea {
artist: ArtistArea { list: artist_list },
album: AlbumArea {
list: album_list,
@ -100,6 +105,7 @@ impl FrameArea {
list: track_list,
info: track_info,
},
},
minibuffer,
}
}

View File

@ -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)]

20
src/tui/ui/input.rs Normal file
View File

@ -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,
))
}
}

View File

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

View File

@ -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<S: AsRef<str>>(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<Match<ArtistMeta>>) -> MatchStateInfo {
let mut list: Vec<MatchOption<ArtistMeta>> = 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<Match<AlbumMeta>>) -> MatchStateInfo {
let mut list: Vec<MatchOption<AlbumMeta>> = 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();
}
}