use crate::app::{AppCommand, AppMode, AppState, RssScreen, RssSectionFocus};
use crate::config::RssFilterMode;
use crate::tui::formatters::centered_rect;
use crate::tui::screen_context::ScreenContext;
use chrono::{DateTime, Local, Utc};
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use ratatui::crossterm::event::{Event as CrosstermEvent, KeyCode, KeyEventKind};
use ratatui::{prelude::*, widgets::*};
use reqwest::Url;
use std::collections::{HashMap, HashSet};
use std::net::IpAddr;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
#[derive(Clone, Debug, PartialEq)]
pub enum RssAction {
ToNormal,
ToggleHistory,
FocusNext,
MoveUp,
MoveDown,
TriggerSync,
InsertChar(char),
Backspace,
CommitInput,
CancelInput,
AddEntry,
DeleteEntry,
ConfirmDeleteEntry,
CancelDeleteEntry,
ToggleFeedEnabled,
StartSearch,
DownloadSelectedExplorer,
ToggleFilterMode,
}
#[derive(Default)]
pub struct RssReduceResult {
pub effects: Vec<RssAction>,
}
fn map_key_to_rss_action(
key_code: KeyCode,
key_kind: KeyEventKind,
app_state: &AppState,
) -> Option<RssAction> {
if key_kind != KeyEventKind::Press {
return None;
}
if app_state.ui.rss.delete_confirm_armed {
return match key_code {
KeyCode::Char('Y') => Some(RssAction::ConfirmDeleteEntry),
KeyCode::Esc | KeyCode::Char('q') => Some(RssAction::CancelDeleteEntry),
_ => None,
};
}
if app_state.ui.rss.is_editing || app_state.ui.rss.is_searching {
return match key_code {
KeyCode::Esc => Some(RssAction::CancelInput),
KeyCode::Enter => Some(RssAction::CommitInput),
KeyCode::Backspace => Some(RssAction::Backspace),
KeyCode::Tab
if app_state.ui.rss.is_editing
&& matches!(app_state.ui.rss.focused_section, RssSectionFocus::Filters) =>
{
Some(RssAction::ToggleFilterMode)
}
KeyCode::Char(c) => Some(RssAction::InsertChar(c)),
_ => None,
};
}
match key_code {
KeyCode::Esc | KeyCode::Char('q') => Some(RssAction::ToNormal),
KeyCode::Char('h') => Some(RssAction::ToggleHistory),
KeyCode::Tab => Some(RssAction::FocusNext),
KeyCode::Char('s') => Some(RssAction::TriggerSync),
KeyCode::Char('a') => Some(RssAction::AddEntry),
KeyCode::Char('D') => Some(RssAction::DeleteEntry),
KeyCode::Char(' ') => Some(RssAction::ToggleFeedEnabled),
KeyCode::Char('/') => Some(RssAction::StartSearch),
KeyCode::Char('Y') => Some(RssAction::DownloadSelectedExplorer),
KeyCode::Up | KeyCode::Char('k') => Some(RssAction::MoveUp),
KeyCode::Down | KeyCode::Char('j') => Some(RssAction::MoveDown),
_ => None,
}
}
fn reduce_rss_action(action: RssAction) -> RssReduceResult {
RssReduceResult {
effects: vec![action],
}
}
fn next_focus(current: RssSectionFocus) -> RssSectionFocus {
match current {
RssSectionFocus::Links => RssSectionFocus::Filters,
RssSectionFocus::Filters => RssSectionFocus::Explorer,
RssSectionFocus::Explorer => RssSectionFocus::Links,
}
}
fn selected_index_mut(app_state: &mut AppState) -> &mut usize {
if matches!(app_state.ui.rss.active_screen, RssScreen::History) {
return &mut app_state.ui.rss.selected_history_index;
}
match app_state.ui.rss.focused_section {
RssSectionFocus::Links => &mut app_state.ui.rss.selected_feed_index,
RssSectionFocus::Filters => &mut app_state.ui.rss.selected_filter_index,
RssSectionFocus::Explorer => &mut app_state.ui.rss.selected_explorer_index,
}
}
fn current_list_len(app_state: &AppState, settings: &crate::config::Settings) -> usize {
if matches!(app_state.ui.rss.active_screen, RssScreen::History) {
return filtered_history_entries(
&app_state.rss_runtime.history,
&app_state.ui.rss.search_query,
)
.len();
}
match app_state.ui.rss.focused_section {
RssSectionFocus::Links => settings.rss.feeds.len(),
RssSectionFocus::Filters => settings.rss.filters.len(),
RssSectionFocus::Explorer => app_state.rss_derived.explorer_items.len(),
}
}
fn sorted_feed_indices(settings: &crate::config::Settings) -> Vec<usize> {
let mut indices: Vec<usize> = (0..settings.rss.feeds.len()).collect();
indices.sort_by(|a, b| {
settings.rss.feeds[*a]
.url
.to_lowercase()
.cmp(&settings.rss.feeds[*b].url.to_lowercase())
});
indices
}
fn selected_feed_actual_idx(
settings: &crate::config::Settings,
selected_display_idx: usize,
) -> Option<usize> {
sorted_feed_indices(settings)
.get(selected_display_idx)
.copied()
}
fn sorted_filter_indices(settings: &crate::config::Settings) -> Vec<usize> {
let mut indices: Vec<usize> = (0..settings.rss.filters.len()).collect();
indices.sort_by(|a, b| {
settings.rss.filters[*a]
.query
.to_lowercase()
.cmp(&settings.rss.filters[*b].query.to_lowercase())
});
indices
}
fn selected_filter_actual_idx(
settings: &crate::config::Settings,
selected_display_idx: usize,
) -> Option<usize> {
sorted_filter_indices(settings)
.get(selected_display_idx)
.copied()
}
#[derive(Clone)]
struct FilterSpec {
query: String,
mode: RssFilterMode,
}
struct PreparedFilter {
mode: RssFilterMode,
query_lc: String,
regex: Option<regex::Regex>,
}
fn prepare_filter(query: &str, mode: RssFilterMode) -> Option<PreparedFilter> {
let trimmed = query.trim();
if trimmed.is_empty() {
return None;
}
let regex = if matches!(mode, RssFilterMode::Regex) {
regex::RegexBuilder::new(trimmed)
.case_insensitive(true)
.build()
.ok()
} else {
None
};
Some(PreparedFilter {
mode,
query_lc: trimmed.to_lowercase(),
regex,
})
}
fn prepared_filter_matches(
title: &str,
title_lc: &str,
filter: &PreparedFilter,
matcher: &SkimMatcherV2,
) -> bool {
match filter.mode {
RssFilterMode::Fuzzy => matcher.fuzzy_match(title_lc, &filter.query_lc).is_some(),
RssFilterMode::Regex => filter.regex.as_ref().is_some_and(|re| re.is_match(title)),
}
}
fn enabled_filters(settings: &crate::config::Settings) -> Vec<FilterSpec> {
settings
.rss
.filters
.iter()
.filter(|f| f.enabled)
.map(|f| FilterSpec {
query: f.query.trim().to_string(),
mode: f.mode,
})
.filter(|f| !f.query.is_empty())
.collect()
}
fn filter_already_exists(
settings: &crate::config::Settings,
query: &str,
mode: RssFilterMode,
) -> bool {
let normalized = query.trim();
settings
.rss
.filters
.iter()
.any(|f| f.mode == mode && f.query.trim().eq_ignore_ascii_case(normalized))
}
fn filter_matches_title(
title: &str,
filter_query: &str,
mode: RssFilterMode,
matcher: &SkimMatcherV2,
) -> bool {
let Some(filter) = prepare_filter(filter_query, mode) else {
return false;
};
let title_lc = title.to_lowercase();
prepared_filter_matches(title, &title_lc, &filter, matcher)
}
fn explorer_should_be_greyed_out(settings: &crate::config::Settings) -> bool {
settings.rss.filters.iter().all(|f| !f.enabled)
}
fn explorer_effective_greyed_out(app_state: &AppState, settings: &crate::config::Settings) -> bool {
let is_creating_filter = app_state.ui.rss.is_editing
&& matches!(app_state.ui.rss.focused_section, RssSectionFocus::Filters);
let has_draft = !app_state.ui.rss.edit_buffer.trim().is_empty();
explorer_should_be_greyed_out(settings) && !(is_creating_filter && has_draft)
}
fn is_valid_feed_url(value: &str) -> bool {
let Ok(url) = Url::parse(value) else {
return false;
};
if !matches!(url.scheme(), "http" | "https") {
return false;
}
if url.host_str().is_none() || !url.username().is_empty() || url.password().is_some() {
return false;
}
if let Some(host) = url.host_str() {
if host.eq_ignore_ascii_case("localhost") {
return false;
}
if let Ok(ip) = host.parse::<IpAddr>() {
match ip {
IpAddr::V4(v4) => {
if v4.is_private()
|| v4.is_loopback()
|| v4.is_link_local()
|| v4.is_multicast()
|| v4.is_broadcast()
|| v4.is_documentation()
|| v4.is_unspecified()
{
return false;
}
}
IpAddr::V6(v6) => {
if v6.is_loopback()
|| v6.is_multicast()
|| v6.is_unspecified()
|| v6.is_unique_local()
|| v6.is_unicast_link_local()
{
return false;
}
}
}
}
}
true
}
fn truncate_with_ellipsis(input: &str, max_chars: usize) -> String {
if max_chars == 0 {
return String::new();
}
let char_count = input.chars().count();
if char_count <= max_chars {
return input.to_string();
}
if max_chars <= 3 {
return ".".repeat(max_chars);
}
let mut out = String::new();
for ch in input.chars().take(max_chars - 3) {
out.push(ch);
}
out.push_str("...");
out
}
fn execute_rss_effects(
app_state: &mut AppState,
settings: &crate::config::Settings,
app_command_tx: &mpsc::Sender<AppCommand>,
effects: Vec<RssAction>,
) {
if app_state.rss_derived.explorer_items.is_empty()
&& !app_state.rss_runtime.preview_items.is_empty()
{
recompute_rss_derived(app_state, settings);
}
fn set_rss_status(app_state: &mut AppState, message: impl Into<String>) {
app_state.ui.rss.status_message = Some(message.into());
}
fn try_update_config(
app_state: &mut AppState,
app_command_tx: &mpsc::Sender<AppCommand>,
new_settings: crate::config::Settings,
success_message: Option<&str>,
) -> bool {
if app_command_tx
.try_send(AppCommand::UpdateConfig(new_settings))
.is_err()
{
set_rss_status(app_state, "RSS settings enqueue failed");
return false;
}
if let Some(message) = success_message {
set_rss_status(app_state, message);
}
true
}
let mut recompute_needed = false;
for effect in effects {
match effect {
RssAction::ToNormal => app_state.mode = AppMode::Normal,
RssAction::ToggleHistory => {
if matches!(app_state.ui.rss.active_screen, RssScreen::History) {
app_state.ui.rss.active_screen = RssScreen::Unified;
app_state.ui.rss.focused_section = RssSectionFocus::Explorer;
} else {
app_state.ui.rss.active_screen = RssScreen::History;
}
}
RssAction::FocusNext => {
if matches!(app_state.ui.rss.active_screen, RssScreen::Unified) {
app_state.ui.rss.focused_section = next_focus(app_state.ui.rss.focused_section);
}
}
RssAction::ToggleFilterMode => {
if app_state.ui.rss.is_editing
&& matches!(app_state.ui.rss.focused_section, RssSectionFocus::Filters)
{
app_state.ui.rss.add_filter_mode = match app_state.ui.rss.add_filter_mode {
RssFilterMode::Fuzzy => RssFilterMode::Regex,
RssFilterMode::Regex => RssFilterMode::Fuzzy,
};
recompute_needed = true;
}
}
RssAction::MoveUp => {
let len = current_list_len(app_state, settings);
let index = selected_index_mut(app_state);
if len > 0 {
*index = index.saturating_sub(1);
} else {
*index = 0;
}
}
RssAction::MoveDown => {
let len = current_list_len(app_state, settings);
let index = selected_index_mut(app_state);
if len > 0 {
*index = (*index + 1).min(len - 1);
} else {
*index = 0;
}
}
RssAction::TriggerSync => {
let now = Instant::now();
if let Some(last) = app_state.ui.rss.last_sync_request_at {
if now.duration_since(last) < Duration::from_secs(1) {
set_rss_status(app_state, "RSS sync throttled");
continue;
}
}
app_state.ui.rss.last_sync_request_at = Some(now);
if !settings.rss.enabled {
let mut new_settings = settings.clone();
new_settings.rss.enabled = true;
if !try_update_config(app_state, app_command_tx, new_settings, None) {
continue;
}
}
if app_command_tx.try_send(AppCommand::RssSyncNow).is_err() {
set_rss_status(app_state, "RSS sync enqueue failed");
} else {
set_rss_status(app_state, "RSS sync requested");
}
}
RssAction::InsertChar(c) => {
if app_state.ui.rss.is_editing {
app_state.ui.rss.edit_buffer.push(c);
if matches!(app_state.ui.rss.focused_section, RssSectionFocus::Filters) {
app_state.ui.rss.filter_draft = app_state.ui.rss.edit_buffer.clone();
recompute_needed = true;
}
} else if app_state.ui.rss.is_searching {
app_state.ui.rss.search_query.push(c);
recompute_needed = true;
}
}
RssAction::Backspace => {
if app_state.ui.rss.is_editing {
app_state.ui.rss.edit_buffer.pop();
if matches!(app_state.ui.rss.focused_section, RssSectionFocus::Filters) {
app_state.ui.rss.filter_draft = app_state.ui.rss.edit_buffer.clone();
recompute_needed = true;
}
} else if app_state.ui.rss.is_searching {
app_state.ui.rss.search_query.pop();
recompute_needed = true;
}
}
RssAction::CommitInput => {
if app_state.ui.rss.is_editing {
let value = app_state.ui.rss.edit_buffer.trim().to_string();
if !value.is_empty() {
let mut new_settings = settings.clone();
match app_state.ui.rss.focused_section {
RssSectionFocus::Links => {
if !is_valid_feed_url(&value) {
set_rss_status(app_state, "Invalid feed URL (use http/https)");
app_state.ui.rss.is_editing = false;
app_state.ui.rss.edit_buffer.clear();
continue;
}
new_settings.rss.enabled = true;
new_settings.rss.feeds.push(crate::config::RssFeed {
url: value,
enabled: true,
});
}
RssSectionFocus::Filters => {
if matches!(app_state.ui.rss.add_filter_mode, RssFilterMode::Regex)
&& regex::Regex::new(&value).is_err()
{
set_rss_status(app_state, "Invalid regex pattern");
continue;
}
if filter_already_exists(
&new_settings,
&value,
app_state.ui.rss.add_filter_mode,
) {
set_rss_status(app_state, "Filter already exists");
continue;
}
new_settings.rss.filters.push(crate::config::RssFilter {
query: value,
mode: app_state.ui.rss.add_filter_mode,
enabled: true,
});
app_state.ui.rss.filter_draft.clear();
}
RssSectionFocus::Explorer => {}
}
let success_message = match app_state.ui.rss.focused_section {
RssSectionFocus::Links => Some("Link added"),
RssSectionFocus::Filters => Some("Filter added"),
RssSectionFocus::Explorer => None,
};
let _ = try_update_config(
app_state,
app_command_tx,
new_settings,
success_message,
);
}
app_state.ui.rss.is_editing = false;
app_state.ui.rss.edit_buffer.clear();
app_state.ui.rss.add_filter_mode = RssFilterMode::Fuzzy;
recompute_needed = true;
} else if app_state.ui.rss.is_searching {
if app_state.ui.rss.search_query.trim().is_empty() {
app_state.ui.rss.is_searching = false;
set_rss_status(app_state, "Search cleared");
} else {
set_rss_status(app_state, "Search applied");
}
recompute_needed = true;
}
}
RssAction::CancelInput => {
if app_state.ui.rss.is_editing {
app_state.ui.rss.is_editing = false;
app_state.ui.rss.edit_buffer.clear();
app_state.ui.rss.filter_draft.clear();
app_state.ui.rss.add_filter_mode = RssFilterMode::Fuzzy;
set_rss_status(app_state, "Edit cancelled");
recompute_needed = true;
} else if app_state.ui.rss.is_searching {
app_state.ui.rss.is_searching = false;
app_state.ui.rss.search_query.clear();
set_rss_status(app_state, "Search cleared");
recompute_needed = true;
} else {
app_state.mode = AppMode::Normal;
}
}
RssAction::AddEntry => {
if matches!(app_state.ui.rss.active_screen, RssScreen::Unified)
&& matches!(
app_state.ui.rss.focused_section,
RssSectionFocus::Links | RssSectionFocus::Filters
)
{
app_state.ui.rss.is_editing = true;
app_state.ui.rss.edit_buffer.clear();
if matches!(app_state.ui.rss.focused_section, RssSectionFocus::Filters) {
app_state.ui.rss.add_filter_mode = RssFilterMode::Fuzzy;
}
set_rss_status(app_state, "Editing new entry");
recompute_needed = true;
}
}
RssAction::DeleteEntry => {
if !matches!(app_state.ui.rss.active_screen, RssScreen::Unified) {
continue;
}
match app_state.ui.rss.focused_section {
RssSectionFocus::Links => {
if selected_feed_actual_idx(settings, app_state.ui.rss.selected_feed_index)
.is_some()
{
app_state.ui.rss.delete_confirm_armed = true;
set_rss_status(app_state, "Press Y to confirm delete");
}
}
RssSectionFocus::Filters => {
if selected_filter_actual_idx(
settings,
app_state.ui.rss.selected_filter_index,
)
.is_some()
{
app_state.ui.rss.delete_confirm_armed = true;
set_rss_status(app_state, "Press Y to confirm delete");
}
}
RssSectionFocus::Explorer => {}
}
}
RssAction::ConfirmDeleteEntry => {
if !app_state.ui.rss.delete_confirm_armed
|| !matches!(app_state.ui.rss.active_screen, RssScreen::Unified)
{
continue;
}
app_state.ui.rss.delete_confirm_armed = false;
let mut new_settings = settings.clone();
match app_state.ui.rss.focused_section {
RssSectionFocus::Links => {
if let Some(idx) = selected_feed_actual_idx(
&new_settings,
app_state.ui.rss.selected_feed_index,
) {
new_settings.rss.feeds.remove(idx);
app_state.ui.rss.selected_feed_index =
app_state.ui.rss.selected_feed_index.saturating_sub(1);
let _ = try_update_config(
app_state,
app_command_tx,
new_settings,
Some("Link deleted"),
);
recompute_needed = true;
}
}
RssSectionFocus::Filters => {
if let Some(idx) = selected_filter_actual_idx(
&new_settings,
app_state.ui.rss.selected_filter_index,
) {
new_settings.rss.filters.remove(idx);
app_state.ui.rss.selected_filter_index =
app_state.ui.rss.selected_filter_index.saturating_sub(1);
let _ = try_update_config(
app_state,
app_command_tx,
new_settings,
Some("Filter deleted"),
);
recompute_needed = true;
}
}
RssSectionFocus::Explorer => {}
}
}
RssAction::CancelDeleteEntry => {
if app_state.ui.rss.delete_confirm_armed {
app_state.ui.rss.delete_confirm_armed = false;
set_rss_status(app_state, "Delete cancelled");
}
}
RssAction::ToggleFeedEnabled => {
if !matches!(app_state.ui.rss.active_screen, RssScreen::Unified) {
continue;
}
let mut new_settings = settings.clone();
match app_state.ui.rss.focused_section {
RssSectionFocus::Links => {
if let Some(idx) = selected_feed_actual_idx(
&new_settings,
app_state.ui.rss.selected_feed_index,
) {
new_settings.rss.feeds[idx].enabled =
!new_settings.rss.feeds[idx].enabled;
let enabled = new_settings.rss.feeds[idx].enabled;
let _ = try_update_config(
app_state,
app_command_tx,
new_settings,
Some(if enabled {
"Link enabled"
} else {
"Link disabled"
}),
);
recompute_needed = true;
}
}
RssSectionFocus::Filters => {
if let Some(idx) = selected_filter_actual_idx(
&new_settings,
app_state.ui.rss.selected_filter_index,
) {
new_settings.rss.filters[idx].enabled =
!new_settings.rss.filters[idx].enabled;
let enabled = new_settings.rss.filters[idx].enabled;
let _ = try_update_config(
app_state,
app_command_tx,
new_settings,
Some(if enabled {
"Filter enabled"
} else {
"Filter disabled"
}),
);
recompute_needed = true;
}
}
RssSectionFocus::Explorer => {}
}
}
RssAction::StartSearch => {
if (matches!(app_state.ui.rss.active_screen, RssScreen::Unified)
&& matches!(app_state.ui.rss.focused_section, RssSectionFocus::Explorer))
|| matches!(app_state.ui.rss.active_screen, RssScreen::History)
{
app_state.ui.rss.is_searching = true;
set_rss_status(app_state, "Search mode");
recompute_needed = true;
}
}
RssAction::DownloadSelectedExplorer => {
if matches!(app_state.ui.rss.active_screen, RssScreen::Unified)
&& matches!(app_state.ui.rss.focused_section, RssSectionFocus::Explorer)
{
let idx = app_state
.ui
.rss
.selected_explorer_index
.min(app_state.rss_derived.explorer_items.len().saturating_sub(1));
if let Some(item) = app_state.rss_derived.explorer_items.get(idx) {
if app_command_tx
.try_send(AppCommand::RssDownloadPreview(item.clone()))
.is_err()
{
set_rss_status(app_state, "RSS download enqueue failed");
} else {
set_rss_status(app_state, "RSS download requested");
}
}
}
}
}
}
if recompute_needed {
recompute_rss_derived(app_state, settings);
}
}
fn apply_pasted_text(app_state: &mut AppState, pasted_text: &str) {
let trimmed = pasted_text.trim();
if trimmed.is_empty() {
return;
}
if app_state.ui.rss.is_editing {
app_state.ui.rss.edit_buffer.push_str(trimmed);
if matches!(app_state.ui.rss.focused_section, RssSectionFocus::Filters) {
app_state.ui.rss.filter_draft = app_state.ui.rss.edit_buffer.clone();
}
app_state.ui.rss.status_message = Some("Pasted input".to_string());
} else if app_state.ui.rss.is_searching {
app_state.ui.rss.search_query.push_str(trimmed);
app_state.ui.rss.status_message = Some("Pasted search".to_string());
}
}
pub fn handle_event(
event: CrosstermEvent,
app_state: &mut AppState,
settings: &crate::config::Settings,
app_command_tx: &mpsc::Sender<AppCommand>,
) {
if !matches!(app_state.mode, AppMode::Rss) {
return;
}
match event {
CrosstermEvent::Key(key) => {
if let Some(action) = map_key_to_rss_action(key.code, key.kind, app_state) {
let result = reduce_rss_action(action);
execute_rss_effects(app_state, settings, app_command_tx, result.effects);
app_state.ui.needs_redraw = true;
}
}
CrosstermEvent::Paste(pasted_text) => {
apply_pasted_text(app_state, pasted_text.as_str());
recompute_rss_derived(app_state, settings);
app_state.ui.needs_redraw = true;
}
_ => {}
}
}
fn draw_input_panel(f: &mut Frame, area: Rect, screen: &ScreenContext<'_>) {
let app_state = screen.app.state;
let ctx = screen.theme;
let (title, value) = if app_state.ui.rss.is_searching {
(
" RSS Search ".to_string(),
app_state.ui.rss.search_query.clone(),
)
} else {
let label = match app_state.ui.rss.focused_section {
RssSectionFocus::Links => "Add Link",
RssSectionFocus::Filters => "Add Filter",
RssSectionFocus::Explorer => "Input",
};
(
format!(" RSS {} ", label),
app_state.ui.rss.edit_buffer.clone(),
)
};
let mut line_spans = vec![
Span::styled(
"> ",
ctx.apply(Style::default().fg(ctx.state_selected()).bold()),
),
Span::raw(value),
Span::styled("_", ctx.apply(Style::default().fg(ctx.state_warning()))),
];
if app_state.ui.rss.is_editing
&& matches!(app_state.ui.rss.focused_section, RssSectionFocus::Filters)
{
let (fuzzy_style, regex_style) = match app_state.ui.rss.add_filter_mode {
RssFilterMode::Fuzzy => (
ctx.apply(Style::default().fg(ctx.state_selected()).bold()),
ctx.apply(Style::default().fg(ctx.theme.semantic.overlay0)),
),
RssFilterMode::Regex => (
ctx.apply(Style::default().fg(ctx.theme.semantic.overlay0)),
ctx.apply(Style::default().fg(ctx.state_selected()).bold()),
),
};
line_spans.push(Span::raw(" "));
line_spans.push(Span::styled("Fuzzy", fuzzy_style));
line_spans.push(Span::raw(" / "));
line_spans.push(Span::styled("Regex", regex_style));
}
let line = Line::from(line_spans);
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.padding(Padding::horizontal(1))
.border_style(ctx.apply(Style::default().fg(ctx.state_selected())));
f.render_widget(Paragraph::new(line).block(block), area);
}
fn draw_shared_footer(f: &mut Frame, area: Rect, screen: &ScreenContext<'_>) {
let ctx = screen.theme;
let app_state = screen.app.state;
let mut footer_spans = vec![];
let mut push_action = |key: &str, action: &str, key_color: Color| {
footer_spans.push(Span::styled(
format!("[{}]", key),
ctx.apply(Style::default().fg(key_color).bold()),
));
footer_spans.push(Span::styled(
action.to_string(),
ctx.apply(Style::default().fg(ctx.theme.semantic.subtext0)),
));
footer_spans.push(Span::styled(
" | ",
ctx.apply(Style::default().fg(ctx.theme.semantic.overlay0)),
));
};
if app_state.ui.rss.delete_confirm_armed {
push_action("Y", "confirm-delete", ctx.state_error());
push_action("Esc", "cancel", ctx.state_selected());
} else if app_state.ui.rss.is_editing {
push_action("Enter", "save", ctx.state_success());
push_action("Esc", "cancel", ctx.state_error());
if matches!(app_state.ui.rss.focused_section, RssSectionFocus::Filters) {
push_action("Tab", "mode", ctx.state_selected());
}
} else if app_state.ui.rss.is_searching {
push_action("Enter", "apply", ctx.state_success());
push_action("Esc", "clear", ctx.state_error());
} else {
push_action("Tab", "next-pane", ctx.state_selected());
push_action("h", "history", ctx.accent_sapphire());
push_action("s", "ync", ctx.state_warning());
match app_state.ui.rss.active_screen {
RssScreen::Unified => match app_state.ui.rss.focused_section {
RssSectionFocus::Links => {
push_action("a", "dd", ctx.state_success());
push_action("D", "elete", ctx.state_error());
push_action("Space", "toggle", ctx.state_info());
}
RssSectionFocus::Filters => {
push_action("a", "dd", ctx.state_success());
push_action("D", "elete", ctx.state_error());
push_action("Space", "toggle", ctx.state_info());
}
RssSectionFocus::Explorer => {
push_action("/", "search", ctx.accent_sapphire());
push_action("Y", "download", ctx.state_success());
}
},
RssScreen::History => {}
}
push_action("Esc", "back", ctx.state_error());
}
if !footer_spans.is_empty() {
footer_spans.pop();
}
let footer = Line::from(footer_spans);
let p = Paragraph::new(footer)
.style(ctx.apply(Style::default().fg(ctx.theme.semantic.subtext1)))
.alignment(Alignment::Center);
f.render_widget(p, area);
}
fn selected_delete_label(
app_state: &AppState,
settings: &crate::config::Settings,
) -> Option<String> {
if !matches!(app_state.ui.rss.active_screen, RssScreen::Unified) {
return None;
}
match app_state.ui.rss.focused_section {
RssSectionFocus::Links => {
selected_feed_actual_idx(settings, app_state.ui.rss.selected_feed_index)
.and_then(|idx| settings.rss.feeds.get(idx))
.map(|feed| truncate_with_ellipsis(&feed.url, 72))
}
RssSectionFocus::Filters => {
selected_filter_actual_idx(settings, app_state.ui.rss.selected_filter_index)
.and_then(|idx| settings.rss.filters.get(idx))
.map(|filter| truncate_with_ellipsis(&filter.query, 72))
}
RssSectionFocus::Explorer => None,
}
}
fn draw_delete_confirm_dialog(f: &mut Frame, area: Rect, screen: &ScreenContext<'_>) {
let app_state = screen.app.state;
let settings = screen.settings;
let ctx = screen.theme;
let target =
selected_delete_label(app_state, settings).unwrap_or_else(|| "selected item".to_string());
let rect_width = if area.width < 60 { 90 } else { 50 };
let rect_height = if area.height < 20 { 95 } else { 18 };
let dialog = centered_rect(rect_width, rect_height, area);
f.render_widget(Clear, dialog);
let vert_padding = if dialog.height < 10 { 0 } else { 1 };
let block = Block::default()
.borders(Borders::ALL)
.padding(Padding::new(2, 2, vert_padding, vert_padding))
.border_style(ctx.apply(Style::default().fg(ctx.state_error())));
let inner = block.inner(dialog);
f.render_widget(block, dialog);
let chunks = Layout::vertical([
Constraint::Length(2),
Constraint::Min(0),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner);
f.render_widget(
Paragraph::new(vec![
Line::from(Span::styled(
"Delete RSS Entry",
ctx.apply(Style::default().fg(ctx.state_warning()).bold().underlined()),
)),
Line::from(Span::styled(
target,
ctx.apply(Style::default().fg(ctx.theme.semantic.text)),
)),
])
.alignment(Alignment::Center),
chunks[0],
);
if chunks[1].height > 0 {
f.render_widget(
Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(
"This removes the selected RSS link/filter.",
ctx.apply(Style::default().fg(ctx.theme.semantic.text)),
)),
Line::from(Span::styled(
"This action cannot be undone.",
ctx.apply(Style::default().fg(ctx.state_error()).bold()),
)),
])
.alignment(Alignment::Center)
.wrap(Wrap { trim: true }),
chunks[1],
);
}
let actions = Line::from(vec![
Span::styled(
"[Y]",
ctx.apply(Style::default().fg(ctx.state_success()).bold()),
),
Span::raw(" Confirm "),
Span::styled(
"[Esc]",
ctx.apply(Style::default().fg(ctx.state_error()).bold()),
),
Span::raw(" Cancel"),
]);
f.render_widget(
Paragraph::new(actions).alignment(Alignment::Center),
chunks[3],
);
}
fn sync_countdown_label(next_sync_at: &str) -> Option<String> {
let next_sync = DateTime::parse_from_rfc3339(next_sync_at).ok()?;
let remaining_secs = next_sync
.with_timezone(&Utc)
.signed_duration_since(Utc::now())
.num_seconds();
if remaining_secs <= 0 {
return None;
}
let hours = remaining_secs / 3600;
let minutes = (remaining_secs % 3600) / 60;
let seconds = remaining_secs % 60;
let label = if hours > 0 {
format!("{}h {}m {}s", hours, minutes, seconds)
} else if minutes > 0 {
format!("{}m {}s", minutes, seconds)
} else {
format!("{}s", seconds)
};
Some(label)
}
fn filtered_history_entries<'a>(
history: &'a [crate::config::RssHistoryEntry],
search_query: &str,
) -> Vec<&'a crate::config::RssHistoryEntry> {
let query = search_query.trim().to_lowercase();
if query.is_empty() {
return history.iter().collect();
}
history
.iter()
.filter(|entry| {
entry.title.to_lowercase().contains(&query)
|| entry
.source
.as_deref()
.unwrap_or("")
.to_lowercase()
.contains(&query)
|| entry.date_iso.to_lowercase().contains(&query)
})
.collect()
}
fn human_readable_history_time(date_iso: &str) -> String {
DateTime::parse_from_rfc3339(date_iso)
.map(|dt| {
dt.with_timezone(&Local)
.format("%Y-%m-%d %H:%M")
.to_string()
})
.unwrap_or_else(|_| date_iso.to_string())
}
fn link_matches_selected_explorer_item(feed_url: &str, item: &crate::app::RssPreviewItem) -> bool {
let feed_url_lc = feed_url.to_lowercase();
if let Some(link) = &item.link {
let link_lc = link.to_lowercase();
if feed_url_lc.contains(&link_lc) || link_lc.contains(&feed_url_lc) {
return true;
}
let feed_host = Url::parse(feed_url)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_lowercase()));
let link_host = Url::parse(link)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_lowercase()));
if let (Some(fh), Some(lh)) = (feed_host.clone(), link_host) {
if fh == lh {
return true;
}
}
if let (Some(fh), Some(source)) = (feed_host, item.source.as_ref()) {
let host_root = fh
.split('.')
.next()
.unwrap_or_default()
.trim()
.to_lowercase();
if !host_root.is_empty() && source.to_lowercase().contains(&host_root) {
return true;
}
}
}
false
}
fn pane_block<'a>(title: &'a str, active: bool, ctx: &crate::theme::ThemeContext) -> Block<'a> {
let border_style = if active {
ctx.apply(Style::default().fg(ctx.state_selected()))
} else {
ctx.apply(Style::default().fg(ctx.theme.semantic.border))
};
Block::default()
.title(format!(" {} ", title))
.borders(Borders::ALL)
.padding(Padding::horizontal(1))
.border_style(border_style)
}
fn draw_links(f: &mut Frame, area: Rect, screen: &ScreenContext<'_>, active: bool) {
let perf_start = Instant::now();
let app_state = screen.app.state;
let settings = screen.settings;
let ctx = screen.theme;
let selected = app_state.ui.rss.selected_feed_index;
let selected_item_start = Instant::now();
let selected_explorer_item = if matches!(app_state.ui.rss.active_screen, RssScreen::Unified)
&& matches!(app_state.ui.rss.focused_section, RssSectionFocus::Explorer)
{
if app_state.rss_derived.explorer_items.is_empty() {
None
} else {
let idx = app_state
.ui
.rss
.selected_explorer_index
.min(app_state.rss_derived.explorer_items.len().saturating_sub(1));
app_state.rss_derived.explorer_items.get(idx).cloned()
}
} else {
None
};
let selected_item_ms = selected_item_start.elapsed().as_millis();
let sorted_indices = sorted_feed_indices(settings);
let sync_countdown = app_state
.rss_runtime
.next_sync_at
.as_deref()
.and_then(sync_countdown_label);
let lines_start = Instant::now();
let mut lines: Vec<Line<'static>> = sorted_indices
.iter()
.map(|idx| {
let feed = &settings.rss.feeds[*idx];
let is_explorer_link_match = selected_explorer_item
.as_ref()
.is_some_and(|item| link_matches_selected_explorer_item(&feed.url, item));
let style = if !feed.enabled {
ctx.apply(
Style::default()
.fg(ctx.theme.semantic.overlay0)
.add_modifier(Modifier::CROSSED_OUT),
)
} else if is_explorer_link_match {
ctx.apply(Style::default().fg(ctx.state_selected()).bold())
} else {
ctx.apply(Style::default().fg(ctx.theme.semantic.text))
};
let mut spans = vec![Span::styled(feed.url.clone(), style)];
if let Some(countdown) = &sync_countdown {
spans.push(Span::styled(
format!(" ({})", countdown),
ctx.apply(Style::default().fg(ctx.theme.semantic.subtext0)),
));
}
Line::from(spans)
})
.collect();
let lines_ms = lines_start.elapsed().as_millis();
let errors_start = Instant::now();
let mut feed_error_rows: Vec<_> = app_state
.rss_runtime
.feed_errors
.iter()
.map(|(url, err)| (url.clone(), err.message.clone()))
.collect();
feed_error_rows.sort_by(|a, b| a.0.cmp(&b.0));
if !feed_error_rows.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"Feed errors:",
ctx.apply(Style::default().fg(ctx.state_error()).bold()),
)]));
let max_line_chars = area.width.saturating_sub(4) as usize;
for (url, message) in feed_error_rows {
let max_url_chars = (max_line_chars / 3).max(12);
let url_text = truncate_with_ellipsis(&url, max_url_chars);
let prefix = format!("{}: ", url_text);
let remaining = max_line_chars.saturating_sub(prefix.chars().count());
let message_text = truncate_with_ellipsis(&message, remaining);
lines.push(Line::from(vec![
Span::styled(
prefix,
ctx.apply(Style::default().fg(ctx.theme.semantic.subtext0)),
),
Span::styled(
message_text,
ctx.apply(Style::default().fg(ctx.state_error())),
),
]));
}
}
let errors_ms = errors_start.elapsed().as_millis();
let items: Vec<ListItem<'static>> = lines.into_iter().map(ListItem::new).collect();
let mut state = ListState::default();
if !sorted_indices.is_empty() {
state.select(Some(selected.min(sorted_indices.len() - 1)));
}
let highlight_style = if active {
screen
.theme
.apply(Style::default().fg(screen.theme.state_selected()).bold())
} else {
screen.theme.apply(Style::default())
};
let render_start = Instant::now();
f.render_stateful_widget(
List::new(items)
.block(pane_block("Links", active, screen.theme))
.highlight_style(highlight_style),
area,
&mut state,
);
let render_ms = render_start.elapsed().as_millis();
let _ = perf_start;
let _ = selected_item_ms;
let _ = lines_ms;
let _ = errors_ms;
let _ = render_ms;
}
fn active_filter_spec(
app_state: &AppState,
settings: &crate::config::Settings,
) -> Option<FilterSpec> {
if app_state.ui.rss.is_editing
&& matches!(app_state.ui.rss.focused_section, RssSectionFocus::Filters)
{
return Some(FilterSpec {
query: app_state.ui.rss.edit_buffer.clone(),
mode: app_state.ui.rss.add_filter_mode,
});
}
settings
.rss
.filters
.get(
selected_filter_actual_idx(settings, app_state.ui.rss.selected_filter_index)
.unwrap_or(app_state.ui.rss.selected_filter_index),
)
.filter(|f| f.enabled)
.map(|f| FilterSpec {
query: f.query.clone(),
mode: f.mode,
})
}
fn focused_filter_query(
app_state: &AppState,
settings: &crate::config::Settings,
) -> Option<FilterSpec> {
if !matches!(app_state.ui.rss.active_screen, RssScreen::Unified)
|| !matches!(app_state.ui.rss.focused_section, RssSectionFocus::Filters)
|| app_state.ui.rss.is_editing
{
return None;
}
settings
.rss
.filters
.get(
selected_filter_actual_idx(settings, app_state.ui.rss.selected_filter_index)
.unwrap_or(app_state.ui.rss.selected_filter_index),
)
.filter(|f| f.enabled)
.map(|f| FilterSpec {
query: f.query.trim().to_string(),
mode: f.mode,
})
.filter(|f| !f.query.is_empty())
}
#[cfg(test)]
fn compute_filter_preview_items(
preview_items: &[crate::app::RssPreviewItem],
draft: &str,
) -> Vec<(crate::app::RssPreviewItem, bool)> {
let draft = draft.trim();
if draft.is_empty() {
return preview_items
.iter()
.cloned()
.map(|item| (item, true))
.collect();
}
let matcher = SkimMatcherV2::default();
let draft_lc = draft.to_lowercase();
let mut ranked: Vec<(crate::app::RssPreviewItem, bool)> = preview_items
.iter()
.map(|item| {
let is_match = matcher
.fuzzy_match(&item.title.to_lowercase(), &draft_lc)
.is_some();
(item.clone(), is_match)
})
.collect();
ranked.sort_by_key(|item| std::cmp::Reverse(item.1));
ranked
}
#[cfg(test)]
fn compute_filter_match_counts(
app_state: &AppState,
filter_text: &str,
filter_mode: RssFilterMode,
matcher: &SkimMatcherV2,
) -> (usize, usize) {
let filter = filter_text.trim();
if filter.is_empty() {
return (0, 0);
}
let matched_items: Vec<&crate::app::RssPreviewItem> = app_state
.rss_runtime
.preview_items
.iter()
.filter(|item| filter_matches_title(&item.title, filter, filter_mode, matcher))
.collect();
let feed_matches = matched_items.len();
let downloaded_from_torrents = app_state
.torrents
.values()
.filter(|torrent| {
filter_matches_title(
&torrent.latest_state.torrent_name,
filter,
filter_mode,
matcher,
)
})
.count();
let app_hashes: HashSet<Vec<u8>> = app_state.torrents.keys().cloned().collect();
let app_titles: HashSet<String> = app_state
.torrents
.values()
.map(|torrent| normalize_title(&torrent.latest_state.torrent_name))
.collect();
let history_missing_from_app = app_state
.rss_runtime
.history
.iter()
.filter(|entry| filter_matches_title(&entry.title, filter, filter_mode, matcher))
.filter(|entry| {
let hash_in_app = entry
.info_hash
.as_deref()
.and_then(|hash| hex::decode(hash).ok())
.is_some_and(|hash| app_hashes.contains(&hash));
let title_in_app = app_titles.contains(&normalize_title(&entry.title));
!hash_in_app && !title_in_app
})
.count();
let downloaded_matches = downloaded_from_torrents + history_missing_from_app;
(feed_matches, downloaded_matches)
}
fn compute_filter_downloaded_matches(
app_state: &AppState,
filter_text: &str,
filter_mode: RssFilterMode,
matcher: &SkimMatcherV2,
) -> usize {
let filter = filter_text.trim();
if filter.is_empty() {
return 0;
}
let downloaded_from_torrents = app_state
.torrents
.values()
.filter(|torrent| {
filter_matches_title(
&torrent.latest_state.torrent_name,
filter,
filter_mode,
matcher,
)
})
.count();
let app_hashes: HashSet<Vec<u8>> = app_state.torrents.keys().cloned().collect();
let app_titles: HashSet<String> = app_state
.torrents
.values()
.map(|torrent| normalize_title(&torrent.latest_state.torrent_name))
.collect();
let history_missing_from_app = app_state
.rss_runtime
.history
.iter()
.filter(|entry| filter_matches_title(&entry.title, filter, filter_mode, matcher))
.filter(|entry| {
let hash_in_app = entry
.info_hash
.as_deref()
.and_then(|hash| hex::decode(hash).ok())
.is_some_and(|hash| app_hashes.contains(&hash));
let title_in_app = app_titles.contains(&normalize_title(&entry.title));
!hash_in_app && !title_in_app
})
.count();
downloaded_from_torrents + history_missing_from_app
}
fn filter_history_age_label(
app_state: &AppState,
filter_text: &str,
filter_mode: RssFilterMode,
matcher: &SkimMatcherV2,
) -> String {
let latest = app_state
.rss_runtime
.history
.iter()
.filter(|entry| filter_matches_title(&entry.title, filter_text, filter_mode, matcher))
.filter_map(|entry| DateTime::parse_from_rfc3339(&entry.date_iso).ok())
.max();
let Some(latest) = latest else {
return "today".to_string();
};
let now = Utc::now();
let days = now
.signed_duration_since(latest.with_timezone(&Utc))
.num_days();
if days <= 0 {
"today".to_string()
} else if days == 1 {
"1 day ago".to_string()
} else {
format!("{} days ago", days)
}
}
fn compute_filter_runtime_stats(
app_state: &AppState,
settings: &crate::config::Settings,
) -> HashMap<usize, crate::app::RssFilterRuntimeStat> {
let matcher = SkimMatcherV2::default();
settings
.rss
.filters
.iter()
.enumerate()
.map(|(idx, filter)| {
let downloaded_matches =
compute_filter_downloaded_matches(app_state, &filter.query, filter.mode, &matcher);
let history_age =
filter_history_age_label(app_state, &filter.query, filter.mode, &matcher);
(
idx,
crate::app::RssFilterRuntimeStat {
downloaded_matches,
history_age,
},
)
})
.collect::<HashMap<_, _>>()
}
fn build_history_hash_by_dedupe(
history: &[crate::config::RssHistoryEntry],
) -> HashMap<String, Vec<u8>> {
history
.iter()
.filter_map(|entry| {
entry
.info_hash
.as_deref()
.and_then(|hash| hex::decode(hash).ok())
.map(|decoded| (entry.dedupe_key.clone(), decoded))
})
.collect()
}
pub fn recompute_rss_derived(app_state: &mut AppState, settings: &crate::config::Settings) {
let enabled_filters = enabled_filters(settings);
let is_creating_filter = app_state.ui.rss.is_editing
&& matches!(app_state.ui.rss.focused_section, RssSectionFocus::Filters);
let active_filter = active_filter_spec(app_state, settings);
let active_filter_query = active_filter
.as_ref()
.map(|f| f.query.as_str())
.unwrap_or("");
let active_filter_mode = active_filter
.as_ref()
.map(|f| f.mode)
.unwrap_or(RssFilterMode::Fuzzy);
let (items, combined_match, prioritise_matches) = compute_explorer_items(
&app_state.rss_runtime.preview_items,
&app_state.ui.rss.search_query,
&enabled_filters,
active_filter_query,
active_filter_mode,
is_creating_filter,
);
app_state.rss_derived.explorer_items = items;
app_state.rss_derived.explorer_combined_match = combined_match;
app_state.rss_derived.explorer_prioritise_matches = prioritise_matches;
app_state.rss_derived.history_hash_by_dedupe =
build_history_hash_by_dedupe(&app_state.rss_runtime.history);
app_state.rss_derived.filter_runtime_stats = compute_filter_runtime_stats(app_state, settings);
}
fn normalize_title(input: &str) -> String {
input
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
fn draw_filters(f: &mut Frame, area: Rect, screen: &ScreenContext<'_>, active: bool) {
let perf_start = Instant::now();
let app_state = screen.app.state;
let settings = screen.settings;
let ctx = screen.theme;
let matcher = SkimMatcherV2::default();
let selected = app_state.ui.rss.selected_filter_index;
let is_creating_filter = app_state.ui.rss.is_editing
&& matches!(app_state.ui.rss.focused_section, RssSectionFocus::Filters);
let draft_lc = app_state.ui.rss.edit_buffer.trim().to_lowercase();
let crosswire_start = Instant::now();
let explorer_selected_title_lc = if matches!(app_state.ui.rss.active_screen, RssScreen::Unified)
&& matches!(app_state.ui.rss.focused_section, RssSectionFocus::Explorer)
{
if app_state.rss_derived.explorer_items.is_empty() {
None
} else {
let idx = app_state
.ui
.rss
.selected_explorer_index
.min(app_state.rss_derived.explorer_items.len().saturating_sub(1));
app_state
.rss_derived
.explorer_items
.get(idx)
.map(|item| item.title.to_lowercase())
}
} else {
None
};
let crosswire_ms = crosswire_start.elapsed().as_millis();
let sort_start = Instant::now();
let mut sorted_indices = sorted_filter_indices(settings);
if is_creating_filter && !draft_lc.is_empty() {
sorted_indices.sort_by(|a, b| {
let a_query = settings.rss.filters[*a].query.to_lowercase();
let b_query = settings.rss.filters[*b].query.to_lowercase();
let a_match = a_query.contains(&draft_lc);
let b_match = b_query.contains(&draft_lc);
b_match.cmp(&a_match).then_with(|| a_query.cmp(&b_query))
});
}
let sort_ms = sort_start.elapsed().as_millis();
let stats_start = Instant::now();
let filter_runtime_stats = &app_state.rss_derived.filter_runtime_stats;
let stats_ms = stats_start.elapsed().as_millis();
let rows_start = Instant::now();
let mut items: Vec<ListItem<'static>> = Vec::with_capacity(sorted_indices.len());
for idx in &sorted_indices {
let filter = &settings.rss.filters[*idx];
let filter_text = filter.query.clone();
let filter_lc = filter_text.trim().to_lowercase();
let is_matching_existing = is_creating_filter
&& !draft_lc.is_empty()
&& matches!(app_state.ui.rss.add_filter_mode, RssFilterMode::Fuzzy)
&& filter_lc.contains(&draft_lc);
let matches_explorer_selection = filter.enabled
&& !filter_lc.is_empty()
&& explorer_selected_title_lc.as_ref().is_some_and(|title| {
filter_matches_title(title, &filter_text, filter.mode, &matcher)
});
let style = if !filter.enabled {
ctx.apply(
Style::default()
.fg(ctx.theme.semantic.overlay0)
.add_modifier(Modifier::CROSSED_OUT),
)
} else if matches_explorer_selection {
ctx.apply(Style::default().fg(ctx.state_selected()).bold())
} else if is_matching_existing {
ctx.apply(Style::default().fg(ctx.theme.semantic.overlay0))
} else {
ctx.apply(Style::default().fg(ctx.theme.semantic.text))
};
let stats = filter_runtime_stats.get(idx);
let downloaded_matches = stats.map(|s| s.downloaded_matches).unwrap_or(0);
let history_age = stats.map(|s| s.history_age.as_str()).unwrap_or("today");
let mode_label = match filter.mode {
RssFilterMode::Fuzzy => "[fuzzy]",
RssFilterMode::Regex => "[regex]",
};
items.push(ListItem::new(Line::from(vec![Span::styled(
format!(
"{} [downloaded {}] \"{}\" - {}",
mode_label, downloaded_matches, filter_text, history_age
),
style,
)])));
}
let rows_ms = rows_start.elapsed().as_millis();
let mut state = ListState::default();
if !items.is_empty() {
state.select(Some(selected.min(items.len() - 1)));
}
let highlight_style = if active {
screen
.theme
.apply(Style::default().fg(screen.theme.state_selected()).bold())
} else {
screen.theme.apply(Style::default())
};
let render_start = Instant::now();
f.render_stateful_widget(
List::new(items)
.block(pane_block("Filters", active, screen.theme))
.highlight_style(highlight_style),
area,
&mut state,
);
let render_ms = render_start.elapsed().as_millis();
let _ = perf_start;
let _ = crosswire_ms;
let _ = sort_ms;
let _ = stats_ms;
let _ = rows_ms;
let _ = render_ms;
}
fn compute_explorer_items(
preview_items: &[crate::app::RssPreviewItem],
search_query: &str,
enabled_filters: &[FilterSpec],
draft_filter_query: &str,
draft_filter_mode: RssFilterMode,
prefer_draft_sort: bool,
) -> (Vec<crate::app::RssPreviewItem>, Vec<bool>, bool) {
let search = search_query.to_lowercase();
let has_search = !search.is_empty();
let draft_filter = prepare_filter(draft_filter_query, draft_filter_mode);
let has_draft_query = draft_filter.is_some();
let matcher = SkimMatcherV2::default();
let enabled_prepared: Vec<PreparedFilter> = enabled_filters
.iter()
.filter_map(|f| prepare_filter(&f.query, f.mode))
.collect();
let has_enabled_filters = !enabled_prepared.is_empty();
let prioritise_matches = has_search || has_enabled_filters || has_draft_query;
let prepared_items: Vec<(crate::app::RssPreviewItem, String)> = preview_items
.iter()
.cloned()
.map(|item| {
let title_lc = item.title.to_lowercase();
(item, title_lc)
})
.collect();
let mut combined_match: Vec<bool> = prepared_items
.iter()
.map(|(item, title_lc)| {
let search_hit = has_search && title_lc.contains(&search);
let enabled_filter_hit = enabled_prepared
.iter()
.any(|f| prepared_filter_matches(&item.title, title_lc, f, &matcher));
let draft_hit = draft_filter
.as_ref()
.is_some_and(|f| prepared_filter_matches(&item.title, title_lc, f, &matcher));
enabled_filter_hit || search_hit || draft_hit
})
.collect();
if has_search {
let filtered: Vec<(crate::app::RssPreviewItem, String, bool)> = prepared_items
.into_iter()
.zip(combined_match)
.filter_map(|((item, title_lc), is_match)| is_match.then_some((item, title_lc, true)))
.collect();
let mut filtered = filtered;
filtered.sort_by(|a, b| a.1.cmp(&b.1));
combined_match = filtered.iter().map(|p| p.2).collect();
let items = filtered.into_iter().map(|p| p.0).collect();
return (items, combined_match, prioritise_matches);
}
let mut paired: Vec<(crate::app::RssPreviewItem, String, bool, bool, Option<i64>)> =
prepared_items
.into_iter()
.zip(combined_match)
.map(|((item, title_lc), is_match)| {
let draft_hit = draft_filter
.as_ref()
.is_some_and(|f| prepared_filter_matches(&item.title, &title_lc, f, &matcher));
let draft_score = draft_filter.as_ref().and_then(|f| {
if matches!(f.mode, RssFilterMode::Fuzzy) {
matcher.fuzzy_match(&title_lc, &f.query_lc)
} else {
None
}
});
(item, title_lc, is_match, draft_hit, draft_score)
})
.collect();
if prioritise_matches {
if prefer_draft_sort && has_draft_query {
paired.sort_by(|a, b| {
b.3.cmp(&a.3)
.then_with(|| b.4.unwrap_or(0).cmp(&a.4.unwrap_or(0)))
.then_with(|| b.2.cmp(&a.2))
.then_with(|| a.1.cmp(&b.1))
});
} else {
paired.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.1.cmp(&b.1)));
}
} else {
paired.sort_by(|a, b| a.1.cmp(&b.1));
}
combined_match = paired.iter().map(|p| p.2).collect();
let items = paired.into_iter().map(|p| p.0).collect();
(items, combined_match, prioritise_matches)
}
fn rss_item_completion_percent(
item: &crate::app::RssPreviewItem,
app_state: &AppState,
history_hash_map: &HashMap<String, Vec<u8>>,
completion_by_title: &HashMap<String, f64>,
) -> Option<f64> {
if app_state.torrents.is_empty() {
return None;
}
if let Some(link) = &item.link {
if link.starts_with("magnet:") {
let (v1_hash, v2_hash) = crate::app::parse_hybrid_hashes(link);
for hash in [v1_hash, v2_hash].into_iter().flatten() {
if let Some(torrent) = app_state.torrents.get(&hash) {
return Some(crate::app::torrent_completion_percent(
&torrent.latest_state,
));
}
}
}
}
if let Some(history_hash) = history_hash_map.get(&item.dedupe_key) {
if let Some(torrent) = app_state.torrents.get(history_hash) {
return Some(crate::app::torrent_completion_percent(
&torrent.latest_state,
));
}
}
let normalized_title = normalize_title(&item.title);
completion_by_title.get(&normalized_title).copied()
}
fn build_completion_by_normalized_title(app_state: &AppState) -> HashMap<String, f64> {
let mut completion_by_title: HashMap<String, f64> = HashMap::new();
for torrent in app_state.torrents.values() {
let normalized = normalize_title(&torrent.latest_state.torrent_name);
let completion = crate::app::torrent_completion_percent(&torrent.latest_state);
completion_by_title
.entry(normalized)
.and_modify(|existing| *existing = existing.max(completion))
.or_insert(completion);
}
completion_by_title
}
fn format_completion_prefix(pct: f64) -> String {
if (pct - 100.0).abs() < f64::EPSILON {
"100.0% ".to_string()
} else {
format!("{:.1}% ", pct)
}
}
fn completion_color_for_pct(ctx: &crate::theme::ThemeContext, pct: f64) -> ratatui::style::Color {
if pct >= 100.0 {
ctx.state_success()
} else {
ctx.state_selected()
}
}
fn draw_explorer(f: &mut Frame, area: Rect, screen: &ScreenContext<'_>, active: bool) {
let perf_start = Instant::now();
let app_state = screen.app.state;
let settings = screen.settings;
let ctx = screen.theme;
let matcher = SkimMatcherV2::default();
let selected = app_state
.ui
.rss
.selected_explorer_index
.min(app_state.rss_derived.explorer_items.len().saturating_sub(1));
let explorer_greyed_out = explorer_effective_greyed_out(app_state, settings);
let is_creating_filter = app_state.ui.rss.is_editing
&& matches!(app_state.ui.rss.focused_section, RssSectionFocus::Filters);
let focused_filter_query = focused_filter_query(app_state, settings);
let compute_start = Instant::now();
let items = &app_state.rss_derived.explorer_items;
let combined_match = &app_state.rss_derived.explorer_combined_match;
let prioritise_matches = app_state.rss_derived.explorer_prioritise_matches;
let compute_ms = compute_start.elapsed().as_millis();
let history_hash_map = &app_state.rss_derived.history_hash_by_dedupe;
let completion_by_title = build_completion_by_normalized_title(app_state);
let draft_filter = prepare_filter(
&app_state.ui.rss.edit_buffer,
app_state.ui.rss.add_filter_mode,
);
let enabled_prepared: Vec<PreparedFilter> = settings
.rss
.filters
.iter()
.filter(|f| f.enabled)
.filter_map(|f| prepare_filter(&f.query, f.mode))
.collect();
let focused_filter = focused_filter_query
.as_ref()
.and_then(|f| prepare_filter(&f.query, f.mode));
let rows_start = Instant::now();
let list_items: Vec<ListItem<'static>> = items
.iter()
.enumerate()
.map(|(i, item)| {
let is_combined_match = combined_match.get(i).copied().unwrap_or(item.is_match);
let title_lc = item.title.to_lowercase();
let draft_hit = draft_filter
.as_ref()
.is_some_and(|f| prepared_filter_matches(&item.title, &title_lc, f, &matcher));
let existing_filter_hit = enabled_prepared
.iter()
.any(|f| prepared_filter_matches(&item.title, &title_lc, f, &matcher));
let focused_filter_hit = focused_filter
.as_ref()
.is_none_or(|f| prepared_filter_matches(&item.title, &title_lc, f, &matcher));
let dim_as_other_filter_match = is_creating_filter && existing_filter_hit && !draft_hit;
let style = if explorer_greyed_out || dim_as_other_filter_match {
ctx.apply(Style::default().fg(ctx.theme.semantic.overlay0))
} else if focused_filter_query.is_some() && focused_filter_hit {
ctx.apply(Style::default().fg(ctx.state_selected()).bold())
} else if prioritise_matches && !is_combined_match {
ctx.apply(Style::default().fg(ctx.theme.semantic.overlay0))
} else {
ctx.apply(Style::default().fg(ctx.theme.semantic.text))
};
let completion_pct = rss_item_completion_percent(
item,
app_state,
history_hash_map,
&completion_by_title,
);
if let Some(pct) = completion_pct {
let completion_style =
style.patch(Style::default().fg(completion_color_for_pct(ctx, pct)));
ListItem::new(Line::from(vec![
Span::styled(format_completion_prefix(pct), completion_style),
Span::styled(item.title.clone(), style),
]))
} else {
ListItem::new(Line::from(vec![Span::styled(item.title.clone(), style)]))
}
})
.collect();
let rows_ms = rows_start.elapsed().as_millis();
let mut state = ListState::default();
if active && !items.is_empty() {
state.select(Some(selected.min(items.len() - 1)));
}
let suppress_selection_highlight = app_state.ui.rss.is_editing
&& matches!(app_state.ui.rss.focused_section, RssSectionFocus::Filters);
let highlight_style = if suppress_selection_highlight || explorer_greyed_out {
screen.theme.apply(Style::default())
} else if active {
screen
.theme
.apply(Style::default().fg(screen.theme.state_selected()).bold())
} else {
screen
.theme
.apply(Style::default().fg(screen.theme.theme.semantic.text).bold())
};
let render_start = Instant::now();
f.render_stateful_widget(
List::new(list_items)
.block(pane_block("Explorer", active, screen.theme))
.highlight_style(highlight_style),
area,
&mut state,
);
let render_ms = render_start.elapsed().as_millis();
let _ = perf_start;
let _ = compute_ms;
let _ = rows_ms;
let _ = render_ms;
}
fn draw_history(f: &mut Frame, area: Rect, screen: &ScreenContext<'_>) {
let app_state = screen.app.state;
let ctx = screen.theme;
let selected = app_state.ui.rss.selected_history_index;
let filtered = filtered_history_entries(
&app_state.rss_runtime.history,
&app_state.ui.rss.search_query,
);
let lines: Vec<Line<'static>> = filtered
.iter()
.map(|entry| {
let src = entry
.source
.clone()
.unwrap_or_else(|| "unknown".to_string());
Line::from(format!(
"{} | {} | {}",
human_readable_history_time(&entry.date_iso),
entry.title,
src
))
})
.collect();
let items: Vec<ListItem<'static>> = lines.into_iter().map(ListItem::new).collect();
let mut state = ListState::default();
if !filtered.is_empty() {
state.select(Some(selected.min(filtered.len() - 1)));
}
f.render_stateful_widget(
List::new(items)
.block(pane_block("History", true, ctx))
.highlight_style(ctx.apply(Style::default().fg(ctx.state_selected()).bold())),
area,
&mut state,
);
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum UnifiedLayout {
Wide,
Narrow,
}
fn unified_layout_for_width(width: u16) -> UnifiedLayout {
if width >= 140 {
UnifiedLayout::Wide
} else {
UnifiedLayout::Narrow
}
}
fn draw_unified_body(f: &mut Frame, area: Rect, screen: &ScreenContext<'_>, show_history: bool) {
let app_state = screen.app.state;
if matches!(unified_layout_for_width(area.width), UnifiedLayout::Wide) {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
let right_rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(cols[1]);
if show_history {
draw_history(f, cols[0], screen);
} else {
draw_explorer(
f,
cols[0],
screen,
matches!(app_state.ui.rss.focused_section, RssSectionFocus::Explorer),
);
}
draw_links(
f,
right_rows[0],
screen,
!show_history && matches!(app_state.ui.rss.focused_section, RssSectionFocus::Links),
);
draw_filters(
f,
right_rows[1],
screen,
!show_history && matches!(app_state.ui.rss.focused_section, RssSectionFocus::Filters),
);
} else {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(50),
Constraint::Percentage(20),
Constraint::Percentage(30),
])
.split(area);
if show_history {
draw_history(f, rows[0], screen);
} else {
draw_explorer(
f,
rows[0],
screen,
matches!(app_state.ui.rss.focused_section, RssSectionFocus::Explorer),
);
}
draw_filters(
f,
rows[1],
screen,
!show_history && matches!(app_state.ui.rss.focused_section, RssSectionFocus::Filters),
);
draw_links(
f,
rows[2],
screen,
!show_history && matches!(app_state.ui.rss.focused_section, RssSectionFocus::Links),
);
}
}
pub fn draw(f: &mut Frame, screen: &ScreenContext<'_>) {
let area = centered_rect(88, 86, f.area());
let app_state = screen.app.state;
f.render_widget(Clear, area);
let show_input_panel = app_state.ui.rss.is_editing || app_state.ui.rss.is_searching;
let constraints = if show_input_panel {
vec![
Constraint::Length(3),
Constraint::Min(5),
Constraint::Length(1),
]
} else {
vec![Constraint::Min(5), Constraint::Length(1)]
};
let inner = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
if show_input_panel {
draw_input_panel(f, inner[0], screen);
}
let body_idx = if show_input_panel { 1 } else { 0 };
let footer_idx = if show_input_panel { 2 } else { 1 };
draw_unified_body(
f,
inner[body_idx],
screen,
matches!(app_state.ui.rss.active_screen, RssScreen::History),
);
draw_shared_footer(f, inner[footer_idx], screen);
if app_state.ui.rss.delete_confirm_armed {
draw_delete_confirm_dialog(f, area, screen);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::RssPreviewItem;
fn base_state() -> AppState {
AppState {
mode: AppMode::Rss,
..Default::default()
}
}
#[test]
fn esc_returns_to_normal_mode() {
let mut app_state = base_state();
let settings = crate::config::Settings::default();
let (tx, _rx) = mpsc::channel(2);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Esc,
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(matches!(app_state.mode, AppMode::Normal));
}
#[test]
fn tab_cycles_focus_sections() {
let mut app_state = base_state();
let settings = crate::config::Settings::default();
let (tx, _rx) = mpsc::channel(2);
assert!(matches!(
app_state.ui.rss.focused_section,
RssSectionFocus::Explorer
));
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Tab,
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(matches!(
app_state.ui.rss.focused_section,
RssSectionFocus::Links
));
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Tab,
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(matches!(
app_state.ui.rss.focused_section,
RssSectionFocus::Filters
));
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Tab,
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(matches!(
app_state.ui.rss.focused_section,
RssSectionFocus::Explorer
));
}
#[test]
fn h_toggles_history_and_returns_to_explorer_focus() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Links;
let settings = crate::config::Settings::default();
let (tx, _rx) = mpsc::channel(2);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('h'),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(matches!(app_state.ui.rss.active_screen, RssScreen::History));
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('h'),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(matches!(app_state.ui.rss.active_screen, RssScreen::Unified));
assert!(matches!(
app_state.ui.rss.focused_section,
RssSectionFocus::Explorer
));
}
#[test]
fn down_moves_rows_and_left_right_do_not_change_focus() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Explorer;
app_state.rss_runtime.preview_items.push(RssPreviewItem {
title: "A".to_string(),
..Default::default()
});
app_state.rss_runtime.preview_items.push(RssPreviewItem {
title: "B".to_string(),
..Default::default()
});
let settings = crate::config::Settings::default();
let (tx, _rx) = mpsc::channel(2);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Down,
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert_eq!(app_state.ui.rss.selected_explorer_index, 1);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Left,
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(matches!(
app_state.ui.rss.focused_section,
RssSectionFocus::Explorer
));
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Right,
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(matches!(
app_state.ui.rss.focused_section,
RssSectionFocus::Explorer
));
}
#[test]
fn sync_key_enqueues_command() {
let mut app_state = base_state();
let settings = crate::config::Settings::default();
let (tx, mut rx) = mpsc::channel(2);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('s'),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
let cmd = rx.try_recv().expect("expected rss sync command");
assert!(matches!(cmd, AppCommand::RssSyncNow));
assert_eq!(
app_state.ui.rss.status_message.as_deref(),
Some("RSS sync requested")
);
}
#[test]
fn sync_key_auto_enables_rss_when_disabled() {
let mut app_state = base_state();
let mut settings = crate::config::Settings::default();
settings.rss.enabled = false;
let (tx, mut rx) = mpsc::channel(4);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('s'),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
let first = rx.try_recv().expect("expected first command");
match first {
AppCommand::UpdateConfig(s) => assert!(s.rss.enabled),
_ => panic!("unexpected first command"),
}
let second = rx.try_recv().expect("expected second command");
assert!(matches!(second, AppCommand::RssSyncNow));
}
#[test]
fn sync_key_is_throttled_when_spammed() {
let mut app_state = base_state();
let settings = crate::config::Settings::default();
let (tx, mut rx) = mpsc::channel(4);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('s'),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(matches!(
rx.try_recv().expect("expected first sync command"),
AppCommand::RssSyncNow
));
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('s'),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(rx.try_recv().is_err());
assert_eq!(
app_state.ui.rss.status_message.as_deref(),
Some("RSS sync throttled")
);
}
#[test]
fn add_link_dispatches_update_config() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Links;
app_state.ui.rss.is_editing = true;
let settings = crate::config::Settings::default();
let (tx, mut rx) = mpsc::channel(8);
for c in "https://example.com/rss.xml".chars() {
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char(c),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
}
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Enter,
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
let cmd = rx.try_recv().expect("expected UpdateConfig dispatch");
match cmd {
AppCommand::UpdateConfig(s) => {
assert_eq!(s.rss.feeds.len(), 1);
assert_eq!(s.rss.feeds[0].url, "https://example.com/rss.xml");
assert!(s.rss.feeds[0].enabled);
}
_ => panic!("unexpected command"),
}
}
#[test]
fn add_entry_from_explorer_does_not_start_editing() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Explorer;
app_state.ui.rss.add_filter_mode = RssFilterMode::Fuzzy;
app_state.ui.rss.edit_buffer.clear();
let settings = crate::config::Settings::default();
let (tx, _rx) = mpsc::channel(8);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('a'),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(matches!(
app_state.ui.rss.focused_section,
RssSectionFocus::Explorer
));
assert!(!app_state.ui.rss.is_editing);
assert!(app_state.ui.rss.edit_buffer.is_empty());
assert!(matches!(
app_state.ui.rss.add_filter_mode,
RssFilterMode::Fuzzy
));
assert!(app_state.ui.rss.status_message.is_none());
}
#[test]
fn add_entry_from_links_starts_editing() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Links;
let settings = crate::config::Settings::default();
let (tx, _rx) = mpsc::channel(8);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('a'),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(app_state.ui.rss.is_editing);
assert_eq!(
app_state.ui.rss.status_message.as_deref(),
Some("Editing new entry")
);
}
#[test]
fn add_entry_from_filters_starts_editing() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Filters;
app_state.ui.rss.add_filter_mode = RssFilterMode::Regex;
let settings = crate::config::Settings::default();
let (tx, _rx) = mpsc::channel(8);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('a'),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(app_state.ui.rss.is_editing);
assert!(app_state.ui.rss.edit_buffer.is_empty());
assert!(matches!(
app_state.ui.rss.add_filter_mode,
RssFilterMode::Fuzzy
));
assert_eq!(
app_state.ui.rss.status_message.as_deref(),
Some("Editing new entry")
);
}
#[test]
fn add_link_reports_failure_when_update_config_enqueue_fails() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Links;
app_state.ui.rss.is_editing = true;
let settings = crate::config::Settings::default();
let (tx, mut rx) = mpsc::channel(1);
tx.try_send(AppCommand::RssSyncNow)
.expect("prefill channel to force enqueue failure");
for c in "https://example.com/rss.xml".chars() {
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char(c),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
}
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Enter,
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert_eq!(
app_state.ui.rss.status_message.as_deref(),
Some("RSS settings enqueue failed")
);
assert!(matches!(rx.try_recv(), Ok(AppCommand::RssSyncNow)));
assert!(rx.try_recv().is_err());
}
#[test]
fn invalid_feed_url_is_rejected() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Links;
app_state.ui.rss.is_editing = true;
let settings = crate::config::Settings::default();
let (tx, mut rx) = mpsc::channel(8);
for c in "javascript:alert(1)".chars() {
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char(c),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
}
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Enter,
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(rx.try_recv().is_err());
assert_eq!(
app_state.ui.rss.status_message.as_deref(),
Some("Invalid feed URL (use http/https)")
);
assert!(!app_state.ui.rss.is_editing);
}
#[test]
fn paste_link_in_edit_mode_dispatches_update_config() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Links;
app_state.ui.rss.is_editing = true;
let settings = crate::config::Settings::default();
let (tx, mut rx) = mpsc::channel(8);
handle_event(
CrosstermEvent::Paste("https://example.test/rss/?t&r=1080".to_string()),
&mut app_state,
&settings,
&tx,
);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Enter,
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
let cmd = rx.try_recv().expect("expected UpdateConfig dispatch");
match cmd {
AppCommand::UpdateConfig(s) => {
assert_eq!(s.rss.feeds.len(), 1);
assert_eq!(s.rss.feeds[0].url, "https://example.test/rss/?t&r=1080");
}
_ => panic!("unexpected command"),
}
}
#[test]
fn delete_link_requires_confirmation_then_dispatches_update_config() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Links;
let mut settings = crate::config::Settings::default();
settings.rss.feeds.push(crate::config::RssFeed {
url: "https://a.test/rss".to_string(),
enabled: true,
});
let (tx, mut rx) = mpsc::channel(8);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('D'),
ratatui::crossterm::event::KeyModifiers::SHIFT,
)),
&mut app_state,
&settings,
&tx,
);
assert!(app_state.ui.rss.delete_confirm_armed);
assert!(rx.try_recv().is_err());
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('Y'),
ratatui::crossterm::event::KeyModifiers::SHIFT,
)),
&mut app_state,
&settings,
&tx,
);
assert!(!app_state.ui.rss.delete_confirm_armed);
let cmd = rx.try_recv().expect("expected UpdateConfig dispatch");
match cmd {
AppCommand::UpdateConfig(s) => assert!(s.rss.feeds.is_empty()),
_ => panic!("unexpected command"),
}
}
#[test]
fn delete_link_confirmation_can_be_cancelled_with_escape() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Links;
let mut settings = crate::config::Settings::default();
settings.rss.feeds.push(crate::config::RssFeed {
url: "https://a.test/rss".to_string(),
enabled: true,
});
let (tx, mut rx) = mpsc::channel(8);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('D'),
ratatui::crossterm::event::KeyModifiers::SHIFT,
)),
&mut app_state,
&settings,
&tx,
);
assert!(app_state.ui.rss.delete_confirm_armed);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Esc,
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(!app_state.ui.rss.delete_confirm_armed);
assert_eq!(
app_state.ui.rss.status_message.as_deref(),
Some("Delete cancelled")
);
assert!(rx.try_recv().is_err());
}
#[test]
fn toggle_link_dispatches_update_config() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Links;
let mut settings = crate::config::Settings::default();
settings.rss.feeds.push(crate::config::RssFeed {
url: "https://a.test/rss".to_string(),
enabled: true,
});
let (tx, mut rx) = mpsc::channel(8);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char(' '),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
let cmd = rx.try_recv().expect("expected UpdateConfig dispatch");
match cmd {
AppCommand::UpdateConfig(s) => assert!(!s.rss.feeds[0].enabled),
_ => panic!("unexpected command"),
}
}
#[test]
fn toggle_filter_dispatches_update_config() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Filters;
let mut settings = crate::config::Settings::default();
settings.rss.filters.push(crate::config::RssFilter {
query: "samplealpha".to_string(),
mode: RssFilterMode::Fuzzy,
enabled: true,
});
let (tx, mut rx) = mpsc::channel(8);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char(' '),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
let cmd = rx.try_recv().expect("expected UpdateConfig dispatch");
match cmd {
AppCommand::UpdateConfig(s) => assert!(!s.rss.filters[0].enabled),
_ => panic!("unexpected command"),
}
}
#[test]
fn tab_toggles_filter_mode_while_editing_filter() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Filters;
let settings = crate::config::Settings::default();
let (tx, _rx) = mpsc::channel(8);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('a'),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(app_state.ui.rss.is_editing);
assert!(matches!(
app_state.ui.rss.add_filter_mode,
RssFilterMode::Fuzzy
));
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Tab,
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(matches!(
app_state.ui.rss.add_filter_mode,
RssFilterMode::Regex
));
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Tab,
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(matches!(
app_state.ui.rss.add_filter_mode,
RssFilterMode::Fuzzy
));
}
#[test]
fn add_filter_rejects_invalid_regex_pattern() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Filters;
let settings = crate::config::Settings::default();
let (tx, mut rx) = mpsc::channel(8);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('a'),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
app_state.ui.rss.add_filter_mode = RssFilterMode::Regex;
for c in "(invalid".chars() {
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char(c),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
}
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Enter,
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(rx.try_recv().is_err());
assert!(app_state.ui.rss.is_editing);
assert_eq!(
app_state.ui.rss.status_message.as_deref(),
Some("Invalid regex pattern")
);
}
#[test]
fn add_filter_rejects_duplicate_filter() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Filters;
let mut settings = crate::config::Settings::default();
settings.rss.filters.push(crate::config::RssFilter {
query: "samplealpha".to_string(),
mode: RssFilterMode::Fuzzy,
enabled: true,
});
let (tx, mut rx) = mpsc::channel(8);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('a'),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
for c in "samplealpha".chars() {
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char(c),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
}
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Enter,
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(rx.try_recv().is_err());
assert!(app_state.ui.rss.is_editing);
assert_eq!(
app_state.ui.rss.status_message.as_deref(),
Some("Filter already exists")
);
}
#[test]
fn add_filter_rejects_duplicate_filter_with_case_and_whitespace() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Filters;
let mut settings = crate::config::Settings::default();
settings.rss.filters.push(crate::config::RssFilter {
query: " SampleAlpha ".to_string(),
mode: RssFilterMode::Fuzzy,
enabled: true,
});
let (tx, mut rx) = mpsc::channel(8);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('a'),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
for c in "samplealpha".chars() {
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char(c),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
}
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Enter,
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(rx.try_recv().is_err());
assert!(app_state.ui.rss.is_editing);
assert_eq!(
app_state.ui.rss.status_message.as_deref(),
Some("Filter already exists")
);
}
#[test]
fn shift_y_downloads_selected_explorer_item_when_not_downloaded() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Explorer;
app_state.rss_runtime.preview_items.push(RssPreviewItem {
title: "SampleAlpha ISO".to_string(),
link: Some("magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()),
dedupe_key: "guid:samplealpha".to_string(),
is_downloaded: false,
..Default::default()
});
let settings = crate::config::Settings::default();
let (tx, mut rx) = mpsc::channel(8);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('Y'),
ratatui::crossterm::event::KeyModifiers::SHIFT,
)),
&mut app_state,
&settings,
&tx,
);
let cmd = rx.try_recv().expect("expected RSS download command");
match cmd {
AppCommand::RssDownloadPreview(item) => {
assert_eq!(item.title, "SampleAlpha ISO");
assert_eq!(item.dedupe_key, "guid:samplealpha");
}
_ => panic!("unexpected command"),
}
}
#[test]
fn shift_y_allows_selected_explorer_item_when_already_downloaded() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Explorer;
app_state.rss_runtime.preview_items.push(RssPreviewItem {
title: "SampleAlpha ISO".to_string(),
link: Some("magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()),
dedupe_key: "guid:samplealpha".to_string(),
is_downloaded: true,
..Default::default()
});
let settings = crate::config::Settings::default();
let (tx, mut rx) = mpsc::channel(8);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('Y'),
ratatui::crossterm::event::KeyModifiers::SHIFT,
)),
&mut app_state,
&settings,
&tx,
);
let cmd = rx.try_recv().expect("expected RSS download command");
match cmd {
AppCommand::RssDownloadPreview(item) => {
assert_eq!(item.title, "SampleAlpha ISO");
assert_eq!(item.dedupe_key, "guid:samplealpha");
}
_ => panic!("unexpected command"),
}
assert_eq!(
app_state.ui.rss.status_message.as_deref(),
Some("RSS download requested")
);
}
#[test]
fn explorer_search_mode_sets_and_clears_status() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Explorer;
let settings = crate::config::Settings::default();
let (tx, _rx) = mpsc::channel(8);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('/'),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(app_state.ui.rss.is_searching);
assert_eq!(
app_state.ui.rss.status_message.as_deref(),
Some("Search mode")
);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Esc,
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(!app_state.ui.rss.is_searching);
assert_eq!(
app_state.ui.rss.status_message.as_deref(),
Some("Search cleared")
);
}
#[test]
fn history_search_mode_sets_and_clears_status() {
let mut app_state = base_state();
app_state.ui.rss.active_screen = RssScreen::History;
let settings = crate::config::Settings::default();
let (tx, _rx) = mpsc::channel(8);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('/'),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(app_state.ui.rss.is_searching);
assert_eq!(
app_state.ui.rss.status_message.as_deref(),
Some("Search mode")
);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Esc,
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(!app_state.ui.rss.is_searching);
assert_eq!(
app_state.ui.rss.status_message.as_deref(),
Some("Search cleared")
);
}
#[test]
fn backspace_does_not_exit_search_mode_when_query_becomes_empty() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Explorer;
let settings = crate::config::Settings::default();
let (tx, _rx) = mpsc::channel(8);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('/'),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('x'),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Backspace,
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(app_state.ui.rss.is_searching);
assert!(app_state.ui.rss.search_query.is_empty());
}
#[test]
fn explorer_compute_filters_out_non_matches_when_search_active() {
let items = vec![
RssPreviewItem {
title: "SampleAlpha LTS".to_string(),
is_match: true,
..Default::default()
},
RssPreviewItem {
title: "SampleBeta".to_string(),
is_match: false,
..Default::default()
},
];
let (sorted, combined, prioritise) =
compute_explorer_items(&items, "samplealpha", &[], "", RssFilterMode::Fuzzy, false);
assert!(prioritise);
assert_eq!(sorted.len(), 1);
assert_eq!(combined.len(), 1);
assert_eq!(sorted[0].title, "SampleAlpha LTS");
}
#[test]
fn search_enter_keeps_mode_active_when_query_non_empty() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Explorer;
let settings = crate::config::Settings::default();
let (tx, _rx) = mpsc::channel(8);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('/'),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Char('f'),
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
handle_event(
CrosstermEvent::Key(ratatui::crossterm::event::KeyEvent::new(
KeyCode::Enter,
ratatui::crossterm::event::KeyModifiers::NONE,
)),
&mut app_state,
&settings,
&tx,
);
assert!(app_state.ui.rss.is_searching);
}
#[test]
fn explorer_compute_sorts_matches_first_only_when_active() {
let items = vec![
RssPreviewItem {
title: "Non match".to_string(),
is_match: false,
..Default::default()
},
RssPreviewItem {
title: "Match".to_string(),
is_match: true,
..Default::default()
},
];
let (inactive_sorted, _, inactive_prioritise) =
compute_explorer_items(&items, "", &[], "", RssFilterMode::Fuzzy, false);
assert!(!inactive_prioritise);
assert_eq!(inactive_sorted[0].title, "Match");
let enabled = vec![FilterSpec {
query: "match".to_string(),
mode: RssFilterMode::Fuzzy,
}];
let (active_sorted, _, active_prioritise) =
compute_explorer_items(&items, "", &enabled, "", RssFilterMode::Fuzzy, false);
assert!(active_prioritise);
assert_eq!(active_sorted[0].title, "Match");
}
#[test]
fn explorer_compute_prefers_draft_matches_while_creating_filter() {
let items = vec![
RssPreviewItem {
title: "Series Beta".to_string(),
..Default::default()
},
RssPreviewItem {
title: "Series Alpha".to_string(),
..Default::default()
},
];
let enabled = vec![FilterSpec {
query: "series beta".to_string(),
mode: RssFilterMode::Fuzzy,
}];
let (sorted, _, prioritise) =
compute_explorer_items(&items, "", &enabled, "alpha", RssFilterMode::Fuzzy, true);
assert!(prioritise);
assert_eq!(sorted[0].title, "Series Alpha");
}
#[test]
fn explorer_compute_supports_regex_draft_matching() {
let items = vec![
RssPreviewItem {
title: "Series Beta".to_string(),
..Default::default()
},
RssPreviewItem {
title: "Series Alpha".to_string(),
..Default::default()
},
];
let (sorted, _, prioritise) = compute_explorer_items(
&items,
"",
&[],
"series\\s+alpha",
RssFilterMode::Regex,
true,
);
assert!(prioritise);
assert_eq!(sorted[0].title, "Series Alpha");
}
#[test]
fn explorer_compute_prefers_regex_draft_matches_over_existing_filter_matches() {
let items = vec![
RssPreviewItem {
title: "Series Beta".to_string(),
..Default::default()
},
RssPreviewItem {
title: "Series Alpha".to_string(),
..Default::default()
},
];
let enabled = vec![FilterSpec {
query: "series beta".to_string(),
mode: RssFilterMode::Fuzzy,
}];
let (sorted, _, prioritise) = compute_explorer_items(
&items,
"",
&enabled,
"series\\s+alpha",
RssFilterMode::Regex,
true,
);
assert!(prioritise);
assert_eq!(sorted[0].title, "Series Alpha");
}
#[test]
fn filter_preview_keeps_all_items_and_sorts_matches_first() {
let items = vec![
RssPreviewItem {
title: "SampleBeta".to_string(),
..Default::default()
},
RssPreviewItem {
title: "SampleAlpha LTS".to_string(),
..Default::default()
},
];
let ranked = compute_filter_preview_items(&items, "samplealpha");
assert_eq!(ranked.len(), 2);
assert!(ranked[0].1);
assert_eq!(ranked[0].0.title, "SampleAlpha LTS");
assert!(!ranked[1].1);
assert_eq!(ranked[1].0.title, "SampleBeta");
}
#[test]
fn filter_preview_with_empty_draft_still_shows_full_list() {
let items = vec![
RssPreviewItem {
title: "SampleBeta".to_string(),
..Default::default()
},
RssPreviewItem {
title: "SampleAlpha".to_string(),
..Default::default()
},
];
let ranked = compute_filter_preview_items(&items, "");
assert_eq!(ranked.len(), 2);
assert!(ranked.iter().all(|(_, is_match)| *is_match));
}
#[test]
fn compute_filter_match_counts_counts_feed_and_downloaded_from_torrent_hash() {
let mut app_state = base_state();
app_state.rss_runtime.preview_items.push(RssPreviewItem {
title: "Series Alpha Episode 1".to_string(),
link: Some("magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()),
..Default::default()
});
app_state.rss_runtime.preview_items.push(RssPreviewItem {
title: "Series Beta Episode 1".to_string(),
..Default::default()
});
let mut torrent = crate::app::TorrentDisplayState::default();
torrent.latest_state.torrent_name = "Series Alpha Batch".to_string();
app_state.torrents.insert(vec![0xaa; 20], torrent);
let matcher = SkimMatcherV2::default();
let (feed, downloaded) =
compute_filter_match_counts(&app_state, "alpha", RssFilterMode::Fuzzy, &matcher);
assert_eq!(feed, 1);
assert_eq!(downloaded, 1);
}
#[test]
fn compute_filter_match_counts_falls_back_to_history_when_no_torrent_hash_match() {
let mut app_state = base_state();
app_state.rss_runtime.preview_items.push(RssPreviewItem {
dedupe_key: "guid:series-alpha-1".to_string(),
title: "Series Alpha Episode 1".to_string(),
link: Some("https://example.test/series-alpha-1.torrent".to_string()),
..Default::default()
});
app_state
.rss_runtime
.history
.push(crate::config::RssHistoryEntry {
dedupe_key: "guid:series-alpha-1".to_string(),
title: "Series Alpha Episode 1".to_string(),
..Default::default()
});
let matcher = SkimMatcherV2::default();
let (feed, downloaded) =
compute_filter_match_counts(&app_state, "alpha", RssFilterMode::Fuzzy, &matcher);
assert_eq!(feed, 1);
assert_eq!(downloaded, 1);
}
#[test]
fn compute_filter_match_counts_uses_history_when_no_feed_matches() {
let mut app_state = base_state();
app_state
.rss_runtime
.history
.push(crate::config::RssHistoryEntry {
dedupe_key: "guid:series-seriessigma-1".to_string(),
title: "Series Sigma Episode 54".to_string(),
..Default::default()
});
app_state
.rss_runtime
.history
.push(crate::config::RssHistoryEntry {
dedupe_key: "guid:series-seriessigma-2".to_string(),
title: "Series Sigma Episode 55".to_string(),
..Default::default()
});
let matcher = SkimMatcherV2::default();
let (feed, downloaded) =
compute_filter_match_counts(&app_state, "seriessigma", RssFilterMode::Fuzzy, &matcher);
assert_eq!(feed, 0);
assert_eq!(downloaded, 2);
}
#[test]
fn compute_filter_match_counts_counts_app_state_and_missing_history_entries() {
let mut app_state = base_state();
let mut torrent_one = crate::app::TorrentDisplayState::default();
torrent_one.latest_state.torrent_name = "Series Sigma Episode 1".to_string();
app_state.torrents.insert(vec![1; 20], torrent_one);
let mut torrent_two = crate::app::TorrentDisplayState::default();
torrent_two.latest_state.torrent_name = "Series Sigma Episode 2".to_string();
app_state.torrents.insert(vec![2; 20], torrent_two);
app_state
.rss_runtime
.history
.push(crate::config::RssHistoryEntry {
dedupe_key: "guid:series-seriessigma-1".to_string(),
info_hash: Some(hex::encode(vec![1; 20])),
title: "Series Sigma Episode 1".to_string(),
..Default::default()
});
app_state
.rss_runtime
.history
.push(crate::config::RssHistoryEntry {
dedupe_key: "guid:series-seriessigma-2".to_string(),
info_hash: Some(hex::encode(vec![2; 20])),
title: "Series Sigma Episode 2".to_string(),
..Default::default()
});
app_state
.rss_runtime
.history
.push(crate::config::RssHistoryEntry {
dedupe_key: "guid:series-seriessigma-3".to_string(),
title: "Series Sigma Episode 3".to_string(),
..Default::default()
});
let matcher = SkimMatcherV2::default();
let (feed, downloaded) =
compute_filter_match_counts(&app_state, "seriessigma", RssFilterMode::Fuzzy, &matcher);
assert_eq!(feed, 0);
assert_eq!(downloaded, 3);
}
#[test]
fn active_filter_spec_uses_selected_filter_in_nav_mode() {
let mut app_state = base_state();
app_state.ui.rss.active_screen = RssScreen::Unified;
app_state.ui.rss.focused_section = RssSectionFocus::Filters;
app_state.ui.rss.is_editing = false;
app_state.ui.rss.filter_draft.clear();
app_state.ui.rss.selected_filter_index = 1;
let mut settings = crate::config::Settings::default();
settings.rss.filters.push(crate::config::RssFilter {
query: "samplealpha".to_string(),
mode: RssFilterMode::Fuzzy,
enabled: true,
});
settings.rss.filters.push(crate::config::RssFilter {
query: "samplebeta".to_string(),
mode: RssFilterMode::Regex,
enabled: true,
});
let spec = active_filter_spec(&app_state, &settings).expect("expected active filter");
assert_eq!(spec.query, "samplebeta");
assert!(matches!(spec.mode, RssFilterMode::Regex));
}
#[test]
fn active_filter_spec_ignores_disabled_selected_filter() {
let mut app_state = base_state();
app_state.ui.rss.active_screen = RssScreen::Unified;
app_state.ui.rss.focused_section = RssSectionFocus::Filters;
app_state.ui.rss.is_editing = false;
app_state.ui.rss.filter_draft.clear();
app_state.ui.rss.selected_filter_index = 0;
let mut settings = crate::config::Settings::default();
settings.rss.filters.push(crate::config::RssFilter {
query: "seriesdelta".to_string(),
mode: RssFilterMode::Fuzzy,
enabled: false,
});
assert!(active_filter_spec(&app_state, &settings).is_none());
}
#[test]
fn active_filter_spec_ignores_stale_draft_when_not_editing() {
let mut app_state = base_state();
app_state.ui.rss.active_screen = RssScreen::Unified;
app_state.ui.rss.focused_section = RssSectionFocus::Explorer;
app_state.ui.rss.is_editing = false;
app_state.ui.rss.edit_buffer.clear();
app_state.ui.rss.filter_draft = "seriesomega".to_string();
let settings = crate::config::Settings::default();
assert!(active_filter_spec(&app_state, &settings).is_none());
}
#[test]
fn focused_filter_query_uses_selected_filter_in_filters_focus() {
let mut app_state = base_state();
app_state.ui.rss.active_screen = RssScreen::Unified;
app_state.ui.rss.focused_section = RssSectionFocus::Filters;
app_state.ui.rss.is_editing = false;
let mut settings = crate::config::Settings::default();
settings.rss.filters.push(crate::config::RssFilter {
query: "series alpha".to_string(),
mode: RssFilterMode::Fuzzy,
enabled: true,
});
let focused = focused_filter_query(&app_state, &settings).expect("focused filter");
assert_eq!(focused.query, "series alpha");
assert!(matches!(focused.mode, RssFilterMode::Fuzzy));
}
#[test]
fn focused_filter_query_is_none_when_not_on_filters_focus() {
let mut app_state = base_state();
app_state.ui.rss.active_screen = RssScreen::Unified;
app_state.ui.rss.focused_section = RssSectionFocus::Explorer;
app_state.ui.rss.is_editing = false;
let mut settings = crate::config::Settings::default();
settings.rss.filters.push(crate::config::RssFilter {
query: "series alpha".to_string(),
mode: RssFilterMode::Fuzzy,
enabled: true,
});
assert!(focused_filter_query(&app_state, &settings).is_none());
}
#[test]
fn explorer_greyed_out_when_no_filters_exist() {
let settings = crate::config::Settings::default();
assert!(explorer_should_be_greyed_out(&settings));
}
#[test]
fn explorer_greyed_out_when_all_filters_disabled() {
let mut settings = crate::config::Settings::default();
settings.rss.filters.push(crate::config::RssFilter {
query: "samplealpha".to_string(),
mode: RssFilterMode::Fuzzy,
enabled: false,
});
settings.rss.filters.push(crate::config::RssFilter {
query: "samplebeta".to_string(),
mode: RssFilterMode::Fuzzy,
enabled: false,
});
assert!(explorer_should_be_greyed_out(&settings));
}
#[test]
fn explorer_not_greyed_out_when_any_filter_enabled() {
let mut settings = crate::config::Settings::default();
settings.rss.filters.push(crate::config::RssFilter {
query: "samplealpha".to_string(),
mode: RssFilterMode::Fuzzy,
enabled: false,
});
settings.rss.filters.push(crate::config::RssFilter {
query: "samplebeta".to_string(),
mode: RssFilterMode::Fuzzy,
enabled: true,
});
assert!(!explorer_should_be_greyed_out(&settings));
}
#[test]
fn explorer_effective_greyed_out_is_false_while_creating_first_filter_with_draft() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Filters;
app_state.ui.rss.is_editing = true;
app_state.ui.rss.edit_buffer = "sample draft".to_string();
let settings = crate::config::Settings::default();
assert!(!explorer_effective_greyed_out(&app_state, &settings));
}
#[test]
fn explorer_effective_greyed_out_is_true_while_creating_first_filter_without_draft() {
let mut app_state = base_state();
app_state.ui.rss.focused_section = RssSectionFocus::Filters;
app_state.ui.rss.is_editing = true;
app_state.ui.rss.edit_buffer.clear();
let settings = crate::config::Settings::default();
assert!(explorer_effective_greyed_out(&app_state, &settings));
}
#[test]
fn sync_countdown_label_formats_minutes_and_seconds() {
let future = (Utc::now() + chrono::Duration::seconds(274)).to_rfc3339();
let label = sync_countdown_label(&future).expect("expected countdown");
assert!(label.ends_with('s'));
assert!(label.contains('m'));
}
#[test]
fn sync_countdown_label_returns_none_for_past_timestamp() {
let past = (Utc::now() - chrono::Duration::seconds(5)).to_rfc3339();
assert!(sync_countdown_label(&past).is_none());
}
#[test]
fn is_valid_feed_url_rejects_localhost_and_private_ips() {
assert!(!is_valid_feed_url("http://localhost/rss"));
assert!(!is_valid_feed_url("https://127.0.0.1/feed.xml"));
assert!(!is_valid_feed_url("https://192.168.1.20/rss"));
}
#[test]
fn is_valid_feed_url_accepts_public_https_feed() {
assert!(is_valid_feed_url("https://example.com/rss.xml"));
}
#[test]
fn truncate_with_ellipsis_shortens_long_text() {
assert_eq!(truncate_with_ellipsis("abcdefghij", 6), "abc...");
}
#[test]
fn filtered_history_entries_respects_search_query() {
let entries = vec![
crate::config::RssHistoryEntry {
title: "Series Alpha".to_string(),
source: Some("ExampleFeed".to_string()),
date_iso: "2026-02-17T10:00:00Z".to_string(),
..Default::default()
},
crate::config::RssHistoryEntry {
title: "Series Gamma".to_string(),
source: Some("ExampleFeed".to_string()),
date_iso: "2026-02-16T10:00:00Z".to_string(),
..Default::default()
},
];
let filtered = filtered_history_entries(&entries, "alpha");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].title, "Series Alpha");
}
#[test]
fn human_readable_history_time_formats_rfc3339() {
let ts = "2026-02-17T10:05:00Z";
assert_eq!(human_readable_history_time(ts).len(), 16);
}
#[test]
fn link_matches_selected_explorer_item_matches_by_host() {
let item = RssPreviewItem {
link: Some("https://example.test/item/abc".to_string()),
..Default::default()
};
assert!(link_matches_selected_explorer_item(
"https://example.test/rss/?t&r=1080",
&item
));
}
#[test]
fn link_matches_selected_explorer_item_matches_by_source_hint() {
let item = RssPreviewItem {
link: Some("magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()),
source: Some("ExampleFeed RSS".to_string()),
..Default::default()
};
assert!(link_matches_selected_explorer_item(
"https://example.test/rss/?t&r=1080",
&item
));
}
#[test]
fn rss_item_completion_percent_is_none_without_live_torrent_metrics() {
let app_state = base_state();
let history_hash_map = build_history_hash_by_dedupe(&app_state.rss_runtime.history);
let item = RssPreviewItem {
title: "Series Alpha".to_string(),
is_downloaded: true,
link: Some("magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()),
..Default::default()
};
let completion_by_title = HashMap::new();
assert!(rss_item_completion_percent(
&item,
&app_state,
&history_hash_map,
&completion_by_title
)
.is_none());
}
#[test]
fn rss_item_completion_percent_uses_history_info_hash_fallback() {
let mut app_state = base_state();
let info_hash = vec![0xaa; 20];
let mut torrent = crate::app::TorrentDisplayState::default();
torrent.latest_state.number_of_pieces_total = 10;
torrent.latest_state.number_of_pieces_completed = 10;
app_state.torrents.insert(info_hash.clone(), torrent);
app_state
.rss_runtime
.history
.push(crate::config::RssHistoryEntry {
dedupe_key: "guid:series-alpha".to_string(),
info_hash: Some(hex::encode(&info_hash)),
title: "Series Alpha".to_string(),
..Default::default()
});
let item = RssPreviewItem {
dedupe_key: "guid:series-alpha".to_string(),
title: "Series Alpha".to_string(),
link: Some("https://example.test/series-alpha.torrent".to_string()),
is_downloaded: true,
..Default::default()
};
let history_hash_map = build_history_hash_by_dedupe(&app_state.rss_runtime.history);
let completion_by_title = HashMap::new();
assert_eq!(
rss_item_completion_percent(&item, &app_state, &history_hash_map, &completion_by_title),
Some(100.0)
);
}
#[test]
fn rss_item_completion_percent_does_not_require_downloaded_flag() {
let mut app_state = base_state();
let info_hash = vec![0xbb; 20];
let mut torrent = crate::app::TorrentDisplayState::default();
torrent.latest_state.number_of_pieces_total = 10;
torrent.latest_state.number_of_pieces_completed = 10;
app_state.torrents.insert(info_hash.clone(), torrent);
app_state
.rss_runtime
.history
.push(crate::config::RssHistoryEntry {
dedupe_key: "guid:series-beta".to_string(),
info_hash: Some(hex::encode(&info_hash)),
title: "Series Beta".to_string(),
..Default::default()
});
let item = RssPreviewItem {
dedupe_key: "guid:series-beta".to_string(),
title: "Series Beta".to_string(),
is_downloaded: false,
..Default::default()
};
let history_hash_map = build_history_hash_by_dedupe(&app_state.rss_runtime.history);
let completion_by_title = HashMap::new();
assert_eq!(
rss_item_completion_percent(&item, &app_state, &history_hash_map, &completion_by_title),
Some(100.0)
);
}
#[test]
fn unified_layout_is_narrow_below_boundary() {
assert!(matches!(
unified_layout_for_width(139),
UnifiedLayout::Narrow
));
}
#[test]
fn unified_layout_is_wide_at_boundary() {
assert!(matches!(unified_layout_for_width(140), UnifiedLayout::Wide));
}
}