Compare commits

..

29 Commits

Author SHA1 Message Date
2e773cdeaf style: format code for better readability in tests and function signatures
Some checks failed
CI / test (push) Failing after 4m59s
CI / clippy (push) Failing after 4m58s
CI / fmt (push) Successful in 23s
2026-03-18 13:59:53 +01:00
3d2bd5f9fe fix: update build_entries function signature to ignore frecency parameter
Some checks failed
CI / test (push) Failing after 5m6s
CI / clippy (push) Failing after 5m3s
CI / fmt (push) Failing after 23s
2026-03-18 13:48:02 +01:00
ff9b2b5712 fix(review): bugs, arch violations, design smells
P1 bugs:
- unix_launcher: shell_split respects quoted args (was split_whitespace)
- plugin-host: 5s timeout on external plugin search
- ui: handle engine init panic, wire error state
- ui-egui: read window config instead of always using defaults
- plugin-url: use OpenPath action instead of SpawnProcess+xdg-open

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

Design smells:
- frecency: extract decay_factor helper, write outside mutex
- apps: remove cfg(test) cache_path hack; add new_for_test ctor
- apps: stable ResultId using name+exec to prevent collision
- files: stable ResultId using full path instead of index
- plugin-host: remove k-launcher-os-bridge dep (WindowConfig gone)
2026-03-18 13:45:48 +01:00
38860762c0 Update iced dependency in Cargo.toml to disable default features and add additional ones
Some checks failed
CI / test (push) Failing after 5m6s
CI / clippy (push) Failing after 5m3s
CI / fmt (push) Failing after 30s
2026-03-18 13:09:33 +01:00
248094f442 feat(app): enhance engine initialization with EngineHandle and update run function signature 2026-03-18 13:05:14 +01:00
bd356f27d1 feat: production hardening (panic isolation, file logging, apps cache)
- Kernel::search wraps each plugin in catch_unwind; panics are logged and return []
- init_logging() adds daily rolling file at ~/.local/share/k-launcher/logs/
- AppsPlugin caches entries to ~/.cache/k-launcher/apps.bin via bincode; stale-while-revalidate on subsequent launches
- 57 tests pass
2026-03-18 12:59:24 +01:00
58d0739cea refactor: remove client module and associated show command logic
Some checks failed
CI / test (push) Failing after 5m7s
CI / clippy (push) Failing after 4m59s
CI / fmt (push) Successful in 28s
2026-03-15 23:49:31 +01:00
12f1f541ae fix(app): format code for clarity in update function
Some checks failed
CI / test (push) Failing after 5m5s
CI / clippy (push) Failing after 5m3s
CI / fmt (push) Successful in 28s
2026-03-15 23:31:50 +01:00
bee429192f chore: update .gitignore and enhance README with compositor setup instructions
Some checks failed
CI / test (push) Failing after 4m58s
CI / clippy (push) Failing after 5m3s
CI / fmt (push) Successful in 19s
2026-03-15 20:09:06 +01:00
86e843f666 chore(docs): remove unused screenshot file
Some checks failed
CI / test (push) Has been cancelled
CI / clippy (push) Has been cancelled
CI / fmt (push) Has been cancelled
2026-03-15 20:08:29 +01:00
71b8e46ae6 feature/prod-ready (#1)
Some checks failed
CI / test (push) Has been cancelled
CI / clippy (push) Has been cancelled
CI / fmt (push) Has been cancelled
Reviewed-on: #1
2026-03-15 19:03:30 +00:00
2e2351e084 fix(calc): remove ambiguous log alias, use ln/log2/log10 explicitly 2026-03-15 19:34:54 +01:00
b567414930 fix(calc): fix log/ln naming, cache math context, strengthen sin(pi) test 2026-03-15 19:32:46 +01:00
aeea3756c1 feat(calc): add math functions (sqrt, sin, cos, etc.) and pi/e constants 2026-03-15 19:30:01 +01:00
207c20f77d refactor(calc): rename preprocess, extend underscore test assertions 2026-03-15 19:24:15 +01:00
be7c2b6b59 feat(calc): strip underscore digit separators 2026-03-15 19:21:43 +01:00
bf065ffdf0 feat: update dependencies for improved compatibility and performance 2026-03-15 19:14:50 +01:00
4283460c82 feat: add plugin-url for URL handling and open in browser functionality 2026-03-15 19:08:38 +01:00
d1479f41d2 feat: add support for external plugins and enhance plugin management 2026-03-15 18:54:55 +01:00
b8a9a6b02f feat: add Makefile for build, run, and installation commands 2026-03-15 18:42:07 +01:00
5bb5c8f531 feat: add required features for k-launcher-egui and update dependencies 2026-03-15 18:40:11 +01:00
fe46b7808a feat: update README and add documentation for installation, configuration, usage, and plugin development 2026-03-15 18:37:48 +01:00
3093bc9124 feat: enhance configuration management and UI styling, remove unused theme module 2026-03-15 18:31:22 +01:00
3098a4be7c feat: add k-launcher-config crate for configuration management and integrate with existing components 2026-03-15 18:20:15 +01:00
bc7c896519 feat: add k-launcher-ui-egui crate for enhanced UI
- Introduced a new crate `k-launcher-ui-egui` to provide a graphical user interface using eframe and egui.
- Updated the workspace configuration in `Cargo.toml` to include the new crate.
- Implemented the main application logic in `src/app.rs`, handling search functionality and user interactions.
- Created a library entry point in `src/lib.rs` to expose the `run` function for launching the UI.
- Modified the `k-launcher` crate to include a new binary target for the egui-based launcher.
- Added a new main file `src/main_egui.rs` to initialize and run the egui UI with the existing kernel and launcher components.
2026-03-15 18:10:46 +01:00
1a2de21bf6 feat: implement OS bridge and enhance app launcher functionality 2026-03-15 17:45:24 +01:00
93736ae19d feat: add FilesPlugin for file searching and integrate into KLauncher 2026-03-15 17:15:47 +01:00
dbce15bfd5 feat: implement frecency tracking for app usage and enhance search functionality 2026-03-15 17:05:05 +01:00
f5dd303b79 feat: add CmdPlugin for executing terminal commands and update workspace configuration 2026-03-15 16:53:30 +01:00
52 changed files with 4640 additions and 1249 deletions

39
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install system deps
run: sudo apt-get install -y libwayland-dev libxkbcommon-dev pkg-config
- run: cargo test --workspace
clippy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- name: Install system deps
run: sudo apt-get install -y libwayland-dev libxkbcommon-dev pkg-config
- run: cargo clippy --workspace -- -D warnings
fmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --check

21
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install system deps
run: sudo apt-get install -y libwayland-dev libxkbcommon-dev pkg-config
- run: cargo build --release
- uses: actions/upload-artifact@v4
with:
name: k-launcher
path: target/release/k-launcher

1
.gitignore vendored
View File

@@ -1 +1,2 @@
target/ target/
.worktrees/

110
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,110 @@
# k-launcher Architecture
## Philosophy
- **TDD:** Red-Green-Refactor is mandatory. No functional code without a failing test first.
- **Clean Architecture:** Strict layer separation — Domain, Application, Infrastructure, Main.
- **Newtype Pattern:** All domain primitives wrapped (e.g. `struct Score(f64)`).
- **Small Traits / ISP:** Many focused traits over one "God" trait.
- **No Cyclic Dependencies:** Use IoC (define traits in higher-level modules, implement in lower-level).
---
## Workspace Structure
| Crate | Layer | Responsibility |
|---|---|---|
| `k-launcher-kernel` | Domain + Application | Newtypes (`ResultId`, `ResultTitle`, `Score`), `Plugin` trait, `SearchEngine` trait, `AppLauncher` port, `Kernel` use case |
| `k-launcher-config` | Infrastructure | TOML config loading; `Config`, `WindowCfg`, `AppearanceCfg`, `PluginsCfg` structs |
| `k-launcher-os-bridge` | Infrastructure | `UnixAppLauncher` (process spawning), `WindowConfig` adapter |
| `k-launcher-plugin-host` | Infrastructure | `ExternalPlugin` — JSON-newline IPC protocol for out-of-process plugins |
| `k-launcher-ui` | Infrastructure | iced 0.14 Elm-like UI (`KLauncherApp`, debounced async search, keyboard nav) |
| `k-launcher-ui-egui` | Infrastructure | Alternative egui UI (feature-gated) |
| `plugins/plugin-apps` | Infrastructure | XDG `.desktop` parser, frecency scoring, nucleo fuzzy matching |
| `plugins/plugin-calc` | Infrastructure | `evalexpr`-based calculator |
| `plugins/plugin-cmd` | Infrastructure | Shell command runner |
| `plugins/plugin-files` | Infrastructure | File path search |
| `plugins/plugin-url` | Infrastructure | URL opener |
| `k-launcher` | Main/Entry | DI wiring, CLI arg parsing (`show` command), `run_ui()` composition root |
---
## Dependency Graph
```
k-launcher (main)
├── k-launcher-kernel (Domain/Application)
├── k-launcher-config (Infrastructure — pure data, no kernel dep)
├── k-launcher-os-bridge (Infrastructure)
├── k-launcher-plugin-host (Infrastructure)
├── k-launcher-ui (Infrastructure)
└── plugins/* (Infrastructure)
└── k-launcher-kernel
```
All arrows point inward toward the kernel. The kernel has no external dependencies.
---
## Core Abstractions (kernel)
```rust
// Plugin trait — implemented by every plugin
async fn search(&self, query: &str) -> Vec<SearchResult>;
// SearchEngine trait — implemented by Kernel
async fn search(&self, query: &str) -> Vec<SearchResult>;
// AppLauncher port — implemented by UnixAppLauncher in os-bridge
fn execute(&self, action: &LaunchAction);
// DesktopEntrySource trait (plugin-apps) — swappable .desktop file source
```
---
## Plugin System
Two kinds of plugins:
1. **In-process** — implement `Plugin` in Rust, linked at compile time.
- `plugin-calc`, `plugin-apps`, `plugin-cmd`, `plugin-files`, `plugin-url`
2. **External / out-of-process**`ExternalPlugin` in `k-launcher-plugin-host` communicates via JSON newline protocol over stdin/stdout.
- Query: `{"query": "..."}`
- Response: `[{"id": "...", "title": "...", "score": 1.0, "description": "...", "icon": "...", "action": "..."}]`
Plugins are enabled/disabled via `~/.config/k-launcher/config.toml`.
---
## Kernel (Application Use Case)
`Kernel::search` fans out to all registered plugins concurrently via `join_all`, merges results, sorts by `Score` descending, truncates to `max_results`.
---
## UI Architecture (iced 0.14 — Elm model)
- **State:** `KLauncherApp` holds engine ref, launcher ref, query string, results, selected index, appearance config.
- **Messages:** `QueryChanged`, `ResultsReady`, `KeyPressed`
- **Update:**
- `QueryChanged` → spawns debounced async task (50 ms) → `ResultsReady`
- Epoch guard prevents stale results from out-of-order responses
- **View:** search bar + scrollable result list with icon support (SVG/raster)
- **Subscription:** keyboard events — `Esc` = quit, `Enter` = launch, arrows = navigate
- **Window:** transparent, undecorated, centered (Wayland-compatible)
---
## Frecency (plugin-apps)
`FrecencyStore` records app launches by ID. On empty query, returns top-5 frecent apps instead of search results.
---
## Configuration
`~/.config/k-launcher/config.toml` — sections: `[window]`, `[appearance]`, `[search]`, `[plugins]`.
All fields have sane defaults; a missing file yields defaults without error.

58
CLAUDE.md Normal file
View File

@@ -0,0 +1,58 @@
## 1. Core Philosophy
- **Test-Driven Development (TDD):** No functional code is written without a failing test first. Red-Green-Refactor is the mandatory cycle.
- **Clean Architecture:** Maintain strict separation between Domain, Application, and Infrastructure layers.
- **Newtype Pattern:** Use the "Newtype" pattern for all domain primitives (e.g., `struct UserId(Uuid)`) to ensure type safety and prevent primitive obsession.
---
## 2. Structural Rules
### Dependency Management
- **Strict Unidirectionality:** Dependencies must only point inwards (towards the Domain).
- **No Cyclic Dependencies:** Use traits and Dependency Injection (DI) to break cycles. If two modules need each other, abstract the shared behavior into a trait or move common data to a lower-level module.
- **Feature Gating:** Organize the project into logical crates or modules that can be compiled independently.
### Traits and Decoupling
- **Swappable Infrastructure:** Define all external interactions (Database, API, File System) as traits in the Application layer.
- **Small Traits:** Adhere to the Interface Segregation Principle. Favor many specific traits over one "God" trait.
- **Mocking:** Use traits to allow easy mocking in unit tests without requiring a real database or network.
---
## 3. Rust Specifics & Clean Code
### Type Safety
- Avoid `String` or `i32` for domain concepts. Wrap them in structs.
- Use `Result` and `Option` explicitly. Minimize `unwrap()` and `expect()`—handle errors gracefully at the boundaries.
### Formatting & Style
- Follow standard `rustfmt` and `clippy` suggestions.
- Function names should be descriptive (e.g., `process_valid_order` instead of `handle_data`).
- Keep functions small (typically under 20-30 lines).
---
## 4. Layer Definitions
| Layer | Responsibility | Allowed Dependencies |
| ------------------ | --------------------------------------------- | -------------------- |
| **Domain** | Pure Business Logic, Entities, Value Objects. | None (Pure Rust) |
| **Application** | Use Cases, Orchestration, Trait definitions. | Domain |
| **Infrastructure** | Trait implementations (DB, HTTP clients). | Domain, Application |
| **Main/API** | Entry point, Wire-up/DI, Routing. | All of the above |
---
## 5. TDD Workflow Requirement
1. **Write a Test:** Create a test in `src/lib.rs` or a `tests/` directory.
2. **Define the Interface:** Use a trait or function signature to make the test compile (but fail).
3. **Minimum Implementation:** Write just enough code to pass the test.
4. **Refactor:** Clean up the logic, ensure no duplication, and check for "Newtype" opportunities.
> **Note on Cyclic Dependencies:** If an AI agent suggests a change that introduces a cycle, it must be rejected. Use **Inversion of Control** by defining a trait in the higher-level module that the lower-level module implements.

2230
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,41 @@
[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-plugin-host",
"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",
"crates/plugins/plugin-url",
]
default-members = [
"crates/k-launcher",
"crates/k-launcher-config",
"crates/k-launcher-kernel",
"crates/k-launcher-os-bridge",
"crates/k-launcher-plugin-host",
"crates/k-launcher-ui",
"crates/plugins/plugin-apps",
"crates/plugins/plugin-calc",
"crates/plugins/plugin-cmd",
"crates/plugins/plugin-files",
"crates/plugins/plugin-url",
] ]
resolver = "2" resolver = "2"
[workspace.dependencies] [workspace.dependencies]
async-trait = "0.1" async-trait = "0.1"
bincode = { version = "2", features = ["serde"] }
dirs = "6.0"
futures = "0.3" futures = "0.3"
iced = { version = "0.14", features = ["image", "svg", "tokio", "tiny-skia"] } iced = { version = "0.14", default-features = false, features = ["image", "svg", "tokio", "tiny-skia", "wayland", "x11", "crisp", "web-colors", "thread-pool"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
tokio = { version = "1.35", features = ["rt-multi-thread", "macros"] } tokio = { version = "1.35", features = ["rt-multi-thread", "macros"] }
toml = "1.0"
tracing = "0.1" tracing = "0.1"

View File

@@ -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

109
README.md
View File

@@ -1,56 +1,69 @@
# 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 ## Quick Start
- Zero Webview: No Chromium, no Electron. Every pixel is rendered via WGPU (Iced) for sub-5ms input-to-render latency. ```bash
git clone https://github.com/GKaszewski/k-launcher
- Async-First: Search queries never block the UI. If the file-searcher is indexing, the calculator still feels instant. cd k-launcher
cargo build --release
- The "Aero" Standard: Deep support for Gaussian blur (via Layer Shell), linear gradients, and high-gloss textures. ./target/release/k-launcher
## 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
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 ## Keybinds
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. | Key | Action |
| --------- | --------------- |
| Type | Filter results |
| `↑` / `↓` | Navigate |
| `Enter` | Launch selected |
| `Escape` | Close |
To achieve the 2000s aesthetic without a browser: ## Compositor Setup
- Background Blur: On Wayland, we request blur through the org_kde_kwin_blur or fractional-scale protocols. k-launcher uses a normal window; configure your compositor to float it.
- Shaders: We will use Iceds 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. **Hyprland** (`~/.config/hypr/hyprland.conf`):
```
windowrule = float, ^(k-launcher)$
windowrule = center, ^(k-launcher)$
bind = SUPER, Space, exec, k-launcher
```
**Sway** (`~/.config/sway/config`):
```
for_window [app_id="k-launcher"] floating enable, move position center
bindsym Mod4+space exec k-launcher
```
## Built-in Plugins
| Trigger | Plugin | Example |
| ----------------- | ------ | -------------- |
| (any text) | Apps | `firefox` |
| number/expression | Calc | `2^10 + 5` |
| `>` prefix | Shell | `> echo hello` |
| `/` or `~/` | Files | `~/Documents` |
## External Plugins
Drop in community plugins — any language, no recompilation. Plugins are executables that communicate over stdin/stdout JSON:
```toml
# ~/.config/k-launcher/config.toml
[[plugins.external]]
name = "my-plugin"
path = "/usr/lib/k-launcher/plugins/my-plugin"
```
See [Plugin Development](docs/plugin-development.md) for the full protocol.
## Docs
- [Installation](docs/install.md)
- [Usage & Keybinds](docs/usage.md)
- [Configuration & Theming](docs/configuration.md)
- [Plugin Development](docs/plugin-development.md)

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

View File

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

View File

@@ -8,3 +8,4 @@ async-trait = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tracing = { workspace = true }

View File

@@ -3,8 +3,6 @@ use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use futures::future::join_all; use futures::future::join_all;
pub type PluginName = &'static str;
// --- Newtypes --- // --- Newtypes ---
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
@@ -31,7 +29,9 @@ impl ResultTitle {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] #[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
pub struct Score(u32); pub struct Score(u32);
impl Score { impl Score {
@@ -43,6 +43,21 @@ impl Score {
} }
} }
// --- LaunchAction (port) ---
pub enum LaunchAction {
SpawnProcess(String),
SpawnInTerminal(String),
OpenPath(String),
CopyToClipboard(String),
}
// --- AppLauncher port trait ---
pub trait AppLauncher: Send + Sync {
fn execute(&self, action: &LaunchAction);
}
// --- SearchResult --- // --- SearchResult ---
pub struct SearchResult { pub struct SearchResult {
@@ -51,7 +66,7 @@ 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,
} }
impl std::fmt::Debug for SearchResult { impl std::fmt::Debug for SearchResult {
@@ -69,30 +84,88 @@ impl std::fmt::Debug for SearchResult {
#[async_trait] #[async_trait]
pub trait Plugin: Send + Sync { pub trait Plugin: Send + Sync {
fn name(&self) -> PluginName; fn name(&self) -> &str;
async fn search(&self, query: &str) -> Vec<SearchResult>; async fn search(&self, query: &str) -> Vec<SearchResult>;
fn on_selected(&self, _id: &ResultId) {}
}
// --- SearchEngine port trait ---
#[async_trait]
pub trait SearchEngine: Send + Sync {
async fn search(&self, query: &str) -> Vec<SearchResult>;
fn on_selected(&self, id: &ResultId);
}
// --- NullSearchEngine ---
pub struct NullSearchEngine;
#[async_trait]
impl SearchEngine for NullSearchEngine {
async fn search(&self, _query: &str) -> Vec<SearchResult> {
vec![]
}
fn on_selected(&self, _id: &ResultId) {}
} }
// --- Kernel (Application use case) --- // --- 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 fn on_selected(&self, id: &ResultId) {
for plugin in &self.plugins {
plugin.on_selected(id);
}
} }
pub async fn search(&self, query: &str) -> Vec<SearchResult> { pub async fn search(&self, query: &str) -> Vec<SearchResult> {
let futures = self.plugins.iter().map(|p| p.search(query)); use futures::FutureExt;
let nested: Vec<Vec<SearchResult>> = join_all(futures).await; use std::panic::AssertUnwindSafe;
let mut flat: Vec<SearchResult> = nested.into_iter().flatten().collect();
let futures = self
.plugins
.iter()
.map(|p| AssertUnwindSafe(p.search(query)).catch_unwind());
let outcomes = join_all(futures).await;
let mut flat: Vec<SearchResult> = outcomes
.into_iter()
.zip(self.plugins.iter())
.flat_map(|(outcome, plugin)| match outcome {
Ok(results) => results,
Err(_) => {
tracing::error!(plugin = plugin.name(), "plugin panicked during search");
vec![]
}
})
.collect();
flat.sort_by(|a, b| b.score.cmp(&a.score)); flat.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
}
fn on_selected(&self, id: &ResultId) {
self.on_selected(id);
}
}
// --- Tests --- // --- Tests ---
#[cfg(test)] #[cfg(test)]
@@ -111,7 +184,7 @@ mod tests {
#[async_trait] #[async_trait]
impl Plugin for MockPlugin { impl Plugin for MockPlugin {
fn name(&self) -> PluginName { fn name(&self) -> &str {
"mock" "mock"
} }
@@ -125,7 +198,7 @@ mod tests {
description: None, description: None,
icon: None, icon: None,
score: Score::new(*score), score: Score::new(*score),
on_execute: Arc::new(|| {}), action: LaunchAction::SpawnProcess("mock".to_string()),
}) })
.collect() .collect()
} }
@@ -148,7 +221,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 +232,49 @@ 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);
} }
struct PanicPlugin;
#[async_trait]
impl Plugin for PanicPlugin {
fn name(&self) -> &str {
"panic-plugin"
}
async fn search(&self, _query: &str) -> Vec<SearchResult> {
panic!("test panic");
}
}
#[tokio::test]
async fn kernel_continues_after_plugin_panic() {
let panic_plugin = Arc::new(PanicPlugin);
let normal_plugin = Arc::new(MockPlugin::returns(vec![("survivor", 5)]));
let k = Kernel::new(vec![panic_plugin, normal_plugin], 8);
let results = k.search("q").await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].title.as_str(), "survivor");
}
#[tokio::test]
async fn kernel_truncates_at_max_results() {
let plugin = Arc::new(MockPlugin::returns(vec![
("a", 10),
("b", 9),
("c", 8),
("d", 7),
("e", 6),
]));
let k = Kernel::new(vec![plugin], 3);
let results = k.search("q").await;
assert_eq!(results.len(), 3);
assert_eq!(results[0].score.value(), 10);
assert_eq!(results[2].score.value(), 8);
}
} }

View File

@@ -4,3 +4,5 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
k-launcher-kernel = { path = "../k-launcher-kernel" }
libc = "0.2"

View File

@@ -1,20 +1,2 @@
/// Configuration for the launcher window. mod unix_launcher;
pub struct WindowConfig { pub use unix_launcher::UnixAppLauncher;
pub width: f32,
pub height: f32,
pub decorations: bool,
pub transparent: bool,
pub resizable: bool,
}
impl WindowConfig {
pub fn launcher() -> Self {
Self {
width: 600.0,
height: 400.0,
decorations: false,
transparent: true,
resizable: false,
}
}
}

View File

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

View File

@@ -0,0 +1,16 @@
[package]
name = "k-launcher-plugin-host"
version = "0.1.0"
edition = "2024"
[lib]
name = "k_launcher_plugin_host"
path = "src/lib.rs"
[dependencies]
async-trait = { workspace = true }
k-launcher-kernel = { path = "../k-launcher-kernel" }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["process", "io-util", "sync", "time"] }
tracing = { workspace = true }

View File

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

View File

@@ -0,0 +1,15 @@
[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" }
tokio = { workspace = true }

View File

@@ -0,0 +1,186 @@
use std::sync::{Arc, mpsc};
use egui::{Color32, Key, ViewportCommand};
use k_launcher_kernel::{AppLauncher, SearchEngine, SearchResult};
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) {
self.engine.on_selected(&result.id);
self.launcher.execute(&result.action);
}
ctx.send_viewport_cmd(ViewportCommand::Close);
return;
}
let frame = egui::Frame::new()
.fill(BG)
.stroke(egui::Stroke::new(1.0, BORDER_COLOR))
.inner_margin(egui::Margin::same(12))
.corner_radius(egui::CornerRadius::same(8));
egui::CentralPanel::default().frame(frame).show(ctx, |ui| {
let response = ui.add_sized(
[ui.available_width(), 36.0],
egui::TextEdit::singleline(&mut self.query)
.hint_text("Search...")
.font(egui::TextStyle::Heading),
);
if response.changed() {
self.selected = 0;
self.trigger_search(self.query.clone());
}
response.request_focus();
ui.add_space(8.0);
if self.results.is_empty() && !self.query.is_empty() {
ui.add_space(20.0);
ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| {
ui.colored_label(DIM_TEXT, "No results");
});
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>,
window_cfg: &k_launcher_config::WindowCfg,
) -> Result<(), eframe::Error> {
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([window_cfg.width, window_cfg.height])
.with_decorations(window_cfg.decorations)
.with_transparent(window_cfg.transparent)
.with_resizable(window_cfg.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)))),
)
}

View File

@@ -0,0 +1,13 @@
mod app;
use std::sync::Arc;
use k_launcher_kernel::{AppLauncher, SearchEngine};
pub fn run(
engine: Arc<dyn SearchEngine>,
launcher: Arc<dyn AppLauncher>,
window_cfg: &k_launcher_config::WindowCfg,
) -> Result<(), eframe::Error> {
app::run(engine, launcher, window_cfg)
}

View File

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

View File

@@ -1,34 +1,57 @@
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::{Space, column, container, image, row, scrollable, svg, text, text_input},
window, window,
}; };
use k_launcher_kernel::{Kernel, SearchResult}; use k_launcher_config::AppearanceCfg;
use k_launcher_kernel::{AppLauncher, NullSearchEngine, SearchEngine, SearchResult};
use crate::theme;
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"));
#[derive(Clone)]
pub(crate) struct EngineHandle(Arc<dyn SearchEngine>);
impl std::fmt::Debug for EngineHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("EngineHandle")
}
}
fn rgba(c: &[f32; 4]) -> Color {
Color::from_rgba8(c[0] as u8, c[1] as u8, c[2] as u8, c[3])
}
pub struct KLauncherApp { 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,
error: Option<String>,
search_epoch: u64,
} }
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,
error: None,
search_epoch: 0,
} }
} }
} }
@@ -36,23 +59,45 @@ impl KLauncherApp {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Message { pub enum Message {
QueryChanged(String), QueryChanged(String),
ResultsReady(Arc<Vec<SearchResult>>), ResultsReady(u64, Arc<Vec<SearchResult>>),
KeyPressed(KeyEvent), KeyPressed(KeyEvent),
EngineReady(EngineHandle),
EngineInitFailed(String),
} }
fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> { fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
match message { match message {
Message::QueryChanged(q) => { Message::QueryChanged(q) => {
state.error = None;
state.query = q.clone(); state.query = q.clone();
state.selected = 0; state.selected = 0;
let kernel = state.kernel.clone(); state.search_epoch += 1;
let epoch = state.search_epoch;
let engine = state.engine.clone();
Task::perform( Task::perform(
async move { kernel.search(&q).await }, async move {
|results| Message::ResultsReady(Arc::new(results)), tokio::time::sleep(std::time::Duration::from_millis(50)).await;
(epoch, engine.search(&q).await)
},
|(epoch, results)| Message::ResultsReady(epoch, Arc::new(results)),
) )
} }
Message::ResultsReady(results) => { Message::ResultsReady(epoch, results) => {
state.results = results; if epoch == state.search_epoch {
state.results = results;
}
Task::none()
}
Message::EngineInitFailed(msg) => {
state.error = Some(msg);
Task::none()
}
Message::EngineReady(handle) => {
state.engine = handle.0;
if !state.query.is_empty() {
let q = state.query.clone();
return Task::done(Message::QueryChanged(q));
}
Task::none() Task::none()
} }
Message::KeyPressed(event) => { Message::KeyPressed(event) => {
@@ -65,7 +110,9 @@ fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
}; };
let len = state.results.len(); let len = state.results.len();
match named { match named {
Named::Escape => std::process::exit(0), Named::Escape => {
std::process::exit(0);
}
Named::ArrowDown => { Named::ArrowDown => {
if len > 0 { if len > 0 {
state.selected = (state.selected + 1).min(len - 1); state.selected = (state.selected + 1).min(len - 1);
@@ -78,7 +125,8 @@ 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)(); state.engine.on_selected(&result.id);
state.launcher.execute(&result.action);
} }
std::process::exit(0); std::process::exit(0);
} }
@@ -90,13 +138,28 @@ 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,48 +168,99 @@ 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)
}; };
let icon_el: Element<'_, Message> = match &result.icon { let icon_el: Element<'_, Message> = match &result.icon {
Some(p) if p.ends_with(".svg") => Some(p) if p.ends_with(".svg") => {
svg(svg::Handle::from_path(p)).width(24).height(24).into(), svg(svg::Handle::from_path(p)).width(24).height(24).into()
Some(p) => }
image(image::Handle::from_path(p)).width(24).height(24).into(), Some(p) => image(image::Handle::from_path(p))
.width(24)
.height(24)
.into(),
None => Space::new().width(24).height(24).into(), None => Space::new().width(24).height(24).into(),
}; };
container( let title_col: Element<'_, Message> = if let Some(desc) = &result.description {
row![icon_el, text(result.title.as_str()).size(15)] column![
.spacing(8) text(result.title.as_str()).size(title_size),
.align_y(iced::Center), text(desc)
) .size(desc_size)
.color(Color::from_rgba8(210, 215, 230, 1.0)),
]
.into()
} else {
text(result.title.as_str()).size(title_size).into()
};
container(row![icon_el, title_col].spacing(8).align_y(iced::Center))
.width(Length::Fill) .width(Length::Fill)
.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 maybe_error: Option<Element<'_, Message>> = state.error.as_ref().map(|msg| {
container(
text(msg.as_str())
.size(12.0)
.color(Color::from_rgba8(255, 80, 80, 1.0)),
)
.width(Length::Fill)
.padding([4, 12])
.into()
});
let mut content_children: Vec<Element<'_, Message>> =
vec![search_bar.into(), results_list.into()];
if let Some(err) = maybe_error {
content_children.push(err);
}
let content = column(content_children)
.spacing(8) .spacing(8)
.padding(12) .padding(12)
.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,25 +273,46 @@ fn subscription(_state: &KLauncherApp) -> Subscription<Message> {
}) })
} }
pub fn run(kernel: Arc<Kernel>) -> iced::Result { pub fn run(
engine_factory: Arc<dyn Fn() -> Arc<dyn SearchEngine> + Send + Sync>,
launcher: Arc<dyn AppLauncher>,
window_cfg: &k_launcher_config::WindowCfg,
appearance_cfg: AppearanceCfg,
) -> iced::Result {
iced::application( iced::application(
move || { move || {
let app = KLauncherApp::new(kernel.clone()); let app = KLauncherApp::new(
Arc::new(NullSearchEngine),
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) let ef = engine_factory.clone();
let init = Task::perform(
async move {
tokio::task::spawn_blocking(move || ef())
.await
.map_err(|e| format!("Engine init failed: {e}"))
},
|result| match result {
Ok(e) => Message::EngineReady(EngineHandle(e)),
Err(msg) => Message::EngineInitFailed(msg),
},
);
(app, Task::batch([focus, init]))
}, },
update, update,
view, view,
) )
.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(window_cfg.width, window_cfg.height),
position: window::Position::Centered, position: window::Position::Centered,
decorations: false, decorations: window_cfg.decorations,
transparent: true, transparent: window_cfg.transparent,
resizable: false, resizable: window_cfg.resizable,
..Default::default() ..Default::default()
}) })
.run() .run()
} }

View File

@@ -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_factory: Arc<dyn Fn() -> Arc<dyn SearchEngine> + Send + Sync>,
app::run(kernel) launcher: Arc<dyn AppLauncher>,
window_cfg: &WindowCfg,
appearance_cfg: AppearanceCfg,
) -> iced::Result {
app::run(engine_factory, launcher, window_cfg, appearance_cfg)
} }

View File

@@ -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,
},
])),
}
}
}

View File

@@ -4,14 +4,38 @@ version = "0.1.0"
edition = "2024" edition = "2024"
default-run = "k-launcher" default-run = "k-launcher"
[profile.release]
lto = true
strip = true
codegen-units = 1
opt-level = 3
[[bin]] [[bin]]
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-plugin-host = { path = "../k-launcher-plugin-host" }
k-launcher-os-bridge = { path = "../k-launcher-os-bridge" }
k-launcher-ui = { path = "../k-launcher-ui" } k-launcher-ui = { 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" }
dirs = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tracing = { workspace = true }
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View File

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

View File

@@ -0,0 +1,27 @@
use std::sync::Arc;
use k_launcher_kernel::Kernel;
use k_launcher_os_bridge::UnixAppLauncher;
#[cfg(target_os = "linux")]
use plugin_apps::linux::FsDesktopEntrySource;
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
use plugin_calc::CalcPlugin;
use plugin_cmd::CmdPlugin;
use plugin_files::FilesPlugin;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cfg = k_launcher_config::load();
let launcher = Arc::new(UnixAppLauncher::new());
let frecency = FrecencyStore::load();
let kernel: Arc<dyn k_launcher_kernel::SearchEngine> = Arc::new(Kernel::new(
vec![
Arc::new(CmdPlugin::new()),
Arc::new(CalcPlugin::new()),
Arc::new(FilesPlugin::new()),
Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)),
],
8,
));
k_launcher_ui_egui::run(kernel, launcher, &cfg.window)?;
Ok(())
}

View File

@@ -7,13 +7,17 @@ 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 }
bincode = { workspace = true }
dirs = { workspace = true }
k-launcher-kernel = { path = "../../k-launcher-kernel" } k-launcher-kernel = { path = "../../k-launcher-kernel" }
libc = "0.2" nucleo-matcher = "0.3"
serde = { workspace = true }
serde_json = "1.0"
tokio = { workspace = true } tokio = { workspace = true }
xdg = "2" tracing = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
linicon = "2.3.0"
xdg = "3"

View File

@@ -0,0 +1,154 @@
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 Some(data_home) = xdg::BaseDirectories::new().get_data_home() else {
tracing::warn!("XDG_DATA_HOME unavailable; frecency disabled (in-memory only)");
return Arc::new(Self {
path: PathBuf::from("/dev/null"),
data: Mutex::new(HashMap::new()),
});
};
let path = data_home.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 json = {
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;
serde_json::to_string(&*data).ok()
}; // lock released here
if let Some(json) = json {
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
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);
entry.count * decay_factor(age_secs)
}
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);
(id.clone(), entry.count * decay_factor(age_secs))
})
.collect();
scored.sort_by(|a, b| b.1.cmp(&a.1));
scored.into_iter().take(n).map(|(id, _)| id).collect()
}
}
fn decay_factor(age_secs: u64) -> u32 {
if age_secs < 3600 {
4
} else if age_secs < 86400 {
2
} else {
1
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_store() -> Arc<FrecencyStore> {
Arc::new(FrecencyStore {
path: PathBuf::from("/dev/null"),
data: Mutex::new(HashMap::new()),
})
}
#[test]
fn record_increments_count() {
let store = make_store();
store.record("app-firefox");
store.record("app-firefox");
let data = store.data.lock().unwrap();
assert_eq!(data["app-firefox"].count, 2);
}
#[test]
fn record_updates_last_used() {
let store = make_store();
store.record("app-firefox");
let data = store.data.lock().unwrap();
assert!(data["app-firefox"].last_used > 0);
}
#[test]
fn top_ids_returns_sorted_order() {
let store = make_store();
store.record("app-firefox");
store.record("app-code");
store.record("app-code");
store.record("app-code");
let top = store.top_ids(2);
assert_eq!(top[0], "app-code");
assert_eq!(top[1], "app-firefox");
}
}

View File

@@ -1,8 +1,17 @@
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,
path::{Path, PathBuf},
sync::{Arc, RwLock},
};
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, ResultId, ResultTitle, Score, SearchResult};
use crate::frecency::FrecencyStore;
// --- Domain newtypes --- // --- Domain newtypes ---
@@ -48,6 +57,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,214 +70,268 @@ 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, keywords_lc: Vec<String>,
category: Option<String>,
icon: Option<String>, icon: Option<String>,
on_execute: Arc<dyn Fn() + Send + Sync>, exec: String,
}
// --- Serializable cache data (no closures) ---
#[derive(serde::Serialize, serde::Deserialize)]
struct CachedEntryData {
id: String,
name: String,
keywords_lc: Vec<String>,
category: Option<String>,
icon: Option<String>,
exec: String,
}
fn cache_path() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("k-launcher/apps.bin"))
}
fn load_from_path(path: &Path) -> Option<HashMap<String, CachedEntry>> {
let data = std::fs::read(path).ok()?;
let (entries_data, _): (Vec<CachedEntryData>, _) =
bincode::serde::decode_from_slice(&data, bincode::config::standard()).ok()?;
let map = entries_data
.into_iter()
.map(|e| {
let cached = CachedEntry {
id: e.id.clone(),
name: AppName::new(e.name),
keywords_lc: e.keywords_lc,
category: e.category,
icon: e.icon,
exec: e.exec,
};
(e.id, cached)
})
.collect();
Some(map)
}
fn save_to_path(path: &Path, entries: &HashMap<String, CachedEntry>) {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).ok();
}
let data: Vec<CachedEntryData> = entries
.values()
.map(|e| CachedEntryData {
id: e.id.clone(),
name: e.name.as_str().to_string(),
keywords_lc: e.keywords_lc.clone(),
category: e.category.clone(),
icon: e.icon.clone(),
exec: e.exec.clone(),
})
.collect();
if let Ok(encoded) = bincode::serde::encode_to_vec(&data, bincode::config::standard()) {
std::fs::write(path, encoded).ok();
}
}
fn build_entries(
source: &impl DesktopEntrySource,
_frecency: &Arc<FrecencyStore>,
) -> HashMap<String, CachedEntry> {
source
.entries()
.into_iter()
.map(|e| {
let id = format!("app-{}:{}", e.name.as_str(), e.exec.as_str());
let keywords_lc = e.keywords.iter().map(|k| k.to_lowercase()).collect();
#[cfg(target_os = "linux")]
let icon = e
.icon
.as_ref()
.and_then(|p| linux::resolve_icon_path(p.as_str()));
#[cfg(not(target_os = "linux"))]
let icon: Option<String> = None;
let exec = e.exec.as_str().to_string();
let cached = CachedEntry {
id: id.clone(),
keywords_lc,
category: e.category,
icon,
exec,
name: e.name,
};
(id, cached)
})
.collect()
} }
// --- Plugin --- // --- Plugin ---
pub struct AppsPlugin { pub struct AppsPlugin {
entries: Vec<CachedEntry>, entries: Arc<RwLock<HashMap<String, CachedEntry>>>,
frecency: Arc<FrecencyStore>,
} }
impl AppsPlugin { impl AppsPlugin {
pub fn new(source: impl DesktopEntrySource) -> Self { pub fn new(source: impl DesktopEntrySource + 'static, frecency: Arc<FrecencyStore>) -> Self {
let entries = source Self::new_impl(source, frecency, cache_path())
.entries() }
.into_iter()
.map(|e| { fn new_impl(
let name_lc = e.name.as_str().to_lowercase(); source: impl DesktopEntrySource + 'static,
let icon = e.icon.as_ref().and_then(|p| resolve_icon_path(p.as_str())); frecency: Arc<FrecencyStore>,
let exec = e.exec.clone(); cp: Option<PathBuf>,
CachedEntry { ) -> Self {
name_lc, let cached = cp.as_deref().and_then(load_from_path);
icon,
on_execute: Arc::new(move || { let entries = if let Some(from_cache) = cached {
let parts: Vec<&str> = exec.as_str().split_whitespace().collect(); // Serve cache immediately; refresh in background.
if let Some((cmd, args)) = parts.split_first() { let map = Arc::new(RwLock::new(from_cache));
let _ = unsafe { let entries_bg = Arc::clone(&map);
Command::new(cmd) let frecency_bg = Arc::clone(&frecency);
.args(args) let cp_bg = cp.clone();
.stdin(Stdio::null()) std::thread::spawn(move || {
.stdout(Stdio::null()) let fresh = build_entries(&source, &frecency_bg);
.stderr(Stdio::null()) if let Some(path) = cp_bg {
.pre_exec(|| { save_to_path(&path, &fresh);
libc::setsid();
Ok(())
})
.spawn()
};
}
}),
name: e.name,
} }
}) *entries_bg.write().unwrap() = fresh;
.collect(); });
Self { entries } map
} else {
// No cache: build synchronously, then persist.
let initial = build_entries(&source, &frecency);
if let Some(path) = &cp {
save_to_path(path, &initial);
}
Arc::new(RwLock::new(initial))
};
Self { entries, frecency }
}
#[cfg(test)]
fn new_for_test(
source: impl DesktopEntrySource + 'static,
frecency: Arc<FrecencyStore>,
) -> Self {
Self::new_impl(source, frecency, None)
} }
} }
fn resolve_icon_path(name: &str) -> Option<String> { fn initials(name_lc: &str) -> String {
if name.starts_with('/') && Path::new(name).exists() { name_lc
return Some(name.to_string()); .split_whitespace()
} .filter_map(|w| w.chars().next())
let candidates = [ .collect()
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: &str, query: &str) -> Option<u32> {
if name_lc == query_lc { use nucleo_matcher::{
Some(100) Config, Matcher, Utf32Str,
} else if name_lc.starts_with(query_lc) { pattern::{CaseMatching, Normalization, Pattern},
Some(80) };
} else if name_lc.contains(query_lc) {
Some(60) let mut matcher = Matcher::new(Config::DEFAULT);
let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
let mut name_chars: Vec<char> = name.chars().collect();
let haystack = Utf32Str::new(name, &mut name_chars);
let score = pattern.score(haystack, &mut matcher);
if let Some(s) = score {
let name_lc = name.to_lowercase();
let query_lc = query.to_lowercase();
let bonus: u32 = if initials(&name_lc).starts_with(&query_lc) {
20
} else {
0
};
Some(s.saturating_add(bonus))
} else { } else {
None 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]
impl Plugin for AppsPlugin { impl Plugin for AppsPlugin {
fn name(&self) -> PluginName { fn name(&self) -> &str {
"apps" "apps"
} }
fn on_selected(&self, id: &ResultId) {
self.frecency.record(id.as_str());
}
async fn search(&self, query: &str) -> Vec<SearchResult> { async fn search(&self, query: &str) -> Vec<SearchResult> {
let entries = self.entries.read().unwrap();
if query.is_empty() { if query.is_empty() {
return vec![]; return self
.frecency
.top_ids(5)
.iter()
.filter_map(|id| {
let e = entries.get(id)?;
let score = self.frecency.frecency_score(id).max(1);
Some(SearchResult {
id: ResultId::new(id),
title: ResultTitle::new(e.name.as_str()),
description: e.category.clone(),
icon: e.icon.clone(),
score: Score::new(score),
action: LaunchAction::SpawnProcess(e.exec.clone()),
})
})
.collect();
} }
let query_lc = query.to_lowercase(); let query_lc = query.to_lowercase();
self.entries 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.as_str(), query).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()),
}) })
}) })
.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 +339,32 @@ 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 +374,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 +387,135 @@ 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_for_test(
let p = AppsPlugin::new(source); MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
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_for_test(
let p = AppsPlugin::new(source); MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
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_for_test(
let p = AppsPlugin::new(source); MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
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!(score_match("visual studio code", "vsc").is_some());
}
#[test]
fn score_match_exact_beats_prefix() {
let exact = score_match("firefox", "firefox");
let prefix = score_match("firefox", "fire");
let abbrev = score_match("gnu firefox", "gf");
let substr = score_match("ice firefox", "fire");
assert!(exact.is_some());
assert!(prefix.is_some());
assert!(abbrev.is_some());
assert!(substr.is_some());
assert!(exact.unwrap() > prefix.unwrap());
}
#[tokio::test]
async fn apps_abbreviation_match() {
let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Visual Studio Code", "code")]),
ephemeral_frecency(),
);
let results = p.search("vsc").await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].title.as_str(), "Visual Studio Code");
assert!(results[0].score.value() > 0);
}
#[tokio::test]
async fn apps_keyword_match() {
let p = AppsPlugin::new_for_test(
MockSource::with_keywords(vec![("Code", "code", vec!["editor", "ide"])]),
ephemeral_frecency(),
);
let results = p.search("editor").await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].score.value(), 50);
}
#[tokio::test]
async fn apps_fuzzy_typo_match() {
let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
let results = p.search("frefox").await;
assert!(
!results.is_empty(),
"nucleo should fuzzy-match 'frefox' to 'Firefox'"
);
assert!(results[0].score.value() > 0);
}
#[test]
fn humanize_category_splits_camel_case() {
assert_eq!(humanize_category("TextEditor"), "Text Editor");
assert_eq!(humanize_category("WebBrowser"), "Web Browser");
assert_eq!(humanize_category("Development"), "Development");
}
#[tokio::test]
async fn apps_category_appears_in_description() {
let p = AppsPlugin::new_for_test(
MockSource::with_categories(vec![("Code", "code", "Text Editor")]),
ephemeral_frecency(),
);
let results = p.search("code").await;
assert_eq!(results[0].description.as_deref(), Some("Text Editor"));
}
#[tokio::test]
async fn apps_empty_query_returns_top_frecent() {
let frecency = ephemeral_frecency();
frecency.record("app-Code:code");
frecency.record("app-Code:code");
frecency.record("app-Firefox:firefox");
let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Firefox", "firefox"), ("Code", "code")]),
frecency,
);
let results = p.search("").await;
assert_eq!(results.len(), 2);
assert_eq!(results[0].title.as_str(), "Code");
}
#[test]
fn apps_loads_from_cache_when_source_is_empty() {
let frecency = ephemeral_frecency();
let cache_file =
std::env::temp_dir().join(format!("k-launcher-test-{}.bin", std::process::id()));
// Build entries from a real source and save to temp path
let source = MockSource::with(vec![("Firefox", "firefox")]);
let entries = build_entries(&source, &frecency);
save_to_path(&cache_file, &entries);
// Load from temp path — should contain Firefox
let loaded = load_from_path(&cache_file).unwrap();
assert!(loaded.contains_key("app-Firefox:firefox"));
std::fs::remove_file(&cache_file).ok();
}
} }

View File

@@ -0,0 +1,217 @@
use std::path::Path;
use crate::humanize_category;
use crate::{AppName, DesktopEntry, DesktopEntrySource, ExecCommand, IconPath};
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();
let xdg = xdg::BaseDirectories::new();
if let Some(data_home) = xdg.get_data_home() {
dirs.push(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(crate) fn clean_exec(exec: &str) -> String {
// Tokenize respecting double-quoted strings, then filter field codes.
let mut tokens: Vec<String> = Vec::new();
let mut chars = exec.chars().peekable();
while let Some(&ch) = chars.peek() {
if ch.is_whitespace() {
chars.next();
continue;
}
if ch == '"' {
// Consume opening quote
chars.next();
let mut token = String::from('"');
while let Some(&c) = chars.peek() {
chars.next();
if c == '"' {
token.push('"');
break;
}
token.push(c);
}
// Strip embedded field codes like %f inside the quoted string
// (between the quotes, before re-assembling)
let inner = &token[1..token.len().saturating_sub(1)];
let cleaned_inner: String = inner
.split_whitespace()
.filter(|s| !is_field_code(s))
.collect::<Vec<_>>()
.join(" ");
tokens.push(format!("\"{cleaned_inner}\""));
} else {
let mut token = String::new();
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
break;
}
chars.next();
token.push(c);
}
if !is_field_code(&token) {
tokens.push(token);
}
}
}
tokens.join(" ")
}
fn is_field_code(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 2 && b[0] == b'%' && b[1].is_ascii_alphabetic()
}
pub fn resolve_icon_path(name: &str) -> Option<String> {
if name.starts_with('/') && Path::new(name).exists() {
return Some(name.to_string());
}
// Try linicon freedesktop theme traversal
let themes = ["hicolor", "Adwaita", "breeze", "Papirus"];
for theme in &themes {
if let Some(icon_path) = linicon::lookup_icon(name)
.from_theme(theme)
.with_size(48)
.find_map(|r| r.ok())
{
return Some(icon_path.path.to_string_lossy().into_owned());
}
}
// Fallback to pixmaps
let candidates = [
format!("/usr/share/pixmaps/{name}.png"),
format!("/usr/share/pixmaps/{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 = clean_exec(&exec?);
Some(DesktopEntry {
name: AppName::new(name?),
exec: ExecCommand::new(exec_clean),
icon: icon.map(IconPath::new),
category,
keywords,
})
}
#[cfg(test)]
mod exec_tests {
use super::clean_exec;
#[test]
fn strips_bare_field_code() {
assert_eq!(clean_exec("app --file %f"), "app --file");
}
#[test]
fn strips_multiple_field_codes() {
assert_eq!(clean_exec("app %U --flag"), "app --flag");
}
#[test]
fn preserves_quoted_value() {
assert_eq!(
clean_exec(r#"app --arg="value" %U"#),
r#"app --arg="value""#
);
}
#[test]
fn handles_plain_exec() {
assert_eq!(clean_exec("firefox"), "firefox");
}
}

View File

@@ -1 +0,0 @@
fn main() {}

View File

@@ -7,12 +7,8 @@ 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 = "13"
k-launcher-kernel = { path = "../../k-launcher-kernel" } k-launcher-kernel = { path = "../../k-launcher-kernel" }
tokio = { workspace = true } tokio = { workspace = true }

View File

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

View File

@@ -1 +0,0 @@
fn main() {}

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

View File

@@ -0,0 +1,69 @@
use async_trait::async_trait;
use k_launcher_kernel::{LaunchAction, Plugin, 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) -> &str {
"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()),
}]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn cmd_prefix_triggers() {
let p = CmdPlugin::new();
let results = p.search("> echo hello").await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].title.as_str(), "Run: echo hello");
assert_eq!(results[0].score.value(), 95);
}
#[tokio::test]
async fn cmd_empty_remainder_returns_empty() {
let p = CmdPlugin::new();
assert!(p.search(">").await.is_empty());
assert!(p.search("> ").await.is_empty());
}
#[tokio::test]
async fn cmd_no_prefix_returns_empty() {
let p = CmdPlugin::new();
assert!(p.search("echo hello").await.is_empty());
assert!(p.search("firefox").await.is_empty());
}
}

View File

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

View File

@@ -0,0 +1,109 @@
mod platform;
use std::path::Path;
use async_trait::async_trait;
use k_launcher_kernel::{LaunchAction, Plugin, 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) -> &str {
"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)
.map(|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(&path_str),
title: ResultTitle::new(title),
description: Some(path_str.clone()),
icon: None,
score: Score::new(50),
action: LaunchAction::OpenPath(path_str),
}
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn files_ignores_non_path_query() {
let p = FilesPlugin::new();
assert!(p.search("firefox").await.is_empty());
}
#[tokio::test]
async fn files_handles_root() {
let p = FilesPlugin::new();
let results = p.search("/").await;
assert!(!results.is_empty());
}
}

View File

@@ -1,3 +0,0 @@
fn main() {
println!("Hello, world!");
}

View 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()
}

View File

@@ -0,0 +1,12 @@
[package]
name = "plugin-url"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "k-launcher-plugin-url"
path = "src/main.rs"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }

View File

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

51
docs/configuration.md Normal file
View File

@@ -0,0 +1,51 @@
# 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 0255 as floats, a is 0.01.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
# External (dynamic) plugins — repeat block for each plugin
[[plugins.external]]
name = "my-plugin" # display name / identifier
path = "/path/to/my-plugin" # path to executable
args = [] # optional extra arguments
```
## RGBA Format
Colors use `[r, g, b, a]` arrays where:
- `r`, `g`, `b` — red, green, blue channels as floats **0.0255.0**
- `a` — alpha (opacity) as a float **0.01.0**
Example — semi-transparent white: `[255.0, 255.0, 255.0, 0.5]`

57
docs/install.md Normal file
View 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
```

222
docs/plugin-development.md Normal file
View File

@@ -0,0 +1,222 @@
# Plugin Development
Plugins are queried concurrently — the kernel fans out every search to all enabled plugins and merges results by score.
There are two kinds of plugins:
- **External plugins** — executables that speak a JSON protocol over stdin/stdout. Any language, no compilation required. Recommended for community plugins.
- **Built-in plugins** — Rust crates compiled into the binary. For performance-critical or tightly integrated plugins.
---
## External Plugins
An external plugin is any executable that:
1. Reads a JSON object from stdin (one line per query)
2. Writes a JSON array of results to stdout (one line per response)
### Protocol
**Input** (one line, newline-terminated):
```json
{"query": "firefox"}
```
**Output** (one line, newline-terminated):
```json
[{"id":"app-firefox","title":"Firefox","score":80,"description":"Web Browser","action":{"type":"SpawnProcess","cmd":"firefox"}}]
```
The process is kept alive between queries — do **not** exit after each response.
### Action types
| `"type"` | Extra fields | Behavior |
|----------|-------------|---------|
| `SpawnProcess` | `"cmd"` | Launch process directly |
| `CopyToClipboard` | `"text"` | Copy text to clipboard |
| `OpenPath` | `"path"` | Open file/dir with xdg-open |
### Optional result fields
| Field | Type | Description |
|-------|------|-------------|
| `description` | `string` | Secondary line shown below title |
| `icon` | `string` | Icon path (future use) |
### Enabling an external plugin
In `~/.config/k-launcher/config.toml`:
```toml
[[plugins.external]]
name = "my-plugin"
path = "/usr/lib/k-launcher/plugins/my-plugin"
args = [] # optional
```
Multiple `[[plugins.external]]` blocks are supported.
### Example: shell plugin
```bash
#!/usr/bin/env bash
# A plugin that greets the user.
while IFS= read -r line; do
query=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin)['query'])")
if [[ "$query" == hello* ]]; then
echo '[{"id":"greet","title":"Hello, World!","score":80,"action":{"type":"CopyToClipboard","text":"Hello, World!"}}]'
else
echo '[]'
fi
done
```
### Example: Python plugin
```python
#!/usr/bin/env python3
import sys, json
for line in sys.stdin:
query = json.loads(line)["query"]
results = []
if query.startswith("hello"):
results.append({
"id": "greet",
"title": "Hello, World!",
"score": 80,
"action": {"type": "CopyToClipboard", "text": "Hello, World!"},
})
print(json.dumps(results), flush=True)
```
---
## Built-in Plugins (compiled-in)
Built-in plugins implement the `Plugin` trait from `k-launcher-kernel` as Rust crates compiled into the binary.
### 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, ResultId, ResultTitle, Score, SearchResult};
pub struct HelloPlugin;
impl HelloPlugin {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl Plugin for HelloPlugin {
fn name(&self) -> &str {
"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 |
| 9099 | 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
View 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
```

15
packaging/aur/.SRCINFO Normal file
View File

@@ -0,0 +1,15 @@
pkgbase = k-launcher-bin
pkgdesc = GPU-accelerated command palette launcher for Linux (Wayland/X11)
pkgver = 0.1.0
pkgrel = 1
url = https://github.com/GKaszewski/k-launcher
arch = x86_64
license = MIT
depends = wayland
depends = libxkbcommon
provides = k-launcher
conflicts = k-launcher
source = k-launcher-0.1.0::https://github.com/GKaszewski/k-launcher/releases/download/v0.1.0/k-launcher
sha256sums = SKIP
pkgname = k-launcher-bin

17
packaging/aur/PKGBUILD Normal file
View File

@@ -0,0 +1,17 @@
# Maintainer: k-launcher contributors
pkgname=k-launcher-bin
pkgver=0.1.0
pkgrel=1
pkgdesc="GPU-accelerated command palette launcher for Linux (Wayland/X11)"
arch=('x86_64')
url="https://github.com/GKaszewski/k-launcher"
license=('MIT')
depends=('wayland' 'libxkbcommon')
provides=('k-launcher')
conflicts=('k-launcher')
source=("k-launcher-${pkgver}::https://github.com/GKaszewski/k-launcher/releases/download/v${pkgver}/k-launcher")
sha256sums=('SKIP')
package() {
install -Dm755 "k-launcher-${pkgver}" "${pkgdir}/usr/bin/k-launcher"
}

View File

@@ -0,0 +1,11 @@
[Unit]
Description=k-launcher command palette daemon
After=graphical-session.target
[Service]
Type=simple
ExecStart=/usr/bin/k-launcher
Restart=on-failure
[Install]
WantedBy=graphical-session.target

View File

@@ -0,0 +1,8 @@
[Unit]
Description=k-launcher IPC socket
[Socket]
ListenStream=%t/k-launcher.sock
[Install]
WantedBy=sockets.target