Split ui into ui/render
Some checks failed
Cargo CI / Build and Test (pull_request) Successful in 2m26s
Cargo CI / Lint (pull_request) Failing after 41s

This commit is contained in:
Wojciech Kozlowski 2024-01-29 23:22:23 +01:00
parent 5bda7298ab
commit 7701ce093d
4 changed files with 739 additions and 537 deletions

View File

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

View File

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

View File

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