feature: add fuzzy searching for liked tracks
integrated the fuzzy-matcher crate to allow the user to filter the library view. filtered_likes and search_query states were added to AppState to track and cache search progress. updated input.rs so raw keystrokes get intercepted when search mode is toggled on. inline search bar gets added above the status bar when active. replaced the deprecated get_sorted_likes() method. closes 5f4bc41
diff --git a/Cargo.lock b/Cargo.lock
@@ -1315,6 +1315,15 @@ dependencies = [
"slab",
]
+[[package]]
+name = "fuzzy-matcher"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
+dependencies = [
+ "thread_local",
+]
+
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -2138,6 +2147,7 @@ dependencies = [
"arboard",
"crossterm",
"futures-util",
+ "fuzzy-matcher",
"image",
"notify-rust",
"pretty_assertions",
@@ -4041,6 +4051,15 @@ dependencies = [
"syn 3.0.3",
]
+[[package]]
+name = "thread_local"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
+dependencies = [
+ "cfg-if",
+]
+
[[package]]
name = "tiff"
version = "0.11.3"
diff --git a/Cargo.toml b/Cargo.toml
@@ -17,6 +17,7 @@ strip = true
arboard = { version = "3.6.1", features = ["wayland-data-control"] }
crossterm = "0.29"
futures-util = "0.3"
+fuzzy-matcher = "0.3.7"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
notify-rust = "4.18.0"
ratatui = "0.30"
diff --git a/README.md b/README.md
@@ -57,6 +57,7 @@ makepkg -si
- `c` - Copy track name/artist to clipboard
- `C` - Copy full track metadata to clipboard
- `o`/`Enter` - Open album info in browser
+- `/` - Search through library
- `Tab` - Cycle tabs in interface
- `?` - Show the help menu
- `q/^c` - Quit
diff --git a/pkg/grrs-git/.SRCINFO b/pkg/grrs-git/.SRCINFO
@@ -1,6 +1,6 @@
pkgbase = grrs-git
pkgdesc = A minimal TUI for Gensokyo Radio
- pkgver = r31.be93c4f
+ pkgver = r34.773521c
pkgrel = 1
url = https://radicle.network/nodes/git.myn.sh/grrs
arch = x86_64
diff --git a/src/app.rs b/src/app.rs
@@ -7,6 +7,7 @@ use std::{
};
use crossterm::event::Event as CEvent;
+use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
use ratatui::{style::Color, widgets::ListState};
use ratatui_image::protocol::StatefulProtocol;
use souvlaki::MediaControlEvent;
@@ -48,6 +49,7 @@ pub struct AppState {
pub art_protocol: Option<StatefulProtocol>,
pub live_art_bytes: Option<Vec<u8>>,
pub likes: HashMap<i64, LikedSong>,
+ pub filtered_likes: Vec<LikedSong>,
pub likes_file: PathBuf,
pub history: Vec<HistorySong>,
pub history_list_state: ListState,
@@ -56,6 +58,8 @@ pub struct AppState {
pub status_message_time: Option<Instant>,
pub is_muted: bool,
pub mode: AppMode,
+ pub is_searching: bool,
+ pub search_query: String,
pub liked_list_state: ListState,
pub selected_art: Option<StatefulProtocol>,
pub show_help_overlay: bool,
@@ -134,9 +138,12 @@ impl AppState {
let _ = fs::remove_file(cache_dir.join("theme.txt"));
let likes_file = new_likes;
- let likes =
+ let likes: HashMap<i64, LikedSong> =
fs::read_to_string(&likes_file).ok().and_then(|d| serde_json::from_str(&d).ok()).unwrap_or_default();
+ let mut filtered_likes: Vec<_> = likes.values().cloned().collect();
+ filtered_likes.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
+
let mode = if !warnings.is_empty() {
AppMode::ConfigError(warnings)
} else if config.ui.default_mode.to_lowercase() == "library" {
@@ -161,6 +168,7 @@ impl AppState {
art_protocol: None,
live_art_bytes: None,
likes,
+ filtered_likes,
likes_file,
history: Vec::new(),
history_list_state: ListState::default(),
@@ -169,6 +177,8 @@ impl AppState {
status_message_time: Some(Instant::now()),
is_muted: false,
mode,
+ is_searching: false,
+ search_query: String::new(),
liked_list_state: ListState::default(),
selected_art: None,
show_help_overlay: false,
@@ -223,12 +233,37 @@ impl AppState {
if let Ok(data) = serde_json::to_string_pretty(&self.likes) {
fs::write(&self.likes_file, data).ok();
}
+ self.update_search();
}
- pub fn get_sorted_likes(&self) -> Vec<LikedSong> {
- let mut v: Vec<_> = self.likes.values().cloned().collect();
- v.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
- v
+ pub fn update_search(&mut self) {
+ let mut sorted: Vec<_> = self.likes.values().cloned().collect();
+ sorted.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
+
+ if self.search_query.is_empty() {
+ self.filtered_likes = sorted;
+ } else {
+ let matcher = SkimMatcherV2::default();
+ let mut scored = Vec::new();
+ for song in sorted {
+ let haystack = format!("{} {}", song.artist, song.title);
+ if let Some(score) = matcher.fuzzy_match(&haystack, &self.search_query) {
+ scored.push((score, song));
+ }
+ }
+ scored.sort_by(|a, b| b.0.cmp(&a.0));
+ self.filtered_likes = scored.into_iter().map(|(_, s)| s).collect();
+ }
+
+ if let Some(selected) = self.liked_list_state.selected() {
+ if !self.filtered_likes.is_empty() && selected >= self.filtered_likes.len() {
+ self.liked_list_state.select(Some(self.filtered_likes.len() - 1));
+ } else if self.filtered_likes.is_empty() {
+ self.liked_list_state.select(None);
+ }
+ } else if !self.filtered_likes.is_empty() {
+ self.liked_list_state.select(Some(0));
+ }
}
pub fn art_path(&self, id: i64) -> PathBuf {
diff --git a/src/input.rs b/src/input.rs
@@ -56,6 +56,50 @@ pub async fn handle_input(
return InputOutcome { should_quit: false, should_clear: false };
}
+ if state.is_searching {
+ match evt {
+ CEvent::Key(key) => {
+ if key.code == KeyCode::Esc || key.code == KeyCode::Enter {
+ state.is_searching = false;
+ if key.code == KeyCode::Esc {
+ state.search_query.clear();
+ state.update_search();
+ if let Some(idx) = state.liked_list_state.selected() {
+ if let Some(song) = state.filtered_likes.get(idx) {
+ load_local_art(song.id, state.art_path(song.id), tx.clone(), picker.clone());
+ }
+ }
+ }
+ clear = true;
+ } else if key.code == KeyCode::Backspace {
+ let _ = state.search_query.pop();
+ state.update_search();
+ if let Some(idx) = state.liked_list_state.selected() {
+ if let Some(song) = state.filtered_likes.get(idx) {
+ load_local_art(song.id, state.art_path(song.id), tx.clone(), picker.clone());
+ }
+ } else {
+ state.selected_art = None;
+ }
+ clear = true;
+ } else if let KeyCode::Char(c) = key.code {
+ state.search_query.push(c);
+ state.update_search();
+ if let Some(idx) = state.liked_list_state.selected() {
+ if let Some(song) = state.filtered_likes.get(idx) {
+ load_local_art(song.id, state.art_path(song.id), tx.clone(), picker.clone());
+ }
+ } else {
+ state.selected_art = None;
+ }
+ clear = true;
+ }
+ return InputOutcome { should_quit: false, should_clear: clear };
+ },
+ _ => return InputOutcome { should_quit: false, should_clear: clear },
+ }
+ }
+
match evt {
CEvent::Key(key) => {
if key.code == KeyCode::Char('q')
@@ -75,11 +119,12 @@ pub async fn handle_input(
tokio::spawn(async move {
let _ = tokio::fs::remove_file(path_to_remove).await;
});
+ state.update_search();
}
state.show_unlike_confirm = None;
clear = true;
- let sorted = state.get_sorted_likes();
+ let sorted = state.filtered_likes.clone();
if sorted.is_empty() {
state.liked_list_state.select(None);
state.selected_art = None;
@@ -110,7 +155,6 @@ pub async fn handle_input(
return InputOutcome { should_quit: false, should_clear: clear };
}
- // GLOBAL KEYBINDS
if key.code == KeyCode::Char('m') {
state.is_muted = !state.is_muted;
if let Ok(mut stream) = UnixStream::connect(socket_path) {
@@ -135,12 +179,11 @@ pub async fn handle_input(
return InputOutcome { should_quit: false, should_clear: clear };
}
- // MODE SPECIFIC KEYBINDS
if state.mode == AppMode::Playing {
if key.code == KeyCode::Tab {
state.mode = AppMode::LikedSongs;
state.nav_time = Some(std::time::Instant::now());
- let sorted = state.get_sorted_likes();
+ let sorted = state.filtered_likes.clone();
if !sorted.is_empty() {
state.liked_list_state.select(Some(0));
load_local_art(sorted[0].id, state.art_path(sorted[0].id), tx.clone(), picker.clone());
@@ -226,8 +269,11 @@ pub async fn handle_input(
state.mode = AppMode::Playing;
state.selected_art = None;
state.nav_time = Some(std::time::Instant::now());
+ } else if key.code == KeyCode::Char('/') {
+ state.is_searching = true;
+ clear = true;
} else if key.code == KeyCode::Up || key.code == KeyCode::Char('k') {
- let sorted = state.get_sorted_likes();
+ let sorted = state.filtered_likes.clone();
if !sorted.is_empty() {
let current = state.liked_list_state.selected().unwrap_or(0);
let next = if current == 0 { sorted.len() - 1 } else { current - 1 };
@@ -243,7 +289,7 @@ pub async fn handle_input(
}
}
} else if key.code == KeyCode::Down || key.code == KeyCode::Char('j') {
- let sorted = state.get_sorted_likes();
+ let sorted = state.filtered_likes.clone();
if !sorted.is_empty() {
let current = state.liked_list_state.selected().unwrap_or(0);
let next = if current == sorted.len() - 1 { 0 } else { current + 1 };
@@ -260,7 +306,7 @@ pub async fn handle_input(
}
} else if key.code == KeyCode::Char('c') || key.code == KeyCode::Char('C') {
if let Some(idx) = state.liked_list_state.selected() {
- let sorted_likes = state.get_sorted_likes();
+ let sorted_likes = state.filtered_likes.clone();
if let Some(song) = sorted_likes.get(idx)
&& let Ok(mut clipboard) = arboard::Clipboard::new()
{
@@ -283,7 +329,7 @@ pub async fn handle_input(
}
} else if key.code == KeyCode::Char('l') {
if let Some(idx) = state.liked_list_state.selected() {
- let sorted_likes = state.get_sorted_likes();
+ let sorted_likes = state.filtered_likes.clone();
if let Some(song) = sorted_likes.get(idx) {
state.show_unlike_confirm = Some(song.id);
}
@@ -291,7 +337,7 @@ pub async fn handle_input(
} else if (key.code == KeyCode::Char('o') || key.code == KeyCode::Enter)
&& let Some(idx) = state.liked_list_state.selected()
{
- let sorted_likes = state.get_sorted_likes();
+ let sorted_likes = state.filtered_likes.clone();
if let Some(song) = sorted_likes.get(idx) {
let query = urlencoding::encode(&song.album);
let url = format!("https://touhoudb.com/Search?searchType=Album&filter={query}");
@@ -371,6 +417,7 @@ pub async fn handle_input(
if let Ok(data) = serde_json::to_string_pretty(&state.likes) {
fs::write(&state.likes_file, data).ok();
}
+ state.update_search();
state.status_message = "song liked!".to_string();
state.status_message_time = Some(std::time::Instant::now());
diff --git a/src/main.rs b/src/main.rs
@@ -561,7 +561,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
},
AppEvent::LocalArtLoaded(id, protocol) => {
if let Some(idx) = state.liked_list_state.selected() {
- let sorted_likes = state.get_sorted_likes();
+ let sorted_likes = &state.filtered_likes;
if let Some(song) = sorted_likes.get(idx)
&& song.id == id
{
diff --git a/src/ui.rs b/src/ui.rs
@@ -335,7 +335,7 @@ fn draw_library(f: &mut Frame, state: &mut AppState, content: Rc<[Rect]>, bottom
.constraints([Constraint::Length(image_height), Constraint::Min(0)])
.split(content[0]);
- let sorted = state.get_sorted_likes();
+ let sorted = &state.filtered_likes;
let has_art = state
.liked_list_state
.selected()
@@ -373,7 +373,7 @@ fn draw_library(f: &mut Frame, state: &mut AppState, content: Rc<[Rect]>, bottom
if items.is_empty() {
items.push(ListItem::new(Line::from(vec![Span::styled(
- "No liked songs yet.",
+ if state.search_query.is_empty() { "No liked songs yet." } else { "No search results." },
Style::default().fg(state.secondary_color),
)])));
}
@@ -382,7 +382,20 @@ fn draw_library(f: &mut Frame, state: &mut AppState, content: Rc<[Rect]>, bottom
content[2],
&mut state.liked_list_state,
);
- f.render_widget(Paragraph::new(build_status_bar(state)).alignment(Alignment::Left), bottom);
+
+ if state.is_searching {
+ f.render_widget(
+ Paragraph::new(Line::from(vec![
+ Span::styled(" Search: ", Style::default().fg(state.secondary_color)),
+ Span::styled(&state.search_query, Style::default().fg(state.highlight_fg_color).bold()),
+ Span::styled("█", Style::default().fg(state.theme_color).bold()),
+ ]))
+ .alignment(Alignment::Left),
+ bottom,
+ );
+ } else {
+ f.render_widget(Paragraph::new(build_status_bar(state)).alignment(Alignment::Left), bottom);
+ }
}
fn draw_history(f: &mut Frame, state: &mut AppState, content: Rc<[Rect]>, bottom: Rect, hide_art: bool) {
@@ -506,6 +519,10 @@ fn draw_help_popup(f: &mut Frame, state: &AppState, area: Rect) {
Span::styled("[o/Enter]", Style::default().bold().fg(state.theme_color)),
Span::raw("Open album info"),
]),
+ Row::new(vec![
+ Span::styled("[/]", Style::default().bold().fg(state.theme_color)),
+ Span::raw("Search library"),
+ ]),
Row::new(vec![
Span::styled("[j/k]", Style::default().bold().fg(state.theme_color)),
Span::raw("Scroll list down / up"),