use super::get_sort_keybinds;
use super::shared_components::{
BrowserSearchAction, FilterAction, FilterManager, SearchBlock, SortAction, SortManager,
get_adjusted_list_column,
};
use crate::app::AppCallback;
use crate::app::component::actionhandler::{
Action, ActionHandler, ComponentEffect, KeyRouter, Scrollable, Suggestable, TextHandler,
YoutuiEffect,
};
use crate::app::server::{HandleApiError, SearchSongs};
use crate::app::structures::{
BrowserSongsList, ListSong, ListSongDisplayableField, ListStatus, Percentage, SongListComponent,
};
use crate::app::ui::action::{AppAction, TextEntryAction};
use crate::app::view::{
AdvancedTableView, BasicConstraint, FilterString, HasTitle, Loadable, SortDirection,
TableFilterCommand, TableSortCommand, TableView,
};
use crate::config::Config;
use crate::config::keymap::Keymap;
use crate::drawutils::get_offset_after_list_resize;
use crate::widgets::ScrollingTableState;
use anyhow::{Result, bail};
use async_callback_manager::{AsyncTask, Constraint, NoOpHandler};
use itertools::Either;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use tracing::warn;
use ytmapi_rs::common::SearchSuggestion;
use ytmapi_rs::parse::SearchResultSong;
pub struct SongSearchBrowser {
pub input_routing: InputRouting,
song_list: BrowserSongsList,
cur_selected: usize,
pub search_popped: bool,
pub search: SearchBlock,
pub widget_state: ScrollingTableState,
pub sort: SortManager,
pub filter: FilterManager,
}
impl_youtui_component!(SongSearchBrowser);
#[derive(PartialEq, Clone, Copy, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BrowserSongsAction {
Filter,
Sort,
PlaySong,
PlaySongs,
AddSongToPlaylist,
AddSongsToPlaylist,
}
impl Action for BrowserSongsAction {
fn context(&self) -> std::borrow::Cow<'_, str> {
"Song Search Browser".into()
}
fn describe(&self) -> std::borrow::Cow<'_, str> {
match self {
BrowserSongsAction::Filter => "Filter",
BrowserSongsAction::Sort => "Sort",
BrowserSongsAction::PlaySong => "Play song",
BrowserSongsAction::PlaySongs => "Play songs",
BrowserSongsAction::AddSongToPlaylist => "Add song to playlist",
BrowserSongsAction::AddSongsToPlaylist => "Add songs to playlist",
}
.into()
}
}
#[derive(Default)]
pub enum InputRouting {
List,
#[default]
Search,
Filter,
Sort,
}
impl Suggestable for SongSearchBrowser {
fn get_search_suggestions(&self) -> &[SearchSuggestion] {
self.search.get_search_suggestions()
}
fn has_search_suggestions(&self) -> bool {
self.search.has_search_suggestions()
}
}
impl Scrollable for SongSearchBrowser {
fn increment_list(&mut self, amount: isize) {
match self.input_routing {
InputRouting::List => {
self.cur_selected = self
.cur_selected
.saturating_add_signed(amount)
.min(self.get_filtered_items().count().saturating_sub(1))
}
InputRouting::Sort => {
self.sort.cur = self
.sort
.cur
.saturating_add_signed(amount)
.min(self.get_sortable_columns().len().saturating_sub(1));
}
InputRouting::Search => warn!("Tried to increment list when in search box"),
InputRouting::Filter => warn!("Tried to increment list when filter popup shown"),
}
}
fn is_scrollable(&self) -> bool {
matches!(self.input_routing, InputRouting::Sort | InputRouting::List)
}
}
impl TextHandler for SongSearchBrowser {
fn is_text_handling(&self) -> bool {
matches!(
self.input_routing,
InputRouting::Filter | InputRouting::Search
)
}
fn get_text(&self) -> std::option::Option<&str> {
match self.input_routing {
InputRouting::Filter => self.filter.get_text(),
InputRouting::Search => self.search.get_text(),
InputRouting::List | InputRouting::Sort => None,
}
}
fn replace_text(&mut self, text: impl Into<String>) {
match self.input_routing {
InputRouting::Search => self.search.replace_text(text),
InputRouting::Filter => self.filter.replace_text(text),
InputRouting::List => (),
InputRouting::Sort => (),
}
}
fn clear_text(&mut self) -> bool {
match self.input_routing {
InputRouting::Search => self.search.clear_text(),
InputRouting::Filter => self.filter.clear_text(),
InputRouting::List => false,
InputRouting::Sort => false,
}
}
fn handle_text_event_impl(
&mut self,
event: &crossterm::event::Event,
) -> Option<ComponentEffect<Self>> {
match self.input_routing {
InputRouting::Search => self
.search
.handle_text_event_impl(event)
.map(|effect| effect.map_frontend(|this: &mut Self| &mut this.search)),
InputRouting::Filter => self
.filter
.handle_text_event_impl(event)
.map(|effect| effect.map_frontend(|this: &mut Self| &mut this.filter)),
InputRouting::List => None,
InputRouting::Sort => None,
}
}
}
impl ActionHandler<FilterAction> for SongSearchBrowser {
fn apply_action(&mut self, action: FilterAction) -> impl Into<YoutuiEffect<Self>> {
match action {
FilterAction::Close => self.toggle_filter(),
FilterAction::Apply => self.apply_filter(),
FilterAction::ClearFilter => self.clear_filter(),
};
AsyncTask::new_no_op()
}
}
impl ActionHandler<SortAction> for SongSearchBrowser {
fn apply_action(&mut self, action: SortAction) -> impl Into<YoutuiEffect<Self>> {
match action {
SortAction::SortSelectedAsc => self.handle_sort_cur_asc(),
SortAction::SortSelectedDesc => self.handle_sort_cur_desc(),
SortAction::Close => self.close_sort(),
SortAction::ClearSort => self.handle_clear_sort(),
}
AsyncTask::new_no_op()
}
}
impl ActionHandler<BrowserSearchAction> for SongSearchBrowser {
fn apply_action(&mut self, action: BrowserSearchAction) -> impl Into<YoutuiEffect<Self>> {
match action {
BrowserSearchAction::PrevSearchSuggestion => self.search.increment_list(-1),
BrowserSearchAction::NextSearchSuggestion => self.search.increment_list(1),
}
AsyncTask::new_no_op()
}
}
impl ActionHandler<BrowserSongsAction> for SongSearchBrowser {
fn apply_action(&mut self, action: BrowserSongsAction) -> impl Into<YoutuiEffect<Self>> {
match action {
BrowserSongsAction::Filter => self.toggle_filter(),
BrowserSongsAction::Sort => self.handle_pop_sort(),
BrowserSongsAction::PlaySong => return self.play_song().into(),
BrowserSongsAction::PlaySongs => return self.play_songs().into(),
BrowserSongsAction::AddSongToPlaylist => return self.add_song_to_playlist().into(),
BrowserSongsAction::AddSongsToPlaylist => return self.add_songs_to_playlist().into(),
}
YoutuiEffect::new_no_op()
}
}
impl KeyRouter<AppAction> for SongSearchBrowser {
fn get_all_keybinds<'a>(
&self,
config: &'a Config,
) -> impl Iterator<Item = &'a Keymap<AppAction>> + 'a {
[
&config.keybinds.browser_songs,
&config.keybinds.browser_search,
]
.into_iter()
}
fn get_active_keybinds<'a>(
&self,
config: &'a Config,
) -> impl Iterator<Item = &'a Keymap<AppAction>> + 'a {
match self.input_routing {
InputRouting::List => Either::Left(std::iter::once(&config.keybinds.browser_songs)),
InputRouting::Search => Either::Left(std::iter::once(&config.keybinds.browser_search)),
InputRouting::Filter => Either::Left(std::iter::once(&config.keybinds.filter)),
InputRouting::Sort => Either::Right(get_sort_keybinds(config)),
}
}
}
impl SongListComponent for SongSearchBrowser {
fn get_song_from_idx(&self, idx: usize) -> Option<&crate::app::structures::ListSong> {
self.get_filtered_list_iter().nth(idx)
}
}
impl Loadable for SongSearchBrowser {
fn is_loading(&self) -> bool {
matches!(
self.song_list.state,
crate::app::structures::ListStatus::Loading
)
}
}
impl TableView for SongSearchBrowser {
fn get_selected_item(&self) -> usize {
self.cur_selected
}
fn get_state(&self) -> &ScrollingTableState {
&self.widget_state
}
fn get_layout(&self) -> &[crate::app::view::BasicConstraint] {
&[
BasicConstraint::Percentage(Percentage(40)),
BasicConstraint::Percentage(Percentage(30)),
BasicConstraint::Percentage(Percentage(30)),
BasicConstraint::Length(8),
BasicConstraint::Length(10),
]
}
fn get_highlighted_row(&self) -> Option<usize> {
None
}
fn get_items(&self) -> impl ExactSizeIterator<Item = impl Iterator<Item = Cow<'_, str>> + '_> {
self.song_list
.get_list_iter()
.map(|ls| ls.get_fields(Self::subcolumns_of_vec()).into_iter())
}
fn get_headings(&self) -> impl Iterator<Item = &'static str> {
["Song", "Artist", "Album", "Duration", "Plays"].into_iter()
}
fn get_mut_state(&mut self) -> &mut ScrollingTableState {
&mut self.widget_state
}
}
impl AdvancedTableView for SongSearchBrowser {
fn get_sortable_columns(&self) -> &[usize] {
&[0, 1, 2]
}
fn get_sort_commands(&self) -> &[TableSortCommand] {
&self.sort.sort_commands
}
fn push_sort_command(&mut self, sort_command: TableSortCommand) -> Result<()> {
if !self.get_sortable_columns().contains(&sort_command.column) {
bail!(format!("Unable to sort column {}", sort_command.column,));
}
self.song_list.sort(
get_adjusted_list_column(sort_command.column, Self::subcolumns_of_vec())?,
sort_command.direction,
);
self.sort
.sort_commands
.retain(|cmd| cmd.column != sort_command.column);
self.sort.sort_commands.push(sort_command);
Ok(())
}
fn clear_sort_commands(&mut self) {
self.sort.sort_commands.clear();
}
fn get_filter_commands(&self) -> &[TableFilterCommand] {
&self.filter.filter_commands
}
fn push_filter_command(&mut self, filter_command: TableFilterCommand) {
self.filter.filter_commands.push(filter_command)
}
fn clear_filter_commands(&mut self) {
self.filter.filter_commands.clear()
}
fn get_filterable_columns(&self) -> &[usize] {
&[0, 1, 2]
}
fn get_sort_popup_cur(&self) -> usize {
self.sort.cur
}
fn get_filtered_items(&self) -> impl Iterator<Item = impl Iterator<Item = Cow<'_, str>> + '_> {
self.get_filtered_list_iter()
.map(|ls| ls.get_fields(Self::subcolumns_of_vec()).into_iter())
}
fn sort_popup_shown(&self) -> bool {
self.sort.shown
}
fn filter_popup_shown(&self) -> bool {
self.filter.shown
}
fn get_sort_state(&self) -> &ratatui::widgets::ListState {
&self.sort.state
}
fn get_mut_sort_state(&mut self) -> &mut ratatui::widgets::ListState {
&mut self.sort.state
}
fn get_mut_filter_state(&mut self) -> &mut rat_text::text_input::TextInputState {
&mut self.filter.filter_text
}
}
impl HasTitle for SongSearchBrowser {
fn get_title(&self) -> std::borrow::Cow<'_, str> {
match self.song_list.state {
ListStatus::New => "Songs".into(),
ListStatus::Loading => "Songs - loading".into(),
ListStatus::InProgress => format!(
"Songs - {} results - loading",
self.song_list.get_list_iter().len()
)
.into(),
ListStatus::Loaded => {
format!("Songs - {} results", self.song_list.get_list_iter().len()).into()
}
ListStatus::Error => "Songs - Error receieved".into(),
}
}
}
impl SongSearchBrowser {
pub fn new() -> Self {
Self {
input_routing: Default::default(),
song_list: Default::default(),
search_popped: true,
search: Default::default(),
widget_state: Default::default(),
sort: Default::default(),
filter: Default::default(),
cur_selected: Default::default(),
}
}
pub fn subcolumns_of_vec() -> [ListSongDisplayableField; 5] {
[
ListSongDisplayableField::Song,
ListSongDisplayableField::Artists,
ListSongDisplayableField::Album,
ListSongDisplayableField::Duration,
ListSongDisplayableField::Plays,
]
}
pub fn apply_all_sort_commands(&mut self) -> Result<()> {
for c in self.sort.sort_commands.iter() {
if !self.get_sortable_columns().contains(&c.column) {
bail!(format!("Unable to sort column {}", c.column,));
}
self.song_list.sort(
get_adjusted_list_column(c.column, Self::subcolumns_of_vec())?,
c.direction,
);
}
Ok(())
}
pub fn get_filtered_list_iter(&self) -> impl Iterator<Item = &ListSong> + '_ {
self.song_list.get_list_iter().filter(move |ls| {
self.get_filter_commands()
.iter()
.fold(true, |acc, command| {
let match_found = command.matches_row(
ls,
Self::subcolumns_of_vec(),
self.get_filterable_columns(),
); acc && match_found
})
})
}
pub fn apply_filter(&mut self) {
self.filter.shown = false;
self.input_routing = InputRouting::List;
let Some(filter) = self.filter.get_text().map(|s| s.to_string()) else {
return;
};
let cmd = TableFilterCommand::All(crate::app::view::Filter::Contains(
FilterString::CaseInsensitive(filter),
));
let prev_max_cur = self.get_filtered_items().count().saturating_sub(1);
let prev_cur = self.cur_selected;
let prev_offset = self.widget_state.offset();
self.filter.filter_commands.push(cmd);
let new_max_cur = self.get_filtered_items().count().saturating_sub(1);
self.cur_selected = self.cur_selected.min(new_max_cur);
*self.widget_state.offset_mut() = get_offset_after_list_resize(
prev_offset,
prev_cur,
prev_max_cur,
self.cur_selected,
new_max_cur,
);
}
pub fn clear_filter(&mut self) {
self.filter.shown = false;
self.input_routing = InputRouting::List;
self.clear_filter_commands();
}
fn open_sort(&mut self) {
self.sort.shown = true;
self.input_routing = InputRouting::Sort;
}
pub fn toggle_filter(&mut self) {
let shown = self.filter.shown;
if !shown {
self.filter.filter_text.clear();
self.input_routing = InputRouting::Filter;
} else {
self.input_routing = InputRouting::List;
}
self.filter.shown = !shown;
}
pub fn close_sort(&mut self) {
self.sort.shown = false;
self.input_routing = InputRouting::List;
}
pub fn handle_pop_sort(&mut self) {
self.sort.cur = 0;
self.open_sort();
}
pub fn handle_clear_sort(&mut self) {
self.close_sort();
self.clear_sort_commands();
}
pub fn handle_sort_cur_asc(&mut self) {
let Some(column) = self.get_sortable_columns().get(self.sort.cur) else {
warn!("Tried to index sortable columns but was out of range");
return;
};
if let Err(e) = self.push_sort_command(TableSortCommand {
column: *column,
direction: SortDirection::Asc,
}) {
warn!("Tried to sort a column that is not sortable - error {e}")
};
self.close_sort();
}
pub fn handle_sort_cur_desc(&mut self) {
let Some(column) = self.get_sortable_columns().get(self.sort.cur) else {
warn!("Tried to index sortable columns but was out of range");
return;
};
if let Err(e) = self.push_sort_command(TableSortCommand {
column: *column,
direction: SortDirection::Desc,
}) {
warn!("Tried to sort a column that is not sortable - error {e}")
};
self.close_sort();
}
pub fn handle_text_entry_action(&mut self, action: TextEntryAction) -> ComponentEffect<Self> {
if self.is_text_handling()
&& self.search_popped
&& matches!(self.input_routing, InputRouting::Search)
{
match action {
TextEntryAction::Submit => {
return self.search();
}
TextEntryAction::Left => (),
TextEntryAction::Right => (),
TextEntryAction::Backspace => (),
}
}
AsyncTask::new_no_op()
}
pub fn handle_toggle_search(&mut self) {
if self.search_popped {
self.search_popped = false;
self.input_routing = InputRouting::List;
} else {
self.search_popped = true;
self.input_routing = InputRouting::Search;
}
}
pub fn search(&mut self) -> ComponentEffect<Self> {
self.search_popped = false;
self.input_routing = InputRouting::List;
let Some(search_query) = self.search.get_text().map(|s| s.to_string()) else {
return AsyncTask::new_no_op();
};
self.search.clear_text();
AsyncTask::new_future_try(
SearchSongs(search_query),
HandleSearchSongsOk,
HandleSearchSongsErr,
Some(Constraint::new_kill_same_type()),
)
}
pub fn play_song(&mut self) -> impl Into<YoutuiEffect<Self>> + use<> {
let cur_song_idx = self.get_selected_item();
if let Some(cur_song) = self.get_song_from_idx(cur_song_idx) {
return (
AsyncTask::new_no_op(),
Some(AppCallback::AddSongsToPlaylistAndPlay(vec![
cur_song.clone(),
])),
);
}
(AsyncTask::new_no_op(), None)
}
pub fn play_songs(&mut self) -> impl Into<YoutuiEffect<Self>> + use<> {
let cur_idx = self.get_selected_item();
let song_list = self
.get_filtered_list_iter()
.skip(cur_idx)
.cloned()
.collect();
(
AsyncTask::new_no_op(),
Some(AppCallback::AddSongsToPlaylistAndPlay(song_list)),
)
}
pub fn add_songs_to_playlist(&mut self) -> impl Into<YoutuiEffect<Self>> + use<> {
let cur_idx = self.get_selected_item();
let song_list = self
.get_filtered_list_iter()
.skip(cur_idx)
.cloned()
.collect();
(
AsyncTask::new_no_op(),
Some(AppCallback::AddSongsToPlaylist(song_list)),
)
}
pub fn add_song_to_playlist(&mut self) -> impl Into<YoutuiEffect<Self>> + use<> {
let cur_idx = self.get_selected_item();
if let Some(cur_song) = self.get_song_from_idx(cur_idx) {
return (
AsyncTask::new_no_op(),
Some(AppCallback::AddSongsToPlaylist(vec![cur_song.clone()])),
);
}
(AsyncTask::new_no_op(), None)
}
pub fn replace_song_list(&mut self, song_list: Vec<SearchResultSong>) {
self.song_list.clear();
self.song_list.append_raw_search_result_songs(song_list);
if let Err(e) = self.apply_all_sort_commands() {
warn!("Tried to sort a column that is not sortable - error {e}")
};
}
}
#[derive(Debug, PartialEq)]
struct HandleSearchSongsOk;
#[derive(Debug, PartialEq)]
struct HandleSearchSongsErr;
impl_youtui_task_handler!(
HandleSearchSongsOk,
Vec<SearchResultSong>,
SongSearchBrowser,
|_, songs| |this: &mut SongSearchBrowser| { this.replace_song_list(songs) }
);
impl_youtui_task_handler!(
HandleSearchSongsErr,
anyhow::Error,
SongSearchBrowser,
|_, error| |_: &mut SongSearchBrowser| AsyncTask::new_future(
HandleApiError {
error,
message: "Error recieved getting songs".to_string(),
},
NoOpHandler,
None,
)
);
#[cfg(test)]
mod tests {
use crate::app::server::SearchSongs;
use crate::app::ui::browser::songsearch::{
HandleSearchSongsErr, HandleSearchSongsOk, SongSearchBrowser,
};
use async_callback_manager::{AsyncTask, Constraint};
fn get_dummy_song_search_browser() -> SongSearchBrowser {
SongSearchBrowser::new()
}
#[test]
fn test_on_submit_action_search_box_cleared() {
let mut browser = get_dummy_song_search_browser();
browser.search.search_contents.set_text("Search!");
let browser_text = browser.search.search_contents.text();
assert!(!browser_text.is_empty());
let _ = browser.handle_text_entry_action(crate::app::ui::action::TextEntryAction::Submit);
let browser_text = browser.search.search_contents.text();
assert!(browser_text.is_empty());
}
#[test]
fn test_search_returns_effect() {
let mut browser = get_dummy_song_search_browser();
browser.search.search_contents.set_text("Search!");
let effect = browser.search();
let expected_effect = AsyncTask::new_future_try(
SearchSongs("Search!".to_string()),
HandleSearchSongsOk,
HandleSearchSongsErr,
Some(Constraint::new_kill_same_type()),
);
assert_eq!(effect, expected_effect);
}
}