Reverse renderer/ui relationship
All checks were successful
Cargo CI / Build and Test (pull_request) Successful in 2m30s
Cargo CI / Lint (pull_request) Successful in 44s

This commit is contained in:
Wojciech Kozlowski 2024-01-31 20:37:18 +01:00
parent 7701ce093d
commit c398cb5baa
7 changed files with 517 additions and 532 deletions

View File

@ -716,25 +716,29 @@ mod tests {
let mut expected = FULL_COLLECTION.to_owned(); let mut expected = FULL_COLLECTION.to_owned();
expected.sort_unstable(); expected.sort_unstable();
let mut mh = MusicHoard::default(); let mut mh = MusicHoard {
mh.library_cache = left collection: right.clone(),
library_cache: left
.clone() .clone()
.into_iter() .into_iter()
.map(|a| (a.id.clone(), a)) .map(|a| (a.id.clone(), a))
.collect(); .collect(),
mh.collection = right.clone(); ..Default::default()
};
mh.merge_collections(); mh.merge_collections();
assert_eq!(expected, mh.collection); assert_eq!(expected, mh.collection);
// The merge is completely non-overlapping so it should be commutative. // The merge is completely non-overlapping so it should be commutative.
let mut mh = MusicHoard::default(); let mut mh = MusicHoard {
mh.library_cache = right collection: left.clone(),
library_cache: right
.clone() .clone()
.into_iter() .into_iter()
.map(|a| (a.id.clone(), a)) .map(|a| (a.id.clone(), a))
.collect(); .collect(),
mh.collection = left.clone(); ..Default::default()
};
mh.merge_collections(); mh.merge_collections();
assert_eq!(expected, mh.collection); assert_eq!(expected, mh.collection);
@ -750,25 +754,29 @@ mod tests {
let mut expected = FULL_COLLECTION.to_owned(); let mut expected = FULL_COLLECTION.to_owned();
expected.sort_unstable(); expected.sort_unstable();
let mut mh = MusicHoard::default(); let mut mh = MusicHoard {
mh.library_cache = left collection: right.clone(),
library_cache: left
.clone() .clone()
.into_iter() .into_iter()
.map(|a| (a.id.clone(), a)) .map(|a| (a.id.clone(), a))
.collect(); .collect(),
mh.collection = right.clone(); ..Default::default()
};
mh.merge_collections(); mh.merge_collections();
assert_eq!(expected, mh.collection); assert_eq!(expected, mh.collection);
// The merge does not overwrite any data so it should be commutative. // The merge does not overwrite any data so it should be commutative.
let mut mh = MusicHoard::default(); let mut mh = MusicHoard {
mh.library_cache = right collection: left.clone(),
library_cache: right
.clone() .clone()
.into_iter() .into_iter()
.map(|a| (a.id.clone(), a)) .map(|a| (a.id.clone(), a))
.collect(); .collect(),
mh.collection = left.clone(); ..Default::default()
};
mh.merge_collections(); mh.merge_collections();
assert_eq!(expected, mh.collection); assert_eq!(expected, mh.collection);
@ -797,25 +805,29 @@ mod tests {
expected.last_mut().as_mut().unwrap().sort = artist_sort.clone(); expected.last_mut().as_mut().unwrap().sort = artist_sort.clone();
expected.rotate_right(1); expected.rotate_right(1);
let mut mh = MusicHoard::default(); let mut mh = MusicHoard {
mh.library_cache = left collection: right.clone(),
library_cache: left
.clone() .clone()
.into_iter() .into_iter()
.map(|a| (a.id.clone(), a)) .map(|a| (a.id.clone(), a))
.collect(); .collect(),
mh.collection = right.clone(); ..Default::default()
};
mh.merge_collections(); mh.merge_collections();
assert_eq!(expected, mh.collection); assert_eq!(expected, mh.collection);
// The merge overwrites the sort data, but no data is erased so it should be commutative. // The merge overwrites the sort data, but no data is erased so it should be commutative.
let mut mh = MusicHoard::default(); let mut mh = MusicHoard {
mh.library_cache = right collection: left.clone(),
library_cache: right
.clone() .clone()
.into_iter() .into_iter()
.map(|a| (a.id.clone(), a)) .map(|a| (a.id.clone(), a))
.collect(); .collect(),
mh.collection = left.clone(); ..Default::default()
};
mh.merge_collections(); mh.merge_collections();
assert_eq!(expected, mh.collection); assert_eq!(expected, mh.collection);

View File

@ -20,13 +20,7 @@ use musichoard::{
MusicHoardBuilder, NoDatabase, NoLibrary, MusicHoardBuilder, NoDatabase, NoLibrary,
}; };
use tui::{ use tui::{App, EventChannel, EventHandler, EventListener, Tui, Ui};
event::EventChannel,
handler::EventHandler,
listener::EventListener,
ui::{render::Renderer, Ui},
Tui,
};
#[derive(StructOpt)] #[derive(StructOpt)]
struct Opt { struct Opt {
@ -73,11 +67,11 @@ fn with<LIB: ILibrary, DB: IDatabase>(builder: MusicHoardBuilder<LIB, DB>) {
let listener = EventListener::new(channel.sender()); let listener = EventListener::new(channel.sender());
let handler = EventHandler::new(channel.receiver()); let handler = EventHandler::new(channel.receiver());
let ui = Ui::new(music_hoard).expect("failed to initialise ui"); let app = App::new(music_hoard).expect("failed to initialise application");
let renderer = Renderer; let ui = Ui;
// Run the TUI application. // Run the TUI application.
Tui::run(terminal, ui, renderer, handler, listener).expect("failed to run tui"); Tui::run(terminal, app, ui, handler, listener).expect("failed to run tui");
} }
fn with_database<LIB: ILibrary>(db_opt: DbOpt, builder: MusicHoardBuilder<LIB, NoDatabase>) { fn with_database<LIB: ILibrary>(db_opt: DbOpt, builder: MusicHoardBuilder<LIB, NoDatabase>) {

View File

@ -1,21 +1,16 @@
pub mod render;
use std::fmt; use std::fmt;
use musichoard::collection::{album::Album, artist::Artist, track::Track, Collection}; use musichoard::collection::{album::Album, artist::Artist, track::Track, Collection};
use ratatui::{backend::Backend, widgets::ListState, Frame}; use ratatui::widgets::ListState;
use crate::tui::{ use crate::tui::{lib::IMusicHoard, Error};
ui::render::IRender,
{lib::IMusicHoard, Error},
};
#[derive(Debug)] #[derive(Debug)]
pub enum UiError { pub enum AppError {
Lib(String), Lib(String),
} }
impl fmt::Display for UiError { impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self { match *self {
Self::Lib(ref s) => write!(f, "the musichoard library returned an error: {s}"), Self::Lib(ref s) => write!(f, "the musichoard library returned an error: {s}"),
@ -23,54 +18,52 @@ impl fmt::Display for UiError {
} }
} }
impl From<musichoard::Error> for UiError { impl From<musichoard::Error> for AppError {
fn from(err: musichoard::Error) -> UiError { fn from(err: musichoard::Error) -> AppError {
UiError::Lib(err.to_string()) AppError::Lib(err.to_string())
} }
} }
pub enum UiState<BS, IS, RS, ES> { pub enum AppState<BS, IS, RS, ES> {
Browse(BS), Browse(BS),
Info(IS), Info(IS),
Reload(RS), Reload(RS),
Error(ES), Error(ES),
} }
impl<BS, IS, RS, ES> UiState<BS, IS, RS, ES> { impl<BS, IS, RS, ES> AppState<BS, IS, RS, ES> {
fn is_browse(&self) -> bool { fn is_browse(&self) -> bool {
matches!(self, UiState::Browse(_)) matches!(self, AppState::Browse(_))
} }
fn is_info(&self) -> bool { fn is_info(&self) -> bool {
matches!(self, UiState::Info(_)) matches!(self, AppState::Info(_))
} }
fn is_reload(&self) -> bool { fn is_reload(&self) -> bool {
matches!(self, UiState::Reload(_)) matches!(self, AppState::Reload(_))
} }
fn is_error(&self) -> bool { fn is_error(&self) -> bool {
matches!(self, UiState::Error(_)) matches!(self, AppState::Error(_))
} }
} }
pub trait IUi { pub trait IAppInteract {
type BS: IUiBrowse; type BS: IAppInteractBrowse;
type IS: IUiInfo; type IS: IAppInteractInfo;
type RS: IUiReload; type RS: IAppInteractReload;
type ES: IUiError; type ES: IAppInteractError;
fn is_running(&self) -> bool; fn is_running(&self) -> bool;
fn quit(&mut self); fn quit(&mut self);
fn save(&mut self) -> Result<(), UiError>; fn save(&mut self) -> Result<(), AppError>;
fn state(&mut self) -> UiState<&mut Self::BS, &mut Self::IS, &mut Self::RS, &mut Self::ES>; fn state(&mut self) -> AppState<&mut Self::BS, &mut Self::IS, &mut Self::RS, &mut Self::ES>;
fn render<R: IRender, B: Backend>(&mut self, renderer: &R, frame: &mut Frame<'_, B>);
} }
pub trait IUiBrowse { pub trait IAppInteractBrowse {
fn increment_category(&mut self); fn increment_category(&mut self);
fn decrement_category(&mut self); fn decrement_category(&mut self);
fn increment_selection(&mut self); fn increment_selection(&mut self);
@ -81,109 +74,46 @@ pub trait IUiBrowse {
fn show_reload_menu(&mut self); fn show_reload_menu(&mut self);
} }
pub trait IUiInfo { pub trait IAppInteractInfo {
fn hide_info_overlay(&mut self); fn hide_info_overlay(&mut self);
} }
pub trait IUiReload { pub trait IAppInteractReload {
fn reload_library(&mut self); fn reload_library(&mut self);
fn reload_database(&mut self); fn reload_database(&mut self);
fn go_back(&mut self); fn go_back(&mut self);
} }
pub trait IUiError { pub trait IAppInteractError {
fn dismiss_error(&mut self); fn dismiss_error(&mut self);
} }
struct TrackSelection { // It would be preferable to have a getter for each field separately. However, the selection field
state: ListState, // needs to be mutably accessible requiring a mutable borrow of the entire struct if behind a trait.
// This in turn complicates simultaneous field access since only a single mutable borrow is allowed.
// Therefore, all fields are grouped into a single struct and returned as a batch.
pub trait IAppAccess {
fn get(&mut self) -> AppPublic;
} }
struct AlbumSelection { pub struct AppPublic<'app> {
state: ListState, pub collection: &'app Collection,
track: TrackSelection, pub selection: &'app mut Selection,
pub state: &'app AppState<(), (), (), String>,
} }
struct ArtistSelection { pub struct ArtistSelection {
state: ListState, pub state: ListState,
album: AlbumSelection, pub album: AlbumSelection,
} }
impl TrackSelection { pub struct AlbumSelection {
fn initialise(tracks: Option<&[Track]>) -> Self { pub state: ListState,
let mut state = ListState::default(); pub track: TrackSelection,
if let Some(tracks) = tracks {
state.select(if !tracks.is_empty() { Some(0) } else { None });
} else {
state.select(None);
};
TrackSelection { state }
}
fn increment(&mut self, tracks: &[Track]) {
if let Some(index) = self.state.selected() {
if let Some(result) = index.checked_add(1) {
if result < tracks.len() {
self.state.select(Some(result));
}
}
}
}
fn decrement(&mut self, _tracks: &[Track]) {
if let Some(index) = self.state.selected() {
if let Some(result) = index.checked_sub(1) {
self.state.select(Some(result));
}
}
}
} }
impl AlbumSelection { pub struct TrackSelection {
fn initialise(albums: Option<&[Album]>) -> Self { pub state: ListState,
let mut state = ListState::default();
let track: TrackSelection;
if let Some(albums) = albums {
state.select(if !albums.is_empty() { Some(0) } else { None });
track = TrackSelection::initialise(albums.first().map(|a| a.tracks.as_slice()));
} else {
state.select(None);
track = TrackSelection::initialise(None);
}
AlbumSelection { state, track }
}
fn increment(&mut self, albums: &[Album]) {
if let Some(index) = self.state.selected() {
if let Some(result) = index.checked_add(1) {
if result < albums.len() {
self.state.select(Some(result));
self.track = TrackSelection::initialise(Some(&albums[result].tracks));
}
}
}
}
fn increment_track(&mut self, albums: &[Album]) {
if let Some(index) = self.state.selected() {
self.track.increment(&albums[index].tracks);
}
}
fn decrement(&mut self, albums: &[Album]) {
if let Some(index) = self.state.selected() {
if let Some(result) = index.checked_sub(1) {
self.state.select(Some(result));
self.track = TrackSelection::initialise(Some(&albums[result].tracks));
}
}
}
fn decrement_track(&mut self, albums: &[Album]) {
if let Some(index) = self.state.selected() {
self.track.decrement(&albums[index].tracks);
}
}
} }
impl ArtistSelection { impl ArtistSelection {
@ -245,6 +175,83 @@ impl ArtistSelection {
} }
} }
impl AlbumSelection {
fn initialise(albums: Option<&[Album]>) -> Self {
let mut state = ListState::default();
let track: TrackSelection;
if let Some(albums) = albums {
state.select(if !albums.is_empty() { Some(0) } else { None });
track = TrackSelection::initialise(albums.first().map(|a| a.tracks.as_slice()));
} else {
state.select(None);
track = TrackSelection::initialise(None);
}
AlbumSelection { state, track }
}
fn increment(&mut self, albums: &[Album]) {
if let Some(index) = self.state.selected() {
if let Some(result) = index.checked_add(1) {
if result < albums.len() {
self.state.select(Some(result));
self.track = TrackSelection::initialise(Some(&albums[result].tracks));
}
}
}
}
fn increment_track(&mut self, albums: &[Album]) {
if let Some(index) = self.state.selected() {
self.track.increment(&albums[index].tracks);
}
}
fn decrement(&mut self, albums: &[Album]) {
if let Some(index) = self.state.selected() {
if let Some(result) = index.checked_sub(1) {
self.state.select(Some(result));
self.track = TrackSelection::initialise(Some(&albums[result].tracks));
}
}
}
fn decrement_track(&mut self, albums: &[Album]) {
if let Some(index) = self.state.selected() {
self.track.decrement(&albums[index].tracks);
}
}
}
impl TrackSelection {
fn initialise(tracks: Option<&[Track]>) -> Self {
let mut state = ListState::default();
if let Some(tracks) = tracks {
state.select(if !tracks.is_empty() { Some(0) } else { None });
} else {
state.select(None);
};
TrackSelection { state }
}
fn increment(&mut self, tracks: &[Track]) {
if let Some(index) = self.state.selected() {
if let Some(result) = index.checked_add(1) {
if result < tracks.len() {
self.state.select(Some(result));
}
}
}
}
fn decrement(&mut self, _tracks: &[Track]) {
if let Some(index) = self.state.selected() {
if let Some(result) = index.checked_sub(1) {
self.state.select(Some(result));
}
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)] #[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Category { pub enum Category {
Artist, Artist,
@ -253,19 +260,19 @@ pub enum Category {
} }
pub struct Selection { pub struct Selection {
active: Category, pub active: Category,
artist: ArtistSelection, pub artist: ArtistSelection,
} }
impl Selection { impl Selection {
fn new(artists: Option<&[Artist]>) -> Self { pub fn new(artists: Option<&[Artist]>) -> Self {
Selection { Selection {
active: Category::Artist, active: Category::Artist,
artist: ArtistSelection::initialise(artists), artist: ArtistSelection::initialise(artists),
} }
} }
fn increment_category(&mut self) { pub fn increment_category(&mut self) {
self.active = match self.active { self.active = match self.active {
Category::Artist => Category::Album, Category::Artist => Category::Album,
Category::Album => Category::Track, Category::Album => Category::Track,
@ -273,7 +280,7 @@ impl Selection {
}; };
} }
fn decrement_category(&mut self) { pub fn decrement_category(&mut self) {
self.active = match self.active { self.active = match self.active {
Category::Artist => Category::Artist, Category::Artist => Category::Artist,
Category::Album => Category::Artist, Category::Album => Category::Artist,
@ -281,7 +288,7 @@ impl Selection {
}; };
} }
fn increment_selection(&mut self, collection: &Collection) { pub fn increment_selection(&mut self, collection: &Collection) {
match self.active { match self.active {
Category::Artist => self.increment_artist(collection), Category::Artist => self.increment_artist(collection),
Category::Album => self.increment_album(collection), Category::Album => self.increment_album(collection),
@ -289,7 +296,7 @@ impl Selection {
} }
} }
fn decrement_selection(&mut self, collection: &Collection) { pub fn decrement_selection(&mut self, collection: &Collection) {
match self.active { match self.active {
Category::Artist => self.decrement_artist(collection), Category::Artist => self.decrement_artist(collection),
Category::Album => self.decrement_album(collection), Category::Album => self.decrement_album(collection),
@ -322,29 +329,29 @@ impl Selection {
} }
} }
pub struct Ui<MH: IMusicHoard> { pub struct App<MH: IMusicHoard> {
running: bool, running: bool,
music_hoard: MH, music_hoard: MH,
selection: Selection, selection: Selection,
state: UiState<(), (), (), String>, state: AppState<(), (), (), String>,
} }
impl<MH: IMusicHoard> Ui<MH> { impl<MH: IMusicHoard> App<MH> {
pub fn new(mut music_hoard: MH) -> Result<Self, Error> { pub fn new(mut music_hoard: MH) -> Result<Self, Error> {
// FIXME: if either returns an error start in an error state // FIXME: if either returns an error start in an error state
music_hoard.load_from_database()?; music_hoard.load_from_database()?;
music_hoard.rescan_library()?; music_hoard.rescan_library()?;
let selection = Selection::new(Some(music_hoard.get_collection())); let selection = Selection::new(Some(music_hoard.get_collection()));
Ok(Ui { Ok(App {
running: true, running: true,
music_hoard, music_hoard,
selection, selection,
state: UiState::Browse(()), state: AppState::Browse(()),
}) })
} }
} }
impl<MH: IMusicHoard> IUi for Ui<MH> { impl<MH: IMusicHoard> IAppInteract for App<MH> {
type BS = Self; type BS = Self;
type IS = Self; type IS = Self;
type RS = Self; type RS = Self;
@ -358,37 +365,21 @@ impl<MH: IMusicHoard> IUi for Ui<MH> {
self.running = false; self.running = false;
} }
fn save(&mut self) -> Result<(), UiError> { fn save(&mut self) -> Result<(), AppError> {
Ok(self.music_hoard.save_to_database()?) Ok(self.music_hoard.save_to_database()?)
} }
fn state(&mut self) -> UiState<&mut Self::BS, &mut Self::IS, &mut Self::RS, &mut Self::ES> { fn state(&mut self) -> AppState<&mut Self::BS, &mut Self::IS, &mut Self::RS, &mut Self::ES> {
match self.state { match self.state {
UiState::Browse(_) => UiState::Browse(self), AppState::Browse(_) => AppState::Browse(self),
UiState::Info(_) => UiState::Info(self), AppState::Info(_) => AppState::Info(self),
UiState::Reload(_) => UiState::Reload(self), AppState::Reload(_) => AppState::Reload(self),
UiState::Error(_) => UiState::Error(self), AppState::Error(_) => AppState::Error(self),
}
}
// 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;
R::render_collection(collection, selection, frame);
match self.state {
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),
_ => {}
} }
} }
} }
impl<MH: IMusicHoard> IUiBrowse for Ui<MH> { impl<MH: IMusicHoard> IAppInteractBrowse for App<MH> {
fn increment_category(&mut self) { fn increment_category(&mut self) {
self.selection.increment_category(); self.selection.increment_category();
} }
@ -409,23 +400,23 @@ impl<MH: IMusicHoard> IUiBrowse for Ui<MH> {
fn show_info_overlay(&mut self) { fn show_info_overlay(&mut self) {
assert!(self.state.is_browse()); assert!(self.state.is_browse());
self.state = UiState::Info(()); self.state = AppState::Info(());
} }
fn show_reload_menu(&mut self) { fn show_reload_menu(&mut self) {
assert!(self.state.is_browse()); assert!(self.state.is_browse());
self.state = UiState::Reload(()); self.state = AppState::Reload(());
} }
} }
impl<MH: IMusicHoard> IUiInfo for Ui<MH> { impl<MH: IMusicHoard> IAppInteractInfo for App<MH> {
fn hide_info_overlay(&mut self) { fn hide_info_overlay(&mut self) {
assert!(self.state.is_info()); assert!(self.state.is_info());
self.state = UiState::Browse(()); self.state = AppState::Browse(());
} }
} }
impl<MH: IMusicHoard> IUiReload for Ui<MH> { impl<MH: IMusicHoard> IAppInteractReload for App<MH> {
fn reload_library(&mut self) { fn reload_library(&mut self) {
let result = self.music_hoard.rescan_library(); let result = self.music_hoard.rescan_library();
self.refresh(result); self.refresh(result);
@ -438,44 +429,51 @@ impl<MH: IMusicHoard> IUiReload for Ui<MH> {
fn go_back(&mut self) { fn go_back(&mut self) {
assert!(self.state.is_reload()); assert!(self.state.is_reload());
self.state = UiState::Browse(()); self.state = AppState::Browse(());
} }
} }
trait IUiReloadPrivate { trait IAppInteractReloadPrivate {
fn refresh(&mut self, result: Result<(), musichoard::Error>); fn refresh(&mut self, result: Result<(), musichoard::Error>);
} }
impl<MH: IMusicHoard> IUiReloadPrivate for Ui<MH> { impl<MH: IMusicHoard> IAppInteractReloadPrivate for App<MH> {
fn refresh(&mut self, result: Result<(), musichoard::Error>) { fn refresh(&mut self, result: Result<(), musichoard::Error>) {
assert!(self.state.is_reload()); assert!(self.state.is_reload());
match result { match result {
Ok(()) => { Ok(()) => {
self.selection = Selection::new(Some(self.music_hoard.get_collection())); self.selection = Selection::new(Some(self.music_hoard.get_collection()));
self.state = UiState::Browse(()) self.state = AppState::Browse(())
} }
Err(err) => self.state = UiState::Error(err.to_string()), Err(err) => self.state = AppState::Error(err.to_string()),
} }
} }
} }
impl<MH: IMusicHoard> IUiError for Ui<MH> { impl<MH: IMusicHoard> IAppInteractError for App<MH> {
fn dismiss_error(&mut self) { fn dismiss_error(&mut self) {
assert!(self.state.is_error()); assert!(self.state.is_error());
self.state = UiState::Browse(()); self.state = AppState::Browse(());
}
}
impl<MH: IMusicHoard> IAppAccess for App<MH> {
fn get(&mut self) -> AppPublic {
AppPublic {
collection: self.music_hoard.get_collection(),
selection: &mut self.selection,
state: &self.state,
}
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::tui::lib::MockIMusicHoard; use crate::tui::{lib::MockIMusicHoard, testmod::COLLECTION};
use crate::tui::testmod::COLLECTION;
use crate::tui::tests::{terminal, ui};
use crate::tui::ui::render::Renderer;
use super::*; use super::*;
pub fn music_hoard(collection: Collection) -> MockIMusicHoard { fn music_hoard(collection: Collection) -> MockIMusicHoard {
let mut music_hoard = MockIMusicHoard::new(); let mut music_hoard = MockIMusicHoard::new();
music_hoard music_hoard
@ -645,11 +643,11 @@ mod tests {
#[test] #[test]
fn running() { fn running() {
let mut ui = Ui::new(music_hoard(COLLECTION.to_owned())).unwrap(); let mut app = App::new(music_hoard(COLLECTION.to_owned())).unwrap();
assert!(ui.is_running()); assert!(app.is_running());
ui.quit(); app.quit();
assert!(!ui.is_running()); assert!(!app.is_running());
} }
#[test] #[test]
@ -661,99 +659,99 @@ mod tests {
.times(1) .times(1)
.return_once(|| Ok(())); .return_once(|| Ok(()));
let mut ui = Ui::new(music_hoard).unwrap(); let mut app = App::new(music_hoard).unwrap();
let result = ui.save(); let result = app.save();
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[test] #[test]
fn modifiers() { fn modifiers() {
let mut ui = Ui::new(music_hoard(COLLECTION.to_owned())).unwrap(); let mut app = App::new(music_hoard(COLLECTION.to_owned())).unwrap();
assert!(ui.is_running()); assert!(app.is_running());
assert_eq!(ui.selection.active, Category::Artist); assert_eq!(app.selection.active, Category::Artist);
assert_eq!(ui.selection.artist.state.selected(), Some(0)); assert_eq!(app.selection.artist.state.selected(), Some(0));
assert_eq!(ui.selection.artist.album.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.state.selected(), Some(0));
assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.track.state.selected(), Some(0));
ui.increment_selection(); app.increment_selection();
assert_eq!(ui.selection.active, Category::Artist); assert_eq!(app.selection.active, Category::Artist);
assert_eq!(ui.selection.artist.state.selected(), Some(1)); assert_eq!(app.selection.artist.state.selected(), Some(1));
assert_eq!(ui.selection.artist.album.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.state.selected(), Some(0));
assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.track.state.selected(), Some(0));
ui.increment_category(); app.increment_category();
assert_eq!(ui.selection.active, Category::Album); assert_eq!(app.selection.active, Category::Album);
assert_eq!(ui.selection.artist.state.selected(), Some(1)); assert_eq!(app.selection.artist.state.selected(), Some(1));
assert_eq!(ui.selection.artist.album.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.state.selected(), Some(0));
assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.track.state.selected(), Some(0));
ui.increment_selection(); app.increment_selection();
assert_eq!(ui.selection.active, Category::Album); assert_eq!(app.selection.active, Category::Album);
assert_eq!(ui.selection.artist.state.selected(), Some(1)); assert_eq!(app.selection.artist.state.selected(), Some(1));
assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); assert_eq!(app.selection.artist.album.state.selected(), Some(1));
assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.track.state.selected(), Some(0));
ui.increment_category(); app.increment_category();
assert_eq!(ui.selection.active, Category::Track); assert_eq!(app.selection.active, Category::Track);
assert_eq!(ui.selection.artist.state.selected(), Some(1)); assert_eq!(app.selection.artist.state.selected(), Some(1));
assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); assert_eq!(app.selection.artist.album.state.selected(), Some(1));
assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.track.state.selected(), Some(0));
ui.increment_selection(); app.increment_selection();
assert_eq!(ui.selection.active, Category::Track); assert_eq!(app.selection.active, Category::Track);
assert_eq!(ui.selection.artist.state.selected(), Some(1)); assert_eq!(app.selection.artist.state.selected(), Some(1));
assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); assert_eq!(app.selection.artist.album.state.selected(), Some(1));
assert_eq!(ui.selection.artist.album.track.state.selected(), Some(1)); assert_eq!(app.selection.artist.album.track.state.selected(), Some(1));
ui.increment_category(); app.increment_category();
assert_eq!(ui.selection.active, Category::Track); assert_eq!(app.selection.active, Category::Track);
assert_eq!(ui.selection.artist.state.selected(), Some(1)); assert_eq!(app.selection.artist.state.selected(), Some(1));
assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); assert_eq!(app.selection.artist.album.state.selected(), Some(1));
assert_eq!(ui.selection.artist.album.track.state.selected(), Some(1)); assert_eq!(app.selection.artist.album.track.state.selected(), Some(1));
ui.decrement_selection(); app.decrement_selection();
assert_eq!(ui.selection.active, Category::Track); assert_eq!(app.selection.active, Category::Track);
assert_eq!(ui.selection.artist.state.selected(), Some(1)); assert_eq!(app.selection.artist.state.selected(), Some(1));
assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); assert_eq!(app.selection.artist.album.state.selected(), Some(1));
assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.track.state.selected(), Some(0));
ui.increment_selection(); app.increment_selection();
ui.decrement_category(); app.decrement_category();
assert_eq!(ui.selection.active, Category::Album); assert_eq!(app.selection.active, Category::Album);
assert_eq!(ui.selection.artist.state.selected(), Some(1)); assert_eq!(app.selection.artist.state.selected(), Some(1));
assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); assert_eq!(app.selection.artist.album.state.selected(), Some(1));
assert_eq!(ui.selection.artist.album.track.state.selected(), Some(1)); assert_eq!(app.selection.artist.album.track.state.selected(), Some(1));
ui.decrement_selection(); app.decrement_selection();
assert_eq!(ui.selection.active, Category::Album); assert_eq!(app.selection.active, Category::Album);
assert_eq!(ui.selection.artist.state.selected(), Some(1)); assert_eq!(app.selection.artist.state.selected(), Some(1));
assert_eq!(ui.selection.artist.album.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.state.selected(), Some(0));
assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.track.state.selected(), Some(0));
ui.increment_selection(); app.increment_selection();
ui.decrement_category(); app.decrement_category();
assert_eq!(ui.selection.active, Category::Artist); assert_eq!(app.selection.active, Category::Artist);
assert_eq!(ui.selection.artist.state.selected(), Some(1)); assert_eq!(app.selection.artist.state.selected(), Some(1));
assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); assert_eq!(app.selection.artist.album.state.selected(), Some(1));
assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.track.state.selected(), Some(0));
ui.decrement_selection(); app.decrement_selection();
assert_eq!(ui.selection.active, Category::Artist); assert_eq!(app.selection.active, Category::Artist);
assert_eq!(ui.selection.artist.state.selected(), Some(0)); assert_eq!(app.selection.artist.state.selected(), Some(0));
assert_eq!(ui.selection.artist.album.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.state.selected(), Some(0));
assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.track.state.selected(), Some(0));
ui.increment_category(); app.increment_category();
ui.increment_selection(); app.increment_selection();
ui.decrement_category(); app.decrement_category();
ui.decrement_selection(); app.decrement_selection();
ui.decrement_category(); app.decrement_category();
assert_eq!(ui.selection.active, Category::Artist); assert_eq!(app.selection.active, Category::Artist);
assert_eq!(ui.selection.artist.state.selected(), Some(0)); assert_eq!(app.selection.artist.state.selected(), Some(0));
assert_eq!(ui.selection.artist.album.state.selected(), Some(1)); assert_eq!(app.selection.artist.album.state.selected(), Some(1));
assert_eq!(ui.selection.artist.album.track.state.selected(), Some(0)); assert_eq!(app.selection.artist.album.track.state.selected(), Some(0));
} }
#[test] #[test]
@ -761,7 +759,7 @@ mod tests {
let mut collection = COLLECTION.to_owned(); let mut collection = COLLECTION.to_owned();
collection[0].albums[0].tracks = vec![]; collection[0].albums[0].tracks = vec![];
let mut app = Ui::new(music_hoard(collection)).unwrap(); let mut app = App::new(music_hoard(collection)).unwrap();
assert!(app.is_running()); assert!(app.is_running());
assert_eq!(app.selection.active, Category::Artist); assert_eq!(app.selection.active, Category::Artist);
@ -790,7 +788,7 @@ mod tests {
let mut collection = COLLECTION.to_owned(); let mut collection = COLLECTION.to_owned();
collection[0].albums = vec![]; collection[0].albums = vec![];
let mut app = Ui::new(music_hoard(collection)).unwrap(); let mut app = App::new(music_hoard(collection)).unwrap();
assert!(app.is_running()); assert!(app.is_running());
assert_eq!(app.selection.active, Category::Artist); assert_eq!(app.selection.active, Category::Artist);
@ -829,7 +827,7 @@ mod tests {
#[test] #[test]
fn no_artists() { fn no_artists() {
let mut app = Ui::new(music_hoard(vec![])).unwrap(); let mut app = App::new(music_hoard(vec![])).unwrap();
assert!(app.is_running()); assert!(app.is_running());
assert_eq!(app.selection.active, Category::Artist); assert_eq!(app.selection.active, Category::Artist);
@ -882,49 +880,30 @@ mod tests {
#[test] #[test]
fn info_overlay() { fn info_overlay() {
let mut terminal = terminal(); let mut app = App::new(music_hoard(COLLECTION.to_owned())).unwrap();
let renderer = Renderer; assert!(app.state().is_browse());
let mut ui = ui(COLLECTION.to_owned()); app.show_info_overlay();
assert!(ui.state().is_browse()); assert!(app.state().is_info());
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
ui.show_info_overlay(); app.hide_info_overlay();
assert!(ui.state().is_info()); assert!(app.state().is_browse());
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(&renderer, frame)).unwrap();
ui.hide_info_overlay();
assert!(ui.state().is_browse());
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
} }
#[test] #[test]
fn reload_go_back() { fn reload_go_back() {
let mut terminal = terminal(); let mut app = App::new(music_hoard(COLLECTION.to_owned())).unwrap();
let renderer = Renderer; assert!(app.state().is_browse());
let music_hoard = music_hoard(COLLECTION.to_owned());
let mut ui = Ui::new(music_hoard).unwrap(); app.show_reload_menu();
assert!(ui.state().is_browse()); assert!(app.state().is_reload());
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
ui.show_reload_menu(); app.go_back();
assert!(ui.state().is_reload()); assert!(app.state().is_browse());
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] #[test]
fn reload_database() { fn reload_database() {
let mut terminal = terminal();
let renderer = Renderer;
let mut music_hoard = music_hoard(COLLECTION.to_owned()); let mut music_hoard = music_hoard(COLLECTION.to_owned());
music_hoard music_hoard
@ -932,23 +911,18 @@ mod tests {
.times(1) .times(1)
.return_once(|| Ok(())); .return_once(|| Ok(()));
let mut ui = Ui::new(music_hoard).unwrap(); let mut app = App::new(music_hoard).unwrap();
assert!(ui.state().is_browse()); assert!(app.state().is_browse());
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
ui.show_reload_menu(); app.show_reload_menu();
assert!(ui.state().is_reload()); assert!(app.state().is_reload());
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
ui.reload_database(); app.reload_database();
assert!(ui.state().is_browse()); assert!(app.state().is_browse());
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
} }
#[test] #[test]
fn reload_library() { fn reload_library() {
let mut terminal = terminal();
let renderer = Renderer;
let mut music_hoard = music_hoard(COLLECTION.to_owned()); let mut music_hoard = music_hoard(COLLECTION.to_owned());
music_hoard music_hoard
@ -956,23 +930,18 @@ mod tests {
.times(1) .times(1)
.return_once(|| Ok(())); .return_once(|| Ok(()));
let mut ui = Ui::new(music_hoard).unwrap(); let mut app = App::new(music_hoard).unwrap();
assert!(ui.state().is_browse()); assert!(app.state().is_browse());
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
ui.show_reload_menu(); app.show_reload_menu();
assert!(ui.state().is_reload()); assert!(app.state().is_reload());
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
ui.reload_library(); app.reload_library();
assert!(ui.state().is_browse()); assert!(app.state().is_browse());
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
} }
#[test] #[test]
fn reload_error() { fn reload_error() {
let mut terminal = terminal();
let renderer = Renderer;
let mut music_hoard = music_hoard(COLLECTION.to_owned()); let mut music_hoard = music_hoard(COLLECTION.to_owned());
music_hoard music_hoard
@ -980,29 +949,25 @@ mod tests {
.times(1) .times(1)
.return_once(|| Err(musichoard::Error::DatabaseError(String::from("get rekt")))); .return_once(|| Err(musichoard::Error::DatabaseError(String::from("get rekt"))));
let mut ui = Ui::new(music_hoard).unwrap(); let mut app = App::new(music_hoard).unwrap();
assert!(ui.state().is_browse()); assert!(app.state().is_browse());
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
ui.show_reload_menu(); app.show_reload_menu();
assert!(ui.state().is_reload()); assert!(app.state().is_reload());
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
ui.reload_database(); app.reload_database();
assert!(ui.state().is_error()); assert!(app.state().is_error());
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
ui.dismiss_error(); app.dismiss_error();
assert!(ui.state().is_browse()); assert!(app.state().is_browse());
terminal.draw(|frame| ui.render(&renderer, frame)).unwrap();
} }
#[test] #[test]
fn errors() { fn errors() {
let ui_err: UiError = musichoard::Error::DatabaseError(String::from("get rekt")).into(); let app_err: AppError = musichoard::Error::DatabaseError(String::from("get rekt")).into();
assert!(!ui_err.to_string().is_empty()); assert!(!app_err.to_string().is_empty());
assert!(!format!("{:?}", ui_err).is_empty()); assert!(!format!("{:?}", app_err).is_empty());
} }
} }

View File

@ -2,14 +2,14 @@ use crossterm::event::{KeyEvent, MouseEvent};
use std::fmt; use std::fmt;
use std::sync::mpsc; use std::sync::mpsc;
use crate::tui::ui::UiError; use crate::tui::app::AppError;
#[derive(Debug)] #[derive(Debug)]
pub enum EventError { pub enum EventError {
Send(Event), Send(Event),
Recv, Recv,
Io(std::io::Error), Io(std::io::Error),
Ui(String), App(String),
} }
impl fmt::Display for EventError { impl fmt::Display for EventError {
@ -20,8 +20,11 @@ impl fmt::Display for EventError {
Self::Io(ref e) => { Self::Io(ref e) => {
write!(f, "an I/O error was triggered during event handling: {e}") write!(f, "an I/O error was triggered during event handling: {e}")
} }
Self::Ui(ref s) => { Self::App(ref s) => {
write!(f, "the UI returned an error during event handling: {s}") write!(
f,
"the application returned an error during event handling: {s}"
)
} }
} }
} }
@ -39,9 +42,9 @@ impl From<mpsc::RecvError> for EventError {
} }
} }
impl From<UiError> for EventError { impl From<AppError> for EventError {
fn from(err: UiError) -> EventError { fn from(err: AppError) -> EventError {
EventError::Ui(err.to_string()) EventError::App(err.to_string())
} }
} }
@ -102,8 +105,6 @@ mod tests {
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use crate::tui::ui::UiError;
use super::*; use super::*;
#[test] #[test]
@ -147,16 +148,16 @@ mod tests {
})); }));
let recv_err = EventError::Recv; let recv_err = EventError::Recv;
let io_err = EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "interrupted")); let io_err = EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "interrupted"));
let ui_err: EventError = UiError::Lib(String::from("lib error")).into(); let app_err: EventError = AppError::Lib(String::from("lib error")).into();
assert!(!send_err.to_string().is_empty()); assert!(!send_err.to_string().is_empty());
assert!(!recv_err.to_string().is_empty()); assert!(!recv_err.to_string().is_empty());
assert!(!io_err.to_string().is_empty()); assert!(!io_err.to_string().is_empty());
assert!(!ui_err.to_string().is_empty()); assert!(!app_err.to_string().is_empty());
assert!(!format!("{:?}", send_err).is_empty()); assert!(!format!("{:?}", send_err).is_empty());
assert!(!format!("{:?}", recv_err).is_empty()); assert!(!format!("{:?}", recv_err).is_empty());
assert!(!format!("{:?}", io_err).is_empty()); assert!(!format!("{:?}", io_err).is_empty());
assert!(!format!("{:?}", ui_err).is_empty()); assert!(!format!("{:?}", app_err).is_empty());
} }
} }

View File

@ -1,39 +1,41 @@
// FIXME: Can code here be less verbose
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[cfg(test)] #[cfg(test)]
use mockall::automock; use mockall::automock;
use crate::tui::{ use crate::tui::{
app::{
AppState, IAppInteract, IAppInteractBrowse, IAppInteractError, IAppInteractInfo,
IAppInteractReload,
},
event::{Event, EventError, EventReceiver}, event::{Event, EventError, EventReceiver},
ui::{IUi, IUiBrowse, IUiError, IUiInfo, UiState},
}; };
use super::ui::IUiReload;
#[cfg_attr(test, automock)] #[cfg_attr(test, automock)]
pub trait IEventHandler<UI: IUi> { pub trait IEventHandler<APP: IAppInteract> {
fn handle_next_event(&self, ui: &mut UI) -> Result<(), EventError>; fn handle_next_event(&self, app: &mut APP) -> Result<(), EventError>;
} }
trait IEventHandlerPrivate<UI: IUi> { trait IEventHandlerPrivate<APP: IAppInteract> {
fn handle_key_event(ui: &mut UI, key_event: KeyEvent) -> Result<(), EventError>; fn handle_key_event(app: &mut APP, key_event: KeyEvent) -> Result<(), EventError>;
fn handle_browse_key_event( fn handle_browse_key_event(
ui: &mut <UI as IUi>::BS, app: &mut <APP as IAppInteract>::BS,
key_event: KeyEvent, key_event: KeyEvent,
) -> Result<(), EventError>; ) -> Result<(), EventError>;
fn handle_info_key_event( fn handle_info_key_event(
ui: &mut <UI as IUi>::IS, app: &mut <APP as IAppInteract>::IS,
key_event: KeyEvent, key_event: KeyEvent,
) -> Result<(), EventError>; ) -> Result<(), EventError>;
fn handle_reload_key_event( fn handle_reload_key_event(
ui: &mut <UI as IUi>::RS, app: &mut <APP as IAppInteract>::RS,
key_event: KeyEvent, key_event: KeyEvent,
) -> Result<(), EventError>; ) -> Result<(), EventError>;
fn handle_error_key_event( fn handle_error_key_event(
ui: &mut <UI as IUi>::ES, app: &mut <APP as IAppInteract>::ES,
key_event: KeyEvent, key_event: KeyEvent,
) -> Result<(), EventError>; ) -> Result<(), EventError>;
fn quit(ui: &mut UI) -> Result<(), EventError>; fn quit(app: &mut APP) -> Result<(), EventError>;
} }
pub struct EventHandler { pub struct EventHandler {
@ -47,10 +49,10 @@ impl EventHandler {
} }
} }
impl<UI: IUi> IEventHandler<UI> for EventHandler { impl<APP: IAppInteract> IEventHandler<APP> for EventHandler {
fn handle_next_event(&self, ui: &mut UI) -> Result<(), EventError> { fn handle_next_event(&self, app: &mut APP) -> Result<(), EventError> {
match self.events.recv()? { match self.events.recv()? {
Event::Key(key_event) => Self::handle_key_event(ui, key_event)?, Event::Key(key_event) => Self::handle_key_event(app, key_event)?,
Event::Mouse(_) => {} Event::Mouse(_) => {}
Event::Resize(_, _) => {} Event::Resize(_, _) => {}
}; };
@ -58,31 +60,35 @@ impl<UI: IUi> IEventHandler<UI> for EventHandler {
} }
} }
impl<UI: IUi> IEventHandlerPrivate<UI> for EventHandler { impl<APP: IAppInteract> IEventHandlerPrivate<APP> for EventHandler {
fn handle_key_event(ui: &mut UI, key_event: KeyEvent) -> Result<(), EventError> { fn handle_key_event(app: &mut APP, key_event: KeyEvent) -> Result<(), EventError> {
match key_event.code { match key_event.code {
// Exit application on `ESC` or `q`. // Exit application on `ESC` or `q`.
KeyCode::Esc | KeyCode::Char('q') => { KeyCode::Esc | KeyCode::Char('q') => {
Self::quit(ui)?; Self::quit(app)?;
} }
// Exit application on `Ctrl-C`. // Exit application on `Ctrl-C`.
KeyCode::Char('c') | KeyCode::Char('C') => { KeyCode::Char('c') | KeyCode::Char('C') => {
if key_event.modifiers == KeyModifiers::CONTROL { if key_event.modifiers == KeyModifiers::CONTROL {
Self::quit(ui)?; Self::quit(app)?;
} }
} }
_ => match ui.state() { _ => match app.state() {
UiState::Browse(browse) => { AppState::Browse(browse) => {
<Self as IEventHandlerPrivate<UI>>::handle_browse_key_event(browse, key_event)?; <Self as IEventHandlerPrivate<APP>>::handle_browse_key_event(
browse, key_event,
)?;
} }
UiState::Info(info) => { AppState::Info(info) => {
<Self as IEventHandlerPrivate<UI>>::handle_info_key_event(info, key_event)?; <Self as IEventHandlerPrivate<APP>>::handle_info_key_event(info, key_event)?;
} }
UiState::Reload(reload) => { AppState::Reload(reload) => {
<Self as IEventHandlerPrivate<UI>>::handle_reload_key_event(reload, key_event)?; <Self as IEventHandlerPrivate<APP>>::handle_reload_key_event(
reload, key_event,
)?;
} }
UiState::Error(error) => { AppState::Error(error) => {
<Self as IEventHandlerPrivate<UI>>::handle_error_key_event(error, key_event)?; <Self as IEventHandlerPrivate<APP>>::handle_error_key_event(error, key_event)?;
} }
}, },
}; };
@ -90,20 +96,20 @@ impl<UI: IUi> IEventHandlerPrivate<UI> for EventHandler {
} }
fn handle_browse_key_event( fn handle_browse_key_event(
ui: &mut <UI as IUi>::BS, app: &mut <APP as IAppInteract>::BS,
key_event: KeyEvent, key_event: KeyEvent,
) -> Result<(), EventError> { ) -> Result<(), EventError> {
match key_event.code { match key_event.code {
// Category change. // Category change.
KeyCode::Left => ui.decrement_category(), KeyCode::Left => app.decrement_category(),
KeyCode::Right => ui.increment_category(), KeyCode::Right => app.increment_category(),
// Selection change. // Selection change.
KeyCode::Up => ui.decrement_selection(), KeyCode::Up => app.decrement_selection(),
KeyCode::Down => ui.increment_selection(), KeyCode::Down => app.increment_selection(),
// Toggle overlay. // Toggle overlay.
KeyCode::Char('m') | KeyCode::Char('M') => ui.show_info_overlay(), KeyCode::Char('m') | KeyCode::Char('M') => app.show_info_overlay(),
// Toggle Reload // Toggle Reload
KeyCode::Char('g') | KeyCode::Char('G') => ui.show_reload_menu(), KeyCode::Char('g') | KeyCode::Char('G') => app.show_reload_menu(),
// Othey keys. // Othey keys.
_ => {} _ => {}
} }
@ -112,12 +118,12 @@ impl<UI: IUi> IEventHandlerPrivate<UI> for EventHandler {
} }
fn handle_info_key_event( fn handle_info_key_event(
ui: &mut <UI as IUi>::IS, app: &mut <APP as IAppInteract>::IS,
key_event: KeyEvent, key_event: KeyEvent,
) -> Result<(), EventError> { ) -> Result<(), EventError> {
match key_event.code { match key_event.code {
// Toggle overlay. // Toggle overlay.
KeyCode::Char('m') | KeyCode::Char('M') => ui.hide_info_overlay(), KeyCode::Char('m') | KeyCode::Char('M') => app.hide_info_overlay(),
// Othey keys. // Othey keys.
_ => {} _ => {}
} }
@ -126,15 +132,15 @@ impl<UI: IUi> IEventHandlerPrivate<UI> for EventHandler {
} }
fn handle_reload_key_event( fn handle_reload_key_event(
ui: &mut <UI as IUi>::RS, app: &mut <APP as IAppInteract>::RS,
key_event: KeyEvent, key_event: KeyEvent,
) -> Result<(), EventError> { ) -> Result<(), EventError> {
match key_event.code { match key_event.code {
// Reload keys. // Reload keys.
KeyCode::Char('l') | KeyCode::Char('L') => ui.reload_library(), KeyCode::Char('l') | KeyCode::Char('L') => app.reload_library(),
KeyCode::Char('d') | KeyCode::Char('D') => ui.reload_database(), KeyCode::Char('d') | KeyCode::Char('D') => app.reload_database(),
// Return. // Return.
KeyCode::Char('g') | KeyCode::Char('G') => ui.go_back(), KeyCode::Char('g') | KeyCode::Char('G') => app.go_back(),
// Othey keys. // Othey keys.
_ => {} _ => {}
} }
@ -143,17 +149,17 @@ impl<UI: IUi> IEventHandlerPrivate<UI> for EventHandler {
} }
fn handle_error_key_event( fn handle_error_key_event(
ui: &mut <UI as IUi>::ES, app: &mut <APP as IAppInteract>::ES,
_key_event: KeyEvent, _key_event: KeyEvent,
) -> Result<(), EventError> { ) -> Result<(), EventError> {
// Any key dismisses the error. // Any key dismisses the error.
ui.dismiss_error(); app.dismiss_error();
Ok(()) Ok(())
} }
fn quit(ui: &mut UI) -> Result<(), EventError> { fn quit(app: &mut APP) -> Result<(), EventError> {
ui.quit(); app.quit();
ui.save()?; app.save()?;
Ok(()) Ok(())
} }
} }

View File

@ -1,9 +1,15 @@
pub mod event; mod app;
pub mod handler; mod event;
pub mod listener; mod handler;
pub mod ui;
mod lib; mod lib;
mod listener;
mod ui;
pub use app::App;
pub use event::EventChannel;
pub use handler::EventHandler;
pub use listener::EventListener;
pub use ui::Ui;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
@ -12,10 +18,13 @@ use ratatui::Terminal;
use std::io; use std::io;
use std::marker::PhantomData; use std::marker::PhantomData;
use self::event::EventError; use crate::tui::{
use self::handler::IEventHandler; app::{IAppAccess, IAppInteract},
use self::listener::IEventListener; event::EventError,
use self::ui::{render::IRender, IUi}; handler::IEventHandler,
listener::IEventListener,
ui::IUi,
};
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub enum Error { pub enum Error {
@ -43,12 +52,12 @@ impl From<EventError> for Error {
} }
} }
pub struct Tui<B: Backend, R: IRender, UI: IUi> { pub struct Tui<B: Backend, UI: IUi, APP: IAppInteract + IAppAccess> {
terminal: Terminal<B>, terminal: Terminal<B>,
_phantom: PhantomData<(R, UI)>, _phantom: PhantomData<(UI, APP)>,
} }
impl<B: Backend, R: IRender, UI: IUi> Tui<B, R, UI> { impl<B: Backend, UI: IUi, APP: IAppInteract + IAppAccess> Tui<B, UI, APP> {
fn init(&mut self) -> Result<(), Error> { fn init(&mut self) -> Result<(), Error> {
self.terminal.hide_cursor()?; self.terminal.hide_cursor()?;
self.terminal.clear()?; self.terminal.clear()?;
@ -67,13 +76,16 @@ impl<B: Backend, R: IRender, UI: IUi> Tui<B, R, UI> {
fn main_loop( fn main_loop(
&mut self, &mut self,
mut ui: UI, mut app: APP,
renderer: R, _ui: UI,
handler: impl IEventHandler<UI>, handler: impl IEventHandler<APP>,
) -> Result<(), Error> { ) -> Result<(), Error> {
while ui.is_running() { while app.is_running() {
self.terminal.draw(|frame| ui.render(&renderer, frame))?; // FIXME: rendering logic should be in render.rs - reverse Renderer/Ui relationship so
handler.handle_next_event(&mut ui)?; // that TAPP calls are renderer.render(ui, frame); Then rename ui -> app and render -> ui
// as it used to be originally.
self.terminal.draw(|frame| UI::render(&mut app, frame))?;
handler.handle_next_event(&mut app)?;
} }
Ok(()) Ok(())
@ -81,9 +93,9 @@ impl<B: Backend, R: IRender, UI: IUi> Tui<B, R, UI> {
fn main( fn main(
term: Terminal<B>, term: Terminal<B>,
app: APP,
ui: UI, ui: UI,
renderer: R, handler: impl IEventHandler<APP>,
handler: impl IEventHandler<UI>,
listener: impl IEventListener, listener: impl IEventListener,
) -> Result<(), Error> { ) -> Result<(), Error> {
let mut tui = Tui { let mut tui = Tui {
@ -94,7 +106,7 @@ impl<B: Backend, R: IRender, UI: IUi> Tui<B, R, UI> {
tui.init()?; tui.init()?;
let listener_handle = listener.spawn(); let listener_handle = listener.spawn();
let result = tui.main_loop(ui, renderer, handler); let result = tui.main_loop(app, ui, handler);
match result { match result {
Ok(_) => { Ok(_) => {
@ -111,7 +123,7 @@ impl<B: Backend, R: IRender, UI: IUi> Tui<B, R, UI> {
Ok(err) => return Err(err.into()), Ok(err) => return Err(err.into()),
// Calling std::panic::resume_unwind(err) as recommended by the Rust docs // Calling std::panic::resume_unwind(err) as recommended by the Rust docs
// will not produce an error message. The panic error message is printed at // will not produce an error message. The panic error message is printed at
// the location of the panic which at the time is hidden by the TUI. // the location of the panic which at the time is hidden by the TAPP.
Err(_) => return Err(Error::ListenerPanic), Err(_) => return Err(Error::ListenerPanic),
} }
} }
@ -141,13 +153,13 @@ impl<B: Backend, R: IRender, UI: IUi> Tui<B, R, UI> {
pub fn run( pub fn run(
term: Terminal<B>, term: Terminal<B>,
app: APP,
ui: UI, ui: UI,
renderer: R, handler: impl IEventHandler<APP>,
handler: impl IEventHandler<UI>,
listener: impl IEventListener, listener: impl IEventListener,
) -> Result<(), Error> { ) -> Result<(), Error> {
Self::enable()?; Self::enable()?;
let result = Self::main(term, ui, renderer, handler, listener); let result = Self::main(term, app, ui, handler, listener);
match result { match result {
Ok(_) => { Ok(_) => {
Self::disable()?; Self::disable()?;
@ -176,10 +188,8 @@ mod tests {
use musichoard::collection::Collection; use musichoard::collection::Collection;
use crate::tui::{ use crate::tui::{
handler::MockIEventHandler, app::App, handler::MockIEventHandler, lib::MockIMusicHoard, listener::MockIEventListener,
lib::MockIMusicHoard, ui::Ui,
listener::MockIEventListener,
ui::{render::Renderer, Ui},
}; };
use super::*; use super::*;
@ -190,7 +200,7 @@ mod tests {
Terminal::new(backend).unwrap() Terminal::new(backend).unwrap()
} }
pub fn music_hoard(collection: Collection) -> MockIMusicHoard { fn music_hoard(collection: Collection) -> MockIMusicHoard {
let mut music_hoard = MockIMusicHoard::new(); let mut music_hoard = MockIMusicHoard::new();
music_hoard.expect_load_from_database().returning(|| Ok(())); music_hoard.expect_load_from_database().returning(|| Ok(()));
@ -200,8 +210,8 @@ mod tests {
music_hoard music_hoard
} }
pub fn ui(collection: Collection) -> Ui<MockIMusicHoard> { fn app(collection: Collection) -> App<MockIMusicHoard> {
Ui::new(music_hoard(collection)).unwrap() App::new(music_hoard(collection)).unwrap()
} }
fn listener() -> MockIEventListener { fn listener() -> MockIEventListener {
@ -215,12 +225,12 @@ mod tests {
listener listener
} }
fn handler() -> MockIEventHandler<Ui<MockIMusicHoard>> { fn handler() -> MockIEventHandler<App<MockIMusicHoard>> {
let mut handler = MockIEventHandler::new(); let mut handler = MockIEventHandler::new();
handler handler
.expect_handle_next_event() .expect_handle_next_event()
.return_once(|ui: &mut Ui<MockIMusicHoard>| { .return_once(|app: &mut App<MockIMusicHoard>| {
ui.quit(); app.quit();
Ok(()) Ok(())
}); });
handler handler
@ -229,21 +239,21 @@ mod tests {
#[test] #[test]
fn run() { fn run() {
let terminal = terminal(); let terminal = terminal();
let ui = ui(COLLECTION.to_owned()); let app = app(COLLECTION.to_owned());
let renderer = Renderer; let ui = Ui;
let listener = listener(); let listener = listener();
let handler = handler(); let handler = handler();
let result = Tui::main(terminal, ui, renderer, handler, listener); let result = Tui::main(terminal, app, ui, handler, listener);
assert!(result.is_ok()); assert!(result.is_ok());
} }
#[test] #[test]
fn event_error() { fn event_error() {
let terminal = terminal(); let terminal = terminal();
let ui = ui(COLLECTION.to_owned()); let app = app(COLLECTION.to_owned());
let renderer = Renderer; let ui = Ui;
let listener = listener(); let listener = listener();
@ -252,7 +262,7 @@ mod tests {
.expect_handle_next_event() .expect_handle_next_event()
.return_once(|_| Err(EventError::Recv)); .return_once(|_| Err(EventError::Recv));
let result = Tui::main(terminal, ui, renderer, handler, listener); let result = Tui::main(terminal, app, ui, handler, listener);
assert!(result.is_err()); assert!(result.is_err());
assert_eq!( assert_eq!(
result.unwrap_err(), result.unwrap_err(),
@ -263,8 +273,8 @@ mod tests {
#[test] #[test]
fn listener_error() { fn listener_error() {
let terminal = terminal(); let terminal = terminal();
let ui = ui(COLLECTION.to_owned()); let app = app(COLLECTION.to_owned());
let renderer = Renderer; let ui = Ui;
let error = EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "error")); let error = EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "error"));
let listener_handle: thread::JoinHandle<EventError> = thread::spawn(|| error); let listener_handle: thread::JoinHandle<EventError> = thread::spawn(|| error);
@ -278,7 +288,7 @@ mod tests {
.expect_handle_next_event() .expect_handle_next_event()
.return_once(|_| Err(EventError::Recv)); .return_once(|_| Err(EventError::Recv));
let result = Tui::main(terminal, ui, renderer, handler, listener); let result = Tui::main(terminal, app, ui, handler, listener);
assert!(result.is_err()); assert!(result.is_err());
let error = EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "error")); let error = EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "error"));
@ -288,8 +298,8 @@ mod tests {
#[test] #[test]
fn listener_panic() { fn listener_panic() {
let terminal = terminal(); let terminal = terminal();
let ui = ui(COLLECTION.to_owned()); let app = app(COLLECTION.to_owned());
let renderer = Renderer; let ui = Ui;
let listener_handle: thread::JoinHandle<EventError> = thread::spawn(|| panic!()); let listener_handle: thread::JoinHandle<EventError> = thread::spawn(|| panic!());
while !listener_handle.is_finished() {} while !listener_handle.is_finished() {}
@ -302,7 +312,7 @@ mod tests {
.expect_handle_next_event() .expect_handle_next_event()
.return_once(|_| Err(EventError::Recv)); .return_once(|_| Err(EventError::Recv));
let result = Tui::main(terminal, ui, renderer, handler, listener); let result = Tui::main(terminal, app, ui, handler, listener);
assert!(result.is_err()); assert!(result.is_err());
assert_eq!(result.unwrap_err(), Error::ListenerPanic); assert_eq!(result.unwrap_err(), Error::ListenerPanic);
} }

View File

@ -12,21 +12,10 @@ use ratatui::{
Frame, Frame,
}; };
use crate::tui::ui::{Category, Selection}; use crate::tui::app::{AppState, Category, IAppAccess, Selection};
pub trait IRender { pub trait IUi {
fn render_collection<B: Backend>( fn render<APP: IAppAccess, B: Backend>(app: &mut APP, frame: &mut Frame<'_, B>);
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 { struct ArtistArea {
@ -314,9 +303,9 @@ impl<'a, 'b> TrackState<'a, 'b> {
} }
} }
pub struct Renderer; pub struct Ui;
impl Renderer { impl Ui {
fn style(_active: bool) -> Style { fn style(_active: bool) -> Style {
Style::default().fg(Color::White).bg(Color::Black) Style::default().fg(Color::White).bg(Color::Black)
} }
@ -403,9 +392,7 @@ impl Renderer {
Self::render_list_widget("Tracks", st.list, st.state, st.active, ar.list, fr); 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); Self::render_info_widget("Track info", st.info, st.active, ar.info, fr);
} }
}
impl IRender for Renderer {
fn render_collection<B: Backend>( fn render_collection<B: Backend>(
artists: &Collection, artists: &Collection,
selection: &mut Selection, selection: &mut Selection,
@ -496,56 +483,59 @@ impl IRender for Renderer {
} }
} }
impl IUi for Ui {
fn render<APP: IAppAccess, B: Backend>(app: &mut APP, frame: &mut Frame<'_, B>) {
let app = app.get();
let collection = app.collection;
let selection = app.selection;
Self::render_collection(collection, selection, frame);
match app.state {
AppState::Info(_) => Self::render_info_overlay(collection, selection, frame),
AppState::Reload(_) => Self::render_reload_overlay(frame),
AppState::Error(ref msg) => Self::render_error_overlay(msg, frame),
_ => {}
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::tui::{testmod::COLLECTION, tests::terminal}; use crate::tui::{app::AppPublic, testmod::COLLECTION, tests::terminal};
use super::*; use super::*;
fn draw_test_suite(artists: &Collection, selection: &mut Selection) { // Automock does not support returning types with generic lifetimes.
let mut terminal = terminal(); impl IAppAccess for AppPublic<'_> {
fn get(&mut self) -> AppPublic {
terminal AppPublic {
.draw(|frame| Renderer::render_collection(artists, selection, frame)) collection: self.collection,
.unwrap(); selection: self.selection,
state: self.state,
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 draw_test_suite(collection: &Collection, selection: &mut Selection) {
fn stateless() {
let mut terminal = terminal(); let mut terminal = terminal();
terminal let mut app = AppPublic {
.draw(|frame| Renderer::render_reload_overlay(frame)) collection,
.unwrap(); selection,
state: &AppState::Browse(()),
};
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
terminal app.state = &AppState::Info(());
.draw(|frame| Renderer::render_error_overlay("get rekt scrub", frame)) terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
.unwrap();
app.state = &AppState::Reload(());
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
let binding = AppState::Error(String::from("get rekt scrub"));
app.state = &binding;
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
} }
#[test] #[test]
@ -558,16 +548,23 @@ mod tests {
#[test] #[test]
fn collection() { fn collection() {
let artists: &Collection = &COLLECTION; let artists = &COLLECTION;
let mut selection = Selection::new(Some(artists)); let mut selection = Selection::new(Some(artists));
draw_test_suite(&artists, &mut selection); draw_test_suite(artists, &mut selection);
// Change the track (which has a different track format). // Change the track (which has a different track format).
selection.increment_category(); selection.increment_category();
selection.increment_category(); selection.increment_category();
selection.increment_selection(artists); selection.increment_selection(artists);
draw_test_suite(&artists, &mut selection); draw_test_suite(artists, &mut selection);
// Change the artist (which has a multi-link entry).
selection.decrement_category();
selection.decrement_category();
selection.increment_selection(artists);
draw_test_suite(artists, &mut selection);
} }
} }