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

clean architecture refactor, performance, resilience, DX/UX

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

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

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

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

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

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

fix: update build_entries function signature to ignore frecency parameter

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

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

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

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

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

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

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

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

refactor: remove client module and associated show command logic

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

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

chore(docs): remove unused screenshot file

feature/prod-ready (#1)

Reviewed-on: #1

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

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

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

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

feat(calc): strip underscore digit separators

feat: update dependencies for improved compatibility and performance

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

feat: add support for external plugins and enhance plugin management

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

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

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

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

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

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

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

feat: implement OS bridge and enhance app launcher functionality

feat: add FilesPlugin for file searching and integrate into KLauncher

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

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

refactor: update dependencies and improve keyboard event handling in KLauncherApp

refactor: simplify theme usage and enhance AppsPlugin structure

feat: restructure k-launcher workspace and add core functionality

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

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

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
target/
.worktrees/
docs/superpowers/
.superpowers/

View File

@@ -1,110 +0,0 @@
# 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.

View File

@@ -39,12 +39,12 @@
## 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 |
| Layer | Crates | Responsibility | Allowed Dependencies |
| ------------------ | ----------------------------------------------- | --------------------------------------------- | -------------------- |
| **Domain** | `k-launcher-domain` | Pure value types, newtypes, constants, port traits (Plugin, AppLauncher). | None (Pure Rust + serde + async-trait) |
| **Application** | `k-launcher-kernel` | Kernel orchestrator. | Domain |
| **Infrastructure** | `k-launcher-ui`, `k-launcher-ui-egui`, `k-launcher-ui-core`, `k-launcher-os-bridge`, `k-launcher-plugin-host`, `k-launcher-config`, all `plugin-*` crates | Trait implementations, UI, config, plugins. | Domain, Application |
| **Main** | `k-launcher` | Entry point, DI wiring, logging. | All of the above |
---

135
Cargo.lock generated
View File

@@ -337,6 +337,15 @@ dependencies = [
"objc2 0.5.2",
]
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2 0.6.4",
]
[[package]]
name = "built"
version = "0.8.0"
@@ -714,6 +723,17 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e162d0c2e2068eb736b71e5597eff0b9944e6b973cd9f37b6a288ab9bf20e300"
[[package]]
name = "ctrlc"
version = "3.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
dependencies = [
"dispatch2",
"nix 0.31.2",
"windows-sys 0.61.2",
]
[[package]]
name = "cursor-icon"
version = "1.2.0"
@@ -769,6 +789,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.11.0",
"block2 0.6.2",
"libc",
"objc2 0.6.4",
]
@@ -1102,7 +1124,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75ae8b5984a4863d8a32109a848d038bd6d914f20f010cc141375f7a183c41cf"
dependencies = [
"nix",
"nix 0.29.0",
]
[[package]]
@@ -2038,11 +2060,12 @@ dependencies = [
[[package]]
name = "k-launcher"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"ctrlc",
"dirs",
"iced",
"k-launcher-config",
"k-launcher-domain",
"k-launcher-kernel",
"k-launcher-os-bridge",
"k-launcher-plugin-host",
@@ -2052,6 +2075,7 @@ dependencies = [
"plugin-calc",
"plugin-cmd",
"plugin-files",
"thiserror 2.0.18",
"tokio",
"tracing",
"tracing-appender",
@@ -2060,19 +2084,33 @@ dependencies = [
[[package]]
name = "k-launcher-config"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"dirs",
"k-launcher-domain",
"serde",
"thiserror 2.0.18",
"toml",
"tracing",
]
[[package]]
name = "k-launcher-domain"
version = "0.2.0"
dependencies = [
"async-trait",
"serde",
]
[[package]]
name = "k-launcher-kernel"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"async-trait",
"futures",
"k-launcher-domain",
"plugin-calc",
"plugin-cmd",
"serde",
"tokio",
"tracing",
@@ -2080,44 +2118,60 @@ dependencies = [
[[package]]
name = "k-launcher-os-bridge"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"k-launcher-kernel",
"k-launcher-domain",
"libc",
"tracing",
]
[[package]]
name = "k-launcher-plugin-host"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"async-trait",
"k-launcher-kernel",
"k-launcher-domain",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]]
name = "k-launcher-ui"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"iced",
"k-launcher-config",
"k-launcher-domain",
"k-launcher-kernel",
"k-launcher-os-bridge",
"k-launcher-ui-core",
"tokio",
]
[[package]]
name = "k-launcher-ui-core"
version = "0.2.0"
dependencies = [
"k-launcher-config",
"k-launcher-domain",
"k-launcher-kernel",
]
[[package]]
name = "k-launcher-ui-egui"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"eframe",
"egui",
"k-launcher-config",
"k-launcher-domain",
"k-launcher-kernel",
"k-launcher-ui-core",
"tokio",
"tracing",
]
[[package]]
@@ -2538,6 +2592,18 @@ dependencies = [
"libc",
]
[[package]]
name = "nix"
version = "0.31.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
@@ -2708,7 +2774,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
dependencies = [
"bitflags 2.11.0",
"block2",
"block2 0.5.1",
"libc",
"objc2 0.5.2",
"objc2-core-data",
@@ -2737,7 +2803,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
dependencies = [
"bitflags 2.11.0",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-core-location",
"objc2-foundation 0.2.2",
@@ -2749,7 +2815,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
@@ -2761,7 +2827,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
dependencies = [
"bitflags 2.11.0",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
@@ -2796,7 +2862,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
"objc2-metal",
@@ -2808,7 +2874,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-contacts",
"objc2-foundation 0.2.2",
@@ -2827,7 +2893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
dependencies = [
"bitflags 2.11.0",
"block2",
"block2 0.5.1",
"dispatch",
"libc",
"objc2 0.5.2",
@@ -2861,7 +2927,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
@@ -2874,7 +2940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
dependencies = [
"bitflags 2.11.0",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
@@ -2886,7 +2952,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
dependencies = [
"bitflags 2.11.0",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
"objc2-metal",
@@ -2921,7 +2987,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
dependencies = [
"bitflags 2.11.0",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-cloud-kit",
"objc2-core-data",
@@ -2941,7 +3007,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
dependencies = [
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
@@ -2953,7 +3019,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
dependencies = [
"bitflags 2.11.0",
"block2",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-core-location",
"objc2-foundation 0.2.2",
@@ -3102,14 +3168,15 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "plugin-apps"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"async-trait",
"bincode",
"dirs",
"k-launcher-kernel",
"k-launcher-domain",
"linicon",
"nucleo-matcher",
"parking_lot",
"serde",
"serde_json",
"tokio",
@@ -3119,35 +3186,35 @@ dependencies = [
[[package]]
name = "plugin-calc"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"async-trait",
"evalexpr",
"k-launcher-kernel",
"k-launcher-domain",
"tokio",
]
[[package]]
name = "plugin-cmd"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"async-trait",
"k-launcher-kernel",
"k-launcher-domain",
"tokio",
]
[[package]]
name = "plugin-files"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"async-trait",
"k-launcher-kernel",
"k-launcher-domain",
"tokio",
]
[[package]]
name = "plugin-url"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"serde",
"serde_json",
@@ -5300,7 +5367,7 @@ dependencies = [
"android-activity",
"atomic-waker",
"bitflags 2.11.0",
"block2",
"block2 0.5.1",
"bytemuck",
"calloop 0.13.0",
"cfg_aliases",

View File

@@ -2,10 +2,12 @@
members = [
"crates/k-launcher",
"crates/k-launcher-config",
"crates/k-launcher-domain",
"crates/k-launcher-kernel",
"crates/k-launcher-os-bridge",
"crates/k-launcher-plugin-host",
"crates/k-launcher-ui",
"crates/k-launcher-ui-core",
"crates/plugins/plugin-apps",
"crates/plugins/plugin-calc",
"crates/plugins/plugin-cmd",
@@ -16,10 +18,12 @@ members = [
default-members = [
"crates/k-launcher",
"crates/k-launcher-config",
"crates/k-launcher-domain",
"crates/k-launcher-kernel",
"crates/k-launcher-os-bridge",
"crates/k-launcher-plugin-host",
"crates/k-launcher-ui",
"crates/k-launcher-ui-core",
"crates/plugins/plugin-apps",
"crates/plugins/plugin-calc",
"crates/plugins/plugin-cmd",
@@ -29,13 +33,27 @@ default-members = [
resolver = "2"
[workspace.dependencies]
k-launcher = { path = "crates/k-launcher" }
k-launcher-config = { path = "crates/k-launcher-config" }
k-launcher-domain = { path = "crates/k-launcher-domain" }
k-launcher-kernel = { path = "crates/k-launcher-kernel" }
k-launcher-os-bridge = { path = "crates/k-launcher-os-bridge" }
k-launcher-plugin-host = { path = "crates/k-launcher-plugin-host" }
k-launcher-ui = { path = "crates/k-launcher-ui" }
k-launcher-ui-core = { path = "crates/k-launcher-ui-core" }
k-launcher-ui-egui = { path = "crates/k-launcher-ui-egui" }
plugin-apps = { path = "crates/plugins/plugin-apps" }
plugin-calc = { path = "crates/plugins/plugin-calc" }
plugin-cmd = { path = "crates/plugins/plugin-cmd" }
plugin-files = { path = "crates/plugins/plugin-files" }
plugin-url = { path = "crates/plugins/plugin-url" }
async-trait = "0.1"
bincode = { version = "2", features = ["serde"] }
dirs = "6.0"
futures = "0.3"
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_json = "1"
tokio = { version = "1.35", features = ["rt-multi-thread", "macros"] }
toml = "1.0"
thiserror = "2"
parking_lot = "0.12"
tracing = "0.1"
ctrlc = "3"

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Gabriel Kaszewski
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,4 +1,4 @@
.PHONY: build build-egui dev check clippy fmt fmt-check test run run-egui install install-egui clean
.PHONY: build build-egui dev check test fmt run run-egui install install-egui clean
RELEASE_BIN := target/release/k-launcher
EGUI_BIN := target/release/k-launcher-egui
@@ -11,23 +11,19 @@ build-egui:
cargo build --release -p k-launcher --features egui --bin k-launcher-egui
dev:
cargo build
RUST_LOG=debug cargo run
check:
cargo check --workspace
clippy:
cargo clippy --workspace -- -D warnings
fmt:
cargo fmt --all
fmt-check:
cargo fmt --all -- --check
cargo clippy --workspace -- -D warnings
cargo test --workspace
test:
cargo test --workspace
fmt:
cargo fmt --all
run:
cargo run --release

View File

@@ -1,29 +1,54 @@
# k-launcher
A lightweight, GPU-accelerated command palette for Linux (Wayland/X11). Zero Electron — every pixel rendered via WGPU. Async search that never blocks the UI.
A lightweight command palette for Linux (Wayland/X11). Fuzzy search, frecency ranking, plugin system. Written in Rust.
## Quick Start
```bash
git clone https://github.com/GKaszewski/k-launcher
cd k-launcher
cargo build --release
./target/release/k-launcher
make install
```
Or with cargo directly:
```bash
cargo build --release
cp target/release/k-launcher ~/.local/bin/
```
### Arch Linux (AUR)
```bash
yay -S k-launcher
```
## Usage
| Input | What it does | Example |
|---|---|---|
| any text | Fuzzy-search installed apps | `firefox` |
| empty | Show most-used apps (frecency) | |
| `>` prefix | Run shell command in terminal | `> htop` |
| `=` or math | Evaluate expression, copy result | `2^10 + 5` |
| `/` or `~/` | Browse filesystem | `~/Documents` |
## Keybinds
| Key | Action |
| --------- | --------------- |
| Type | Filter results |
| `↑` / `↓` | Navigate |
| `Enter` | Launch selected |
| `Escape` | Close |
| Key | Action |
|---|---|
| `↑` / `↓` | Navigate results |
| `Enter` | Launch / copy |
| `Escape` | Close |
## Configuration
`~/.config/k-launcher/config.toml` — all fields optional, sensible defaults.
See [config.example.toml](config.example.toml) for all available options.
## Compositor Setup
k-launcher uses a normal window; configure your compositor to float it.
**Hyprland** (`~/.config/hypr/hyprland.conf`):
```
@@ -39,24 +64,15 @@ for_window [app_id="k-launcher"] floating enable, move position center
bindsym Mod4+space exec k-launcher
```
## Built-in Plugins
## 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:
Built-in plugins (calc, apps, shell, files) are enabled by default. External plugins communicate via JSON over stdin/stdout — any language, no recompilation:
```toml
# ~/.config/k-launcher/config.toml
[[plugins.external]]
name = "my-plugin"
path = "/usr/lib/k-launcher/plugins/my-plugin"
path = "/path/to/plugin"
timeout_secs = 5
```
See [Plugin Development](docs/plugin-development.md) for the full protocol.
@@ -64,6 +80,11 @@ 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)
- [Usage](docs/usage.md)
- [Configuration](docs/configuration.md)
- [Plugin Development](docs/plugin-development.md)
- `man k-launcher`
## License
[MIT](LICENSE)

52
config.example.toml Normal file
View File

@@ -0,0 +1,52 @@
# k-launcher configuration
# Copy to ~/.config/k-launcher/config.toml
[window]
width = 600.0
height = 400.0
decorations = false
transparent = true
resizable = false
[appearance]
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 apps, type > for commands, = for math"
selected_row_rgba = [229.0, 125.0, 33.0, 1.0]
unselected_row_rgba = [255.0, 255.0, 255.0, 0.07]
description_rgba = [210.0, 215.0, 230.0, 1.0]
no_results_rgba = [180.0, 180.0, 200.0, 0.5]
error_rgba = [255.0, 80.0, 80.0, 1.0]
icon_size = 24.0
[search]
max_results = 8
debounce_ms = 50
frecency_compact_threshold = 50
[plugins]
calc = true
cmd = true
files = true
apps = true
# External plugins (can have multiple [[plugins.external]] blocks)
# [[plugins.external]]
# name = "my-plugin"
# path = "/path/to/plugin-binary"
# args = []
# timeout_secs = 5
[logging]
max_log_files = 7
[terminal]
# Override terminal emulator for > commands
# Default: auto-detect from $TERM_CMD, $TERMINAL, or PATH
# cmd = "kitty -e"

43
contrib/PKGBUILD Normal file
View File

@@ -0,0 +1,43 @@
# Maintainer: Gabriel Kaszewski <gabriel@gabrielkaszewski.dev>
# AUR package for k-launcher
# Copy this file as PKGBUILD to your AUR repo
pkgname=k-launcher
pkgver=0.2.0
pkgrel=1
pkgdesc='Wayland command palette launcher with fuzzy search, frecency, and plugin support'
arch=('x86_64')
url='https://github.com/GKaszewski/k-launcher'
license=('MIT')
depends=('gcc-libs')
makedepends=('cargo' 'git')
optdepends=(
'wl-clipboard: clipboard support on Wayland'
'xclip: clipboard support on X11'
'xdg-utils: open files and URLs'
)
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
sha256sums=('SKIP')
prepare() {
cd "$pkgname-$pkgver"
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "$pkgname-$pkgver"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo build --frozen --release -p k-launcher
}
package() {
cd "$pkgname-$pkgver"
install -Dm755 "target/release/k-launcher" "$pkgdir/usr/bin/k-launcher"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
install -Dm644 "man/k-launcher.1" "$pkgdir/usr/share/man/man1/k-launcher.1"
gzip -9 "$pkgdir/usr/share/man/man1/k-launcher.1"
install -Dm644 config.example.toml "$pkgdir/usr/share/doc/$pkgname/config.example.toml"
install -Dm644 docs/plugin-development.md "$pkgdir/usr/share/doc/$pkgname/plugin-development.md"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,521 +1,12 @@
mod cache;
pub mod frecency;
#[cfg(target_os = "linux")]
pub mod linux;
mod plugin;
mod scoring;
mod types;
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::{Arc, RwLock},
};
use async_trait::async_trait;
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
use crate::frecency::FrecencyStore;
// --- Domain newtypes ---
#[derive(Debug, Clone)]
pub struct AppName(String);
impl AppName {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct ExecCommand(String);
impl ExecCommand {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct IconPath(String);
impl IconPath {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
// --- Desktop entry ---
pub struct DesktopEntry {
pub name: AppName,
pub exec: ExecCommand,
pub icon: Option<IconPath>,
pub category: Option<String>,
pub keywords: Vec<String>,
}
// --- Swappable source trait (Application layer principle) ---
pub trait DesktopEntrySource: Send + Sync {
fn entries(&self) -> Vec<DesktopEntry>;
}
// --- Cached entry (pre-computed at construction) ---
struct CachedEntry {
id: String,
name: AppName,
keywords_lc: Vec<String>,
category: Option<String>,
icon: Option<String>,
exec: String,
}
// --- Serializable cache data (no closures) ---
#[derive(serde::Serialize, serde::Deserialize)]
struct CachedEntryData {
id: String,
name: String,
keywords_lc: Vec<String>,
category: Option<String>,
icon: Option<String>,
exec: String,
}
fn cache_path() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("k-launcher/apps.bin"))
}
fn load_from_path(path: &Path) -> Option<HashMap<String, CachedEntry>> {
let data = std::fs::read(path).ok()?;
let (entries_data, _): (Vec<CachedEntryData>, _) =
bincode::serde::decode_from_slice(&data, bincode::config::standard()).ok()?;
let map = entries_data
.into_iter()
.map(|e| {
let cached = CachedEntry {
id: e.id.clone(),
name: AppName::new(e.name),
keywords_lc: e.keywords_lc,
category: e.category,
icon: e.icon,
exec: e.exec,
};
(e.id, cached)
})
.collect();
Some(map)
}
fn save_to_path(path: &Path, entries: &HashMap<String, CachedEntry>) {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).ok();
}
let data: Vec<CachedEntryData> = entries
.values()
.map(|e| CachedEntryData {
id: e.id.clone(),
name: e.name.as_str().to_string(),
keywords_lc: e.keywords_lc.clone(),
category: e.category.clone(),
icon: e.icon.clone(),
exec: e.exec.clone(),
})
.collect();
if let Ok(encoded) = bincode::serde::encode_to_vec(&data, bincode::config::standard()) {
std::fs::write(path, encoded).ok();
}
}
fn build_entries(
source: &impl DesktopEntrySource,
_frecency: &Arc<FrecencyStore>,
) -> HashMap<String, CachedEntry> {
source
.entries()
.into_iter()
.map(|e| {
let id = format!("app-{}:{}", e.name.as_str(), e.exec.as_str());
let keywords_lc = e.keywords.iter().map(|k| k.to_lowercase()).collect();
#[cfg(target_os = "linux")]
let icon = e
.icon
.as_ref()
.and_then(|p| linux::resolve_icon_path(p.as_str()));
#[cfg(not(target_os = "linux"))]
let icon: Option<String> = None;
let exec = e.exec.as_str().to_string();
let cached = CachedEntry {
id: id.clone(),
keywords_lc,
category: e.category,
icon,
exec,
name: e.name,
};
(id, cached)
})
.collect()
}
// --- Plugin ---
pub struct AppsPlugin {
entries: Arc<RwLock<HashMap<String, CachedEntry>>>,
frecency: Arc<FrecencyStore>,
}
impl AppsPlugin {
pub fn new(source: impl DesktopEntrySource + 'static, frecency: Arc<FrecencyStore>) -> Self {
Self::new_impl(source, frecency, cache_path())
}
fn new_impl(
source: impl DesktopEntrySource + 'static,
frecency: Arc<FrecencyStore>,
cp: Option<PathBuf>,
) -> Self {
let cached = cp.as_deref().and_then(load_from_path);
let entries = if let Some(from_cache) = cached {
// Serve cache immediately; refresh in background.
let map = Arc::new(RwLock::new(from_cache));
let entries_bg = Arc::clone(&map);
let frecency_bg = Arc::clone(&frecency);
let cp_bg = cp.clone();
std::thread::spawn(move || {
let fresh = build_entries(&source, &frecency_bg);
if let Some(path) = cp_bg {
save_to_path(&path, &fresh);
}
*entries_bg.write().unwrap() = fresh;
});
map
} else {
// No cache: build synchronously, then persist.
let initial = build_entries(&source, &frecency);
if let Some(path) = &cp {
save_to_path(path, &initial);
}
Arc::new(RwLock::new(initial))
};
Self { entries, frecency }
}
#[cfg(test)]
fn new_for_test(
source: impl DesktopEntrySource + 'static,
frecency: Arc<FrecencyStore>,
) -> Self {
Self::new_impl(source, frecency, None)
}
}
fn initials(name_lc: &str) -> String {
name_lc
.split_whitespace()
.filter_map(|w| w.chars().next())
.collect()
}
fn score_match(name: &str, query: &str) -> Option<u32> {
use nucleo_matcher::{
Config, Matcher, Utf32Str,
pattern::{CaseMatching, Normalization, Pattern},
};
let mut matcher = Matcher::new(Config::DEFAULT);
let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
let mut name_chars: Vec<char> = name.chars().collect();
let haystack = Utf32Str::new(name, &mut name_chars);
let score = pattern.score(haystack, &mut matcher);
if let Some(s) = score {
let name_lc = name.to_lowercase();
let query_lc = query.to_lowercase();
let bonus: u32 = if initials(&name_lc).starts_with(&query_lc) {
20
} else {
0
};
Some(s.saturating_add(bonus))
} else {
None
}
}
pub(crate) fn humanize_category(s: &str) -> String {
let mut result = String::new();
for ch in s.chars() {
if ch.is_uppercase() && !result.is_empty() {
result.push(' ');
}
result.push(ch);
}
result
}
#[async_trait]
impl Plugin for AppsPlugin {
fn name(&self) -> &str {
"apps"
}
fn on_selected(&self, id: &ResultId) {
self.frecency.record(id.as_str());
}
async fn search(&self, query: &str) -> Vec<SearchResult> {
let entries = self.entries.read().unwrap();
if query.is_empty() {
return self
.frecency
.top_ids(5)
.iter()
.filter_map(|id| {
let e = entries.get(id)?;
let score = self.frecency.frecency_score(id).max(1);
Some(SearchResult {
id: ResultId::new(id),
title: ResultTitle::new(e.name.as_str()),
description: e.category.clone(),
icon: e.icon.clone(),
score: Score::new(score),
action: LaunchAction::SpawnProcess(e.exec.clone()),
})
})
.collect();
}
let query_lc = query.to_lowercase();
entries
.values()
.filter_map(|e| {
let score = score_match(e.name.as_str(), query).or_else(|| {
e.keywords_lc
.iter()
.any(|k| k.contains(&query_lc))
.then_some(50)
})?;
Some(SearchResult {
id: ResultId::new(&e.id),
title: ResultTitle::new(e.name.as_str()),
description: e.category.clone(),
icon: e.icon.clone(),
score: Score::new(score),
action: LaunchAction::SpawnProcess(e.exec.clone()),
})
})
.collect()
}
}
// --- Tests ---
#[cfg(test)]
mod tests {
use super::*;
fn ephemeral_frecency() -> Arc<FrecencyStore> {
FrecencyStore::new_for_test()
}
struct MockSource {
entries: Vec<(String, String, Option<String>, Vec<String>)>, // (name, exec, category, keywords)
}
impl MockSource {
fn with(entries: Vec<(&str, &str)>) -> Self {
Self {
entries: entries
.into_iter()
.map(|(n, e)| (n.to_string(), e.to_string(), None, vec![]))
.collect(),
}
}
fn with_categories(entries: Vec<(&str, &str, &str)>) -> Self {
Self {
entries: entries
.into_iter()
.map(|(n, e, c)| (n.to_string(), e.to_string(), Some(c.to_string()), vec![]))
.collect(),
}
}
fn with_keywords(entries: Vec<(&str, &str, Vec<&str>)>) -> Self {
Self {
entries: entries
.into_iter()
.map(|(n, e, kw)| {
(
n.to_string(),
e.to_string(),
None,
kw.into_iter().map(|s| s.to_string()).collect(),
)
})
.collect(),
}
}
}
impl DesktopEntrySource for MockSource {
fn entries(&self) -> Vec<DesktopEntry> {
self.entries
.iter()
.map(|(name, exec, category, keywords)| DesktopEntry {
name: AppName::new(name.clone()),
exec: ExecCommand::new(exec.clone()),
icon: None,
category: category.clone(),
keywords: keywords.clone(),
})
.collect()
}
}
#[tokio::test]
async fn apps_prefix_match() {
let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
let results = p.search("fire").await;
assert_eq!(results[0].title.as_str(), "Firefox");
}
#[tokio::test]
async fn apps_no_match_returns_empty() {
let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
assert!(p.search("zz").await.is_empty());
}
#[tokio::test]
async fn apps_empty_query_no_frecency_returns_empty() {
let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
assert!(p.search("").await.is_empty());
}
#[test]
fn score_match_abbreviation() {
assert_eq!(initials("visual studio code"), "vsc");
assert!(score_match("visual studio code", "vsc").is_some());
}
#[test]
fn score_match_exact_beats_prefix() {
let exact = score_match("firefox", "firefox");
let prefix = score_match("firefox", "fire");
let abbrev = score_match("gnu firefox", "gf");
let substr = score_match("ice firefox", "fire");
assert!(exact.is_some());
assert!(prefix.is_some());
assert!(abbrev.is_some());
assert!(substr.is_some());
assert!(exact.unwrap() > prefix.unwrap());
}
#[tokio::test]
async fn apps_abbreviation_match() {
let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Visual Studio Code", "code")]),
ephemeral_frecency(),
);
let results = p.search("vsc").await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].title.as_str(), "Visual Studio Code");
assert!(results[0].score.value() > 0);
}
#[tokio::test]
async fn apps_keyword_match() {
let p = AppsPlugin::new_for_test(
MockSource::with_keywords(vec![("Code", "code", vec!["editor", "ide"])]),
ephemeral_frecency(),
);
let results = p.search("editor").await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].score.value(), 50);
}
#[tokio::test]
async fn apps_fuzzy_typo_match() {
let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
let results = p.search("frefox").await;
assert!(
!results.is_empty(),
"nucleo should fuzzy-match 'frefox' to 'Firefox'"
);
assert!(results[0].score.value() > 0);
}
#[test]
fn humanize_category_splits_camel_case() {
assert_eq!(humanize_category("TextEditor"), "Text Editor");
assert_eq!(humanize_category("WebBrowser"), "Web Browser");
assert_eq!(humanize_category("Development"), "Development");
}
#[tokio::test]
async fn apps_category_appears_in_description() {
let p = AppsPlugin::new_for_test(
MockSource::with_categories(vec![("Code", "code", "Text Editor")]),
ephemeral_frecency(),
);
let results = p.search("code").await;
assert_eq!(results[0].description.as_deref(), Some("Text Editor"));
}
#[tokio::test]
async fn apps_empty_query_returns_top_frecent() {
let frecency = ephemeral_frecency();
frecency.record("app-Code:code");
frecency.record("app-Code:code");
frecency.record("app-Firefox:firefox");
let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Firefox", "firefox"), ("Code", "code")]),
frecency,
);
let results = p.search("").await;
assert_eq!(results.len(), 2);
assert_eq!(results[0].title.as_str(), "Code");
}
#[test]
fn apps_loads_from_cache_when_source_is_empty() {
let frecency = ephemeral_frecency();
let cache_file =
std::env::temp_dir().join(format!("k-launcher-test-{}.bin", std::process::id()));
// Build entries from a real source and save to temp path
let source = MockSource::with(vec![("Firefox", "firefox")]);
let entries = build_entries(&source, &frecency);
save_to_path(&cache_file, &entries);
// Load from temp path — should contain Firefox
let loaded = load_from_path(&cache_file).unwrap();
assert!(loaded.contains_key("app-Firefox:firefox"));
std::fs::remove_file(&cache_file).ok();
}
}
pub use cache::{CachedEntry, build_entries, load_from_path, save_to_path};
pub use plugin::*;
pub use scoring::{humanize_category, new_matcher, parse_pattern, score_match};
pub use types::*;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,53 @@
// --- Domain newtypes ---
#[derive(Debug, Clone)]
pub struct AppName(String);
impl AppName {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct ExecCommand(String);
impl ExecCommand {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct IconPath(String);
impl IconPath {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
// --- Desktop entry ---
pub struct DesktopEntry {
pub name: AppName,
pub exec: ExecCommand,
pub icon: Option<IconPath>,
pub category: Option<String>,
pub keywords: Vec<String>,
}
// --- Swappable source trait (Application layer principle) ---
pub trait DesktopEntrySource: Send + Sync {
fn entries(&self) -> Vec<DesktopEntry>;
}

View File

@@ -0,0 +1,21 @@
use plugin_apps::frecency::FrecencyStore;
#[test]
fn record_increments_count() {
let store = FrecencyStore::new_for_test();
store.record("app-firefox");
store.record("app-firefox");
assert!(store.frecency_score("app-firefox") > 0);
}
#[test]
fn top_ids_returns_sorted_order() {
let store = FrecencyStore::new_for_test();
store.record("app-firefox");
store.record("app-code");
store.record("app-code");
store.record("app-code");
let top = store.top_ids(2);
assert_eq!(top[0], "app-code");
assert_eq!(top[1], "app-firefox");
}

View File

@@ -0,0 +1,27 @@
#[cfg(target_os = "linux")]
mod linux_tests {
use plugin_apps::linux::clean_exec;
#[test]
fn strips_bare_field_code() {
assert_eq!(clean_exec("app --file %f"), "app --file");
}
#[test]
fn strips_multiple_field_codes() {
assert_eq!(clean_exec("app %U --flag"), "app --flag");
}
#[test]
fn preserves_quoted_value() {
assert_eq!(
clean_exec(r#"app --arg="value" %U"#),
r#"app --arg="value""#
);
}
#[test]
fn handles_plain_exec() {
assert_eq!(clean_exec("firefox"), "firefox");
}
}

View File

@@ -0,0 +1,260 @@
use std::sync::Arc;
use k_launcher_domain::Plugin;
use plugin_apps::frecency::FrecencyStore;
use plugin_apps::{
AppName, AppsPlugin, DesktopEntry, DesktopEntrySource, ExecCommand, build_entries,
humanize_category, load_from_path, new_matcher, parse_pattern, save_to_path, score_match,
};
fn ephemeral_frecency() -> Arc<FrecencyStore> {
FrecencyStore::new_for_test()
}
struct MockEntry {
name: String,
exec: String,
category: Option<String>,
keywords: Vec<String>,
}
struct MockSource {
entries: Vec<MockEntry>,
}
impl MockSource {
fn with(entries: Vec<(&str, &str)>) -> Self {
Self {
entries: entries
.into_iter()
.map(|(n, e)| MockEntry {
name: n.to_string(),
exec: e.to_string(),
category: None,
keywords: vec![],
})
.collect(),
}
}
fn with_categories(entries: Vec<(&str, &str, &str)>) -> Self {
Self {
entries: entries
.into_iter()
.map(|(n, e, c)| MockEntry {
name: n.to_string(),
exec: e.to_string(),
category: Some(c.to_string()),
keywords: vec![],
})
.collect(),
}
}
fn with_keywords(entries: Vec<(&str, &str, Vec<&str>)>) -> Self {
Self {
entries: entries
.into_iter()
.map(|(n, e, kw)| MockEntry {
name: n.to_string(),
exec: e.to_string(),
category: None,
keywords: kw.into_iter().map(|s| s.to_string()).collect(),
})
.collect(),
}
}
}
impl DesktopEntrySource for MockSource {
fn entries(&self) -> Vec<DesktopEntry> {
self.entries
.iter()
.map(|e| DesktopEntry {
name: AppName::new(e.name.clone()),
exec: ExecCommand::new(e.exec.clone()),
icon: None,
category: e.category.clone(),
keywords: e.keywords.clone(),
})
.collect()
}
}
#[tokio::test]
async fn apps_prefix_match() {
let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
let results = p.search("fire").await;
assert_eq!(results[0].title.as_str(), "Firefox");
}
#[tokio::test]
async fn apps_no_match_returns_empty() {
let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
assert!(p.search("zz").await.is_empty());
}
#[tokio::test]
async fn apps_empty_query_no_frecency_returns_empty() {
let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
assert!(p.search("").await.is_empty());
}
#[test]
fn score_match_abbreviation() {
let mut matcher = new_matcher();
let pattern = parse_pattern("vsc");
let mut buf = Vec::new();
assert!(
score_match(
&mut matcher,
&pattern,
"visual studio code",
&mut buf,
"visual studio code",
"vsc"
)
.is_some()
);
}
#[test]
fn score_match_exact_beats_prefix() {
let mut matcher = new_matcher();
let mut buf = Vec::new();
let exact_pattern = parse_pattern("firefox");
let fire_pattern = parse_pattern("fire");
let gf_pattern = parse_pattern("gf");
let exact = score_match(
&mut matcher,
&exact_pattern,
"firefox",
&mut buf,
"firefox",
"firefox",
);
let prefix = score_match(
&mut matcher,
&fire_pattern,
"firefox",
&mut buf,
"firefox",
"fire",
);
let abbrev = score_match(
&mut matcher,
&gf_pattern,
"gnu firefox",
&mut buf,
"gnu firefox",
"gf",
);
let substr = score_match(
&mut matcher,
&fire_pattern,
"ice firefox",
&mut buf,
"ice firefox",
"fire",
);
assert!(exact.is_some());
assert!(prefix.is_some());
assert!(abbrev.is_some());
assert!(substr.is_some());
assert!(exact.unwrap() > prefix.unwrap());
}
#[tokio::test]
async fn apps_abbreviation_match() {
let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Visual Studio Code", "code")]),
ephemeral_frecency(),
);
let results = p.search("vsc").await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].title.as_str(), "Visual Studio Code");
assert!(results[0].score.value() > 0);
}
#[tokio::test]
async fn apps_keyword_match() {
let p = AppsPlugin::new_for_test(
MockSource::with_keywords(vec![("Code", "code", vec!["editor", "ide"])]),
ephemeral_frecency(),
);
let results = p.search("editor").await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].score.value(), 50);
}
#[tokio::test]
async fn apps_fuzzy_typo_match() {
let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
let results = p.search("frefox").await;
assert!(
!results.is_empty(),
"nucleo should fuzzy-match 'frefox' to 'Firefox'"
);
assert!(results[0].score.value() > 0);
}
#[test]
fn humanize_category_splits_camel_case() {
assert_eq!(humanize_category("TextEditor"), "Text Editor");
assert_eq!(humanize_category("WebBrowser"), "Web Browser");
assert_eq!(humanize_category("Development"), "Development");
}
#[tokio::test]
async fn apps_category_appears_in_description() {
let p = AppsPlugin::new_for_test(
MockSource::with_categories(vec![("Code", "code", "Text Editor")]),
ephemeral_frecency(),
);
let results = p.search("code").await;
assert_eq!(results[0].description.as_deref(), Some("Text Editor"));
}
#[tokio::test]
async fn apps_empty_query_returns_top_frecent() {
let frecency = ephemeral_frecency();
frecency.record("app-Code:code");
frecency.record("app-Code:code");
frecency.record("app-Firefox:firefox");
let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Firefox", "firefox"), ("Code", "code")]),
frecency,
);
let results = p.search("").await;
assert_eq!(results.len(), 2);
assert_eq!(results[0].title.as_str(), "Code");
}
#[test]
fn apps_loads_from_cache_when_source_is_empty() {
let frecency = ephemeral_frecency();
let cache_file =
std::env::temp_dir().join(format!("k-launcher-test-{}.bin", std::process::id()));
let source = MockSource::with(vec![("Firefox", "firefox")]);
let entries = build_entries(&source, &frecency);
save_to_path(&cache_file, &entries);
let loaded = load_from_path(&cache_file).unwrap();
assert!(loaded.contains_key("app-Firefox:firefox"));
std::fs::remove_file(&cache_file).ok();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,50 +2,88 @@
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.
The file is optional — all fields have defaults and missing sections fall back to defaults automatically. If the file exists but has a parse error, a warning is logged and defaults are used.
## Full Annotated Example
See [config.example.toml](../config.example.toml) for a ready-to-copy template with all options.
```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
## Sections
[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
### [window]
[search]
max_results = 8 # maximum results shown at once
| Field | Type | Default | Description |
|---|---|---|---|
| `width` | float | `600.0` | Window width in pixels |
| `height` | float | `400.0` | Window height in pixels |
| `decorations` | bool | `false` | Show window title bar |
| `transparent` | bool | `true` | Enable background transparency |
| `resizable` | bool | `false` | Allow manual resizing |
[plugins]
calc = true # math expression evaluator
cmd = true # shell command runner (> prefix)
files = true # filesystem browser (/ or ~/ prefix)
apps = true # XDG application launcher
### [appearance]
# 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
```
| Field | Type | Default | Description |
|---|---|---|---|
| `background_rgba` | [R,G,B,A] | `[20, 20, 30, 0.9]` | Main background color |
| `border_rgba` | [R,G,B,A] | `[229, 125, 33, 1.0]` | Border/accent color |
| `border_width` | float | `1.0` | Border thickness |
| `border_radius` | float | `8.0` | Window corner radius |
| `search_font_size` | float | `18.0` | Search input font size |
| `title_size` | float | `15.0` | Result title font size |
| `desc_size` | float | `12.0` | Result description font size |
| `row_radius` | float | `4.0` | Result row corner radius |
| `placeholder` | string | `"Search apps, ..."` | Search input placeholder |
| `selected_row_rgba` | [R,G,B,A] | `[229, 125, 33, 1.0]` | Selected result background |
| `unselected_row_rgba` | [R,G,B,A] | `[255, 255, 255, 0.07]` | Unselected result background |
| `description_rgba` | [R,G,B,A] | `[210, 215, 230, 1.0]` | Description text color |
| `no_results_rgba` | [R,G,B,A] | `[180, 180, 200, 0.5]` | "No results" text color |
| `error_rgba` | [R,G,B,A] | `[255, 80, 80, 1.0]` | Error text color |
| `icon_size` | float | `24.0` | App icon size in pixels |
## RGBA Format
#### 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**
Colors use `[R, G, B, A]` arrays where R/G/B are 0255 (as floats) and A is 0.01.0 (opacity). Values are clamped to valid ranges.
Example — semi-transparent white: `[255.0, 255.0, 255.0, 0.5]`
### [search]
| Field | Type | Default | Description |
|---|---|---|---|
| `max_results` | integer | `8` | Maximum results shown |
| `debounce_ms` | integer | `50` | Milliseconds to wait after last keystroke before searching |
| `frecency_compact_threshold` | integer | `50` | Frecency log entries before compacting to snapshot |
### [plugins]
| Field | Type | Default | Description |
|---|---|---|---|
| `calc` | bool | `true` | Calculator plugin |
| `cmd` | bool | `true` | Shell command plugin |
| `files` | bool | `true` | File browser plugin |
| `apps` | bool | `true` | Application search plugin |
### [[plugins.external]]
Repeatable block for external plugins.
| Field | Type | Default | Description |
|---|---|---|---|
| `name` | string | required | Plugin display name |
| `path` | string | required | Path to plugin executable |
| `args` | string[] | `[]` | Arguments to pass |
| `timeout_secs` | integer | `5` | Search timeout per query |
### [logging]
| Field | Type | Default | Description |
|---|---|---|---|
| `max_log_files` | integer | `7` | Daily log files to keep |
Logs are stored in `~/.local/share/k-launcher/logs/`.
### [terminal]
| Field | Type | Default | Description |
|---|---|---|---|
| `cmd` | string | auto-detect | Terminal emulator for `>` commands |
If unset, detected from `$TERM_CMD`, `$TERMINAL`, or PATH (foot, kitty, alacritty, wezterm, konsole, xterm).
Example: `cmd = "kitty -e"`

View File

@@ -1,39 +1,61 @@
# Installation
## Prerequisites
## Arch Linux (AUR)
```bash
yay -S k-launcher
```
## Build from Source
### Prerequisites
- **Rust** stable toolchain — install via [rustup](https://rustup.rs)
- **git**
- A **Wayland** or **X11** compositor (Linux)
## Build from Source
### Build and install
```bash
git clone https://github.com/GKaszewski/k-launcher
cd k-launcher
cargo build --release
make install
```
Binary location: `target/release/k-launcher`
### Optional: install to PATH
```bash
cp target/release/k-launcher ~/.local/bin/
```
This builds a release binary and copies it to `~/.local/bin/k-launcher`.
Ensure `~/.local/bin` is in your `$PATH`.
## Autostart
### Manual build
```bash
cargo build --release
cp target/release/k-launcher ~/.local/bin/
```
## Compositor Keybind
### Hyprland
Add to `~/.config/hypr/hyprland.conf`:
```
exec-once = k-launcher
windowrule = float, ^(k-launcher)$
windowrule = center, ^(k-launcher)$
bind = SUPER, Space, exec, k-launcher
```
### Sway
Add to `~/.config/sway/config`:
```
for_window [app_id="k-launcher"] floating enable, move position center
bindsym Mod4+space exec k-launcher
```
## Autostart (optional)
### systemd user service
Create `~/.config/systemd/user/k-launcher.service`:
@@ -55,3 +77,9 @@ Then enable it:
```bash
systemctl --user enable --now k-launcher
```
## Verify
```bash
k-launcher --version
```

View File

@@ -35,6 +35,7 @@ The process is kept alive between queries — do **not** exit after each respons
| `"type"` | Extra fields | Behavior |
|----------|-------------|---------|
| `SpawnProcess` | `"cmd"` | Launch process directly |
| `SpawnInTerminal` | `"cmd"` | Run command in terminal emulator |
| `CopyToClipboard` | `"text"` | Copy text to clipboard |
| `OpenPath` | `"path"` | Open file/dir with xdg-open |
@@ -53,7 +54,8 @@ In `~/.config/k-launcher/config.toml`:
[[plugins.external]]
name = "my-plugin"
path = "/usr/lib/k-launcher/plugins/my-plugin"
args = [] # optional
args = [] # optional
timeout_secs = 5 # optional, default 5
```
Multiple `[[plugins.external]]` blocks are supported.
@@ -96,7 +98,7 @@ for line in sys.stdin:
## Built-in Plugins (compiled-in)
Built-in plugins implement the `Plugin` trait from `k-launcher-kernel` as Rust crates compiled into the binary.
Built-in plugins implement the `Plugin` trait from `k-launcher-domain` as Rust crates compiled into the binary.
### 1. Create a new crate in the workspace
@@ -120,7 +122,7 @@ members = [
```toml
[dependencies]
k-launcher-kernel = { path = "../../k-launcher-kernel" }
k-launcher-domain = { workspace = true }
async-trait = "0.1"
```
@@ -129,8 +131,10 @@ async-trait = "0.1"
`crates/plugins/plugin-hello/src/lib.rs`:
```rust
use std::sync::Arc;
use async_trait::async_trait;
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
use k_launcher_domain::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
pub struct HelloPlugin;
@@ -154,11 +158,10 @@ impl Plugin for HelloPlugin {
vec![SearchResult {
id: ResultId::new("hello:world"),
title: ResultTitle::new("Hello, World!"),
description: Some("A greeting from the hello plugin".to_string()),
description: Some(Arc::from("A greeting from the hello plugin")),
icon: None,
score: Score::new(80),
action: LaunchAction::CopyToClipboard("Hello, World!".to_string()),
on_select: None,
}]
}
}
@@ -192,11 +195,10 @@ plugin-hello = { path = "../plugins/plugin-hello" }
|-------|------|-------------|
| `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) |
| `description` | `Option<Arc<str>>` | Secondary line shown below title |
| `icon` | `Option<Arc<str>>` | 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
@@ -206,7 +208,6 @@ plugin-hello = { path = "../plugins/plugin-hello" }
| `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

168
man/k-launcher.1 Normal file
View File

@@ -0,0 +1,168 @@
.TH K\-LAUNCHER 1 "2026-07-24" "k-launcher 0.2.0" "User Commands"
.SH NAME
k\-launcher \- Wayland command palette launcher
.SH SYNOPSIS
.B k\-launcher
.RB [ \-\-version ]
.SH DESCRIPTION
.B k\-launcher
is a keyboard-driven application launcher for Wayland desktops. It provides
fuzzy search over installed applications, a calculator, a file browser, and
a shell command runner, all accessible from a single search bar.
.PP
Results are ranked by a combination of fuzzy match score and frecency (how
frequently and recently an application was launched). On an empty query, the
most frecent applications are displayed.
.SH OPTIONS
.TP
.BR \-\-version ", " \-V
Print version information and exit.
.SH USAGE
.TP
.B Type text
Fuzzy-search installed applications by name or keywords.
.TP
.B > command
Run a shell command in a terminal emulator.
.TP
.B = expression
Evaluate a math expression. Supports +, \-, *, /, parentheses, and functions
such as sqrt, sin, cos, tan, ln, log2, log10, abs, ceil, floor, round.
Constants: pi, e. Result is copied to clipboard on Enter.
.TP
.B ~/path \fRor\fB /path
Browse the filesystem. Tab-like prefix matching on directory entries.
.SH KEYBOARD
.TP
.B Enter
Launch the selected result (or copy to clipboard for calculator results).
.TP
.B Escape
Close the launcher.
.TP
.B Arrow Up / Arrow Down
Navigate through results.
.SH CONFIGURATION
Configuration is stored in
.IR ~/.config/k\-launcher/config.toml .
If the file is absent, sensible defaults are used. A parse error is logged
as a warning and defaults are used.
.PP
See
.I config.example.toml
in the source repository for all available options.
.SS [window]
.TP
.BR width " (float, default: 600.0)"
Window width in pixels.
.TP
.BR height " (float, default: 400.0)"
Window height in pixels.
.TP
.BR decorations " (bool, default: false)"
Show window decorations.
.TP
.BR transparent " (bool, default: true)"
Enable window transparency.
.SS [appearance]
.TP
.BR background_rgba " (array, default: [20, 20, 30, 0.9])"
Background color as [R, G, B, A] where RGB are 0\-255 and A is 0.0\-1.0.
.TP
.BR border_rgba " (array, default: [229, 125, 33, 1.0])"
Border color.
.TP
.BR placeholder " (string)"
Search bar placeholder text.
.TP
.BR icon_size " (float, default: 24.0)"
Application icon size in pixels.
.SS [search]
.TP
.BR max_results " (integer, default: 8)"
Maximum number of results to display.
.TP
.BR debounce_ms " (integer, default: 50)"
Milliseconds to wait after last keystroke before searching.
.TP
.BR frecency_compact_threshold " (integer, default: 50)"
Number of frecency log entries before compacting to a snapshot.
.SS [plugins]
.TP
.BR calc " (bool, default: true)"
Enable the calculator plugin.
.TP
.BR cmd " (bool, default: true)"
Enable the shell command plugin.
.TP
.BR files " (bool, default: true)"
Enable the file browser plugin.
.TP
.BR apps " (bool, default: true)"
Enable the application search plugin.
.SS [[plugins.external]]
External plugins are executables that communicate via JSON over stdin/stdout.
.TP
.BR name " (string, required)"
Display name for the plugin.
.TP
.BR path " (string, required)"
Path to the plugin executable.
.TP
.BR args " (array of strings, default: [])"
Arguments to pass to the plugin.
.TP
.BR timeout_secs " (integer, default: 5)"
Search timeout in seconds per query.
.SS [logging]
.TP
.BR max_log_files " (integer, default: 7)"
Number of daily log files to keep.
.SS [terminal]
.TP
.BR cmd " (string, optional)"
Terminal emulator command for
.B > command
execution. If unset, detected from
.BR $TERM_CMD ,
.BR $TERMINAL ,
or PATH (foot, kitty, alacritty, wezterm, konsole, xterm).
.SH FILES
.TP
.I ~/.config/k\-launcher/config.toml
User configuration file.
.TP
.I ~/.local/share/k\-launcher/frecency.json
Frecency snapshot (launch history).
.TP
.I ~/.local/share/k\-launcher/frecency.log
Frecency append-only log (compacted periodically).
.TP
.I ~/.local/share/k\-launcher/logs/
Daily log files.
.TP
.I ~/.cache/k\-launcher/apps.bin
Cached desktop entry data (bincode).
.SH PLUGINS
See
.I docs/plugin\-development.md
in the source repository for the external plugin protocol and a guide to
writing built-in plugins.
.SH SIGNALS
.TP
.BR SIGINT ", " SIGTERM
Graceful shutdown. Frecency data is compacted before exit.
.SH EXIT STATUS
.TP
.B 0
Normal exit.
.TP
.B 1
Fatal error (UI initialization failure).
.SH AUTHORS
Written by Gabriel Kaszewski.
.SH LICENSE
MIT License. See LICENSE in the source repository.
.SH SEE ALSO
.BR wl\-copy (1),
.BR xdg\-open (1)