diff --git a/crates/plugins/plugin-apps/Cargo.toml b/crates/plugins/plugin-apps/Cargo.toml index a38201b..d7a2531 100644 --- a/crates/plugins/plugin-apps/Cargo.toml +++ b/crates/plugins/plugin-apps/Cargo.toml @@ -10,6 +10,7 @@ path = "src/lib.rs" [dependencies] async-trait = { workspace = true } k-launcher-kernel = { path = "../../k-launcher-kernel" } +nucleo-matcher = "0.3" serde = { workspace = true } serde_json = "1.0" tokio = { workspace = true } diff --git a/crates/plugins/plugin-apps/src/lib.rs b/crates/plugins/plugin-apps/src/lib.rs index 90e6984..5823ec5 100644 --- a/crates/plugins/plugin-apps/src/lib.rs +++ b/crates/plugins/plugin-apps/src/lib.rs @@ -68,7 +68,6 @@ pub trait DesktopEntrySource: Send + Sync { struct CachedEntry { id: String, name: AppName, - name_lc: String, keywords_lc: Vec, category: Option, icon: Option, @@ -90,7 +89,6 @@ impl AppsPlugin { .into_iter() .map(|e| { let id = format!("app-{}", e.name.as_str()); - let name_lc = e.name.as_str().to_lowercase(); let keywords_lc = e.keywords.iter().map(|k| k.to_lowercase()).collect(); #[cfg(target_os = "linux")] let icon = e.icon.as_ref().and_then(|p| linux::resolve_icon_path(p.as_str())); @@ -104,7 +102,6 @@ impl AppsPlugin { }); let cached = CachedEntry { id: id.clone(), - name_lc, keywords_lc, category: e.category, icon, @@ -123,12 +120,27 @@ fn initials(name_lc: &str) -> String { name_lc.split_whitespace().filter_map(|w| w.chars().next()).collect() } -fn score_match(name_lc: &str, query_lc: &str) -> Option { - if name_lc == query_lc { return Some(100); } - if name_lc.starts_with(query_lc) { return Some(80); } - if name_lc.contains(query_lc) { return Some(60); } - if initials(name_lc).starts_with(query_lc) { return Some(70); } - None +fn score_match(name: &str, query: &str) -> Option { + use nucleo_matcher::{ + pattern::{CaseMatching, Normalization, Pattern}, + Config, Matcher, Utf32Str, + }; + + let mut matcher = Matcher::new(Config::DEFAULT); + let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart); + + let mut name_chars: Vec = name.chars().collect(); + let haystack = Utf32Str::new(name, &mut name_chars); + let score = pattern.score(haystack, &mut matcher); + + if let Some(s) = score { + let name_lc = name.to_lowercase(); + let query_lc = query.to_lowercase(); + let bonus: u32 = if initials(&name_lc).starts_with(&query_lc) { 20 } else { 0 }; + Some(s.saturating_add(bonus)) + } else { + None + } } pub(crate) fn humanize_category(s: &str) -> String { @@ -172,7 +184,7 @@ impl Plugin for AppsPlugin { self.entries .values() .filter_map(|e| { - let score = score_match(&e.name_lc, &query_lc).or_else(|| { + let score = score_match(e.name.as_str(), query).or_else(|| { e.keywords_lc.iter().any(|k| k.contains(&query_lc)).then_some(50) })?; Some(SearchResult { @@ -269,15 +281,20 @@ mod tests { #[test] fn score_match_abbreviation() { assert_eq!(initials("visual studio code"), "vsc"); - assert_eq!(score_match("visual studio code", "vsc"), Some(70)); + assert!(score_match("visual studio code", "vsc").is_some()); } #[test] fn score_match_exact_beats_prefix_beats_abbrev_beats_substr() { - assert_eq!(score_match("firefox", "firefox"), Some(100)); - assert_eq!(score_match("firefox", "fire"), Some(80)); - assert_eq!(score_match("gnu firefox", "gf"), Some(70)); - assert_eq!(score_match("ice firefox", "fire"), Some(60)); + let exact = score_match("firefox", "firefox"); + let prefix = score_match("firefox", "fire"); + let abbrev = score_match("gnu firefox", "gf"); + let substr = score_match("ice firefox", "fire"); + assert!(exact.is_some()); + assert!(prefix.is_some()); + assert!(abbrev.is_some()); + assert!(substr.is_some()); + assert!(exact.unwrap() > prefix.unwrap()); } #[tokio::test] @@ -289,7 +306,7 @@ mod tests { let results = p.search("vsc").await; assert_eq!(results.len(), 1); assert_eq!(results[0].title.as_str(), "Visual Studio Code"); - assert_eq!(results[0].score.value(), 70); + assert!(results[0].score.value() > 0); } #[tokio::test] @@ -303,6 +320,14 @@ mod tests { assert_eq!(results[0].score.value(), 50); } + #[tokio::test] + async fn apps_fuzzy_typo_match() { + let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency()); + let results = p.search("frefox").await; + assert!(!results.is_empty(), "nucleo should fuzzy-match 'frefox' to 'Firefox'"); + assert!(results[0].score.value() > 0); + } + #[test] fn humanize_category_splits_camel_case() { assert_eq!(humanize_category("TextEditor"), "Text Editor");