Compare commits
10 Commits
c059961854
...
b8a9a6b02f
| Author | SHA1 | Date | |
|---|---|---|---|
| b8a9a6b02f | |||
| 5bb5c8f531 | |||
| fe46b7808a | |||
| 3093bc9124 | |||
| 3098a4be7c | |||
| bc7c896519 | |||
| 1a2de21bf6 | |||
| 93736ae19d | |||
| dbce15bfd5 | |||
| f5dd303b79 |
1101
Cargo.lock
generated
1101
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,24 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"crates/k-launcher",
|
"crates/k-launcher",
|
||||||
|
"crates/k-launcher-config",
|
||||||
"crates/k-launcher-kernel",
|
"crates/k-launcher-kernel",
|
||||||
"crates/k-launcher-os-bridge",
|
"crates/k-launcher-os-bridge",
|
||||||
"crates/k-launcher-ui",
|
"crates/k-launcher-ui",
|
||||||
"crates/plugins/plugin-apps",
|
"crates/plugins/plugin-apps",
|
||||||
"crates/plugins/plugin-calc",
|
"crates/plugins/plugin-calc",
|
||||||
|
"crates/plugins/plugin-cmd",
|
||||||
"crates/plugins/plugin-files",
|
"crates/plugins/plugin-files",
|
||||||
|
"crates/k-launcher-ui-egui",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
dirs = "5.0"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
iced = { version = "0.14", features = ["image", "svg", "tokio", "tiny-skia"] }
|
iced = { version = "0.14", features = ["image", "svg", "tokio", "tiny-skia"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
tokio = { version = "1.35", features = ["rt-multi-thread", "macros"] }
|
tokio = { version = "1.35", features = ["rt-multi-thread", "macros"] }
|
||||||
|
toml = "0.8"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
|||||||
44
Makefile
44
Makefile
@@ -0,0 +1,44 @@
|
|||||||
|
.PHONY: build build-egui dev check clippy fmt fmt-check test run run-egui install install-egui clean
|
||||||
|
|
||||||
|
RELEASE_BIN := target/release/k-launcher
|
||||||
|
EGUI_BIN := target/release/k-launcher-egui
|
||||||
|
INSTALL_DIR := $(HOME)/.local/bin
|
||||||
|
|
||||||
|
build:
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
build-egui:
|
||||||
|
cargo build --release -p k-launcher --features egui --bin k-launcher-egui
|
||||||
|
|
||||||
|
dev:
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
check:
|
||||||
|
cargo check --workspace
|
||||||
|
|
||||||
|
clippy:
|
||||||
|
cargo clippy --workspace -- -D warnings
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
cargo fmt --all
|
||||||
|
|
||||||
|
fmt-check:
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
|
||||||
|
test:
|
||||||
|
cargo test --workspace
|
||||||
|
|
||||||
|
run:
|
||||||
|
cargo run --release
|
||||||
|
|
||||||
|
run-egui:
|
||||||
|
cargo run --release -p k-launcher --features egui --bin k-launcher-egui
|
||||||
|
|
||||||
|
install: build
|
||||||
|
install -Dm755 $(RELEASE_BIN) $(INSTALL_DIR)/k-launcher
|
||||||
|
|
||||||
|
install-egui: build-egui
|
||||||
|
install -Dm755 $(EGUI_BIN) $(INSTALL_DIR)/k-launcher-egui
|
||||||
|
|
||||||
|
clean:
|
||||||
|
cargo clean
|
||||||
|
|||||||
81
README.md
81
README.md
@@ -1,56 +1,41 @@
|
|||||||
# K-Launcher
|
# k-launcher
|
||||||
|
|
||||||
K-Launcher is a lightweight, GPU-accelerated command palette for Linux (Wayland/X11), macOS, and Windows. It reimagines the "Spotlight" experience through the lens of Frutiger Aero—focusing on gloss, glass, and skeuomorphism—powered by a non-blocking, multi-threaded Rust kernel.
|
A lightweight, GPU-accelerated command palette for Linux (Wayland/X11). Zero Electron — every pixel rendered via WGPU. Async search that never blocks the UI.
|
||||||
|
|
||||||
## Core Philosophy
|
|
||||||
|
|
||||||
- Zero Webview: No Chromium, no Electron. Every pixel is rendered via WGPU (Iced) for sub-5ms input-to-render latency.
|
|
||||||
|
|
||||||
- Async-First: Search queries never block the UI. If the file-searcher is indexing, the calculator still feels instant.
|
|
||||||
|
|
||||||
- The "Aero" Standard: Deep support for Gaussian blur (via Layer Shell), linear gradients, and high-gloss textures.
|
|
||||||
|
|
||||||
## High-Level Architecture
|
|
||||||
|
|
||||||
We are utilizing a "Hub-and-Spoke" model within a Cargo Workspace. The k-launcher-kernel acts as the central hub, dispatching user input to various "Spokes" (Plugins).
|
|
||||||
|
|
||||||
### The Crate Hierarchy
|
|
||||||
|
|
||||||
| Crate | Responsibility | Key Dependencies |
|
|
||||||
| -------------------------- | --------------------------------------------------------- | -------------------------------------- |
|
|
||||||
| **`k-launcher`** | The entry-point binary. Glues everything together. | `k-launcher-ui`, `k-launcher-kernel` |
|
|
||||||
| **`k-launcher-ui`** | The Iced-based view layer. Handles animations/theming. | `iced`, `lyon` (for vector paths) |
|
|
||||||
| **`k-launcher-kernel`** | The "Brain." Manages state, history, and plugin dispatch. | `tokio`, `tracing` |
|
|
||||||
| **`k-launcher-os-bridge`** | OS-specific windowing (Layer Shell for Wayland, Win32). | `iced_layershell`, `raw-window-handle` |
|
|
||||||
| **`plugins/*`** | Individual features (Calc, Files, Apps, Web). | `plugin-api` (Shared traits) |
|
|
||||||
|
|
||||||
## Data & Communication Flow
|
|
||||||
|
|
||||||
K-Launcher operates on an Event loop.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
sequenceDiagram
|
[screenshot placeholder]
|
||||||
participant User
|
|
||||||
participant UI as k-launcher-ui
|
|
||||||
participant Kernel as k-launcher-kernel
|
|
||||||
participant Plugins as plugin-file-search
|
|
||||||
|
|
||||||
User->>UI: Types "p"
|
|
||||||
UI->>Kernel: QueryUpdate("p")
|
|
||||||
par Parallel Search
|
|
||||||
Kernel->>Plugins: async search("p")
|
|
||||||
Plugins-->>Kernel: List<SearchResult>
|
|
||||||
end
|
|
||||||
Kernel->>UI: NewResults(Vec)
|
|
||||||
UI-->>User: Render Glass Result List
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Technical Specifications
|
## Quick Start
|
||||||
|
|
||||||
To ensure "Plug and Play" capability, all features must implement the `Plugin` trait. This allows the user to swap the default `file-searcher` for something like `fzf` or `plocate` without recompiling the UI.
|
```bash
|
||||||
|
git clone https://github.com/GKaszewski/k-launcher
|
||||||
|
cd k-launcher
|
||||||
|
cargo build --release
|
||||||
|
./target/release/k-launcher
|
||||||
|
```
|
||||||
|
|
||||||
To achieve the 2000s aesthetic without a browser:
|
## Keybinds
|
||||||
|
|
||||||
- Background Blur: On Wayland, we request blur through the org_kde_kwin_blur or fractional-scale protocols.
|
| Key | Action |
|
||||||
- Shaders: We will use Iced’s canvas to draw glossy "shine" overlays that respond to mouse hovering.
|
| --------- | --------------- |
|
||||||
- Icons: We will prefer .svg and .png with high-depth shadows over flat icon fonts.
|
| Type | Filter results |
|
||||||
|
| `↑` / `↓` | Navigate |
|
||||||
|
| `Enter` | Launch selected |
|
||||||
|
| `Escape` | Close |
|
||||||
|
|
||||||
|
## Built-in Plugins
|
||||||
|
|
||||||
|
| Trigger | Plugin | Example |
|
||||||
|
| ----------------- | ------ | -------------- |
|
||||||
|
| (any text) | Apps | `firefox` |
|
||||||
|
| number/expression | Calc | `2^10 + 5` |
|
||||||
|
| `>` prefix | Shell | `> echo hello` |
|
||||||
|
| `/` or `~/` | Files | `~/Documents` |
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
- [Installation](docs/install.md)
|
||||||
|
- [Usage & Keybinds](docs/usage.md)
|
||||||
|
- [Configuration & Theming](docs/configuration.md)
|
||||||
|
- [Plugin Development](docs/plugin-development.md)
|
||||||
|
|||||||
13
crates/k-launcher-config/Cargo.toml
Normal file
13
crates/k-launcher-config/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "k-launcher-config"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "k_launcher_config"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
dirs = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
toml = { workspace = true }
|
||||||
182
crates/k-launcher-config/src/lib.rs
Normal file
182
crates/k-launcher-config/src/lib.rs
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
// RGBA: [r, g, b, a] where r/g/b are 0–255 as f32, a is 0.0–1.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)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct PluginsCfg {
|
||||||
|
pub calc: bool,
|
||||||
|
pub cmd: bool,
|
||||||
|
pub files: bool,
|
||||||
|
pub apps: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PluginsCfg {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
calc: true,
|
||||||
|
cmd: true,
|
||||||
|
files: true,
|
||||||
|
apps: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,22 @@ impl Score {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- LaunchAction (port) ---
|
||||||
|
|
||||||
|
pub enum LaunchAction {
|
||||||
|
SpawnProcess(String),
|
||||||
|
SpawnInTerminal(String),
|
||||||
|
OpenPath(String),
|
||||||
|
CopyToClipboard(String),
|
||||||
|
Custom(Arc<dyn Fn() + Send + Sync>),
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- AppLauncher port trait ---
|
||||||
|
|
||||||
|
pub trait AppLauncher: Send + Sync {
|
||||||
|
fn execute(&self, action: &LaunchAction);
|
||||||
|
}
|
||||||
|
|
||||||
// --- SearchResult ---
|
// --- SearchResult ---
|
||||||
|
|
||||||
pub struct SearchResult {
|
pub struct SearchResult {
|
||||||
@@ -51,7 +67,8 @@ pub struct SearchResult {
|
|||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub icon: Option<String>,
|
pub icon: Option<String>,
|
||||||
pub score: Score,
|
pub score: Score,
|
||||||
pub on_execute: Arc<dyn Fn() + Send + Sync>,
|
pub action: LaunchAction,
|
||||||
|
pub on_select: Option<Arc<dyn Fn() + Send + Sync>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for SearchResult {
|
impl std::fmt::Debug for SearchResult {
|
||||||
@@ -73,15 +90,23 @@ pub trait Plugin: Send + Sync {
|
|||||||
async fn search(&self, query: &str) -> Vec<SearchResult>;
|
async fn search(&self, query: &str) -> Vec<SearchResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- SearchEngine port trait ---
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait SearchEngine: Send + Sync {
|
||||||
|
async fn search(&self, query: &str) -> Vec<SearchResult>;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Kernel (Application use case) ---
|
// --- Kernel (Application use case) ---
|
||||||
|
|
||||||
pub struct Kernel {
|
pub struct Kernel {
|
||||||
plugins: Vec<Arc<dyn Plugin>>,
|
plugins: Vec<Arc<dyn Plugin>>,
|
||||||
|
max_results: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Kernel {
|
impl Kernel {
|
||||||
pub fn new(plugins: Vec<Arc<dyn Plugin>>) -> Self {
|
pub fn new(plugins: Vec<Arc<dyn Plugin>>, max_results: usize) -> Self {
|
||||||
Self { plugins }
|
Self { plugins, max_results }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn search(&self, query: &str) -> Vec<SearchResult> {
|
pub async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||||
@@ -89,10 +114,18 @@ impl Kernel {
|
|||||||
let nested: Vec<Vec<SearchResult>> = join_all(futures).await;
|
let nested: Vec<Vec<SearchResult>> = join_all(futures).await;
|
||||||
let mut flat: Vec<SearchResult> = nested.into_iter().flatten().collect();
|
let mut flat: Vec<SearchResult> = nested.into_iter().flatten().collect();
|
||||||
flat.sort_by(|a, b| b.score.cmp(&a.score));
|
flat.sort_by(|a, b| b.score.cmp(&a.score));
|
||||||
|
flat.truncate(self.max_results);
|
||||||
flat
|
flat
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SearchEngine for Kernel {
|
||||||
|
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||||
|
self.search(query).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Tests ---
|
// --- Tests ---
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -125,7 +158,8 @@ mod tests {
|
|||||||
description: None,
|
description: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
score: Score::new(*score),
|
score: Score::new(*score),
|
||||||
on_execute: Arc::new(|| {}),
|
action: LaunchAction::Custom(Arc::new(|| {})),
|
||||||
|
on_select: None,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -148,7 +182,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn empty_kernel_returns_empty() {
|
async fn empty_kernel_returns_empty() {
|
||||||
let k = Kernel::new(vec![]);
|
let k = Kernel::new(vec![], 8);
|
||||||
assert!(k.search("x").await.is_empty());
|
assert!(k.search("x").await.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,10 +193,26 @@ mod tests {
|
|||||||
("higher", 10),
|
("higher", 10),
|
||||||
("middle", 7),
|
("middle", 7),
|
||||||
]));
|
]));
|
||||||
let k = Kernel::new(vec![plugin]);
|
let k = Kernel::new(vec![plugin], 8);
|
||||||
let results = k.search("q").await;
|
let results = k.search("q").await;
|
||||||
assert_eq!(results[0].score.value(), 10);
|
assert_eq!(results[0].score.value(), 10);
|
||||||
assert_eq!(results[1].score.value(), 7);
|
assert_eq!(results[1].score.value(), 7);
|
||||||
assert_eq!(results[2].score.value(), 5);
|
assert_eq!(results[2].score.value(), 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,6 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
k-launcher-config = { path = "../k-launcher-config" }
|
||||||
|
k-launcher-kernel = { path = "../k-launcher-kernel" }
|
||||||
|
libc = "0.2"
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
/// Configuration for the launcher window.
|
mod unix_launcher;
|
||||||
|
|
||||||
|
pub use unix_launcher::UnixAppLauncher;
|
||||||
|
|
||||||
pub struct WindowConfig {
|
pub struct WindowConfig {
|
||||||
pub width: f32,
|
pub width: f32,
|
||||||
pub height: f32,
|
pub height: f32,
|
||||||
@@ -8,13 +11,13 @@ pub struct WindowConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl WindowConfig {
|
impl WindowConfig {
|
||||||
pub fn launcher() -> Self {
|
pub fn from_cfg(w: &k_launcher_config::WindowCfg) -> Self {
|
||||||
Self {
|
Self {
|
||||||
width: 600.0,
|
width: w.width,
|
||||||
height: 400.0,
|
height: w.height,
|
||||||
decorations: false,
|
decorations: w.decorations,
|
||||||
transparent: true,
|
transparent: w.transparent,
|
||||||
resizable: false,
|
resizable: w.resizable,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
117
crates/k-launcher-os-bridge/src/unix_launcher.rs
Normal file
117
crates/k-launcher-os-bridge/src/unix_launcher.rs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
|
||||||
|
use k_launcher_kernel::{AppLauncher, LaunchAction};
|
||||||
|
|
||||||
|
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: Vec<&str> = cmd.split_whitespace().collect();
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchAction::Custom(f) => f(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
crates/k-launcher-ui-egui/Cargo.toml
Normal file
16
crates/k-launcher-ui-egui/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "k-launcher-ui-egui"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "k_launcher_ui_egui"
|
||||||
|
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-os-bridge = { path = "../k-launcher-os-bridge" }
|
||||||
|
tokio = { workspace = true }
|
||||||
180
crates/k-launcher-ui-egui/src/app.rs
Normal file
180
crates/k-launcher-ui-egui/src/app.rs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
use std::sync::{Arc, mpsc};
|
||||||
|
|
||||||
|
use egui::{Color32, Key, ViewportCommand};
|
||||||
|
use k_launcher_kernel::{AppLauncher, SearchEngine, SearchResult};
|
||||||
|
use k_launcher_os_bridge::WindowConfig;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
pub struct KLauncherApp {
|
||||||
|
engine: Arc<dyn SearchEngine>,
|
||||||
|
launcher: Arc<dyn AppLauncher>,
|
||||||
|
query: String,
|
||||||
|
results: Vec<SearchResult>,
|
||||||
|
selected: usize,
|
||||||
|
rt: tokio::runtime::Handle,
|
||||||
|
result_tx: mpsc::SyncSender<Vec<SearchResult>>,
|
||||||
|
result_rx: mpsc::Receiver<Vec<SearchResult>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KLauncherApp {
|
||||||
|
fn new(
|
||||||
|
engine: Arc<dyn SearchEngine>,
|
||||||
|
launcher: Arc<dyn AppLauncher>,
|
||||||
|
rt: tokio::runtime::Handle,
|
||||||
|
) -> Self {
|
||||||
|
let (result_tx, result_rx) = mpsc::sync_channel(4);
|
||||||
|
Self {
|
||||||
|
engine,
|
||||||
|
launcher,
|
||||||
|
query: String::new(),
|
||||||
|
results: vec![],
|
||||||
|
selected: 0,
|
||||||
|
rt,
|
||||||
|
result_tx,
|
||||||
|
result_rx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trigger_search(&self, query: String) {
|
||||||
|
let engine = self.engine.clone();
|
||||||
|
let tx = self.result_tx.clone();
|
||||||
|
self.rt.spawn(async move {
|
||||||
|
let results = engine.search(&query).await;
|
||||||
|
let _ = tx.send(results);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if let Some(on_select) = &result.on_select {
|
||||||
|
on_select();
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(
|
||||||
|
engine: Arc<dyn SearchEngine>,
|
||||||
|
launcher: Arc<dyn AppLauncher>,
|
||||||
|
) -> Result<(), eframe::Error> {
|
||||||
|
let wc = WindowConfig::from_cfg(&k_launcher_config::WindowCfg::default());
|
||||||
|
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
|
||||||
|
let handle = rt.handle().clone();
|
||||||
|
|
||||||
|
let options = eframe::NativeOptions {
|
||||||
|
viewport: egui::ViewportBuilder::default()
|
||||||
|
.with_inner_size([wc.width, wc.height])
|
||||||
|
.with_decorations(wc.decorations)
|
||||||
|
.with_transparent(wc.transparent)
|
||||||
|
.with_resizable(wc.resizable)
|
||||||
|
.with_always_on_top(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
eframe::run_native(
|
||||||
|
"K-Launcher",
|
||||||
|
options,
|
||||||
|
Box::new(move |_cc| Ok(Box::new(KLauncherApp::new(engine, launcher, handle)))),
|
||||||
|
)
|
||||||
|
}
|
||||||
12
crates/k-launcher-ui-egui/src/lib.rs
Normal file
12
crates/k-launcher-ui-egui/src/lib.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
mod app;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use k_launcher_kernel::{AppLauncher, SearchEngine};
|
||||||
|
|
||||||
|
pub fn run(
|
||||||
|
engine: Arc<dyn SearchEngine>,
|
||||||
|
launcher: Arc<dyn AppLauncher>,
|
||||||
|
) -> Result<(), eframe::Error> {
|
||||||
|
app::run(engine, launcher)
|
||||||
|
}
|
||||||
@@ -9,5 +9,7 @@ path = "src/lib.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
iced = { workspace = true }
|
iced = { workspace = true }
|
||||||
|
k-launcher-config = { path = "../k-launcher-config" }
|
||||||
k-launcher-kernel = { path = "../k-launcher-kernel" }
|
k-launcher-kernel = { path = "../k-launcher-kernel" }
|
||||||
|
k-launcher-os-bridge = { path = "../k-launcher-os-bridge" }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -1,34 +1,46 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use iced::{
|
use iced::{
|
||||||
Color, Element, Length, Size, Subscription, Task,
|
Border, Color, Element, Length, Size, Subscription, Task,
|
||||||
event,
|
event,
|
||||||
keyboard::{Event as KeyEvent, Key, key::Named},
|
keyboard::{Event as KeyEvent, Key, key::Named},
|
||||||
widget::{column, container, image, row, scrollable, svg, text, text_input, Space},
|
widget::{column, container, image, row, scrollable, svg, text, text_input, Space},
|
||||||
window,
|
window,
|
||||||
};
|
};
|
||||||
|
|
||||||
use k_launcher_kernel::{Kernel, SearchResult};
|
use k_launcher_config::AppearanceCfg;
|
||||||
|
use k_launcher_kernel::{AppLauncher, SearchEngine, SearchResult};
|
||||||
use crate::theme;
|
use k_launcher_os_bridge::WindowConfig;
|
||||||
|
|
||||||
static INPUT_ID: std::sync::LazyLock<iced::widget::Id> =
|
static INPUT_ID: std::sync::LazyLock<iced::widget::Id> =
|
||||||
std::sync::LazyLock::new(|| iced::widget::Id::new("search"));
|
std::sync::LazyLock::new(|| iced::widget::Id::new("search"));
|
||||||
|
|
||||||
|
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 {
|
pub struct KLauncherApp {
|
||||||
kernel: Arc<Kernel>,
|
engine: Arc<dyn SearchEngine>,
|
||||||
|
launcher: Arc<dyn AppLauncher>,
|
||||||
query: String,
|
query: String,
|
||||||
results: Arc<Vec<SearchResult>>,
|
results: Arc<Vec<SearchResult>>,
|
||||||
selected: usize,
|
selected: usize,
|
||||||
|
cfg: AppearanceCfg,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KLauncherApp {
|
impl KLauncherApp {
|
||||||
fn new(kernel: Arc<Kernel>) -> Self {
|
fn new(
|
||||||
|
engine: Arc<dyn SearchEngine>,
|
||||||
|
launcher: Arc<dyn AppLauncher>,
|
||||||
|
cfg: AppearanceCfg,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
kernel,
|
engine,
|
||||||
|
launcher,
|
||||||
query: String::new(),
|
query: String::new(),
|
||||||
results: Arc::new(vec![]),
|
results: Arc::new(vec![]),
|
||||||
selected: 0,
|
selected: 0,
|
||||||
|
cfg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,9 +57,9 @@ fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
|
|||||||
Message::QueryChanged(q) => {
|
Message::QueryChanged(q) => {
|
||||||
state.query = q.clone();
|
state.query = q.clone();
|
||||||
state.selected = 0;
|
state.selected = 0;
|
||||||
let kernel = state.kernel.clone();
|
let engine = state.engine.clone();
|
||||||
Task::perform(
|
Task::perform(
|
||||||
async move { kernel.search(&q).await },
|
async move { engine.search(&q).await },
|
||||||
|results| Message::ResultsReady(Arc::new(results)),
|
|results| Message::ResultsReady(Arc::new(results)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -78,7 +90,10 @@ fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
|
|||||||
}
|
}
|
||||||
Named::Enter => {
|
Named::Enter => {
|
||||||
if let Some(result) = state.results.get(state.selected) {
|
if let Some(result) = state.results.get(state.selected) {
|
||||||
(result.on_execute)();
|
if let Some(on_select) = &result.on_select {
|
||||||
|
on_select();
|
||||||
|
}
|
||||||
|
state.launcher.execute(&result.action);
|
||||||
}
|
}
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
@@ -90,13 +105,23 @@ fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn view(state: &KLauncherApp) -> Element<'_, Message> {
|
fn view(state: &KLauncherApp) -> Element<'_, Message> {
|
||||||
let colors = &*theme::AERO;
|
let cfg = &state.cfg;
|
||||||
|
let border_color = rgba(&cfg.border_rgba);
|
||||||
|
|
||||||
let search_bar = text_input("Search...", &state.query)
|
let search_bar = text_input(&cfg.placeholder, &state.query)
|
||||||
.id(INPUT_ID.clone())
|
.id(INPUT_ID.clone())
|
||||||
.on_input(Message::QueryChanged)
|
.on_input(Message::QueryChanged)
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.size(18);
|
.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
|
let result_rows: Vec<Element<'_, Message>> = state
|
||||||
.results
|
.results
|
||||||
@@ -105,7 +130,7 @@ fn view(state: &KLauncherApp) -> Element<'_, Message> {
|
|||||||
.map(|(i, result)| {
|
.map(|(i, result)| {
|
||||||
let is_selected = i == state.selected;
|
let is_selected = i == state.selected;
|
||||||
let bg_color = if is_selected {
|
let bg_color = if is_selected {
|
||||||
colors.border_cyan
|
border_color
|
||||||
} else {
|
} else {
|
||||||
Color::from_rgba8(255, 255, 255, 0.07)
|
Color::from_rgba8(255, 255, 255, 0.07)
|
||||||
};
|
};
|
||||||
@@ -116,8 +141,17 @@ fn view(state: &KLauncherApp) -> Element<'_, Message> {
|
|||||||
image(image::Handle::from_path(p)).width(24).height(24).into(),
|
image(image::Handle::from_path(p)).width(24).height(24).into(),
|
||||||
None => Space::new().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(
|
container(
|
||||||
row![icon_el, text(result.title.as_str()).size(15)]
|
row![icon_el, title_col]
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
.align_y(iced::Center),
|
.align_y(iced::Center),
|
||||||
)
|
)
|
||||||
@@ -125,14 +159,32 @@ fn view(state: &KLauncherApp) -> Element<'_, Message> {
|
|||||||
.padding([6, 12])
|
.padding([6, 12])
|
||||||
.style(move |_theme| container::Style {
|
.style(move |_theme| container::Style {
|
||||||
background: Some(iced::Background::Color(bg_color)),
|
background: Some(iced::Background::Color(bg_color)),
|
||||||
|
border: Border {
|
||||||
|
color: Color::TRANSPARENT,
|
||||||
|
width: 0.0,
|
||||||
|
radius: row_radius.into(),
|
||||||
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.into()
|
.into()
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let results_list =
|
let results_list = if state.results.is_empty() && !state.query.is_empty() {
|
||||||
scrollable(column(result_rows).spacing(2).width(Length::Fill)).height(Length::Fill);
|
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 content = column![search_bar, results_list]
|
let content = column![search_bar, results_list]
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
@@ -140,13 +192,20 @@ fn view(state: &KLauncherApp) -> Element<'_, Message> {
|
|||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.height(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)
|
container(content)
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
.style(|_theme| container::Style {
|
.style(move |_theme| container::Style {
|
||||||
background: Some(iced::Background::Color(Color::from_rgba8(
|
background: Some(iced::Background::Color(bg_color)),
|
||||||
20, 20, 30, 0.9,
|
border: Border {
|
||||||
))),
|
color: border_color,
|
||||||
|
width: border_width,
|
||||||
|
radius: border_radius.into(),
|
||||||
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.into()
|
.into()
|
||||||
@@ -159,10 +218,16 @@ fn subscription(_state: &KLauncherApp) -> Subscription<Message> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(kernel: Arc<Kernel>) -> iced::Result {
|
pub fn run(
|
||||||
|
engine: Arc<dyn SearchEngine>,
|
||||||
|
launcher: Arc<dyn AppLauncher>,
|
||||||
|
window_cfg: &k_launcher_config::WindowCfg,
|
||||||
|
appearance_cfg: AppearanceCfg,
|
||||||
|
) -> iced::Result {
|
||||||
|
let wc = WindowConfig::from_cfg(window_cfg);
|
||||||
iced::application(
|
iced::application(
|
||||||
move || {
|
move || {
|
||||||
let app = KLauncherApp::new(kernel.clone());
|
let app = KLauncherApp::new(engine.clone(), launcher.clone(), appearance_cfg.clone());
|
||||||
let focus = iced::widget::operation::focus(INPUT_ID.clone());
|
let focus = iced::widget::operation::focus(INPUT_ID.clone());
|
||||||
(app, focus)
|
(app, focus)
|
||||||
},
|
},
|
||||||
@@ -172,11 +237,11 @@ pub fn run(kernel: Arc<Kernel>) -> iced::Result {
|
|||||||
.title("K-Launcher")
|
.title("K-Launcher")
|
||||||
.subscription(subscription)
|
.subscription(subscription)
|
||||||
.window(window::Settings {
|
.window(window::Settings {
|
||||||
size: Size::new(600.0, 400.0),
|
size: Size::new(wc.width, wc.height),
|
||||||
position: window::Position::Centered,
|
position: window::Position::Centered,
|
||||||
decorations: false,
|
decorations: wc.decorations,
|
||||||
transparent: true,
|
transparent: wc.transparent,
|
||||||
resizable: false,
|
resizable: wc.resizable,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.run()
|
.run()
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
mod app;
|
mod app;
|
||||||
pub mod theme;
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use k_launcher_kernel::Kernel;
|
use k_launcher_config::{AppearanceCfg, WindowCfg};
|
||||||
|
use k_launcher_kernel::{AppLauncher, SearchEngine};
|
||||||
|
|
||||||
|
pub fn run(
|
||||||
pub fn run(kernel: Arc<Kernel>) -> iced::Result {
|
engine: Arc<dyn SearchEngine>,
|
||||||
app::run(kernel)
|
launcher: Arc<dyn AppLauncher>,
|
||||||
|
window_cfg: &WindowCfg,
|
||||||
|
appearance_cfg: AppearanceCfg,
|
||||||
|
) -> iced::Result {
|
||||||
|
app::run(engine, launcher, window_cfg, appearance_cfg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
use iced::{
|
|
||||||
Color, Gradient,
|
|
||||||
gradient::{ColorStop, Linear},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct AeroColors {
|
|
||||||
pub glass_bg: Color,
|
|
||||||
pub gloss_highlight: Gradient,
|
|
||||||
pub border_cyan: Color,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub static AERO: std::sync::LazyLock<AeroColors> =
|
|
||||||
std::sync::LazyLock::new(AeroColors::standard);
|
|
||||||
|
|
||||||
impl AeroColors {
|
|
||||||
pub fn standard() -> Self {
|
|
||||||
Self {
|
|
||||||
// Semi-transparent "Aero Glass" base
|
|
||||||
glass_bg: Color::from_rgba8(255, 255, 255, 0.2),
|
|
||||||
// Cyan/Blue glow typical of the 2008 era
|
|
||||||
border_cyan: Color::from_rgb8(0, 183, 235),
|
|
||||||
// We'll use this for the "shine" effect on buttons
|
|
||||||
gloss_highlight: Gradient::Linear(Linear::new(0.0).add_stops([
|
|
||||||
ColorStop {
|
|
||||||
color: Color::from_rgba8(255, 255, 255, 0.5),
|
|
||||||
offset: 0.0,
|
|
||||||
},
|
|
||||||
ColorStop {
|
|
||||||
color: Color::from_rgba8(255, 255, 255, 0.0),
|
|
||||||
offset: 1.0,
|
|
||||||
},
|
|
||||||
])),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,10 +8,23 @@ default-run = "k-launcher"
|
|||||||
name = "k-launcher"
|
name = "k-launcher"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "k-launcher-egui"
|
||||||
|
path = "src/main_egui.rs"
|
||||||
|
required-features = ["egui"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
egui = ["dep:k-launcher-ui-egui"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
iced = { workspace = true }
|
iced = { workspace = true }
|
||||||
|
k-launcher-config = { path = "../k-launcher-config" }
|
||||||
k-launcher-kernel = { path = "../k-launcher-kernel" }
|
k-launcher-kernel = { path = "../k-launcher-kernel" }
|
||||||
|
k-launcher-os-bridge = { path = "../k-launcher-os-bridge" }
|
||||||
k-launcher-ui = { path = "../k-launcher-ui" }
|
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-apps = { path = "../plugins/plugin-apps" }
|
||||||
plugin-calc = { path = "../plugins/plugin-calc" }
|
plugin-calc = { path = "../plugins/plugin-calc" }
|
||||||
|
plugin-cmd = { path = "../plugins/plugin-cmd" }
|
||||||
|
plugin-files = { path = "../plugins/plugin-files" }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use k_launcher_kernel::Kernel;
|
use k_launcher_kernel::Kernel;
|
||||||
use plugin_apps::{AppsPlugin, FsDesktopEntrySource};
|
use k_launcher_os_bridge::UnixAppLauncher;
|
||||||
|
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use plugin_apps::linux::FsDesktopEntrySource;
|
||||||
use plugin_calc::CalcPlugin;
|
use plugin_calc::CalcPlugin;
|
||||||
|
use plugin_cmd::CmdPlugin;
|
||||||
|
use plugin_files::FilesPlugin;
|
||||||
|
|
||||||
fn main() -> iced::Result {
|
fn main() -> iced::Result {
|
||||||
let kernel = Arc::new(Kernel::new(vec![
|
let cfg = k_launcher_config::load();
|
||||||
Arc::new(CalcPlugin::new()),
|
let launcher = Arc::new(UnixAppLauncher::new());
|
||||||
Arc::new(AppsPlugin::new(FsDesktopEntrySource::new())),
|
let frecency = FrecencyStore::load();
|
||||||
]));
|
|
||||||
k_launcher_ui::run(kernel)
|
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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let kernel: Arc<dyn k_launcher_kernel::SearchEngine> =
|
||||||
|
Arc::new(Kernel::new(plugins, cfg.search.max_results));
|
||||||
|
|
||||||
|
k_launcher_ui::run(kernel, launcher, &cfg.window, cfg.appearance)
|
||||||
}
|
}
|
||||||
|
|||||||
23
crates/k-launcher/src/main_egui.rs
Normal file
23
crates/k-launcher/src/main_egui.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use k_launcher_kernel::Kernel;
|
||||||
|
use k_launcher_os_bridge::UnixAppLauncher;
|
||||||
|
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use plugin_apps::linux::FsDesktopEntrySource;
|
||||||
|
use plugin_calc::CalcPlugin;
|
||||||
|
use plugin_cmd::CmdPlugin;
|
||||||
|
use plugin_files::FilesPlugin;
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
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)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -7,13 +7,12 @@ edition = "2024"
|
|||||||
name = "plugin_apps"
|
name = "plugin_apps"
|
||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "plugin-apps"
|
|
||||||
path = "src/main.rs"
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
||||||
libc = "0.2"
|
serde = { workspace = true }
|
||||||
|
serde_json = "1.0"
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
xdg = "2"
|
xdg = "2"
|
||||||
|
|||||||
134
crates/plugins/plugin-apps/src/frecency.rs
Normal file
134
crates/plugins/plugin-apps/src/frecency.rs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
path::PathBuf,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct Entry {
|
||||||
|
count: u32,
|
||||||
|
last_used: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FrecencyStore {
|
||||||
|
path: PathBuf,
|
||||||
|
data: Mutex<HashMap<String, Entry>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrecencyStore {
|
||||||
|
pub fn new(path: PathBuf) -> Arc<Self> {
|
||||||
|
let data = std::fs::read_to_string(&path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
Arc::new(Self { path, data: Mutex::new(data) })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn new_for_test() -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
path: PathBuf::from("/dev/null"),
|
||||||
|
data: Mutex::new(HashMap::new()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Arc<Self> {
|
||||||
|
let path = xdg::BaseDirectories::new()
|
||||||
|
.map(|xdg| xdg.get_data_home())
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
.join("k-launcher")
|
||||||
|
.join("frecency.json");
|
||||||
|
Self::new(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record(&self, id: &str) {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let mut data = self.data.lock().unwrap();
|
||||||
|
let entry = data.entry(id.to_string()).or_insert(Entry { count: 0, last_used: 0 });
|
||||||
|
entry.count += 1;
|
||||||
|
entry.last_used = now;
|
||||||
|
if let Some(parent) = self.path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
if let Ok(json) = serde_json::to_string(&*data) {
|
||||||
|
let _ = std::fs::write(&self.path, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn frecency_score(&self, id: &str) -> u32 {
|
||||||
|
let data = self.data.lock().unwrap();
|
||||||
|
let Some(entry) = data.get(id) else { return 0 };
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let age_secs = now.saturating_sub(entry.last_used);
|
||||||
|
let decay = if age_secs < 3600 { 4 } else if age_secs < 86400 { 2 } else { 1 };
|
||||||
|
entry.count * decay
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn top_ids(&self, n: usize) -> Vec<String> {
|
||||||
|
let data = self.data.lock().unwrap();
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let mut scored: Vec<(String, u32)> = data
|
||||||
|
.iter()
|
||||||
|
.map(|(id, entry)| {
|
||||||
|
let age_secs = now.saturating_sub(entry.last_used);
|
||||||
|
let decay = if age_secs < 3600 { 4 } else if age_secs < 86400 { 2 } else { 1 };
|
||||||
|
(id.clone(), entry.count * decay)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
scored.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
scored.into_iter().take(n).map(|(id, _)| id).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_store() -> Arc<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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
use std::{path::Path, process::{Command, Stdio}, sync::Arc};
|
pub mod frecency;
|
||||||
use std::os::unix::process::CommandExt;
|
#[cfg(target_os = "linux")]
|
||||||
|
pub mod linux;
|
||||||
|
|
||||||
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use k_launcher_kernel::{Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult};
|
use k_launcher_kernel::{LaunchAction, Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult};
|
||||||
|
|
||||||
|
use crate::frecency::FrecencyStore;
|
||||||
|
|
||||||
// --- Domain newtypes ---
|
// --- Domain newtypes ---
|
||||||
|
|
||||||
@@ -48,6 +53,8 @@ pub struct DesktopEntry {
|
|||||||
pub name: AppName,
|
pub name: AppName,
|
||||||
pub exec: ExecCommand,
|
pub exec: ExecCommand,
|
||||||
pub icon: Option<IconPath>,
|
pub icon: Option<IconPath>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
pub keywords: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Swappable source trait (Application layer principle) ---
|
// --- Swappable source trait (Application layer principle) ---
|
||||||
@@ -59,78 +66,80 @@ pub trait DesktopEntrySource: Send + Sync {
|
|||||||
// --- Cached entry (pre-computed at construction) ---
|
// --- Cached entry (pre-computed at construction) ---
|
||||||
|
|
||||||
struct CachedEntry {
|
struct CachedEntry {
|
||||||
|
id: String,
|
||||||
name: AppName,
|
name: AppName,
|
||||||
name_lc: String,
|
name_lc: String,
|
||||||
|
keywords_lc: Vec<String>,
|
||||||
|
category: Option<String>,
|
||||||
icon: Option<String>,
|
icon: Option<String>,
|
||||||
on_execute: Arc<dyn Fn() + Send + Sync>,
|
exec: String,
|
||||||
|
on_select: Arc<dyn Fn() + Send + Sync>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Plugin ---
|
// --- Plugin ---
|
||||||
|
|
||||||
pub struct AppsPlugin {
|
pub struct AppsPlugin {
|
||||||
entries: Vec<CachedEntry>,
|
entries: HashMap<String, CachedEntry>,
|
||||||
|
frecency: Arc<FrecencyStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppsPlugin {
|
impl AppsPlugin {
|
||||||
pub fn new(source: impl DesktopEntrySource) -> Self {
|
pub fn new(source: impl DesktopEntrySource, frecency: Arc<FrecencyStore>) -> Self {
|
||||||
let entries = source
|
let entries = source
|
||||||
.entries()
|
.entries()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|e| {
|
.map(|e| {
|
||||||
|
let id = format!("app-{}", e.name.as_str());
|
||||||
let name_lc = e.name.as_str().to_lowercase();
|
let name_lc = e.name.as_str().to_lowercase();
|
||||||
let icon = e.icon.as_ref().and_then(|p| resolve_icon_path(p.as_str()));
|
let keywords_lc = e.keywords.iter().map(|k| k.to_lowercase()).collect();
|
||||||
let exec = e.exec.clone();
|
#[cfg(target_os = "linux")]
|
||||||
CachedEntry {
|
let icon = e.icon.as_ref().and_then(|p| linux::resolve_icon_path(p.as_str()));
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let icon: Option<String> = None;
|
||||||
|
let exec = e.exec.as_str().to_string();
|
||||||
|
let store = Arc::clone(&frecency);
|
||||||
|
let record_id = id.clone();
|
||||||
|
let on_select: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
|
||||||
|
store.record(&record_id);
|
||||||
|
});
|
||||||
|
let cached = CachedEntry {
|
||||||
|
id: id.clone(),
|
||||||
name_lc,
|
name_lc,
|
||||||
|
keywords_lc,
|
||||||
|
category: e.category,
|
||||||
icon,
|
icon,
|
||||||
on_execute: Arc::new(move || {
|
exec,
|
||||||
let parts: Vec<&str> = exec.as_str().split_whitespace().collect();
|
on_select,
|
||||||
if let Some((cmd, args)) = parts.split_first() {
|
|
||||||
let _ = unsafe {
|
|
||||||
Command::new(cmd)
|
|
||||||
.args(args)
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.pre_exec(|| {
|
|
||||||
libc::setsid();
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.spawn()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
name: e.name,
|
name: e.name,
|
||||||
}
|
};
|
||||||
|
(id, cached)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Self { entries }
|
Self { entries, frecency }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_icon_path(name: &str) -> Option<String> {
|
fn initials(name_lc: &str) -> String {
|
||||||
if name.starts_with('/') && Path::new(name).exists() {
|
name_lc.split_whitespace().filter_map(|w| w.chars().next()).collect()
|
||||||
return Some(name.to_string());
|
|
||||||
}
|
|
||||||
let candidates = [
|
|
||||||
format!("/usr/share/pixmaps/{name}.png"),
|
|
||||||
format!("/usr/share/pixmaps/{name}.svg"),
|
|
||||||
format!("/usr/share/icons/hicolor/48x48/apps/{name}.png"),
|
|
||||||
format!("/usr/share/icons/hicolor/scalable/apps/{name}.svg"),
|
|
||||||
];
|
|
||||||
candidates.into_iter().find(|p| Path::new(p).exists())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn score_match(name_lc: &str, query_lc: &str) -> Option<u32> {
|
fn score_match(name_lc: &str, query_lc: &str) -> Option<u32> {
|
||||||
if name_lc == query_lc {
|
if name_lc == query_lc { return Some(100); }
|
||||||
Some(100)
|
if name_lc.starts_with(query_lc) { return Some(80); }
|
||||||
} else if name_lc.starts_with(query_lc) {
|
if name_lc.contains(query_lc) { return Some(60); }
|
||||||
Some(80)
|
if initials(name_lc).starts_with(query_lc) { return Some(70); }
|
||||||
} else if name_lc.contains(query_lc) {
|
None
|
||||||
Some(60)
|
}
|
||||||
} 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]
|
#[async_trait]
|
||||||
@@ -141,132 +150,57 @@ impl Plugin for AppsPlugin {
|
|||||||
|
|
||||||
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
return vec![];
|
return self.frecency.top_ids(5)
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| {
|
||||||
|
let e = self.entries.get(id)?;
|
||||||
|
let score = self.frecency.frecency_score(id).max(1);
|
||||||
|
Some(SearchResult {
|
||||||
|
id: ResultId::new(id),
|
||||||
|
title: ResultTitle::new(e.name.as_str()),
|
||||||
|
description: e.category.clone(),
|
||||||
|
icon: e.icon.clone(),
|
||||||
|
score: Score::new(score),
|
||||||
|
action: LaunchAction::SpawnProcess(e.exec.clone()),
|
||||||
|
on_select: Some(Arc::clone(&e.on_select)),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
let query_lc = query.to_lowercase();
|
let query_lc = query.to_lowercase();
|
||||||
self.entries
|
self.entries
|
||||||
.iter()
|
.values()
|
||||||
.filter_map(|e| {
|
.filter_map(|e| {
|
||||||
score_match(&e.name_lc, &query_lc).map(|score| SearchResult {
|
let score = score_match(&e.name_lc, &query_lc).or_else(|| {
|
||||||
id: ResultId::new(format!("app-{}", e.name.as_str())),
|
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()),
|
title: ResultTitle::new(e.name.as_str()),
|
||||||
description: None,
|
description: e.category.clone(),
|
||||||
icon: e.icon.clone(),
|
icon: e.icon.clone(),
|
||||||
score: Score::new(score),
|
score: Score::new(score),
|
||||||
on_execute: Arc::clone(&e.on_execute),
|
action: LaunchAction::SpawnProcess(e.exec.clone()),
|
||||||
|
on_select: Some(Arc::clone(&e.on_select)),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Filesystem source ---
|
|
||||||
|
|
||||||
pub struct FsDesktopEntrySource;
|
|
||||||
|
|
||||||
impl FsDesktopEntrySource {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for FsDesktopEntrySource {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DesktopEntrySource for FsDesktopEntrySource {
|
|
||||||
fn entries(&self) -> Vec<DesktopEntry> {
|
|
||||||
let mut dirs = Vec::new();
|
|
||||||
if let Ok(xdg) = xdg::BaseDirectories::new() {
|
|
||||||
dirs.push(xdg.get_data_home().join("applications"));
|
|
||||||
for d in xdg.get_data_dirs() {
|
|
||||||
dirs.push(d.join("applications"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut entries = Vec::new();
|
|
||||||
for dir in &dirs {
|
|
||||||
if let Ok(read_dir) = std::fs::read_dir(dir) {
|
|
||||||
for entry in read_dir.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(de) = parse_desktop_file(&path) {
|
|
||||||
entries.push(de);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entries
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
|
|
||||||
let content = std::fs::read_to_string(path).ok()?;
|
|
||||||
let mut in_section = false;
|
|
||||||
let mut name: Option<String> = None;
|
|
||||||
let mut exec: Option<String> = None;
|
|
||||||
let mut icon: Option<String> = None;
|
|
||||||
let mut is_application = false;
|
|
||||||
let mut no_display = false;
|
|
||||||
|
|
||||||
for line in content.lines() {
|
|
||||||
let line = line.trim();
|
|
||||||
if line == "[Desktop Entry]" {
|
|
||||||
in_section = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if line.starts_with('[') {
|
|
||||||
in_section = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if !in_section || line.starts_with('#') || line.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some((key, value)) = line.split_once('=') {
|
|
||||||
match key.trim() {
|
|
||||||
"Name" if name.is_none() => name = Some(value.trim().to_string()),
|
|
||||||
"Exec" if exec.is_none() => exec = Some(value.trim().to_string()),
|
|
||||||
"Icon" if icon.is_none() => icon = Some(value.trim().to_string()),
|
|
||||||
"Type" if !is_application => is_application = value.trim() == "Application",
|
|
||||||
"NoDisplay" => no_display = value.trim().eq_ignore_ascii_case("true"),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !is_application || no_display {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let exec_clean: String = exec?
|
|
||||||
.split_whitespace()
|
|
||||||
.filter(|s| !s.starts_with('%'))
|
|
||||||
.fold(String::new(), |mut acc, s| {
|
|
||||||
if !acc.is_empty() {
|
|
||||||
acc.push(' ');
|
|
||||||
}
|
|
||||||
acc.push_str(s);
|
|
||||||
acc
|
|
||||||
});
|
|
||||||
|
|
||||||
Some(DesktopEntry {
|
|
||||||
name: AppName::new(name?),
|
|
||||||
exec: ExecCommand::new(exec_clean),
|
|
||||||
icon: icon.map(IconPath::new),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Tests ---
|
// --- Tests ---
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn ephemeral_frecency() -> Arc<FrecencyStore> {
|
||||||
|
FrecencyStore::new_for_test()
|
||||||
|
}
|
||||||
|
|
||||||
struct MockSource {
|
struct MockSource {
|
||||||
entries: Vec<(String, String)>, // (name, exec)
|
entries: Vec<(String, String, Option<String>, Vec<String>)>, // (name, exec, category, keywords)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MockSource {
|
impl MockSource {
|
||||||
@@ -274,7 +208,25 @@ mod tests {
|
|||||||
Self {
|
Self {
|
||||||
entries: entries
|
entries: entries
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(n, e)| (n.to_string(), e.to_string()))
|
.map(|(n, e)| (n.to_string(), e.to_string(), None, vec![]))
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_categories(entries: Vec<(&str, &str, &str)>) -> Self {
|
||||||
|
Self {
|
||||||
|
entries: entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|(n, e, c)| (n.to_string(), e.to_string(), Some(c.to_string()), vec![]))
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_keywords(entries: Vec<(&str, &str, Vec<&str>)>) -> Self {
|
||||||
|
Self {
|
||||||
|
entries: entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|(n, e, kw)| (n.to_string(), e.to_string(), None, kw.into_iter().map(|s| s.to_string()).collect()))
|
||||||
.collect(),
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,10 +236,12 @@ mod tests {
|
|||||||
fn entries(&self) -> Vec<DesktopEntry> {
|
fn entries(&self) -> Vec<DesktopEntry> {
|
||||||
self.entries
|
self.entries
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(name, exec)| DesktopEntry {
|
.map(|(name, exec, category, keywords)| DesktopEntry {
|
||||||
name: AppName::new(name.clone()),
|
name: AppName::new(name.clone()),
|
||||||
exec: ExecCommand::new(exec.clone()),
|
exec: ExecCommand::new(exec.clone()),
|
||||||
icon: None,
|
icon: None,
|
||||||
|
category: category.clone(),
|
||||||
|
keywords: keywords.clone(),
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -295,23 +249,89 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn apps_prefix_match() {
|
async fn apps_prefix_match() {
|
||||||
let source = MockSource::with(vec![("Firefox", "firefox")]);
|
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency());
|
||||||
let p = AppsPlugin::new(source);
|
|
||||||
let results = p.search("fire").await;
|
let results = p.search("fire").await;
|
||||||
assert_eq!(results[0].title.as_str(), "Firefox");
|
assert_eq!(results[0].title.as_str(), "Firefox");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn apps_no_match_returns_empty() {
|
async fn apps_no_match_returns_empty() {
|
||||||
let source = MockSource::with(vec![("Firefox", "firefox")]);
|
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency());
|
||||||
let p = AppsPlugin::new(source);
|
|
||||||
assert!(p.search("zz").await.is_empty());
|
assert!(p.search("zz").await.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn apps_empty_query_returns_empty() {
|
async fn apps_empty_query_no_frecency_returns_empty() {
|
||||||
let source = MockSource::with(vec![("Firefox", "firefox")]);
|
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency());
|
||||||
let p = AppsPlugin::new(source);
|
|
||||||
assert!(p.search("").await.is_empty());
|
assert!(p.search("").await.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn score_match_abbreviation() {
|
||||||
|
assert_eq!(initials("visual studio code"), "vsc");
|
||||||
|
assert_eq!(score_match("visual studio code", "vsc"), Some(70));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn score_match_exact_beats_prefix_beats_abbrev_beats_substr() {
|
||||||
|
assert_eq!(score_match("firefox", "firefox"), Some(100));
|
||||||
|
assert_eq!(score_match("firefox", "fire"), Some(80));
|
||||||
|
assert_eq!(score_match("gnu firefox", "gf"), Some(70));
|
||||||
|
assert_eq!(score_match("ice firefox", "fire"), Some(60));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn apps_abbreviation_match() {
|
||||||
|
let p = AppsPlugin::new(
|
||||||
|
MockSource::with(vec![("Visual Studio Code", "code")]),
|
||||||
|
ephemeral_frecency(),
|
||||||
|
);
|
||||||
|
let results = p.search("vsc").await;
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].title.as_str(), "Visual Studio Code");
|
||||||
|
assert_eq!(results[0].score.value(), 70);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn apps_keyword_match() {
|
||||||
|
let p = AppsPlugin::new(
|
||||||
|
MockSource::with_keywords(vec![("Code", "code", vec!["editor", "ide"])]),
|
||||||
|
ephemeral_frecency(),
|
||||||
|
);
|
||||||
|
let results = p.search("editor").await;
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].score.value(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn humanize_category_splits_camel_case() {
|
||||||
|
assert_eq!(humanize_category("TextEditor"), "Text Editor");
|
||||||
|
assert_eq!(humanize_category("WebBrowser"), "Web Browser");
|
||||||
|
assert_eq!(humanize_category("Development"), "Development");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn apps_category_appears_in_description() {
|
||||||
|
let p = AppsPlugin::new(
|
||||||
|
MockSource::with_categories(vec![("Code", "code", "Text Editor")]),
|
||||||
|
ephemeral_frecency(),
|
||||||
|
);
|
||||||
|
let results = p.search("code").await;
|
||||||
|
assert_eq!(results[0].description.as_deref(), Some("Text Editor"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn apps_empty_query_returns_top_frecent() {
|
||||||
|
let frecency = ephemeral_frecency();
|
||||||
|
frecency.record("app-Code");
|
||||||
|
frecency.record("app-Code");
|
||||||
|
frecency.record("app-Firefox");
|
||||||
|
let p = AppsPlugin::new(
|
||||||
|
MockSource::with(vec![("Firefox", "firefox"), ("Code", "code")]),
|
||||||
|
frecency,
|
||||||
|
);
|
||||||
|
let results = p.search("").await;
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
assert_eq!(results[0].title.as_str(), "Code");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
131
crates/plugins/plugin-apps/src/linux.rs
Normal file
131
crates/plugins/plugin-apps/src/linux.rs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::{AppName, DesktopEntry, DesktopEntrySource, ExecCommand, IconPath};
|
||||||
|
use crate::humanize_category;
|
||||||
|
|
||||||
|
pub struct FsDesktopEntrySource;
|
||||||
|
|
||||||
|
impl FsDesktopEntrySource {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FsDesktopEntrySource {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DesktopEntrySource for FsDesktopEntrySource {
|
||||||
|
fn entries(&self) -> Vec<DesktopEntry> {
|
||||||
|
let mut dirs = Vec::new();
|
||||||
|
if let Ok(xdg) = xdg::BaseDirectories::new() {
|
||||||
|
dirs.push(xdg.get_data_home().join("applications"));
|
||||||
|
for d in xdg.get_data_dirs() {
|
||||||
|
dirs.push(d.join("applications"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for dir in &dirs {
|
||||||
|
if let Ok(read_dir) = std::fs::read_dir(dir) {
|
||||||
|
for entry in read_dir.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(de) = parse_desktop_file(&path) {
|
||||||
|
entries.push(de);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_icon_path(name: &str) -> Option<String> {
|
||||||
|
if name.starts_with('/') && Path::new(name).exists() {
|
||||||
|
return Some(name.to_string());
|
||||||
|
}
|
||||||
|
let candidates = [
|
||||||
|
format!("/usr/share/pixmaps/{name}.png"),
|
||||||
|
format!("/usr/share/pixmaps/{name}.svg"),
|
||||||
|
format!("/usr/share/icons/hicolor/48x48/apps/{name}.png"),
|
||||||
|
format!("/usr/share/icons/hicolor/scalable/apps/{name}.svg"),
|
||||||
|
];
|
||||||
|
candidates.into_iter().find(|p| Path::new(p).exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
|
||||||
|
let content = std::fs::read_to_string(path).ok()?;
|
||||||
|
let mut in_section = false;
|
||||||
|
let mut name: Option<String> = None;
|
||||||
|
let mut exec: Option<String> = None;
|
||||||
|
let mut icon: Option<String> = None;
|
||||||
|
let mut category: Option<String> = None;
|
||||||
|
let mut keywords: Vec<String> = Vec::new();
|
||||||
|
let mut is_application = false;
|
||||||
|
let mut no_display = false;
|
||||||
|
|
||||||
|
for line in content.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line == "[Desktop Entry]" {
|
||||||
|
in_section = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if line.starts_with('[') {
|
||||||
|
in_section = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !in_section || line.starts_with('#') || line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some((key, value)) = line.split_once('=') {
|
||||||
|
match key.trim() {
|
||||||
|
"Name" if name.is_none() => name = Some(value.trim().to_string()),
|
||||||
|
"Exec" if exec.is_none() => exec = Some(value.trim().to_string()),
|
||||||
|
"Icon" if icon.is_none() => icon = Some(value.trim().to_string()),
|
||||||
|
"Type" if !is_application => is_application = value.trim() == "Application",
|
||||||
|
"NoDisplay" => no_display = value.trim().eq_ignore_ascii_case("true"),
|
||||||
|
"Categories" if category.is_none() => {
|
||||||
|
category = value.trim()
|
||||||
|
.split(';')
|
||||||
|
.find(|s| !s.is_empty())
|
||||||
|
.map(|s| humanize_category(s.trim()));
|
||||||
|
}
|
||||||
|
"Keywords" if keywords.is_empty() => {
|
||||||
|
keywords = value.trim()
|
||||||
|
.split(';')
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !is_application || no_display {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let exec_clean: String = exec?
|
||||||
|
.split_whitespace()
|
||||||
|
.filter(|s| !s.starts_with('%'))
|
||||||
|
.fold(String::new(), |mut acc, s| {
|
||||||
|
if !acc.is_empty() {
|
||||||
|
acc.push(' ');
|
||||||
|
}
|
||||||
|
acc.push_str(s);
|
||||||
|
acc
|
||||||
|
});
|
||||||
|
|
||||||
|
Some(DesktopEntry {
|
||||||
|
name: AppName::new(name?),
|
||||||
|
exec: ExecCommand::new(exec_clean),
|
||||||
|
icon: icon.map(IconPath::new),
|
||||||
|
category,
|
||||||
|
keywords,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
fn main() {}
|
|
||||||
@@ -7,10 +7,6 @@ edition = "2024"
|
|||||||
name = "plugin_calc"
|
name = "plugin_calc"
|
||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "plugin-calc"
|
|
||||||
path = "src/main.rs"
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
evalexpr = "11"
|
evalexpr = "11"
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use k_launcher_kernel::{Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult};
|
use k_launcher_kernel::{LaunchAction, Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult};
|
||||||
|
|
||||||
pub struct CalcPlugin;
|
pub struct CalcPlugin;
|
||||||
|
|
||||||
@@ -39,18 +37,21 @@ impl Plugin for CalcPlugin {
|
|||||||
let expr = query.strip_prefix('=').unwrap_or(query);
|
let expr = query.strip_prefix('=').unwrap_or(query);
|
||||||
match evalexpr::eval_number(expr) {
|
match evalexpr::eval_number(expr) {
|
||||||
Ok(n) if n.is_finite() => {
|
Ok(n) if n.is_finite() => {
|
||||||
let display = if n.fract() == 0.0 {
|
let value_str = if n.fract() == 0.0 {
|
||||||
format!("= {}", n as i64)
|
format!("{}", n as i64)
|
||||||
} else {
|
} else {
|
||||||
format!("= {n}")
|
format!("{n}")
|
||||||
};
|
};
|
||||||
|
let display = format!("= {value_str}");
|
||||||
|
let expr_owned = expr.to_string();
|
||||||
vec![SearchResult {
|
vec![SearchResult {
|
||||||
id: ResultId::new("calc-result"),
|
id: ResultId::new("calc-result"),
|
||||||
title: ResultTitle::new(display),
|
title: ResultTitle::new(display),
|
||||||
description: None,
|
description: Some(format!("{expr_owned} · Enter to copy")),
|
||||||
icon: None,
|
icon: None,
|
||||||
score: Score::new(90),
|
score: Score::new(90),
|
||||||
on_execute: Arc::new(|| {}),
|
action: LaunchAction::CopyToClipboard(value_str),
|
||||||
|
on_select: None,
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
fn main() {}
|
|
||||||
15
crates/plugins/plugin-cmd/Cargo.toml
Normal file
15
crates/plugins/plugin-cmd/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "plugin-cmd"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "plugin_cmd"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true }
|
||||||
70
crates/plugins/plugin-cmd/src/lib.rs
Normal file
70
crates/plugins/plugin-cmd/src/lib.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use k_launcher_kernel::{LaunchAction, Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult};
|
||||||
|
|
||||||
|
pub struct CmdPlugin;
|
||||||
|
|
||||||
|
impl CmdPlugin {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CmdPlugin {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Plugin for CmdPlugin {
|
||||||
|
fn name(&self) -> PluginName {
|
||||||
|
"cmd"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||||
|
let Some(rest) = query.strip_prefix('>') else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
let cmd = rest.trim();
|
||||||
|
if cmd.is_empty() {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
vec![SearchResult {
|
||||||
|
id: ResultId::new(format!("cmd-{cmd}")),
|
||||||
|
title: ResultTitle::new(format!("Run: {cmd}")),
|
||||||
|
description: None,
|
||||||
|
icon: None,
|
||||||
|
score: Score::new(95),
|
||||||
|
action: LaunchAction::SpawnInTerminal(cmd.to_string()),
|
||||||
|
on_select: None,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,4 +3,11 @@ name = "plugin-files"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "plugin_files"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
|||||||
115
crates/plugins/plugin-files/src/lib.rs
Normal file
115
crates/plugins/plugin-files/src/lib.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
mod platform;
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use k_launcher_kernel::{LaunchAction, Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult};
|
||||||
|
|
||||||
|
pub struct FilesPlugin;
|
||||||
|
|
||||||
|
impl FilesPlugin {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FilesPlugin {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_query(query: &str) -> Option<String> {
|
||||||
|
if query.starts_with("~/") {
|
||||||
|
let home = platform::home_dir()?;
|
||||||
|
Some(format!("{}{}", home, &query[1..]))
|
||||||
|
} else if query.starts_with('/') {
|
||||||
|
Some(query.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Plugin for FilesPlugin {
|
||||||
|
fn name(&self) -> PluginName {
|
||||||
|
"files"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||||
|
let expanded = match expand_query(query) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return vec![],
|
||||||
|
};
|
||||||
|
let path = Path::new(&expanded);
|
||||||
|
let (parent, prefix) = if path.is_dir() {
|
||||||
|
(path.to_path_buf(), String::new())
|
||||||
|
} else {
|
||||||
|
let parent = path.parent().unwrap_or(Path::new("/")).to_path_buf();
|
||||||
|
let prefix = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
|
(parent, prefix)
|
||||||
|
};
|
||||||
|
|
||||||
|
let entries = match std::fs::read_dir(&parent) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
entries
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| {
|
||||||
|
if prefix.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
e.file_name()
|
||||||
|
.to_str()
|
||||||
|
.map(|n| n.to_lowercase().starts_with(&prefix))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.take(20)
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, entry)| {
|
||||||
|
let full_path = entry.path();
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
let is_dir = full_path.is_dir();
|
||||||
|
let title = if is_dir {
|
||||||
|
format!("{name}/")
|
||||||
|
} else {
|
||||||
|
name
|
||||||
|
};
|
||||||
|
let path_str = full_path.to_string_lossy().to_string();
|
||||||
|
SearchResult {
|
||||||
|
id: ResultId::new(format!("file-{i}")),
|
||||||
|
title: ResultTitle::new(title),
|
||||||
|
description: Some(path_str.clone()),
|
||||||
|
icon: None,
|
||||||
|
score: Score::new(50),
|
||||||
|
action: LaunchAction::OpenPath(path_str),
|
||||||
|
on_select: None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
fn main() {
|
|
||||||
println!("Hello, world!");
|
|
||||||
}
|
|
||||||
9
crates/plugins/plugin-files/src/platform.rs
Normal file
9
crates/plugins/plugin-files/src/platform.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#[cfg(unix)]
|
||||||
|
pub fn home_dir() -> Option<String> {
|
||||||
|
std::env::var("HOME").ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn home_dir() -> Option<String> {
|
||||||
|
std::env::var("USERPROFILE").ok()
|
||||||
|
}
|
||||||
45
docs/configuration.md
Normal file
45
docs/configuration.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Configuration
|
||||||
|
|
||||||
|
Config file: `~/.config/k-launcher/config.toml`
|
||||||
|
|
||||||
|
The file is optional — all fields have defaults and missing sections fall back to defaults automatically. Create it manually if you want to customize behavior.
|
||||||
|
|
||||||
|
## Full Annotated Example
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[window]
|
||||||
|
width = 600.0 # window width in logical pixels
|
||||||
|
height = 400.0 # window height in logical pixels
|
||||||
|
decorations = false # show window title bar / frame
|
||||||
|
transparent = true # allow background transparency
|
||||||
|
resizable = false # allow manual resizing
|
||||||
|
|
||||||
|
[appearance]
|
||||||
|
# RGBA: r/g/b are 0–255 as floats, a is 0.0–1.0
|
||||||
|
background_rgba = [20.0, 20.0, 30.0, 0.9] # main background
|
||||||
|
border_rgba = [229.0, 125.0, 33.0, 1.0] # accent/border color
|
||||||
|
border_width = 1.0 # border thickness in pixels
|
||||||
|
border_radius = 8.0 # corner radius of the window
|
||||||
|
search_font_size = 18.0 # font size of the search input
|
||||||
|
title_size = 15.0 # font size of result titles
|
||||||
|
desc_size = 12.0 # font size of result descriptions
|
||||||
|
row_radius = 4.0 # corner radius of result rows
|
||||||
|
placeholder = "Search..." # search input placeholder text
|
||||||
|
|
||||||
|
[search]
|
||||||
|
max_results = 8 # maximum results shown at once
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
calc = true # math expression evaluator
|
||||||
|
cmd = true # shell command runner (> prefix)
|
||||||
|
files = true # filesystem browser (/ or ~/ prefix)
|
||||||
|
apps = true # XDG application launcher
|
||||||
|
```
|
||||||
|
|
||||||
|
## RGBA Format
|
||||||
|
|
||||||
|
Colors use `[r, g, b, a]` arrays where:
|
||||||
|
- `r`, `g`, `b` — red, green, blue channels as floats **0.0–255.0**
|
||||||
|
- `a` — alpha (opacity) as a float **0.0–1.0**
|
||||||
|
|
||||||
|
Example — semi-transparent white: `[255.0, 255.0, 255.0, 0.5]`
|
||||||
57
docs/install.md
Normal file
57
docs/install.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Installation
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Rust** stable toolchain — install via [rustup](https://rustup.rs)
|
||||||
|
- **git**
|
||||||
|
- A **Wayland** or **X11** compositor (Linux)
|
||||||
|
|
||||||
|
## Build from Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/GKaszewski/k-launcher
|
||||||
|
cd k-launcher
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
Binary location: `target/release/k-launcher`
|
||||||
|
|
||||||
|
### Optional: install to PATH
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp target/release/k-launcher ~/.local/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensure `~/.local/bin` is in your `$PATH`.
|
||||||
|
|
||||||
|
## Autostart
|
||||||
|
|
||||||
|
### Hyprland
|
||||||
|
|
||||||
|
Add to `~/.config/hypr/hyprland.conf`:
|
||||||
|
|
||||||
|
```
|
||||||
|
exec-once = k-launcher
|
||||||
|
```
|
||||||
|
|
||||||
|
### systemd user service
|
||||||
|
|
||||||
|
Create `~/.config/systemd/user/k-launcher.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=k-launcher command palette
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=%h/.local/bin/k-launcher
|
||||||
|
Restart=on-failure
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=graphical-session.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Then enable it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user enable --now k-launcher
|
||||||
|
```
|
||||||
132
docs/plugin-development.md
Normal file
132
docs/plugin-development.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# Plugin Development
|
||||||
|
|
||||||
|
Plugins are Rust crates that implement the `Plugin` trait from `k-launcher-kernel`. They run concurrently — the kernel fans out every query to all enabled plugins and merges results by score.
|
||||||
|
|
||||||
|
> Note: plugins are compiled into the binary at build time. There is no dynamic loading support yet.
|
||||||
|
|
||||||
|
## Step-by-Step
|
||||||
|
|
||||||
|
### 1. Create a new crate in the workspace
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo new --lib crates/plugins/plugin-hello
|
||||||
|
```
|
||||||
|
|
||||||
|
Add it to the workspace root `Cargo.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
# ...existing members...
|
||||||
|
"crates/plugins/plugin-hello",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Add dependencies
|
||||||
|
|
||||||
|
`crates/plugins/plugin-hello/Cargo.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
||||||
|
async-trait = "0.1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Implement the `Plugin` trait
|
||||||
|
|
||||||
|
`crates/plugins/plugin-hello/src/lib.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use k_launcher_kernel::{
|
||||||
|
LaunchAction, Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct HelloPlugin;
|
||||||
|
|
||||||
|
impl HelloPlugin {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Plugin for HelloPlugin {
|
||||||
|
fn name(&self) -> PluginName {
|
||||||
|
"hello"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||||
|
if !query.starts_with("hello") {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
|
vec![SearchResult {
|
||||||
|
id: ResultId::new("hello:world"),
|
||||||
|
title: ResultTitle::new("Hello, World!"),
|
||||||
|
description: Some("A greeting from the hello plugin".to_string()),
|
||||||
|
icon: None,
|
||||||
|
score: Score::new(80),
|
||||||
|
action: LaunchAction::CopyToClipboard("Hello, World!".to_string()),
|
||||||
|
on_select: None,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Wire up in main.rs
|
||||||
|
|
||||||
|
`crates/k-launcher/src/main.rs` — add alongside the existing plugins:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use plugin_hello::HelloPlugin;
|
||||||
|
|
||||||
|
// inside main():
|
||||||
|
plugins.push(Arc::new(HelloPlugin::new()));
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add the dependency to `crates/k-launcher/Cargo.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
plugin-hello = { path = "../plugins/plugin-hello" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
### `SearchResult` Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `id` | `ResultId` | Unique stable ID (e.g. `"apps:firefox"`) |
|
||||||
|
| `title` | `ResultTitle` | Primary display text |
|
||||||
|
| `description` | `Option<String>` | Secondary line shown below title |
|
||||||
|
| `icon` | `Option<String>` | Icon name or path (currently unused in renderer) |
|
||||||
|
| `score` | `Score(u32)` | Sort priority — higher wins |
|
||||||
|
| `action` | `LaunchAction` | What happens on `Enter` |
|
||||||
|
| `on_select` | `Option<Arc<dyn Fn()>>` | Optional side-effect on selection (e.g. frecency bump) |
|
||||||
|
|
||||||
|
### `LaunchAction` Variants
|
||||||
|
|
||||||
|
| Variant | Behavior |
|
||||||
|
|---------|----------|
|
||||||
|
| `SpawnProcess(String)` | Launch a process directly (e.g. app exec string) |
|
||||||
|
| `SpawnInTerminal(String)` | Run command inside a terminal emulator |
|
||||||
|
| `OpenPath(String)` | Open a file or directory with `xdg-open` |
|
||||||
|
| `CopyToClipboard(String)` | Copy text to clipboard |
|
||||||
|
| `Custom(Arc<dyn Fn()>)` | Arbitrary closure |
|
||||||
|
|
||||||
|
### Scoring Guidance
|
||||||
|
|
||||||
|
| Score range | Match type |
|
||||||
|
|-------------|-----------|
|
||||||
|
| 100 | Exact match |
|
||||||
|
| 90–99 | Calc/command result (always relevant) |
|
||||||
|
| 80 | Prefix match |
|
||||||
|
| 70 | Abbreviation match |
|
||||||
|
| 60 | Substring match |
|
||||||
|
| 50 | Keyword / loose match |
|
||||||
|
|
||||||
|
The kernel sorts all results from all plugins by score descending and truncates to `max_results` (default: 8).
|
||||||
50
docs/usage.md
Normal file
50
docs/usage.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Usage
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
k-launcher
|
||||||
|
```
|
||||||
|
|
||||||
|
## Keybinds
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| Type | Filter results |
|
||||||
|
| `↑` / `↓` | Navigate list |
|
||||||
|
| `Enter` | Launch selected result |
|
||||||
|
| `Escape` | Close launcher |
|
||||||
|
|
||||||
|
## Built-in Plugins
|
||||||
|
|
||||||
|
### Apps
|
||||||
|
|
||||||
|
Type any app name to search installed applications. An empty query shows your most frequently launched apps (frecency-ranked top results).
|
||||||
|
|
||||||
|
### Calc
|
||||||
|
|
||||||
|
Type a math expression — the result appears instantly and is copied to clipboard on `Enter`.
|
||||||
|
|
||||||
|
```
|
||||||
|
2^10 + 5 → 1029
|
||||||
|
sqrt(144) → 12
|
||||||
|
sin(pi / 2) → 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shell Command
|
||||||
|
|
||||||
|
Prefix your input with `>` to run a shell command in a terminal:
|
||||||
|
|
||||||
|
```
|
||||||
|
> echo hello
|
||||||
|
> htop
|
||||||
|
```
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
Start your query with `/` or `~/` to browse the filesystem:
|
||||||
|
|
||||||
|
```
|
||||||
|
/etc/hosts
|
||||||
|
~/Documents/report.pdf
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user