Krolyxon
Commit 815a27a

rmptui815a27a

Use ncmpcpp-style regex search instead of fuzzy matching

/ now matches a case-insensitive regular expression anywhere in the
item, like ncmpcpp's default (regular_expressions = perl). Invalid,
half-typed patterns fall back to a literal match.
Author
krolyxon
Date
2026-10-23 15:49 +0530
Parent
3a91ab1
Commit
815a27a0cbb63afcc89450c8c9ce4afc78d39446

+63 −30

5 files changed

  1. M CHANGELOG.md +6 −0
  2. M Cargo.lock +13 −11
  3. M Cargo.toml +1 −1
  4. M README.md +2 −2
  5. M src/ui/list.rs +41 −16
M CHANGELOG.md View
@@ -1,3 +1,9 @@
1+## Unreleased
2+
3+- `/` search now works like ncmpcpp's: a case-insensitive regular
4+ expression matched anywhere in the item, instead of fuzzy matching.
5+ Invalid (half-typed) patterns are matched literally.
6+
17 ## v0.2.0
28
39 A rewrite from scratch with a new architecture modelled on ncmpcpp and rmpc.
M Cargo.lock View
@@ -1090,16 +1090,6 @@ dependencies = [
10901090 "windows-sys 0.61.2",
10911091 ]
10921092
1093−[[package]]
1094−name = "nucleo-matcher"
1095−version = "0.3.1"
1096−source = "registry+https://github.com/rust-lang/crates.io-index"
1097−checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85"
1098−dependencies = [
1099− "memchr",
1100− "unicode-segmentation",
1101−]
1102−
11031093 [[package]]
11041094 name = "num-complex"
11051095 version = "0.4.6"
@@ -1470,6 +1460,18 @@ dependencies = [
14701460 "bitflags",
14711461 ]
14721462
1463+[[package]]
1464+name = "regex"
1465+version = "1.13.1"
1466+source = "registry+https://github.com/rust-lang/crates.io-index"
1467+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
1468+dependencies = [
1469+ "aho-corasick",
1470+ "memchr",
1471+ "regex-automata",
1472+ "regex-syntax",
1473+]
1474+
14731475 [[package]]
14741476 name = "regex-automata"
14751477 version = "0.4.18"
@@ -1549,9 +1551,9 @@ dependencies = [
15491551 "futures-util",
15501552 "libc",
15511553 "lofty",
1552− "nucleo-matcher",
15531554 "ratatui",
15541555 "realfft",
1556+ "regex",
15551557 "reqwest",
15561558 "serde",
15571559 "serde_json",
M Cargo.toml View
@@ -18,9 +18,9 @@ crossterm = { version = "0.29.0", features = ["event-stream"] }
1818 futures-util = "0.3.34"
1919 libc = "0.2.186"
2020 lofty = "0.25.4"
21−nucleo-matcher = "0.3.1"
2221 ratatui = { version = "0.30.2", default-features = false, features = ["crossterm_0_29"] }
2322 realfft = "3.5.0"
23+regex = "1.13.1"
2424 reqwest = { version = "0.13.5", default-features = false, features = ["json", "query", "rustls"] }
2525 serde = { version = "1.0.229", features = ["derive"] }
2626 serde_json = "1.0.154"
M README.md View
@@ -43,8 +43,8 @@ Other highlights:
4343 - **Instant controls.** All MPD traffic runs in background tasks over MPD's
4444 `idle` notifications, so seeking, volume changes and skipping never wait for
4545 the UI, and the UI never waits for the server.
46−- **Fuzzy search everywhere.** `/` searches the focused list; `n`/`N` cycle
47− through the matches.
46+- **Search everywhere.** `/` searches the focused list with a
47+ case-insensitive regex, like ncmpcpp; `n`/`N` cycle through the matches.
4848 - **Marks.** `v` marks items; queue removal, add-to-playlist and tag edits
4949 then apply to every marked item.
5050 - **Mouse support.** Click to select, double-click to play/open, scroll with
M src/ui/list.rs View
@@ -3,10 +3,6 @@
33 use std::time::{Duration, Instant};
44
55 use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
6−use nucleo_matcher::{
7− Config as MatcherConfig, Matcher, Utf32Str,
8− pattern::{CaseMatching, Normalization, Pattern},
9−};
106 use ratatui::{
117 Frame,
128 layout::{Constraint, Layout, Position, Rect},
@@ -15,6 +11,8 @@ use ratatui::{
1511 widgets::{Row, Table, TableState},
1612 };
1713
14+use regex::{Regex, RegexBuilder};
15+
1816 use crate::{action::Action, config::Theme, ui};
1917
2018 /// Cursor, scroll offset and search state of a list.
@@ -217,8 +215,11 @@ pub enum Header<'a> {
217215 Columns(Row<'a>),
218216 }
219217
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.
222223 #[derive(Debug, Default)]
223224 pub struct Search {
224225 query: String,
@@ -232,15 +233,12 @@ impl Search {
232233 if query.trim().is_empty() {
233234 return;
234235 }
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+ );
244242 }
245243
246244 pub fn clear(&mut self) {
@@ -284,6 +282,16 @@ impl Search {
284282 }
285283 }
286284
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(&regex::escape(query)))
292+ .expect("escaped pattern is valid")
293+}
294+
287295 #[cfg(test)]
288296 mod tests {
289297 use super::*;
@@ -297,11 +305,28 @@ mod tests {
297305 }
298306
299307 #[test]
300− fn finds_fuzzy_matches_in_list_order() {
308+ fn finds_case_insensitive_matches_in_list_order() {
301309 let s = search("alp");
302310 assert_eq!(s.matches, [0, 3]);
303311 assert!(s.is_match(3) && !s.is_match(1));
304312 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]);
305330 }
306331
307332 #[test]