Split ui.rs into modules based on UI element (#200)
Closes #135 Reviewed-on: #200
This commit is contained in:
parent
0fefc52603
commit
c38961c3c1
1162
src/tui/ui.rs
1162
src/tui/ui.rs
File diff suppressed because it is too large
Load Diff
234
src/tui/ui/browse.rs
Normal file
234
src/tui/ui/browse.rs
Normal 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
237
src/tui/ui/display.rs
Normal 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()), "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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
14
src/tui/ui/error.rs
Normal file
14
src/tui/ui/error.rs
Normal 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
111
src/tui/ui/info.rs
Normal 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
23
src/tui/ui/matches.rs
Normal 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
89
src/tui/ui/minibuffer.rs
Normal 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
320
src/tui/ui/mod.rs
Normal 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
57
src/tui/ui/overlay.rs
Normal 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
13
src/tui/ui/reload.rs
Normal 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
38
src/tui/ui/style.rs
Normal 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
138
src/tui/ui/widgets.rs
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user