| @@ -3,10 +3,6 @@ |
| 3 | 3 | | use std::time::{Duration, Instant}; |
| 4 | 4 | | |
| 5 | 5 | | use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; |
| 6 | | − | use nucleo_matcher::{ |
| 7 | | − | Config as MatcherConfig, Matcher, Utf32Str, |
| 8 | | − | pattern::{CaseMatching, Normalization, Pattern}, |
| 9 | | − | }; |
| 10 | 6 | | use ratatui::{ |
| 11 | 7 | | Frame, |
| 12 | 8 | | layout::{Constraint, Layout, Position, Rect}, |
| @@ -15,6 +11,8 @@ use ratatui::{ |
| 15 | 11 | | widgets::{Row, Table, TableState}, |
| 16 | 12 | | }; |
| 17 | 13 | | |
| 14 | + | use regex::{Regex, RegexBuilder}; |
| 15 | + | |
| 18 | 16 | | use crate::{action::Action, config::Theme, ui}; |
| 19 | 17 | | |
| 20 | 18 | | /// Cursor, scroll offset and search state of a list. |
| @@ -217,8 +215,11 @@ pub enum Header<'a> { |
| 217 | 215 | | Columns(Row<'a>), |
| 218 | 216 | | } |
| 219 | 217 | | |
| 220 | | − | /// Fuzzy search results over a list, kept in list order so that `n`/`N` |
| 221 | | − | /// walk through them like in a pager. |
| 218 | + | /// Search results over a list, kept in list order so that `n`/`N` walk |
| 219 | + | /// through them like in a pager. |
| 220 | + | /// |
| 221 | + | /// Like ncmpcpp, the query is a case-insensitive regular expression that |
| 222 | + | /// may match anywhere in an item. |
| 222 | 223 | | #[derive(Debug, Default)] |
| 223 | 224 | | pub struct Search { |
| 224 | 225 | | query: String, |
| @@ -232,15 +233,12 @@ impl Search { |
| 232 | 233 | | if query.trim().is_empty() { |
| 233 | 234 | | return; |
| 234 | 235 | | } |
| 235 | | − | let pattern = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart); |
| 236 | | − | let mut matcher = Matcher::new(MatcherConfig::DEFAULT); |
| 237 | | − | let mut buf = Vec::new(); |
| 238 | | − | self.matches |
| 239 | | − | .extend(items.enumerate().filter_map(|(index, item)| { |
| 240 | | − | pattern |
| 241 | | − | .score(Utf32Str::new(item, &mut buf), &mut matcher) |
| 242 | | − | .map(|_| index) |
| 243 | | − | })); |
| 236 | + | let regex = compile(query); |
| 237 | + | self.matches.extend( |
| 238 | + | items |
| 239 | + | .enumerate() |
| 240 | + | .filter_map(|(index, item)| regex.is_match(item).then_some(index)), |
| 241 | + | ); |
| 244 | 242 | | } |
| 245 | 243 | | |
| 246 | 244 | | pub fn clear(&mut self) { |
| @@ -284,6 +282,16 @@ impl Search { |
| 284 | 282 | | } |
| 285 | 283 | | } |
| 286 | 284 | | |
| 285 | + | /// Builds a case-insensitive regex from a query. Since the search runs as |
| 286 | + | /// you type, an invalid (often half-typed) pattern such as `(` is matched |
| 287 | + | /// literally instead. |
| 288 | + | fn compile(query: &str) -> Regex { |
| 289 | + | let build = |pattern: &str| RegexBuilder::new(pattern).case_insensitive(true).build(); |
| 290 | + | build(query) |
| 291 | + | .or_else(|_| build(®ex::escape(query))) |
| 292 | + | .expect("escaped pattern is valid") |
| 293 | + | } |
| 294 | + | |
| 287 | 295 | | #[cfg(test)] |
| 288 | 296 | | mod tests { |
| 289 | 297 | | use super::*; |
| @@ -297,11 +305,28 @@ mod tests { |
| 297 | 305 | | } |
| 298 | 306 | | |
| 299 | 307 | | #[test] |
| 300 | | − | fn finds_fuzzy_matches_in_list_order() { |
| 308 | + | fn finds_case_insensitive_matches_in_list_order() { |
| 301 | 309 | | let s = search("alp"); |
| 302 | 310 | | assert_eq!(s.matches, [0, 3]); |
| 303 | 311 | | assert!(s.is_match(3) && !s.is_match(1)); |
| 304 | 312 | | assert_eq!(search("").match_count(), 0); |
| 313 | + | // Substring, not fuzzy: the letters must be adjacent. |
| 314 | + | assert_eq!(search("apa").match_count(), 0); |
| 315 | + | assert_eq!(search("ET").matches, [1, 3]); |
| 316 | + | } |
| 317 | + | |
| 318 | + | #[test] |
| 319 | + | fn supports_regex_and_falls_back_to_literal() { |
| 320 | + | assert_eq!(search("^a").matches, [0, 3]); |
| 321 | + | assert_eq!(search("ta$").matches, [1, 4]); |
| 322 | + | assert_eq!(search("gam|del").matches, [2, 4]); |
| 323 | + | // Invalid regex: matched literally, so nothing here. |
| 324 | + | assert_eq!(search("(").match_count(), 0); |
| 325 | + | let mut s = Search::default(); |
| 326 | + | s.update("(live)", ["Song (Live)", "Live"].into_iter()); |
| 327 | + | assert_eq!(s.matches, [0, 1]); |
| 328 | + | s.update("(live", ["Song (Live)", "Live"].into_iter()); |
| 329 | + | assert_eq!(s.matches, [0]); |
| 305 | 330 | | } |
| 306 | 331 | | |
| 307 | 332 | | #[test] |