Add shortcut to reload database and/or library #116
11
src/main.rs
11
src/main.rs
@ -20,7 +20,13 @@ use musichoard::{
|
||||
MusicHoardBuilder, NoDatabase, NoLibrary,
|
||||
};
|
||||
|
||||
use tui::{event::EventChannel, handler::EventHandler, listener::EventListener, ui::Ui, Tui};
|
||||
use tui::{
|
||||
event::EventChannel,
|
||||
handler::EventHandler,
|
||||
listener::EventListener,
|
||||
ui::{render::Renderer, Ui},
|
||||
Tui,
|
||||
};
|
||||
|
||||
#[derive(StructOpt)]
|
||||
struct Opt {
|
||||
@ -68,9 +74,10 @@ fn with<LIB: ILibrary, DB: IDatabase>(builder: MusicHoardBuilder<LIB, DB>) {
|
||||
let handler = EventHandler::new(channel.receiver());
|
||||
|
||||
let ui = Ui::new(music_hoard).expect("failed to initialise ui");
|
||||
let renderer = Renderer;
|
||||
|
||||
// Run the TUI application.
|
||||
Tui::run(terminal, ui, handler, listener).expect("failed to run tui");
|
||||
Tui::run(terminal, ui, renderer, handler, listener).expect("failed to run tui");
|
||||
}
|
||||
|
||||
fn with_database<LIB: ILibrary>(db_opt: DbOpt, builder: MusicHoardBuilder<LIB, NoDatabase>) {
|
||||
|
@ -15,7 +15,7 @@ use std::marker::PhantomData;
|
||||
use self::event::EventError;
|
||||
use self::handler::IEventHandler;
|
||||
use self::listener::IEventListener;
|
||||
use self::ui::IUi;
|
||||
use self::ui::{render::IRender, IUi};
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
@ -43,12 +43,12 @@ impl From<EventError> for Error {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Tui<B: Backend, UI: IUi> {
|
||||
pub struct Tui<B: Backend, R: IRender, UI: IUi> {
|
||||
terminal: Terminal<B>,
|
||||
_phantom: PhantomData<UI>,
|
||||
_phantom: PhantomData<(R, UI)>,
|
||||
}
|
||||
|
||||
impl<B: Backend, UI: IUi> Tui<B, UI> {
|
||||
impl<B: Backend, R: IRender, UI: IUi> Tui<B, R, UI> {
|
||||
fn init(&mut self) -> Result<(), Error> {
|
||||
self.terminal.hide_cursor()?;
|
||||
self.terminal.clear()?;
|
||||
@ -65,9 +65,14 @@ impl<B: Backend, UI: IUi> Tui<B, UI> {
|
||||
self.exit();
|
||||
}
|
||||
|
||||
fn main_loop(&mut self, mut ui: UI, handler: impl IEventHandler<UI>) -> Result<(), Error> {
|
||||
fn main_loop(
|
||||
&mut self,
|
||||
mut ui: UI,
|
||||
renderer: R,
|
||||
handler: impl IEventHandler<UI>,
|
||||
) -> Result<(), Error> {
|
||||
while ui.is_running() {
|
||||
self.terminal.draw(|frame| ui.render(frame))?;
|
||||
self.terminal.draw(|frame| ui.render(&renderer, frame))?;
|
||||
handler.handle_next_event(&mut ui)?;
|
||||
}
|
||||
|
||||
@ -77,6 +82,7 @@ impl<B: Backend, UI: IUi> Tui<B, UI> {
|
||||
fn main(
|
||||
term: Terminal<B>,
|
||||
ui: UI,
|
||||
renderer: R,
|
||||
handler: impl IEventHandler<UI>,
|
||||
listener: impl IEventListener,
|
||||
) -> Result<(), Error> {
|
||||
@ -88,7 +94,7 @@ impl<B: Backend, UI: IUi> Tui<B, UI> {
|
||||
tui.init()?;
|
||||
|
||||
let listener_handle = listener.spawn();
|
||||
let result = tui.main_loop(ui, handler);
|
||||
let result = tui.main_loop(ui, renderer, handler);
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
@ -136,11 +142,12 @@ impl<B: Backend, UI: IUi> Tui<B, UI> {
|
||||
pub fn run(
|
||||
term: Terminal<B>,
|
||||
ui: UI,
|
||||
renderer: R,
|
||||
handler: impl IEventHandler<UI>,
|
||||
listener: impl IEventListener,
|
||||
) -> Result<(), Error> {
|
||||
Self::enable()?;
|
||||
let result = Self::main(term, ui, handler, listener);
|
||||
let result = Self::main(term, ui, renderer, handler, listener);
|
||||
match result {
|
||||
Ok(_) => {
|
||||
Self::disable()?;
|
||||
@ -169,7 +176,10 @@ mod tests {
|
||||
use musichoard::collection::Collection;
|
||||
|
||||
use crate::tui::{
|
||||
handler::MockIEventHandler, lib::MockIMusicHoard, listener::MockIEventListener, ui::Ui,
|
||||
handler::MockIEventHandler,
|
||||
lib::MockIMusicHoard,
|
||||
listener::MockIEventListener,
|
||||
ui::{render::Renderer, Ui},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
@ -220,11 +230,12 @@ mod tests {
|
||||
fn run() {
|
||||
let terminal = terminal();
|
||||
let ui = ui(COLLECTION.to_owned());
|
||||
let renderer = Renderer;
|
||||
|
||||
let listener = listener();
|
||||
let handler = handler();
|
||||
|
||||
let result = Tui::main(terminal, ui, handler, listener);
|
||||
let result = Tui::main(terminal, ui, renderer, handler, listener);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
@ -232,6 +243,7 @@ mod tests {
|
||||
fn event_error() {
|
||||
let terminal = terminal();
|
||||
let ui = ui(COLLECTION.to_owned());
|
||||
let renderer = Renderer;
|
||||
|
||||
let listener = listener();
|
||||
|
||||
@ -240,7 +252,7 @@ mod tests {
|
||||
.expect_handle_next_event()
|
||||
.return_once(|_| Err(EventError::Recv));
|
||||
|
||||
let result = Tui::main(terminal, ui, handler, listener);
|
||||
let result = Tui::main(terminal, ui, renderer, handler, listener);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
@ -252,6 +264,7 @@ mod tests {
|
||||
fn listener_error() {
|
||||
let terminal = terminal();
|
||||
let ui = ui(COLLECTION.to_owned());
|
||||
let renderer = Renderer;
|
||||
|
||||
let error = EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "error"));
|
||||
let listener_handle: thread::JoinHandle<EventError> = thread::spawn(|| error);
|
||||
@ -265,7 +278,7 @@ mod tests {
|
||||
.expect_handle_next_event()
|
||||
.return_once(|_| Err(EventError::Recv));
|
||||
|
||||
let result = Tui::main(terminal, ui, handler, listener);
|
||||
let result = Tui::main(terminal, ui, renderer, handler, listener);
|
||||
assert!(result.is_err());
|
||||
|
||||
let error = EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "error"));
|
||||
@ -276,6 +289,7 @@ mod tests {
|
||||
fn listener_panic() {
|
||||
let terminal = terminal();
|
||||
let ui = ui(COLLECTION.to_owned());
|
||||
let renderer = Renderer;
|
||||
|
||||
let listener_handle: thread::JoinHandle<EventError> = thread::spawn(|| panic!());
|
||||
while !listener_handle.is_finished() {}
|
||||
@ -288,7 +302,7 @@ mod tests {
|
||||
.expect_handle_next_event()
|
||||
.return_once(|_| Err(EventError::Recv));
|
||||
|
||||
let result = Tui::main(terminal, ui, handler, listener);
|
||||
let result = Tui::main(terminal, ui, renderer, handler, listener);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), Error::ListenerPanic);
|
||||
}
|
||||
|
@ -1,20 +1,14 @@
|
||||
pub mod render;
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use musichoard::collection::{
|
||||
album::Album,
|
||||
artist::Artist,
|
||||
track::{Format, Track},
|
||||
Collection,
|
||||
};
|
||||
use ratatui::{
|
||||
backend::Backend,
|
||||
layout::{Alignment, Rect},
|
||||
style::{Color, Style},
|
||||
widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
use musichoard::collection::{album::Album, artist::Artist, track::Track, Collection};
|
||||
use ratatui::{backend::Backend, widgets::ListState, Frame};
|
||||
|
||||
use crate::tui::{lib::IMusicHoard, Error};
|
||||
use crate::tui::{
|
||||
ui::render::IRender,
|
||||
{lib::IMusicHoard, Error},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum UiError {
|
||||
@ -73,7 +67,7 @@ pub trait IUi {
|
||||
|
||||
fn state(&mut self) -> UiState<&mut Self::BS, &mut Self::IS, &mut Self::RS, &mut Self::ES>;
|
||||
|
||||
fn render<B: Backend>(&mut self, frame: &mut Frame<'_, B>);
|
||||
fn render<R: IRender, B: Backend>(&mut self, renderer: &R, frame: &mut Frame<'_, B>);
|
||||
}
|
||||
|
||||
pub trait IUiBrowse {
|
||||
@ -258,7 +252,7 @@ pub enum Category {
|
||||
Track,
|
||||
}
|
||||
|
||||
struct Selection {
|
||||
pub struct Selection {
|
||||
active: Category,
|
||||
artist: ArtistSelection,
|
||||
}
|
||||
@ -328,292 +322,7 @@ impl Selection {
|
||||
}
|
||||
}
|
||||
|
||||
struct ArtistArea {
|
||||
list: Rect,
|
||||
}
|
||||
|
||||
struct AlbumArea {
|
||||
list: Rect,
|
||||
info: Rect,
|
||||
}
|
||||
|
||||
struct TrackArea {
|
||||
list: Rect,
|
||||
info: Rect,
|
||||
}
|
||||
|
||||
struct FrameArea {
|
||||
artist: ArtistArea,
|
||||
album: AlbumArea,
|
||||
track: TrackArea,
|
||||
}
|
||||
|
||||
impl FrameArea {
|
||||
fn new(frame: Rect) -> Self {
|
||||
let width_one_third = frame.width / 3;
|
||||
let height_one_third = frame.height / 3;
|
||||
|
||||
let panel_width = width_one_third;
|
||||
let panel_width_last = frame.width - 2 * panel_width;
|
||||
let panel_height_top = frame.height - height_one_third;
|
||||
let panel_height_bottom = height_one_third;
|
||||
|
||||
let artist_list = Rect {
|
||||
x: frame.x,
|
||||
y: frame.y,
|
||||
width: panel_width,
|
||||
height: frame.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,
|
||||
};
|
||||
|
||||
FrameArea {
|
||||
artist: ArtistArea { list: artist_list },
|
||||
album: AlbumArea {
|
||||
list: album_list,
|
||||
info: album_info,
|
||||
},
|
||||
track: TrackArea {
|
||||
list: track_list,
|
||||
info: track_info,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 - (2 * margin))
|
||||
}
|
||||
OverlaySize::Value(value) => {
|
||||
let margin = (full - 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 ListState,
|
||||
}
|
||||
|
||||
impl<'a, 'b> ArtistState<'a, 'b> {
|
||||
fn new(active: bool, artists: &'a [Artist], state: &'b mut ListState) -> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ArtistOverlay<'a> {
|
||||
properties: Paragraph<'a>,
|
||||
}
|
||||
|
||||
impl<'a> ArtistOverlay<'a> {
|
||||
fn opt_opt_to_str<S: AsRef<str>>(opt: Option<Option<&S>>) -> &str {
|
||||
opt.flatten().map(|item| item.as_ref()).unwrap_or("")
|
||||
}
|
||||
|
||||
fn opt_vec_to_string<S: AsRef<str>>(opt_vec: Option<&Vec<S>>, indent: &str) -> String {
|
||||
opt_vec
|
||||
.map(|vec| {
|
||||
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}")
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| String::from(""))
|
||||
}
|
||||
|
||||
fn new(artists: &'a [Artist], state: &ListState) -> ArtistOverlay<'a> {
|
||||
let artist = state.selected().map(|i| &artists[i]);
|
||||
|
||||
let item_indent = " ";
|
||||
let list_indent = " - ";
|
||||
let properties = Paragraph::new(format!(
|
||||
"Artist: {}\n\n{item_indent}\
|
||||
MusicBrainz: {}\n{item_indent}\
|
||||
MusicButler: {}\n{item_indent}\
|
||||
Bandcamp: {}\n{item_indent}\
|
||||
Qobuz: {}",
|
||||
artist.map(|a| a.id.name.as_str()).unwrap_or(""),
|
||||
Self::opt_opt_to_str(artist.map(|a| a.properties.musicbrainz.as_ref())),
|
||||
Self::opt_vec_to_string(artist.map(|a| &a.properties.musicbutler), list_indent),
|
||||
Self::opt_vec_to_string(artist.map(|a| &a.properties.bandcamp), list_indent),
|
||||
Self::opt_opt_to_str(artist.map(|a| a.properties.qobuz.as_ref())),
|
||||
));
|
||||
|
||||
ArtistOverlay { properties }
|
||||
}
|
||||
}
|
||||
|
||||
struct AlbumState<'a, 'b> {
|
||||
active: bool,
|
||||
list: List<'a>,
|
||||
state: &'b mut ListState,
|
||||
info: Paragraph<'a>,
|
||||
}
|
||||
|
||||
impl<'a, 'b> AlbumState<'a, 'b> {
|
||||
fn new(active: bool, albums: &'a [Album], state: &'b mut ListState) -> AlbumState<'a, 'b> {
|
||||
let list = List::new(
|
||||
albums
|
||||
.iter()
|
||||
.map(|a| ListItem::new(a.id.title.as_str()))
|
||||
.collect::<Vec<ListItem>>(),
|
||||
);
|
||||
|
||||
let album = state.selected().map(|i| &albums[i]);
|
||||
let info = Paragraph::new(format!(
|
||||
"Title: {}\n\
|
||||
Year: {}",
|
||||
album.map(|a| a.id.title.as_str()).unwrap_or(""),
|
||||
album.map(|a| a.id.year.to_string()).unwrap_or_default(),
|
||||
));
|
||||
|
||||
AlbumState {
|
||||
active,
|
||||
list,
|
||||
state,
|
||||
info,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TrackState<'a, 'b> {
|
||||
active: bool,
|
||||
list: List<'a>,
|
||||
state: &'b mut ListState,
|
||||
info: Paragraph<'a>,
|
||||
}
|
||||
|
||||
impl<'a, 'b> TrackState<'a, 'b> {
|
||||
fn new(active: bool, tracks: &'a [Track], state: &'b mut ListState) -> TrackState<'a, 'b> {
|
||||
let list = List::new(
|
||||
tracks
|
||||
.iter()
|
||||
.map(|tr| ListItem::new(tr.id.title.as_str()))
|
||||
.collect::<Vec<ListItem>>(),
|
||||
);
|
||||
|
||||
let track = state.selected().map(|i| &tracks[i]);
|
||||
let info = Paragraph::new(format!(
|
||||
"Track: {}\n\
|
||||
Title: {}\n\
|
||||
Artist: {}\n\
|
||||
Quality: {}",
|
||||
track.map(|t| t.id.number.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| match t.quality.format {
|
||||
Format::Flac => "FLAC".to_string(),
|
||||
Format::Mp3 => format!("MP3 {}kbps", t.quality.bitrate),
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
));
|
||||
|
||||
TrackState {
|
||||
active,
|
||||
list,
|
||||
state,
|
||||
info,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Ui<MH> {
|
||||
pub struct Ui<MH: IMusicHoard> {
|
||||
running: bool,
|
||||
music_hoard: MH,
|
||||
selection: Selection,
|
||||
@ -622,6 +331,7 @@ pub struct Ui<MH> {
|
||||
|
||||
impl<MH: IMusicHoard> Ui<MH> {
|
||||
pub fn new(mut music_hoard: MH) -> Result<Self, Error> {
|
||||
// FIXME: if either returns an error start in an error state
|
||||
music_hoard.load_from_database()?;
|
||||
music_hoard.rescan_library()?;
|
||||
let selection = Selection::new(Some(music_hoard.get_collection()));
|
||||
@ -632,182 +342,6 @@ impl<MH: IMusicHoard> Ui<MH> {
|
||||
state: UiState::Browse(()),
|
||||
})
|
||||
}
|
||||
|
||||
fn style(_active: bool) -> Style {
|
||||
Style::default().fg(Color::White).bg(Color::Black)
|
||||
}
|
||||
|
||||
fn block_style(active: bool) -> Style {
|
||||
Self::style(active)
|
||||
}
|
||||
|
||||
fn highlight_style(active: bool) -> Style {
|
||||
if active {
|
||||
Style::default().fg(Color::White).bg(Color::DarkGray)
|
||||
} else {
|
||||
Self::style(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn block<'a>(title: &str, active: bool) -> Block<'a> {
|
||||
Block::default()
|
||||
.title_alignment(Alignment::Center)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.style(Self::block_style(active))
|
||||
.title(format!(" {title} "))
|
||||
}
|
||||
|
||||
fn render_list_widget<B: Backend>(
|
||||
title: &str,
|
||||
list: List,
|
||||
list_state: &mut ListState,
|
||||
active: bool,
|
||||
area: Rect,
|
||||
frame: &mut Frame<'_, B>,
|
||||
) {
|
||||
frame.render_stateful_widget(
|
||||
list.highlight_style(Self::highlight_style(active))
|
||||
.highlight_symbol(">> ")
|
||||
.style(Self::style(active))
|
||||
.block(Self::block(title, active)),
|
||||
area,
|
||||
list_state,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_info_widget<B: Backend>(
|
||||
title: &str,
|
||||
paragraph: Paragraph,
|
||||
active: bool,
|
||||
area: Rect,
|
||||
frame: &mut Frame<'_, B>,
|
||||
) {
|
||||
frame.render_widget(
|
||||
paragraph
|
||||
.style(Self::style(active))
|
||||
.block(Self::block(title, active)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_overlay_widget<B: Backend>(
|
||||
title: &str,
|
||||
paragraph: Paragraph,
|
||||
area: Rect,
|
||||
frame: &mut Frame<'_, B>,
|
||||
) {
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(
|
||||
paragraph
|
||||
.style(Self::style(true))
|
||||
.block(Self::block(title, true)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_artist_column<B: Backend>(st: ArtistState, ar: ArtistArea, fr: &mut Frame<'_, B>) {
|
||||
Self::render_list_widget("Artists", st.list, st.state, st.active, ar.list, fr);
|
||||
}
|
||||
|
||||
fn render_album_column<B: Backend>(st: AlbumState, ar: AlbumArea, fr: &mut Frame<'_, B>) {
|
||||
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<B: Backend>(st: TrackState, ar: TrackArea, fr: &mut Frame<'_, B>) {
|
||||
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_collection<B: Backend>(
|
||||
artists: &Collection,
|
||||
selection: &mut Selection,
|
||||
frame: &mut Frame<'_, B>,
|
||||
) {
|
||||
let active = selection.active;
|
||||
let areas = FrameArea::new(frame.size());
|
||||
|
||||
let artist_selection = &mut selection.artist;
|
||||
let artist_state = ArtistState::new(
|
||||
active == Category::Artist,
|
||||
artists,
|
||||
&mut artist_selection.state,
|
||||
);
|
||||
|
||||
Self::render_artist_column(artist_state, areas.artist, frame);
|
||||
|
||||
let no_albums: Vec<Album> = vec![];
|
||||
let albums = artist_selection
|
||||
.state
|
||||
.selected()
|
||||
.map(|i| &artists[i].albums)
|
||||
.unwrap_or_else(|| &no_albums);
|
||||
let album_selection = &mut artist_selection.album;
|
||||
let album_state = AlbumState::new(
|
||||
active == Category::Album,
|
||||
albums,
|
||||
&mut album_selection.state,
|
||||
);
|
||||
|
||||
Self::render_album_column(album_state, areas.album, frame);
|
||||
|
||||
let no_tracks: Vec<Track> = vec![];
|
||||
let tracks = album_selection
|
||||
.state
|
||||
.selected()
|
||||
.map(|i| &albums[i].tracks)
|
||||
.unwrap_or_else(|| &no_tracks);
|
||||
let track_selection = &mut album_selection.track;
|
||||
let track_state = TrackState::new(
|
||||
active == Category::Track,
|
||||
tracks,
|
||||
&mut track_selection.state,
|
||||
);
|
||||
|
||||
Self::render_track_column(track_state, areas.track, frame);
|
||||
}
|
||||
|
||||
fn render_info_overlay<B: Backend>(
|
||||
artists: &Collection,
|
||||
selection: &mut Selection,
|
||||
frame: &mut Frame<'_, B>,
|
||||
) {
|
||||
let area = OverlayBuilder::default().build(frame.size());
|
||||
|
||||
let artist_selection = &mut selection.artist;
|
||||
let artist_overlay = ArtistOverlay::new(artists, &artist_selection.state);
|
||||
|
||||
Self::render_overlay_widget("Artist", artist_overlay.properties, area, frame);
|
||||
}
|
||||
|
||||
fn render_reload_overlay<B: Backend>(frame: &mut Frame<'_, B>) {
|
||||
let area = OverlayBuilder::default()
|
||||
.with_width(OverlaySize::Value(39))
|
||||
.with_height(OverlaySize::Value(4))
|
||||
.build(frame.size());
|
||||
|
||||
let reload_text = Paragraph::new(
|
||||
"d: database\n\
|
||||
l: library",
|
||||
)
|
||||
.alignment(Alignment::Center);
|
||||
|
||||
Self::render_overlay_widget("Reload", reload_text, area, frame);
|
||||
}
|
||||
|
||||
fn render_error_overlay<S: AsRef<str>, B: Backend>(msg: S, frame: &mut Frame<'_, B>) {
|
||||
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 });
|
||||
|
||||
// FIXME: highlight with red
|
||||
Self::render_overlay_widget("Error", error_text, area, frame);
|
||||
}
|
||||
}
|
||||
|
||||
impl<MH: IMusicHoard> IUi for Ui<MH> {
|
||||
@ -837,15 +371,18 @@ impl<MH: IMusicHoard> IUi for Ui<MH> {
|
||||
}
|
||||
}
|
||||
|
||||
fn render<B: Backend>(&mut self, frame: &mut Frame<'_, B>) {
|
||||
// FIXME: rendering logic should be in render.rs - reverse Renderer/Ui relationship so that TUI
|
||||
// calls are renderer.render(ui, frame); Then rename ui -> app and render -> ui as it used to be
|
||||
// originally.
|
||||
fn render<R: IRender, B: Backend>(&mut self, _: &R, frame: &mut Frame<'_, B>) {
|
||||
let collection: &Collection = &self.music_hoard.get_collection();
|
||||
let selection: &mut Selection = &mut self.selection;
|
||||
|
||||
Self::render_collection(collection, selection, frame);
|
||||
R::render_collection(collection, selection, frame);
|
||||
match self.state {
|
||||
UiState::Info(_) => Self::render_info_overlay(collection, selection, frame),
|
||||
UiState::Reload(_) => Self::render_reload_overlay(frame),
|
||||
UiState::Error(ref msg) => Self::render_error_overlay(msg, frame),
|
||||
UiState::Info(_) => R::render_info_overlay(collection, selection, frame),
|
||||
UiState::Reload(_) => R::render_reload_overlay(frame),
|
||||
UiState::Error(ref msg) => R::render_error_overlay(msg, frame),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@ -934,6 +471,7 @@ mod tests {
|
||||
use crate::tui::lib::MockIMusicHoard;
|
||||
use crate::tui::testmod::COLLECTION;
|
||||
use crate::tui::tests::{terminal, ui};
|
||||
use crate::tui::ui::render::Renderer;
|
||||
|
||||
use super::*;
|
||||
|
||||
@ -954,7 +492,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_track_selection() {
|
||||
fn track_selection() {
|
||||
let tracks = &COLLECTION[0].albums[0].tracks;
|
||||
assert!(tracks.len() > 1);
|
||||
|
||||
@ -990,7 +528,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_album_selection() {
|
||||
fn album_selection() {
|
||||
let albums = &COLLECTION[0].albums;
|
||||
assert!(albums.len() > 1);
|
||||
|
||||
@ -1048,7 +586,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_artist_selection() {
|
||||
fn artist_selection() {
|
||||
let artists = &COLLECTION;
|
||||
assert!(artists.len() > 1);
|
||||
|
||||
@ -1106,7 +644,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_running() {
|
||||
fn running() {
|
||||
let mut ui = Ui::new(music_hoard(COLLECTION.to_owned())).unwrap();
|
||||
assert!(ui.is_running());
|
||||
|
||||
@ -1115,7 +653,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_save() {
|
||||
fn save() {
|
||||
let mut music_hoard = music_hoard(COLLECTION.to_owned());
|
||||
|
||||
music_hoard
|
||||
@ -1130,7 +668,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_modifiers() {
|
||||
fn modifiers() {
|
||||
let mut ui = Ui::new(music_hoard(COLLECTION.to_owned())).unwrap();
|
||||
assert!(ui.is_running());
|
||||
|
||||
@ -1219,7 +757,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_no_tracks() {
|
||||
fn no_tracks() {
|
||||
let mut collection = COLLECTION.to_owned();
|
||||
collection[0].albums[0].tracks = vec![];
|
||||
|
||||
@ -1248,7 +786,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_no_albums() {
|
||||
fn no_albums() {
|
||||
let mut collection = COLLECTION.to_owned();
|
||||
collection[0].albums = vec![];
|
||||
|
||||
@ -1290,7 +828,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_no_artists() {
|
||||
fn no_artists() {
|
||||
let mut app = Ui::new(music_hoard(vec![])).unwrap();
|
||||
assert!(app.is_running());
|
||||
|
||||
@ -1343,50 +881,120 @@ mod tests {
|
||||
// This is UI so the only sensible unit test is to run the code through various app states.
|
||||
|
||||
#[test]
|
||||
fn empty() {
|
||||
fn info_overlay() {
|
||||
let mut terminal = terminal();
|
||||
let mut ui = ui(vec![]);
|
||||
let renderer = Renderer;
|
||||
|
||||
terminal.draw(|frame| ui.render(frame)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collection() {
|
||||
let mut terminal = terminal();
|
||||
let mut ui = ui(COLLECTION.to_owned());
|
||||
|
||||
terminal.draw(|frame| ui.render(frame)).unwrap();
|
||||
|
||||
// Change the track (which has a different track format).
|
||||
ui.increment_category();
|
||||
ui.increment_category();
|
||||
ui.increment_selection();
|
||||
|
||||
terminal.draw(|frame| ui.render(frame)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay() {
|
||||
let mut terminal = terminal();
|
||||
let mut ui = ui(COLLECTION.to_owned());
|
||||
assert!(ui.state().is_browse());
|
||||
|
||||
terminal.draw(|frame| ui.render(frame)).unwrap();
|
||||
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
|
||||
|
||||
ui.show_info_overlay();
|
||||
assert!(ui.state().is_info());
|
||||
|
||||
terminal.draw(|frame| ui.render(frame)).unwrap();
|
||||
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
|
||||
|
||||
// Change the artist (which has a multi-link entry).
|
||||
ui.increment_selection();
|
||||
|
||||
terminal.draw(|frame| ui.render(frame)).unwrap();
|
||||
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
|
||||
|
||||
ui.hide_info_overlay();
|
||||
assert!(ui.state().is_browse());
|
||||
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
|
||||
}
|
||||
|
||||
terminal.draw(|frame| ui.render(frame)).unwrap();
|
||||
#[test]
|
||||
fn reload_go_back() {
|
||||
let mut terminal = terminal();
|
||||
let renderer = Renderer;
|
||||
let music_hoard = music_hoard(COLLECTION.to_owned());
|
||||
|
||||
let mut ui = Ui::new(music_hoard).unwrap();
|
||||
assert!(ui.state().is_browse());
|
||||
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
|
||||
|
||||
ui.show_reload_menu();
|
||||
assert!(ui.state().is_reload());
|
||||
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
|
||||
|
||||
ui.go_back();
|
||||
assert!(ui.state().is_browse());
|
||||
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_database() {
|
||||
let mut terminal = terminal();
|
||||
let renderer = Renderer;
|
||||
let mut music_hoard = music_hoard(COLLECTION.to_owned());
|
||||
|
||||
music_hoard
|
||||
.expect_load_from_database()
|
||||
.times(1)
|
||||
.return_once(|| Ok(()));
|
||||
|
||||
let mut ui = Ui::new(music_hoard).unwrap();
|
||||
assert!(ui.state().is_browse());
|
||||
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
|
||||
|
||||
ui.show_reload_menu();
|
||||
assert!(ui.state().is_reload());
|
||||
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
|
||||
|
||||
ui.reload_database();
|
||||
assert!(ui.state().is_browse());
|
||||
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_library() {
|
||||
let mut terminal = terminal();
|
||||
let renderer = Renderer;
|
||||
let mut music_hoard = music_hoard(COLLECTION.to_owned());
|
||||
|
||||
music_hoard
|
||||
.expect_rescan_library()
|
||||
.times(1)
|
||||
.return_once(|| Ok(()));
|
||||
|
||||
let mut ui = Ui::new(music_hoard).unwrap();
|
||||
assert!(ui.state().is_browse());
|
||||
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
|
||||
|
||||
ui.show_reload_menu();
|
||||
assert!(ui.state().is_reload());
|
||||
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
|
||||
|
||||
ui.reload_library();
|
||||
assert!(ui.state().is_browse());
|
||||
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_error() {
|
||||
let mut terminal = terminal();
|
||||
let renderer = Renderer;
|
||||
let mut music_hoard = music_hoard(COLLECTION.to_owned());
|
||||
|
||||
music_hoard
|
||||
.expect_load_from_database()
|
||||
.times(1)
|
||||
.return_once(|| Err(musichoard::Error::DatabaseError(String::from("get rekt"))));
|
||||
|
||||
let mut ui = Ui::new(music_hoard).unwrap();
|
||||
assert!(ui.state().is_browse());
|
||||
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
|
||||
|
||||
ui.show_reload_menu();
|
||||
assert!(ui.state().is_reload());
|
||||
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
|
||||
|
||||
ui.reload_database();
|
||||
assert!(ui.state().is_error());
|
||||
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
|
||||
|
||||
ui.dismiss_error();
|
||||
assert!(ui.state().is_browse());
|
||||
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
573
src/tui/ui/render.rs
Normal file
573
src/tui/ui/render.rs
Normal file
@ -0,0 +1,573 @@
|
||||
use musichoard::collection::{
|
||||
album::Album,
|
||||
artist::Artist,
|
||||
track::{Format, Track},
|
||||
Collection,
|
||||
};
|
||||
use ratatui::{
|
||||
backend::Backend,
|
||||
layout::{Alignment, Rect},
|
||||
style::{Color, Style},
|
||||
widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::tui::ui::{Category, Selection};
|
||||
|
||||
pub trait IRender {
|
||||
fn render_collection<B: Backend>(
|
||||
artists: &Collection,
|
||||
selection: &mut Selection,
|
||||
frame: &mut Frame<'_, B>,
|
||||
);
|
||||
fn render_info_overlay<B: Backend>(
|
||||
artists: &Collection,
|
||||
selection: &mut Selection,
|
||||
frame: &mut Frame<'_, B>,
|
||||
);
|
||||
fn render_reload_overlay<B: Backend>(frame: &mut Frame<'_, B>);
|
||||
fn render_error_overlay<S: AsRef<str>, B: Backend>(msg: S, frame: &mut Frame<'_, B>);
|
||||
}
|
||||
|
||||
struct ArtistArea {
|
||||
list: Rect,
|
||||
}
|
||||
|
||||
struct AlbumArea {
|
||||
list: Rect,
|
||||
info: Rect,
|
||||
}
|
||||
|
||||
struct TrackArea {
|
||||
list: Rect,
|
||||
info: Rect,
|
||||
}
|
||||
|
||||
struct FrameArea {
|
||||
artist: ArtistArea,
|
||||
album: AlbumArea,
|
||||
track: TrackArea,
|
||||
}
|
||||
|
||||
impl FrameArea {
|
||||
fn new(frame: Rect) -> Self {
|
||||
let width_one_third = frame.width / 3;
|
||||
let height_one_third = frame.height / 3;
|
||||
|
||||
let panel_width = width_one_third;
|
||||
let panel_width_last = frame.width - 2 * panel_width;
|
||||
let panel_height_top = frame.height - height_one_third;
|
||||
let panel_height_bottom = height_one_third;
|
||||
|
||||
let artist_list = Rect {
|
||||
x: frame.x,
|
||||
y: frame.y,
|
||||
width: panel_width,
|
||||
height: frame.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,
|
||||
};
|
||||
|
||||
FrameArea {
|
||||
artist: ArtistArea { list: artist_list },
|
||||
album: AlbumArea {
|
||||
list: album_list,
|
||||
info: album_info,
|
||||
},
|
||||
track: TrackArea {
|
||||
list: track_list,
|
||||
info: track_info,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 - (2 * margin))
|
||||
}
|
||||
OverlaySize::Value(value) => {
|
||||
let margin = (full - 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 ListState,
|
||||
}
|
||||
|
||||
impl<'a, 'b> ArtistState<'a, 'b> {
|
||||
fn new(active: bool, artists: &'a [Artist], state: &'b mut ListState) -> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ArtistOverlay<'a> {
|
||||
properties: Paragraph<'a>,
|
||||
}
|
||||
|
||||
impl<'a> ArtistOverlay<'a> {
|
||||
fn opt_opt_to_str<S: AsRef<str>>(opt: Option<Option<&S>>) -> &str {
|
||||
opt.flatten().map(|item| item.as_ref()).unwrap_or("")
|
||||
}
|
||||
|
||||
fn opt_vec_to_string<S: AsRef<str>>(opt_vec: Option<&Vec<S>>, indent: &str) -> String {
|
||||
opt_vec
|
||||
.map(|vec| {
|
||||
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}")
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| String::from(""))
|
||||
}
|
||||
|
||||
fn new(artists: &'a [Artist], state: &ListState) -> ArtistOverlay<'a> {
|
||||
let artist = state.selected().map(|i| &artists[i]);
|
||||
|
||||
let item_indent = " ";
|
||||
let list_indent = " - ";
|
||||
let properties = Paragraph::new(format!(
|
||||
"Artist: {}\n\n{item_indent}\
|
||||
MusicBrainz: {}\n{item_indent}\
|
||||
MusicButler: {}\n{item_indent}\
|
||||
Bandcamp: {}\n{item_indent}\
|
||||
Qobuz: {}",
|
||||
artist.map(|a| a.id.name.as_str()).unwrap_or(""),
|
||||
Self::opt_opt_to_str(artist.map(|a| a.properties.musicbrainz.as_ref())),
|
||||
Self::opt_vec_to_string(artist.map(|a| &a.properties.musicbutler), list_indent),
|
||||
Self::opt_vec_to_string(artist.map(|a| &a.properties.bandcamp), list_indent),
|
||||
Self::opt_opt_to_str(artist.map(|a| a.properties.qobuz.as_ref())),
|
||||
));
|
||||
|
||||
ArtistOverlay { properties }
|
||||
}
|
||||
}
|
||||
|
||||
struct AlbumState<'a, 'b> {
|
||||
active: bool,
|
||||
list: List<'a>,
|
||||
state: &'b mut ListState,
|
||||
info: Paragraph<'a>,
|
||||
}
|
||||
|
||||
impl<'a, 'b> AlbumState<'a, 'b> {
|
||||
fn new(active: bool, albums: &'a [Album], state: &'b mut ListState) -> AlbumState<'a, 'b> {
|
||||
let list = List::new(
|
||||
albums
|
||||
.iter()
|
||||
.map(|a| ListItem::new(a.id.title.as_str()))
|
||||
.collect::<Vec<ListItem>>(),
|
||||
);
|
||||
|
||||
let album = state.selected().map(|i| &albums[i]);
|
||||
let info = Paragraph::new(format!(
|
||||
"Title: {}\n\
|
||||
Year: {}",
|
||||
album.map(|a| a.id.title.as_str()).unwrap_or(""),
|
||||
album.map(|a| a.id.year.to_string()).unwrap_or_default(),
|
||||
));
|
||||
|
||||
AlbumState {
|
||||
active,
|
||||
list,
|
||||
state,
|
||||
info,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TrackState<'a, 'b> {
|
||||
active: bool,
|
||||
list: List<'a>,
|
||||
state: &'b mut ListState,
|
||||
info: Paragraph<'a>,
|
||||
}
|
||||
|
||||
impl<'a, 'b> TrackState<'a, 'b> {
|
||||
fn new(active: bool, tracks: &'a [Track], state: &'b mut ListState) -> TrackState<'a, 'b> {
|
||||
let list = List::new(
|
||||
tracks
|
||||
.iter()
|
||||
.map(|tr| ListItem::new(tr.id.title.as_str()))
|
||||
.collect::<Vec<ListItem>>(),
|
||||
);
|
||||
|
||||
let track = state.selected().map(|i| &tracks[i]);
|
||||
let info = Paragraph::new(format!(
|
||||
"Track: {}\n\
|
||||
Title: {}\n\
|
||||
Artist: {}\n\
|
||||
Quality: {}",
|
||||
track.map(|t| t.id.number.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| match t.quality.format {
|
||||
Format::Flac => "FLAC".to_string(),
|
||||
Format::Mp3 => format!("MP3 {}kbps", t.quality.bitrate),
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
));
|
||||
|
||||
TrackState {
|
||||
active,
|
||||
list,
|
||||
state,
|
||||
info,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Renderer;
|
||||
|
||||
impl Renderer {
|
||||
fn style(_active: bool) -> Style {
|
||||
Style::default().fg(Color::White).bg(Color::Black)
|
||||
}
|
||||
|
||||
fn block_style(active: bool) -> Style {
|
||||
Self::style(active)
|
||||
}
|
||||
|
||||
fn highlight_style(active: bool) -> Style {
|
||||
if active {
|
||||
Style::default().fg(Color::White).bg(Color::DarkGray)
|
||||
} else {
|
||||
Self::style(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn block<'a>(title: &str, active: bool) -> Block<'a> {
|
||||
Block::default()
|
||||
.title_alignment(Alignment::Center)
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.style(Self::block_style(active))
|
||||
.title(format!(" {title} "))
|
||||
}
|
||||
|
||||
fn render_list_widget<B: Backend>(
|
||||
title: &str,
|
||||
list: List,
|
||||
list_state: &mut ListState,
|
||||
active: bool,
|
||||
area: Rect,
|
||||
frame: &mut Frame<'_, B>,
|
||||
) {
|
||||
frame.render_stateful_widget(
|
||||
list.highlight_style(Self::highlight_style(active))
|
||||
.highlight_symbol(">> ")
|
||||
.style(Self::style(active))
|
||||
.block(Self::block(title, active)),
|
||||
area,
|
||||
list_state,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_info_widget<B: Backend>(
|
||||
title: &str,
|
||||
paragraph: Paragraph,
|
||||
active: bool,
|
||||
area: Rect,
|
||||
frame: &mut Frame<'_, B>,
|
||||
) {
|
||||
frame.render_widget(
|
||||
paragraph
|
||||
.style(Self::style(active))
|
||||
.block(Self::block(title, active)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_overlay_widget<B: Backend>(
|
||||
title: &str,
|
||||
paragraph: Paragraph,
|
||||
area: Rect,
|
||||
frame: &mut Frame<'_, B>,
|
||||
) {
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(
|
||||
paragraph
|
||||
.style(Self::style(true))
|
||||
.block(Self::block(title, true)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_artist_column<B: Backend>(st: ArtistState, ar: ArtistArea, fr: &mut Frame<'_, B>) {
|
||||
Self::render_list_widget("Artists", st.list, st.state, st.active, ar.list, fr);
|
||||
}
|
||||
|
||||
fn render_album_column<B: Backend>(st: AlbumState, ar: AlbumArea, fr: &mut Frame<'_, B>) {
|
||||
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<B: Backend>(st: TrackState, ar: TrackArea, fr: &mut Frame<'_, B>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
impl IRender for Renderer {
|
||||
fn render_collection<B: Backend>(
|
||||
artists: &Collection,
|
||||
selection: &mut Selection,
|
||||
frame: &mut Frame<'_, B>,
|
||||
) {
|
||||
let active = selection.active;
|
||||
let areas = FrameArea::new(frame.size());
|
||||
|
||||
let artist_selection = &mut selection.artist;
|
||||
let artist_state = ArtistState::new(
|
||||
active == Category::Artist,
|
||||
artists,
|
||||
&mut artist_selection.state,
|
||||
);
|
||||
|
||||
Self::render_artist_column(artist_state, areas.artist, frame);
|
||||
|
||||
let no_albums: Vec<Album> = vec![];
|
||||
let albums = artist_selection
|
||||
.state
|
||||
.selected()
|
||||
.map(|i| &artists[i].albums)
|
||||
.unwrap_or_else(|| &no_albums);
|
||||
let album_selection = &mut artist_selection.album;
|
||||
let album_state = AlbumState::new(
|
||||
active == Category::Album,
|
||||
albums,
|
||||
&mut album_selection.state,
|
||||
);
|
||||
|
||||
Self::render_album_column(album_state, areas.album, frame);
|
||||
|
||||
let no_tracks: Vec<Track> = vec![];
|
||||
let tracks = album_selection
|
||||
.state
|
||||
.selected()
|
||||
.map(|i| &albums[i].tracks)
|
||||
.unwrap_or_else(|| &no_tracks);
|
||||
let track_selection = &mut album_selection.track;
|
||||
let track_state = TrackState::new(
|
||||
active == Category::Track,
|
||||
tracks,
|
||||
&mut track_selection.state,
|
||||
);
|
||||
|
||||
Self::render_track_column(track_state, areas.track, frame);
|
||||
}
|
||||
|
||||
fn render_info_overlay<B: Backend>(
|
||||
artists: &Collection,
|
||||
selection: &mut Selection,
|
||||
frame: &mut Frame<'_, B>,
|
||||
) {
|
||||
let area = OverlayBuilder::default().build(frame.size());
|
||||
|
||||
let artist_selection = &mut selection.artist;
|
||||
let artist_overlay = ArtistOverlay::new(artists, &artist_selection.state);
|
||||
|
||||
Self::render_overlay_widget("Artist", artist_overlay.properties, area, frame);
|
||||
}
|
||||
|
||||
fn render_reload_overlay<B: Backend>(frame: &mut Frame<'_, B>) {
|
||||
let area = OverlayBuilder::default()
|
||||
.with_width(OverlaySize::Value(39))
|
||||
.with_height(OverlaySize::Value(4))
|
||||
.build(frame.size());
|
||||
|
||||
let reload_text = Paragraph::new(
|
||||
"d: database\n\
|
||||
l: library",
|
||||
)
|
||||
.alignment(Alignment::Center);
|
||||
|
||||
Self::render_overlay_widget("Reload", reload_text, area, frame);
|
||||
}
|
||||
|
||||
fn render_error_overlay<S: AsRef<str>, B: Backend>(msg: S, frame: &mut Frame<'_, B>) {
|
||||
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 });
|
||||
|
||||
// FIXME: highlight with red
|
||||
Self::render_overlay_widget("Error", error_text, area, frame);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tui::{testmod::COLLECTION, tests::terminal};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn draw_test_suite(artists: &Collection, selection: &mut Selection) {
|
||||
let mut terminal = terminal();
|
||||
|
||||
terminal
|
||||
.draw(|frame| Renderer::render_collection(artists, selection, frame))
|
||||
.unwrap();
|
||||
|
||||
terminal
|
||||
.draw(|frame| Renderer::render_info_overlay(artists, selection, frame))
|
||||
.unwrap();
|
||||
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
Renderer::render_collection(artists, selection, frame);
|
||||
Renderer::render_info_overlay(artists, selection, frame);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
Renderer::render_collection(artists, selection, frame);
|
||||
Renderer::render_reload_overlay(frame);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
Renderer::render_collection(artists, selection, frame);
|
||||
Renderer::render_error_overlay("get rekt scrub", frame);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stateless() {
|
||||
let mut terminal = terminal();
|
||||
|
||||
terminal
|
||||
.draw(|frame| Renderer::render_reload_overlay(frame))
|
||||
.unwrap();
|
||||
|
||||
terminal
|
||||
.draw(|frame| Renderer::render_error_overlay("get rekt scrub", frame))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty() {
|
||||
let artists: Vec<Artist> = vec![];
|
||||
let mut selection = Selection::new(Some(&artists));
|
||||
|
||||
draw_test_suite(&artists, &mut selection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collection() {
|
||||
let artists: &Collection = &COLLECTION;
|
||||
let mut selection = Selection::new(Some(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);
|
||||
|
||||
draw_test_suite(&artists, &mut selection);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user