From 93d23e566622f1f33540452b5e9cb610b5da4cd2 Mon Sep 17 00:00:00 2001 From: Wojciech Kozlowski Date: Sun, 25 Aug 2024 21:59:10 +0200 Subject: [PATCH] Very sketchy first draft --- src/tui/app/machine/info.rs | 50 +++++++++++++++++- src/tui/app/machine/matches.rs | 92 ++++++++++++++++++++++++++++++++++ src/tui/app/machine/mod.rs | 20 +++++++- src/tui/app/mod.rs | 35 +++++++++++-- src/tui/handler.rs | 19 ++++++- src/tui/ui.rs | 70 +++++++++++++++++++++++--- 6 files changed, 269 insertions(+), 17 deletions(-) create mode 100644 src/tui/app/machine/matches.rs diff --git a/src/tui/app/machine/info.rs b/src/tui/app/machine/info.rs index e6e005d..719b4af 100644 --- a/src/tui/app/machine/info.rs +++ b/src/tui/app/machine/info.rs @@ -1,7 +1,13 @@ +use musichoard::{ + collection::{album::Album, artist::Artist, musicbrainz::IMusicBrainzRef, Collection}, + external::musicbrainz::api::{client::MusicBrainzApiClient, MusicBrainzApi}, + interface::musicbrainz::IMusicBrainz, +}; + use crate::tui::{ app::{ machine::{App, AppInner, AppMachine}, - AppPublic, AppState, IAppInteractInfo, + AppPublic, AppState, Category, IAppInteractInfo, }, lib::IMusicHoard, }; @@ -39,6 +45,48 @@ impl IAppInteractInfo for AppMachine { AppMachine::browse(self.inner).into() } + fn fetch_musicbrainz(self) -> Self::APP { + const USER_AGENT: &str = concat!( + "MusicHoard/", + env!("CARGO_PKG_VERSION"), + " ( musichoard@thenineworlds.net )" + ); + + let client = MusicBrainzApiClient::new(USER_AGENT).expect("failed to create API client"); + let mut api = MusicBrainzApi::new(client); + + if self.inner.selection.category() == Category::Artist { + return AppMachine::error(self.inner, "artist fetch not yet supported").into(); + } + + let coll: &Collection = self.inner.music_hoard.get_collection(); + let artist: &Artist = match self.inner.selection.state_artist(coll) { + Some(artist_state) => &coll[artist_state.index], + None => { + return AppMachine::error(self.inner, "cannot fetch: no artist selected").into() + } + }; + + let arid = match artist.musicbrainz { + Some(ref mbid) => mbid.mbid(), + None => { + return AppMachine::error(self.inner, "cannot fetch: missing artist MBID").into() + } + }; + + let album: &Album = match self.inner.selection.state_album(coll) { + Some(album_state) => &artist.albums[album_state.index], + None => { + return AppMachine::error(self.inner, "cannot fetch: no album selected").into() + } + }; + + match api.search_release_group(arid, album) { + Ok(matches) => AppMachine::matches(self.inner, matches).into(), + Err(err) => AppMachine::error(self.inner, err.to_string()).into(), + } + } + fn no_op(self) -> Self::APP { self.into() } diff --git a/src/tui/app/machine/matches.rs b/src/tui/app/machine/matches.rs new file mode 100644 index 0000000..e24abeb --- /dev/null +++ b/src/tui/app/machine/matches.rs @@ -0,0 +1,92 @@ +use std::cmp; + +use musichoard::{collection::album::Album, interface::musicbrainz::Match}; + +use crate::tui::{ + app::{ + machine::{App, AppInner, AppMachine}, + AppPublic, AppPublicMatches, AppState, IAppInteractMatches, WidgetState, + }, + lib::IMusicHoard, +}; + +pub struct AppMatches { + matches: Vec>, + state: WidgetState, +} + +impl AppMachine { + pub fn matches(inner: AppInner, matches: Vec>) -> Self { + let mut state = WidgetState::default(); + if !matches.is_empty() { + state.list.select(Some(0)); + } + AppMachine { + inner, + state: AppMatches { matches, state }, + } + } +} + +impl From> for App { + fn from(machine: AppMachine) -> Self { + AppState::Matches(machine) + } +} + +impl<'a, MH: IMusicHoard> From<&'a mut AppMachine> for AppPublic<'a> { + fn from(machine: &'a mut AppMachine) -> Self { + AppPublic { + inner: (&mut machine.inner).into(), + state: AppState::Matches(AppPublicMatches { + matches: &machine.state.matches, + state: &mut machine.state.state, + }), + } + } +} + +impl IAppInteractMatches for AppMachine { + type APP = App; + + fn prev_match(mut self) -> Self::APP { + if let Some(index) = self.state.state.list.selected() { + let result = index.saturating_sub(1); + self.state.state.list.select(Some(result)); + } + + self.into() + } + + fn next_match(mut self) -> Self::APP { + if let Some(index) = self.state.state.list.selected() { + let result = index.saturating_add(1); + let to = cmp::min(result, self.state.matches.len() - 1); + self.state.state.list.select(Some(to)); + } + + self.into() + } + + fn abort(self) -> Self::APP { + AppMachine::info(self.inner).into() + } + + fn no_op(self) -> Self::APP { + self.into() + } +} + +#[cfg(test)] +mod tests { + use crate::tui::app::machine::tests::{inner, music_hoard}; + + use super::*; + + #[test] + fn no_op() { + let matches = AppMachine::matches(inner(music_hoard(vec![])), vec![]); + let app = matches.no_op(); + app.unwrap_matches(); + } +} diff --git a/src/tui/app/machine/mod.rs b/src/tui/app/machine/mod.rs index e4e9c60..646e9c8 100644 --- a/src/tui/app/machine/mod.rs +++ b/src/tui/app/machine/mod.rs @@ -2,6 +2,7 @@ mod browse; mod critical; mod error; mod info; +mod matches; mod reload; mod search; @@ -14,12 +15,14 @@ use browse::AppBrowse; use critical::AppCritical; use error::AppError; use info::AppInfo; +use matches::AppMatches; use reload::AppReload; use search::AppSearch; pub type App = AppState< AppMachine, AppMachine, + AppMachine, AppMachine, AppMachine, AppMachine, @@ -56,6 +59,7 @@ impl App { match self { AppState::Browse(browse) => &browse.inner, AppState::Info(info) => &info.inner, + AppState::Matches(matches) => &matches.inner, AppState::Reload(reload) => &reload.inner, AppState::Search(search) => &search.inner, AppState::Error(error) => &error.inner, @@ -67,6 +71,7 @@ impl App { match self { AppState::Browse(browse) => &mut browse.inner, AppState::Info(info) => &mut info.inner, + AppState::Matches(matches) => &mut matches.inner, AppState::Reload(reload) => &mut reload.inner, AppState::Search(search) => &mut search.inner, AppState::Error(error) => &mut error.inner, @@ -78,6 +83,7 @@ impl App { impl IAppInteract for App { type BS = AppMachine; type IS = AppMachine; + type MS = AppMachine; type RS = AppMachine; type SS = AppMachine; type ES = AppMachine; @@ -92,7 +98,9 @@ impl IAppInteract for App { self } - fn state(self) -> AppState { + fn state( + self, + ) -> AppState { self } } @@ -102,6 +110,7 @@ impl IAppAccess for App { match self { AppState::Browse(browse) => browse.into(), AppState::Info(info) => info.into(), + AppState::Matches(matches) => matches.into(), AppState::Reload(reload) => reload.into(), AppState::Search(search) => search.into(), AppState::Error(error) => error.into(), @@ -141,7 +150,7 @@ mod tests { use super::*; - impl AppState { + impl AppState { pub fn unwrap_browse(self) -> BS { match self { AppState::Browse(browse) => browse, @@ -156,6 +165,13 @@ mod tests { } } + pub fn unwrap_matches(self) -> MS { + match self { + AppState::Matches(matches) => matches, + _ => panic!(), + } + } + pub fn unwrap_reload(self) -> RS { match self { AppState::Reload(reload) => reload, diff --git a/src/tui/app/mod.rs b/src/tui/app/mod.rs index 8f86587..0d94ca5 100644 --- a/src/tui/app/mod.rs +++ b/src/tui/app/mod.rs @@ -4,11 +4,15 @@ mod selection; pub use machine::App; pub use selection::{Category, Delta, Selection, WidgetState}; -use musichoard::collection::Collection; +use musichoard::{ + collection::{album::Album, Collection}, + interface::musicbrainz::Match, +}; -pub enum AppState { +pub enum AppState { Browse(BS), Info(IS), + Matches(MS), Reload(RS), Search(SS), Error(ES), @@ -18,6 +22,7 @@ pub enum AppState { pub trait IAppInteract { type BS: IAppInteractBrowse; type IS: IAppInteractInfo; + type MS: IAppInteractMatches; type RS: IAppInteractReload; type SS: IAppInteractSearch; type ES: IAppInteractError; @@ -27,7 +32,9 @@ pub trait IAppInteract { fn force_quit(self) -> Self; #[allow(clippy::type_complexity)] - fn state(self) -> AppState; + fn state( + self, + ) -> AppState; } pub trait IAppInteractBrowse { @@ -53,6 +60,18 @@ pub trait IAppInteractInfo { type APP: IAppInteract; fn hide_info_overlay(self) -> Self::APP; + fn fetch_musicbrainz(self) -> Self::APP; + + fn no_op(self) -> Self::APP; +} + +pub trait IAppInteractMatches { + type APP: IAppInteract; + + fn prev_match(self) -> Self::APP; + fn next_match(self) -> Self::APP; + + fn abort(self) -> Self::APP; fn no_op(self) -> Self::APP; } @@ -109,9 +128,15 @@ pub struct AppPublicInner<'app> { pub selection: &'app mut Selection, } -pub type AppPublicState<'app> = AppState<(), (), (), &'app str, &'app str, &'app str>; +pub struct AppPublicMatches<'app> { + pub matches: &'app [Match], + pub state: &'app mut WidgetState, +} -impl AppState { +pub type AppPublicState<'app> = + AppState<(), (), AppPublicMatches<'app>, (), &'app str, &'app str, &'app str>; + +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 038e93e..1c8ade3 100644 --- a/src/tui/handler.rs +++ b/src/tui/handler.rs @@ -6,7 +6,7 @@ use mockall::automock; use crate::tui::{ app::{ AppState, Delta, IAppInteract, IAppInteractBrowse, IAppInteractCritical, IAppInteractError, - IAppInteractInfo, IAppInteractReload, IAppInteractSearch, + IAppInteractInfo, IAppInteractMatches, IAppInteractReload, IAppInteractSearch, }, event::{Event, EventError, EventReceiver}, }; @@ -20,6 +20,7 @@ trait IEventHandlerPrivate { fn handle_key_event(app: APP, key_event: KeyEvent) -> APP; fn handle_browse_key_event(app: ::BS, key_event: KeyEvent) -> APP; fn handle_info_key_event(app: ::IS, key_event: KeyEvent) -> APP; + fn handle_matches_key_event(app: ::MS, key_event: KeyEvent) -> APP; fn handle_reload_key_event(app: ::RS, key_event: KeyEvent) -> APP; fn handle_search_key_event(app: ::SS, key_event: KeyEvent) -> APP; fn handle_error_key_event(app: ::ES, key_event: KeyEvent) -> APP; @@ -63,6 +64,9 @@ impl IEventHandlerPrivate for EventHandler { AppState::Info(info) => { >::handle_info_key_event(info, key_event) } + AppState::Matches(matches) => { + >::handle_matches_key_event(matches, key_event) + } AppState::Reload(reload) => { >::handle_reload_key_event(reload, key_event) } @@ -115,6 +119,19 @@ impl IEventHandlerPrivate for EventHandler { | KeyCode::Char('Q') | KeyCode::Char('m') | KeyCode::Char('M') => app.hide_info_overlay(), + KeyCode::Char('f') | KeyCode::Char('F') => app.fetch_musicbrainz(), + // Othey keys. + _ => app.no_op(), + } + } + + fn handle_matches_key_event(app: ::MS, key_event: KeyEvent) -> APP { + match key_event.code { + // Abort. + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => app.abort(), + // Select. + KeyCode::Up => app.prev_match(), + KeyCode::Down => app.next_match(), // Othey keys. _ => app.no_op(), } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index c066da3..62d50a7 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -1,11 +1,14 @@ use std::collections::HashMap; -use musichoard::collection::{ - album::{Album, AlbumDate, AlbumPrimaryType, AlbumSecondaryType, AlbumSeq, AlbumStatus}, - artist::Artist, - musicbrainz::IMusicBrainzRef, - track::{Track, TrackFormat, TrackQuality}, - Collection, +use musichoard::{ + collection::{ + album::{Album, AlbumDate, AlbumPrimaryType, AlbumSecondaryType, AlbumSeq, AlbumStatus}, + artist::Artist, + musicbrainz::IMusicBrainzRef, + track::{Track, TrackFormat, TrackQuality}, + Collection, + }, + interface::musicbrainz::Match, }; use ratatui::{ layout::{Alignment, Rect}, @@ -505,7 +508,14 @@ impl Minibuffer<'_> { columns, }, AppState::Info(_) => Minibuffer { - paragraphs: vec![Paragraph::new("m: hide info overlay")], + paragraphs: vec![ + Paragraph::new("m: hide info overlay"), + Paragraph::new("f: fetch musicbrainz"), + ], + columns, + }, + AppState::Matches(_) => Minibuffer { + paragraphs: vec![], columns, }, AppState::Reload(_) => Minibuffer { @@ -621,6 +631,18 @@ impl Ui { state.height = area.height.saturating_sub(2) as usize; } + fn render_overlay_list_widget( + title: &str, + list: List, + state: &mut WidgetState, + active: bool, + area: Rect, + frame: &mut Frame, + ) { + frame.render_widget(Clear, area); + Self::render_list_widget(title, list, state, active, area, frame); + } + fn render_info_widget( title: &str, paragraph: Paragraph, @@ -792,6 +814,31 @@ impl Ui { } } + fn render_matches_overlay( + matches: &[Match], + state: &mut WidgetState, + frame: &mut Frame, + ) { + let area = OverlayBuilder::default().build(frame.size()); + + let list = List::new( + matches + .iter() + .map(|m| { + ListItem::new(format!( + "{:010} | {} [{}] ({}%)", + AlbumState::display_album_date(&m.item.date), + &m.item.id.title, + AlbumState::display_type(&m.item.primary_type, &m.item.secondary_types), + m.score, + )) + }) + .collect::>(), + ); + + Self::render_overlay_list_widget("Matches", list, state, true, area, frame) + } + fn render_reload_overlay(frame: &mut Frame) { let area = OverlayBuilder::default() .with_width(OverlaySize::Value(39)) @@ -827,6 +874,9 @@ impl IUi for Ui { Self::render_main_frame(collection, selection, &state, frame); match state { AppState::Info(_) => Self::render_info_overlay(collection, selection, frame), + AppState::Matches(public) => { + Self::render_matches_overlay(public.matches, public.state, frame) + } AppState::Reload(_) => Self::render_reload_overlay(frame), AppState::Error(msg) => Self::render_error_overlay("Error", msg, frame), AppState::Critical(msg) => Self::render_error_overlay("Critical Error", msg, frame), @@ -840,7 +890,7 @@ mod tests { use musichoard::collection::artist::ArtistId; use crate::tui::{ - app::{AppPublic, AppPublicInner, Delta}, + app::{AppPublic, AppPublicInner, AppPublicMatches, Delta}, testmod::COLLECTION, tests::terminal, }; @@ -858,6 +908,10 @@ mod tests { state: match self.state { AppState::Browse(()) => AppState::Browse(()), AppState::Info(()) => AppState::Info(()), + AppState::Matches(ref mut m) => AppState::Matches(AppPublicMatches { + matches: &m.matches, + state: &mut m.state, + }), AppState::Reload(()) => AppState::Reload(()), AppState::Search(s) => AppState::Search(s), AppState::Error(s) => AppState::Error(s),