feat: add live fuzzy search/filter for torrents

This commit is contained in:
Kristofers Solo 2026-01-01 15:48:51 +02:00
parent 82389ef5b3
commit 1b145f9ace
Signed by: kristoferssolo
GPG Key ID: 8687F2D3EEE6F0ED
11 changed files with 113 additions and 16 deletions

10
Cargo.lock generated
View File

@ -600,6 +600,15 @@ dependencies = [
"pin-utils",
]
[[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"
@ -2513,6 +2522,7 @@ dependencies = [
"crossterm",
"derive_more",
"dirs",
"fuzzy-matcher",
"ratatui",
"serde",
"thiserror 2.0.12",

View File

@ -10,6 +10,7 @@ color-eyre = "0.6"
crossterm = "0.29"
derive_more = { version = "2.1", features = ["display"] }
dirs = "6.0"
fuzzy-matcher = "0.3"
ratatui = "0.30"
serde = { version = "1.0", features = ["derive"] }
thiserror = "2.0"

View File

@ -35,6 +35,10 @@ rename_torrent = "r"
delete = "d"
delete_force = "D"
# Search/filter
filter = "/"
clear_filter = "escape"
# General
toggle_help = "?"
quit = "q"

View File

@ -30,6 +30,10 @@ pub enum Action {
Delete(bool),
#[display("Rename Torrent")]
Rename,
#[display("Filter")]
Filter,
#[display("Clear Filter")]
ClearFilter,
#[display("Select")]
Select,
#[display("Submit")]

View File

@ -9,6 +9,7 @@ pub mod utils;
use crate::error::Result;
use crate::{app::input::InputHandler, config::Config};
use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
use ratatui::widgets::TableState;
use std::path::PathBuf;
use types::Selected;
@ -21,6 +22,7 @@ pub enum InputMode {
None,
Move,
Rename,
Filter,
/// Confirm delete dialog. Bool indicates whether to delete local data.
ConfirmDelete(bool),
}
@ -37,6 +39,7 @@ pub struct App {
pub config: Config,
pub input_handler: InputHandler,
pub input_mode: InputMode,
pub filter_text: String,
}
impl App {
@ -58,6 +61,7 @@ impl App {
config,
input_handler: InputHandler::new(),
input_mode: InputMode::None,
filter_text: String::new(),
})
}
@ -121,12 +125,15 @@ impl App {
#[inline]
pub fn prev_tab(&mut self) {
self.close_help();
self.index = self.index.checked_sub(1).unwrap_or(self.tabs.len() - 1);
self.index = self
.index
.checked_sub(1)
.unwrap_or_else(|| self.tabs.len() - 1);
}
/// Switches to the tab whose index is `idx` if it exists.
#[inline]
pub fn switch_tab(&mut self, idx: usize) {
pub const fn switch_tab(&mut self, idx: usize) {
if idx < self.tabs.len() {
self.close_help();
self.index = idx;
@ -227,6 +234,61 @@ impl App {
self.close_help();
}
/// Start filter mode.
pub fn start_filter(&mut self) {
self.input_handler.set_text(self.filter_text.clone());
self.input_mode = InputMode::Filter;
}
/// Apply filter from input.
pub fn apply_filter(&mut self) {
self.filter_text = self.input_handler.text.clone();
self.input_handler.clear();
self.input_mode = InputMode::None;
self.state.select(Some(0));
}
/// Clear the active filter.
pub fn clear_filter(&mut self) {
self.filter_text.clear();
self.input_handler.clear();
self.input_mode = InputMode::None;
}
/// Get the active filter text (live from input or saved).
#[must_use]
pub fn active_filter(&self) -> &str {
if self.input_mode == InputMode::Filter {
&self.input_handler.text
} else {
&self.filter_text
}
}
/// Get filtered torrents based on current filter text using fuzzy matching.
#[must_use]
pub fn filtered_torrents(&self) -> Vec<&transmission_rpc::types::Torrent> {
let filter = self.active_filter();
if filter.is_empty() {
self.torrents.torrents.iter().collect()
} else {
let matcher = SkimMatcherV2::default();
let mut scored: Vec<_> = self
.torrents
.torrents
.iter()
.filter_map(|t| {
t.name
.as_ref()
.and_then(|name| matcher.fuzzy_match(name, filter).map(|score| (t, score)))
})
.collect();
// Sort by score descending (best matches first)
scored.sort_by(|a, b| b.1.cmp(&a.1));
scored.into_iter().map(|(t, _)| t).collect()
}
}
/// Prepare delete confirmation dialog.
pub const fn prepare_delete(&mut self, delete_local_data: bool) {
self.input_mode = InputMode::ConfirmDelete(delete_local_data);

View File

@ -25,4 +25,6 @@ pub struct KeybindsConfig {
pub toggle_help: String,
pub move_torrent: String,
pub rename_torrent: String,
pub filter: String,
pub clear_filter: String,
}

View File

@ -73,6 +73,8 @@ pub async fn get_action(key_event: KeyEvent, app: &mut App) -> Result<Option<Act
(Action::ToggleHelp, &keybinds.toggle_help),
(Action::Move, &keybinds.move_torrent),
(Action::Rename, &keybinds.rename_torrent),
(Action::Filter, &keybinds.filter),
(Action::ClearFilter, &keybinds.clear_filter),
]
.into_iter()
.find_map(|(action, keybind)| matches_keybind(&key_event, keybind).then_some(action)))
@ -100,11 +102,14 @@ pub async fn update(app: &mut App, action: Action) -> Result<()> {
Action::StartAll => app.torrents.start_all().await?,
Action::Move => app.prepare_move_action(),
Action::Rename => app.prepare_rename_action(),
Action::Filter => app.start_filter(),
Action::ClearFilter => app.clear_filter(),
Action::Delete(delete_local_data) => app.prepare_delete(delete_local_data),
Action::Select => app.select(),
Action::Submit => match app.input_mode {
InputMode::Move => app.move_torrent().await?,
InputMode::Rename => app.rename_torrent().await?,
InputMode::Filter => app.apply_filter(),
InputMode::None | InputMode::ConfirmDelete(_) => {}
},
Action::ConfirmYes => app.confirm_delete().await?,

View File

@ -8,7 +8,7 @@ use tracing::warn;
pub fn render(f: &mut Frame, app: &App) {
match app.input_mode {
InputMode::Move | InputMode::Rename => render_text_input(f, app),
InputMode::Move | InputMode::Rename | InputMode::Filter => render_text_input(f, app),
InputMode::ConfirmDelete(delete_local_data) => render_confirm_delete(f, delete_local_data),
InputMode::None => {}
}
@ -21,6 +21,7 @@ fn render_text_input(f: &mut Frame, app: &App) {
let title = match app.input_mode {
InputMode::Move => "Move to",
InputMode::Rename => "Rename",
InputMode::Filter => "Filter",
_ => return,
};

View File

@ -51,12 +51,12 @@ pub fn render(app: &mut App, frame: &mut Frame) {
frame.render_widget(tabs, chunks[0]); // renders tab
app.torrents.set_fields(None);
let torrents = &app.torrents.torrents;
let torrents = app.filtered_torrents();
let selected = &app.torrents.selected;
let colors = &app.config.colors;
let fields = app.tabs()[app.index()].fields();
let table = build_table(torrents, selected, colors, app.tabs()[app.index()].fields());
let table = build_table(&torrents, selected, colors, fields);
frame.render_stateful_widget(table, chunks[1], &mut app.state);
status::render(frame, app, chunks[2]);

View File

@ -23,24 +23,32 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) {
let uploaded = FileSize::new(total_uploaded.unsigned_abs());
let total = app.torrents.len();
let filtered = app.filtered_torrents().len();
let selected_count = app.torrents.selected.len();
let active_filter = app.active_filter();
let count_info = if selected_count > 0 {
format!("{selected_count}/{total}")
} else if !active_filter.is_empty() {
format!("{filtered}/{total}")
} else {
format!("{total}")
};
let mode_text = match app.input_mode {
InputMode::Move => Some("MOVE"),
InputMode::Rename => Some("RENAME"),
InputMode::ConfirmDelete(_) => Some("DELETE"),
InputMode::Move => Some("MOVE".to_string()),
InputMode::Rename => Some("RENAME".to_string()),
InputMode::Filter => Some(format!("Filter: {active_filter}")),
InputMode::ConfirmDelete(_) => Some("DELETE".to_string()),
InputMode::None if !active_filter.is_empty() => Some(format!("Filter: {active_filter}")),
InputMode::None => None,
};
let keybinds = match app.input_mode {
InputMode::None if !app.filter_text.is_empty() => "Esc Clear filter │ ? Help",
InputMode::None => "? Help",
InputMode::Move | InputMode::Rename => "Enter Submit │ Esc Cancel",
InputMode::Filter => "Enter Confirm │ Esc Cancel",
InputMode::ConfirmDelete(_) => "y Confirm │ n Cancel",
};
@ -65,7 +73,7 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) {
.borders(Borders::ALL)
.border_type(BorderType::Rounded);
if let Some(mode) = mode_text {
if let Some(ref mode) = mode_text {
block = block
.title(format!(" {mode} "))
.title_style(Style::default().fg(Color::Yellow).bold());

View File

@ -8,12 +8,12 @@ use ratatui::{
use std::collections::HashSet;
use transmission_rpc::types::{Torrent, TorrentGetField, TorrentStatus};
pub fn build_table<'a>(
torrents: &'a [Torrent],
pub fn build_table(
torrents: &[&Torrent],
selected: &HashSet<i64>,
colors: &ColorConfig,
fields: &[TorrentGetField],
) -> Table<'a> {
) -> Table<'static> {
let row_style = row_style(colors);
let header_style = header_style(colors);
let highlight_row_style = hightlighted_row_style(colors);
@ -60,13 +60,13 @@ fn hightlighted_row_style(cfg: &ColorConfig) -> Style {
Style::default().bg(bg).fg(fg)
}
fn make_row<'a>(
torrent: &'a Torrent,
fn make_row(
torrent: &Torrent,
fields: &[TorrentGetField],
selected: &HashSet<i64>,
highlight: Style,
colors: &ColorConfig,
) -> Row<'a> {
) -> Row<'static> {
let status_style = status_style(torrent.status, colors);
let cells = fields.iter().map(|&field| {