Split ui.rs into modules based on UI element (#200)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m59s
Cargo CI / Lint (push) Successful in 1m5s

Closes #135

Reviewed-on: #200
This commit is contained in:
Wojciech Kozlowski 2024-08-29 17:21:52 +02:00
parent 0fefc52603
commit c38961c3c1
12 changed files with 1274 additions and 1162 deletions

File diff suppressed because it is too large Load Diff

234
src/tui/ui/browse.rs Normal file
View File

@ -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::<Vec<ListItem>>(),
);
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::<Vec<ListItem>>(),
);
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::<Vec<ListItem>>(),
);
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,
}
}
}

237
src/tui/ui/display.rs Normal file
View File

@ -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<AlbumPrimaryType>,
secondary: &Vec<AlbumSecondaryType>,
) -> 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<AlbumSecondaryType>) -> 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<Album>) -> 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()), "199005");
assert_eq!(
UiDisplay::display_album_date(&(1990, 5, 6).into()),
"19900506"
);
}
#[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"
);
}
}

14
src/tui/ui/error.rs Normal file
View File

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

111
src/tui/ui/info.rs Normal file
View File

@ -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<K: Ord + AsRef<str>, T: AsRef<str>>(
opt_map: Option<&HashMap<K, Vec<T>>>,
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<K: AsRef<str>, T: AsRef<str>>(
map: &HashMap<K, Vec<T>>,
item_indent: &str,
list_indent: &str,
) -> String {
let mut vec: Vec<(&str, &Vec<T>)> = 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::<Vec<String>>()
.join(&indent);
format!("{indent}{list}")
}
fn slice_to_string<S: AsRef<str>>(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::<Vec<&str>>()
.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 }
}
}

23
src/tui/ui/matches.rs Normal file
View File

@ -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<Album>], state: &'b mut WidgetState) -> Self {
let list = List::new(
matches
.iter()
.map(UiDisplay::display_match_string)
.map(ListItem::new)
.collect::<Vec<ListItem>>(),
);
AlbumMatchesState { list, state }
}
}

89
src/tui/ui/minibuffer.rs Normal file
View File

@ -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<Paragraph<'a>>,
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
}
}

320
src/tui/ui/mod.rs Normal file
View File

@ -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: IAppAccess>(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<Album> = 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<Album>]>,
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<S: AsRef<str>>(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: IAppAccess>(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<Artist> = vec![];
let mut selection = Selection::new(&artists);
draw_test_suite(&artists, &mut selection);
}
#[test]
fn empty_album() {
let mut artists: Vec<Artist> = 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);
}
}

57
src/tui/ui/overlay.rs Normal file
View File

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

13
src/tui/ui/reload.rs Normal file
View File

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

38
src/tui/ui/style.rs Normal file
View File

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

138
src/tui/ui/widgets.rs Normal file
View File

@ -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<Paragraph>, min: u16, area: Rect) -> Vec<Column> {
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<Paragraph>,
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,
);
}
}
}