v0.2.0
Some checks failed
CI / test (push) Failing after 5m16s
CI / fmt (push) Has been cancelled
CI / clippy (push) Has been cancelled
Release / build (push) Failing after 5m31s

clean architecture refactor, performance, resilience, DX/UX

architecture:
- 13 crates with proper domain/application/infrastructure layers
- domain crate: newtypes, ports (Plugin, AppLauncher), constants
- kernel: pure orchestrator
- shared UI state machine (k-launcher-ui-core)
- merged plugin-api into domain as ports module
- granular file structure (no monolithic lib.rs)
- all tests extracted to tests/ directories

features:
- frecency boost in search results
- empty query shows top frecent apps
- append-only frecency log with configurable compaction
- config-driven styling (all colors, sizes, debounce)
- configurable terminal emulator, external plugin timeout
- log rotation with max_log_files
- loading indicator, descriptive placeholder text
- graceful shutdown via iced::exit() + Plugin::shutdown()
- --version flag, panic hook, signal handling (SIGINT/SIGTERM)
- SpawnInTerminal in external plugin protocol

performance:
- ~1500 -> ~50 heap allocs per keystroke
- reused Matcher, Pattern, char buffer across entries
- Arc<str> for shared result fields
- pre-filter before fuzzy matching
- partial sort for top frecent IDs
- cached lowercase names in entries

resilience:
- parking_lot (no mutex poisoning)
- thiserror hierarchy (PluginError, ConfigError, AppError)
- all silent error swallowing replaced with tracing::warn
- config parse errors logged

quality:
- named constants (no magic strings/numbers)
- named types (no anonymous tuples)
- Rgba newtype with validation
- domain newtype validation (debug_assert non-empty)
- man page, LICENSE (MIT), PKGBUILD, example config
- plugin development guide updated
- make check (fmt + clippy + test), make dev (RUST_LOG=debug)

style: format code for better readability in tests and function signatures

fix: update build_entries function signature to ignore frecency parameter

fix(review): bugs, arch violations, design smells

P1 bugs:
- unix_launcher: shell_split respects quoted args (was split_whitespace)
- plugin-host: 5s timeout on external plugin search
- ui: handle engine init panic, wire error state
- ui-egui: read window config instead of always using defaults
- plugin-url: use OpenPath action instead of SpawnProcess+xdg-open

Architecture:
- remove WindowConfig (mirror of WindowCfg); use WindowCfg directly
- remove on_select closure from SearchResult (domain leakage)
- remove LaunchAction::Custom; add Plugin::on_selected + SearchEngine::on_selected
- apps: record frecency via on_selected instead of embedded closure

Design smells:
- frecency: extract decay_factor helper, write outside mutex
- apps: remove cfg(test) cache_path hack; add new_for_test ctor
- apps: stable ResultId using name+exec to prevent collision
- files: stable ResultId using full path instead of index
- plugin-host: remove k-launcher-os-bridge dep (WindowConfig gone)

Update iced dependency in Cargo.toml to disable default features and add additional ones

feat(app): enhance engine initialization with EngineHandle and update run function signature

feat: production hardening (panic isolation, file logging, apps cache)

- Kernel::search wraps each plugin in catch_unwind; panics are logged and return []
- init_logging() adds daily rolling file at ~/.local/share/k-launcher/logs/
- AppsPlugin caches entries to ~/.cache/k-launcher/apps.bin via bincode; stale-while-revalidate on subsequent launches
- 57 tests pass

refactor: remove client module and associated show command logic

fix(app): format code for clarity in update function

chore: update .gitignore and enhance README with compositor setup instructions

chore(docs): remove unused screenshot file

feature/prod-ready (#1)

Reviewed-on: #1

fix(calc): remove ambiguous log alias, use ln/log2/log10 explicitly

fix(calc): fix log/ln naming, cache math context, strengthen sin(pi) test

feat(calc): add math functions (sqrt, sin, cos, etc.) and pi/e constants

refactor(calc): rename preprocess, extend underscore test assertions

feat(calc): strip underscore digit separators

feat: update dependencies for improved compatibility and performance

feat: add plugin-url for URL handling and open in browser functionality

feat: add support for external plugins and enhance plugin management

feat: add Makefile for build, run, and installation commands

feat: add required features for k-launcher-egui and update dependencies

feat: update README and add documentation for installation, configuration, usage, and plugin development

feat: enhance configuration management and UI styling, remove unused theme module

feat: add k-launcher-config crate for configuration management and integrate with existing components

feat: add k-launcher-ui-egui crate for enhanced UI

- Introduced a new crate `k-launcher-ui-egui` to provide a graphical user interface using eframe and egui.
- Updated the workspace configuration in `Cargo.toml` to include the new crate.
- Implemented the main application logic in `src/app.rs`, handling search functionality and user interactions.
- Created a library entry point in `src/lib.rs` to expose the `run` function for launching the UI.
- Modified the `k-launcher` crate to include a new binary target for the egui-based launcher.
- Added a new main file `src/main_egui.rs` to initialize and run the egui UI with the existing kernel and launcher components.

feat: implement OS bridge and enhance app launcher functionality

feat: add FilesPlugin for file searching and integrate into KLauncher

feat: implement frecency tracking for app usage and enhance search functionality

feat: add CmdPlugin for executing terminal commands and update workspace configuration

refactor: update dependencies and improve keyboard event handling in KLauncherApp

refactor: simplify theme usage and enhance AppsPlugin structure

feat: restructure k-launcher workspace and add core functionality

- Updated Cargo.toml to include a new k-launcher crate and reorganized workspace members.
- Introduced a README.md file detailing the project philosophy, architecture, and technical specifications.
- Implemented a new Kernel struct in k-launcher-kernel for managing plugins and search functionality.
- Created a Plugin trait for plugins to implement, allowing for asynchronous search operations.
- Developed k-launcher-ui with an Iced-based UI for user interaction, including search input and result display.
- Added AppsPlugin and CalcPlugin to handle application launching and basic calculations, respectively.
- Established a theme module for UI styling, focusing on an Aero aesthetic.
- Removed unnecessary main.rs files from plugin crates, streamlining the project structure.

Initialize k-launcher project structure with multiple crates and basic configurations
This commit is contained in:
2026-07-24 13:42:14 +02:00
parent 2e773cdeaf
commit 051d19d878
95 changed files with 4129 additions and 2591 deletions

View File

@@ -0,0 +1,110 @@
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::Arc,
};
use crate::frecency::FrecencyStore;
use crate::types::{AppName, DesktopEntrySource};
pub struct CachedEntry {
pub(crate) id: String,
pub(crate) name: AppName,
pub(crate) name_lowercase: String,
pub(crate) keywords_lowercase: Vec<String>,
pub(crate) category: Option<Arc<str>>,
pub(crate) icon: Option<Arc<str>>,
pub(crate) exec: String,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct CachedEntryData {
id: String,
name: String,
name_lowercase: String,
keywords_lowercase: Vec<String>,
category: Option<String>,
icon: Option<String>,
exec: String,
}
pub fn cache_path() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("k-launcher/apps.bin"))
}
pub fn load_from_path(path: &Path) -> Option<HashMap<String, CachedEntry>> {
let data = std::fs::read(path).ok()?;
let (entries_data, _): (Vec<CachedEntryData>, _) =
bincode::serde::decode_from_slice(&data, bincode::config::standard()).ok()?;
let map = entries_data
.into_iter()
.map(|e| {
let cached = CachedEntry {
id: e.id.clone(),
name: AppName::new(e.name),
name_lowercase: e.name_lowercase,
keywords_lowercase: e.keywords_lowercase,
category: e.category.map(Arc::from),
icon: e.icon.map(Arc::from),
exec: e.exec,
};
(e.id, cached)
})
.collect();
Some(map)
}
pub fn save_to_path(path: &Path, entries: &HashMap<String, CachedEntry>) {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).ok();
}
let data: Vec<CachedEntryData> = entries
.values()
.map(|e| CachedEntryData {
id: e.id.clone(),
name: e.name.as_str().to_string(),
name_lowercase: e.name_lowercase.clone(),
keywords_lowercase: e.keywords_lowercase.clone(),
category: e.category.as_deref().map(str::to_string),
icon: e.icon.as_deref().map(str::to_string),
exec: e.exec.clone(),
})
.collect();
if let Ok(encoded) = bincode::serde::encode_to_vec(&data, bincode::config::standard()) {
std::fs::write(path, encoded).ok();
}
}
pub fn build_entries(
source: &impl DesktopEntrySource,
_frecency: &Arc<FrecencyStore>,
) -> HashMap<String, CachedEntry> {
source
.entries()
.into_iter()
.map(|e| {
let id = format!("app-{}:{}", e.name.as_str(), e.exec.as_str());
let name_lowercase = e.name.as_str().to_lowercase();
let keywords_lowercase = e.keywords.iter().map(|k| k.to_lowercase()).collect();
#[cfg(target_os = "linux")]
let icon: Option<Arc<str>> = e
.icon
.as_ref()
.and_then(|p| crate::linux::resolve_icon_path(p.as_str()))
.map(Arc::from);
#[cfg(not(target_os = "linux"))]
let icon: Option<Arc<str>> = None;
let exec = e.exec.as_str().to_string();
let cached = CachedEntry {
id: id.clone(),
name_lowercase,
keywords_lowercase,
category: e.category.map(Arc::from),
icon,
exec,
name: e.name,
};
(id, cached)
})
.collect()
}

View File

@@ -1,10 +1,13 @@
use std::{
collections::HashMap,
fs::{File, OpenOptions},
io::{BufRead, BufReader, Write},
path::PathBuf,
sync::{Arc, Mutex},
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -13,41 +16,64 @@ struct Entry {
last_used: u64,
}
#[derive(Serialize, Deserialize)]
struct LogRecord {
id: String,
ts: u64,
}
pub struct FrecencyStore {
path: PathBuf,
snapshot_path: PathBuf,
log_path: PathBuf,
data: Mutex<HashMap<String, Entry>>,
log_count: Mutex<usize>,
compact_threshold: usize,
}
impl FrecencyStore {
pub fn new(path: PathBuf) -> Arc<Self> {
let data = std::fs::read_to_string(&path)
pub fn new(snapshot_path: PathBuf, compact_threshold: usize) -> Arc<Self> {
let mut data: HashMap<String, Entry> = std::fs::read_to_string(&snapshot_path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
Arc::new(Self {
path,
let log_path = snapshot_path.with_extension("log");
let log_count = replay_log_into(&log_path, &mut data);
let store = Arc::new(Self {
snapshot_path,
log_path,
data: Mutex::new(data),
})
log_count: Mutex::new(log_count),
compact_threshold,
});
if log_count >= compact_threshold {
store.compact();
}
store
}
#[cfg(test)]
pub fn new_for_test() -> Arc<Self> {
Arc::new(Self {
path: PathBuf::from("/dev/null"),
snapshot_path: PathBuf::from("/dev/null"),
log_path: PathBuf::from("/dev/null"),
data: Mutex::new(HashMap::new()),
log_count: Mutex::new(0),
compact_threshold: usize::MAX,
})
}
pub fn load() -> Arc<Self> {
pub fn load(compact_threshold: usize) -> Arc<Self> {
let Some(data_home) = xdg::BaseDirectories::new().get_data_home() else {
tracing::warn!("XDG_DATA_HOME unavailable; frecency disabled (in-memory only)");
return Arc::new(Self {
path: PathBuf::from("/dev/null"),
data: Mutex::new(HashMap::new()),
});
return Self::new_for_test();
};
let path = data_home.join("k-launcher").join("frecency.json");
Self::new(path)
let path = data_home
.join(k_launcher_domain::constants::APP_NAME)
.join(k_launcher_domain::constants::FRECENCY_SNAPSHOT_FILENAME);
Self::new(path, compact_threshold)
}
pub fn record(&self, id: &str) {
@@ -55,26 +81,72 @@ impl FrecencyStore {
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let json = {
let mut data = self.data.lock().unwrap();
{
let mut data = self.data.lock();
let entry = data.entry(id.to_string()).or_insert(Entry {
count: 0,
last_used: 0,
});
entry.count += 1;
entry.last_used = now;
serde_json::to_string(&*data).ok()
}; // lock released here
if let Some(json) = json {
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
self.append_log(id, now);
}
fn append_log(&self, id: &str, ts: u64) {
let record = LogRecord {
id: id.to_string(),
ts,
};
if let Ok(json) = serde_json::to_string(&record) {
if let Some(parent) = self.log_path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
tracing::warn!("failed to create frecency dir: {e}");
}
let _ = std::fs::write(&self.path, json);
if let Ok(mut file) = OpenOptions::new()
.create(true)
.append(true)
.open(&self.log_path)
&& let Err(e) = writeln!(file, "{json}")
{
tracing::warn!("failed to write frecency log: {e}");
}
}
let mut count = self.log_count.lock();
*count += 1;
if *count >= self.compact_threshold {
drop(count);
self.compact();
}
}
pub fn compact(&self) {
let json = {
let data = self.data.lock();
serde_json::to_string(&*data).ok()
};
if let Some(json) = json {
if let Some(parent) = self.snapshot_path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
tracing::warn!("failed to create frecency dir: {e}");
}
if std::fs::write(&self.snapshot_path, json).is_ok() {
if let Err(e) = File::create(&self.log_path) {
tracing::warn!("failed to truncate frecency log: {e}");
}
*self.log_count.lock() = 0;
}
}
}
pub fn shutdown(&self) {
self.compact();
}
pub fn frecency_score(&self, id: &str) -> u32 {
let data = self.data.lock().unwrap();
let data = self.data.lock();
let Some(entry) = data.get(id) else { return 0 };
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -85,70 +157,73 @@ impl FrecencyStore {
}
pub fn top_ids(&self, n: usize) -> Vec<String> {
let data = self.data.lock().unwrap();
struct ScoredId {
id: String,
score: u32,
}
let data = self.data.lock();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut scored: Vec<(String, u32)> = data
let mut scored: Vec<ScoredId> = data
.iter()
.map(|(id, entry)| {
let age_secs = now.saturating_sub(entry.last_used);
(id.clone(), entry.count * decay_factor(age_secs))
ScoredId {
id: id.clone(),
score: entry.count * decay_factor(age_secs),
}
})
.collect();
scored.sort_by(|a, b| b.1.cmp(&a.1));
scored.into_iter().take(n).map(|(id, _)| id).collect()
if scored.len() <= n {
scored.sort_by_key(|s| std::cmp::Reverse(s.score));
return scored.into_iter().map(|s| s.id).collect();
}
scored.select_nth_unstable_by_key(n, |s| std::cmp::Reverse(s.score));
scored.truncate(n);
scored.sort_by_key(|s| std::cmp::Reverse(s.score));
scored.into_iter().map(|s| s.id).collect()
}
}
fn replay_log_into(log_path: &PathBuf, data: &mut HashMap<String, Entry>) -> usize {
let file = match File::open(log_path) {
Ok(f) => f,
Err(_) => return 0,
};
let mut count = 0;
for line in BufReader::new(file).lines() {
let Ok(line) = line else { continue };
let Ok(record) = serde_json::from_str::<LogRecord>(&line) else {
continue;
};
let entry = data.entry(record.id).or_insert(Entry {
count: 0,
last_used: 0,
});
entry.count += 1;
entry.last_used = record.ts;
count += 1;
}
count
}
const ONE_HOUR: u64 = 3600;
const ONE_DAY: u64 = 86400;
const DECAY_RECENT: u32 = 4;
const DECAY_TODAY: u32 = 2;
const DECAY_OLD: u32 = 1;
fn decay_factor(age_secs: u64) -> u32 {
if age_secs < 3600 {
4
} else if age_secs < 86400 {
2
if age_secs < ONE_HOUR {
DECAY_RECENT
} else if age_secs < ONE_DAY {
DECAY_TODAY
} else {
1
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_store() -> Arc<FrecencyStore> {
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");
DECAY_OLD
}
}

View File

@@ -1,521 +1,12 @@
mod cache;
pub mod frecency;
#[cfg(target_os = "linux")]
pub mod linux;
mod plugin;
mod scoring;
mod types;
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::{Arc, RwLock},
};
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,
keywords_lc: Vec<String>,
category: Option<String>,
icon: Option<String>,
exec: String,
}
// --- Serializable cache data (no closures) ---
#[derive(serde::Serialize, serde::Deserialize)]
struct CachedEntryData {
id: String,
name: String,
keywords_lc: Vec<String>,
category: Option<String>,
icon: Option<String>,
exec: String,
}
fn cache_path() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("k-launcher/apps.bin"))
}
fn load_from_path(path: &Path) -> Option<HashMap<String, CachedEntry>> {
let data = std::fs::read(path).ok()?;
let (entries_data, _): (Vec<CachedEntryData>, _) =
bincode::serde::decode_from_slice(&data, bincode::config::standard()).ok()?;
let map = entries_data
.into_iter()
.map(|e| {
let cached = CachedEntry {
id: e.id.clone(),
name: AppName::new(e.name),
keywords_lc: e.keywords_lc,
category: e.category,
icon: e.icon,
exec: e.exec,
};
(e.id, cached)
})
.collect();
Some(map)
}
fn save_to_path(path: &Path, entries: &HashMap<String, CachedEntry>) {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).ok();
}
let data: Vec<CachedEntryData> = entries
.values()
.map(|e| CachedEntryData {
id: e.id.clone(),
name: e.name.as_str().to_string(),
keywords_lc: e.keywords_lc.clone(),
category: e.category.clone(),
icon: e.icon.clone(),
exec: e.exec.clone(),
})
.collect();
if let Ok(encoded) = bincode::serde::encode_to_vec(&data, bincode::config::standard()) {
std::fs::write(path, encoded).ok();
}
}
fn build_entries(
source: &impl DesktopEntrySource,
_frecency: &Arc<FrecencyStore>,
) -> HashMap<String, CachedEntry> {
source
.entries()
.into_iter()
.map(|e| {
let id = format!("app-{}:{}", e.name.as_str(), e.exec.as_str());
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 cached = CachedEntry {
id: id.clone(),
keywords_lc,
category: e.category,
icon,
exec,
name: e.name,
};
(id, cached)
})
.collect()
}
// --- Plugin ---
pub struct AppsPlugin {
entries: Arc<RwLock<HashMap<String, CachedEntry>>>,
frecency: Arc<FrecencyStore>,
}
impl AppsPlugin {
pub fn new(source: impl DesktopEntrySource + 'static, frecency: Arc<FrecencyStore>) -> Self {
Self::new_impl(source, frecency, cache_path())
}
fn new_impl(
source: impl DesktopEntrySource + 'static,
frecency: Arc<FrecencyStore>,
cp: Option<PathBuf>,
) -> Self {
let cached = cp.as_deref().and_then(load_from_path);
let entries = if let Some(from_cache) = cached {
// Serve cache immediately; refresh in background.
let map = Arc::new(RwLock::new(from_cache));
let entries_bg = Arc::clone(&map);
let frecency_bg = Arc::clone(&frecency);
let cp_bg = cp.clone();
std::thread::spawn(move || {
let fresh = build_entries(&source, &frecency_bg);
if let Some(path) = cp_bg {
save_to_path(&path, &fresh);
}
*entries_bg.write().unwrap() = fresh;
});
map
} else {
// No cache: build synchronously, then persist.
let initial = build_entries(&source, &frecency);
if let Some(path) = &cp {
save_to_path(path, &initial);
}
Arc::new(RwLock::new(initial))
};
Self { entries, frecency }
}
#[cfg(test)]
fn new_for_test(
source: impl DesktopEntrySource + 'static,
frecency: Arc<FrecencyStore>,
) -> Self {
Self::new_impl(source, frecency, None)
}
}
fn initials(name_lc: &str) -> String {
name_lc
.split_whitespace()
.filter_map(|w| w.chars().next())
.collect()
}
fn score_match(name: &str, query: &str) -> Option<u32> {
use nucleo_matcher::{
Config, Matcher, Utf32Str,
pattern::{CaseMatching, Normalization, Pattern},
};
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
}
}
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"
}
fn on_selected(&self, id: &ResultId) {
self.frecency.record(id.as_str());
}
async fn search(&self, query: &str) -> Vec<SearchResult> {
let entries = self.entries.read().unwrap();
if query.is_empty() {
return self
.frecency
.top_ids(5)
.iter()
.filter_map(|id| {
let e = 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()),
})
})
.collect();
}
let query_lc = query.to_lowercase();
entries
.values()
.filter_map(|e| {
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 {
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()),
})
})
.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_for_test(
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_for_test(
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_for_test(
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!(score_match("visual studio code", "vsc").is_some());
}
#[test]
fn score_match_exact_beats_prefix() {
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]
async fn apps_abbreviation_match() {
let p = AppsPlugin::new_for_test(
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!(results[0].score.value() > 0);
}
#[tokio::test]
async fn apps_keyword_match() {
let p = AppsPlugin::new_for_test(
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);
}
#[tokio::test]
async fn apps_fuzzy_typo_match() {
let p = AppsPlugin::new_for_test(
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");
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_for_test(
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:code");
frecency.record("app-Code:code");
frecency.record("app-Firefox:firefox");
let p = AppsPlugin::new_for_test(
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");
}
#[test]
fn apps_loads_from_cache_when_source_is_empty() {
let frecency = ephemeral_frecency();
let cache_file =
std::env::temp_dir().join(format!("k-launcher-test-{}.bin", std::process::id()));
// Build entries from a real source and save to temp path
let source = MockSource::with(vec![("Firefox", "firefox")]);
let entries = build_entries(&source, &frecency);
save_to_path(&cache_file, &entries);
// Load from temp path — should contain Firefox
let loaded = load_from_path(&cache_file).unwrap();
assert!(loaded.contains_key("app-Firefox:firefox"));
std::fs::remove_file(&cache_file).ok();
}
}
pub use cache::{CachedEntry, build_entries, load_from_path, save_to_path};
pub use plugin::*;
pub use scoring::{humanize_category, new_matcher, parse_pattern, score_match};
pub use types::*;

View File

@@ -1,6 +1,6 @@
use std::path::Path;
use crate::humanize_category;
use crate::scoring::humanize_category;
use crate::{AppName, DesktopEntry, DesktopEntrySource, ExecCommand, IconPath};
pub struct FsDesktopEntrySource;
@@ -45,7 +45,7 @@ impl DesktopEntrySource for FsDesktopEntrySource {
}
}
pub(crate) fn clean_exec(exec: &str) -> String {
pub fn clean_exec(exec: &str) -> String {
// Tokenize respecting double-quoted strings, then filter field codes.
let mut tokens: Vec<String> = Vec::new();
let mut chars = exec.chars().peekable();
@@ -99,16 +99,18 @@ fn is_field_code(s: &str) -> bool {
b.len() == 2 && b[0] == b'%' && b[1].is_ascii_alphabetic()
}
const ICON_LOOKUP_SIZE: u16 = 48;
const ICON_THEMES: &[&str] = &["hicolor", "Adwaita", "breeze", "Papirus"];
const PIXMAPS_DIR: &str = "/usr/share/pixmaps";
pub fn resolve_icon_path(name: &str) -> Option<String> {
if name.starts_with('/') && Path::new(name).exists() {
return Some(name.to_string());
}
// Try linicon freedesktop theme traversal
let themes = ["hicolor", "Adwaita", "breeze", "Papirus"];
for theme in &themes {
for theme in ICON_THEMES {
if let Some(icon_path) = linicon::lookup_icon(name)
.from_theme(theme)
.with_size(48)
.with_size(ICON_LOOKUP_SIZE)
.find_map(|r| r.ok())
{
return Some(icon_path.path.to_string_lossy().into_owned());
@@ -116,8 +118,8 @@ pub fn resolve_icon_path(name: &str) -> Option<String> {
}
// Fallback to pixmaps
let candidates = [
format!("/usr/share/pixmaps/{name}.png"),
format!("/usr/share/pixmaps/{name}.svg"),
format!("{PIXMAPS_DIR}/{name}.png"),
format!("{PIXMAPS_DIR}/{name}.svg"),
];
candidates.into_iter().find(|p| Path::new(p).exists())
}
@@ -187,31 +189,3 @@ fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
keywords,
})
}
#[cfg(test)]
mod exec_tests {
use super::clean_exec;
#[test]
fn strips_bare_field_code() {
assert_eq!(clean_exec("app --file %f"), "app --file");
}
#[test]
fn strips_multiple_field_codes() {
assert_eq!(clean_exec("app %U --flag"), "app --flag");
}
#[test]
fn preserves_quoted_value() {
assert_eq!(
clean_exec(r#"app --arg="value" %U"#),
r#"app --arg="value""#
);
}
#[test]
fn handles_plain_exec() {
assert_eq!(clean_exec("firefox"), "firefox");
}
}

View File

@@ -0,0 +1,142 @@
use std::{collections::HashMap, path::PathBuf, sync::Arc};
use parking_lot::RwLock;
use async_trait::async_trait;
use k_launcher_domain::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
use crate::cache::{CachedEntry, build_entries, cache_path, load_from_path, save_to_path};
use crate::frecency::FrecencyStore;
use crate::scoring::{new_matcher, parse_pattern, score_match};
use crate::types::DesktopEntrySource;
const FRECENT_RESULTS_COUNT: usize = 5;
const KEYWORD_MATCH_SCORE: u32 = 50;
pub struct AppsPlugin {
entries: Arc<RwLock<HashMap<String, CachedEntry>>>,
frecency: Arc<FrecencyStore>,
}
impl AppsPlugin {
pub fn new(source: impl DesktopEntrySource + 'static, frecency: Arc<FrecencyStore>) -> Self {
Self::new_impl(source, frecency, cache_path())
}
fn new_impl(
source: impl DesktopEntrySource + 'static,
frecency: Arc<FrecencyStore>,
cp: Option<PathBuf>,
) -> Self {
let cached = cp.as_deref().and_then(load_from_path);
let entries = if let Some(from_cache) = cached {
// Serve cache immediately; refresh in background.
let map = Arc::new(RwLock::new(from_cache));
let entries_bg = Arc::clone(&map);
let frecency_bg = Arc::clone(&frecency);
let cp_bg = cp.clone();
std::thread::spawn(move || {
let fresh = build_entries(&source, &frecency_bg);
if let Some(path) = cp_bg {
save_to_path(&path, &fresh);
}
*entries_bg.write() = fresh;
});
map
} else {
// No cache: build synchronously, then persist.
let initial = build_entries(&source, &frecency);
if let Some(path) = &cp {
save_to_path(path, &initial);
}
Arc::new(RwLock::new(initial))
};
Self { entries, frecency }
}
pub fn new_for_test(
source: impl DesktopEntrySource + 'static,
frecency: Arc<FrecencyStore>,
) -> Self {
Self::new_impl(source, frecency, None)
}
}
#[async_trait]
impl Plugin for AppsPlugin {
fn name(&self) -> &str {
"apps"
}
fn on_selected(&self, id: &ResultId) {
self.frecency.record(id.as_str());
}
fn shutdown(&self) {
self.frecency.shutdown();
}
async fn search(&self, query: &str) -> Vec<SearchResult> {
let entries = self.entries.read();
if query.is_empty() {
return self
.frecency
.top_ids(FRECENT_RESULTS_COUNT)
.iter()
.filter_map(|id| {
let e = 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()),
})
})
.collect();
}
let query_lowercase = query.to_lowercase();
let first_char = query_lowercase.chars().next().unwrap_or_default();
let mut matcher = new_matcher();
let pattern = parse_pattern(query);
let mut char_buf: Vec<char> = Vec::with_capacity(64);
entries
.values()
.filter(|e| {
e.name_lowercase.contains(first_char)
|| e.keywords_lowercase.iter().any(|k| k.contains(first_char))
})
.filter_map(|e| {
let match_score = score_match(
&mut matcher,
&pattern,
e.name.as_str(),
&mut char_buf,
&e.name_lowercase,
&query_lowercase,
)
.or_else(|| {
e.keywords_lowercase
.iter()
.any(|k| k.contains(query_lowercase.as_str()))
.then_some(KEYWORD_MATCH_SCORE)
})?;
let frecency_boost = self.frecency.frecency_score(&e.id);
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(match_score.saturating_add(frecency_boost)),
action: LaunchAction::SpawnProcess(e.exec.clone()),
})
})
.collect()
}
}

View File

@@ -0,0 +1,60 @@
pub use nucleo_matcher::Matcher;
use nucleo_matcher::{
Config, Utf32Str,
pattern::{CaseMatching, Normalization, Pattern},
};
pub fn new_matcher() -> Matcher {
Matcher::new(Config::DEFAULT)
}
pub fn parse_pattern(query: &str) -> Pattern {
Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart)
}
fn matches_initials(name_lowercase: &str, query_lowercase: &str) -> bool {
let mut initials = name_lowercase
.split_whitespace()
.filter_map(|w| w.chars().next());
let mut query_chars = query_lowercase.chars();
for expected in &mut query_chars {
match initials.next() {
Some(initial) if initial == expected => continue,
_ => return false,
}
}
true
}
const INITIALS_BONUS: u32 = 20;
pub fn score_match(
matcher: &mut Matcher,
pattern: &Pattern,
name: &str,
char_buf: &mut Vec<char>,
name_lowercase: &str,
query_lowercase: &str,
) -> Option<u32> {
let haystack = Utf32Str::new(name, char_buf);
let score = pattern.score(haystack, matcher)?;
let bonus = if matches_initials(name_lowercase, query_lowercase) {
INITIALS_BONUS
} else {
0
};
Some(score.saturating_add(bonus))
}
pub 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
}

View File

@@ -0,0 +1,53 @@
// --- 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>;
}