feature/prod-ready #1
@@ -10,6 +10,7 @@ path = "src/lib.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
||||||
|
nucleo-matcher = "0.3"
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ pub trait DesktopEntrySource: Send + Sync {
|
|||||||
struct CachedEntry {
|
struct CachedEntry {
|
||||||
id: String,
|
id: String,
|
||||||
name: AppName,
|
name: AppName,
|
||||||
name_lc: String,
|
|
||||||
keywords_lc: Vec<String>,
|
keywords_lc: Vec<String>,
|
||||||
category: Option<String>,
|
category: Option<String>,
|
||||||
icon: Option<String>,
|
icon: Option<String>,
|
||||||
@@ -90,7 +89,6 @@ impl AppsPlugin {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|e| {
|
.map(|e| {
|
||||||
let id = format!("app-{}", e.name.as_str());
|
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();
|
let keywords_lc = e.keywords.iter().map(|k| k.to_lowercase()).collect();
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
let icon = e.icon.as_ref().and_then(|p| linux::resolve_icon_path(p.as_str()));
|
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 {
|
let cached = CachedEntry {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
name_lc,
|
|
||||||
keywords_lc,
|
keywords_lc,
|
||||||
category: e.category,
|
category: e.category,
|
||||||
icon,
|
icon,
|
||||||
@@ -123,12 +120,27 @@ fn initials(name_lc: &str) -> String {
|
|||||||
name_lc.split_whitespace().filter_map(|w| w.chars().next()).collect()
|
name_lc.split_whitespace().filter_map(|w| w.chars().next()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn score_match(name_lc: &str, query_lc: &str) -> Option<u32> {
|
fn score_match(name: &str, query: &str) -> Option<u32> {
|
||||||
if name_lc == query_lc { return Some(100); }
|
use nucleo_matcher::{
|
||||||
if name_lc.starts_with(query_lc) { return Some(80); }
|
pattern::{CaseMatching, Normalization, Pattern},
|
||||||
if name_lc.contains(query_lc) { return Some(60); }
|
Config, Matcher, Utf32Str,
|
||||||
if initials(name_lc).starts_with(query_lc) { return Some(70); }
|
};
|
||||||
|
|
||||||
|
let mut matcher = Matcher::new(Config::DEFAULT);
|
||||||
|
let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
|
||||||
|
|
||||||
|
let mut name_chars: Vec<char> = 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
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn humanize_category(s: &str) -> String {
|
pub(crate) fn humanize_category(s: &str) -> String {
|
||||||
@@ -172,7 +184,7 @@ impl Plugin for AppsPlugin {
|
|||||||
self.entries
|
self.entries
|
||||||
.values()
|
.values()
|
||||||
.filter_map(|e| {
|
.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)
|
e.keywords_lc.iter().any(|k| k.contains(&query_lc)).then_some(50)
|
||||||
})?;
|
})?;
|
||||||
Some(SearchResult {
|
Some(SearchResult {
|
||||||
@@ -269,15 +281,20 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn score_match_abbreviation() {
|
fn score_match_abbreviation() {
|
||||||
assert_eq!(initials("visual studio code"), "vsc");
|
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]
|
#[test]
|
||||||
fn score_match_exact_beats_prefix_beats_abbrev_beats_substr() {
|
fn score_match_exact_beats_prefix_beats_abbrev_beats_substr() {
|
||||||
assert_eq!(score_match("firefox", "firefox"), Some(100));
|
let exact = score_match("firefox", "firefox");
|
||||||
assert_eq!(score_match("firefox", "fire"), Some(80));
|
let prefix = score_match("firefox", "fire");
|
||||||
assert_eq!(score_match("gnu firefox", "gf"), Some(70));
|
let abbrev = score_match("gnu firefox", "gf");
|
||||||
assert_eq!(score_match("ice firefox", "fire"), Some(60));
|
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]
|
#[tokio::test]
|
||||||
@@ -289,7 +306,7 @@ mod tests {
|
|||||||
let results = p.search("vsc").await;
|
let results = p.search("vsc").await;
|
||||||
assert_eq!(results.len(), 1);
|
assert_eq!(results.len(), 1);
|
||||||
assert_eq!(results[0].title.as_str(), "Visual Studio Code");
|
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]
|
#[tokio::test]
|
||||||
@@ -303,6 +320,14 @@ mod tests {
|
|||||||
assert_eq!(results[0].score.value(), 50);
|
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]
|
#[test]
|
||||||
fn humanize_category_splits_camel_case() {
|
fn humanize_category_splits_camel_case() {
|
||||||
assert_eq!(humanize_category("TextEditor"), "Text Editor");
|
assert_eq!(humanize_category("TextEditor"), "Text Editor");
|
||||||
|
|||||||
Reference in New Issue
Block a user