musichoard/src/tui/ui.rs

370 lines
9.5 KiB
Rust
Raw Normal View History

use std::marker::PhantomData;
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<APP> {
_phantom: PhantomData<APP>,
}
impl<APP: App> Ui<APP> {
pub fn new() -> Self {
Ui {
_phantom: PhantomData,
}
}
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::<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,
}
}
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::<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,
}
}
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::<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,
}
}
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<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);
}
pub fn render<B: Backend>(&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::{
app::App,
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();
}
}