use crate::app::component::actionhandler::{Action, ComponentEffect, Suggestable, TextHandler};
use crate::app::server::{GetSearchSuggestions, HandleApiError};
use crate::app::view::{TableFilterCommand, TableSortCommand};
use anyhow::Context;
use async_callback_manager::{AsyncTask, Constraint, NoOpHandler};
use rat_text::text_input::{TextInputState, handle_events};
use ratatui::widgets::ListState;
use serde::{Deserialize, Serialize};
use ytmapi_rs::common::SearchSuggestion;
#[derive(Default)]
pub struct SearchBlock {
pub search_contents: TextInputState,
pub search_suggestions: Vec<SearchSuggestion>,
pub suggestions_cur: Option<usize>,
}
impl_youtui_component!(SearchBlock);
#[derive(Clone, Default)]
pub struct FilterManager {
pub filter_commands: Vec<TableFilterCommand>,
pub filter_text: TextInputState,
pub shown: bool,
}
impl_youtui_component!(FilterManager);
#[derive(Clone, Default)]
pub struct SortManager {
pub sort_commands: Vec<TableSortCommand>,
pub shown: bool,
pub cur: usize,
pub state: ListState,
}
impl_youtui_component!(SortManager);
#[derive(PartialEq, Clone, Copy, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FilterAction {
Close,
ClearFilter,
Apply,
}
#[derive(PartialEq, Clone, Copy, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SortAction {
Close,
ClearSort,
SortSelectedAsc,
SortSelectedDesc,
}
#[derive(PartialEq, Clone, Copy, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BrowserSearchAction {
PrevSearchSuggestion,
NextSearchSuggestion,
}
impl Action for FilterAction {
fn context(&self) -> std::borrow::Cow<'_, str> {
"Filter".into()
}
fn describe(&self) -> std::borrow::Cow<'_, str> {
match self {
FilterAction::Close => "Close Filter",
FilterAction::Apply => "Apply filter",
FilterAction::ClearFilter => "Clear filter",
}
.into()
}
}
impl Action for SortAction {
fn context(&self) -> std::borrow::Cow<'_, str> {
"Filter".into()
}
fn describe(&self) -> std::borrow::Cow<'_, str> {
match self {
SortAction::Close => "Close sort",
SortAction::ClearSort => "Clear sort",
SortAction::SortSelectedAsc => "Sort ascending",
SortAction::SortSelectedDesc => "Sort descending",
}
.into()
}
}
impl Action for BrowserSearchAction {
fn context(&self) -> std::borrow::Cow<'_, str> {
"Browser Search Panel".into()
}
fn describe(&self) -> std::borrow::Cow<'_, str> {
match self {
BrowserSearchAction::PrevSearchSuggestion => "Prev Search Suggestion",
BrowserSearchAction::NextSearchSuggestion => "Next Search Suggestion",
}
.into()
}
}
impl SortManager {
pub fn new() -> Self {
SortManager {
sort_commands: Default::default(),
shown: Default::default(),
cur: Default::default(),
state: Default::default(),
}
}
}
impl FilterManager {
pub fn new() -> Self {
Self {
filter_text: Default::default(),
filter_commands: Default::default(),
shown: Default::default(),
}
}
}
impl TextHandler for FilterManager {
fn is_text_handling(&self) -> bool {
true
}
fn get_text(&self) -> std::option::Option<&str> {
Some(self.filter_text.text())
}
fn replace_text(&mut self, text: impl Into<String>) {
self.filter_text.set_text(text)
}
fn clear_text(&mut self) -> bool {
self.filter_text.clear()
}
fn handle_text_event_impl(
&mut self,
event: &crossterm::event::Event,
) -> Option<ComponentEffect<Self>> {
match handle_events(&mut self.filter_text, true, event) {
rat_text::event::TextOutcome::Continue => None,
rat_text::event::TextOutcome::Unchanged => Some(AsyncTask::new_no_op()),
rat_text::event::TextOutcome::Changed => Some(AsyncTask::new_no_op()),
rat_text::event::TextOutcome::TextChanged => Some(AsyncTask::new_no_op()),
}
}
}
impl TextHandler for SearchBlock {
fn is_text_handling(&self) -> bool {
true
}
fn get_text(&self) -> std::option::Option<&str> {
Some(self.search_contents.text())
}
fn replace_text(&mut self, text: impl Into<String>) {
self.search_contents.set_text(text);
self.search_contents.move_to_line_end(false);
}
fn clear_text(&mut self) -> bool {
self.search_suggestions.clear();
self.search_contents.clear()
}
fn handle_text_event_impl(
&mut self,
event: &crossterm::event::Event,
) -> Option<ComponentEffect<Self>> {
match handle_events(&mut self.search_contents, true, event) {
rat_text::event::TextOutcome::Continue => None,
rat_text::event::TextOutcome::Unchanged => Some(AsyncTask::new_no_op()),
rat_text::event::TextOutcome::Changed => Some(AsyncTask::new_no_op()),
rat_text::event::TextOutcome::TextChanged => Some(self.fetch_search_suggestions()),
}
}
}
impl Suggestable for SearchBlock {
fn get_search_suggestions(&self) -> &[SearchSuggestion] {
self.search_suggestions.as_slice()
}
fn has_search_suggestions(&self) -> bool {
!self.search_suggestions.is_empty()
}
}
impl SearchBlock {
fn fetch_search_suggestions(&mut self) -> ComponentEffect<Self> {
if self.search_contents.is_empty() {
self.search_suggestions.clear();
return AsyncTask::new_no_op();
}
AsyncTask::new_future_try(
GetSearchSuggestions(self.search_contents.text().to_owned()),
HandleSearchSuggestionsOk,
HandleSearchSuggestionsErr,
Some(Constraint::new_kill_same_type()),
)
}
fn replace_search_suggestions(
&mut self,
search_suggestions: Vec<SearchSuggestion>,
search: String,
) {
if self.get_text() == Some(&search) {
self.search_suggestions = search_suggestions;
self.suggestions_cur = None;
}
}
pub fn increment_list(&mut self, amount: isize) {
if !self.search_suggestions.is_empty() {
self.suggestions_cur = Some(
self.suggestions_cur
.map(|cur| {
cur.saturating_add_signed(amount)
.min(self.search_suggestions.len() - 1)
})
.unwrap_or_default(),
);
self.replace_text(
self.search_suggestions[self.suggestions_cur.expect("Set to non-None value above")]
.get_text(),
);
}
}
}
#[derive(PartialEq, Debug)]
struct HandleSearchSuggestionsOk;
#[derive(PartialEq, Debug)]
struct HandleSearchSuggestionsErr;
impl_youtui_task_handler!(
HandleSearchSuggestionsOk,
(Vec<SearchSuggestion>, String),
SearchBlock,
|_, (suggestions, text)| |this: &mut SearchBlock| this
.replace_search_suggestions(suggestions, text)
);
impl_youtui_task_handler!(
HandleSearchSuggestionsErr,
anyhow::Error,
SearchBlock,
|_, error| |_: &mut SearchBlock| AsyncTask::new_future(
HandleApiError {
error,
message: "Error recieved getting search suggestions".to_string(),
},
NoOpHandler,
None,
)
);
pub fn get_adjusted_list_column<T: Copy, const N: usize>(
target_col: usize,
adjusted_cols: [T; N],
) -> anyhow::Result<T> {
adjusted_cols
.get(target_col)
.with_context(|| {
format!("Unable to sort column, doesn't match up with underlying list. {target_col}",)
})
.copied()
}
#[cfg(test)]
mod tests {
use crate::app::component::actionhandler::TextHandler;
use crate::app::server::GetSearchSuggestions;
use crate::app::ui::browser::shared_components::{
HandleSearchSuggestionsErr, HandleSearchSuggestionsOk, SearchBlock,
get_adjusted_list_column,
};
use async_callback_manager::{AsyncTask, Constraint};
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
#[test]
fn test_get_adjusted_list_column() {
assert_eq!(get_adjusted_list_column(2, [3, 1, 2]).unwrap(), 2);
assert_eq!(get_adjusted_list_column(0, [3, 1, 2]).unwrap(), 3);
assert_eq!(get_adjusted_list_column(1, [3, 1, 2]).unwrap(), 1);
}
#[test]
fn test_get_adjusted_list_column_out_of_bounds() {
assert!(get_adjusted_list_column(3, [3, 1, 2]).is_err())
}
#[test]
fn test_dont_fetch_search_suggestions_when_empty() {
let mut b = SearchBlock::default();
let effect = b.fetch_search_suggestions();
assert!(effect.is_no_op());
}
#[test]
fn test_search_suggestions_fetch_effect() {
let mut b = SearchBlock::default();
b.search_contents.set_text("The beatles");
let effect = b.fetch_search_suggestions();
let expected_effect = AsyncTask::new_future_try(
GetSearchSuggestions("The beatles".to_string()),
HandleSearchSuggestionsOk,
HandleSearchSuggestionsErr,
Some(Constraint::new_kill_same_type()),
);
assert_eq!(effect, expected_effect);
}
#[test]
fn test_search_suggestions_fetched_on_change() {
let mut b = SearchBlock::default();
let effect = b
.try_handle_text(&crossterm::event::Event::Key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('A'),
KeyModifiers::empty(),
),
))
.unwrap();
let expected_effect = AsyncTask::new_future_try(
GetSearchSuggestions("A".to_string()),
HandleSearchSuggestionsOk,
HandleSearchSuggestionsErr,
Some(Constraint::new_kill_same_type()),
);
assert_eq!(effect, expected_effect)
}
}