diff --git a/Cargo.lock b/Cargo.lock index c797e61..a370410 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2737,6 +2737,8 @@ dependencies = [ "async-trait", "k-launcher-kernel", "libc", + "serde", + "serde_json", "tokio", "xdg", ] diff --git a/crates/k-launcher-ui/src/app.rs b/crates/k-launcher-ui/src/app.rs index 3393233..c494f6c 100644 --- a/crates/k-launcher-ui/src/app.rs +++ b/crates/k-launcher-ui/src/app.rs @@ -116,8 +116,17 @@ fn view(state: &KLauncherApp) -> Element<'_, Message> { image(image::Handle::from_path(p)).width(24).height(24).into(), None => Space::new().width(24).height(24).into(), }; + let title_col: Element<'_, Message> = if let Some(desc) = &result.description { + column![ + text(result.title.as_str()).size(15), + text(desc).size(11).color(Color::from_rgba8(180, 180, 200, 0.8)), + ] + .into() + } else { + text(result.title.as_str()).size(15).into() + }; container( - row![icon_el, text(result.title.as_str()).size(15)] + row![icon_el, title_col] .spacing(8) .align_y(iced::Center), ) diff --git a/crates/k-launcher/src/main.rs b/crates/k-launcher/src/main.rs index 8a5e725..77b76d2 100644 --- a/crates/k-launcher/src/main.rs +++ b/crates/k-launcher/src/main.rs @@ -1,15 +1,16 @@ use std::sync::Arc; use k_launcher_kernel::Kernel; -use plugin_apps::{AppsPlugin, FsDesktopEntrySource}; +use plugin_apps::{AppsPlugin, FsDesktopEntrySource, frecency::FrecencyStore}; use plugin_calc::CalcPlugin; use plugin_cmd::CmdPlugin; fn main() -> iced::Result { + let frecency = FrecencyStore::load(); let kernel = Arc::new(Kernel::new(vec![ Arc::new(CmdPlugin::new()), Arc::new(CalcPlugin::new()), - Arc::new(AppsPlugin::new(FsDesktopEntrySource::new())), + Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)), ])); k_launcher_ui::run(kernel) } diff --git a/crates/plugins/plugin-apps/Cargo.toml b/crates/plugins/plugin-apps/Cargo.toml index 27da97a..19fc5e7 100644 --- a/crates/plugins/plugin-apps/Cargo.toml +++ b/crates/plugins/plugin-apps/Cargo.toml @@ -15,5 +15,7 @@ path = "src/main.rs" async-trait = { workspace = true } k-launcher-kernel = { path = "../../k-launcher-kernel" } libc = "0.2" +serde = { workspace = true } +serde_json = "1.0" tokio = { workspace = true } xdg = "2" diff --git a/crates/plugins/plugin-apps/src/frecency.rs b/crates/plugins/plugin-apps/src/frecency.rs new file mode 100644 index 0000000..f4d93f4 --- /dev/null +++ b/crates/plugins/plugin-apps/src/frecency.rs @@ -0,0 +1,134 @@ +use std::{ + collections::HashMap, + path::PathBuf, + sync::{Arc, Mutex}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Entry { + count: u32, + last_used: u64, +} + +pub struct FrecencyStore { + path: PathBuf, + data: Mutex>, +} + +impl FrecencyStore { + pub fn new(path: PathBuf) -> Arc { + let data = std::fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + Arc::new(Self { path, data: Mutex::new(data) }) + } + + #[cfg(test)] + pub fn new_for_test() -> Arc { + Arc::new(Self { + path: PathBuf::from("/dev/null"), + data: Mutex::new(HashMap::new()), + }) + } + + pub fn load() -> Arc { + let path = xdg::BaseDirectories::new() + .map(|xdg| xdg.get_data_home()) + .unwrap_or_else(|_| PathBuf::from(".")) + .join("k-launcher") + .join("frecency.json"); + Self::new(path) + } + + pub fn record(&self, id: &str) { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let mut data = self.data.lock().unwrap(); + let entry = data.entry(id.to_string()).or_insert(Entry { count: 0, last_used: 0 }); + entry.count += 1; + entry.last_used = now; + if let Some(parent) = self.path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(json) = serde_json::to_string(&*data) { + let _ = std::fs::write(&self.path, json); + } + } + + pub fn frecency_score(&self, id: &str) -> u32 { + let data = self.data.lock().unwrap(); + let Some(entry) = data.get(id) else { return 0 }; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let age_secs = now.saturating_sub(entry.last_used); + let decay = if age_secs < 3600 { 4 } else if age_secs < 86400 { 2 } else { 1 }; + entry.count * decay + } + + pub fn top_ids(&self, n: usize) -> Vec { + let data = self.data.lock().unwrap(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let mut scored: Vec<(String, u32)> = data + .iter() + .map(|(id, entry)| { + let age_secs = now.saturating_sub(entry.last_used); + let decay = if age_secs < 3600 { 4 } else if age_secs < 86400 { 2 } else { 1 }; + (id.clone(), entry.count * decay) + }) + .collect(); + scored.sort_by(|a, b| b.1.cmp(&a.1)); + scored.into_iter().take(n).map(|(id, _)| id).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_store() -> Arc { + Arc::new(FrecencyStore { + path: PathBuf::from("/dev/null"), + data: Mutex::new(HashMap::new()), + }) + } + + #[test] + fn record_increments_count() { + let store = make_store(); + store.record("app-firefox"); + store.record("app-firefox"); + let data = store.data.lock().unwrap(); + assert_eq!(data["app-firefox"].count, 2); + } + + #[test] + fn record_updates_last_used() { + let store = make_store(); + store.record("app-firefox"); + let data = store.data.lock().unwrap(); + assert!(data["app-firefox"].last_used > 0); + } + + #[test] + fn top_ids_returns_sorted_order() { + let store = make_store(); + store.record("app-firefox"); + store.record("app-code"); + store.record("app-code"); + store.record("app-code"); + let top = store.top_ids(2); + assert_eq!(top[0], "app-code"); + assert_eq!(top[1], "app-firefox"); + } +} diff --git a/crates/plugins/plugin-apps/src/lib.rs b/crates/plugins/plugin-apps/src/lib.rs index c621200..16f6f89 100644 --- a/crates/plugins/plugin-apps/src/lib.rs +++ b/crates/plugins/plugin-apps/src/lib.rs @@ -1,9 +1,13 @@ -use std::{path::Path, process::{Command, Stdio}, sync::Arc}; +pub mod frecency; + +use std::{collections::HashMap, path::Path, process::{Command, Stdio}, sync::Arc}; use std::os::unix::process::CommandExt; use async_trait::async_trait; use k_launcher_kernel::{Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult}; +use crate::frecency::FrecencyStore; + // --- Domain newtypes --- #[derive(Debug, Clone)] @@ -48,6 +52,8 @@ pub struct DesktopEntry { pub name: AppName, pub exec: ExecCommand, pub icon: Option, + pub category: Option, + pub keywords: Vec, } // --- Swappable source trait (Application layer principle) --- @@ -59,8 +65,11 @@ pub trait DesktopEntrySource: Send + Sync { // --- Cached entry (pre-computed at construction) --- struct CachedEntry { + id: String, name: AppName, name_lc: String, + keywords_lc: Vec, + category: Option, icon: Option, on_execute: Arc, } @@ -68,43 +77,54 @@ struct CachedEntry { // --- Plugin --- pub struct AppsPlugin { - entries: Vec, + entries: HashMap, + frecency: Arc, } impl AppsPlugin { - pub fn new(source: impl DesktopEntrySource) -> Self { + pub fn new(source: impl DesktopEntrySource, frecency: Arc) -> 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(); let icon = e.icon.as_ref().and_then(|p| resolve_icon_path(p.as_str())); let exec = e.exec.clone(); - CachedEntry { + let store = Arc::clone(&frecency); + let record_id = id.clone(); + let on_execute: Arc = Arc::new(move || { + store.record(&record_id); + let parts: Vec<&str> = exec.as_str().split_whitespace().collect(); + if let Some((cmd, args)) = parts.split_first() { + let _ = unsafe { + Command::new(cmd) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .pre_exec(|| { + libc::setsid(); + Ok(()) + }) + .spawn() + }; + } + }); + let cached = CachedEntry { + id: id.clone(), name_lc, + keywords_lc, + category: e.category, icon, - on_execute: Arc::new(move || { - let parts: Vec<&str> = exec.as_str().split_whitespace().collect(); - if let Some((cmd, args)) = parts.split_first() { - let _ = unsafe { - Command::new(cmd) - .args(args) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .pre_exec(|| { - libc::setsid(); - Ok(()) - }) - .spawn() - }; - } - }), + on_execute, name: e.name, - } + }; + (id, cached) }) .collect(); - Self { entries } + Self { entries, frecency } } } @@ -121,16 +141,27 @@ fn resolve_icon_path(name: &str) -> Option { candidates.into_iter().find(|p| Path::new(p).exists()) } +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 { - Some(100) - } else if name_lc.starts_with(query_lc) { - Some(80) - } else if name_lc.contains(query_lc) { - Some(60) - } else { - None + 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 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] @@ -141,16 +172,34 @@ impl Plugin for AppsPlugin { async fn search(&self, query: &str) -> Vec { if query.is_empty() { - return vec![]; + 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), + on_execute: Arc::clone(&e.on_execute), + }) + }) + .collect(); } + let query_lc = query.to_lowercase(); self.entries - .iter() + .values() .filter_map(|e| { - score_match(&e.name_lc, &query_lc).map(|score| SearchResult { - id: ResultId::new(format!("app-{}", e.name.as_str())), + 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: None, + description: e.category.clone(), icon: e.icon.clone(), score: Score::new(score), on_execute: Arc::clone(&e.on_execute), @@ -209,6 +258,8 @@ fn parse_desktop_file(path: &Path) -> Option { let mut name: Option = None; let mut exec: Option = None; let mut icon: Option = None; + let mut category: Option = None; + let mut keywords: Vec = Vec::new(); let mut is_application = false; let mut no_display = false; @@ -232,6 +283,19 @@ fn parse_desktop_file(path: &Path) -> Option { "Icon" if icon.is_none() => icon = Some(value.trim().to_string()), "Type" if !is_application => is_application = value.trim() == "Application", "NoDisplay" => no_display = value.trim().eq_ignore_ascii_case("true"), + "Categories" if category.is_none() => { + category = value.trim() + .split(';') + .find(|s| !s.is_empty()) + .map(|s| humanize_category(s.trim())); + } + "Keywords" if keywords.is_empty() => { + keywords = value.trim() + .split(';') + .filter(|s| !s.is_empty()) + .map(|s| s.trim().to_string()) + .collect(); + } _ => {} } } @@ -256,6 +320,8 @@ fn parse_desktop_file(path: &Path) -> Option { name: AppName::new(name?), exec: ExecCommand::new(exec_clean), icon: icon.map(IconPath::new), + category, + keywords, }) } @@ -265,8 +331,12 @@ fn parse_desktop_file(path: &Path) -> Option { mod tests { use super::*; + fn ephemeral_frecency() -> Arc { + FrecencyStore::new_for_test() + } + struct MockSource { - entries: Vec<(String, String)>, // (name, exec) + entries: Vec<(String, String, Option, Vec)>, // (name, exec, category, keywords) } impl MockSource { @@ -274,7 +344,25 @@ mod tests { Self { entries: entries .into_iter() - .map(|(n, e)| (n.to_string(), e.to_string())) + .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(), } } @@ -284,10 +372,12 @@ mod tests { fn entries(&self) -> Vec { self.entries .iter() - .map(|(name, exec)| DesktopEntry { + .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() } @@ -295,23 +385,89 @@ mod tests { #[tokio::test] async fn apps_prefix_match() { - let source = MockSource::with(vec![("Firefox", "firefox")]); - let p = AppsPlugin::new(source); + 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 source = MockSource::with(vec![("Firefox", "firefox")]); - let p = AppsPlugin::new(source); + 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_returns_empty() { - let source = MockSource::with(vec![("Firefox", "firefox")]); - let p = AppsPlugin::new(source); + 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"); + } } diff --git a/crates/plugins/plugin-calc/Cargo.toml b/crates/plugins/plugin-calc/Cargo.toml index b100d52..7d4865d 100644 --- a/crates/plugins/plugin-calc/Cargo.toml +++ b/crates/plugins/plugin-calc/Cargo.toml @@ -7,10 +7,6 @@ edition = "2024" name = "plugin_calc" path = "src/lib.rs" -[[bin]] -name = "plugin-calc" -path = "src/main.rs" - [dependencies] async-trait = { workspace = true } evalexpr = "11" diff --git a/crates/plugins/plugin-calc/src/main.rs b/crates/plugins/plugin-calc/src/main.rs deleted file mode 100644 index f328e4d..0000000 --- a/crates/plugins/plugin-calc/src/main.rs +++ /dev/null @@ -1 +0,0 @@ -fn main() {} diff --git a/crates/plugins/plugin-files/Cargo.toml b/crates/plugins/plugin-files/Cargo.toml index 0015dd7..cbd9a44 100644 --- a/crates/plugins/plugin-files/Cargo.toml +++ b/crates/plugins/plugin-files/Cargo.toml @@ -3,4 +3,8 @@ name = "plugin-files" version = "0.1.0" edition = "2024" +[lib] +name = "plugin_files" +path = "src/lib.rs" + [dependencies] diff --git a/crates/plugins/plugin-files/src/lib.rs b/crates/plugins/plugin-files/src/lib.rs new file mode 100644 index 0000000..e69de29 diff --git a/crates/plugins/plugin-files/src/main.rs b/crates/plugins/plugin-files/src/main.rs deleted file mode 100644 index e7a11a9..0000000 --- a/crates/plugins/plugin-files/src/main.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - println!("Hello, world!"); -}