use musichoard::TrackFormat; use ratatui::{ backend::Backend, layout::{Alignment, Rect}, style::{Color, Style}, widgets::{Block, BorderType, Borders, List, ListItem, ListState, Paragraph}, Frame, }; use super::app::{App, Category}; struct ArtistArea { list: Rect, } struct AlbumArea { list: Rect, info: Rect, } struct TrackArea { list: Rect, info: Rect, } struct FrameAreas { artists: ArtistArea, albums: AlbumArea, tracks: TrackArea, } struct SelectionList<'a> { list: List<'a>, state: ListState, } struct ArtistState<'a> { list: SelectionList<'a>, active: bool, } struct AlbumState<'a> { list: SelectionList<'a>, info: Paragraph<'a>, active: bool, } struct TrackState<'a> { list: SelectionList<'a>, info: Paragraph<'a>, active: bool, } struct AppState<'a> { artists: ArtistState<'a>, albums: AlbumState<'a>, tracks: TrackState<'a>, } pub struct Ui {} impl Ui { pub fn new() -> Self { Ui {} } fn construct_areas(frame: Rect) -> FrameAreas { 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, }; FrameAreas { artists: ArtistArea { list: artist_list }, albums: AlbumArea { list: album_list, info: album_info, }, tracks: TrackArea { list: track_list, info: track_info, }, } } fn construct_artist_list(app: &App) -> ArtistState { let artists = app.get_artist_ids(); let list = List::new( artists .iter() .map(|id| ListItem::new(id.name.as_str())) .collect::>(), ); let selected_artist = app.selected_artist(); let mut state = ListState::default(); state.select(selected_artist); let active = app.get_active_category() == Category::Artist; ArtistState { list: SelectionList { list, state }, active, } } fn construct_album_list(app: &App) -> AlbumState { let albums = app.get_album_ids(); let list = List::new( albums .iter() .map(|id| ListItem::new(id.title.as_str())) .collect::>(), ); let selected_album = app.selected_album(); let mut state = ListState::default(); state.select(selected_album); let active = app.get_active_category() == Category::Album; let album = selected_album.map(|i| albums[i]); let info = Paragraph::new(format!( "Title: {}\n\ Year: {}", album.map(|a| a.title.as_str()).unwrap_or(""), album .map(|a| a.year.to_string()) .unwrap_or_else(|| "".to_string()), )); AlbumState { list: SelectionList { list, state }, info, active, } } fn construct_track_list(app: &App) -> TrackState { let tracks = app.get_track_ids(); let list = List::new( tracks .iter() .map(|id| ListItem::new(id.title.as_str())) .collect::>(), ); let selected_track = app.selected_track(); let mut state = ListState::default(); state.select(selected_track); let active = app.get_active_category() == Category::Track; let track = selected_track.map(|i| tracks[i]); let info = Paragraph::new(format!( "Track: {}\n\ Title: {}\n\ Artist: {}\n\ Format: {}", track .map(|t| t.number.to_string()) .unwrap_or_else(|| "".to_string()), track.map(|t| t.title.as_str()).unwrap_or(""), track .map(|t| t.artist.join("; ")) .unwrap_or_else(|| "".to_string()), track .map(|t| match t.format { TrackFormat::Flac => "FLAC", TrackFormat::Mp3 => "MP3", }) .unwrap_or(""), )); TrackState { list: SelectionList { list, state }, info, active, } } fn construct_app_state(app: &App) -> AppState { AppState { artists: Self::construct_artist_list(app), albums: Self::construct_album_list(app), tracks: Self::construct_track_list(app), } } 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( title: &str, mut list: SelectionList, active: bool, area: Rect, frame: &mut Frame<'_, B>, ) { frame.render_stateful_widget( list.list .highlight_style(Self::highlight_style(active)) .highlight_symbol(">> ") .style(Self::style(active)) .block(Self::block(title, active)), area, &mut list.state, ); } fn render_info_widget( 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_artist_column( state: ArtistState, area: ArtistArea, frame: &mut Frame<'_, B>, ) { Self::render_list_widget("Artists", state.list, state.active, area.list, frame); } fn render_album_column( state: AlbumState, area: AlbumArea, frame: &mut Frame<'_, B>, ) { Self::render_list_widget("Albums", state.list, state.active, area.list, frame); Self::render_info_widget("Album info", state.info, state.active, area.info, frame); } fn render_track_column( state: TrackState, area: TrackArea, frame: &mut Frame<'_, B>, ) { Self::render_list_widget("Tracks", state.list, state.active, area.list, frame); Self::render_info_widget("Track info", state.info, state.active, area.info, frame); } pub fn render(&self, app: &App, frame: &mut Frame<'_, B>) { let areas = Self::construct_areas(frame.size()); let app_state = Self::construct_app_state(app); Self::render_artist_column(app_state.artists, areas.artists, frame); Self::render_album_column(app_state.albums, areas.albums, frame); Self::render_track_column(app_state.tracks, areas.tracks, frame); } } #[cfg(test)] mod tests { // This is UI so the only sensible unit test is to run the code through various app states. use crate::{ tests::COLLECTION, tui::tests::{app, terminal}, }; use super::Ui; #[test] fn empty() { let mut terminal = terminal(); let app = app(vec![]); let ui = Ui::new(); terminal.draw(|frame| ui.render(&app, frame)).unwrap(); } #[test] fn collection() { let mut terminal = terminal(); let mut app = app(COLLECTION.to_owned()); let ui = Ui::new(); terminal.draw(|frame| ui.render(&app, frame)).unwrap(); // Change the track (which has a different track format). app.increment_category(); app.increment_category(); app.increment_selection(); terminal.draw(|frame| ui.render(&app, frame)).unwrap(); } }