diff --git a/Cargo.lock b/Cargo.lock index 28d4124..c8c5984 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 2ac73f9..3aa29db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/config/default.toml b/config/default.toml index 6309d7b..b2bcf90 100644 --- a/config/default.toml +++ b/config/default.toml @@ -35,6 +35,10 @@ rename_torrent = "r" delete = "d" delete_force = "D" +# Search/filter +filter = "/" +clear_filter = "escape" + # General toggle_help = "?" quit = "q" diff --git a/src/app/action.rs b/src/app/action.rs index 88a5455..2fbf2ef 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -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")] diff --git a/src/app/mod.rs b/src/app/mod.rs index a285fea..8d62147 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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); diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 5be0832..67be622 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -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, } diff --git a/src/handler.rs b/src/handler.rs index 925e88a..07575cf 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -73,6 +73,8 @@ pub async fn get_action(key_event: KeyEvent, app: &mut App) -> Result 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?, diff --git a/src/ui/input.rs b/src/ui/input.rs index 1fec81b..46ce91e 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -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, }; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 031ad39..b6d939e 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -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]); diff --git a/src/ui/status.rs b/src/ui/status.rs index 820342a..ee1dd25 100644 --- a/src/ui/status.rs +++ b/src/ui/status.rs @@ -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()); diff --git a/src/ui/table.rs b/src/ui/table.rs index 756efc5..e3983aa 100644 --- a/src/ui/table.rs +++ b/src/ui/table.rs @@ -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, 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, highlight: Style, colors: &ColorConfig, -) -> Row<'a> { +) -> Row<'static> { let status_style = status_style(torrent.status, colors); let cells = fields.iter().map(|&field| {