pulsedeck 0.1.8

A cyber-synthwave internet radio player and smart tape recorder for your terminal
use ratatui::prelude::*;
use ratatui::widgets::Paragraph;

use super::theme;
use crate::app::{App, SearchStatus};

const SEARCH_DEBOUNCE_FRAMES: [&str; 10] = ["", "", "", "", "", "", "", "", "", ""];

/// Render the search input bar.
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
    let result_count = app.search_results.len();
    let selected_saved = app
        .search_results
        .get(app.selected)
        .map(|station| app.library.contains(&station.url))
        .unwrap_or(false);

    let api_indicator = match &app.search_status {
        SearchStatus::WaitingForInput => Span::styled("  Type 2+ chars to search", theme::dim()),
        SearchStatus::Debouncing { query } => {
            Span::styled(debounce_indicator_text(query, app.tick_count), theme::dim())
        }
        SearchStatus::Searching { query } => Span::styled(
            format!("  ◌ searching {}...", query),
            Style::default().fg(theme::warm()),
        ),
        SearchStatus::Ready { .. } if selected_saved => {
            Span::styled("  ★ Saved to library", Style::default().fg(theme::warm()))
        }
        SearchStatus::Ready { .. } => {
            Span::styled(format!("  {} found", result_count), theme::dim())
        }
        SearchStatus::Empty { query } => {
            Span::styled(format!("  No results for {}", query), theme::dim())
        }
        SearchStatus::Error { message, .. } => {
            let message = message
                .split('|')
                .next()
                .unwrap_or(message)
                .chars()
                .take(96)
                .collect::<String>();
            Span::styled(format!("  Search failed: {}", message), theme::error())
        }
        SearchStatus::StaleResponseDiscarded {
            query,
            received_stale,
        } => Span::styled(
            stale_response_text(query, received_stale),
            Style::default().fg(theme::warm()),
        ),
    };

    let spans = vec![
        Span::styled(" 🔍 ", theme::neon()),
        Span::styled(&app.search_query, theme::cyan()),
        Span::styled("", Style::default().fg(theme::highlight())),
        api_indicator,
    ];

    let line = Line::from(spans);

    let search_bar = Paragraph::new(vec![line]).style(Style::default().bg(theme::surface_color()));

    frame.render_widget(search_bar, area);
}

fn debounce_indicator_text(query: &str, tick_count: u64) -> String {
    format!(
        "  {} initializing query for {}...",
        search_debounce_frame(tick_count),
        query
    )
}

fn search_debounce_frame(tick_count: u64) -> &'static str {
    SEARCH_DEBOUNCE_FRAMES[tick_count as usize % SEARCH_DEBOUNCE_FRAMES.len()]
}

fn stale_response_text(query: &str, received_stale: &str) -> String {
    format!(
        "  ⊘ discarded stale {}; {} is current",
        compact_search_label(received_stale),
        compact_search_label(query)
    )
}

fn compact_search_label(value: &str) -> String {
    const MAX_CHARS: usize = 24;
    let mut chars = value.chars();

    let compact = chars.by_ref().take(MAX_CHARS).collect::<String>();
    if chars.next().is_some() {
        format!("{compact}")
    } else {
        compact
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn search_debounce_frame_wraps_through_spinner_frames() {
        assert_eq!(search_debounce_frame(0), "");
        assert_eq!(search_debounce_frame(1), "");
        assert_eq!(search_debounce_frame(9), "");
        assert_eq!(search_debounce_frame(10), "");
    }

    #[test]
    fn debounce_indicator_text_feels_active_without_saying_soon() {
        let text = debounce_indicator_text("lofi", 2);

        assert_eq!(text, "  ⠹ initializing query for lofi...");
        assert!(!text.contains("soon"));
    }

    #[test]
    fn stale_response_text_reports_discarded_query() {
        let text = stale_response_text("jazz", "synth");

        assert_eq!(text, "  ⊘ discarded stale synth; jazz is current");
    }

    #[test]
    fn compact_search_label_truncates_long_queries() {
        assert_eq!(
            compact_search_label("abcdefghijklmnopqrstuvwxyz"),
            "abcdefghijklmnopqrstuvwx…"
        );
    }
}