From c38961c3c1a626f5cefbdd765d283d306e07911e Mon Sep 17 00:00:00 2001 From: Wojciech Kozlowski Date: Thu, 29 Aug 2024 17:21:52 +0200 Subject: [PATCH] Split ui.rs into modules based on UI element (#200) Closes #135 Reviewed-on: https://git.thenineworlds.net/wojtek/musichoard/pulls/200 --- src/tui/ui.rs | 1162 -------------------------------------- src/tui/ui/browse.rs | 234 ++++++++ src/tui/ui/display.rs | 237 ++++++++ src/tui/ui/error.rs | 14 + src/tui/ui/info.rs | 111 ++++ src/tui/ui/matches.rs | 23 + src/tui/ui/minibuffer.rs | 89 +++ src/tui/ui/mod.rs | 320 +++++++++++ src/tui/ui/overlay.rs | 57 ++ src/tui/ui/reload.rs | 13 + src/tui/ui/style.rs | 38 ++ src/tui/ui/widgets.rs | 138 +++++ 12 files changed, 1274 insertions(+), 1162 deletions(-) delete mode 100644 src/tui/ui.rs create mode 100644 src/tui/ui/browse.rs create mode 100644 src/tui/ui/display.rs create mode 100644 src/tui/ui/error.rs create mode 100644 src/tui/ui/info.rs create mode 100644 src/tui/ui/matches.rs create mode 100644 src/tui/ui/minibuffer.rs create mode 100644 src/tui/ui/mod.rs create mode 100644 src/tui/ui/overlay.rs create mode 100644 src/tui/ui/reload.rs create mode 100644 src/tui/ui/style.rs create mode 100644 src/tui/ui/widgets.rs diff --git a/src/tui/ui.rs b/src/tui/ui.rs deleted file mode 100644 index 545a4a1..0000000 --- a/src/tui/ui.rs +++ /dev/null @@ -1,1162 +0,0 @@ -use std::collections::HashMap; - -use musichoard::collection::{ - album::{Album, AlbumDate, AlbumPrimaryType, AlbumSecondaryType, AlbumSeq, AlbumStatus}, - artist::Artist, - musicbrainz::IMusicBrainzRef, - track::{Track, TrackFormat, TrackQuality}, - Collection, -}; -use ratatui::{ - layout::{Alignment, Rect}, - style::{Color, Style}, - text::Line, - widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}, - Frame, -}; - -use crate::tui::{ - app::{AppPublicState, AppState, Category, IAppAccess, Selection, WidgetState}, - lib::interface::musicbrainz::Match, -}; - -const COLOR_BG: Color = Color::Black; -const COLOR_BG_HL: Color = Color::DarkGray; -const COLOR_FG: Color = Color::White; -const COLOR_FG_ERR: Color = Color::Red; -const COLOR_FG_WARN: Color = Color::LightYellow; -const COLOR_FG_GOOD: Color = Color::LightGreen; - -pub trait IUi { - fn render(app: &mut APP, frame: &mut Frame); -} - -struct ArtistArea { - list: Rect, -} - -struct AlbumArea { - list: Rect, - info: Rect, -} - -struct TrackArea { - list: Rect, - info: Rect, -} - -struct FrameArea { - artist: ArtistArea, - album: AlbumArea, - track: TrackArea, - minibuffer: Rect, -} - -impl FrameArea { - fn new(frame: Rect) -> Self { - let minibuffer_height = 3; - let buffer_height = frame.height.saturating_sub(minibuffer_height); - - let width_one_third = frame.width / 3; - let height_one_third = buffer_height / 3; - - let panel_width = width_one_third; - let panel_width_last = frame.width.saturating_sub(2 * panel_width); - let panel_height_top = buffer_height.saturating_sub(height_one_third); - let panel_height_bottom = height_one_third; - - let artist_list = Rect { - x: frame.x, - y: frame.y, - width: panel_width, - height: buffer_height, - }; - - let album_list = Rect { - x: artist_list.x + artist_list.width, - y: frame.y, - width: panel_width, - height: panel_height_top, - }; - - let album_info = Rect { - x: album_list.x, - y: album_list.y + album_list.height, - width: album_list.width, - height: panel_height_bottom, - }; - - let track_list = Rect { - x: album_list.x + album_list.width, - y: frame.y, - width: panel_width_last, - height: panel_height_top, - }; - - let track_info = Rect { - x: track_list.x, - y: track_list.y + track_list.height, - width: track_list.width, - height: panel_height_bottom, - }; - - let minibuffer = Rect { - x: frame.x, - y: frame.y + buffer_height, - width: frame.width, - height: minibuffer_height, - }; - - FrameArea { - artist: ArtistArea { list: artist_list }, - album: AlbumArea { - list: album_list, - info: album_info, - }, - track: TrackArea { - list: track_list, - info: track_info, - }, - minibuffer, - } - } -} - -enum OverlaySize { - MarginFactor(u16), - Value(u16), -} - -impl Default for OverlaySize { - fn default() -> Self { - OverlaySize::MarginFactor(8) - } -} - -impl OverlaySize { - fn get(&self, full: u16) -> (u16, u16) { - match self { - OverlaySize::MarginFactor(margin_factor) => { - let margin = full / margin_factor; - (margin, full.saturating_sub(2 * margin)) - } - OverlaySize::Value(value) => { - let margin = (full.saturating_sub(*value)) / 2; - (margin, *value) - } - } - } -} - -#[derive(Default)] -struct OverlayBuilder { - width: OverlaySize, - height: OverlaySize, -} - -impl OverlayBuilder { - fn with_width(mut self, width: OverlaySize) -> OverlayBuilder { - self.width = width; - self - } - - fn with_height(mut self, height: OverlaySize) -> OverlayBuilder { - self.height = height; - self - } - - fn build(self, frame: Rect) -> Rect { - let (x, width) = self.width.get(frame.width); - let (y, height) = self.height.get(frame.height); - - Rect { - x, - y, - width, - height, - } - } -} - -struct ArtistState<'a, 'b> { - active: bool, - list: List<'a>, - state: &'b mut WidgetState, -} - -impl<'a, 'b> ArtistState<'a, 'b> { - fn new(active: bool, artists: &'a [Artist], state: &'b mut WidgetState) -> ArtistState<'a, 'b> { - let list = List::new( - artists - .iter() - .map(|a| ListItem::new(a.id.name.as_str())) - .collect::>(), - ); - - ArtistState { - active, - list, - state, - } - } -} - -struct InfoOverlay; - -impl InfoOverlay { - const ITEM_INDENT: &'static str = " "; - const LIST_INDENT: &'static str = " - "; -} - -struct ArtistOverlay<'a> { - properties: Paragraph<'a>, -} - -impl<'a> ArtistOverlay<'a> { - fn opt_hashmap_to_string, T: AsRef>( - opt_map: Option<&HashMap>>, - item_indent: &str, - list_indent: &str, - ) -> String { - opt_map - .map(|map| Self::hashmap_to_string(map, item_indent, list_indent)) - .unwrap_or_else(|| String::from("")) - } - - fn hashmap_to_string, T: AsRef>( - map: &HashMap>, - item_indent: &str, - list_indent: &str, - ) -> String { - let mut vec: Vec<(&str, &Vec)> = map.iter().map(|(k, v)| (k.as_ref(), v)).collect(); - vec.sort_by(|x, y| x.0.cmp(y.0)); - - let indent = format!("\n{item_indent}"); - let list = vec - .iter() - .map(|(k, v)| format!("{k}: {}", Self::slice_to_string(v, list_indent))) - .collect::>() - .join(&indent); - format!("{indent}{list}") - } - - fn slice_to_string>(vec: &[S], indent: &str) -> String { - if vec.len() < 2 { - vec.first() - .map(|item| item.as_ref()) - .unwrap_or("") - .to_string() - } else { - let indent = format!("\n{indent}"); - let list = vec - .iter() - .map(|item| item.as_ref()) - .collect::>() - .join(&indent); - format!("{indent}{list}") - } - } - - fn new(artists: &'a [Artist], state: &ListState) -> ArtistOverlay<'a> { - let artist = state.selected().map(|i| &artists[i]); - - let item_indent = InfoOverlay::ITEM_INDENT; - let list_indent = InfoOverlay::LIST_INDENT; - - let double_item_indent = format!("{item_indent}{item_indent}"); - let double_list_indent = format!("{item_indent}{list_indent}"); - - let properties = Paragraph::new(format!( - "Artist: {}\n\n{item_indent}\ - MusicBrainz: {}\n{item_indent}\ - Properties: {}", - artist.map(|a| a.id.name.as_str()).unwrap_or(""), - artist - .and_then(|a| a.musicbrainz.as_ref().map(|mb| mb.url().as_str())) - .unwrap_or(""), - Self::opt_hashmap_to_string( - artist.map(|a| &a.properties), - &double_item_indent, - &double_list_indent - ), - )); - - ArtistOverlay { properties } - } -} - -struct AlbumState<'a, 'b> { - active: bool, - list: List<'a>, - state: &'b mut WidgetState, - info: Paragraph<'a>, -} - -impl<'a, 'b> AlbumState<'a, 'b> { - fn new(active: bool, albums: &'a [Album], state: &'b mut WidgetState) -> AlbumState<'a, 'b> { - let list = List::new( - albums - .iter() - .map(Self::to_list_item) - .collect::>(), - ); - - let album = state.list.selected().map(|i| &albums[i]); - let info = Paragraph::new(format!( - "Title: {}\n\ - Date: {}\n\ - Type: {}\n\ - Status: {}", - album.map(|a| a.id.title.as_str()).unwrap_or(""), - album - .map(|a| Self::display_date(&a.date, &a.seq)) - .unwrap_or_default(), - album - .map(|a| Self::display_type(&a.primary_type, &a.secondary_types)) - .unwrap_or_default(), - album - .map(|a| Self::display_album_status(&a.get_status())) - .unwrap_or("") - )); - - AlbumState { - active, - list, - state, - info, - } - } - - fn to_list_item(album: &Album) -> ListItem { - let line = match album.get_status() { - AlbumStatus::None => Line::raw(album.id.title.as_str()), - AlbumStatus::Owned(format) => match format { - TrackFormat::Mp3 => Line::styled(album.id.title.as_str(), COLOR_FG_WARN), - TrackFormat::Flac => Line::styled(album.id.title.as_str(), COLOR_FG_GOOD), - }, - }; - ListItem::new(line) - } - - fn display_date(date: &AlbumDate, seq: &AlbumSeq) -> String { - if seq.0 > 0 { - format!("{} ({})", Self::display_album_date(date), seq.0) - } else { - Self::display_album_date(date) - } - } - - fn display_album_date(date: &AlbumDate) -> String { - match date.year { - Some(year) => match date.month { - Some(month) => match date.day { - Some(day) => format!("{year}‐{month:02}‐{day:02}"), - None => format!("{year}‐{month:02}"), - }, - None => format!("{year}"), - }, - None => String::from(""), - } - } - - fn display_type( - primary: &Option, - secondary: &Vec, - ) -> String { - match primary { - Some(ref primary) => { - if secondary.is_empty() { - Self::display_primary_type(primary).to_string() - } else { - format!( - "{} ({})", - Self::display_primary_type(primary), - Self::display_secondary_types(secondary) - ) - } - } - None => String::default(), - } - } - - fn display_primary_type(value: &AlbumPrimaryType) -> &'static str { - match value { - AlbumPrimaryType::Album => "Album", - AlbumPrimaryType::Single => "Single", - AlbumPrimaryType::Ep => "EP", - AlbumPrimaryType::Broadcast => "Broadcast", - AlbumPrimaryType::Other => "Other", - } - } - - fn display_secondary_types(values: &Vec) -> String { - let mut types: Vec<&'static str> = vec![]; - for value in values { - match value { - AlbumSecondaryType::Compilation => types.push("Compilation"), - AlbumSecondaryType::Soundtrack => types.push("Soundtrack"), - AlbumSecondaryType::Spokenword => types.push("Spokenword"), - AlbumSecondaryType::Interview => types.push("Interview"), - AlbumSecondaryType::Audiobook => types.push("Audiobook"), - AlbumSecondaryType::AudioDrama => types.push("Audio drama"), - AlbumSecondaryType::Live => types.push("Live"), - AlbumSecondaryType::Remix => types.push("Remix"), - AlbumSecondaryType::DjMix => types.push("DJ-mix"), - AlbumSecondaryType::MixtapeStreet => types.push("Mixtape/Street"), - AlbumSecondaryType::Demo => types.push("Demo"), - AlbumSecondaryType::FieldRecording => types.push("Field recording"), - } - } - types.join(", ") - } - - fn display_album_status(status: &AlbumStatus) -> &'static str { - match status { - AlbumStatus::None => "None", - AlbumStatus::Owned(format) => match format { - TrackFormat::Mp3 => "MP3", - TrackFormat::Flac => "FLAC", - }, - } - } -} - -struct AlbumOverlay<'a> { - properties: Paragraph<'a>, -} - -impl<'a> AlbumOverlay<'a> { - fn new(albums: &'a [Album], state: &ListState) -> AlbumOverlay<'a> { - let album = state.selected().map(|i| &albums[i]); - - let item_indent = InfoOverlay::ITEM_INDENT; - - let properties = Paragraph::new(format!( - "Album: {}\n\n{item_indent}\ - MusicBrainz: {}", - album.map(|a| a.id.title.as_str()).unwrap_or(""), - album - .and_then(|a| a.musicbrainz.as_ref().map(|mb| mb.url().as_str())) - .unwrap_or(""), - )); - - AlbumOverlay { properties } - } -} - -struct TrackState<'a, 'b> { - active: bool, - list: List<'a>, - state: &'b mut WidgetState, - info: Paragraph<'a>, -} - -impl<'a, 'b> TrackState<'a, 'b> { - fn new(active: bool, tracks: &'a [Track], state: &'b mut WidgetState) -> TrackState<'a, 'b> { - let list = List::new( - tracks - .iter() - .map(|tr| ListItem::new(tr.id.title.as_str())) - .collect::>(), - ); - - let track = state.list.selected().map(|i| &tracks[i]); - let info = Paragraph::new(format!( - "Track: {}\n\ - Title: {}\n\ - Artist: {}\n\ - Quality: {}", - track.map(|t| t.number.0.to_string()).unwrap_or_default(), - track.map(|t| t.id.title.as_str()).unwrap_or(""), - track.map(|t| t.artist.join("; ")).unwrap_or_default(), - track - .map(|t| Self::display_track_quality(&t.quality)) - .unwrap_or_default(), - )); - - TrackState { - active, - list, - state, - info, - } - } - - fn display_track_quality(quality: &TrackQuality) -> String { - match quality.format { - TrackFormat::Flac => "FLAC".to_string(), - TrackFormat::Mp3 => format!("MP3 {}kbps", quality.bitrate), - } - } -} - -struct Minibuffer<'a> { - paragraphs: Vec>, - columns: u16, -} - -impl Minibuffer<'_> { - fn paragraphs(state: &AppPublicState) -> Self { - let columns = 3; - let mut mb = match state { - AppState::Browse(_) => Minibuffer { - paragraphs: vec![ - Paragraph::new("m: show info overlay"), - Paragraph::new("g: show reload menu"), - Paragraph::new("ctrl+s: search artist"), - Paragraph::new("f: fetch musicbrainz"), - ], - columns, - }, - AppState::Info(_) => Minibuffer { - paragraphs: vec![Paragraph::new("m: hide info overlay")], - columns, - }, - AppState::Reload(_) => Minibuffer { - paragraphs: vec![ - Paragraph::new("g: hide reload menu"), - Paragraph::new("d: reload database"), - Paragraph::new("l: reload library"), - ], - columns, - }, - AppState::Search(ref s) => Minibuffer { - paragraphs: vec![ - Paragraph::new(format!("I-search: {s}")), - Paragraph::new("ctrl+s: search next".to_string()).alignment(Alignment::Center), - Paragraph::new("ctrl+g: cancel search".to_string()) - .alignment(Alignment::Center), - ], - columns, - }, - AppState::Matches(public) => Minibuffer { - paragraphs: vec![ - Paragraph::new(Minibuffer::display_matching_info(public.matching)), - Paragraph::new("q: abort"), - ], - columns: 2, - }, - AppState::Error(_) => Minibuffer { - paragraphs: vec![Paragraph::new( - "Press any key to dismiss the error message...", - )], - columns: 0, - }, - AppState::Critical(_) => Minibuffer { - paragraphs: vec![Paragraph::new("Press ctrl+c to terminate the program...")], - columns: 0, - }, - }; - - if !state.is_search() { - mb.paragraphs = mb - .paragraphs - .into_iter() - .map(|p| p.alignment(Alignment::Center)) - .collect(); - } - - mb - } - - fn display_matching_info(matching: Option<&Album>) -> String { - match matching { - Some(matching) => format!( - "Matching: {} | {}", - AlbumState::display_album_date(&matching.date), - &matching.id.title - ), - None => String::from("Matching: nothing"), - } - } -} - -struct ReloadMenu; - -impl ReloadMenu { - fn paragraph<'a>() -> Paragraph<'a> { - Paragraph::new( - "d: database\n\ - l: library", - ) - } -} - -struct Column<'a> { - paragraph: Paragraph<'a>, - area: Rect, -} - -pub struct Ui; - -impl Ui { - fn style(_active: bool, error: bool) -> Style { - let style = Style::default().bg(COLOR_BG); - if error { - style.fg(COLOR_FG_ERR) - } else { - style.fg(COLOR_FG) - } - } - - fn block_style(active: bool, error: bool) -> Style { - Self::style(active, error) - } - - fn highlight_style(active: bool) -> Style { - // Do not set the fg color here as it will overwrite any list-specific customisation. - if active { - Style::default().bg(COLOR_BG_HL) - } else { - Style::default().bg(COLOR_BG) - } - } - - fn block<'a>(active: bool, error: bool) -> Block<'a> { - Block::default().style(Self::block_style(active, error)) - } - - fn block_with_borders<'a>(title: &str, active: bool, error: bool) -> Block<'a> { - Self::block(active, error) - .title_alignment(Alignment::Center) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .title(format!(" {title} ")) - } - - fn render_list_widget( - title: &str, - list: List, - state: &mut WidgetState, - active: bool, - area: Rect, - frame: &mut Frame, - ) { - frame.render_stateful_widget( - list.highlight_style(Self::highlight_style(active)) - .highlight_symbol(">> ") - .style(Self::style(active, false)) - .block(Self::block_with_borders(title, active, false)), - area, - &mut state.list, - ); - 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, - active: bool, - area: Rect, - frame: &mut Frame, - ) { - frame.render_widget( - paragraph - .style(Self::style(active, false)) - .block(Self::block_with_borders(title, active, false)), - area, - ); - } - - fn render_overlay_widget( - title: &str, - paragraph: Paragraph, - area: Rect, - error: bool, - frame: &mut Frame, - ) { - frame.render_widget(Clear, area); - frame.render_widget( - paragraph - .style(Self::style(true, error)) - .block(Self::block_with_borders(title, true, error)), - area, - ); - } - - fn columns(paragraphs: Vec, min: u16, area: Rect) -> Vec { - let mut x = area.x; - let mut width = area.width; - let mut remaining = paragraphs.len() as u16; - if remaining < min { - remaining = min; - } - - let mut blocks = vec![]; - for paragraph in paragraphs.into_iter() { - let block_width = width / remaining; - - blocks.push(Column { - paragraph, - area: Rect { - x, - y: area.y, - width: block_width, - height: area.height, - }, - }); - - x = x.saturating_add(block_width); - width = width.saturating_sub(block_width); - remaining -= 1; - } - - blocks - } - - fn render_columns( - paragraphs: Vec, - min: u16, - active: bool, - area: Rect, - frame: &mut Frame, - ) { - for column in Self::columns(paragraphs, min, area).into_iter() { - frame.render_widget( - column - .paragraph - .style(Self::style(active, false)) - .block(Self::block(active, false)), - column.area, - ); - } - } - - fn render_artist_column(st: ArtistState, ar: ArtistArea, fr: &mut Frame) { - Self::render_list_widget("Artists", st.list, st.state, st.active, ar.list, fr); - } - - fn render_album_column(st: AlbumState, ar: AlbumArea, fr: &mut Frame) { - Self::render_list_widget("Albums", st.list, st.state, st.active, ar.list, fr); - Self::render_info_widget("Album info", st.info, st.active, ar.info, fr); - } - - fn render_track_column(st: TrackState, ar: TrackArea, fr: &mut Frame) { - Self::render_list_widget("Tracks", st.list, st.state, st.active, ar.list, fr); - Self::render_info_widget("Track info", st.info, st.active, ar.info, fr); - } - - fn render_minibuffer(state: &AppPublicState, ar: Rect, fr: &mut Frame) { - let mb = Minibuffer::paragraphs(state); - - let space = 3; - let area = Rect { - x: ar.x + 1 + space, - y: ar.y + 1, - width: ar.width.saturating_sub(2 + 2 * space), - height: 1, - }; - - Self::render_info_widget("Minibuffer", Paragraph::new(""), false, ar, fr); - Self::render_columns(mb.paragraphs, mb.columns, false, area, fr); - } - - fn render_main_frame( - artists: &Collection, - selection: &mut Selection, - state: &AppPublicState, - frame: &mut Frame, - ) { - let active = selection.category(); - let areas = FrameArea::new(frame.size()); - - let artist_state = ArtistState::new( - active == Category::Artist, - artists, - selection.widget_state_artist(), - ); - - Self::render_artist_column(artist_state, areas.artist, frame); - - let no_albums: Vec = vec![]; - let albums = selection - .state_album(artists) - .map(|st| st.list) - .unwrap_or_else(|| &no_albums); - let album_state = AlbumState::new( - active == Category::Album, - albums, - selection.widget_state_album(), - ); - - Self::render_album_column(album_state, areas.album, frame); - - let no_tracks: Vec = vec![]; - let tracks = selection - .state_track(artists) - .map(|st| st.list) - .unwrap_or_else(|| &no_tracks); - let track_state = TrackState::new( - active == Category::Track, - tracks, - selection.widget_state_track(), - ); - - 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()); - - if selection.category() == Category::Artist { - let artist_overlay = ArtistOverlay::new(artists, &selection.widget_state_artist().list); - Self::render_overlay_widget("Artist", artist_overlay.properties, area, false, frame); - } else { - let no_albums: Vec = vec![]; - let albums = selection - .state_album(artists) - .map(|st| st.list) - .unwrap_or_else(|| &no_albums); - let album_overlay = AlbumOverlay::new(albums, &selection.widget_state_album().list); - Self::render_overlay_widget("Album", album_overlay.properties, area, false, frame); - } - } - - fn display_match_string(match_album: &Match) -> String { - format!( - "{:010} | {} [{}] ({}%)", - AlbumState::display_album_date(&match_album.item.date), - &match_album.item.id.title, - AlbumState::display_type( - &match_album.item.primary_type, - &match_album.item.secondary_types - ), - match_album.score, - ) - } - - fn build_match_list(matches: &[Match]) -> List { - List::new( - matches - .iter() - .map(Ui::display_match_string) - .map(ListItem::new) - .collect::>(), - ) - } - - fn render_matches_overlay( - matching: Option<&Album>, - matches: Option<&[Match]>, - state: &mut WidgetState, - frame: &mut Frame, - ) { - let area = OverlayBuilder::default().build(frame.size()); - let matching_string = Minibuffer::display_matching_info(matching); - let list = matches.map(|m| Ui::build_match_list(m)).unwrap_or_default(); - Self::render_overlay_list_widget(&matching_string, list, state, true, area, frame) - } - - fn render_reload_overlay(frame: &mut Frame) { - let area = OverlayBuilder::default() - .with_width(OverlaySize::Value(39)) - .with_height(OverlaySize::Value(4)) - .build(frame.size()); - - let reload_text = ReloadMenu::paragraph().alignment(Alignment::Center); - - Self::render_overlay_widget("Reload", reload_text, area, false, frame); - } - - fn render_error_overlay>(title: S, msg: S, frame: &mut Frame) { - let area = OverlayBuilder::default() - .with_height(OverlaySize::Value(4)) - .build(frame.size()); - - let error_text = Paragraph::new(msg.as_ref()) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }); - - Self::render_overlay_widget(title.as_ref(), error_text, area, true, frame); - } -} - -impl IUi for Ui { - fn render(app: &mut APP, frame: &mut Frame) { - let app = app.get(); - - let collection = app.inner.collection; - let selection = app.inner.selection; - let state = app.state; - - 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.matching, 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), - _ => {} - } - } -} - -#[cfg(test)] -mod tests { - use musichoard::collection::{album::AlbumId, artist::ArtistId}; - - use crate::tui::{ - app::{AppPublic, AppPublicInner, AppPublicMatches, Delta}, - testmod::COLLECTION, - tests::terminal, - }; - - use super::*; - - // Automock does not support returning types with generic lifetimes. - impl IAppAccess for AppPublic<'_> { - fn get(&mut self) -> AppPublic { - AppPublic { - inner: AppPublicInner { - collection: self.inner.collection, - selection: self.inner.selection, - }, - state: match self.state { - AppState::Browse(()) => AppState::Browse(()), - AppState::Info(()) => AppState::Info(()), - AppState::Reload(()) => AppState::Reload(()), - AppState::Search(s) => AppState::Search(s), - AppState::Matches(ref mut m) => AppState::Matches(AppPublicMatches { - matching: m.matching, - matches: m.matches, - state: m.state, - }), - AppState::Error(s) => AppState::Error(s), - AppState::Critical(s) => AppState::Critical(s), - }, - } - } - } - - fn draw_test_suite(collection: &Collection, selection: &mut Selection) { - let mut terminal = terminal(); - - let album = Album::new( - AlbumId::new("An Album"), - AlbumDate::new(Some(1990), Some(5), None), - Some(AlbumPrimaryType::Album), - vec![AlbumSecondaryType::Live, AlbumSecondaryType::Compilation], - ); - - let album_match = Match { - score: 80, - item: album.clone(), - }; - - let mut app = AppPublic { - inner: AppPublicInner { - collection, - selection, - }, - state: AppState::Browse(()), - }; - terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); - - app.state = AppState::Info(()); - terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); - - app.state = AppState::Reload(()); - terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); - - app.state = AppState::Search(""); - terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); - - let album_matches = [album_match.clone(), album_match.clone()]; - let mut widget_state = WidgetState::default(); - widget_state.list.select(Some(0)); - - app.state = AppState::Matches(AppPublicMatches { - matching: Some(&album), - matches: Some(&album_matches), - state: &mut widget_state, - }); - terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); - - let mut widget_state = WidgetState::default(); - - app.state = AppState::Matches(AppPublicMatches { - matching: None, - matches: None, - state: &mut widget_state, - }); - terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); - - app.state = AppState::Error("get rekt scrub"); - terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); - - app.state = AppState::Critical("get critically rekt scrub"); - terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); - } - - #[test] - fn display_album_date() { - assert_eq!(AlbumState::display_album_date(&AlbumDate::default()), ""); - assert_eq!(AlbumState::display_album_date(&1990.into()), "1990"); - assert_eq!(AlbumState::display_album_date(&(1990, 5).into()), "1990‐05"); - assert_eq!( - AlbumState::display_album_date(&(1990, 5, 6).into()), - "1990‐05‐06" - ); - } - - #[test] - fn display_date() { - let date: AlbumDate = 1990.into(); - assert_eq!( - AlbumState::display_date(&date, &AlbumSeq::default()), - "1990" - ); - assert_eq!(AlbumState::display_date(&date, &AlbumSeq(0)), "1990"); - assert_eq!(AlbumState::display_date(&date, &AlbumSeq(5)), "1990 (5)"); - } - - #[test] - fn display_primary_type() { - assert_eq!( - AlbumState::display_primary_type(&AlbumPrimaryType::Album), - "Album" - ); - assert_eq!( - AlbumState::display_primary_type(&AlbumPrimaryType::Single), - "Single" - ); - assert_eq!( - AlbumState::display_primary_type(&AlbumPrimaryType::Ep), - "EP" - ); - assert_eq!( - AlbumState::display_primary_type(&AlbumPrimaryType::Broadcast), - "Broadcast" - ); - assert_eq!( - AlbumState::display_primary_type(&AlbumPrimaryType::Other), - "Other" - ); - } - - #[test] - fn display_secondary_types() { - assert_eq!( - AlbumState::display_secondary_types(&vec![ - AlbumSecondaryType::Compilation, - AlbumSecondaryType::Soundtrack, - AlbumSecondaryType::Spokenword, - AlbumSecondaryType::Interview, - AlbumSecondaryType::Audiobook, - AlbumSecondaryType::AudioDrama, - AlbumSecondaryType::Live, - AlbumSecondaryType::Remix, - AlbumSecondaryType::DjMix, - AlbumSecondaryType::MixtapeStreet, - AlbumSecondaryType::Demo, - AlbumSecondaryType::FieldRecording, - ]), - "Compilation, Soundtrack, Spokenword, Interview, Audiobook, Audio drama, Live, Remix, \ - DJ-mix, Mixtape/Street, Demo, Field recording" - ); - } - - #[test] - fn display_type() { - assert_eq!(AlbumState::display_type(&None, &vec![]), ""); - assert_eq!( - AlbumState::display_type(&Some(AlbumPrimaryType::Album), &vec![]), - "Album" - ); - assert_eq!( - AlbumState::display_type( - &Some(AlbumPrimaryType::Album), - &vec![AlbumSecondaryType::Live, AlbumSecondaryType::Compilation] - ), - "Album (Live, Compilation)" - ); - } - - #[test] - fn display_album_status() { - assert_eq!(AlbumState::display_album_status(&AlbumStatus::None), "None"); - assert_eq!( - AlbumState::display_album_status(&AlbumStatus::Owned(TrackFormat::Mp3)), - "MP3" - ); - assert_eq!( - AlbumState::display_album_status(&AlbumStatus::Owned(TrackFormat::Flac)), - "FLAC" - ); - } - - #[test] - fn display_track_quality() { - assert_eq!( - TrackState::display_track_quality(&TrackQuality { - format: TrackFormat::Flac, - bitrate: 1411 - }), - "FLAC" - ); - assert_eq!( - TrackState::display_track_quality(&TrackQuality { - format: TrackFormat::Mp3, - bitrate: 218 - }), - "MP3 218kbps" - ); - } - - #[test] - fn empty() { - let artists: Vec = vec![]; - let mut selection = Selection::new(&artists); - - draw_test_suite(&artists, &mut selection); - } - - #[test] - fn empty_album() { - let mut artists: Vec = vec![Artist::new(ArtistId::new("An artist"))]; - artists[0] - .albums - .push(Album::new("An album", AlbumDate::default(), None, vec![])); - let mut selection = Selection::new(&artists); - - draw_test_suite(&artists, &mut selection); - } - - #[test] - fn collection() { - let artists = &COLLECTION; - let mut selection = Selection::new(artists); - - draw_test_suite(artists, &mut selection); - - // Change the track (which has a different track format). - selection.increment_category(); - selection.increment_category(); - selection.increment_selection(artists, Delta::Line); - - draw_test_suite(artists, &mut selection); - - // Change the artist (which has a multi-link entry). - selection.decrement_category(); - selection.decrement_category(); - selection.increment_selection(artists, Delta::Line); - - draw_test_suite(artists, &mut selection); - } -} diff --git a/src/tui/ui/browse.rs b/src/tui/ui/browse.rs new file mode 100644 index 0000000..616a5fe --- /dev/null +++ b/src/tui/ui/browse.rs @@ -0,0 +1,234 @@ +use musichoard::collection::{ + album::{Album, AlbumStatus}, + artist::Artist, + track::{Track, TrackFormat}, +}; +use ratatui::{ + layout::Rect, + text::Line, + widgets::{List, ListItem, Paragraph}, +}; + +use crate::tui::{ + app::WidgetState, + ui::{display::UiDisplay, style::UiColor}, +}; + +pub struct ArtistArea { + pub list: Rect, +} + +pub struct AlbumArea { + pub list: Rect, + pub info: Rect, +} + +pub struct TrackArea { + pub list: Rect, + pub info: Rect, +} + +pub struct FrameArea { + pub artist: ArtistArea, + pub album: AlbumArea, + pub track: TrackArea, + pub minibuffer: Rect, +} + +impl FrameArea { + pub fn new(frame: Rect) -> Self { + let minibuffer_height = 3; + let buffer_height = frame.height.saturating_sub(minibuffer_height); + + let width_one_third = frame.width / 3; + let height_one_third = buffer_height / 3; + + let panel_width = width_one_third; + let panel_width_last = frame.width.saturating_sub(2 * panel_width); + let panel_height_top = buffer_height.saturating_sub(height_one_third); + let panel_height_bottom = height_one_third; + + let artist_list = Rect { + x: frame.x, + y: frame.y, + width: panel_width, + height: buffer_height, + }; + + let album_list = Rect { + x: artist_list.x + artist_list.width, + y: frame.y, + width: panel_width, + height: panel_height_top, + }; + + let album_info = Rect { + x: album_list.x, + y: album_list.y + album_list.height, + width: album_list.width, + height: panel_height_bottom, + }; + + let track_list = Rect { + x: album_list.x + album_list.width, + y: frame.y, + width: panel_width_last, + height: panel_height_top, + }; + + let track_info = Rect { + x: track_list.x, + y: track_list.y + track_list.height, + width: track_list.width, + height: panel_height_bottom, + }; + + let minibuffer = Rect { + x: frame.x, + y: frame.y + buffer_height, + width: frame.width, + height: minibuffer_height, + }; + + FrameArea { + artist: ArtistArea { list: artist_list }, + album: AlbumArea { + list: album_list, + info: album_info, + }, + track: TrackArea { + list: track_list, + info: track_info, + }, + minibuffer, + } + } +} + +pub struct ArtistState<'a, 'b> { + pub active: bool, + pub list: List<'a>, + pub state: &'b mut WidgetState, +} + +impl<'a, 'b> ArtistState<'a, 'b> { + pub fn new( + active: bool, + artists: &'a [Artist], + state: &'b mut WidgetState, + ) -> ArtistState<'a, 'b> { + let list = List::new( + artists + .iter() + .map(|a| ListItem::new(a.id.name.as_str())) + .collect::>(), + ); + + ArtistState { + active, + list, + state, + } + } +} + +pub struct AlbumState<'a, 'b> { + pub active: bool, + pub list: List<'a>, + pub state: &'b mut WidgetState, + pub info: Paragraph<'a>, +} + +impl<'a, 'b> AlbumState<'a, 'b> { + pub fn new( + active: bool, + albums: &'a [Album], + state: &'b mut WidgetState, + ) -> AlbumState<'a, 'b> { + let list = List::new( + albums + .iter() + .map(Self::to_list_item) + .collect::>(), + ); + + let album = state.list.selected().map(|i| &albums[i]); + let info = Paragraph::new(format!( + "Title: {}\n\ + Date: {}\n\ + Type: {}\n\ + Status: {}", + album.map(|a| a.id.title.as_str()).unwrap_or(""), + album + .map(|a| UiDisplay::display_date(&a.date, &a.seq)) + .unwrap_or_default(), + album + .map(|a| UiDisplay::display_type(&a.primary_type, &a.secondary_types)) + .unwrap_or_default(), + album + .map(|a| UiDisplay::display_album_status(&a.get_status())) + .unwrap_or("") + )); + + AlbumState { + active, + list, + state, + info, + } + } + + fn to_list_item(album: &Album) -> ListItem { + let line = match album.get_status() { + AlbumStatus::None => Line::raw(album.id.title.as_str()), + AlbumStatus::Owned(format) => match format { + TrackFormat::Mp3 => Line::styled(album.id.title.as_str(), UiColor::FG_WARN), + TrackFormat::Flac => Line::styled(album.id.title.as_str(), UiColor::FG_GOOD), + }, + }; + ListItem::new(line) + } +} + +pub struct TrackState<'a, 'b> { + pub active: bool, + pub list: List<'a>, + pub state: &'b mut WidgetState, + pub info: Paragraph<'a>, +} + +impl<'a, 'b> TrackState<'a, 'b> { + pub fn new( + active: bool, + tracks: &'a [Track], + state: &'b mut WidgetState, + ) -> TrackState<'a, 'b> { + let list = List::new( + tracks + .iter() + .map(|tr| ListItem::new(tr.id.title.as_str())) + .collect::>(), + ); + + let track = state.list.selected().map(|i| &tracks[i]); + let info = Paragraph::new(format!( + "Track: {}\n\ + Title: {}\n\ + Artist: {}\n\ + Quality: {}", + track.map(|t| t.number.0.to_string()).unwrap_or_default(), + track.map(|t| t.id.title.as_str()).unwrap_or(""), + track.map(|t| t.artist.join("; ")).unwrap_or_default(), + track + .map(|t| UiDisplay::display_track_quality(&t.quality)) + .unwrap_or_default(), + )); + + TrackState { + active, + list, + state, + info, + } + } +} diff --git a/src/tui/ui/display.rs b/src/tui/ui/display.rs new file mode 100644 index 0000000..893a515 --- /dev/null +++ b/src/tui/ui/display.rs @@ -0,0 +1,237 @@ +use musichoard::collection::{ + album::{Album, AlbumDate, AlbumPrimaryType, AlbumSecondaryType, AlbumSeq, AlbumStatus}, + track::{TrackFormat, TrackQuality}, +}; + +use crate::tui::lib::interface::musicbrainz::Match; + +pub struct UiDisplay; + +impl UiDisplay { + pub fn display_date(date: &AlbumDate, seq: &AlbumSeq) -> String { + if seq.0 > 0 { + format!("{} ({})", Self::display_album_date(date), seq.0) + } else { + Self::display_album_date(date) + } + } + + pub fn display_album_date(date: &AlbumDate) -> String { + match date.year { + Some(year) => match date.month { + Some(month) => match date.day { + Some(day) => format!("{year}‐{month:02}‐{day:02}"), + None => format!("{year}‐{month:02}"), + }, + None => format!("{year}"), + }, + None => String::from(""), + } + } + + pub fn display_type( + primary: &Option, + secondary: &Vec, + ) -> String { + match primary { + Some(ref primary) => { + if secondary.is_empty() { + Self::display_primary_type(primary).to_string() + } else { + format!( + "{} ({})", + Self::display_primary_type(primary), + Self::display_secondary_types(secondary) + ) + } + } + None => String::default(), + } + } + + pub fn display_primary_type(value: &AlbumPrimaryType) -> &'static str { + match value { + AlbumPrimaryType::Album => "Album", + AlbumPrimaryType::Single => "Single", + AlbumPrimaryType::Ep => "EP", + AlbumPrimaryType::Broadcast => "Broadcast", + AlbumPrimaryType::Other => "Other", + } + } + + pub fn display_secondary_types(values: &Vec) -> String { + let mut types: Vec<&'static str> = vec![]; + for value in values { + match value { + AlbumSecondaryType::Compilation => types.push("Compilation"), + AlbumSecondaryType::Soundtrack => types.push("Soundtrack"), + AlbumSecondaryType::Spokenword => types.push("Spokenword"), + AlbumSecondaryType::Interview => types.push("Interview"), + AlbumSecondaryType::Audiobook => types.push("Audiobook"), + AlbumSecondaryType::AudioDrama => types.push("Audio drama"), + AlbumSecondaryType::Live => types.push("Live"), + AlbumSecondaryType::Remix => types.push("Remix"), + AlbumSecondaryType::DjMix => types.push("DJ-mix"), + AlbumSecondaryType::MixtapeStreet => types.push("Mixtape/Street"), + AlbumSecondaryType::Demo => types.push("Demo"), + AlbumSecondaryType::FieldRecording => types.push("Field recording"), + } + } + types.join(", ") + } + + pub fn display_album_status(status: &AlbumStatus) -> &'static str { + match status { + AlbumStatus::None => "None", + AlbumStatus::Owned(format) => match format { + TrackFormat::Mp3 => "MP3", + TrackFormat::Flac => "FLAC", + }, + } + } + + pub fn display_track_quality(quality: &TrackQuality) -> String { + match quality.format { + TrackFormat::Flac => "FLAC".to_string(), + TrackFormat::Mp3 => format!("MP3 {}kbps", quality.bitrate), + } + } + + pub fn display_matching_info(matching: Option<&Album>) -> String { + match matching { + Some(matching) => format!( + "Matching: {} | {}", + UiDisplay::display_album_date(&matching.date), + &matching.id.title + ), + None => String::from("Matching: nothing"), + } + } + + pub fn display_match_string(match_album: &Match) -> String { + format!( + "{:010} | {} [{}] ({}%)", + UiDisplay::display_album_date(&match_album.item.date), + &match_album.item.id.title, + UiDisplay::display_type( + &match_album.item.primary_type, + &match_album.item.secondary_types + ), + match_album.score, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_album_date() { + assert_eq!(UiDisplay::display_album_date(&AlbumDate::default()), ""); + assert_eq!(UiDisplay::display_album_date(&1990.into()), "1990"); + assert_eq!(UiDisplay::display_album_date(&(1990, 5).into()), "1990‐05"); + assert_eq!( + UiDisplay::display_album_date(&(1990, 5, 6).into()), + "1990‐05‐06" + ); + } + + #[test] + fn display_date() { + let date: AlbumDate = 1990.into(); + assert_eq!(UiDisplay::display_date(&date, &AlbumSeq::default()), "1990"); + assert_eq!(UiDisplay::display_date(&date, &AlbumSeq(0)), "1990"); + assert_eq!(UiDisplay::display_date(&date, &AlbumSeq(5)), "1990 (5)"); + } + + #[test] + fn display_primary_type() { + assert_eq!( + UiDisplay::display_primary_type(&AlbumPrimaryType::Album), + "Album" + ); + assert_eq!( + UiDisplay::display_primary_type(&AlbumPrimaryType::Single), + "Single" + ); + assert_eq!(UiDisplay::display_primary_type(&AlbumPrimaryType::Ep), "EP"); + assert_eq!( + UiDisplay::display_primary_type(&AlbumPrimaryType::Broadcast), + "Broadcast" + ); + assert_eq!( + UiDisplay::display_primary_type(&AlbumPrimaryType::Other), + "Other" + ); + } + + #[test] + fn display_secondary_types() { + assert_eq!( + UiDisplay::display_secondary_types(&vec![ + AlbumSecondaryType::Compilation, + AlbumSecondaryType::Soundtrack, + AlbumSecondaryType::Spokenword, + AlbumSecondaryType::Interview, + AlbumSecondaryType::Audiobook, + AlbumSecondaryType::AudioDrama, + AlbumSecondaryType::Live, + AlbumSecondaryType::Remix, + AlbumSecondaryType::DjMix, + AlbumSecondaryType::MixtapeStreet, + AlbumSecondaryType::Demo, + AlbumSecondaryType::FieldRecording, + ]), + "Compilation, Soundtrack, Spokenword, Interview, Audiobook, Audio drama, Live, Remix, \ + DJ-mix, Mixtape/Street, Demo, Field recording" + ); + } + + #[test] + fn display_type() { + assert_eq!(UiDisplay::display_type(&None, &vec![]), ""); + assert_eq!( + UiDisplay::display_type(&Some(AlbumPrimaryType::Album), &vec![]), + "Album" + ); + assert_eq!( + UiDisplay::display_type( + &Some(AlbumPrimaryType::Album), + &vec![AlbumSecondaryType::Live, AlbumSecondaryType::Compilation] + ), + "Album (Live, Compilation)" + ); + } + + #[test] + fn display_album_status() { + assert_eq!(UiDisplay::display_album_status(&AlbumStatus::None), "None"); + assert_eq!( + UiDisplay::display_album_status(&AlbumStatus::Owned(TrackFormat::Mp3)), + "MP3" + ); + assert_eq!( + UiDisplay::display_album_status(&AlbumStatus::Owned(TrackFormat::Flac)), + "FLAC" + ); + } + + #[test] + fn display_track_quality() { + assert_eq!( + UiDisplay::display_track_quality(&TrackQuality { + format: TrackFormat::Flac, + bitrate: 1411 + }), + "FLAC" + ); + assert_eq!( + UiDisplay::display_track_quality(&TrackQuality { + format: TrackFormat::Mp3, + bitrate: 218 + }), + "MP3 218kbps" + ); + } +} diff --git a/src/tui/ui/error.rs b/src/tui/ui/error.rs new file mode 100644 index 0000000..a556fb0 --- /dev/null +++ b/src/tui/ui/error.rs @@ -0,0 +1,14 @@ +use ratatui::{ + layout::Alignment, + widgets::{Paragraph, Wrap}, +}; + +pub struct ErrorOverlay; + +impl ErrorOverlay { + pub fn paragraph(msg: &str) -> Paragraph { + Paragraph::new(msg) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }) + } +} diff --git a/src/tui/ui/info.rs b/src/tui/ui/info.rs new file mode 100644 index 0000000..41a2e23 --- /dev/null +++ b/src/tui/ui/info.rs @@ -0,0 +1,111 @@ +use std::collections::HashMap; + +use musichoard::collection::{album::Album, artist::Artist, musicbrainz::IMusicBrainzRef}; +use ratatui::widgets::{ListState, Paragraph}; + +struct InfoOverlay; + +impl InfoOverlay { + const ITEM_INDENT: &'static str = " "; + const LIST_INDENT: &'static str = " - "; +} + +pub struct ArtistOverlay<'a> { + pub properties: Paragraph<'a>, +} + +impl<'a> ArtistOverlay<'a> { + fn opt_hashmap_to_string, T: AsRef>( + opt_map: Option<&HashMap>>, + item_indent: &str, + list_indent: &str, + ) -> String { + opt_map + .map(|map| Self::hashmap_to_string(map, item_indent, list_indent)) + .unwrap_or_else(|| String::from("")) + } + + fn hashmap_to_string, T: AsRef>( + map: &HashMap>, + item_indent: &str, + list_indent: &str, + ) -> String { + let mut vec: Vec<(&str, &Vec)> = map.iter().map(|(k, v)| (k.as_ref(), v)).collect(); + vec.sort_by(|x, y| x.0.cmp(y.0)); + + let indent = format!("\n{item_indent}"); + let list = vec + .iter() + .map(|(k, v)| format!("{k}: {}", Self::slice_to_string(v, list_indent))) + .collect::>() + .join(&indent); + format!("{indent}{list}") + } + + fn slice_to_string>(vec: &[S], indent: &str) -> String { + if vec.len() < 2 { + vec.first() + .map(|item| item.as_ref()) + .unwrap_or("") + .to_string() + } else { + let indent = format!("\n{indent}"); + let list = vec + .iter() + .map(|item| item.as_ref()) + .collect::>() + .join(&indent); + format!("{indent}{list}") + } + } + + pub fn new(artists: &'a [Artist], state: &ListState) -> ArtistOverlay<'a> { + let artist = state.selected().map(|i| &artists[i]); + + let item_indent = InfoOverlay::ITEM_INDENT; + let list_indent = InfoOverlay::LIST_INDENT; + + let double_item_indent = format!("{item_indent}{item_indent}"); + let double_list_indent = format!("{item_indent}{list_indent}"); + + let properties = Paragraph::new(format!( + "Artist: {}\n\n{item_indent}\ + MusicBrainz: {}\n{item_indent}\ + Properties: {}", + artist.map(|a| a.id.name.as_str()).unwrap_or(""), + artist + .and_then(|a| a.musicbrainz.as_ref().map(|mb| mb.url().as_str())) + .unwrap_or(""), + Self::opt_hashmap_to_string( + artist.map(|a| &a.properties), + &double_item_indent, + &double_list_indent + ), + )); + + ArtistOverlay { properties } + } +} + +pub struct AlbumOverlay<'a> { + pub properties: Paragraph<'a>, +} + +impl<'a> AlbumOverlay<'a> { + pub fn new(albums: &'a [Album], state: &ListState) -> AlbumOverlay<'a> { + let album = state.selected().map(|i| &albums[i]); + + let item_indent = InfoOverlay::ITEM_INDENT; + + let properties = Paragraph::new(format!( + "Album: {}\n\n{item_indent}\ + MusicBrainz: {}", + album.map(|a| a.id.title.as_str()).unwrap_or(""), + album + .and_then(|a| a.musicbrainz.as_ref().map(|mb| mb.url().as_str())) + .unwrap_or(""), + )); + + AlbumOverlay { properties } + } +} diff --git a/src/tui/ui/matches.rs b/src/tui/ui/matches.rs new file mode 100644 index 0000000..cbd2da4 --- /dev/null +++ b/src/tui/ui/matches.rs @@ -0,0 +1,23 @@ +use musichoard::collection::album::Album; +use ratatui::widgets::{List, ListItem}; + +use crate::tui::{app::WidgetState, lib::interface::musicbrainz::Match, ui::display::UiDisplay}; + +pub struct AlbumMatchesState<'a, 'b> { + pub list: List<'a>, + pub state: &'b mut WidgetState, +} + +impl<'a, 'b> AlbumMatchesState<'a, 'b> { + pub fn new(matches: &[Match], state: &'b mut WidgetState) -> Self { + let list = List::new( + matches + .iter() + .map(UiDisplay::display_match_string) + .map(ListItem::new) + .collect::>(), + ); + + AlbumMatchesState { list, state } + } +} diff --git a/src/tui/ui/minibuffer.rs b/src/tui/ui/minibuffer.rs new file mode 100644 index 0000000..114d34a --- /dev/null +++ b/src/tui/ui/minibuffer.rs @@ -0,0 +1,89 @@ +use ratatui::{ + layout::{Alignment, Rect}, + widgets::Paragraph, +}; + +use crate::tui::{ + app::{AppPublicState, AppState}, + ui::UiDisplay, +}; + +pub struct Minibuffer<'a> { + pub paragraphs: Vec>, + pub columns: u16, +} + +impl Minibuffer<'_> { + pub fn area(ar: Rect) -> Rect { + let space = 3; + Rect { + x: ar.x + 1 + space, + y: ar.y + 1, + width: ar.width.saturating_sub(2 + 2 * space), + height: 1, + } + } + + pub fn new(state: &AppPublicState) -> Self { + let columns = 3; + let mut mb = match state { + AppState::Browse(_) => Minibuffer { + paragraphs: vec![ + Paragraph::new("m: show info overlay"), + Paragraph::new("g: show reload menu"), + Paragraph::new("ctrl+s: search artist"), + Paragraph::new("f: fetch musicbrainz"), + ], + columns, + }, + AppState::Info(_) => Minibuffer { + paragraphs: vec![Paragraph::new("m: hide info overlay")], + columns, + }, + AppState::Reload(_) => Minibuffer { + paragraphs: vec![ + Paragraph::new("g: hide reload menu"), + Paragraph::new("d: reload database"), + Paragraph::new("l: reload library"), + ], + columns, + }, + AppState::Search(ref s) => Minibuffer { + paragraphs: vec![ + Paragraph::new(format!("I-search: {s}")), + Paragraph::new("ctrl+s: search next".to_string()).alignment(Alignment::Center), + Paragraph::new("ctrl+g: cancel search".to_string()) + .alignment(Alignment::Center), + ], + columns, + }, + AppState::Matches(public) => Minibuffer { + paragraphs: vec![ + Paragraph::new(UiDisplay::display_matching_info(public.matching)), + Paragraph::new("q: abort"), + ], + columns: 2, + }, + AppState::Error(_) => Minibuffer { + paragraphs: vec![Paragraph::new( + "Press any key to dismiss the error message...", + )], + columns: 0, + }, + AppState::Critical(_) => Minibuffer { + paragraphs: vec![Paragraph::new("Press ctrl+c to terminate the program...")], + columns: 0, + }, + }; + + if !state.is_search() { + mb.paragraphs = mb + .paragraphs + .into_iter() + .map(|p| p.alignment(Alignment::Center)) + .collect(); + } + + mb + } +} diff --git a/src/tui/ui/mod.rs b/src/tui/ui/mod.rs new file mode 100644 index 0000000..cf8ab4d --- /dev/null +++ b/src/tui/ui/mod.rs @@ -0,0 +1,320 @@ +mod browse; +mod display; +mod error; +mod info; +mod matches; +mod minibuffer; +mod overlay; +mod reload; +mod style; +mod widgets; + +use ratatui::{layout::Rect, widgets::Paragraph, Frame}; + +use musichoard::collection::{album::Album, Collection}; + +use crate::tui::{ + app::{AppPublicState, AppState, Category, IAppAccess, Selection, WidgetState}, + lib::interface::musicbrainz::Match, + ui::{ + browse::{ + AlbumArea, AlbumState, ArtistArea, ArtistState, FrameArea, TrackArea, TrackState, + }, + display::UiDisplay, + error::ErrorOverlay, + info::{AlbumOverlay, ArtistOverlay}, + matches::AlbumMatchesState, + minibuffer::Minibuffer, + overlay::{OverlayBuilder, OverlaySize}, + reload::ReloadOverlay, + widgets::UiWidget, + }, +}; + +pub trait IUi { + fn render(app: &mut APP, frame: &mut Frame); +} + +pub struct Ui; + +impl Ui { + fn render_artist_column(st: ArtistState, ar: ArtistArea, fr: &mut Frame) { + UiWidget::render_list_widget("Artists", st.list, st.state, st.active, ar.list, fr); + } + + fn render_album_column(st: AlbumState, ar: AlbumArea, fr: &mut Frame) { + UiWidget::render_list_widget("Albums", st.list, st.state, st.active, ar.list, fr); + UiWidget::render_info_widget("Album info", st.info, st.active, ar.info, fr); + } + + fn render_track_column(st: TrackState, ar: TrackArea, fr: &mut Frame) { + UiWidget::render_list_widget("Tracks", st.list, st.state, st.active, ar.list, fr); + UiWidget::render_info_widget("Track info", st.info, st.active, ar.info, fr); + } + + fn render_minibuffer(state: &AppPublicState, ar: Rect, fr: &mut Frame) { + let mb = Minibuffer::new(state); + let area = Minibuffer::area(ar); + + UiWidget::render_info_widget("Minibuffer", Paragraph::new(""), false, ar, fr); + UiWidget::render_columns(mb.paragraphs, mb.columns, false, area, fr); + } + + fn render_browse_frame( + artists: &Collection, + selection: &mut Selection, + state: &AppPublicState, + frame: &mut Frame, + ) { + let active = selection.category(); + let areas = FrameArea::new(frame.size()); + + let artist_state = ArtistState::new( + active == Category::Artist, + artists, + selection.widget_state_artist(), + ); + + Self::render_artist_column(artist_state, areas.artist, frame); + + let albums = selection + .state_album(artists) + .map(|st| st.list) + .unwrap_or_default(); + let album_state = AlbumState::new( + active == Category::Album, + albums, + selection.widget_state_album(), + ); + + Self::render_album_column(album_state, areas.album, frame); + + let tracks = selection + .state_track(artists) + .map(|st| st.list) + .unwrap_or_default(); + let track_state = TrackState::new( + active == Category::Track, + tracks, + selection.widget_state_track(), + ); + + 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()); + + if selection.category() == Category::Artist { + let overlay = ArtistOverlay::new(artists, &selection.widget_state_artist().list); + UiWidget::render_overlay_widget("Artist", overlay.properties, area, false, frame); + } else { + let no_albums: Vec = vec![]; + let albums = selection + .state_album(artists) + .map(|st| st.list) + .unwrap_or_else(|| &no_albums); + let overlay = AlbumOverlay::new(albums, &selection.widget_state_album().list); + UiWidget::render_overlay_widget("Album", overlay.properties, area, false, frame); + } + } + + fn render_reload_overlay(frame: &mut Frame) { + let area = OverlayBuilder::default() + .with_width(OverlaySize::Value(39)) + .with_height(OverlaySize::Value(4)) + .build(frame.size()); + + let reload_text = ReloadOverlay::paragraph(); + + UiWidget::render_overlay_widget("Reload", reload_text, area, false, frame); + } + + fn render_matches_overlay( + matching: Option<&Album>, + matches: Option<&[Match]>, + state: &mut WidgetState, + frame: &mut Frame, + ) { + let area = OverlayBuilder::default().build(frame.size()); + let matching_string = UiDisplay::display_matching_info(matching); + let st = AlbumMatchesState::new(matches.unwrap_or_default(), state); + UiWidget::render_overlay_list_widget(&matching_string, st.list, st.state, true, 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()); + + let error_text = ErrorOverlay::paragraph(msg.as_ref()); + + UiWidget::render_overlay_widget(title.as_ref(), error_text, area, true, frame); + } +} + +impl IUi for Ui { + fn render(app: &mut APP, frame: &mut Frame) { + let app = app.get(); + + let collection = app.inner.collection; + let selection = app.inner.selection; + let state = app.state; + + Self::render_browse_frame(collection, selection, &state, frame); + match state { + AppState::Info(_) => Self::render_info_overlay(collection, selection, frame), + AppState::Matches(public) => { + Self::render_matches_overlay(public.matching, 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), + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use musichoard::collection::{ + album::{AlbumDate, AlbumId, AlbumPrimaryType, AlbumSecondaryType}, + artist::{Artist, ArtistId}, + }; + + use crate::tui::{ + app::{AppPublic, AppPublicInner, AppPublicMatches, Delta}, + testmod::COLLECTION, + tests::terminal, + }; + + use super::*; + + // Automock does not support returning types with generic lifetimes. + impl IAppAccess for AppPublic<'_> { + fn get(&mut self) -> AppPublic { + AppPublic { + inner: AppPublicInner { + collection: self.inner.collection, + selection: self.inner.selection, + }, + state: match self.state { + AppState::Browse(()) => AppState::Browse(()), + AppState::Info(()) => AppState::Info(()), + AppState::Reload(()) => AppState::Reload(()), + AppState::Search(s) => AppState::Search(s), + AppState::Matches(ref mut m) => AppState::Matches(AppPublicMatches { + matching: m.matching, + matches: m.matches, + state: m.state, + }), + AppState::Error(s) => AppState::Error(s), + AppState::Critical(s) => AppState::Critical(s), + }, + } + } + } + + fn draw_test_suite(collection: &Collection, selection: &mut Selection) { + let mut terminal = terminal(); + + let album = Album::new( + AlbumId::new("An Album"), + AlbumDate::new(Some(1990), Some(5), None), + Some(AlbumPrimaryType::Album), + vec![AlbumSecondaryType::Live, AlbumSecondaryType::Compilation], + ); + + let album_match = Match { + score: 80, + item: album.clone(), + }; + + let mut app = AppPublic { + inner: AppPublicInner { + collection, + selection, + }, + state: AppState::Browse(()), + }; + terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); + + app.state = AppState::Info(()); + terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); + + app.state = AppState::Reload(()); + terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); + + app.state = AppState::Search(""); + terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); + + let album_matches = [album_match.clone(), album_match.clone()]; + let mut widget_state = WidgetState::default(); + widget_state.list.select(Some(0)); + + app.state = AppState::Matches(AppPublicMatches { + matching: Some(&album), + matches: Some(&album_matches), + state: &mut widget_state, + }); + terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); + + let mut widget_state = WidgetState::default(); + + app.state = AppState::Matches(AppPublicMatches { + matching: None, + matches: None, + state: &mut widget_state, + }); + terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); + + app.state = AppState::Error("get rekt scrub"); + terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); + + app.state = AppState::Critical("get critically rekt scrub"); + terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap(); + } + + #[test] + fn empty() { + let artists: Vec = vec![]; + let mut selection = Selection::new(&artists); + + draw_test_suite(&artists, &mut selection); + } + + #[test] + fn empty_album() { + let mut artists: Vec = vec![Artist::new(ArtistId::new("An artist"))]; + artists[0] + .albums + .push(Album::new("An album", AlbumDate::default(), None, vec![])); + let mut selection = Selection::new(&artists); + + draw_test_suite(&artists, &mut selection); + } + + #[test] + fn collection() { + let artists = &COLLECTION; + let mut selection = Selection::new(artists); + + draw_test_suite(artists, &mut selection); + + // Change the track (which has a different track format). + selection.increment_category(); + selection.increment_category(); + selection.increment_selection(artists, Delta::Line); + + draw_test_suite(artists, &mut selection); + + // Change the artist (which has a multi-link entry). + selection.decrement_category(); + selection.decrement_category(); + selection.increment_selection(artists, Delta::Line); + + draw_test_suite(artists, &mut selection); + } +} diff --git a/src/tui/ui/overlay.rs b/src/tui/ui/overlay.rs new file mode 100644 index 0000000..5e74977 --- /dev/null +++ b/src/tui/ui/overlay.rs @@ -0,0 +1,57 @@ +use ratatui::layout::Rect; + +pub enum OverlaySize { + MarginFactor(u16), + Value(u16), +} + +impl Default for OverlaySize { + fn default() -> Self { + OverlaySize::MarginFactor(8) + } +} + +impl OverlaySize { + fn get(&self, full: u16) -> (u16, u16) { + match self { + OverlaySize::MarginFactor(margin_factor) => { + let margin = full / margin_factor; + (margin, full.saturating_sub(2 * margin)) + } + OverlaySize::Value(value) => { + let margin = (full.saturating_sub(*value)) / 2; + (margin, *value) + } + } + } +} + +#[derive(Default)] +pub struct OverlayBuilder { + width: OverlaySize, + height: OverlaySize, +} + +impl OverlayBuilder { + pub fn with_width(mut self, width: OverlaySize) -> OverlayBuilder { + self.width = width; + self + } + + pub fn with_height(mut self, height: OverlaySize) -> OverlayBuilder { + self.height = height; + self + } + + pub fn build(self, frame: Rect) -> Rect { + let (x, width) = self.width.get(frame.width); + let (y, height) = self.height.get(frame.height); + + Rect { + x, + y, + width, + height, + } + } +} diff --git a/src/tui/ui/reload.rs b/src/tui/ui/reload.rs new file mode 100644 index 0000000..670a00d --- /dev/null +++ b/src/tui/ui/reload.rs @@ -0,0 +1,13 @@ +use ratatui::{layout::Alignment, widgets::Paragraph}; + +pub struct ReloadOverlay; + +impl ReloadOverlay { + pub fn paragraph<'a>() -> Paragraph<'a> { + Paragraph::new( + "d: database\n\ + l: library", + ) + .alignment(Alignment::Center) + } +} diff --git a/src/tui/ui/style.rs b/src/tui/ui/style.rs new file mode 100644 index 0000000..7c70229 --- /dev/null +++ b/src/tui/ui/style.rs @@ -0,0 +1,38 @@ +use ratatui::style::{Color, Style}; + +pub struct UiColor; + +impl UiColor { + pub const BG: Color = Color::Black; + pub const BG_HL: Color = Color::DarkGray; + pub const FG: Color = Color::White; + pub const FG_ERR: Color = Color::Red; + pub const FG_WARN: Color = Color::LightYellow; + pub const FG_GOOD: Color = Color::LightGreen; +} + +pub struct UiStyle; + +impl UiStyle { + pub fn style(_active: bool, error: bool) -> Style { + let style = Style::default().bg(UiColor::BG); + if error { + style.fg(UiColor::FG_ERR) + } else { + style.fg(UiColor::FG) + } + } + + pub fn block_style(active: bool, error: bool) -> Style { + Self::style(active, error) + } + + pub fn highlight_style(active: bool) -> Style { + // Do not set the fg color here as it will overwrite any list-specific customisation. + if active { + Style::default().bg(UiColor::BG_HL) + } else { + Style::default().bg(UiColor::BG) + } + } +} diff --git a/src/tui/ui/widgets.rs b/src/tui/ui/widgets.rs new file mode 100644 index 0000000..7592e19 --- /dev/null +++ b/src/tui/ui/widgets.rs @@ -0,0 +1,138 @@ +use ratatui::{ + layout::{Alignment, Rect}, + widgets::{Block, BorderType, Borders, Clear, List, Paragraph}, + Frame, +}; + +use crate::tui::{app::WidgetState, ui::style::UiStyle}; + +struct Column<'a> { + paragraph: Paragraph<'a>, + area: Rect, +} + +pub struct UiWidget; + +impl UiWidget { + fn block<'a>(active: bool, error: bool) -> Block<'a> { + Block::default().style(UiStyle::block_style(active, error)) + } + + fn block_with_borders<'a>(title: &str, active: bool, error: bool) -> Block<'a> { + Self::block(active, error) + .title_alignment(Alignment::Center) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .title(format!(" {title} ")) + } + + pub fn render_list_widget( + title: &str, + list: List, + state: &mut WidgetState, + active: bool, + area: Rect, + frame: &mut Frame, + ) { + frame.render_stateful_widget( + list.highlight_style(UiStyle::highlight_style(active)) + .highlight_symbol(">> ") + .style(UiStyle::style(active, false)) + .block(Self::block_with_borders(title, active, false)), + area, + &mut state.list, + ); + state.height = area.height.saturating_sub(2) as usize; + } + + pub 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); + } + + pub fn render_info_widget( + title: &str, + paragraph: Paragraph, + active: bool, + area: Rect, + frame: &mut Frame, + ) { + frame.render_widget( + paragraph + .style(UiStyle::style(active, false)) + .block(Self::block_with_borders(title, active, false)), + area, + ); + } + + pub fn render_overlay_widget( + title: &str, + paragraph: Paragraph, + area: Rect, + error: bool, + frame: &mut Frame, + ) { + frame.render_widget(Clear, area); + frame.render_widget( + paragraph + .style(UiStyle::style(true, error)) + .block(Self::block_with_borders(title, true, error)), + area, + ); + } + + fn columns(paragraphs: Vec, min: u16, area: Rect) -> Vec { + let mut x = area.x; + let mut width = area.width; + let mut remaining = paragraphs.len() as u16; + if remaining < min { + remaining = min; + } + + let mut blocks = vec![]; + for paragraph in paragraphs.into_iter() { + let block_width = width / remaining; + + blocks.push(Column { + paragraph, + area: Rect { + x, + y: area.y, + width: block_width, + height: area.height, + }, + }); + + x = x.saturating_add(block_width); + width = width.saturating_sub(block_width); + remaining -= 1; + } + + blocks + } + + pub fn render_columns( + paragraphs: Vec, + min: u16, + active: bool, + area: Rect, + frame: &mut Frame, + ) { + for column in Self::columns(paragraphs, min, area).into_iter() { + frame.render_widget( + column + .paragraph + .style(UiStyle::style(active, false)) + .block(Self::block(active, false)), + column.area, + ); + } + } +}