use log::info; use std::cmp::{max, min, Ordering}; use std::sync::{Arc, RwLock}; use cursive::align::HAlign; use cursive::event::{Event, EventResult, MouseButton, MouseEvent}; use cursive::theme::{ColorStyle, ColorType, PaletteColor}; use cursive::traits::View; use cursive::view::ScrollBase; use cursive::{Cursive, Printer, Rect, Vec2}; use unicode_width::UnicodeWidthStr; use crate::command::{Command, GotoMode, InsertSource, JumpMode, MoveAmount, MoveMode, TargetMode}; use crate::commands::CommandResult; use crate::library::Library; use crate::model::album::Album; use crate::model::artist::Artist; use crate::model::episode::Episode; use crate::model::playable::Playable; use crate::model::playlist::Playlist; use crate::model::show::Show; use crate::model::track::Track; use crate::queue::Queue; #[cfg(feature = "share_clipboard")] use crate::sharing::{read_share, write_share}; use crate::traits::{IntoBoxedViewExt, ListItem, ViewExt}; use crate::ui::album::AlbumView; use crate::ui::artist::ArtistView; use crate::ui::contextmenu::ContextMenu; use crate::ui::pagination::Pagination; use crate::{spotify::UriType, spotify_url::SpotifyUrl}; pub struct ListView { content: Arc>>, last_content_len: usize, selected: usize, search_query: String, search_indexes: Vec, search_selected_index: usize, last_size: Vec2, scrollbar: ScrollBase, queue: Arc, library: Arc, pagination: Pagination, title: String, } impl ListView { pub fn new(content: Arc>>, queue: Arc, library: Arc) -> Self { Self { content, last_content_len: 0, selected: 0, search_query: String::new(), search_indexes: Vec::new(), search_selected_index: 0, last_size: Vec2::new(0, 0), scrollbar: ScrollBase::new(), queue, library, pagination: Pagination::default(), title: "".to_string(), } } pub fn set_title(mut self, title: String) -> Self { self.title = title; self } pub fn get_pagination(&self) -> &Pagination { &self.pagination } fn can_paginate(&self) -> bool { if let Some(max) = self.get_pagination().max_content() { if max > self.last_content_len { return true; } } false } pub fn get_selected_index(&self) -> usize { self.selected } pub fn get_indexes_of(&self, query: &str) -> Vec { let content = self.content.read().unwrap(); content .iter() .enumerate() .filter(|(_, i)| { i.display_left() .to_lowercase() .contains(&query[..].to_lowercase()) }) .map(|(i, _)| i) .collect() } pub fn move_focus_to(&mut self, target: usize) { let len = self.content.read().unwrap().len().saturating_sub(1); self.selected = min(target, len); self.scrollbar.scroll_to(self.selected); } pub fn move_focus(&mut self, delta: i32) { let new = self.selected as i32 + delta; self.move_focus_to(max(new, 0) as usize); } fn attempt_play_all_tracks(&self) -> bool { let content = self.content.read().unwrap(); let any = &(*content) as &dyn std::any::Any; let playables = any.downcast_ref::>(); let tracks = any.downcast_ref::>().map(|t| { t.iter() .map(|t| Playable::Track(t.clone())) .collect::>() }); if let Some(tracks) = playables.or_else(|| tracks.as_ref()) { let index = self.queue.append_next(tracks); self.queue.play(index + self.selected, true, false); true } else { false } } pub fn remove(&self, index: usize) { let mut c = self.content.write().unwrap(); c.remove(index); } } impl View for ListView { fn draw(&self, printer: &Printer<'_, '_>) { let content = self.content.read().unwrap(); self.scrollbar.draw(printer, |printer, i| { // draw paginator after content if i == content.len() && self.can_paginate() { let style = ColorStyle::secondary(); let max = self.pagination.max_content().unwrap(); let buf = format!("{} more items, scroll to load", max - i); printer.with_color(style, |printer| { printer.print((0, 0), &buf); }); } else if i < content.len() { let item = &content[i]; let currently_playing = item.is_playing(self.queue.clone()) && self.queue.get_current_index() == Some(i); let style = if self.selected == i { let fg = if currently_playing { *printer.theme.palette.custom("playing_selected").unwrap() } else { PaletteColor::Tertiary.resolve(&printer.theme.palette) }; ColorStyle::new( ColorType::Color(fg), ColorType::Palette(PaletteColor::Highlight), ) } else if currently_playing { ColorStyle::new( ColorType::Color(*printer.theme.palette.custom("playing").unwrap()), ColorType::Color(*printer.theme.palette.custom("playing_bg").unwrap()), ) } else { ColorStyle::primary() }; let left = item.display_left(); let center = item.display_center(self.library.clone()); let right = item.display_right(self.library.clone()); let draw_center = !center.is_empty(); // draw left string printer.with_color(style, |printer| { printer.print_hline((0, 0), printer.size.x, " "); printer.print((0, 0), &left); }); // if line contains search query match, draw on top with // highlight color if self.search_indexes.contains(&i) { let fg = *printer.theme.palette.custom("search_match").unwrap(); let matched_style = ColorStyle::new(fg, style.back); let matches: Vec<(usize, usize)> = left .to_lowercase() .match_indices(&self.search_query) .map(|i| (i.0, i.0 + i.1.len())) .collect(); for m in matches { printer.with_color(matched_style, |printer| { printer.print((left[0..m.0].width(), 0), &left[m.0..m.1]); }); } } // left string cut off indicator let center_offset = printer.size.x / 2; let left_max_length = if draw_center { center_offset.saturating_sub(1) } else { printer.size.x.saturating_sub(right.width() + 1) }; if left_max_length < left.width() { let offset = left_max_length.saturating_sub(1); printer.with_color(style, |printer| { printer.print_hline((offset, 0), printer.size.x, " "); printer.print((offset, 0), ".."); }); } // draw center string if draw_center { printer.with_color(style, |printer| { printer.print((center_offset, 0), ¢er); }); // center string cut off indicator let max_length = printer.size.x.saturating_sub(right.width() + 1); if max_length < center_offset + center.width() { let offset = max_length.saturating_sub(1); printer.with_color(style, |printer| { printer.print((offset, 0), ".."); }); } } // draw right string let offset = HAlign::Right.get_offset(right.width(), printer.size.x); printer.with_color(style, |printer| { printer.print((offset, 0), &right); }); } }); } fn layout(&mut self, size: Vec2) { let content_len = self.content.read().unwrap().len(); // add 1 more row for paginator if we can paginate self.last_content_len = if self.can_paginate() { content_len + 1 } else { content_len }; self.last_size = size; self.scrollbar.set_heights(size.y, self.last_content_len); } fn needs_relayout(&self) -> bool { self.content.read().unwrap().len() != self.last_content_len } fn required_size(&mut self, constraint: Vec2) -> Vec2 { Vec2::new(constraint.x, self.content.read().unwrap().len()) } fn on_event(&mut self, e: Event) -> EventResult { match e { Event::Mouse { event: MouseEvent::WheelUp, .. } => self.move_focus(-3), Event::Mouse { event: MouseEvent::WheelDown, .. } => self.move_focus(3), Event::Mouse { event: MouseEvent::Press(MouseButton::Left), position, offset, } => { if self.scrollbar.scrollable() && position.y > 0 && position.y <= self.last_size.y && position .checked_sub(offset) .map(|p| self.scrollbar.start_drag(p, self.last_size.x)) .unwrap_or(false) {} } Event::Mouse { event: MouseEvent::Hold(MouseButton::Left), position, offset, } => { if self.scrollbar.scrollable() { self.scrollbar.drag(position.saturating_sub(offset)); } } Event::Mouse { event: MouseEvent::Release(MouseButton::Left), .. } => { self.scrollbar.release_grab(); } _ => { return EventResult::Ignored; } } EventResult::Consumed(None) } fn important_area(&self, view_size: Vec2) -> Rect { if self.content.read().unwrap().len() > 0 { Rect::from_point((view_size.x, self.selected)) } else { Rect::from_point((0, 0)) } } } impl ViewExt for ListView { fn title(&self) -> String { self.title.clone() } fn on_command(&mut self, _s: &mut Cursive, cmd: &Command) -> Result { match cmd { Command::Play => { self.queue.clear(); if !self.attempt_play_all_tracks() { let mut content = self.content.write().unwrap(); if let Some(item) = content.get_mut(self.selected) { item.play(self.queue.clone()); } } return Ok(CommandResult::Consumed(None)); } Command::PlayNext => { info!("played next"); let mut content = self.content.write().unwrap(); if let Some(item) = content.get_mut(self.selected) { item.play_next(self.queue.clone()); } return Ok(CommandResult::Consumed(None)); } Command::Queue => { let mut content = self.content.write().unwrap(); if let Some(item) = content.get_mut(self.selected) { item.queue(self.queue.clone()); } return Ok(CommandResult::Consumed(None)); } Command::Save => { let mut item = { let content = self.content.read().unwrap(); content.get(self.selected).cloned() }; if let Some(item) = item.as_mut() { item.save(self.library.clone()); } } Command::Delete => { let mut item = { let content = self.content.read().unwrap(); content.get(self.selected).cloned() }; if let Some(item) = item.as_mut() { item.unsave(self.library.clone()); } } Command::Share(mode) => { let url = match mode { TargetMode::Selected => self.content.read().ok().and_then(|content| { content.get(self.selected).and_then(ListItem::share_url) }), TargetMode::Current => self .queue .get_current() .and_then(|t| t.as_listitem().share_url()), }; if let Some(url) = url { #[cfg(feature = "share_clipboard")] write_share(url); } return Ok(CommandResult::Consumed(None)); } Command::Jump(mode) => match mode { JumpMode::Query(query) => { self.search_query = query.to_lowercase(); self.search_indexes = self.get_indexes_of(query); self.search_selected_index = 0; match self.search_indexes.get(0) { Some(&index) => { self.move_focus_to(index); return Ok(CommandResult::Consumed(None)); } None => return Ok(CommandResult::Ignored), } } JumpMode::Next => { let len = self.search_indexes.len(); if len == 0 { return Ok(CommandResult::Ignored); } let index = self.search_selected_index; let next_index = match index.cmp(&(len - 1)) { Ordering::Equal => 0, _ => index + 1, }; self.move_focus_to(self.search_indexes[next_index]); self.search_selected_index = next_index; return Ok(CommandResult::Consumed(None)); } JumpMode::Previous => { let len = self.search_indexes.len(); if len == 0 { return Ok(CommandResult::Ignored); } let index = self.search_selected_index; let prev_index = match index.cmp(&0) { Ordering::Equal => len - 1, _ => index - 1, }; self.move_focus_to(self.search_indexes[prev_index]); self.search_selected_index = prev_index; return Ok(CommandResult::Consumed(None)); } }, Command::Move(mode, amount) => { let last_idx = self.content.read().unwrap().len().saturating_sub(1); match mode { MoveMode::Up 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::Down 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)); } MoveMode::Down if self.selected == last_idx && self.can_paginate() => { self.pagination.call(&self.content, self.library.clone()); } _ => {} } } Command::Open(mode) => { let queue = self.queue.clone(); let library = self.library.clone(); let target: Option> = match mode { TargetMode::Current => self.queue.get_current().map(|t| t.as_listitem()), TargetMode::Selected => { let content = self.content.read().unwrap(); content.get(self.selected).map(|t| t.as_listitem()) } }; // if item has a dedicated view, show it; otherwise open the context menu if let Some(target) = target { let view = target.open(queue.clone(), library.clone()); return match view { Some(view) => Ok(CommandResult::View(view)), None => { let contextmenu = ContextMenu::new(&*target, queue, library); Ok(CommandResult::Modal(Box::new(contextmenu))) } }; } } Command::Goto(mode) => { let mut content = self.content.write().unwrap(); if let Some(item) = content.get_mut(self.selected) { let queue = self.queue.clone(); let library = self.library.clone(); match mode { GotoMode::Album => { if let Some(album) = item.album(queue.clone()) { let view = AlbumView::new(queue, library, &album).into_boxed_view_ext(); return Ok(CommandResult::View(view)); } } GotoMode::Artist => { if let Some(artists) = item.artists() { return match artists.len() { 0 => Ok(CommandResult::Consumed(None)), 1 => { let view = ArtistView::new(queue, library, &artists[0]) .into_boxed_view_ext(); Ok(CommandResult::View(view)) } _ => { let dialog = ContextMenu::select_artist_dialog( library, queue, artists, ); Ok(CommandResult::Modal(Box::new(dialog))) } }; } } } } } Command::Insert(source) => { let url = match source { InsertSource::Input(url) => Some(url.clone()), #[cfg(feature = "share_clipboard")] InsertSource::Clipboard => read_share().and_then(SpotifyUrl::from_url), }; let spotify = self.queue.get_spotify(); if let Some(url) = url { let target: Option> = match url.uri_type { UriType::Track => spotify .api .track(&url.id) .map(|track| Track::from(&track).as_listitem()), UriType::Album => spotify .api .album(&url.id) .map(|album| Album::from(&album).as_listitem()), UriType::Playlist => spotify .api .playlist(&url.id) .map(|playlist| Playlist::from(&playlist).as_listitem()), UriType::Artist => spotify .api .artist(&url.id) .map(|artist| Artist::from(&artist).as_listitem()), UriType::Episode => spotify .api .episode(&url.id) .map(|episode| Episode::from(&episode).as_listitem()), UriType::Show => spotify .api .get_show(&url.id) .map(|show| Show::from(&show).as_listitem()), }; let queue = self.queue.clone(); let library = self.library.clone(); // if item has a dedicated view, show it; otherwise open the context menu if let Some(target) = target { let view = target.open(queue.clone(), library.clone()); return match view { Some(view) => Ok(CommandResult::View(view)), None => { let contextmenu = ContextMenu::new(target.as_ref(), queue, library); Ok(CommandResult::Modal(Box::new(contextmenu))) } }; } } return Ok(CommandResult::Consumed(None)); } Command::ShowRecommendations(mode) => { let queue = self.queue.clone(); let library = self.library.clone(); let target: Option> = match mode { TargetMode::Current => self.queue.get_current().map(|t| t.as_listitem()), TargetMode::Selected => { let content = self.content.read().unwrap(); content.get(self.selected).map(|t| t.as_listitem()) } }; if let Some(mut target) = target { let view = target.open_recommendations(queue, library); return match view { Some(view) => Ok(CommandResult::View(view)), None => Ok(CommandResult::Consumed(None)), }; } } _ => {} }; Ok(CommandResult::Ignored) } }