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

@@ -1,6 +1,6 @@
[package]
name = "k-launcher-config"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
[lib]
@@ -9,5 +9,11 @@ path = "src/lib.rs"
[dependencies]
dirs = { workspace = true }
k-launcher-domain = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }
thiserror = { workspace = true }
toml = "1.0"
tracing = { workspace = true }
[dev-dependencies]
toml = "1.0"

View File

@@ -0,0 +1,14 @@
use serde::Deserialize;
use crate::types::*;
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
pub struct Config {
pub window: WindowCfg,
pub appearance: AppearanceCfg,
pub search: SearchCfg,
pub plugins: PluginsCfg,
pub logging: LoggingCfg,
pub terminal: TerminalCfg,
}

View File

@@ -0,0 +1,18 @@
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("config directory not found")]
NoDirFound,
#[error("failed to read config at {path}: {source}")]
ReadFailed {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to parse config at {path}: {source}")]
ParseFailed {
path: PathBuf,
source: toml::de::Error,
},
}

View File

@@ -1,191 +1,8 @@
use serde::Deserialize;
mod config;
pub mod error;
mod load;
mod types;
// RGBA: [r, g, b, a] where r/g/b are 0255 as f32, a is 0.01.0
pub type Rgba = [f32; 4];
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
pub struct Config {
pub window: WindowCfg,
pub appearance: AppearanceCfg,
pub search: SearchCfg,
pub plugins: PluginsCfg,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct WindowCfg {
pub width: f32,
pub height: f32,
pub decorations: bool,
pub transparent: bool,
pub resizable: bool,
}
impl Default for WindowCfg {
fn default() -> Self {
Self {
width: 600.0,
height: 400.0,
decorations: false,
transparent: true,
resizable: false,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct AppearanceCfg {
pub background_rgba: Rgba,
pub border_rgba: Rgba,
pub border_width: f32,
pub border_radius: f32,
pub search_font_size: f32,
pub title_size: f32,
pub desc_size: f32,
pub row_radius: f32,
pub placeholder: String,
}
impl Default for AppearanceCfg {
fn default() -> Self {
Self {
background_rgba: [20.0, 20.0, 30.0, 0.9],
border_rgba: [229.0, 125.0, 33.0, 1.0],
border_width: 1.0,
border_radius: 8.0,
search_font_size: 18.0,
title_size: 15.0,
desc_size: 12.0,
row_radius: 4.0,
placeholder: "Search...".to_string(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct SearchCfg {
pub max_results: usize,
}
impl Default for SearchCfg {
fn default() -> Self {
Self { max_results: 8 }
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ExternalPluginCfg {
pub name: String,
pub path: String,
#[serde(default)]
pub args: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct PluginsCfg {
pub calc: bool,
pub cmd: bool,
pub files: bool,
pub apps: bool,
pub external: Vec<ExternalPluginCfg>,
}
impl Default for PluginsCfg {
fn default() -> Self {
Self {
calc: true,
cmd: true,
files: true,
apps: true,
external: vec![],
}
}
}
pub fn load() -> Config {
let path = dirs::config_dir().map(|d| d.join("k-launcher").join("config.toml"));
let Some(path) = path else {
return Config::default();
};
let Ok(content) = std::fs::read_to_string(&path) else {
return Config::default();
};
toml::from_str(&content).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_has_sane_values() {
let cfg = Config::default();
assert_eq!(cfg.search.max_results, 8);
assert_eq!(cfg.window.width, 600.0);
assert_eq!(cfg.window.height, 400.0);
assert!(!cfg.window.decorations);
assert!(cfg.window.transparent);
assert!(!cfg.window.resizable);
assert!(cfg.plugins.calc);
assert!(cfg.plugins.apps);
assert_eq!(cfg.appearance.search_font_size, 18.0);
assert_eq!(cfg.appearance.placeholder, "Search...");
}
#[test]
fn parse_partial_toml_uses_defaults() {
let toml = "[search]\nmax_results = 5\n";
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.search.max_results, 5);
assert_eq!(cfg.window.width, 600.0);
assert_eq!(cfg.appearance.search_font_size, 18.0);
assert!(cfg.plugins.apps);
}
#[test]
fn parse_full_toml_roundtrip() {
let toml = r#"
[window]
width = 800.0
height = 500.0
decorations = true
transparent = false
resizable = true
[appearance]
background_rgba = [10.0, 10.0, 20.0, 0.8]
border_rgba = [100.0, 200.0, 255.0, 1.0]
border_width = 2.0
border_radius = 12.0
search_font_size = 20.0
title_size = 16.0
desc_size = 13.0
row_radius = 6.0
placeholder = "Type here..."
[search]
max_results = 12
[plugins]
calc = false
cmd = true
files = false
apps = true
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.window.width, 800.0);
assert_eq!(cfg.window.height, 500.0);
assert!(cfg.window.decorations);
assert!(!cfg.window.transparent);
assert_eq!(cfg.appearance.background_rgba, [10.0, 10.0, 20.0, 0.8]);
assert_eq!(cfg.appearance.search_font_size, 20.0);
assert_eq!(cfg.appearance.placeholder, "Type here...");
assert_eq!(cfg.search.max_results, 12);
assert!(!cfg.plugins.calc);
assert!(!cfg.plugins.files);
}
}
pub use config::*;
pub use load::*;
pub use types::*;

View File

@@ -0,0 +1,25 @@
use k_launcher_domain::constants::{APP_NAME, CONFIG_FILENAME};
use crate::config::Config;
use crate::error::ConfigError;
pub fn load() -> Config {
match try_load() {
Ok(cfg) => cfg,
Err(ConfigError::NoDirFound | ConfigError::ReadFailed { .. }) => Config::default(),
Err(e @ ConfigError::ParseFailed { .. }) => {
tracing::warn!("{e}");
Config::default()
}
}
}
pub fn try_load() -> Result<Config, ConfigError> {
let dir = dirs::config_dir().ok_or(ConfigError::NoDirFound)?;
let path = dir.join(APP_NAME).join(CONFIG_FILENAME);
let content = std::fs::read_to_string(&path).map_err(|e| ConfigError::ReadFailed {
path: path.clone(),
source: e,
})?;
toml::from_str(&content).map_err(|e| ConfigError::ParseFailed { path, source: e })
}

View File

@@ -0,0 +1,200 @@
use serde::Deserialize;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rgba {
red: f32,
green: f32,
blue: f32,
alpha: f32,
}
impl<'de> serde::Deserialize<'de> for Rgba {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let [red, green, blue, alpha] = <[f32; 4]>::deserialize(deserializer)?;
Ok(Self::new(red, green, blue, alpha))
}
}
impl Rgba {
pub fn new(red: f32, green: f32, blue: f32, alpha: f32) -> Self {
Self {
red: red.clamp(0.0, 255.0),
green: green.clamp(0.0, 255.0),
blue: blue.clamp(0.0, 255.0),
alpha: alpha.clamp(0.0, 1.0),
}
}
pub fn red(&self) -> f32 {
self.red
}
pub fn green(&self) -> f32 {
self.green
}
pub fn blue(&self) -> f32 {
self.blue
}
pub fn alpha(&self) -> f32 {
self.alpha
}
pub fn red_u8(&self) -> u8 {
self.red as u8
}
pub fn green_u8(&self) -> u8 {
self.green as u8
}
pub fn blue_u8(&self) -> u8 {
self.blue as u8
}
pub fn alpha_byte(&self) -> u8 {
(self.alpha * 255.0) as u8
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct WindowCfg {
pub width: f32,
pub height: f32,
pub decorations: bool,
pub transparent: bool,
pub resizable: bool,
}
impl Default for WindowCfg {
fn default() -> Self {
Self {
width: 600.0,
height: 400.0,
decorations: false,
transparent: true,
resizable: false,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct AppearanceCfg {
pub background_rgba: Rgba,
pub border_rgba: Rgba,
pub border_width: f32,
pub border_radius: f32,
pub search_font_size: f32,
pub title_size: f32,
pub desc_size: f32,
pub row_radius: f32,
pub placeholder: String,
pub selected_row_rgba: Rgba,
pub unselected_row_rgba: Rgba,
pub description_rgba: Rgba,
pub no_results_rgba: Rgba,
pub error_rgba: Rgba,
pub icon_size: f32,
}
impl Default for AppearanceCfg {
fn default() -> Self {
Self {
background_rgba: Rgba::new(20.0, 20.0, 30.0, 0.9),
border_rgba: Rgba::new(229.0, 125.0, 33.0, 1.0),
border_width: 1.0,
border_radius: 8.0,
search_font_size: 18.0,
title_size: 15.0,
desc_size: 12.0,
row_radius: 4.0,
placeholder: "Search apps, type > for commands, = for math".to_string(),
selected_row_rgba: Rgba::new(229.0, 125.0, 33.0, 1.0),
unselected_row_rgba: Rgba::new(255.0, 255.0, 255.0, 0.07),
description_rgba: Rgba::new(210.0, 215.0, 230.0, 1.0),
no_results_rgba: Rgba::new(180.0, 180.0, 200.0, 0.5),
error_rgba: Rgba::new(255.0, 80.0, 80.0, 1.0),
icon_size: 24.0,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct SearchCfg {
pub max_results: usize,
pub debounce_ms: u64,
pub frecency_compact_threshold: usize,
}
impl Default for SearchCfg {
fn default() -> Self {
Self {
max_results: 8,
debounce_ms: 50,
frecency_compact_threshold: 50,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct LoggingCfg {
pub max_log_files: usize,
}
impl Default for LoggingCfg {
fn default() -> Self {
Self { max_log_files: 7 }
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct TerminalCfg {
pub cmd: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ExternalPluginCfg {
pub name: String,
pub path: String,
pub args: Vec<String>,
pub timeout_secs: u64,
}
impl Default for ExternalPluginCfg {
fn default() -> Self {
Self {
name: String::new(),
path: String::new(),
args: vec![],
timeout_secs: 5,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct PluginsCfg {
pub calc: bool,
pub cmd: bool,
pub files: bool,
pub apps: bool,
pub external: Vec<ExternalPluginCfg>,
}
impl Default for PluginsCfg {
fn default() -> Self {
Self {
calc: true,
cmd: true,
files: true,
apps: true,
external: vec![],
}
}
}

View File

@@ -0,0 +1,75 @@
use k_launcher_config::Config;
#[test]
fn default_config_has_sane_values() {
let cfg = Config::default();
assert_eq!(cfg.search.max_results, 8);
assert_eq!(cfg.window.width, 600.0);
assert_eq!(cfg.window.height, 400.0);
assert!(!cfg.window.decorations);
assert!(cfg.window.transparent);
assert!(!cfg.window.resizable);
assert!(cfg.plugins.calc);
assert!(cfg.plugins.apps);
assert_eq!(cfg.appearance.search_font_size, 18.0);
assert_eq!(
cfg.appearance.placeholder,
"Search apps, type > for commands, = for math"
);
}
#[test]
fn parse_partial_toml_uses_defaults() {
let toml_str = "[search]\nmax_results = 5\n";
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.search.max_results, 5);
assert_eq!(cfg.window.width, 600.0);
assert_eq!(cfg.appearance.search_font_size, 18.0);
assert!(cfg.plugins.apps);
}
#[test]
fn parse_full_toml_roundtrip() {
let toml_str = r#"
[window]
width = 800.0
height = 500.0
decorations = true
transparent = false
resizable = true
[appearance]
background_rgba = [10.0, 10.0, 20.0, 0.8]
border_rgba = [100.0, 200.0, 255.0, 1.0]
border_width = 2.0
border_radius = 12.0
search_font_size = 20.0
title_size = 16.0
desc_size = 13.0
row_radius = 6.0
placeholder = "Type here..."
[search]
max_results = 12
[plugins]
calc = false
cmd = true
files = false
apps = true
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.window.width, 800.0);
assert_eq!(cfg.window.height, 500.0);
assert!(cfg.window.decorations);
assert!(!cfg.window.transparent);
assert_eq!(
cfg.appearance.background_rgba,
k_launcher_config::Rgba::new(10.0, 10.0, 20.0, 0.8)
);
assert_eq!(cfg.appearance.search_font_size, 20.0);
assert_eq!(cfg.appearance.placeholder, "Type here...");
assert_eq!(cfg.search.max_results, 12);
assert!(!cfg.plugins.calc);
assert!(!cfg.plugins.files);
}

View File

@@ -0,0 +1,8 @@
[package]
name = "k-launcher-domain"
version = "0.2.0"
edition = "2024"
[dependencies]
async-trait = { workspace = true }
serde = { workspace = true }

View File

@@ -0,0 +1,7 @@
#[derive(Clone)]
pub enum LaunchAction {
SpawnProcess(String),
SpawnInTerminal(String),
OpenPath(String),
CopyToClipboard(String),
}

View File

@@ -0,0 +1,6 @@
pub const APP_NAME: &str = "k-launcher";
pub const APP_TITLE: &str = "K-Launcher";
pub const CONFIG_FILENAME: &str = "config.toml";
pub const FRECENCY_SNAPSHOT_FILENAME: &str = "frecency.json";
pub const LOG_DIR_NAME: &str = "logs";
pub const LOG_FILE_PREFIX: &str = "k-launcher.log";

View File

@@ -0,0 +1,10 @@
mod action;
pub mod constants;
mod newtypes;
pub mod ports;
mod search_result;
pub use action::*;
pub use newtypes::*;
pub use ports::*;
pub use search_result::*;

View File

@@ -0,0 +1,46 @@
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct ResultId(String);
impl ResultId {
pub fn new(id: impl Into<String>) -> Self {
let id = id.into();
debug_assert!(!id.is_empty(), "ResultId must not be empty");
Self(id)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ResultTitle(String);
impl ResultTitle {
pub fn new(title: impl Into<String>) -> Self {
let title = title.into();
debug_assert!(!title.is_empty(), "ResultTitle must not be empty");
Self(title)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
pub struct Score(u32);
impl Score {
pub const MAX: Self = Self(u32::MAX);
pub fn new(value: u32) -> Self {
Self(value)
}
pub fn value(self) -> u32 {
self.0
}
pub fn saturating_add(self, other: u32) -> Self {
Self(self.0.saturating_add(other))
}
}

View File

@@ -0,0 +1,15 @@
use async_trait::async_trait;
use crate::{LaunchAction, ResultId, SearchResult};
pub trait AppLauncher: Send + Sync {
fn execute(&self, action: &LaunchAction);
}
#[async_trait]
pub trait Plugin: Send + Sync {
fn name(&self) -> &str;
async fn search(&self, query: &str) -> Vec<SearchResult>;
fn on_selected(&self, _id: &ResultId) {}
fn shutdown(&self) {}
}

View File

@@ -0,0 +1,25 @@
use std::sync::Arc;
use crate::action::LaunchAction;
use crate::newtypes::{ResultId, ResultTitle, Score};
#[derive(Clone)]
pub struct SearchResult {
pub id: ResultId,
pub title: ResultTitle,
pub description: Option<Arc<str>>,
pub icon: Option<Arc<str>>,
pub score: Score,
pub action: LaunchAction,
}
impl std::fmt::Debug for SearchResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SearchResult")
.field("id", &self.id)
.field("title", &self.title)
.field("icon", &self.icon)
.field("score", &self.score)
.finish_non_exhaustive()
}
}

View File

@@ -0,0 +1,16 @@
use k_launcher_domain::{ResultId, ResultTitle, Score};
#[test]
fn newtype_result_id() {
assert_eq!(ResultId::new("x").as_str(), "x");
}
#[test]
fn newtype_score() {
assert_eq!(Score::new(42).value(), 42);
}
#[test]
fn newtype_title() {
assert_eq!(ResultTitle::new("hello").as_str(), "hello");
}

View File

@@ -1,11 +1,18 @@
[package]
name = "k-launcher-kernel"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
[dependencies]
async-trait = { workspace = true }
futures = { workspace = true }
futures = "0.3"
k-launcher-domain = { workspace = true }
serde = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
async-trait = { workspace = true }
k-launcher-domain = { workspace = true }
plugin-calc = { workspace = true }
plugin-cmd = { workspace = true }
tokio = { workspace = true }

View File

@@ -0,0 +1,56 @@
use std::sync::Arc;
use futures::future::join_all;
use k_launcher_domain::{Plugin, ResultId, SearchResult};
pub struct Kernel {
plugins: Vec<Arc<dyn Plugin>>,
max_results: usize,
}
impl Kernel {
pub fn new(plugins: Vec<Arc<dyn Plugin>>, max_results: usize) -> Self {
Self {
plugins,
max_results,
}
}
pub fn on_selected(&self, id: &ResultId) {
for plugin in &self.plugins {
plugin.on_selected(id);
}
}
pub fn shutdown(&self) {
for plugin in &self.plugins {
plugin.shutdown();
}
}
pub async fn search(&self, query: &str) -> Vec<SearchResult> {
use futures::FutureExt;
use std::panic::AssertUnwindSafe;
let futures = self
.plugins
.iter()
.map(|p| AssertUnwindSafe(p.search(query)).catch_unwind());
let outcomes = join_all(futures).await;
let mut flat: Vec<SearchResult> = outcomes
.into_iter()
.zip(self.plugins.iter())
.flat_map(|(outcome, plugin)| match outcome {
Ok(results) => results,
Err(_) => {
tracing::error!(plugin = plugin.name(), "plugin panicked during search");
vec![]
}
})
.collect();
flat.sort_by_key(|r| std::cmp::Reverse(r.score));
flat.truncate(self.max_results);
flat
}
}

View File

@@ -1,280 +1,3 @@
use std::sync::Arc;
mod kernel;
use async_trait::async_trait;
use futures::future::join_all;
// --- Newtypes ---
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct ResultId(String);
impl ResultId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ResultTitle(String);
impl ResultTitle {
pub fn new(title: impl Into<String>) -> Self {
Self(title.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
pub struct Score(u32);
impl Score {
pub fn new(value: u32) -> Self {
Self(value)
}
pub fn value(self) -> u32 {
self.0
}
}
// --- LaunchAction (port) ---
pub enum LaunchAction {
SpawnProcess(String),
SpawnInTerminal(String),
OpenPath(String),
CopyToClipboard(String),
}
// --- AppLauncher port trait ---
pub trait AppLauncher: Send + Sync {
fn execute(&self, action: &LaunchAction);
}
// --- SearchResult ---
pub struct SearchResult {
pub id: ResultId,
pub title: ResultTitle,
pub description: Option<String>,
pub icon: Option<String>,
pub score: Score,
pub action: LaunchAction,
}
impl std::fmt::Debug for SearchResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SearchResult")
.field("id", &self.id)
.field("title", &self.title)
.field("icon", &self.icon)
.field("score", &self.score)
.finish_non_exhaustive()
}
}
// --- Plugin trait ---
#[async_trait]
pub trait Plugin: Send + Sync {
fn name(&self) -> &str;
async fn search(&self, query: &str) -> Vec<SearchResult>;
fn on_selected(&self, _id: &ResultId) {}
}
// --- SearchEngine port trait ---
#[async_trait]
pub trait SearchEngine: Send + Sync {
async fn search(&self, query: &str) -> Vec<SearchResult>;
fn on_selected(&self, id: &ResultId);
}
// --- NullSearchEngine ---
pub struct NullSearchEngine;
#[async_trait]
impl SearchEngine for NullSearchEngine {
async fn search(&self, _query: &str) -> Vec<SearchResult> {
vec![]
}
fn on_selected(&self, _id: &ResultId) {}
}
// --- Kernel (Application use case) ---
pub struct Kernel {
plugins: Vec<Arc<dyn Plugin>>,
max_results: usize,
}
impl Kernel {
pub fn new(plugins: Vec<Arc<dyn Plugin>>, max_results: usize) -> Self {
Self {
plugins,
max_results,
}
}
pub fn on_selected(&self, id: &ResultId) {
for plugin in &self.plugins {
plugin.on_selected(id);
}
}
pub async fn search(&self, query: &str) -> Vec<SearchResult> {
use futures::FutureExt;
use std::panic::AssertUnwindSafe;
let futures = self
.plugins
.iter()
.map(|p| AssertUnwindSafe(p.search(query)).catch_unwind());
let outcomes = join_all(futures).await;
let mut flat: Vec<SearchResult> = outcomes
.into_iter()
.zip(self.plugins.iter())
.flat_map(|(outcome, plugin)| match outcome {
Ok(results) => results,
Err(_) => {
tracing::error!(plugin = plugin.name(), "plugin panicked during search");
vec![]
}
})
.collect();
flat.sort_by(|a, b| b.score.cmp(&a.score));
flat.truncate(self.max_results);
flat
}
}
#[async_trait]
impl SearchEngine for Kernel {
async fn search(&self, query: &str) -> Vec<SearchResult> {
self.search(query).await
}
fn on_selected(&self, id: &ResultId) {
self.on_selected(id);
}
}
// --- Tests ---
#[cfg(test)]
mod tests {
use super::*;
struct MockPlugin {
results: Vec<(&'static str, u32)>,
}
impl MockPlugin {
fn returns(results: Vec<(&'static str, u32)>) -> Self {
Self { results }
}
}
#[async_trait]
impl Plugin for MockPlugin {
fn name(&self) -> &str {
"mock"
}
async fn search(&self, _query: &str) -> Vec<SearchResult> {
self.results
.iter()
.enumerate()
.map(|(i, (title, score))| SearchResult {
id: ResultId::new(format!("id-{i}")),
title: ResultTitle::new(*title),
description: None,
icon: None,
score: Score::new(*score),
action: LaunchAction::SpawnProcess("mock".to_string()),
})
.collect()
}
}
#[test]
fn newtype_result_id() {
assert_eq!(ResultId::new("x").as_str(), "x");
}
#[test]
fn newtype_score() {
assert_eq!(Score::new(42).value(), 42);
}
#[test]
fn newtype_title() {
assert_eq!(ResultTitle::new("hello").as_str(), "hello");
}
#[tokio::test]
async fn empty_kernel_returns_empty() {
let k = Kernel::new(vec![], 8);
assert!(k.search("x").await.is_empty());
}
#[tokio::test]
async fn kernel_sorts_by_score_desc() {
let plugin = Arc::new(MockPlugin::returns(vec![
("lower", 5),
("higher", 10),
("middle", 7),
]));
let k = Kernel::new(vec![plugin], 8);
let results = k.search("q").await;
assert_eq!(results[0].score.value(), 10);
assert_eq!(results[1].score.value(), 7);
assert_eq!(results[2].score.value(), 5);
}
struct PanicPlugin;
#[async_trait]
impl Plugin for PanicPlugin {
fn name(&self) -> &str {
"panic-plugin"
}
async fn search(&self, _query: &str) -> Vec<SearchResult> {
panic!("test panic");
}
}
#[tokio::test]
async fn kernel_continues_after_plugin_panic() {
let panic_plugin = Arc::new(PanicPlugin);
let normal_plugin = Arc::new(MockPlugin::returns(vec![("survivor", 5)]));
let k = Kernel::new(vec![panic_plugin, normal_plugin], 8);
let results = k.search("q").await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].title.as_str(), "survivor");
}
#[tokio::test]
async fn kernel_truncates_at_max_results() {
let plugin = Arc::new(MockPlugin::returns(vec![
("a", 10),
("b", 9),
("c", 8),
("d", 7),
("e", 6),
]));
let k = Kernel::new(vec![plugin], 3);
let results = k.search("q").await;
assert_eq!(results.len(), 3);
assert_eq!(results[0].score.value(), 10);
assert_eq!(results[2].score.value(), 8);
}
}
pub use kernel::*;

View File

@@ -0,0 +1,42 @@
use std::sync::Arc;
use k_launcher_kernel::Kernel;
use plugin_calc::CalcPlugin;
use plugin_cmd::CmdPlugin;
fn make_kernel() -> Kernel {
Kernel::new(
vec![Arc::new(CalcPlugin::new()), Arc::new(CmdPlugin::new())],
8,
)
}
#[tokio::test]
async fn full_pipeline_calc() {
let kernel = make_kernel();
let results = kernel.search("2+2").await;
assert!(!results.is_empty());
assert_eq!(results[0].title.as_str(), "= 4");
}
#[tokio::test]
async fn full_pipeline_cmd() {
let kernel = make_kernel();
let results = kernel.search("> echo hello").await;
assert!(!results.is_empty());
assert_eq!(results[0].title.as_str(), "Run: echo hello");
}
#[tokio::test]
async fn full_pipeline_no_match() {
let kernel = make_kernel();
let results = kernel.search("xyzzy").await;
assert!(results.is_empty());
}
#[tokio::test]
async fn full_pipeline_empty_query() {
let kernel = make_kernel();
let results = kernel.search("").await;
assert!(results.is_empty());
}

View File

@@ -0,0 +1,107 @@
use std::sync::Arc;
use async_trait::async_trait;
use k_launcher_domain::Plugin;
use k_launcher_domain::{LaunchAction, ResultId, ResultTitle, Score, SearchResult};
use k_launcher_kernel::Kernel;
struct MockResult {
title: &'static str,
score: u32,
}
struct MockPlugin {
results: Vec<MockResult>,
}
impl MockPlugin {
fn returns(results: Vec<(&'static str, u32)>) -> Self {
Self {
results: results
.into_iter()
.map(|(title, score)| MockResult { title, score })
.collect(),
}
}
}
#[async_trait]
impl Plugin for MockPlugin {
fn name(&self) -> &str {
"mock"
}
async fn search(&self, _query: &str) -> Vec<SearchResult> {
self.results
.iter()
.enumerate()
.map(|(i, r)| SearchResult {
id: ResultId::new(format!("id-{i}")),
title: ResultTitle::new(r.title),
description: None,
icon: None,
score: Score::new(r.score),
action: LaunchAction::SpawnProcess("mock".to_string()),
})
.collect()
}
}
#[tokio::test]
async fn empty_kernel_returns_empty() {
let k = Kernel::new(vec![], 8);
assert!(k.search("x").await.is_empty());
}
#[tokio::test]
async fn kernel_sorts_by_score_desc() {
let plugin = Arc::new(MockPlugin::returns(vec![
("lower", 5),
("higher", 10),
("middle", 7),
]));
let k = Kernel::new(vec![plugin], 8);
let results = k.search("q").await;
assert_eq!(results[0].score.value(), 10);
assert_eq!(results[1].score.value(), 7);
assert_eq!(results[2].score.value(), 5);
}
struct PanicPlugin;
#[async_trait]
impl Plugin for PanicPlugin {
fn name(&self) -> &str {
"panic-plugin"
}
async fn search(&self, _query: &str) -> Vec<SearchResult> {
panic!("test panic");
}
}
#[tokio::test]
async fn kernel_continues_after_plugin_panic() {
let panic_plugin = Arc::new(PanicPlugin);
let normal_plugin = Arc::new(MockPlugin::returns(vec![("survivor", 5)]));
let k = Kernel::new(vec![panic_plugin, normal_plugin], 8);
let results = k.search("q").await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].title.as_str(), "survivor");
}
#[tokio::test]
async fn kernel_truncates_at_max_results() {
let plugin = Arc::new(MockPlugin::returns(vec![
("a", 10),
("b", 9),
("c", 8),
("d", 7),
("e", 6),
]));
let k = Kernel::new(vec![plugin], 3);
let results = k.search("q").await;
assert_eq!(results.len(), 3);
assert_eq!(results[0].score.value(), 10);
assert_eq!(results[2].score.value(), 8);
}

View File

@@ -1,8 +1,9 @@
[package]
name = "k-launcher-os-bridge"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
[dependencies]
k-launcher-kernel = { path = "../k-launcher-kernel" }
k-launcher-domain = { workspace = true }
libc = "0.2"
tracing = { workspace = true }

View File

@@ -0,0 +1,50 @@
use k_launcher_domain::{AppLauncher, LaunchAction};
use crate::shell::shell_split;
use crate::spawn::{copy_to_clipboard, open_path, spawn_detached};
use crate::terminal::resolve_terminal;
pub struct UnixAppLauncher {
terminal_cmd: Option<String>,
}
impl UnixAppLauncher {
pub fn new(terminal_cmd: Option<String>) -> Self {
Self { terminal_cmd }
}
}
impl AppLauncher for UnixAppLauncher {
fn execute(&self, action: &LaunchAction) {
match action {
LaunchAction::SpawnProcess(cmd) => spawn_command(cmd),
LaunchAction::SpawnInTerminal(cmd) => {
spawn_in_terminal(cmd, self.terminal_cmd.as_deref())
}
LaunchAction::OpenPath(path) => open_path(path),
LaunchAction::CopyToClipboard(val) => copy_to_clipboard(val),
}
}
}
fn spawn_command(cmd: &str) {
let parts = shell_split(cmd);
if let Some((bin, args)) = parts.split_first() {
spawn_detached(bin, args);
}
}
fn spawn_in_terminal(cmd: &str, configured: Option<&str>) {
let Some(terminal) = resolve_terminal(configured) else {
return;
};
let mut args = terminal.exec_flag;
const SHELL: &str = "sh";
const SHELL_CMD_FLAG: &str = "-c";
args.extend([
SHELL.to_string(),
SHELL_CMD_FLAG.to_string(),
cmd.to_string(),
]);
spawn_detached(&terminal.bin, &args);
}

View File

@@ -1,2 +1,7 @@
mod unix_launcher;
pub use unix_launcher::UnixAppLauncher;
mod launcher;
mod shell;
mod spawn;
mod terminal;
pub use launcher::UnixAppLauncher;
pub use shell::shell_split;

View File

@@ -0,0 +1,22 @@
pub fn shell_split(cmd: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
for ch in cmd.chars() {
match ch {
'"' => in_quotes = !in_quotes,
' ' | '\t' if !in_quotes => {
if !current.is_empty() {
tokens.push(current.clone());
current.clear();
}
}
_ => current.push(ch),
}
}
if !current.is_empty() {
tokens.push(current);
}
tokens
}

View File

@@ -0,0 +1,49 @@
use std::io::Write;
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
const XDG_OPEN: &str = "xdg-open";
const WL_COPY: &str = "wl-copy";
const XCLIP: &str = "xclip";
pub(crate) fn spawn_detached(bin: &str, args: &[String]) {
// SAFETY: setsid() is async-signal-safe; called in forked child before exec
if let Err(e) = unsafe {
Command::new(bin)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.pre_exec(|| {
libc::setsid();
Ok(())
})
.spawn()
} {
tracing::warn!("failed to spawn detached process '{bin}': {e}");
}
}
pub(crate) fn open_path(path: &str) {
if let Err(e) = Command::new(XDG_OPEN).arg(path).spawn() {
tracing::warn!("failed to open path '{path}': {e}");
}
}
pub(crate) fn copy_to_clipboard(val: &str) {
if Command::new(WL_COPY).arg(val).spawn().is_err() {
copy_to_clipboard_xclip(val);
}
}
fn copy_to_clipboard_xclip(val: &str) {
if let Ok(mut child) = Command::new(XCLIP)
.args(["-selection", "clipboard"])
.stdin(Stdio::piped())
.spawn()
&& let Some(stdin) = child.stdin.as_mut()
&& let Err(e) = stdin.write_all(val.as_bytes())
{
tracing::warn!("failed to write to xclip stdin: {e}");
}
}

View File

@@ -0,0 +1,86 @@
pub(crate) struct TerminalCommand {
pub bin: String,
pub exec_flag: Vec<String>,
}
struct KnownTerminal {
bin: &'static str,
exec_flag: &'static str,
}
const KNOWN_TERMINALS: &[KnownTerminal] = &[
KnownTerminal {
bin: "foot",
exec_flag: "-e",
},
KnownTerminal {
bin: "kitty",
exec_flag: "-e",
},
KnownTerminal {
bin: "alacritty",
exec_flag: "-e",
},
KnownTerminal {
bin: "wezterm",
exec_flag: "start",
},
KnownTerminal {
bin: "konsole",
exec_flag: "-e",
},
KnownTerminal {
bin: "xterm",
exec_flag: "-e",
},
];
fn find_in_path(bin: &str) -> bool {
std::env::var_os("PATH")
.iter()
.flat_map(|p| std::env::split_paths(p))
.any(|dir| dir.join(bin).is_file())
}
fn parse_term_cmd(s: &str) -> TerminalCommand {
let mut parts = s.split_whitespace();
let bin = parts.next().unwrap_or("").to_string();
let exec_flag = parts.map(str::to_string).collect();
TerminalCommand { bin, exec_flag }
}
pub(crate) fn resolve_terminal(configured: Option<&str>) -> Option<TerminalCommand> {
if let Some(cmd) = configured.filter(|s| !s.is_empty()) {
let term = parse_term_cmd(cmd);
if !term.bin.is_empty() {
return Some(term);
}
}
if let Ok(val) = std::env::var("TERM_CMD") {
let val = val.trim().to_string();
if !val.is_empty() {
let term = parse_term_cmd(&val);
if !term.bin.is_empty() {
return Some(term);
}
}
}
if let Ok(val) = std::env::var("TERMINAL") {
let bin = val.trim().to_string();
if !bin.is_empty() {
return Some(TerminalCommand {
bin,
exec_flag: vec!["-e".to_string()],
});
}
}
for terminal in KNOWN_TERMINALS {
if find_in_path(terminal.bin) {
return Some(TerminalCommand {
bin: terminal.bin.to_string(),
exec_flag: vec![terminal.exec_flag.to_string()],
});
}
}
None
}

View File

@@ -1,190 +0,0 @@
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
use k_launcher_kernel::{AppLauncher, LaunchAction};
fn shell_split(cmd: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
for ch in cmd.chars() {
match ch {
'"' => in_quotes = !in_quotes,
' ' | '\t' if !in_quotes => {
if !current.is_empty() {
tokens.push(current.clone());
current.clear();
}
}
_ => current.push(ch),
}
}
if !current.is_empty() {
tokens.push(current);
}
tokens
}
fn parse_term_cmd(s: &str) -> (String, Vec<String>) {
let mut parts = s.split_whitespace();
let bin = parts.next().unwrap_or("").to_string();
let args = parts.map(str::to_string).collect();
(bin, args)
}
fn which(bin: &str) -> bool {
Command::new("which")
.arg(bin)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn resolve_terminal() -> Option<(String, Vec<String>)> {
if let Ok(val) = std::env::var("TERM_CMD") {
let val = val.trim().to_string();
if !val.is_empty() {
let (bin, args) = parse_term_cmd(&val);
if !bin.is_empty() {
return Some((bin, args));
}
}
}
if let Ok(val) = std::env::var("TERMINAL") {
let bin = val.trim().to_string();
if !bin.is_empty() {
return Some((bin, vec!["-e".to_string()]));
}
}
for (bin, flag) in &[
("foot", "-e"),
("kitty", "-e"),
("alacritty", "-e"),
("wezterm", "start"),
("konsole", "-e"),
("xterm", "-e"),
] {
if which(bin) {
return Some((bin.to_string(), vec![flag.to_string()]));
}
}
None
}
pub struct UnixAppLauncher;
impl UnixAppLauncher {
pub fn new() -> Self {
Self
}
}
impl Default for UnixAppLauncher {
fn default() -> Self {
Self::new()
}
}
impl AppLauncher for UnixAppLauncher {
fn execute(&self, action: &LaunchAction) {
match action {
LaunchAction::SpawnProcess(cmd) => {
let parts = shell_split(cmd);
if let Some((bin, args)) = parts.split_first() {
let _ = unsafe {
Command::new(bin)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.pre_exec(|| {
libc::setsid();
Ok(())
})
.spawn()
};
}
}
LaunchAction::SpawnInTerminal(cmd) => {
let Some((term_bin, term_args)) = resolve_terminal() else {
return;
};
let _ = unsafe {
Command::new(&term_bin)
.args(&term_args)
.arg("sh")
.arg("-c")
.arg(cmd)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.pre_exec(|| {
libc::setsid();
Ok(())
})
.spawn()
};
}
LaunchAction::OpenPath(path) => {
let _ = Command::new("xdg-open").arg(path).spawn();
}
LaunchAction::CopyToClipboard(val) => {
if Command::new("wl-copy").arg(val).spawn().is_err() {
use std::io::Write;
if let Ok(mut child) = Command::new("xclip")
.args(["-selection", "clipboard"])
.stdin(Stdio::piped())
.spawn()
&& let Some(stdin) = child.stdin.as_mut()
{
let _ = stdin.write_all(val.as_bytes());
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::shell_split;
#[test]
fn split_simple() {
assert_eq!(shell_split("firefox"), vec!["firefox"]);
}
#[test]
fn split_with_args() {
assert_eq!(
shell_split("firefox --new-window"),
vec!["firefox", "--new-window"]
);
}
#[test]
fn split_quoted_path() {
assert_eq!(shell_split(r#""My App" --flag"#), vec!["My App", "--flag"]);
}
#[test]
fn split_quoted_with_spaces() {
assert_eq!(
shell_split(r#"env "FOO BAR" baz"#),
vec!["env", "FOO BAR", "baz"]
);
}
#[test]
fn split_empty() {
assert!(shell_split("").is_empty());
}
#[test]
fn split_extra_whitespace() {
assert_eq!(shell_split(" a b "), vec!["a", "b"]);
}
}

View File

@@ -0,0 +1,37 @@
use k_launcher_os_bridge::shell_split;
#[test]
fn split_simple() {
assert_eq!(shell_split("firefox"), vec!["firefox"]);
}
#[test]
fn split_with_args() {
assert_eq!(
shell_split("firefox --new-window"),
vec!["firefox", "--new-window"]
);
}
#[test]
fn split_quoted_path() {
assert_eq!(shell_split(r#""My App" --flag"#), vec!["My App", "--flag"]);
}
#[test]
fn split_quoted_with_spaces() {
assert_eq!(
shell_split(r#"env "FOO BAR" baz"#),
vec!["env", "FOO BAR", "baz"]
);
}
#[test]
fn split_empty() {
assert!(shell_split("").is_empty());
}
#[test]
fn split_extra_whitespace() {
assert_eq!(shell_split(" a b "), vec!["a", "b"]);
}

View File

@@ -1,6 +1,6 @@
[package]
name = "k-launcher-plugin-host"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
[lib]
@@ -9,8 +9,11 @@ path = "src/lib.rs"
[dependencies]
async-trait = { workspace = true }
k-launcher-kernel = { path = "../k-launcher-kernel" }
k-launcher-domain = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["process", "io-util", "sync", "time"] }
tracing = { workspace = true }
[dev-dependencies]

View File

@@ -0,0 +1,13 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PluginError {
#[error("plugin spawn failed: {0}")]
SpawnFailed(#[from] std::io::Error),
#[error("search timed out after {timeout_secs}s")]
Timeout { timeout_secs: u64 },
#[error("protocol error: {0}")]
Protocol(String),
#[error("plugin process error: {0}")]
ProcessError(String),
}

View File

@@ -1,218 +1,6 @@
use async_trait::async_trait;
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
use tokio::process::{ChildStdin, ChildStdout, Command};
use tokio::sync::Mutex;
pub mod error;
mod plugin;
mod protocol;
// --- Protocol types ---
#[derive(Serialize)]
struct Query {
query: String,
}
#[derive(Deserialize)]
struct ExternalResult {
id: String,
title: String,
score: u32,
#[serde(default)]
description: Option<String>,
#[serde(default)]
icon: Option<String>,
action: ExternalAction,
}
#[derive(Deserialize)]
#[serde(tag = "type")]
enum ExternalAction {
SpawnProcess { cmd: String },
CopyToClipboard { text: String },
OpenPath { path: String },
}
// --- Process I/O handle ---
struct ProcessIo {
stdin: BufWriter<ChildStdin>,
stdout: BufReader<ChildStdout>,
}
async fn do_search(
io: &mut ProcessIo,
query: &str,
) -> Result<Vec<ExternalResult>, Box<dyn std::error::Error + Send + Sync>> {
let line = serde_json::to_string(&Query {
query: query.to_string(),
})?;
io.stdin.write_all(line.as_bytes()).await?;
io.stdin.write_all(b"\n").await?;
io.stdin.flush().await?;
let mut response = String::new();
io.stdout.read_line(&mut response).await?;
Ok(serde_json::from_str(&response)?)
}
// --- ExternalPlugin ---
pub struct ExternalPlugin {
name: String,
path: String,
args: Vec<String>,
inner: Mutex<Option<ProcessIo>>,
}
impl ExternalPlugin {
pub fn new(name: impl Into<String>, path: impl Into<String>, args: Vec<String>) -> Self {
Self {
name: name.into(),
path: path.into(),
args,
inner: Mutex::new(None),
}
}
async fn spawn(&self) -> std::io::Result<ProcessIo> {
let mut child = Command::new(&self.path)
.args(&self.args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()?;
let stdin = BufWriter::new(child.stdin.take().unwrap());
let stdout = BufReader::new(child.stdout.take().unwrap());
Ok(ProcessIo { stdin, stdout })
}
}
#[async_trait]
impl Plugin for ExternalPlugin {
fn name(&self) -> &str {
&self.name
}
async fn search(&self, query: &str) -> Vec<SearchResult> {
let mut guard = self.inner.lock().await;
if guard.is_none() {
match self.spawn().await {
Ok(io) => *guard = Some(io),
Err(e) => {
tracing::warn!("failed to spawn plugin {}: {e}", self.name);
return vec![];
}
}
}
let result = match guard.as_mut() {
Some(io) => {
tokio::time::timeout(std::time::Duration::from_secs(5), do_search(io, query))
.await
.unwrap_or_else(|_| {
tracing::warn!("plugin {} search timed out", self.name);
Err("timeout".into())
})
}
None => unreachable!(),
};
match result {
Ok(results) => results
.into_iter()
.map(|r| SearchResult {
id: ResultId::new(r.id),
title: ResultTitle::new(r.title),
description: r.description,
icon: r.icon,
score: Score::new(r.score),
action: match r.action {
ExternalAction::SpawnProcess { cmd } => LaunchAction::SpawnProcess(cmd),
ExternalAction::CopyToClipboard { text } => {
LaunchAction::CopyToClipboard(text)
}
ExternalAction::OpenPath { path } => LaunchAction::OpenPath(path),
},
})
.collect(),
Err(e) => {
tracing::warn!("plugin {} error: {e}", self.name);
*guard = None;
vec![]
}
}
}
}
// --- Tests ---
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn query_serializes_correctly() {
let q = Query {
query: "firefox".to_string(),
};
assert_eq!(serde_json::to_string(&q).unwrap(), r#"{"query":"firefox"}"#);
}
#[test]
fn result_parses_spawn_action() {
let json = r#"[{"id":"1","title":"Firefox","score":80,"action":{"type":"SpawnProcess","cmd":"firefox"}}]"#;
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "1");
assert_eq!(results[0].title, "Firefox");
assert_eq!(results[0].score, 80);
assert!(
matches!(&results[0].action, ExternalAction::SpawnProcess { cmd } if cmd == "firefox")
);
}
#[test]
fn result_parses_copy_action() {
let json = r#"[{"id":"c","title":"= 4","score":90,"action":{"type":"CopyToClipboard","text":"4"}}]"#;
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
assert!(
matches!(&results[0].action, ExternalAction::CopyToClipboard { text } if text == "4")
);
}
#[test]
fn result_parses_open_path_action() {
let json = r#"[{"id":"f","title":"/home/user","score":50,"action":{"type":"OpenPath","path":"/home/user"}}]"#;
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
assert!(
matches!(&results[0].action, ExternalAction::OpenPath { path } if path == "/home/user")
);
}
#[test]
fn result_parses_optional_fields() {
let json = r#"[{"id":"x","title":"X","score":10,"description":"desc","icon":"/icon.png","action":{"type":"SpawnProcess","cmd":"x"}}]"#;
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
assert_eq!(results[0].description.as_deref(), Some("desc"));
assert_eq!(results[0].icon.as_deref(), Some("/icon.png"));
}
#[test]
fn result_parses_missing_optional_fields() {
let json =
r#"[{"id":"x","title":"X","score":10,"action":{"type":"SpawnProcess","cmd":"x"}}]"#;
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
assert!(results[0].description.is_none());
assert!(results[0].icon.is_none());
}
#[test]
fn invalid_json_is_err() {
assert!(serde_json::from_str::<Vec<ExternalResult>>("not json").is_err());
}
// Unused import suppression for Arc (used only in production code path)
fn _assert_send_sync() {
fn check<T: Send + Sync>() {}
check::<ExternalPlugin>();
}
}
pub use plugin::*;
pub use protocol::*;

View File

@@ -0,0 +1,137 @@
use std::sync::Arc;
use async_trait::async_trait;
use k_launcher_domain::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
use tokio::process::{ChildStdin, ChildStdout, Command};
use tokio::sync::Mutex;
use crate::error::PluginError;
use crate::protocol::{ExternalAction, ExternalResult, Query};
struct ProcessIo {
stdin: BufWriter<ChildStdin>,
stdout: BufReader<ChildStdout>,
}
async fn do_search(io: &mut ProcessIo, query: &str) -> Result<Vec<ExternalResult>, PluginError> {
let line = serde_json::to_string(&Query {
query: query.to_string(),
})
.map_err(|e| PluginError::Protocol(e.to_string()))?;
io.stdin
.write_all(line.as_bytes())
.await
.map_err(|e| PluginError::ProcessError(e.to_string()))?;
io.stdin
.write_all(b"\n")
.await
.map_err(|e| PluginError::ProcessError(e.to_string()))?;
io.stdin
.flush()
.await
.map_err(|e| PluginError::ProcessError(e.to_string()))?;
let mut response = String::new();
io.stdout
.read_line(&mut response)
.await
.map_err(|e| PluginError::ProcessError(e.to_string()))?;
serde_json::from_str(&response).map_err(|e| PluginError::Protocol(e.to_string()))
}
pub struct ExternalPlugin {
name: String,
path: String,
args: Vec<String>,
timeout_secs: u64,
inner: Mutex<Option<ProcessIo>>,
}
impl ExternalPlugin {
pub fn new(
name: impl Into<String>,
path: impl Into<String>,
args: Vec<String>,
timeout_secs: u64,
) -> Self {
Self {
name: name.into(),
path: path.into(),
args,
timeout_secs,
inner: Mutex::new(None),
}
}
async fn spawn(&self) -> std::io::Result<ProcessIo> {
let mut child = Command::new(&self.path)
.args(&self.args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()?;
let stdin = BufWriter::new(child.stdin.take().unwrap());
let stdout = BufReader::new(child.stdout.take().unwrap());
Ok(ProcessIo { stdin, stdout })
}
}
#[async_trait]
impl Plugin for ExternalPlugin {
fn name(&self) -> &str {
&self.name
}
async fn search(&self, query: &str) -> Vec<SearchResult> {
let mut guard = self.inner.lock().await;
if guard.is_none() {
match self.spawn().await {
Ok(io) => *guard = Some(io),
Err(e) => {
tracing::warn!("failed to spawn plugin {}: {e}", self.name);
return vec![];
}
}
}
let result = match guard.as_mut() {
Some(io) => tokio::time::timeout(
std::time::Duration::from_secs(self.timeout_secs),
do_search(io, query),
)
.await
.unwrap_or(Err(PluginError::Timeout {
timeout_secs: self.timeout_secs,
})),
None => unreachable!(),
};
match result {
Ok(results) => results
.into_iter()
.map(|r| SearchResult {
id: ResultId::new(r.id),
title: ResultTitle::new(r.title),
description: r.description.map(Arc::from),
icon: r.icon.map(Arc::from),
score: Score::new(r.score),
action: match r.action {
ExternalAction::SpawnProcess { cmd } => LaunchAction::SpawnProcess(cmd),
ExternalAction::SpawnInTerminal { cmd } => {
LaunchAction::SpawnInTerminal(cmd)
}
ExternalAction::CopyToClipboard { text } => {
LaunchAction::CopyToClipboard(text)
}
ExternalAction::OpenPath { path } => LaunchAction::OpenPath(path),
},
})
.collect(),
Err(e) => {
tracing::warn!("plugin {} error: {e}", self.name);
*guard = None;
vec![]
}
}
}
}

View File

@@ -0,0 +1,27 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
pub struct Query {
pub query: String,
}
#[derive(Deserialize)]
pub struct ExternalResult {
pub id: String,
pub title: String,
pub score: u32,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub icon: Option<String>,
pub action: ExternalAction,
}
#[derive(Deserialize)]
#[serde(tag = "type")]
pub enum ExternalAction {
SpawnProcess { cmd: String },
SpawnInTerminal { cmd: String },
CopyToClipboard { text: String },
OpenPath { path: String },
}

View File

@@ -0,0 +1,70 @@
use k_launcher_plugin_host::{ExternalAction, ExternalPlugin, ExternalResult, Query};
#[test]
fn query_serializes_correctly() {
let q = Query {
query: "firefox".to_string(),
};
assert_eq!(serde_json::to_string(&q).unwrap(), r#"{"query":"firefox"}"#);
}
#[test]
fn result_parses_spawn_action() {
let json = r#"[{"id":"1","title":"Firefox","score":80,"action":{"type":"SpawnProcess","cmd":"firefox"}}]"#;
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "1");
assert_eq!(results[0].title, "Firefox");
assert_eq!(results[0].score, 80);
assert!(matches!(&results[0].action, ExternalAction::SpawnProcess { cmd } if cmd == "firefox"));
}
#[test]
fn result_parses_copy_action() {
let json =
r#"[{"id":"c","title":"= 4","score":90,"action":{"type":"CopyToClipboard","text":"4"}}]"#;
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
assert!(matches!(&results[0].action, ExternalAction::CopyToClipboard { text } if text == "4"));
}
#[test]
fn result_parses_open_path_action() {
let json = r#"[{"id":"f","title":"/home/user","score":50,"action":{"type":"OpenPath","path":"/home/user"}}]"#;
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
assert!(
matches!(&results[0].action, ExternalAction::OpenPath { path } if path == "/home/user")
);
}
#[test]
fn result_parses_spawn_in_terminal_action() {
let json = r#"[{"id":"t","title":"htop","score":70,"action":{"type":"SpawnInTerminal","cmd":"htop"}}]"#;
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
assert!(matches!(&results[0].action, ExternalAction::SpawnInTerminal { cmd } if cmd == "htop"));
}
#[test]
fn result_parses_optional_fields() {
let json = r#"[{"id":"x","title":"X","score":10,"description":"desc","icon":"/icon.png","action":{"type":"SpawnProcess","cmd":"x"}}]"#;
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
assert_eq!(results[0].description.as_deref(), Some("desc"));
assert_eq!(results[0].icon.as_deref(), Some("/icon.png"));
}
#[test]
fn result_parses_missing_optional_fields() {
let json = r#"[{"id":"x","title":"X","score":10,"action":{"type":"SpawnProcess","cmd":"x"}}]"#;
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
assert!(results[0].description.is_none());
assert!(results[0].icon.is_none());
}
#[test]
fn invalid_json_is_err() {
assert!(serde_json::from_str::<Vec<ExternalResult>>("not json").is_err());
}
fn _assert_send_sync() {
fn check<T: Send + Sync>() {}
check::<ExternalPlugin>();
}

View File

@@ -0,0 +1,9 @@
[package]
name = "k-launcher-ui-core"
version = "0.2.0"
edition = "2024"
[dependencies]
k-launcher-config = { workspace = true }
k-launcher-domain = { workspace = true }
k-launcher-kernel = { workspace = true }

View File

@@ -0,0 +1,171 @@
use std::sync::Arc;
use k_launcher_config::AppearanceCfg;
use k_launcher_domain::AppLauncher;
use k_launcher_domain::{LaunchAction, SearchResult};
use k_launcher_kernel::Kernel;
pub struct LauncherState {
query: String,
results: Vec<SearchResult>,
selected: usize,
engine: Option<Arc<Kernel>>,
launcher: Arc<dyn AppLauncher>,
cfg: AppearanceCfg,
debounce_ms: u64,
search_epoch: u64,
error: Option<String>,
}
pub enum Action {
QueryChanged(String),
MoveDown,
MoveUp,
LaunchSelected,
Exit,
EngineReady(Arc<Kernel>),
EngineInitFailed(String),
ResultsReady {
epoch: u64,
results: Vec<SearchResult>,
},
}
pub enum Effect {
SearchAfterDebounce {
query: String,
debounce_ms: u64,
epoch: u64,
},
LaunchAndExit(LaunchAction),
Exit,
TriggerSearch(String),
None,
}
impl LauncherState {
pub fn new(launcher: Arc<dyn AppLauncher>, cfg: AppearanceCfg, debounce_ms: u64) -> Self {
Self {
query: String::new(),
results: vec![],
selected: 0,
engine: None,
launcher,
cfg,
debounce_ms,
search_epoch: 0,
error: None,
}
}
pub fn handle(&mut self, action: Action) -> Effect {
match action {
Action::QueryChanged(q) => {
self.query = q;
self.selected = 0;
self.error = None;
let Some(_engine) = &self.engine else {
return Effect::None;
};
self.search_epoch += 1;
Effect::SearchAfterDebounce {
query: self.query.clone(),
debounce_ms: self.debounce_ms,
epoch: self.search_epoch,
}
}
Action::MoveDown => {
let len = self.results.len();
if len > 0 {
self.selected = (self.selected + 1).min(len - 1);
}
Effect::None
}
Action::MoveUp => {
self.selected = self.selected.saturating_sub(1);
Effect::None
}
Action::LaunchSelected => {
if let Some(result) = self.results.get(self.selected) {
if let Some(engine) = &self.engine {
engine.on_selected(&result.id);
}
let action = result.action.clone();
self.shutdown_engine();
return Effect::LaunchAndExit(action);
}
self.shutdown_engine();
Effect::Exit
}
Action::Exit => {
self.shutdown_engine();
Effect::Exit
}
Action::EngineReady(kernel) => {
self.engine = Some(kernel);
Effect::TriggerSearch(self.query.clone())
}
Action::EngineInitFailed(msg) => {
self.error = Some(msg);
Effect::None
}
Action::ResultsReady { epoch, results } => {
if epoch == self.search_epoch {
self.results = results;
}
Effect::None
}
}
}
pub fn query(&self) -> &str {
&self.query
}
pub fn results(&self) -> &[SearchResult] {
&self.results
}
pub fn selected(&self) -> usize {
self.selected
}
pub fn cfg(&self) -> &AppearanceCfg {
&self.cfg
}
pub fn error(&self) -> Option<&str> {
self.error.as_deref()
}
pub fn is_loading(&self) -> bool {
self.engine.is_none() && self.error.is_none()
}
pub fn engine(&self) -> Option<&Arc<Kernel>> {
self.engine.as_ref()
}
pub fn launcher(&self) -> &Arc<dyn AppLauncher> {
&self.launcher
}
pub fn search_epoch(&self) -> u64 {
self.search_epoch
}
fn shutdown_engine(&self) {
if let Some(engine) = &self.engine {
engine.shutdown();
}
}
}

View File

@@ -0,0 +1,158 @@
use std::sync::Arc;
use k_launcher_config::AppearanceCfg;
use k_launcher_domain::AppLauncher;
use k_launcher_domain::*;
use k_launcher_kernel::Kernel;
use k_launcher_ui_core::{Action, Effect, LauncherState};
struct NoopLauncher;
impl AppLauncher for NoopLauncher {
fn execute(&self, _action: &LaunchAction) {}
}
fn make_state() -> LauncherState {
LauncherState::new(Arc::new(NoopLauncher), AppearanceCfg::default(), 50)
}
fn make_result(id: &str) -> SearchResult {
SearchResult {
id: ResultId::new(id),
title: ResultTitle::new(id),
description: None,
icon: None,
score: Score::new(100),
action: LaunchAction::CopyToClipboard(id.to_string()),
}
}
fn make_state_with_engine() -> LauncherState {
let kernel = Arc::new(Kernel::new(vec![], 10));
let mut state = make_state();
let _ = state.handle(Action::EngineReady(kernel));
state
}
#[test]
fn move_down_clamps_to_last_result() {
let mut state = make_state();
state.handle(Action::ResultsReady {
epoch: 0,
results: vec![make_result("a"), make_result("b"), make_result("c")],
});
state.handle(Action::MoveDown);
state.handle(Action::MoveDown);
state.handle(Action::MoveDown);
state.handle(Action::MoveDown);
assert_eq!(state.selected(), 2);
}
#[test]
fn move_up_does_not_go_below_zero() {
let mut state = make_state();
state.handle(Action::ResultsReady {
epoch: 0,
results: vec![make_result("a"), make_result("b")],
});
state.handle(Action::MoveUp);
state.handle(Action::MoveUp);
assert_eq!(state.selected(), 0);
}
#[test]
fn query_changed_resets_selected() {
let mut state = make_state_with_engine();
state.handle(Action::ResultsReady {
epoch: state.search_epoch(),
results: vec![make_result("a"), make_result("b"), make_result("c")],
});
state.handle(Action::MoveDown);
state.handle(Action::MoveDown);
assert_eq!(state.selected(), 2);
state.handle(Action::QueryChanged("new".to_string()));
assert_eq!(state.selected(), 0);
}
#[test]
fn engine_ready_returns_trigger_search() {
let mut state = make_state();
let kernel = Arc::new(Kernel::new(vec![], 10));
let effect = state.handle(Action::EngineReady(kernel));
assert!(matches!(effect, Effect::TriggerSearch(_)));
}
#[test]
fn launch_selected_with_no_results_returns_exit() {
let mut state = make_state_with_engine();
let effect = state.handle(Action::LaunchSelected);
assert!(matches!(effect, Effect::Exit));
}
#[test]
fn results_ready_with_wrong_epoch_is_ignored() {
let mut state = make_state_with_engine();
let current_epoch = state.search_epoch();
state.handle(Action::ResultsReady {
epoch: current_epoch + 999,
results: vec![make_result("stale")],
});
assert!(state.results().is_empty());
}
#[test]
fn query_changed_without_engine_returns_none() {
let mut state = make_state();
let effect = state.handle(Action::QueryChanged("hello".to_string()));
assert!(matches!(effect, Effect::None));
assert_eq!(state.query(), "hello");
}
#[test]
fn query_changed_with_engine_returns_search_after_debounce() {
let mut state = make_state_with_engine();
let effect = state.handle(Action::QueryChanged("test".to_string()));
match effect {
Effect::SearchAfterDebounce {
query,
debounce_ms,
epoch,
} => {
assert_eq!(query, "test");
assert_eq!(debounce_ms, 50);
assert_eq!(epoch, state.search_epoch());
}
_ => panic!("expected SearchAfterDebounce"),
}
}
#[test]
fn launch_selected_with_results_returns_launch_and_exit() {
let mut state = make_state_with_engine();
state.handle(Action::ResultsReady {
epoch: state.search_epoch(),
results: vec![make_result("app1")],
});
let effect = state.handle(Action::LaunchSelected);
assert!(matches!(effect, Effect::LaunchAndExit(_)));
}
#[test]
fn engine_init_failed_sets_error() {
let mut state = make_state();
state.handle(Action::EngineInitFailed("boom".to_string()));
assert_eq!(state.error(), Some("boom"));
}

View File

@@ -1,6 +1,6 @@
[package]
name = "k-launcher-ui-egui"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
[lib]
@@ -10,6 +10,9 @@ path = "src/lib.rs"
[dependencies]
eframe = { version = "0.31", default-features = false, features = ["default_fonts", "wayland", "x11", "glow"] }
egui = "0.31"
k-launcher-config = { path = "../k-launcher-config" }
k-launcher-kernel = { path = "../k-launcher-kernel" }
k-launcher-config = { workspace = true }
k-launcher-domain = { workspace = true }
k-launcher-kernel = { workspace = true }
k-launcher-ui-core = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }

View File

@@ -1,169 +1,153 @@
use std::sync::{Arc, mpsc};
use egui::{Color32, Key, ViewportCommand};
use k_launcher_kernel::{AppLauncher, SearchEngine, SearchResult};
use egui::ViewportCommand;
use k_launcher_config::AppearanceCfg;
use k_launcher_domain::AppLauncher;
use k_launcher_domain::SearchResult;
use k_launcher_kernel::Kernel;
use k_launcher_ui_core::{Action, Effect, LauncherState};
const BG: Color32 = Color32::from_rgba_premultiplied(20, 20, 30, 230);
const BORDER_COLOR: Color32 = Color32::from_rgb(229, 125, 33);
const SELECTED_BG: Color32 = Color32::from_rgba_premultiplied(0, 100, 140, 180);
const DIM_TEXT: Color32 = Color32::from_rgb(180, 185, 200);
use crate::input::{InputAction, process_input};
use crate::render;
use crate::style;
pub struct KLauncherApp {
engine: Arc<dyn SearchEngine>,
launcher: Arc<dyn AppLauncher>,
query: String,
results: Vec<SearchResult>,
selected: usize,
pub(crate) inner: LauncherState,
rt: tokio::runtime::Handle,
result_tx: mpsc::SyncSender<Vec<SearchResult>>,
result_rx: mpsc::Receiver<Vec<SearchResult>>,
pub(crate) result_rx: mpsc::Receiver<Vec<SearchResult>>,
}
impl KLauncherApp {
fn new(
engine: Arc<dyn SearchEngine>,
engine: Arc<Kernel>,
launcher: Arc<dyn AppLauncher>,
rt: tokio::runtime::Handle,
cfg: AppearanceCfg,
) -> Self {
let (result_tx, result_rx) = mpsc::sync_channel(4);
Self {
engine,
launcher,
query: String::new(),
results: vec![],
selected: 0,
const RESULT_CHANNEL_CAPACITY: usize = 4;
let (result_tx, result_rx) = mpsc::sync_channel(RESULT_CHANNEL_CAPACITY);
let mut inner = LauncherState::new(launcher, cfg, 0);
let effect = inner.handle(Action::EngineReady(engine));
let app = Self {
inner,
rt,
result_tx,
result_rx,
}
};
app.execute_effect(effect);
app
}
fn trigger_search(&self, query: String) {
let engine = self.engine.clone();
let Some(engine) = self.inner.engine().cloned() else {
return;
};
let tx = self.result_tx.clone();
self.rt.spawn(async move {
let results = engine.search(&query).await;
let _ = tx.send(results);
if let Err(e) = tx.send(results) {
tracing::warn!("search result channel closed: {e}");
}
});
}
fn poll_search_results(&mut self) {
if let Ok(results) = self.result_rx.try_recv() {
self.inner.handle(Action::ResultsReady {
epoch: self.inner.search_epoch(),
results,
});
}
}
fn execute_effect(&self, effect: Effect) {
match effect {
Effect::TriggerSearch(q) => self.trigger_search(q),
Effect::SearchAfterDebounce { query, .. } => self.trigger_search(query),
_ => {}
}
}
fn handle_action(&mut self, action: Action, ctx: &egui::Context) {
let effect = self.inner.handle(action);
match effect {
Effect::LaunchAndExit(action) => {
self.inner.launcher().execute(&action);
ctx.send_viewport_cmd(ViewportCommand::Close);
}
Effect::Exit => {
ctx.send_viewport_cmd(ViewportCommand::Close);
}
other => self.execute_effect(other),
}
}
fn render_panel(&mut self, ctx: &egui::Context) {
let cfg = self.inner.cfg().clone();
egui::CentralPanel::default()
.frame(style::outer_frame(&cfg))
.show(ctx, |ui| {
let query = self.inner.query().to_string();
let mut query_buf = query;
let response = render::render_search_bar(ui, &mut query_buf, &cfg);
if response.changed() {
self.handle_action(Action::QueryChanged(query_buf), ctx);
}
response.request_focus();
ui.add_space(8.0);
if self.inner.is_loading() {
render::render_loading_state(ui, &cfg);
return;
}
if self.inner.results().is_empty() && !self.inner.query().is_empty() {
render::render_empty_state(ui, &cfg);
return;
}
render::render_result_list(ui, self.inner.results(), self.inner.selected(), &cfg);
});
}
}
impl eframe::App for KLauncherApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if let Ok(results) = self.result_rx.try_recv() {
self.results = results;
}
self.poll_search_results();
let mut close = false;
let mut launch_selected = false;
ctx.input(|i| {
if i.key_pressed(Key::Escape) {
close = true;
}
if i.key_pressed(Key::Enter) {
launch_selected = true;
}
if i.key_pressed(Key::ArrowDown) {
let len = self.results.len();
if len > 0 {
self.selected = (self.selected + 1).min(len - 1);
}
}
if i.key_pressed(Key::ArrowUp) && self.selected > 0 {
self.selected -= 1;
}
});
if close {
ctx.send_viewport_cmd(ViewportCommand::Close);
return;
}
if launch_selected {
if let Some(result) = self.results.get(self.selected) {
self.engine.on_selected(&result.id);
self.launcher.execute(&result.action);
}
ctx.send_viewport_cmd(ViewportCommand::Close);
return;
}
let frame = egui::Frame::new()
.fill(BG)
.stroke(egui::Stroke::new(1.0, BORDER_COLOR))
.inner_margin(egui::Margin::same(12))
.corner_radius(egui::CornerRadius::same(8));
egui::CentralPanel::default().frame(frame).show(ctx, |ui| {
let response = ui.add_sized(
[ui.available_width(), 36.0],
egui::TextEdit::singleline(&mut self.query)
.hint_text("Search...")
.font(egui::TextStyle::Heading),
);
if response.changed() {
self.selected = 0;
self.trigger_search(self.query.clone());
}
response.request_focus();
ui.add_space(8.0);
if self.results.is_empty() && !self.query.is_empty() {
ui.add_space(20.0);
ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| {
ui.colored_label(DIM_TEXT, "No results");
});
match process_input(ctx) {
InputAction::Close => {
self.handle_action(Action::Exit, ctx);
return;
}
InputAction::LaunchSelected => {
self.handle_action(Action::LaunchSelected, ctx);
return;
}
InputAction::MoveDown => {
self.inner.handle(Action::MoveDown);
}
InputAction::MoveUp => {
self.inner.handle(Action::MoveUp);
}
InputAction::None => {}
}
egui::ScrollArea::vertical().show(ui, |ui| {
ui.set_width(ui.available_width());
for (i, result) in self.results.iter().enumerate() {
let is_selected = i == self.selected;
let bg = if is_selected {
SELECTED_BG
} else {
Color32::TRANSPARENT
};
let row_frame = egui::Frame::new()
.fill(bg)
.inner_margin(egui::Margin {
left: 8,
right: 8,
top: 6,
bottom: 6,
})
.corner_radius(egui::CornerRadius::same(4));
row_frame.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.vertical(|ui| {
ui.label(result.title.as_str());
if let Some(desc) = &result.description {
ui.colored_label(DIM_TEXT, desc);
}
});
});
});
ui.add_space(2.0);
}
});
});
self.render_panel(ctx);
}
}
pub fn run(
engine: Arc<dyn SearchEngine>,
engine: Arc<Kernel>,
launcher: Arc<dyn AppLauncher>,
window_cfg: &k_launcher_config::WindowCfg,
appearance_cfg: AppearanceCfg,
) -> Result<(), eframe::Error> {
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
let handle = rt.handle().clone();
@@ -179,8 +163,15 @@ pub fn run(
};
eframe::run_native(
"K-Launcher",
k_launcher_domain::constants::APP_TITLE,
options,
Box::new(move |_cc| Ok(Box::new(KLauncherApp::new(engine, launcher, handle)))),
Box::new(move |_cc| {
Ok(Box::new(KLauncherApp::new(
engine,
launcher,
handle,
appearance_cfg,
)))
}),
)
}

View File

@@ -0,0 +1,25 @@
use egui::Key;
pub enum InputAction {
Close,
LaunchSelected,
MoveDown,
MoveUp,
None,
}
pub fn process_input(ctx: &egui::Context) -> InputAction {
ctx.input(|i| {
if i.key_pressed(Key::Escape) {
InputAction::Close
} else if i.key_pressed(Key::Enter) {
InputAction::LaunchSelected
} else if i.key_pressed(Key::ArrowDown) {
InputAction::MoveDown
} else if i.key_pressed(Key::ArrowUp) {
InputAction::MoveUp
} else {
InputAction::None
}
})
}

View File

@@ -1,13 +1,19 @@
mod app;
mod input;
mod render;
mod style;
use std::sync::Arc;
use k_launcher_kernel::{AppLauncher, SearchEngine};
use k_launcher_config::AppearanceCfg;
use k_launcher_domain::AppLauncher;
use k_launcher_kernel::Kernel;
pub fn run(
engine: Arc<dyn SearchEngine>,
engine: Arc<Kernel>,
launcher: Arc<dyn AppLauncher>,
window_cfg: &k_launcher_config::WindowCfg,
) -> Result<(), eframe::Error> {
app::run(engine, launcher, window_cfg)
appearance_cfg: AppearanceCfg,
) -> Result<(), String> {
app::run(engine, launcher, window_cfg, appearance_cfg).map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,58 @@
use egui::Ui;
use k_launcher_config::AppearanceCfg;
use k_launcher_domain::SearchResult;
use crate::style::{self, ROW_SPACING, SEARCH_BAR_HEIGHT, to_color32};
pub fn render_search_bar(ui: &mut Ui, query: &mut String, cfg: &AppearanceCfg) -> egui::Response {
ui.add_sized(
[ui.available_width(), SEARCH_BAR_HEIGHT],
egui::TextEdit::singleline(query)
.hint_text(&cfg.placeholder)
.font(egui::TextStyle::Heading),
)
}
pub fn render_loading_state(ui: &mut Ui, cfg: &AppearanceCfg) {
ui.add_space(20.0);
ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| {
ui.colored_label(to_color32(&cfg.no_results_rgba), "Loading...");
});
}
pub fn render_empty_state(ui: &mut Ui, cfg: &AppearanceCfg) {
ui.add_space(20.0);
ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| {
ui.colored_label(to_color32(&cfg.no_results_rgba), "No results");
});
}
pub fn render_result_list(
ui: &mut Ui,
results: &[SearchResult],
selected: usize,
cfg: &AppearanceCfg,
) {
egui::ScrollArea::vertical().show(ui, |ui| {
ui.set_width(ui.available_width());
for (i, result) in results.iter().enumerate() {
render_result_row(ui, result, i == selected, cfg);
ui.add_space(ROW_SPACING);
}
});
}
fn render_result_row(ui: &mut Ui, result: &SearchResult, is_selected: bool, cfg: &AppearanceCfg) {
style::result_row_frame(is_selected, cfg).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.vertical(|ui| {
ui.label(result.title.as_str());
if let Some(desc) = &result.description {
ui.colored_label(to_color32(&cfg.description_rgba), desc.as_ref());
}
});
});
});
}

View File

@@ -0,0 +1,39 @@
use egui::{Color32, CornerRadius, Frame, Margin, Stroke};
use k_launcher_config::AppearanceCfg;
pub const SEARCH_BAR_HEIGHT: f32 = 36.0;
pub const CONTENT_MARGIN: i8 = 12;
pub const ROW_SPACING: f32 = 2.0;
const ROW_PADDING_VERTICAL: i8 = 6;
const ROW_PADDING_HORIZONTAL: i8 = 8;
pub(crate) fn to_color32(c: &k_launcher_config::Rgba) -> Color32 {
Color32::from_rgba_unmultiplied(c.red_u8(), c.green_u8(), c.blue_u8(), c.alpha_byte())
}
pub fn outer_frame(cfg: &AppearanceCfg) -> Frame {
Frame::new()
.fill(to_color32(&cfg.background_rgba))
.stroke(Stroke::new(cfg.border_width, to_color32(&cfg.border_rgba)))
.inner_margin(Margin::same(CONTENT_MARGIN))
.corner_radius(CornerRadius::same(cfg.border_radius as u8))
}
pub fn result_row_frame(is_selected: bool, cfg: &AppearanceCfg) -> Frame {
let bg = if is_selected {
to_color32(&cfg.selected_row_rgba)
} else {
to_color32(&cfg.unselected_row_rgba)
};
Frame::new()
.fill(bg)
.inner_margin(Margin {
left: ROW_PADDING_HORIZONTAL,
right: ROW_PADDING_HORIZONTAL,
top: ROW_PADDING_VERTICAL,
bottom: ROW_PADDING_VERTICAL,
})
.corner_radius(CornerRadius::same(cfg.row_radius as u8))
}

View File

@@ -1,6 +1,6 @@
[package]
name = "k-launcher-ui"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
[lib]
@@ -8,8 +8,10 @@ name = "k_launcher_ui"
path = "src/lib.rs"
[dependencies]
iced = { workspace = true }
k-launcher-config = { path = "../k-launcher-config" }
k-launcher-kernel = { path = "../k-launcher-kernel" }
k-launcher-os-bridge = { path = "../k-launcher-os-bridge" }
iced = { version = "0.14", default-features = false, features = ["image", "svg", "tokio", "tiny-skia", "wayland", "x11", "crisp", "web-colors", "thread-pool"] }
k-launcher-config = { workspace = true }
k-launcher-domain = { workspace = true }
k-launcher-kernel = { workspace = true }
k-launcher-os-bridge = { workspace = true }
k-launcher-ui-core = { workspace = true }
tokio = { workspace = true }

View File

@@ -1,20 +1,18 @@
use std::sync::Arc;
use iced::{
Border, Color, Element, Length, Size, Subscription, Task, event,
keyboard::{Event as KeyEvent, Key, key::Named},
widget::{Space, column, container, image, row, scrollable, svg, text, text_input},
window,
};
use iced::{Size, Subscription, Task, event, keyboard::Event as KeyEvent, window};
use k_launcher_config::AppearanceCfg;
use k_launcher_kernel::{AppLauncher, NullSearchEngine, SearchEngine, SearchResult};
use k_launcher_domain::AppLauncher;
use k_launcher_domain::SearchResult;
use k_launcher_kernel::Kernel;
use k_launcher_ui_core::LauncherState;
static INPUT_ID: std::sync::LazyLock<iced::widget::Id> =
pub(crate) static INPUT_ID: std::sync::LazyLock<iced::widget::Id> =
std::sync::LazyLock::new(|| iced::widget::Id::new("search"));
#[derive(Clone)]
pub(crate) struct EngineHandle(Arc<dyn SearchEngine>);
pub(crate) struct EngineHandle(pub(crate) Arc<Kernel>);
impl std::fmt::Debug for EngineHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -22,250 +20,22 @@ impl std::fmt::Debug for EngineHandle {
}
}
fn rgba(c: &[f32; 4]) -> Color {
Color::from_rgba8(c[0] as u8, c[1] as u8, c[2] as u8, c[3])
}
pub struct KLauncherApp {
engine: Arc<dyn SearchEngine>,
launcher: Arc<dyn AppLauncher>,
query: String,
results: Arc<Vec<SearchResult>>,
selected: usize,
cfg: AppearanceCfg,
error: Option<String>,
search_epoch: u64,
}
impl KLauncherApp {
fn new(
engine: Arc<dyn SearchEngine>,
launcher: Arc<dyn AppLauncher>,
cfg: AppearanceCfg,
) -> Self {
Self {
engine,
launcher,
query: String::new(),
results: Arc::new(vec![]),
selected: 0,
cfg,
error: None,
search_epoch: 0,
}
}
pub(crate) struct KLauncherApp {
pub(crate) inner: LauncherState,
}
#[derive(Debug, Clone)]
pub enum Message {
pub(crate) enum Message {
QueryChanged(String),
ResultsReady(u64, Arc<Vec<SearchResult>>),
ResultsReady {
epoch: u64,
results: Arc<Vec<SearchResult>>,
},
KeyPressed(KeyEvent),
EngineReady(EngineHandle),
EngineInitFailed(String),
}
fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
match message {
Message::QueryChanged(q) => {
state.error = None;
state.query = q.clone();
state.selected = 0;
state.search_epoch += 1;
let epoch = state.search_epoch;
let engine = state.engine.clone();
Task::perform(
async move {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
(epoch, engine.search(&q).await)
},
|(epoch, results)| Message::ResultsReady(epoch, Arc::new(results)),
)
}
Message::ResultsReady(epoch, results) => {
if epoch == state.search_epoch {
state.results = results;
}
Task::none()
}
Message::EngineInitFailed(msg) => {
state.error = Some(msg);
Task::none()
}
Message::EngineReady(handle) => {
state.engine = handle.0;
if !state.query.is_empty() {
let q = state.query.clone();
return Task::done(Message::QueryChanged(q));
}
Task::none()
}
Message::KeyPressed(event) => {
let key = match event {
KeyEvent::KeyPressed { key, .. } => key,
_ => return Task::none(),
};
let Key::Named(named) = key else {
return Task::none();
};
let len = state.results.len();
match named {
Named::Escape => {
std::process::exit(0);
}
Named::ArrowDown => {
if len > 0 {
state.selected = (state.selected + 1).min(len - 1);
}
}
Named::ArrowUp => {
if state.selected > 0 {
state.selected -= 1;
}
}
Named::Enter => {
if let Some(result) = state.results.get(state.selected) {
state.engine.on_selected(&result.id);
state.launcher.execute(&result.action);
}
std::process::exit(0);
}
_ => {}
}
Task::none()
}
}
}
fn view(state: &KLauncherApp) -> Element<'_, Message> {
let cfg = &state.cfg;
let border_color = rgba(&cfg.border_rgba);
let search_bar = text_input(&cfg.placeholder, &state.query)
.id(INPUT_ID.clone())
.on_input(Message::QueryChanged)
.padding(12)
.size(cfg.search_font_size)
.style(|theme, _status| {
let mut s =
iced::widget::text_input::default(theme, iced::widget::text_input::Status::Active);
s.border = Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 0.0.into(),
};
s
});
let row_radius: f32 = cfg.row_radius;
let title_size: f32 = cfg.title_size;
let desc_size: f32 = cfg.desc_size;
let result_rows: Vec<Element<'_, Message>> = state
.results
.iter()
.enumerate()
.map(|(i, result)| {
let is_selected = i == state.selected;
let bg_color = if is_selected {
border_color
} else {
Color::from_rgba8(255, 255, 255, 0.07)
};
let icon_el: Element<'_, Message> = match &result.icon {
Some(p) if p.ends_with(".svg") => {
svg(svg::Handle::from_path(p)).width(24).height(24).into()
}
Some(p) => 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(title_size),
text(desc)
.size(desc_size)
.color(Color::from_rgba8(210, 215, 230, 1.0)),
]
.into()
} else {
text(result.title.as_str()).size(title_size).into()
};
container(row![icon_el, title_col].spacing(8).align_y(iced::Center))
.width(Length::Fill)
.padding([6, 12])
.style(move |_theme| container::Style {
background: Some(iced::Background::Color(bg_color)),
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: row_radius.into(),
},
..Default::default()
})
.into()
})
.collect();
let results_list = if state.results.is_empty() && !state.query.is_empty() {
scrollable(
container(
text("No results")
.size(title_size)
.color(Color::from_rgba8(180, 180, 200, 0.5)),
)
.width(Length::Fill)
.align_x(iced::Center)
.padding([20, 0]),
)
.height(Length::Fill)
} else {
scrollable(column(result_rows).spacing(2).width(Length::Fill)).height(Length::Fill)
};
let maybe_error: Option<Element<'_, Message>> = state.error.as_ref().map(|msg| {
container(
text(msg.as_str())
.size(12.0)
.color(Color::from_rgba8(255, 80, 80, 1.0)),
)
.width(Length::Fill)
.padding([4, 12])
.into()
});
let mut content_children: Vec<Element<'_, Message>> =
vec![search_bar.into(), results_list.into()];
if let Some(err) = maybe_error {
content_children.push(err);
}
let content = column(content_children)
.spacing(8)
.padding(12)
.width(Length::Fill)
.height(Length::Fill);
let bg_color = rgba(&cfg.background_rgba);
let border_width = cfg.border_width;
let border_radius = cfg.border_radius;
container(content)
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| container::Style {
background: Some(iced::Background::Color(bg_color)),
border: Border {
color: border_color,
width: border_width,
radius: border_radius.into(),
},
..Default::default()
})
.into()
}
fn subscription(_state: &KLauncherApp) -> Subscription<Message> {
event::listen_with(|ev, _status, _id| match ev {
iced::Event::Keyboard(ke) => Some(Message::KeyPressed(ke)),
@@ -274,18 +44,16 @@ fn subscription(_state: &KLauncherApp) -> Subscription<Message> {
}
pub fn run(
engine_factory: Arc<dyn Fn() -> Arc<dyn SearchEngine> + Send + Sync>,
engine_factory: Arc<dyn Fn() -> Arc<Kernel> + Send + Sync>,
launcher: Arc<dyn AppLauncher>,
window_cfg: &k_launcher_config::WindowCfg,
appearance_cfg: AppearanceCfg,
debounce_ms: u64,
) -> iced::Result {
iced::application(
move || {
let app = KLauncherApp::new(
Arc::new(NullSearchEngine),
launcher.clone(),
appearance_cfg.clone(),
);
let inner = LauncherState::new(launcher.clone(), appearance_cfg.clone(), debounce_ms);
let app = KLauncherApp { inner };
let focus = iced::widget::operation::focus(INPUT_ID.clone());
let ef = engine_factory.clone();
let init = Task::perform(
@@ -301,10 +69,10 @@ pub fn run(
);
(app, Task::batch([focus, init]))
},
update,
view,
crate::update::update,
crate::view::view,
)
.title("K-Launcher")
.title(k_launcher_domain::constants::APP_TITLE)
.subscription(subscription)
.window(window::Settings {
size: Size::new(window_cfg.width, window_cfg.height),

View File

@@ -1,15 +1,27 @@
mod app;
mod style;
mod update;
mod view;
use std::sync::Arc;
use k_launcher_config::{AppearanceCfg, WindowCfg};
use k_launcher_kernel::{AppLauncher, SearchEngine};
use k_launcher_config::{AppearanceCfg, SearchCfg, WindowCfg};
use k_launcher_domain::AppLauncher;
use k_launcher_kernel::Kernel;
pub fn run(
engine_factory: Arc<dyn Fn() -> Arc<dyn SearchEngine> + Send + Sync>,
engine_factory: Arc<dyn Fn() -> Arc<Kernel> + Send + Sync>,
launcher: Arc<dyn AppLauncher>,
window_cfg: &WindowCfg,
appearance_cfg: AppearanceCfg,
) -> iced::Result {
app::run(engine_factory, launcher, window_cfg, appearance_cfg)
search_cfg: &SearchCfg,
) -> Result<(), String> {
app::run(
engine_factory,
launcher,
window_cfg,
appearance_cfg,
search_cfg.debounce_ms,
)
.map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,60 @@
use iced::{Border, Color, widget::container, widget::text_input};
// ---- layout constants ----
pub(crate) const ROW_PADDING: [u16; 2] = [6, 12];
pub(crate) const ROW_SPACING: f32 = 2.0;
pub(crate) const CONTENT_PADDING: u16 = 12;
pub(crate) const CONTENT_SPACING: f32 = 8.0;
pub(crate) const EMPTY_STATE_PADDING: [u16; 2] = [20, 0];
pub(crate) const ERROR_FONT_SIZE: f32 = 12.0;
pub(crate) const ERROR_PADDING: [u16; 2] = [4, 12];
// ---- helpers ----
pub(crate) fn rgba(c: &k_launcher_config::Rgba) -> Color {
Color::from_rgba8(c.red_u8(), c.green_u8(), c.blue_u8(), c.alpha())
}
pub(crate) fn search_input_style(
theme: &iced::Theme,
_status: text_input::Status,
) -> text_input::Style {
let mut s = text_input::default(theme, text_input::Status::Active);
s.border = Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 0.0.into(),
};
s
}
pub(crate) fn result_row_style(
bg_color: Color,
row_radius: f32,
) -> impl Fn(&iced::Theme) -> container::Style {
move |_theme| container::Style {
background: Some(iced::Background::Color(bg_color)),
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: row_radius.into(),
},
..Default::default()
}
}
pub(crate) fn outer_container_style(
bg: Color,
border_color: Color,
width: f32,
radius: f32,
) -> impl Fn(&iced::Theme) -> container::Style {
move |_theme| container::Style {
background: Some(iced::Background::Color(bg)),
border: Border {
color: border_color,
width,
radius: radius.into(),
},
..Default::default()
}
}

View File

@@ -0,0 +1,77 @@
use std::sync::Arc;
use iced::{
Task,
keyboard::{Event as KeyEvent, Key, key::Named},
};
use k_launcher_ui_core::{Action, Effect};
use crate::app::{KLauncherApp, Message};
pub(crate) fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
let action = match message {
Message::QueryChanged(q) => Action::QueryChanged(q),
Message::ResultsReady { epoch, results } => {
let results = Arc::try_unwrap(results).unwrap_or_else(|arc| (*arc).clone());
Action::ResultsReady { epoch, results }
}
Message::EngineInitFailed(msg) => Action::EngineInitFailed(msg),
Message::EngineReady(handle) => Action::EngineReady(handle.0),
Message::KeyPressed(event) => match map_key_event(event) {
Some(a) => a,
None => return Task::none(),
},
};
let effect = state.inner.handle(action);
execute_effect(state, effect)
}
fn map_key_event(event: KeyEvent) -> Option<Action> {
let key = match event {
KeyEvent::KeyPressed { key, .. } => key,
_ => return None,
};
let Key::Named(named) = key else {
return None;
};
match named {
Named::Escape => Some(Action::Exit),
Named::ArrowDown => Some(Action::MoveDown),
Named::ArrowUp => Some(Action::MoveUp),
Named::Enter => Some(Action::LaunchSelected),
_ => None,
}
}
fn execute_effect(state: &KLauncherApp, effect: Effect) -> Task<Message> {
match effect {
Effect::SearchAfterDebounce {
query,
debounce_ms,
epoch,
} => {
let Some(engine) = state.inner.engine().cloned() else {
return Task::none();
};
Task::perform(
async move {
tokio::time::sleep(std::time::Duration::from_millis(debounce_ms)).await;
(epoch, engine.search(&query).await)
},
|(epoch, results)| Message::ResultsReady {
epoch,
results: Arc::new(results),
},
)
}
Effect::LaunchAndExit(action) => {
state.inner.launcher().execute(&action);
iced::exit()
}
Effect::Exit => iced::exit(),
Effect::TriggerSearch(q) => Task::done(Message::QueryChanged(q)),
Effect::None => Task::none(),
}
}

View File

@@ -0,0 +1,164 @@
use std::sync::Arc;
use iced::{
Element, Length,
widget::{Space, column, container, image, row, scrollable, svg, text, text_input},
};
use k_launcher_config::AppearanceCfg;
use k_launcher_domain::SearchResult;
use crate::app::{INPUT_ID, KLauncherApp, Message};
use crate::style;
pub(crate) fn view(state: &KLauncherApp) -> Element<'_, Message> {
let cfg = state.inner.cfg();
let mut content_children: Vec<Element<'_, Message>> =
vec![search_bar(cfg, state.inner.query()), results_section(state)];
if let Some(err) = state.inner.error() {
content_children.push(error_bar(err, cfg));
}
let content = column(content_children)
.spacing(style::CONTENT_SPACING)
.padding(style::CONTENT_PADDING)
.width(Length::Fill)
.height(Length::Fill);
container(content)
.width(Length::Fill)
.height(Length::Fill)
.style(style::outer_container_style(
style::rgba(&cfg.background_rgba),
style::rgba(&cfg.border_rgba),
cfg.border_width,
cfg.border_radius,
))
.into()
}
fn search_bar<'a>(cfg: &'a AppearanceCfg, query: &'a str) -> Element<'a, Message> {
text_input(&cfg.placeholder, query)
.id(INPUT_ID.clone())
.on_input(Message::QueryChanged)
.padding(style::CONTENT_PADDING)
.size(cfg.search_font_size)
.style(style::search_input_style)
.into()
}
fn results_section<'a>(state: &'a KLauncherApp) -> Element<'a, Message> {
if state.inner.is_loading() {
loading_state(state.inner.cfg())
} else if state.inner.results().is_empty() && !state.inner.query().is_empty() {
empty_state(state.inner.cfg())
} else {
result_list(state)
}
}
fn loading_state(cfg: &AppearanceCfg) -> Element<'static, Message> {
container(
text("Loading...")
.size(cfg.title_size)
.color(style::rgba(&cfg.no_results_rgba)),
)
.width(Length::Fill)
.align_x(iced::Center)
.padding(style::EMPTY_STATE_PADDING)
.into()
}
fn empty_state(cfg: &AppearanceCfg) -> Element<'static, Message> {
scrollable(
container(
text("No results")
.size(cfg.title_size)
.color(style::rgba(&cfg.no_results_rgba)),
)
.width(Length::Fill)
.align_x(iced::Center)
.padding(style::EMPTY_STATE_PADDING),
)
.height(Length::Fill)
.into()
}
fn result_list<'a>(state: &'a KLauncherApp) -> Element<'a, Message> {
let cfg = state.inner.cfg();
let selected = state.inner.selected();
let rows: Vec<Element<'_, Message>> = state
.inner
.results()
.iter()
.enumerate()
.map(|(i, result)| result_row(result, i == selected, cfg))
.collect();
scrollable(column(rows).spacing(style::ROW_SPACING).width(Length::Fill))
.height(Length::Fill)
.into()
}
fn result_row<'a>(
result: &'a SearchResult,
is_selected: bool,
cfg: &AppearanceCfg,
) -> Element<'a, Message> {
let bg_color = if is_selected {
style::rgba(&cfg.selected_row_rgba)
} else {
style::rgba(&cfg.unselected_row_rgba)
};
container(
row![result_icon(&result.icon, cfg), title_column(result, cfg)]
.spacing(style::CONTENT_SPACING)
.align_y(iced::Center),
)
.width(Length::Fill)
.padding(style::ROW_PADDING)
.style(style::result_row_style(bg_color, cfg.row_radius))
.into()
}
fn result_icon<'a>(icon_path: &'a Option<Arc<str>>, cfg: &AppearanceCfg) -> Element<'a, Message> {
let size = cfg.icon_size;
match icon_path {
Some(p) if p.ends_with(".svg") => svg(svg::Handle::from_path(p.as_ref()))
.width(size)
.height(size)
.into(),
Some(p) => image(image::Handle::from_path(p.as_ref()))
.width(size)
.height(size)
.into(),
None => Space::new().width(size).height(size).into(),
}
}
fn title_column<'a>(result: &'a SearchResult, cfg: &AppearanceCfg) -> Element<'a, Message> {
if let Some(desc) = &result.description {
column![
text(result.title.as_str()).size(cfg.title_size),
text(desc.as_ref())
.size(cfg.desc_size)
.color(style::rgba(&cfg.description_rgba)),
]
.into()
} else {
text(result.title.as_str()).size(cfg.title_size).into()
}
}
fn error_bar<'a>(msg: &'a str, cfg: &AppearanceCfg) -> Element<'a, Message> {
container(
text(msg)
.size(style::ERROR_FONT_SIZE)
.color(style::rgba(&cfg.error_rgba)),
)
.width(Length::Fill)
.padding(style::ERROR_PADDING)
.into()
}

View File

@@ -1,6 +1,6 @@
[package]
name = "k-launcher"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
default-run = "k-launcher"
@@ -23,19 +23,21 @@ required-features = ["egui"]
egui = ["dep:k-launcher-ui-egui"]
[dependencies]
iced = { workspace = true }
k-launcher-config = { path = "../k-launcher-config" }
k-launcher-kernel = { path = "../k-launcher-kernel" }
k-launcher-plugin-host = { path = "../k-launcher-plugin-host" }
k-launcher-os-bridge = { path = "../k-launcher-os-bridge" }
k-launcher-ui = { path = "../k-launcher-ui" }
k-launcher-ui-egui = { path = "../k-launcher-ui-egui", optional = true }
plugin-apps = { path = "../plugins/plugin-apps" }
plugin-calc = { path = "../plugins/plugin-calc" }
plugin-cmd = { path = "../plugins/plugin-cmd" }
plugin-files = { path = "../plugins/plugin-files" }
k-launcher-config = { workspace = true }
k-launcher-kernel = { workspace = true }
k-launcher-domain = { workspace = true }
k-launcher-plugin-host = { workspace = true }
k-launcher-os-bridge = { workspace = true }
k-launcher-ui = { workspace = true }
k-launcher-ui-egui = { workspace = true, optional = true }
plugin-apps = { workspace = true }
plugin-calc = { workspace = true }
plugin-cmd = { workspace = true }
plugin-files = { workspace = true }
dirs = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
ctrlc = { workspace = true }
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View File

@@ -0,0 +1,39 @@
use std::sync::Arc;
use k_launcher_kernel::Kernel;
use k_launcher_plugin_host::ExternalPlugin;
#[cfg(target_os = "linux")]
use plugin_apps::linux::FsDesktopEntrySource;
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
use plugin_calc::CalcPlugin;
use plugin_cmd::CmdPlugin;
use plugin_files::FilesPlugin;
pub(crate) fn build_engine(cfg: Arc<k_launcher_config::Config>) -> Arc<Kernel> {
let frecency = FrecencyStore::load(cfg.search.frecency_compact_threshold);
let mut plugins: Vec<Arc<dyn k_launcher_domain::Plugin>> = vec![];
if cfg.plugins.cmd {
plugins.push(Arc::new(CmdPlugin::new()));
}
if cfg.plugins.calc {
plugins.push(Arc::new(CalcPlugin::new()));
}
if cfg.plugins.files {
plugins.push(Arc::new(FilesPlugin::new()));
}
if cfg.plugins.apps {
plugins.push(Arc::new(AppsPlugin::new(
FsDesktopEntrySource::new(),
frecency,
)));
}
for ext in &cfg.plugins.external {
plugins.push(Arc::new(ExternalPlugin::new(
&ext.name,
&ext.path,
ext.args.clone(),
ext.timeout_secs,
)));
}
Arc::new(Kernel::new(plugins, cfg.search.max_results))
}

View File

@@ -0,0 +1,7 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("UI error: {0}")]
Ui(String),
}

View File

@@ -0,0 +1,53 @@
use k_launcher_domain::constants::{APP_NAME, LOG_DIR_NAME, LOG_FILE_PREFIX};
const FALLBACK_LOG_DIR: &str = "/tmp/k-launcher/logs";
const DEFAULT_LOG_LEVEL: &str = "info";
pub(crate) fn init_logging(
cfg: &k_launcher_config::Config,
) -> tracing_appender::non_blocking::WorkerGuard {
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
let log_dir = dirs::data_local_dir()
.map(|d| d.join(APP_NAME).join(LOG_DIR_NAME))
.unwrap_or_else(|| std::path::PathBuf::from(FALLBACK_LOG_DIR));
std::fs::create_dir_all(&log_dir).ok();
let file_appender = tracing_appender::rolling::RollingFileAppender::builder()
.rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_prefix(LOG_FILE_PREFIX)
.max_log_files(cfg.logging.max_log_files)
.build(&log_dir)
.expect("log appender");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let env_filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(DEFAULT_LOG_LEVEL));
tracing_subscriber::registry()
.with(env_filter)
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
.with(tracing_subscriber::fmt::layer().with_writer(non_blocking))
.init();
guard
}
pub(crate) fn install_panic_hook() {
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let location = info
.location()
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
.unwrap_or_else(|| "unknown".to_string());
let payload = if let Some(s) = info.payload().downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = info.payload().downcast_ref::<String>() {
s.clone()
} else {
"unknown panic".to_string()
};
tracing::error!("PANIC at {location}: {payload}");
default_hook(info);
}));
}

View File

@@ -1,79 +1,46 @@
use std::sync::Arc;
use k_launcher_kernel::Kernel;
use k_launcher_os_bridge::UnixAppLauncher;
use k_launcher_plugin_host::ExternalPlugin;
#[cfg(target_os = "linux")]
use plugin_apps::linux::FsDesktopEntrySource;
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
use plugin_calc::CalcPlugin;
use plugin_cmd::CmdPlugin;
use plugin_files::FilesPlugin;
fn init_logging() -> tracing_appender::non_blocking::WorkerGuard {
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
mod engine;
mod error;
mod logging;
let log_dir = dirs::data_local_dir()
.map(|d| d.join("k-launcher/logs"))
.unwrap_or_else(|| std::path::PathBuf::from("/tmp/k-launcher/logs"));
std::fs::create_dir_all(&log_dir).ok();
let file_appender = tracing_appender::rolling::daily(&log_dir, "k-launcher.log");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::registry()
.with(env_filter)
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
.with(tracing_subscriber::fmt::layer().with_writer(non_blocking))
.init();
guard
}
use error::AppError;
fn main() {
let _guard = init_logging();
if std::env::args().any(|a| a == "--version" || a == "-V") {
println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
return;
}
if let Err(e) = run_ui() {
eprintln!("error: UI: {e}");
let cfg = Arc::new(k_launcher_config::load());
let _guard = logging::init_logging(&cfg);
logging::install_panic_hook();
ctrlc::set_handler(|| {
tracing::info!("received shutdown signal");
std::process::exit(0);
})
.ok();
if let Err(e) = run_ui(cfg) {
eprintln!("error: {e}");
std::process::exit(1);
}
}
fn build_engine(cfg: Arc<k_launcher_config::Config>) -> Arc<dyn k_launcher_kernel::SearchEngine> {
let frecency = FrecencyStore::load();
let mut plugins: Vec<Arc<dyn k_launcher_kernel::Plugin>> = vec![];
if cfg.plugins.cmd {
plugins.push(Arc::new(CmdPlugin::new()));
}
if cfg.plugins.calc {
plugins.push(Arc::new(CalcPlugin::new()));
}
if cfg.plugins.files {
plugins.push(Arc::new(FilesPlugin::new()));
}
if cfg.plugins.apps {
plugins.push(Arc::new(AppsPlugin::new(
FsDesktopEntrySource::new(),
frecency,
)));
}
for ext in &cfg.plugins.external {
plugins.push(Arc::new(ExternalPlugin::new(
&ext.name,
&ext.path,
ext.args.clone(),
)));
}
Arc::new(Kernel::new(plugins, cfg.search.max_results))
}
fn run_ui() -> iced::Result {
let cfg = Arc::new(k_launcher_config::load());
let launcher = Arc::new(UnixAppLauncher::new());
fn run_ui(cfg: Arc<k_launcher_config::Config>) -> Result<(), AppError> {
let launcher = Arc::new(UnixAppLauncher::new(cfg.terminal.cmd.clone()));
let factory_cfg = cfg.clone();
let factory: Arc<dyn Fn() -> Arc<dyn k_launcher_kernel::SearchEngine> + Send + Sync> =
Arc::new(move || build_engine(factory_cfg.clone()));
k_launcher_ui::run(factory, launcher, &cfg.window, cfg.appearance.clone())
let factory: Arc<dyn Fn() -> Arc<k_launcher_kernel::Kernel> + Send + Sync> =
Arc::new(move || engine::build_engine(factory_cfg.clone()));
k_launcher_ui::run(
factory,
launcher,
&cfg.window,
cfg.appearance.clone(),
&cfg.search,
)
.map_err(AppError::Ui)
}

View File

@@ -1,27 +1,38 @@
use std::sync::Arc;
use k_launcher_kernel::Kernel;
use k_launcher_os_bridge::UnixAppLauncher;
#[cfg(target_os = "linux")]
use plugin_apps::linux::FsDesktopEntrySource;
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
use plugin_calc::CalcPlugin;
use plugin_cmd::CmdPlugin;
use plugin_files::FilesPlugin;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cfg = k_launcher_config::load();
let launcher = Arc::new(UnixAppLauncher::new());
let frecency = FrecencyStore::load();
let kernel: Arc<dyn k_launcher_kernel::SearchEngine> = Arc::new(Kernel::new(
vec![
Arc::new(CmdPlugin::new()),
Arc::new(CalcPlugin::new()),
Arc::new(FilesPlugin::new()),
Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)),
],
8,
));
k_launcher_ui_egui::run(kernel, launcher, &cfg.window)?;
Ok(())
mod engine;
mod error;
mod logging;
use error::AppError;
fn main() {
if std::env::args().any(|a| a == "--version" || a == "-V") {
println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
return;
}
let cfg = Arc::new(k_launcher_config::load());
let _guard = logging::init_logging(&cfg);
logging::install_panic_hook();
ctrlc::set_handler(|| {
tracing::info!("received shutdown signal");
std::process::exit(0);
})
.ok();
if let Err(e) = run_ui(cfg) {
eprintln!("error: {e}");
std::process::exit(1);
}
}
fn run_ui(cfg: Arc<k_launcher_config::Config>) -> Result<(), AppError> {
let launcher = Arc::new(UnixAppLauncher::new(cfg.terminal.cmd.clone()));
let kernel = engine::build_engine(cfg.clone());
k_launcher_ui_egui::run(kernel, launcher, &cfg.window, cfg.appearance.clone())
.map_err(AppError::Ui)
}

View File

@@ -1,6 +1,6 @@
[package]
name = "plugin-apps"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
[lib]
@@ -9,10 +9,11 @@ path = "src/lib.rs"
[dependencies]
async-trait = { workspace = true }
bincode = { workspace = true }
bincode = { version = "2", features = ["serde"] }
dirs = { workspace = true }
k-launcher-kernel = { path = "../../k-launcher-kernel" }
k-launcher-domain = { workspace = true }
nucleo-matcher = "0.3"
parking_lot = { workspace = true }
serde = { workspace = true }
serde_json = "1.0"
tokio = { workspace = true }
@@ -21,3 +22,7 @@ tracing = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
linicon = "2.3.0"
xdg = "3"
[dev-dependencies]
k-launcher-domain = { workspace = true }
tokio = { workspace = true }

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>;
}

View File

@@ -0,0 +1,21 @@
use plugin_apps::frecency::FrecencyStore;
#[test]
fn record_increments_count() {
let store = FrecencyStore::new_for_test();
store.record("app-firefox");
store.record("app-firefox");
assert!(store.frecency_score("app-firefox") > 0);
}
#[test]
fn top_ids_returns_sorted_order() {
let store = FrecencyStore::new_for_test();
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");
}

View File

@@ -0,0 +1,27 @@
#[cfg(target_os = "linux")]
mod linux_tests {
use plugin_apps::linux::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,260 @@
use std::sync::Arc;
use k_launcher_domain::Plugin;
use plugin_apps::frecency::FrecencyStore;
use plugin_apps::{
AppName, AppsPlugin, DesktopEntry, DesktopEntrySource, ExecCommand, build_entries,
humanize_category, load_from_path, new_matcher, parse_pattern, save_to_path, score_match,
};
fn ephemeral_frecency() -> Arc<FrecencyStore> {
FrecencyStore::new_for_test()
}
struct MockEntry {
name: String,
exec: String,
category: Option<String>,
keywords: Vec<String>,
}
struct MockSource {
entries: Vec<MockEntry>,
}
impl MockSource {
fn with(entries: Vec<(&str, &str)>) -> Self {
Self {
entries: entries
.into_iter()
.map(|(n, e)| MockEntry {
name: n.to_string(),
exec: e.to_string(),
category: None,
keywords: vec![],
})
.collect(),
}
}
fn with_categories(entries: Vec<(&str, &str, &str)>) -> Self {
Self {
entries: entries
.into_iter()
.map(|(n, e, c)| MockEntry {
name: n.to_string(),
exec: e.to_string(),
category: Some(c.to_string()),
keywords: vec![],
})
.collect(),
}
}
fn with_keywords(entries: Vec<(&str, &str, Vec<&str>)>) -> Self {
Self {
entries: entries
.into_iter()
.map(|(n, e, kw)| MockEntry {
name: n.to_string(),
exec: e.to_string(),
category: None,
keywords: kw.into_iter().map(|s| s.to_string()).collect(),
})
.collect(),
}
}
}
impl DesktopEntrySource for MockSource {
fn entries(&self) -> Vec<DesktopEntry> {
self.entries
.iter()
.map(|e| DesktopEntry {
name: AppName::new(e.name.clone()),
exec: ExecCommand::new(e.exec.clone()),
icon: None,
category: e.category.clone(),
keywords: e.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() {
let mut matcher = new_matcher();
let pattern = parse_pattern("vsc");
let mut buf = Vec::new();
assert!(
score_match(
&mut matcher,
&pattern,
"visual studio code",
&mut buf,
"visual studio code",
"vsc"
)
.is_some()
);
}
#[test]
fn score_match_exact_beats_prefix() {
let mut matcher = new_matcher();
let mut buf = Vec::new();
let exact_pattern = parse_pattern("firefox");
let fire_pattern = parse_pattern("fire");
let gf_pattern = parse_pattern("gf");
let exact = score_match(
&mut matcher,
&exact_pattern,
"firefox",
&mut buf,
"firefox",
"firefox",
);
let prefix = score_match(
&mut matcher,
&fire_pattern,
"firefox",
&mut buf,
"firefox",
"fire",
);
let abbrev = score_match(
&mut matcher,
&gf_pattern,
"gnu firefox",
&mut buf,
"gnu firefox",
"gf",
);
let substr = score_match(
&mut matcher,
&fire_pattern,
"ice firefox",
&mut buf,
"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()));
let source = MockSource::with(vec![("Firefox", "firefox")]);
let entries = build_entries(&source, &frecency);
save_to_path(&cache_file, &entries);
let loaded = load_from_path(&cache_file).unwrap();
assert!(loaded.contains_key("app-Firefox:firefox"));
std::fs::remove_file(&cache_file).ok();
}

View File

@@ -1,6 +1,6 @@
[package]
name = "plugin-calc"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
[lib]
@@ -10,5 +10,9 @@ path = "src/lib.rs"
[dependencies]
async-trait = { workspace = true }
evalexpr = "13"
k-launcher-kernel = { path = "../../k-launcher-kernel" }
k-launcher-domain = { workspace = true }
tokio = { workspace = true }
[dev-dependencies]
k-launcher-domain = { workspace = true }
tokio = { workspace = true }

View File

@@ -0,0 +1,45 @@
use std::sync::LazyLock;
pub(crate) const MATH_FNS: &[&str] = &[
"sqrt", "sin", "cos", "tan", "asin", "acos", "atan", "ln", "log2", "log10", "exp", "abs",
"ceil", "floor", "round",
];
pub(crate) fn strip_numeric_separators(expr: &str) -> String {
expr.replace('_', "")
}
pub(crate) fn should_eval(query: &str) -> bool {
let q = query.strip_prefix('=').unwrap_or(query);
q.chars()
.next()
.map(|c| c.is_ascii_digit() || c == '(' || c == '-')
.unwrap_or(false)
|| query.starts_with('=')
|| MATH_FNS.iter().any(|f| q.starts_with(f))
}
pub(crate) static MATH_CTX: LazyLock<evalexpr::HashMapContext<evalexpr::DefaultNumericTypes>> =
LazyLock::new(|| {
use evalexpr::*;
context_map! {
"pi" => float std::f64::consts::PI,
"e" => float std::f64::consts::E,
"sqrt" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.sqrt()))),
"sin" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.sin()))),
"cos" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.cos()))),
"tan" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.tan()))),
"asin" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.asin()))),
"acos" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.acos()))),
"atan" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.atan()))),
"ln" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.ln()))),
"log2" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.log2()))),
"log10" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.log10()))),
"exp" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.exp()))),
"abs" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.abs()))),
"ceil" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.ceil()))),
"floor" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.floor()))),
"round" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.round())))
}
.expect("static math context must be valid")
});

View File

@@ -1,154 +1,4 @@
use async_trait::async_trait;
use evalexpr::eval_number_with_context;
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
use std::sync::LazyLock;
mod eval;
mod plugin;
pub struct CalcPlugin;
impl CalcPlugin {
pub fn new() -> Self {
Self
}
}
impl Default for CalcPlugin {
fn default() -> Self {
Self::new()
}
}
fn strip_numeric_separators(expr: &str) -> String {
expr.replace('_', "")
}
const MATH_FNS: &[&str] = &[
"sqrt", "sin", "cos", "tan", "asin", "acos", "atan", "ln", "log2", "log10", "exp", "abs",
"ceil", "floor", "round",
];
fn should_eval(query: &str) -> bool {
let q = query.strip_prefix('=').unwrap_or(query);
q.chars()
.next()
.map(|c| c.is_ascii_digit() || c == '(' || c == '-')
.unwrap_or(false)
|| query.starts_with('=')
|| MATH_FNS.iter().any(|f| q.starts_with(f))
}
static MATH_CTX: LazyLock<evalexpr::HashMapContext<evalexpr::DefaultNumericTypes>> = LazyLock::new(
|| {
use evalexpr::*;
context_map! {
"pi" => float std::f64::consts::PI,
"e" => float std::f64::consts::E,
"sqrt" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.sqrt()))),
"sin" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.sin()))),
"cos" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.cos()))),
"tan" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.tan()))),
"asin" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.asin()))),
"acos" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.acos()))),
"atan" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.atan()))),
"ln" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.ln()))),
"log2" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.log2()))),
"log10" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.log10()))),
"exp" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.exp()))),
"abs" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.abs()))),
"ceil" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.ceil()))),
"floor" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.floor()))),
"round" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.round())))
}
.expect("static math context must be valid")
},
);
#[async_trait]
impl Plugin for CalcPlugin {
fn name(&self) -> &str {
"calc"
}
async fn search(&self, query: &str) -> Vec<SearchResult> {
if !should_eval(query) {
return vec![];
}
let raw = query.strip_prefix('=').unwrap_or(query);
let expr_owned = strip_numeric_separators(raw);
let expr = expr_owned.as_str();
match eval_number_with_context(expr, &*MATH_CTX) {
Ok(n) if n.is_finite() => {
let value_str = if n.fract() == 0.0 {
format!("{}", n as i64)
} else {
format!("{n}")
};
let display = format!("= {value_str}");
vec![SearchResult {
id: ResultId::new("calc-result"),
title: ResultTitle::new(display),
description: Some(format!("{expr_owned} · Enter to copy")),
icon: None,
score: Score::new(90),
action: LaunchAction::CopyToClipboard(value_str),
}]
}
_ => vec![],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn calc_valid_expr() {
let p = CalcPlugin::new();
let results = p.search("2+2").await;
assert_eq!(results[0].title.as_str(), "= 4");
}
#[tokio::test]
async fn calc_non_numeric_returns_empty() {
let p = CalcPlugin::new();
assert!(p.search("firefox").await.is_empty());
}
#[tokio::test]
async fn calc_bad_expr_returns_empty() {
let p = CalcPlugin::new();
assert!(p.search("1/0").await.is_empty());
}
#[tokio::test]
async fn calc_sqrt() {
let p = CalcPlugin::new();
let results = p.search("sqrt(9)").await;
assert_eq!(results[0].title.as_str(), "= 3");
}
#[tokio::test]
async fn calc_sin_pi() {
let p = CalcPlugin::new();
let results = p.search("sin(pi)").await;
assert!(!results.is_empty());
let title = results[0].title.as_str();
let val: f64 = title.trim_start_matches("= ").parse().unwrap();
assert!(val.abs() < 1e-10, "sin(pi) should be near zero, got {val}");
}
#[tokio::test]
async fn calc_underscore_separator() {
let p = CalcPlugin::new();
let results = p.search("1_000 * 2").await;
assert_eq!(results[0].title.as_str(), "= 2000");
assert_eq!(
results[0].description.as_deref(),
Some("1000 * 2 · Enter to copy")
);
assert!(matches!(
&results[0].action,
LaunchAction::CopyToClipboard(v) if v == "2000"
));
}
}
pub use plugin::*;

View File

@@ -0,0 +1,59 @@
use std::sync::Arc;
use async_trait::async_trait;
use evalexpr::eval_number_with_context;
use k_launcher_domain::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
use crate::eval::{MATH_CTX, should_eval, strip_numeric_separators};
const RESULT_ID: &str = "calc-result";
const RESULT_SCORE: u32 = 90;
pub struct CalcPlugin;
impl CalcPlugin {
pub fn new() -> Self {
Self
}
}
impl Default for CalcPlugin {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Plugin for CalcPlugin {
fn name(&self) -> &str {
"calc"
}
async fn search(&self, query: &str) -> Vec<SearchResult> {
if !should_eval(query) {
return vec![];
}
let raw = query.strip_prefix('=').unwrap_or(query);
let expr_owned = strip_numeric_separators(raw);
let expr = expr_owned.as_str();
match eval_number_with_context(expr, &*MATH_CTX) {
Ok(n) if n.is_finite() => {
let value_str = if n.fract() == 0.0 {
format!("{}", n as i64)
} else {
format!("{n}")
};
let display = format!("= {value_str}");
vec![SearchResult {
id: ResultId::new(RESULT_ID),
title: ResultTitle::new(display),
description: Some(Arc::from(format!("{expr_owned} · Enter to copy"))),
icon: None,
score: Score::new(RESULT_SCORE),
action: LaunchAction::CopyToClipboard(value_str),
}]
}
_ => vec![],
}
}
}

View File

@@ -0,0 +1,53 @@
use k_launcher_domain::{LaunchAction, Plugin};
use plugin_calc::CalcPlugin;
#[tokio::test]
async fn calc_valid_expr() {
let p = CalcPlugin::new();
let results = p.search("2+2").await;
assert_eq!(results[0].title.as_str(), "= 4");
}
#[tokio::test]
async fn calc_non_numeric_returns_empty() {
let p = CalcPlugin::new();
assert!(p.search("firefox").await.is_empty());
}
#[tokio::test]
async fn calc_bad_expr_returns_empty() {
let p = CalcPlugin::new();
assert!(p.search("1/0").await.is_empty());
}
#[tokio::test]
async fn calc_sqrt() {
let p = CalcPlugin::new();
let results = p.search("sqrt(9)").await;
assert_eq!(results[0].title.as_str(), "= 3");
}
#[tokio::test]
async fn calc_sin_pi() {
let p = CalcPlugin::new();
let results = p.search("sin(pi)").await;
assert!(!results.is_empty());
let title = results[0].title.as_str();
let val: f64 = title.trim_start_matches("= ").parse().unwrap();
assert!(val.abs() < 1e-10, "sin(pi) should be near zero, got {val}");
}
#[tokio::test]
async fn calc_underscore_separator() {
let p = CalcPlugin::new();
let results = p.search("1_000 * 2").await;
assert_eq!(results[0].title.as_str(), "= 2000");
assert_eq!(
results[0].description.as_deref(),
Some("1000 * 2 · Enter to copy")
);
assert!(matches!(
&results[0].action,
LaunchAction::CopyToClipboard(v) if v == "2000"
));
}

View File

@@ -1,6 +1,6 @@
[package]
name = "plugin-cmd"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
[lib]
@@ -9,7 +9,8 @@ path = "src/lib.rs"
[dependencies]
async-trait = { workspace = true }
k-launcher-kernel = { path = "../../k-launcher-kernel" }
k-launcher-domain = { workspace = true }
[dev-dependencies]
k-launcher-domain = { workspace = true }
tokio = { workspace = true }

View File

@@ -1,5 +1,8 @@
use async_trait::async_trait;
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
use k_launcher_domain::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
const CMD_PREFIX: char = '>';
const RESULT_SCORE: u32 = 95;
pub struct CmdPlugin;
@@ -22,7 +25,7 @@ impl Plugin for CmdPlugin {
}
async fn search(&self, query: &str) -> Vec<SearchResult> {
let Some(rest) = query.strip_prefix('>') else {
let Some(rest) = query.strip_prefix(CMD_PREFIX) else {
return vec![];
};
let cmd = rest.trim();
@@ -34,36 +37,8 @@ impl Plugin for CmdPlugin {
title: ResultTitle::new(format!("Run: {cmd}")),
description: None,
icon: None,
score: Score::new(95),
score: Score::new(RESULT_SCORE),
action: LaunchAction::SpawnInTerminal(cmd.to_string()),
}]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn cmd_prefix_triggers() {
let p = CmdPlugin::new();
let results = p.search("> echo hello").await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].title.as_str(), "Run: echo hello");
assert_eq!(results[0].score.value(), 95);
}
#[tokio::test]
async fn cmd_empty_remainder_returns_empty() {
let p = CmdPlugin::new();
assert!(p.search(">").await.is_empty());
assert!(p.search("> ").await.is_empty());
}
#[tokio::test]
async fn cmd_no_prefix_returns_empty() {
let p = CmdPlugin::new();
assert!(p.search("echo hello").await.is_empty());
assert!(p.search("firefox").await.is_empty());
}
}

View File

@@ -0,0 +1,25 @@
use k_launcher_domain::Plugin;
use plugin_cmd::CmdPlugin;
#[tokio::test]
async fn cmd_prefix_triggers() {
let p = CmdPlugin::new();
let results = p.search("> echo hello").await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].title.as_str(), "Run: echo hello");
assert_eq!(results[0].score.value(), 95);
}
#[tokio::test]
async fn cmd_empty_remainder_returns_empty() {
let p = CmdPlugin::new();
assert!(p.search(">").await.is_empty());
assert!(p.search("> ").await.is_empty());
}
#[tokio::test]
async fn cmd_no_prefix_returns_empty() {
let p = CmdPlugin::new();
assert!(p.search("echo hello").await.is_empty());
assert!(p.search("firefox").await.is_empty());
}

View File

@@ -1,6 +1,6 @@
[package]
name = "plugin-files"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
[lib]
@@ -9,5 +9,9 @@ path = "src/lib.rs"
[dependencies]
async-trait = { workspace = true }
k-launcher-kernel = { path = "../../k-launcher-kernel" }
k-launcher-domain = { workspace = true }
tokio = { workspace = true }
[dev-dependencies]
k-launcher-domain = { workspace = true }
tokio = { workspace = true }

View File

@@ -2,8 +2,13 @@ mod platform;
use std::path::Path;
use std::sync::Arc;
use async_trait::async_trait;
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
use k_launcher_domain::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
const MAX_FILE_RESULTS: usize = 20;
const RESULT_SCORE: u32 = 50;
pub struct FilesPlugin;
@@ -70,7 +75,7 @@ impl Plugin for FilesPlugin {
.map(|n| n.to_lowercase().starts_with(&prefix))
.unwrap_or(false)
})
.take(20)
.take(MAX_FILE_RESULTS)
.map(|entry| {
let full_path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
@@ -80,30 +85,12 @@ impl Plugin for FilesPlugin {
SearchResult {
id: ResultId::new(&path_str),
title: ResultTitle::new(title),
description: Some(path_str.clone()),
description: Some(Arc::from(path_str.as_str())),
icon: None,
score: Score::new(50),
score: Score::new(RESULT_SCORE),
action: LaunchAction::OpenPath(path_str),
}
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn files_ignores_non_path_query() {
let p = FilesPlugin::new();
assert!(p.search("firefox").await.is_empty());
}
#[tokio::test]
async fn files_handles_root() {
let p = FilesPlugin::new();
let results = p.search("/").await;
assert!(!results.is_empty());
}
}

View File

@@ -0,0 +1,15 @@
use k_launcher_domain::Plugin;
use plugin_files::FilesPlugin;
#[tokio::test]
async fn files_ignores_non_path_query() {
let p = FilesPlugin::new();
assert!(p.search("firefox").await.is_empty());
}
#[tokio::test]
async fn files_handles_root() {
let p = FilesPlugin::new();
let results = p.search("/").await;
assert!(!results.is_empty());
}

View File

@@ -1,8 +1,12 @@
[package]
name = "plugin-url"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
[lib]
name = "plugin_url"
path = "src/lib.rs"
[[bin]]
name = "k-launcher-plugin-url"
path = "src/main.rs"
@@ -10,3 +14,6 @@ path = "src/main.rs"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }

View File

@@ -0,0 +1,60 @@
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
pub struct Query {
pub query: String,
}
#[derive(Serialize)]
pub struct Action {
pub r#type: &'static str,
pub path: String,
}
#[derive(Serialize)]
pub struct UrlResult {
pub id: &'static str,
pub title: &'static str,
pub description: String,
pub score: u32,
pub action: Action,
}
const HTTP_PREFIX: &str = "http://";
const HTTPS_PREFIX: &str = "https://";
const WWW_PREFIX: &str = "www.";
const RESULT_ID: &str = "url-open";
const RESULT_TITLE: &str = "Open in Browser";
const RESULT_SCORE: u32 = 95;
const ACTION_TYPE: &str = "OpenPath";
pub fn is_url(query: &str) -> bool {
query.starts_with(HTTP_PREFIX)
|| query.starts_with(HTTPS_PREFIX)
|| query.starts_with(WWW_PREFIX)
}
pub fn normalize(query: &str) -> String {
if query.starts_with(WWW_PREFIX) {
format!("{HTTPS_PREFIX}{query}")
} else {
query.to_string()
}
}
pub fn search(query: &str) -> Vec<UrlResult> {
if !is_url(query) {
return vec![];
}
let url = normalize(query);
vec![UrlResult {
id: RESULT_ID,
title: RESULT_TITLE,
description: url.clone(),
score: RESULT_SCORE,
action: Action {
r#type: ACTION_TYPE,
path: url,
},
}]
}

View File

@@ -1,55 +1,6 @@
use std::io::{self, BufRead, Write};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct Query {
query: String,
}
#[derive(Serialize)]
struct Action {
r#type: &'static str,
path: String,
}
#[derive(Serialize)]
struct Result {
id: &'static str,
title: &'static str,
description: String,
score: u32,
action: Action,
}
fn is_url(query: &str) -> bool {
query.starts_with("http://") || query.starts_with("https://") || query.starts_with("www.")
}
fn normalize(query: &str) -> String {
if query.starts_with("www.") {
format!("https://{query}")
} else {
query.to_string()
}
}
fn search(query: &str) -> Vec<Result> {
if !is_url(query) {
return vec![];
}
let url = normalize(query);
vec![Result {
id: "url-open",
title: "Open in Browser",
description: url.clone(),
score: 95,
action: Action {
r#type: "OpenPath",
path: url.clone(),
},
}]
}
use plugin_url::{Query, search};
fn main() -> io::Result<()> {
let stdin = io::stdin();
@@ -68,63 +19,3 @@ fn main() -> io::Result<()> {
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_url_https() {
assert!(is_url("https://example.com"));
}
#[test]
fn is_url_http() {
assert!(is_url("http://example.com"));
}
#[test]
fn is_url_www() {
assert!(is_url("www.foo.com"));
}
#[test]
fn is_url_plain() {
assert!(!is_url("firefox"));
}
#[test]
fn is_url_empty() {
assert!(!is_url(""));
}
#[test]
fn normalize_www() {
assert_eq!(normalize("www.foo.com"), "https://www.foo.com");
}
#[test]
fn normalize_https() {
assert_eq!(normalize("https://example.com"), "https://example.com");
}
#[test]
fn search_returns_result() {
let results = search("https://example.com");
assert_eq!(results.len(), 1);
assert_eq!(results[0].action.path, "https://example.com");
}
#[test]
fn search_returns_empty() {
assert!(search("firefox").is_empty());
}
#[test]
fn result_serializes() {
let results = search("https://example.com");
let json = serde_json::to_string(&results).unwrap();
assert!(json.contains("OpenPath"));
assert!(json.contains("https://example.com"));
}
}

View File

@@ -0,0 +1,56 @@
use plugin_url::{is_url, normalize, search};
#[test]
fn is_url_https() {
assert!(is_url("https://example.com"));
}
#[test]
fn is_url_http() {
assert!(is_url("http://example.com"));
}
#[test]
fn is_url_www() {
assert!(is_url("www.foo.com"));
}
#[test]
fn is_url_plain() {
assert!(!is_url("firefox"));
}
#[test]
fn is_url_empty() {
assert!(!is_url(""));
}
#[test]
fn normalize_www() {
assert_eq!(normalize("www.foo.com"), "https://www.foo.com");
}
#[test]
fn normalize_https() {
assert_eq!(normalize("https://example.com"), "https://example.com");
}
#[test]
fn search_returns_result() {
let results = search("https://example.com");
assert_eq!(results.len(), 1);
assert_eq!(results[0].action.path, "https://example.com");
}
#[test]
fn search_returns_empty() {
assert!(search("firefox").is_empty());
}
#[test]
fn result_serializes() {
let results = search("https://example.com");
let json = serde_json::to_string(&results).unwrap();
assert!(json.contains("OpenPath"));
assert!(json.contains("https://example.com"));
}