Files
k-launcher/crates/plugins/plugin-apps/src/lib.rs

338 lines
10 KiB
Rust

pub mod frecency;
#[cfg(target_os = "linux")]
pub mod linux;
use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait;
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
use crate::frecency::FrecencyStore;
// --- Domain newtypes ---
#[derive(Debug, Clone)]
pub struct AppName(String);
impl AppName {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct ExecCommand(String);
impl ExecCommand {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct IconPath(String);
impl IconPath {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
// --- Desktop entry ---
pub struct DesktopEntry {
pub name: AppName,
pub exec: ExecCommand,
pub icon: Option<IconPath>,
pub category: Option<String>,
pub keywords: Vec<String>,
}
// --- Swappable source trait (Application layer principle) ---
pub trait DesktopEntrySource: Send + Sync {
fn entries(&self) -> Vec<DesktopEntry>;
}
// --- Cached entry (pre-computed at construction) ---
struct CachedEntry {
id: String,
name: AppName,
name_lc: String,
keywords_lc: Vec<String>,
category: Option<String>,
icon: Option<String>,
exec: String,
on_select: Arc<dyn Fn() + Send + Sync>,
}
// --- Plugin ---
pub struct AppsPlugin {
entries: HashMap<String, CachedEntry>,
frecency: Arc<FrecencyStore>,
}
impl AppsPlugin {
pub fn new(source: impl DesktopEntrySource, frecency: Arc<FrecencyStore>) -> Self {
let entries = source
.entries()
.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()));
#[cfg(not(target_os = "linux"))]
let icon: Option<String> = None;
let exec = e.exec.as_str().to_string();
let store = Arc::clone(&frecency);
let record_id = id.clone();
let on_select: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
store.record(&record_id);
});
let cached = CachedEntry {
id: id.clone(),
name_lc,
keywords_lc,
category: e.category,
icon,
exec,
on_select,
name: e.name,
};
(id, cached)
})
.collect();
Self { entries, frecency }
}
}
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<u32> {
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
}
pub(crate) fn humanize_category(s: &str) -> String {
let mut result = String::new();
for ch in s.chars() {
if ch.is_uppercase() && !result.is_empty() {
result.push(' ');
}
result.push(ch);
}
result
}
#[async_trait]
impl Plugin for AppsPlugin {
fn name(&self) -> &str {
"apps"
}
async fn search(&self, query: &str) -> Vec<SearchResult> {
if query.is_empty() {
return self.frecency.top_ids(5)
.iter()
.filter_map(|id| {
let e = self.entries.get(id)?;
let score = self.frecency.frecency_score(id).max(1);
Some(SearchResult {
id: ResultId::new(id),
title: ResultTitle::new(e.name.as_str()),
description: e.category.clone(),
icon: e.icon.clone(),
score: Score::new(score),
action: LaunchAction::SpawnProcess(e.exec.clone()),
on_select: Some(Arc::clone(&e.on_select)),
})
})
.collect();
}
let query_lc = query.to_lowercase();
self.entries
.values()
.filter_map(|e| {
let score = score_match(&e.name_lc, &query_lc).or_else(|| {
e.keywords_lc.iter().any(|k| k.contains(&query_lc)).then_some(50)
})?;
Some(SearchResult {
id: ResultId::new(&e.id),
title: ResultTitle::new(e.name.as_str()),
description: e.category.clone(),
icon: e.icon.clone(),
score: Score::new(score),
action: LaunchAction::SpawnProcess(e.exec.clone()),
on_select: Some(Arc::clone(&e.on_select)),
})
})
.collect()
}
}
// --- Tests ---
#[cfg(test)]
mod tests {
use super::*;
fn ephemeral_frecency() -> Arc<FrecencyStore> {
FrecencyStore::new_for_test()
}
struct MockSource {
entries: Vec<(String, String, Option<String>, Vec<String>)>, // (name, exec, category, keywords)
}
impl MockSource {
fn with(entries: Vec<(&str, &str)>) -> Self {
Self {
entries: entries
.into_iter()
.map(|(n, e)| (n.to_string(), e.to_string(), None, vec![]))
.collect(),
}
}
fn with_categories(entries: Vec<(&str, &str, &str)>) -> Self {
Self {
entries: entries
.into_iter()
.map(|(n, e, c)| (n.to_string(), e.to_string(), Some(c.to_string()), vec![]))
.collect(),
}
}
fn with_keywords(entries: Vec<(&str, &str, Vec<&str>)>) -> Self {
Self {
entries: entries
.into_iter()
.map(|(n, e, kw)| (n.to_string(), e.to_string(), None, kw.into_iter().map(|s| s.to_string()).collect()))
.collect(),
}
}
}
impl DesktopEntrySource for MockSource {
fn entries(&self) -> Vec<DesktopEntry> {
self.entries
.iter()
.map(|(name, exec, category, keywords)| DesktopEntry {
name: AppName::new(name.clone()),
exec: ExecCommand::new(exec.clone()),
icon: None,
category: category.clone(),
keywords: keywords.clone(),
})
.collect()
}
}
#[tokio::test]
async fn apps_prefix_match() {
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency());
let results = p.search("fire").await;
assert_eq!(results[0].title.as_str(), "Firefox");
}
#[tokio::test]
async fn apps_no_match_returns_empty() {
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency());
assert!(p.search("zz").await.is_empty());
}
#[tokio::test]
async fn apps_empty_query_no_frecency_returns_empty() {
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency());
assert!(p.search("").await.is_empty());
}
#[test]
fn score_match_abbreviation() {
assert_eq!(initials("visual studio code"), "vsc");
assert_eq!(score_match("visual studio code", "vsc"), Some(70));
}
#[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));
}
#[tokio::test]
async fn apps_abbreviation_match() {
let p = AppsPlugin::new(
MockSource::with(vec![("Visual Studio Code", "code")]),
ephemeral_frecency(),
);
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);
}
#[tokio::test]
async fn apps_keyword_match() {
let p = AppsPlugin::new(
MockSource::with_keywords(vec![("Code", "code", vec!["editor", "ide"])]),
ephemeral_frecency(),
);
let results = p.search("editor").await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].score.value(), 50);
}
#[test]
fn humanize_category_splits_camel_case() {
assert_eq!(humanize_category("TextEditor"), "Text Editor");
assert_eq!(humanize_category("WebBrowser"), "Web Browser");
assert_eq!(humanize_category("Development"), "Development");
}
#[tokio::test]
async fn apps_category_appears_in_description() {
let p = AppsPlugin::new(
MockSource::with_categories(vec![("Code", "code", "Text Editor")]),
ephemeral_frecency(),
);
let results = p.search("code").await;
assert_eq!(results[0].description.as_deref(), Some("Text Editor"));
}
#[tokio::test]
async fn apps_empty_query_returns_top_frecent() {
let frecency = ephemeral_frecency();
frecency.record("app-Code");
frecency.record("app-Code");
frecency.record("app-Firefox");
let p = AppsPlugin::new(
MockSource::with(vec![("Firefox", "firefox"), ("Code", "code")]),
frecency,
);
let results = p.search("").await;
assert_eq!(results.len(), 2);
assert_eq!(results[0].title.as_str(), "Code");
}
}