git/grrs/main

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
index ff04d4e..5e742ac 100644
--- 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
index c8ff781..47537c3 100644
--- 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
index ca0820c..6ac348e 100644
--- 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
index 2b151ea..e988f9b 100644
--- 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
index 0248385..234457c 100644
--- 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
index 3af2eac..3d2594a 100644
--- 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
index 4a39bc4..e09bf7f 100644
--- 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
index 0e259c1..d4c7f38 100644
--- 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"),