use super::state::{App, Screen};
use crate::podcast::EpisodeInfo;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum EpisodePrompt {
Search,
Jump,
}
fn matches_terms(episode: &EpisodeInfo, terms: &[String]) -> bool {
if terms.is_empty() {
return true;
}
let title = episode.title.to_lowercase();
let description = episode.description.to_lowercase();
let date = episode.pub_date.as_deref().unwrap_or("").to_lowercase();
terms.iter().all(|term| {
title.contains(term.as_str())
|| description.contains(term.as_str())
|| date.contains(term.as_str())
})
}
impl App {
pub fn reset_episode_view(&mut self) {
self.episode_query.clear();
self.episode_prompt = None;
self.episode_jump_buffer.clear();
self.episode_jump_error = None;
self.episode_visible = (0..self.episodes.len()).collect();
}
pub fn episode_filter_active(&self) -> bool {
!self.episode_query.trim().is_empty()
}
pub fn current_episode_index(&self) -> Option<usize> {
self.episode_list_state
.selected()
.and_then(|position| self.episode_visible.get(position).copied())
}
pub fn refresh_episode_visible(&mut self) {
let current = self.current_episode_index();
let terms: Vec<String> = self
.episode_query
.split_whitespace()
.map(str::to_lowercase)
.collect();
self.episode_visible = self
.episodes
.iter()
.enumerate()
.filter(|(_, episode)| matches_terms(episode, &terms))
.map(|(index, _)| index)
.collect();
let position = current
.and_then(|index| {
self.episode_visible
.iter()
.position(|&shown| shown == index)
})
.or(if self.episode_visible.is_empty() {
None
} else {
Some(0)
});
self.episode_list_state.select(position);
}
pub(super) fn episode_prompt_available(&self) -> bool {
self.screen == Screen::EpisodeSelect
&& !self.loading_episodes
&& self.episode_load_error.is_none()
&& !self.episodes.is_empty()
}
pub fn episode_search_open(&mut self) {
if self.episode_prompt_available() {
self.episode_prompt = Some(EpisodePrompt::Search);
}
}
pub fn episode_search_commit(&mut self) {
self.episode_prompt = None;
}
pub fn episode_search_cancel(&mut self) {
self.episode_prompt = None;
self.clear_episode_filter();
}
pub fn clear_episode_filter(&mut self) {
self.episode_query.clear();
self.refresh_episode_visible();
}
pub fn episode_jump_open(&mut self, first_digit: Option<char>) {
if !self.episode_prompt_available() {
return;
}
self.episode_prompt = Some(EpisodePrompt::Jump);
self.episode_jump_buffer.clear();
self.episode_jump_error = None;
if let Some(digit) = first_digit.filter(char::is_ascii_digit) {
self.episode_jump_buffer.push(digit);
}
}
pub fn episode_prompt_edited(&mut self) {
if self.screen != Screen::EpisodeSelect {
return;
}
match self.episode_prompt {
Some(EpisodePrompt::Search) => self.refresh_episode_visible(),
Some(EpisodePrompt::Jump) => {
self.episode_jump_buffer.retain(|c| c.is_ascii_digit());
self.episode_jump_error = None;
}
None => {}
}
}
pub fn episode_jump_commit(&mut self) {
let typed = self.episode_jump_buffer.trim().to_string();
if typed.is_empty() {
self.episode_jump_cancel();
return;
}
let Ok(number) = typed.parse::<usize>() else {
self.episode_jump_error = Some(format!("\"{typed}\" is not an episode number"));
return;
};
let Some(index) = self
.episodes
.iter()
.position(|episode| episode.feed_index == number)
else {
let newest = self
.episodes
.iter()
.map(|episode| episode.feed_index)
.max()
.unwrap_or(0);
self.episode_jump_error = Some(format!(
"No episode #{number} in this feed (it has #1 to #{newest})"
));
return;
};
if !self.episode_visible.contains(&index) {
self.clear_episode_filter();
}
let position = self
.episode_visible
.iter()
.position(|&shown| shown == index);
self.episode_list_state.select(position);
self.episode_jump_cancel();
}
pub fn episode_jump_cancel(&mut self) {
self.episode_prompt = None;
self.episode_jump_buffer.clear();
self.episode_jump_error = None;
}
}
#[cfg(test)]
mod tests {
use super::super::test_app_one_podcast as test_app;
use super::*;
fn episode(title: &str, feed_index: usize, description: &str) -> EpisodeInfo {
EpisodeInfo {
title: title.to_string(),
feed_index,
pub_date: Some("Wed, 10 Sep 2025 06:00:00 +0000".to_string()),
description: description.to_string(),
}
}
fn episodes_app() -> App {
let mut app = test_app();
app.screen = Screen::EpisodeSelect;
app.set_episodes(vec![
episode("Rust in production", 3, "How a team adopted Rust"),
episode(
"Gardening basics",
2,
"Tomatoes and soil, plus a tip on RUST on tools",
),
episode("Cooking with fire", 1, "Grills"),
]);
app
}
fn type_query(app: &mut App, text: &str) {
app.episode_search_open();
app.episode_query.push_str(text);
app.episode_prompt_edited();
}
fn visible_titles(app: &App) -> Vec<&str> {
app.episode_visible
.iter()
.map(|&i| app.episodes[i].title.as_str())
.collect()
}
#[test]
fn search_matches_title_and_description_ignoring_case() {
let mut app = episodes_app();
type_query(&mut app, "rust");
assert_eq!(
visible_titles(&app),
vec!["Rust in production", "Gardening basics"]
);
}
#[test]
fn every_term_must_match_somewhere() {
let mut app = episodes_app();
type_query(&mut app, "rust tomatoes");
assert_eq!(visible_titles(&app), vec!["Gardening basics"]);
}
#[test]
fn search_matches_the_publish_date() {
let mut app = episodes_app();
type_query(&mut app, "sep 2025");
assert_eq!(app.episode_visible.len(), 3);
app.episode_query = "2024".into();
app.episode_prompt_edited();
assert!(app.episode_visible.is_empty());
assert_eq!(app.episode_list_state.selected(), None);
}
#[test]
fn selection_survives_filtering_and_unfiltering() {
let mut app = episodes_app();
app.episode_list_state.select(Some(1));
app.toggle_current_episode();
type_query(&mut app, "cooking");
assert_eq!(visible_titles(&app), vec!["Cooking with fire"]);
app.toggle_current_episode();
assert_eq!(app.selected_feed_indices(), vec![2, 1]);
app.episode_search_cancel();
assert_eq!(app.episode_visible.len(), 3);
assert_eq!(app.selected_feed_indices(), vec![2, 1]);
}
#[test]
fn the_cursor_stays_on_its_episode_while_the_filter_narrows() {
let mut app = episodes_app();
app.episode_list_state.select(Some(1));
type_query(&mut app, "garden");
assert_eq!(app.current_episode_index(), Some(1));
assert_eq!(app.episode_list_state.selected(), Some(0));
}
#[test]
fn select_all_and_navigation_stay_inside_the_filter() {
let mut app = episodes_app();
type_query(&mut app, "rust");
app.episode_move_down();
app.episode_move_down();
assert_eq!(app.episode_list_state.selected(), Some(1));
app.select_all_episodes();
assert_eq!(app.selected_feed_indices(), vec![3, 2]);
app.select_all_episodes();
assert!(app.selected_feed_indices().is_empty());
}
#[test]
fn sorting_keeps_the_filter() {
let mut app = episodes_app();
type_query(&mut app, "rust");
app.toggle_sort_order();
assert_eq!(
visible_titles(&app),
vec!["Gardening basics", "Rust in production"]
);
}
#[test]
fn jump_goes_by_feed_number_under_either_sort_order() {
let mut app = episodes_app();
app.episode_jump_open(Some('2'));
app.episode_jump_commit();
assert_eq!(
app.current_episode_index()
.map(|i| app.episodes[i].feed_index),
Some(2)
);
assert!(app.episode_prompt.is_none());
app.toggle_sort_order();
app.episode_jump_open(Some('3'));
app.episode_jump_commit();
assert_eq!(
app.current_episode_index()
.map(|i| app.episodes[i].feed_index),
Some(3)
);
}
#[test]
fn jump_clears_a_filter_that_hides_the_episode() {
let mut app = episodes_app();
type_query(&mut app, "rust");
app.episode_search_commit();
app.episode_jump_open(Some('1'));
app.episode_jump_commit();
assert!(!app.episode_filter_active());
assert_eq!(app.current_episode_index(), Some(2));
}
#[test]
fn jump_to_a_missing_number_keeps_the_prompt_and_the_cursor() {
let mut app = episodes_app();
app.episode_list_state.select(Some(1));
app.episode_jump_open(Some('9'));
app.episode_jump_buffer.push('9');
app.episode_jump_commit();
assert_eq!(app.episode_prompt, Some(EpisodePrompt::Jump));
assert_eq!(app.episode_list_state.selected(), Some(1));
assert!(app.episode_jump_error.as_deref().unwrap().contains("#99"));
app.episode_jump_buffer.push('x');
app.episode_prompt_edited();
assert_eq!(app.episode_jump_buffer, "99");
assert!(app.episode_jump_error.is_none());
}
#[test]
fn prompts_do_not_open_while_the_list_is_loading() {
let mut app = episodes_app();
app.loading_episodes = true;
app.episode_search_open();
app.episode_jump_open(Some('1'));
assert!(app.episode_prompt.is_none());
}
}