2023-04-13 15:29:14 +02:00
|
|
|
use std::marker::PhantomData;
|
|
|
|
|
2023-04-13 14:09:59 +02:00
|
|
|
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>,
|
|
|
|
}
|
|
|
|
|
2023-04-13 15:29:14 +02:00
|
|
|
pub struct Ui<APP> {
|
|
|
|
_phantom: PhantomData<APP>,
|
|
|
|
}
|
2023-04-13 14:09:59 +02:00
|
|
|
|
2023-04-13 15:29:14 +02:00
|
|
|
impl<APP: App> Ui<APP> {
|
2023-04-13 14:09:59 +02:00
|
|
|
pub fn new() -> Self {
|
2023-04-13 15:29:14 +02:00
|
|
|
Ui {
|
|
|
|
_phantom: PhantomData,
|
|
|
|
}
|
2023-04-13 14:09:59 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
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,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-04-13 15:29:14 +02:00
|
|
|
fn construct_artist_list(app: &APP) -> ArtistState {
|
2023-04-13 14:09:59 +02:00
|
|
|
let artists = app.get_artist_ids();
|
|
|
|
let list = List::new(
|
|
|
|
artists
|
|
|
|
.iter()
|
|
|
|
.map(|id| ListItem::new(id.name.as_str()))
|
|
|
|
.collect::<Vec<ListItem>>(),
|
|
|
|
);
|
|
|
|
|
|
|
|
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,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-04-13 15:29:14 +02:00
|
|
|
fn construct_album_list(app: &APP) -> AlbumState {
|
2023-04-13 14:09:59 +02:00
|
|
|
let albums = app.get_album_ids();
|
|
|
|
let list = List::new(
|
|
|
|
albums
|
|
|
|
.iter()
|
|
|
|
.map(|id| ListItem::new(id.title.as_str()))
|
|
|
|
.collect::<Vec<ListItem>>(),
|
|
|
|
);
|
|
|
|
|
|
|
|
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,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-04-13 15:29:14 +02:00
|
|
|
fn construct_track_list(app: &APP) -> TrackState {
|
2023-04-13 14:09:59 +02:00
|
|
|
let tracks = app.get_track_ids();
|
|
|
|
let list = List::new(
|
|
|
|
tracks
|
|
|
|
.iter()
|
|
|
|
.map(|id| ListItem::new(id.title.as_str()))
|
|
|
|
.collect::<Vec<ListItem>>(),
|
|
|
|
);
|
|
|
|
|
|
|
|
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,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-04-13 15:29:14 +02:00
|
|
|
fn construct_app_state(app: &APP) -> AppState {
|
2023-04-13 14:09:59 +02:00
|
|
|
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<B: Backend>(
|
|
|
|
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<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_artist_column<B: Backend>(
|
|
|
|
state: ArtistState,
|
|
|
|
area: ArtistArea,
|
|
|
|
frame: &mut Frame<'_, B>,
|
|
|
|
) {
|
|
|
|
Self::render_list_widget("Artists", state.list, state.active, area.list, frame);
|
|
|
|
}
|
|
|
|
|
|
|
|
fn render_album_column<B: Backend>(
|
|
|
|
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<B: Backend>(
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
2023-04-13 15:29:14 +02:00
|
|
|
pub fn render<B: Backend>(&self, app: &APP, frame: &mut Frame<'_, B>) {
|
2023-04-13 14:09:59 +02:00
|
|
|
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,
|
2023-04-13 15:29:14 +02:00
|
|
|
tui::{
|
|
|
|
app::App,
|
|
|
|
tests::{app, terminal},
|
|
|
|
},
|
2023-04-13 14:09:59 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
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();
|
|
|
|
}
|
|
|
|
}
|