diff --git a/src/traits.rs b/src/traits.rs index 7d6a808..ba4c1cf 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -104,3 +104,79 @@ impl IntoBoxedViewExt for V { Box::new(self) } } + +pub struct BoxedViewExt { + boxed_view: Box, +} + +impl BoxedViewExt { + pub fn new(view: Box) -> Self { + Self { boxed_view: view } + } +} + +impl View for BoxedViewExt { + fn draw(&self, printer: &cursive::Printer) { + self.boxed_view.draw(printer); + } + + fn layout(&mut self, xy: cursive::Vec2) { + self.boxed_view.layout(xy); + } + + fn needs_relayout(&self) -> bool { + self.boxed_view.needs_relayout() + } + + fn required_size(&mut self, constraint: cursive::Vec2) -> cursive::Vec2 { + self.boxed_view.required_size(constraint) + } + + fn on_event(&mut self, event: cursive::event::Event) -> cursive::event::EventResult { + self.boxed_view.on_event(event) + } + + fn call_on_any(&mut self, selector: &cursive::view::Selector, callback: cursive::event::AnyCb) { + self.boxed_view.call_on_any(selector, callback); + } + + fn focus_view( + &mut self, + selector: &cursive::view::Selector, + ) -> Result { + self.boxed_view.focus_view(selector) + } + + fn take_focus( + &mut self, + source: cursive::direction::Direction, + ) -> Result { + self.boxed_view.take_focus(source) + } + + fn important_area(&self, view_size: cursive::Vec2) -> cursive::Rect { + self.boxed_view.important_area(view_size) + } + + fn type_name(&self) -> &'static str { + std::any::type_name::() + } +} + +impl ViewExt for BoxedViewExt { + fn title(&self) -> String { + self.boxed_view.title() + } + + fn title_sub(&self) -> String { + self.boxed_view.title_sub() + } + + fn on_leave(&self) { + self.boxed_view.on_leave(); + } + + fn on_command(&mut self, s: &mut Cursive, cmd: &Command) -> Result { + self.boxed_view.on_command(s, cmd) + } +} diff --git a/src/ui/album.rs b/src/ui/album.rs index ac884f4..8e8480d 100644 --- a/src/ui/album.rs +++ b/src/ui/album.rs @@ -11,11 +11,11 @@ use crate::model::artist::Artist; use crate::queue::Queue; use crate::traits::ViewExt; use crate::ui::listview::ListView; -use crate::ui::tabview::TabView; +use crate::ui::tabbedview::TabbedView; pub struct AlbumView { album: Album, - tabs: TabView, + tabs: TabbedView, } impl AlbumView { @@ -37,27 +37,26 @@ impl AlbumView { .map(|(id, name)| Artist::new(id.clone(), name.clone())) .collect(); - let tabs = TabView::new() - .tab( - "tracks", - ListView::new( - Arc::new(RwLock::new(tracks)), - queue.clone(), - library.clone(), - ) - .with_title("Tracks"), - ) - .tab( - "artists", - ListView::new(Arc::new(RwLock::new(artists)), queue, library).with_title("Artists"), - ); + let mut tabs = TabbedView::new(); + tabs.add_tab( + "Tracks", + ListView::new( + Arc::new(RwLock::new(tracks)), + queue.clone(), + library.clone(), + ), + ); + tabs.add_tab( + "Artists", + ListView::new(Arc::new(RwLock::new(artists)), queue, library), + ); Self { album, tabs } } } impl ViewWrapper for AlbumView { - wrap_impl!(self.tabs: TabView); + wrap_impl!(self.tabs: TabbedView); } impl ViewExt for AlbumView { diff --git a/src/ui/artist.rs b/src/ui/artist.rs index 95c3574..8b1d537 100644 --- a/src/ui/artist.rs +++ b/src/ui/artist.rs @@ -14,11 +14,11 @@ use crate::model::track::Track; use crate::queue::Queue; use crate::traits::ViewExt; use crate::ui::listview::ListView; -use crate::ui::tabview::TabView; +use crate::ui::tabbedview::TabbedView; pub struct ArtistView { artist: Artist, - tabs: TabView, + tabs: TabbedView, } impl ArtistView { @@ -61,34 +61,27 @@ impl ArtistView { }); } - let mut tabs = TabView::new(); + let mut tabs = TabbedView::new(); if let Some(tracks) = artist.tracks.as_ref() { let tracks = tracks.clone(); tabs.add_tab( - "tracks", + "Saved Tracks", ListView::new( Arc::new(RwLock::new(tracks)), queue.clone(), library.clone(), - ) - .with_title("Saved Tracks"), + ), ); } - tabs.add_tab( - "top_tracks", - ListView::new(top_tracks, queue.clone(), library.clone()).with_title("Top 10"), - ); - - tabs.add_tab("albums", albums_view.with_title("Albums")); - tabs.add_tab("singles", singles_view.with_title("Singles")); - - tabs.add_tab( - "related", - ListView::new(related, queue, library).with_title("Related Artists"), + "Top 10", + ListView::new(top_tracks, queue.clone(), library.clone()), ); + tabs.add_tab("Albums", albums_view); + tabs.add_tab("Singles", singles_view); + tabs.add_tab("Related Artists", ListView::new(related, queue, library)); Self { artist: artist.clone(), @@ -116,7 +109,7 @@ impl ArtistView { } impl ViewWrapper for ArtistView { - wrap_impl!(self.tabs: TabView); + wrap_impl!(self.tabs: TabbedView); } impl ViewExt for ArtistView { diff --git a/src/ui/library.rs b/src/ui/library.rs index cb3b49e..3a63a6f 100644 --- a/src/ui/library.rs +++ b/src/ui/library.rs @@ -13,16 +13,16 @@ use crate::traits::ViewExt; use crate::ui::browse::BrowseView; use crate::ui::listview::ListView; use crate::ui::playlists::PlaylistsView; -use crate::ui::tabview::TabView; +use crate::ui::tabbedview::TabbedView; pub struct LibraryView { - tabs: TabView, + tabs: TabbedView, display_name: Option, } impl LibraryView { pub fn new(queue: Arc, library: Arc) -> Self { - let mut tabview = TabView::new(); + let mut tabview = TabbedView::new(); let selected_tabs = library .cfg .values() @@ -33,31 +33,27 @@ impl LibraryView { for tab in selected_tabs { match tab { LibraryTab::Tracks => tabview.add_tab( - "tracks", - ListView::new(library.tracks.clone(), queue.clone(), library.clone()) - .with_title("Tracks"), + "Tracks", + ListView::new(library.tracks.clone(), queue.clone(), library.clone()), ), LibraryTab::Albums => tabview.add_tab( - "albums", - ListView::new(library.albums.clone(), queue.clone(), library.clone()) - .with_title("Albums"), + "Albums", + ListView::new(library.albums.clone(), queue.clone(), library.clone()), ), LibraryTab::Artists => tabview.add_tab( - "artists", - ListView::new(library.artists.clone(), queue.clone(), library.clone()) - .with_title("Artists"), + "Artists", + ListView::new(library.artists.clone(), queue.clone(), library.clone()), ), LibraryTab::Playlists => tabview.add_tab( - "playlists", + "Playlists", PlaylistsView::new(queue.clone(), library.clone()), ), LibraryTab::Podcasts => tabview.add_tab( - "podcasts", - ListView::new(library.shows.clone(), queue.clone(), library.clone()) - .with_title("Podcasts"), + "Podcasts", + ListView::new(library.shows.clone(), queue.clone(), library.clone()), ), LibraryTab::Browse => { - tabview.add_tab("browse", BrowseView::new(queue.clone(), library.clone())) + tabview.add_tab("Browse", BrowseView::new(queue.clone(), library.clone())) } } } @@ -77,7 +73,7 @@ impl LibraryView { } impl ViewWrapper for LibraryView { - wrap_impl!(self.tabs: TabView); + wrap_impl!(self.tabs: TabbedView); } impl ViewExt for LibraryView { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index e8783f7..903fb2d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -18,7 +18,7 @@ pub mod search; pub mod search_results; pub mod show; pub mod statusbar; -pub mod tabview; +pub mod tabbedview; #[cfg(feature = "cover")] pub mod cover; diff --git a/src/ui/search.rs b/src/ui/search.rs index 09e4a6b..8f8c53b 100644 --- a/src/ui/search.rs +++ b/src/ui/search.rs @@ -26,7 +26,7 @@ use crate::ui::layout::Layout; use crate::ui::listview::ListView; use crate::ui::pagination::Pagination; use crate::ui::search_results::SearchResultsView; -use crate::ui::tabview::TabView; +use crate::ui::tabbedview::TabbedView; use rspotify::model::search::SearchResult; pub struct SearchView { diff --git a/src/ui/search_results.rs b/src/ui/search_results.rs index 02fe446..60f2c80 100644 --- a/src/ui/search_results.rs +++ b/src/ui/search_results.rs @@ -14,7 +14,7 @@ use crate::spotify_url::SpotifyUrl; use crate::traits::{ListItem, ViewExt}; use crate::ui::listview::ListView; use crate::ui::pagination::Pagination; -use crate::ui::tabview::TabView; +use crate::ui::tabbedview::TabbedView; use cursive::view::ViewWrapper; use cursive::Cursive; use rspotify::model::search::SearchResult; @@ -35,7 +35,7 @@ pub struct SearchResultsView { pagination_shows: Pagination, results_episodes: Arc>>, pagination_episodes: Pagination, - tabs: TabView, + tabs: TabbedView, spotify: Spotify, events: EventManager, } @@ -71,13 +71,13 @@ impl SearchResultsView { let list_episodes = ListView::new(results_episodes.clone(), queue.clone(), library); let pagination_episodes = list_episodes.get_pagination().clone(); - let tabs = TabView::new() - .tab("tracks", list_tracks.with_title("Tracks")) - .tab("albums", list_albums.with_title("Albums")) - .tab("artists", list_artists.with_title("Artists")) - .tab("playlists", list_playlists.with_title("Playlists")) - .tab("shows", list_shows.with_title("Podcasts")) - .tab("episodes", list_episodes.with_title("Podcast Episodes")); + let mut tabs = TabbedView::new(); + tabs.add_tab("Tracks", list_tracks); + tabs.add_tab("Albums", list_albums); + tabs.add_tab("Artists", list_artists); + tabs.add_tab("Playlists", list_playlists); + tabs.add_tab("Shows", list_shows); + tabs.add_tab("Episodes", list_episodes); let mut view = Self { search_term, @@ -403,7 +403,7 @@ impl SearchResultsView { &query, None, ); - self.tabs.move_focus_to(0); + self.tabs.set_selected(0); } UriType::Album => { self.perform_search( @@ -412,7 +412,7 @@ impl SearchResultsView { &query, None, ); - self.tabs.move_focus_to(1); + self.tabs.set_selected(1); } UriType::Artist => { self.perform_search( @@ -421,7 +421,7 @@ impl SearchResultsView { &query, None, ); - self.tabs.move_focus_to(2); + self.tabs.set_selected(2); } UriType::Playlist => { self.perform_search( @@ -430,7 +430,7 @@ impl SearchResultsView { &query, None, ); - self.tabs.move_focus_to(3); + self.tabs.set_selected(3); } UriType::Show => { self.perform_search( @@ -439,7 +439,7 @@ impl SearchResultsView { &query, None, ); - self.tabs.move_focus_to(4); + self.tabs.set_selected(4); } UriType::Episode => { self.perform_search( @@ -448,7 +448,7 @@ impl SearchResultsView { &query, None, ); - self.tabs.move_focus_to(5); + self.tabs.set_selected(5); } } // Is the query a spotify URL? @@ -462,7 +462,7 @@ impl SearchResultsView { &url.id, None, ); - self.tabs.move_focus_to(0); + self.tabs.set_selected(0); } UriType::Album => { self.perform_search( @@ -471,7 +471,7 @@ impl SearchResultsView { &url.id, None, ); - self.tabs.move_focus_to(1); + self.tabs.set_selected(1); } UriType::Artist => { self.perform_search( @@ -480,7 +480,7 @@ impl SearchResultsView { &url.id, None, ); - self.tabs.move_focus_to(2); + self.tabs.set_selected(2); } UriType::Playlist => { self.perform_search( @@ -489,7 +489,7 @@ impl SearchResultsView { &url.id, None, ); - self.tabs.move_focus_to(3); + self.tabs.set_selected(3); } UriType::Show => { self.perform_search( @@ -498,7 +498,7 @@ impl SearchResultsView { &url.id, None, ); - self.tabs.move_focus_to(4); + self.tabs.set_selected(4); } UriType::Episode => { self.perform_search( @@ -507,7 +507,7 @@ impl SearchResultsView { &url.id, None, ); - self.tabs.move_focus_to(5); + self.tabs.set_selected(5); } } } else { @@ -552,7 +552,7 @@ impl SearchResultsView { } impl ViewWrapper for SearchResultsView { - wrap_impl!(self.tabs: TabView); + wrap_impl!(self.tabs: TabbedView); } impl ViewExt for SearchResultsView { diff --git a/src/ui/tabbedview.rs b/src/ui/tabbedview.rs new file mode 100644 index 0000000..9367453 --- /dev/null +++ b/src/ui/tabbedview.rs @@ -0,0 +1,217 @@ +use std::cmp::min; + +use cursive::{ + align::HAlign, + event::{Event, EventResult, MouseButton, MouseEvent}, + theme::ColorStyle, + view::Nameable, + views::NamedView, + Cursive, Printer, Vec2, View, +}; +use unicode_width::UnicodeWidthStr; + +use crate::{ + command::{Command, MoveAmount, MoveMode}, + commands::CommandResult, + traits::{BoxedViewExt, IntoBoxedViewExt, ViewExt}, +}; + +/// A view that displays other views in a tab layout. +#[derive(Default)] +pub struct TabbedView { + /// The list of tabs + tabs: Vec>, + /// The index of the currently visible tab from `tabs` + selected: usize, + /// The size given to the last call to `layout()` + last_layout_size: Vec2, +} + +impl TabbedView { + pub fn new() -> Self { + Default::default() + } + + /// Add `view` as a new tab to the end of this [TabsView]. + pub fn add_tab(&mut self, title: impl Into, view: impl IntoBoxedViewExt) { + let tab = BoxedViewExt::new(view.into_boxed_view_ext()).with_name(title); + self.tabs.push(tab); + } + + /// Return a mutable reference to the tab at `index`, or None if there is no tab at `index`. + pub fn tab_mut(&mut self, index: usize) -> Option<&mut NamedView> { + self.tabs.get_mut(index) + } + + /// Return a mutable reference to the selected tab, or None if there is no selected tab + /// currently. + pub fn selected_tab_mut(&mut self) -> Option<&mut NamedView> { + self.tab_mut(self.selected) + } + + /// Return the amount of tabs in this view. + pub fn len(&self) -> usize { + self.tabs.len() + } + + /// Check whether there are tabs in this [TabsView]. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Set the tab at `index` as currently visible. + pub fn set_selected(&mut self, index: usize) { + self.selected = min(self.len().saturating_sub(1), index); + } + + /// Move the focus by `amount`, clipping at the edges. + pub fn move_selected(&mut self, amount: isize) { + self.selected = min( + self.selected.saturating_add_signed(amount), + self.len().saturating_sub(1), + ); + } + + pub fn move_left(&mut self) { + self.move_selected(-1); + } + + pub fn move_right(&mut self) { + self.move_selected(1); + } + + /// Move the focus to the first tab. + pub fn select_first(&mut self) { + self.selected = 0; + } + + /// Move the focus to the last tab. + pub fn select_last(&mut self) { + self.selected = self.len() - 1; + } + + /// Return whether we are on the first tab. + pub fn on_first_tab(&mut self) -> bool { + self.selected == 0 + } + + /// Return whether we are on the last tab. + pub fn on_last_tab(&mut self) -> bool { + self.selected == self.len() - 1 + } + + /// Return the width of a single tab. + /// + /// Keep in mind that this is an average. It's only provided to make sure all functions use the + /// same calculation for tab width to prevent off-by-one errors. + pub fn tab_width(&self) -> usize { + self.last_layout_size.x / self.len() + } +} + +impl View for TabbedView { + fn draw(&self, printer: &Printer<'_, '_>) { + if self.is_empty() { + return; + } + + let tabwidth = self.tab_width(); + for (i, tab) in self.tabs.iter().enumerate() { + let style = if self.selected == i { + ColorStyle::highlight() + } else { + ColorStyle::primary() + }; + + let mut width = tabwidth; + if i == self.tabs.len() - 1 { + width += printer.size.x % self.tabs.len(); + } + + let title = tab.name(); + let offset = HAlign::Center.get_offset(title.width(), width); + + printer.with_color(style, |printer| { + printer.print_hline((i * tabwidth, 0), width, " "); + printer.print((i * tabwidth + offset, 0), title); + }); + } + + if let Some(tab) = self.tabs.get(self.selected) { + let printer = printer + .offset((0, 1)) + .cropped((printer.size.x, printer.size.y - 1)); + + tab.draw(&printer); + } + } + + fn layout(&mut self, size: Vec2) { + self.last_layout_size = size; + if let Some(tab) = self.tab_mut(self.selected) { + tab.layout((size.x, size.y - 1).into()) + } + } + + fn on_event(&mut self, event: Event) -> EventResult { + if let Event::Mouse { + offset, + position, + event, + } = event + { + let position = position.checked_sub(offset); + if let Some(0) = position.map(|p| p.y) { + match event { + MouseEvent::WheelUp => self.move_left(), + MouseEvent::WheelDown => self.move_right(), + MouseEvent::Press(MouseButton::Left) => { + let tabwidth = self.tab_width(); + if let Some(selected_tab) = position.and_then(|p| p.x.checked_div(tabwidth)) + { + self.set_selected(selected_tab); + } + } + _ => {} + }; + return EventResult::consumed(); + } + } + + if let Some(tab) = self.tab_mut(self.selected) { + tab.on_event(event.relativized((0, 1))) + } else { + EventResult::Ignored + } + } +} + +impl ViewExt for TabbedView { + fn on_command(&mut self, s: &mut Cursive, cmd: &Command) -> Result { + match cmd { + Command::Move(mode, amount) if matches!(mode, MoveMode::Left | MoveMode::Right) => { + if matches!(mode, MoveMode::Left) && !self.on_first_tab() { + match amount { + MoveAmount::Extreme => self.select_first(), + MoveAmount::Integer(amount) => self.move_selected(-(*amount) as isize), + _ => (), + } + } else if matches!(mode, MoveMode::Right) && !self.on_last_tab() { + match amount { + MoveAmount::Extreme => self.select_last(), + MoveAmount::Integer(amount) => self.move_selected(*amount as isize), + _ => (), + } + } + Ok(CommandResult::Consumed(None)) + } + _ => { + if let Some(tab) = self.selected_tab_mut() { + tab.on_command(s, cmd) + } else { + Ok(CommandResult::Ignored) + } + } + } + } +} diff --git a/src/ui/tabview.rs b/src/ui/tabview.rs deleted file mode 100644 index 1431308..0000000 --- a/src/ui/tabview.rs +++ /dev/null @@ -1,169 +0,0 @@ -use std::cmp::{max, min}; -use std::collections::HashMap; - -use cursive::align::HAlign; -use cursive::event::{Event, EventResult, MouseButton, MouseEvent}; -use cursive::theme::ColorStyle; -use cursive::traits::View; -use cursive::{Cursive, Printer, Vec2}; -use unicode_width::UnicodeWidthStr; - -use crate::command::{Command, MoveAmount, MoveMode}; -use crate::commands::CommandResult; -use crate::traits::{IntoBoxedViewExt, ViewExt}; - -pub struct Tab { - view: Box, -} - -pub struct TabView { - tabs: Vec, - ids: HashMap, - selected: usize, - size: Vec2, -} - -impl TabView { - pub fn new() -> Self { - Self { - tabs: Vec::new(), - ids: HashMap::new(), - selected: 0, - size: Vec2::default(), - } - } - - pub fn add_tab, V: IntoBoxedViewExt>(&mut self, id: S, view: V) { - let tab = Tab { - view: view.into_boxed_view_ext(), - }; - self.tabs.push(tab); - self.ids.insert(id.into(), self.tabs.len() - 1); - } - - pub fn tab, V: IntoBoxedViewExt>(mut self, id: S, view: V) -> Self { - self.add_tab(id, view); - self - } - - pub fn move_focus_to(&mut self, target: usize) { - let len = self.tabs.len().saturating_sub(1); - self.selected = min(target, len); - } - - pub fn move_focus(&mut self, delta: i32) { - let new = self.selected as i32 + delta; - self.move_focus_to(max(new, 0) as usize); - } -} - -impl View for TabView { - fn draw(&self, printer: &Printer<'_, '_>) { - if self.tabs.is_empty() { - return; - } - - let tabwidth = printer.size.x / self.tabs.len(); - for (i, tab) in self.tabs.iter().enumerate() { - let style = if self.selected == i { - ColorStyle::highlight() - } else { - ColorStyle::primary() - }; - - let mut width = tabwidth; - if i == self.tabs.len() - 1 { - width += printer.size.x % self.tabs.len(); - } - - let title = tab.view.title(); - let offset = HAlign::Center.get_offset(title.width(), width); - - printer.with_color(style, |printer| { - printer.print_hline((i * tabwidth, 0), width, " "); - printer.print((i * tabwidth + offset, 0), &title); - }); - } - - if let Some(tab) = self.tabs.get(self.selected) { - let printer = printer - .offset((0, 1)) - .cropped((printer.size.x, printer.size.y - 1)); - - tab.view.draw(&printer); - } - } - - fn layout(&mut self, size: Vec2) { - self.size = size; - if let Some(tab) = self.tabs.get_mut(self.selected) { - tab.view.layout(Vec2::new(size.x, size.y - 1)); - } - } - - fn on_event(&mut self, event: Event) -> EventResult { - if let Event::Mouse { - offset, - position, - event, - } = event - { - let position = position.checked_sub(offset); - if let Some(0) = position.map(|p| p.y) { - match event { - MouseEvent::WheelUp => self.move_focus(-1), - MouseEvent::WheelDown => self.move_focus(1), - MouseEvent::Press(MouseButton::Left) => { - let tabwidth = self.size.x / self.tabs.len(); - if let Some(selected_tab) = position.and_then(|p| p.x.checked_div(tabwidth)) - { - self.move_focus_to(selected_tab); - } - } - _ => {} - }; - return EventResult::consumed(); - } - } - - if let Some(tab) = self.tabs.get_mut(self.selected) { - tab.view.on_event(event.relativized((0, 1))) - } else { - EventResult::Ignored - } - } -} - -impl ViewExt for TabView { - fn on_command(&mut self, s: &mut Cursive, cmd: &Command) -> Result { - if let Command::Move(mode, amount) = cmd { - let last_idx = self.tabs.len() - 1; - - match mode { - MoveMode::Left if self.selected > 0 => { - match amount { - MoveAmount::Extreme => self.move_focus_to(0), - MoveAmount::Integer(amount) => self.move_focus(-(*amount)), - _ => (), - } - return Ok(CommandResult::Consumed(None)); - } - MoveMode::Right if self.selected < last_idx => { - match amount { - MoveAmount::Extreme => self.move_focus_to(last_idx), - MoveAmount::Integer(amount) => self.move_focus(*amount), - _ => (), - } - return Ok(CommandResult::Consumed(None)); - } - _ => {} - } - } - - if let Some(tab) = self.tabs.get_mut(self.selected) { - tab.view.on_command(s, cmd) - } else { - Ok(CommandResult::Ignored) - } - } -}