use std::collections::HashSet;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
Frame,
};
use crate::theme;
use crate::types::Station;
const SPINNER: [char; 8] = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'];
pub enum FzfOutcome {
Ignored,
Consumed,
QueryChanged,
Submit(Station),
Close,
}
pub struct FzfPopup {
visible: bool,
query: String,
results: Vec<Station>,
matches: Vec<(usize, Vec<usize>)>,
state: ListState,
searching: bool,
spinner: usize,
}
impl FzfPopup {
pub fn new() -> Self {
Self {
visible: false,
query: String::new(),
results: Vec::new(),
matches: Vec::new(),
state: ListState::default(),
searching: false,
spinner: 0,
}
}
pub fn is_visible(&self) -> bool {
self.visible
}
pub fn query(&self) -> &str {
&self.query
}
pub fn open(&mut self, seed: Vec<Station>) {
self.visible = true;
self.query.clear();
self.searching = false;
self.results = seed;
self.recompute();
}
pub fn close(&mut self) {
self.visible = false;
self.searching = false;
}
pub fn set_results(&mut self, results: Vec<Station>) {
self.results = results;
self.recompute();
}
pub fn set_searching(&mut self, searching: bool) {
self.searching = searching;
}
pub fn handle_key(&mut self, key: KeyEvent) -> FzfOutcome {
if !self.visible {
return FzfOutcome::Ignored;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc => {
self.close();
FzfOutcome::Close
}
KeyCode::Enter => match self.selected_station() {
Some(station) => {
self.close();
FzfOutcome::Submit(station)
}
None => FzfOutcome::Consumed,
},
KeyCode::Down => {
self.move_selection(1);
FzfOutcome::Consumed
}
KeyCode::Up => {
self.move_selection(-1);
FzfOutcome::Consumed
}
KeyCode::Char('n') if ctrl => {
self.move_selection(1);
FzfOutcome::Consumed
}
KeyCode::Char('p') if ctrl => {
self.move_selection(-1);
FzfOutcome::Consumed
}
KeyCode::Char('u') if ctrl => {
self.query.clear();
self.recompute();
FzfOutcome::QueryChanged
}
KeyCode::Backspace => {
self.query.pop();
self.recompute();
FzfOutcome::QueryChanged
}
KeyCode::Char(c) if !ctrl => {
self.query.push(c);
self.recompute();
FzfOutcome::QueryChanged
}
_ => FzfOutcome::Consumed,
}
}
fn selected_station(&self) -> Option<Station> {
let selected = self.state.selected()?;
let (index, _) = self.matches.get(selected)?;
self.results.get(*index).cloned()
}
fn move_selection(&mut self, delta: i32) {
if self.matches.is_empty() {
self.state.select(None);
return;
}
let len = self.matches.len() as i32;
let current = self.state.selected().unwrap_or(0) as i32;
let next = (current + delta).clamp(0, len - 1) as usize;
self.state.select(Some(next));
}
fn recompute(&mut self) {
let mut scored: Vec<(usize, i32, Vec<usize>)> = self
.results
.iter()
.enumerate()
.filter_map(|(i, station)| {
fuzzy_match(&self.query, &station.name)
.map(|(score, positions)| (i, score, positions))
})
.collect();
scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
self.matches = scored.into_iter().map(|(i, _, pos)| (i, pos)).collect();
if self.matches.is_empty() {
self.state.select(None);
} else {
self.state.select(Some(0));
}
}
pub fn render(&mut self, frame: &mut Frame) {
if !self.visible {
return;
}
let size = frame.size();
let width = ((size.width as u32 * 80) / 100) as u16;
let height = ((size.height as u32 * 75) / 100) as u16;
let area = centered_rect(
size,
width.clamp(24, size.width),
height.clamp(6, size.height),
);
frame.render_widget(Clear, area);
let block = Block::new()
.borders(Borders::ALL)
.title(" 🔍 Fuzzy Finder ")
.title_alignment(Alignment::Center)
.border_style(Style::default().fg(theme::PRIMARY));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height < 3 || inner.width < 4 {
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), ])
.split(inner);
let mut prompt = vec![Span::styled(
"❯ ",
Style::default()
.fg(theme::PRIMARY)
.add_modifier(Modifier::BOLD),
)];
if self.query.is_empty() {
prompt.push(Span::styled(
"Type to search stations…",
Style::default().fg(Color::DarkGray),
));
} else {
prompt.push(Span::raw(self.query.clone()));
prompt.push(Span::styled(
" ",
Style::default().add_modifier(Modifier::REVERSED),
));
}
frame.render_widget(Paragraph::new(Line::from(prompt)), chunks[0]);
let spinner = if self.searching {
self.spinner = self.spinner.wrapping_add(1);
SPINNER[self.spinner % SPINNER.len()]
} else {
' '
};
let counter = format!(
" {} {}/{}",
spinner,
self.matches.len(),
self.results.len()
);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
counter,
Style::default().fg(Color::DarkGray),
))),
chunks[1],
);
let items: Vec<ListItem> = if self.matches.is_empty() {
let message = if self.searching {
"Searching…"
} else if self.query.is_empty() {
"No stations yet — start typing"
} else {
"No matches"
};
vec![ListItem::new(Span::styled(
message,
Style::default().fg(Color::DarkGray),
))]
} else {
self.matches
.iter()
.map(|(index, positions)| {
ListItem::new(highlight_line(&self.results[*index], positions))
})
.collect()
};
let list = List::new(items).highlight_symbol("❯ ").highlight_style(
Style::default()
.fg(theme::ACCENT)
.add_modifier(Modifier::BOLD),
);
frame.render_stateful_widget(list, chunks[2], &mut self.state);
}
}
fn highlight_line(station: &Station, positions: &[usize]) -> Line<'static> {
let matched: HashSet<usize> = positions.iter().copied().collect();
let mut spans = Vec::new();
let mut run = String::new();
let mut run_matched = false;
for (i, ch) in station.name.chars().enumerate() {
let is_match = matched.contains(&i);
if !run.is_empty() && is_match != run_matched {
spans.push(make_span(&run, run_matched));
run.clear();
}
run.push(ch);
run_matched = is_match;
}
if !run.is_empty() {
spans.push(make_span(&run, run_matched));
}
if let Some(playing) = &station.playing {
let playing = playing.trim();
if !playing.is_empty() {
spans.push(Span::styled(
format!(" — {}", playing),
Style::default().fg(Color::DarkGray),
));
}
}
Line::from(spans)
}
fn make_span(text: &str, matched: bool) -> Span<'static> {
if matched {
Span::styled(
text.to_string(),
Style::default()
.fg(theme::PRIMARY)
.add_modifier(Modifier::BOLD),
)
} else {
Span::raw(text.to_string())
}
}
pub fn fuzzy_match(query: &str, text: &str) -> Option<(i32, Vec<usize>)> {
if query.is_empty() {
return Some((0, Vec::new()));
}
let text: Vec<char> = text.chars().collect();
let mut positions = Vec::new();
let mut score = 0i32;
let mut cursor = 0usize;
let mut previous: Option<usize> = None;
for qc in query.chars() {
let needle = qc.to_ascii_lowercase();
let found = loop {
if cursor >= text.len() {
return None;
}
let matches = text[cursor].to_ascii_lowercase() == needle;
cursor += 1;
if matches {
break cursor - 1;
}
};
let mut char_score = 1;
match previous {
Some(prev) if found == prev + 1 => char_score += 5, Some(prev) => char_score -= ((found - prev - 1).min(10)) as i32, None => char_score += 10 - (found.min(10) as i32), }
let boundary = found == 0
|| matches!(
text.get(found - 1),
Some(' ') | Some('-') | Some('_') | Some('/') | Some('.') | Some('(')
);
if boundary {
char_score += 3;
}
score += char_score;
positions.push(found);
previous = Some(found);
}
Some((score, positions))
}
fn centered_rect(outer: Rect, width: u16, height: u16) -> Rect {
let w = width.min(outer.width);
let h = height.min(outer.height);
Rect {
x: outer.x + (outer.width - w) / 2,
y: outer.y + (outer.height - h) / 2,
width: w,
height: h,
}
}