Compare commits

32 Commits

Author SHA1 Message Date
0fc21ede97 chore: bump version to 0.2.1 and update related files
Some checks failed
CI / clippy (push) Has been cancelled
CI / fmt (push) Has been cancelled
CI / test (push) Has been cancelled
Release / build (push) Failing after 5m11s
2026-07-24 13:54:32 +02:00
fa5d38107a feat: add ghostty to known terminals list
Some checks failed
CI / test (push) Has been cancelled
CI / clippy (push) Has been cancelled
CI / fmt (push) Has been cancelled
2026-07-24 13:50:38 +02:00
051d19d878 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
2026-07-24 13:42:14 +02:00
2e773cdeaf style: format code for better readability in tests and function signatures
Some checks failed
CI / test (push) Failing after 4m59s
CI / clippy (push) Failing after 4m58s
CI / fmt (push) Successful in 23s
2026-03-18 13:59:53 +01:00
3d2bd5f9fe fix: update build_entries function signature to ignore frecency parameter
Some checks failed
CI / test (push) Failing after 5m6s
CI / clippy (push) Failing after 5m3s
CI / fmt (push) Failing after 23s
2026-03-18 13:48:02 +01:00
ff9b2b5712 fix(review): bugs, arch violations, design smells
P1 bugs:
- unix_launcher: shell_split respects quoted args (was split_whitespace)
- plugin-host: 5s timeout on external plugin search
- ui: handle engine init panic, wire error state
- ui-egui: read window config instead of always using defaults
- plugin-url: use OpenPath action instead of SpawnProcess+xdg-open

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

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

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

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

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

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

3
.gitignore vendored
View File

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

58
CLAUDE.md Normal file
View File

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

2271
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,59 @@
[workspace]
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",
"crates/plugins/plugin-files",
"crates/k-launcher-ui-egui",
"crates/plugins/plugin-url",
]
default-members = [
"crates/k-launcher",
"crates/k-launcher-config",
"crates/k-launcher-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",
"crates/plugins/plugin-files",
"crates/plugins/plugin-url",
]
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"
futures = "0.3"
iced = { version = "0.14", features = ["image", "svg", "tokio", "tiny-skia"] }
dirs = "6.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
tokio = { version = "1.35", features = ["rt-multi-thread", "macros"] }
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

@@ -0,0 +1,40 @@
.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
INSTALL_DIR := $(HOME)/.local/bin
build:
cargo build --release
build-egui:
cargo build --release -p k-launcher --features egui --bin k-launcher-egui
dev:
RUST_LOG=debug cargo run
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
run-egui:
cargo run --release -p k-launcher --features egui --bin k-launcher-egui
install: build
install -Dm755 $(RELEASE_BIN) $(INSTALL_DIR)/k-launcher
install-egui: build-egui
install -Dm755 $(EGUI_BIN) $(INSTALL_DIR)/k-launcher-egui
clean:
cargo clean

130
README.md
View File

@@ -1,56 +1,90 @@
# K-Launcher
# k-launcher
K-Launcher is a lightweight, GPU-accelerated command palette for Linux (Wayland/X11), macOS, and Windows. It reimagines the "Spotlight" experience through the lens of Frutiger Aero—focusing on gloss, glass, and skeuomorphism—powered by a non-blocking, multi-threaded Rust kernel.
A lightweight command palette for Linux (Wayland/X11). Fuzzy search, frecency ranking, plugin system. Written in Rust.
## Core Philosophy
## Quick Start
- Zero Webview: No Chromium, no Electron. Every pixel is rendered via WGPU (Iced) for sub-5ms input-to-render latency.
- Async-First: Search queries never block the UI. If the file-searcher is indexing, the calculator still feels instant.
- The "Aero" Standard: Deep support for Gaussian blur (via Layer Shell), linear gradients, and high-gloss textures.
## High-Level Architecture
We are utilizing a "Hub-and-Spoke" model within a Cargo Workspace. The k-launcher-kernel acts as the central hub, dispatching user input to various "Spokes" (Plugins).
### The Crate Hierarchy
| Crate | Responsibility | Key Dependencies |
| -------------------------- | --------------------------------------------------------- | -------------------------------------- |
| **`k-launcher`** | The entry-point binary. Glues everything together. | `k-launcher-ui`, `k-launcher-kernel` |
| **`k-launcher-ui`** | The Iced-based view layer. Handles animations/theming. | `iced`, `lyon` (for vector paths) |
| **`k-launcher-kernel`** | The "Brain." Manages state, history, and plugin dispatch. | `tokio`, `tracing` |
| **`k-launcher-os-bridge`** | OS-specific windowing (Layer Shell for Wayland, Win32). | `iced_layershell`, `raw-window-handle` |
| **`plugins/*`** | Individual features (Calc, Files, Apps, Web). | `plugin-api` (Shared traits) |
## Data & Communication Flow
K-Launcher operates on an Event loop.
```
sequenceDiagram
participant User
participant UI as k-launcher-ui
participant Kernel as k-launcher-kernel
participant Plugins as plugin-file-search
User->>UI: Types "p"
UI->>Kernel: QueryUpdate("p")
par Parallel Search
Kernel->>Plugins: async search("p")
Plugins-->>Kernel: List<SearchResult>
end
Kernel->>UI: NewResults(Vec)
UI-->>User: Render Glass Result List
```bash
git clone https://github.com/GKaszewski/k-launcher
cd k-launcher
make install
```
## Technical Specifications
Or with cargo directly:
To ensure "Plug and Play" capability, all features must implement the `Plugin` trait. This allows the user to swap the default `file-searcher` for something like `fzf` or `plocate` without recompiling the UI.
```bash
cargo build --release
cp target/release/k-launcher ~/.local/bin/
```
To achieve the 2000s aesthetic without a browser:
### Arch Linux (AUR)
- Background Blur: On Wayland, we request blur through the org_kde_kwin_blur or fractional-scale protocols.
- Shaders: We will use Iceds canvas to draw glossy "shine" overlays that respond to mouse hovering.
- Icons: We will prefer .svg and .png with high-depth shadows over flat icon fonts.
```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 |
|---|---|
| `↑` / `↓` | 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
**Hyprland** (`~/.config/hypr/hyprland.conf`):
```
windowrule = float, ^(k-launcher)$
windowrule = center, ^(k-launcher)$
bind = SUPER, Space, exec, k-launcher
```
**Sway** (`~/.config/sway/config`):
```
for_window [app_id="k-launcher"] floating enable, move position center
bindsym Mod4+space exec k-launcher
```
## Plugins
Built-in plugins (calc, apps, shell, files) are enabled by default. External plugins communicate via JSON over stdin/stdout — any language, no recompilation:
```toml
[[plugins.external]]
name = "my-plugin"
path = "/path/to/plugin"
timeout_secs = 5
```
See [Plugin Development](docs/plugin-development.md) for the full protocol.
## Docs
- [Installation](docs/install.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.1
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

@@ -0,0 +1,19 @@
[package]
name = "k-launcher-config"
version = "0.2.1"
edition = "2024"
[lib]
name = "k_launcher_config"
path = "src/lib.rs"
[dependencies]
dirs = { workspace = true }
k-launcher-domain = { workspace = true }
serde = { 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

@@ -0,0 +1,8 @@
mod config;
pub mod error;
mod load;
mod types;
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,206 @@
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 selected_text_rgba: Rgba,
pub selected_description_rgba: Rgba,
pub unselected_row_rgba: Rgba,
pub text_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),
selected_text_rgba: Rgba::new(255.0, 255.0, 255.0, 1.0),
selected_description_rgba: Rgba::new(240.0, 240.0, 240.0, 0.9),
unselected_row_rgba: Rgba::new(255.0, 255.0, 255.0, 0.07),
text_rgba: Rgba::new(255.0, 255.0, 255.0, 1.0),
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.1"
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,10 +1,18 @@
[package]
name = "k-launcher-kernel"
version = "0.1.0"
version = "0.2.1"
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,168 +1,3 @@
use std::sync::Arc;
mod kernel;
use async_trait::async_trait;
use futures::future::join_all;
pub type PluginName = &'static str;
// --- 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
}
}
// --- SearchResult ---
pub struct SearchResult {
pub id: ResultId,
pub title: ResultTitle,
pub description: Option<String>,
pub icon: Option<String>,
pub score: Score,
pub on_execute: Arc<dyn Fn() + Send + Sync>,
}
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) -> PluginName;
async fn search(&self, query: &str) -> Vec<SearchResult>;
}
// --- Kernel (Application use case) ---
pub struct Kernel {
plugins: Vec<Arc<dyn Plugin>>,
}
impl Kernel {
pub fn new(plugins: Vec<Arc<dyn Plugin>>) -> Self {
Self { plugins }
}
pub async fn search(&self, query: &str) -> Vec<SearchResult> {
let futures = self.plugins.iter().map(|p| p.search(query));
let nested: Vec<Vec<SearchResult>> = join_all(futures).await;
let mut flat: Vec<SearchResult> = nested.into_iter().flatten().collect();
flat.sort_by(|a, b| b.score.cmp(&a.score));
flat
}
}
// --- 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) -> PluginName {
"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),
on_execute: Arc::new(|| {}),
})
.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![]);
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]);
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);
}
}
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,6 +1,9 @@
[package]
name = "k-launcher-os-bridge"
version = "0.1.0"
version = "0.2.1"
edition = "2024"
[dependencies]
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,20 +1,7 @@
/// Configuration for the launcher window.
pub struct WindowConfig {
pub width: f32,
pub height: f32,
pub decorations: bool,
pub transparent: bool,
pub resizable: bool,
}
mod launcher;
mod shell;
mod spawn;
mod terminal;
impl WindowConfig {
pub fn launcher() -> Self {
Self {
width: 600.0,
height: 400.0,
decorations: false,
transparent: true,
resizable: false,
}
}
}
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,90 @@
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: "ghostty",
exec_flag: "-e",
},
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

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

@@ -0,0 +1,19 @@
[package]
name = "k-launcher-plugin-host"
version = "0.2.1"
edition = "2024"
[lib]
name = "k_launcher_plugin_host"
path = "src/lib.rs"
[dependencies]
async-trait = { workspace = true }
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

@@ -0,0 +1,6 @@
pub mod error;
mod plugin;
mod protocol;
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.1"
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

@@ -0,0 +1,18 @@
[package]
name = "k-launcher-ui-egui"
version = "0.2.1"
edition = "2024"
[lib]
name = "k_launcher_ui_egui"
path = "src/lib.rs"
[dependencies]
eframe = { version = "0.31", default-features = false, features = ["default_fonts", "wayland", "x11", "glow"] }
egui = "0.31"
k-launcher-config = { 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

@@ -0,0 +1,177 @@
use std::sync::{Arc, mpsc};
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};
use crate::input::{InputAction, process_input};
use crate::render;
use crate::style;
pub struct KLauncherApp {
pub(crate) inner: LauncherState,
rt: tokio::runtime::Handle,
result_tx: mpsc::SyncSender<Vec<SearchResult>>,
pub(crate) result_rx: mpsc::Receiver<Vec<SearchResult>>,
}
impl KLauncherApp {
fn new(
engine: Arc<Kernel>,
launcher: Arc<dyn AppLauncher>,
rt: tokio::runtime::Handle,
cfg: AppearanceCfg,
) -> Self {
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 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;
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) {
self.poll_search_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 => {}
}
self.render_panel(ctx);
}
}
pub fn run(
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();
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([window_cfg.width, window_cfg.height])
.with_decorations(window_cfg.decorations)
.with_transparent(window_cfg.transparent)
.with_resizable(window_cfg.resizable)
.with_always_on_top(),
..Default::default()
};
eframe::run_native(
k_launcher_domain::constants::APP_TITLE,
options,
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

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

View File

@@ -0,0 +1,69 @@
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) {
let title_color = if is_selected {
to_color32(&cfg.selected_text_rgba)
} else {
to_color32(&cfg.text_rgba)
};
let desc_color = if is_selected {
to_color32(&cfg.selected_description_rgba)
} else {
to_color32(&cfg.description_rgba)
};
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.colored_label(title_color, result.title.as_str());
if let Some(desc) = &result.description {
ui.colored_label(desc_color, 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.1"
edition = "2024"
[lib]
@@ -8,6 +8,10 @@ name = "k_launcher_ui"
path = "src/lib.rs"
[dependencies]
iced = { workspace = true }
k-launcher-kernel = { path = "../k-launcher-kernel" }
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,155 +1,39 @@
use std::sync::Arc;
use iced::{
Color, Element, Length, Size, Subscription, Task,
event,
keyboard::{Event as KeyEvent, Key, key::Named},
widget::{column, container, image, row, scrollable, svg, text, text_input, Space},
window,
};
use iced::{Size, Subscription, Task, event, keyboard::Event as KeyEvent, window};
use k_launcher_kernel::{Kernel, SearchResult};
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::LauncherState;
use crate::theme;
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"));
pub struct KLauncherApp {
kernel: Arc<Kernel>,
query: String,
results: Arc<Vec<SearchResult>>,
selected: usize,
#[derive(Clone)]
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 {
f.write_str("EngineHandle")
}
}
impl KLauncherApp {
fn new(kernel: Arc<Kernel>) -> Self {
Self {
kernel,
query: String::new(),
results: Arc::new(vec![]),
selected: 0,
}
}
pub(crate) struct KLauncherApp {
pub(crate) inner: LauncherState,
}
#[derive(Debug, Clone)]
pub enum Message {
pub(crate) enum Message {
QueryChanged(String),
ResultsReady(Arc<Vec<SearchResult>>),
ResultsReady {
epoch: u64,
results: Arc<Vec<SearchResult>>,
},
KeyPressed(KeyEvent),
}
fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
match message {
Message::QueryChanged(q) => {
state.query = q.clone();
state.selected = 0;
let kernel = state.kernel.clone();
Task::perform(
async move { kernel.search(&q).await },
|results| Message::ResultsReady(Arc::new(results)),
)
}
Message::ResultsReady(results) => {
state.results = results;
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) {
(result.on_execute)();
}
std::process::exit(0);
}
_ => {}
}
Task::none()
}
}
}
fn view(state: &KLauncherApp) -> Element<'_, Message> {
let colors = &*theme::AERO;
let search_bar = text_input("Search...", &state.query)
.id(INPUT_ID.clone())
.on_input(Message::QueryChanged)
.padding(12)
.size(18);
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 {
colors.border_cyan
} 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(),
};
container(
row![icon_el, text(result.title.as_str()).size(15)]
.spacing(8)
.align_y(iced::Center),
)
.width(Length::Fill)
.padding([6, 12])
.style(move |_theme| container::Style {
background: Some(iced::Background::Color(bg_color)),
..Default::default()
})
.into()
})
.collect();
let results_list =
scrollable(column(result_rows).spacing(2).width(Length::Fill)).height(Length::Fill);
let content = column![search_bar, results_list]
.spacing(8)
.padding(12)
.width(Length::Fill)
.height(Length::Fill);
container(content)
.width(Length::Fill)
.height(Length::Fill)
.style(|_theme| container::Style {
background: Some(iced::Background::Color(Color::from_rgba8(
20, 20, 30, 0.9,
))),
..Default::default()
})
.into()
EngineReady(EngineHandle),
EngineInitFailed(String),
}
fn subscription(_state: &KLauncherApp) -> Subscription<Message> {
@@ -159,25 +43,44 @@ fn subscription(_state: &KLauncherApp) -> Subscription<Message> {
})
}
pub fn run(kernel: Arc<Kernel>) -> iced::Result {
pub fn run(
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(kernel.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());
(app, focus)
let ef = engine_factory.clone();
let init = Task::perform(
async move {
tokio::task::spawn_blocking(move || ef())
.await
.map_err(|e| format!("Engine init failed: {e}"))
},
|result| match result {
Ok(e) => Message::EngineReady(EngineHandle(e)),
Err(msg) => Message::EngineInitFailed(msg),
},
);
(app, Task::batch([focus, init]))
},
update,
view,
crate::update::update,
crate::view::view,
)
.title("K-Launcher")
.subscription(subscription)
.window(window::Settings {
size: Size::new(600.0, 400.0),
position: window::Position::Centered,
decorations: false,
transparent: true,
resizable: false,
..Default::default()
})
.run()
.title(k_launcher_domain::constants::APP_TITLE)
.subscription(subscription)
.window(window::Settings {
size: Size::new(window_cfg.width, window_cfg.height),
position: window::Position::Centered,
decorations: window_cfg.decorations,
transparent: window_cfg.transparent,
resizable: window_cfg.resizable,
..Default::default()
})
.run()
}

View File

@@ -1,11 +1,27 @@
mod app;
pub mod theme;
mod style;
mod update;
mod view;
use std::sync::Arc;
use k_launcher_config::{AppearanceCfg, SearchCfg, WindowCfg};
use k_launcher_domain::AppLauncher;
use k_launcher_kernel::Kernel;
pub fn run(kernel: Arc<Kernel>) -> iced::Result {
app::run(kernel)
pub fn run(
engine_factory: Arc<dyn Fn() -> Arc<Kernel> + Send + Sync>,
launcher: Arc<dyn AppLauncher>,
window_cfg: &WindowCfg,
appearance_cfg: AppearanceCfg,
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

@@ -1,35 +0,0 @@
use iced::{
Color, Gradient,
gradient::{ColorStop, Linear},
};
pub struct AeroColors {
pub glass_bg: Color,
pub gloss_highlight: Gradient,
pub border_cyan: Color,
}
pub static AERO: std::sync::LazyLock<AeroColors> =
std::sync::LazyLock::new(AeroColors::standard);
impl AeroColors {
pub fn standard() -> Self {
Self {
// Semi-transparent "Aero Glass" base
glass_bg: Color::from_rgba8(255, 255, 255, 0.2),
// Cyan/Blue glow typical of the 2008 era
border_cyan: Color::from_rgb8(0, 183, 235),
// We'll use this for the "shine" effect on buttons
gloss_highlight: Gradient::Linear(Linear::new(0.0).add_stops([
ColorStop {
color: Color::from_rgba8(255, 255, 255, 0.5),
offset: 0.0,
},
ColorStop {
color: Color::from_rgba8(255, 255, 255, 0.0),
offset: 1.0,
},
])),
}
}
}

View File

@@ -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,185 @@
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, is_selected, 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,
is_selected: bool,
cfg: &AppearanceCfg,
) -> Element<'a, Message> {
let title_color = if is_selected {
style::rgba(&cfg.selected_text_rgba)
} else {
style::rgba(&cfg.text_rgba)
};
let desc_color = if is_selected {
style::rgba(&cfg.selected_description_rgba)
} else {
style::rgba(&cfg.description_rgba)
};
if let Some(desc) = &result.description {
column![
text(result.title.as_str())
.size(cfg.title_size)
.color(title_color),
text(desc.as_ref()).size(cfg.desc_size).color(desc_color),
]
.into()
} else {
text(result.title.as_str())
.size(cfg.title_size)
.color(title_color)
.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,17 +1,43 @@
[package]
name = "k-launcher"
version = "0.1.0"
version = "0.2.1"
edition = "2024"
default-run = "k-launcher"
[profile.release]
lto = true
strip = true
codegen-units = 1
opt-level = 3
[[bin]]
name = "k-launcher"
path = "src/main.rs"
[[bin]]
name = "k-launcher-egui"
path = "src/main_egui.rs"
required-features = ["egui"]
[features]
egui = ["dep:k-launcher-ui-egui"]
[dependencies]
iced = { workspace = true }
k-launcher-kernel = { path = "../k-launcher-kernel" }
k-launcher-ui = { path = "../k-launcher-ui" }
plugin-apps = { path = "../plugins/plugin-apps" }
plugin-calc = { path = "../plugins/plugin-calc" }
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,13 +1,46 @@
use std::sync::Arc;
use k_launcher_kernel::Kernel;
use plugin_apps::{AppsPlugin, FsDesktopEntrySource};
use plugin_calc::CalcPlugin;
use k_launcher_os_bridge::UnixAppLauncher;
fn main() -> iced::Result {
let kernel = Arc::new(Kernel::new(vec![
Arc::new(CalcPlugin::new()),
Arc::new(AppsPlugin::new(FsDesktopEntrySource::new())),
]));
k_launcher_ui::run(kernel)
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 factory_cfg = cfg.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

@@ -0,0 +1,38 @@
use std::sync::Arc;
use k_launcher_os_bridge::UnixAppLauncher;
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,19 +1,28 @@
[package]
name = "plugin-apps"
version = "0.1.0"
version = "0.2.1"
edition = "2024"
[lib]
name = "plugin_apps"
path = "src/lib.rs"
[[bin]]
name = "plugin-apps"
path = "src/main.rs"
[dependencies]
async-trait = { workspace = true }
k-launcher-kernel = { path = "../../k-launcher-kernel" }
libc = "0.2"
bincode = { version = "2", features = ["serde"] }
dirs = { workspace = true }
k-launcher-domain = { workspace = true }
nucleo-matcher = "0.3"
parking_lot = { workspace = true }
serde = { workspace = true }
serde_json = "1.0"
tokio = { workspace = true }
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 }
xdg = "2"

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

@@ -0,0 +1,229 @@
use std::{
collections::HashMap,
fs::{File, OpenOptions},
io::{BufRead, BufReader, Write},
path::PathBuf,
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Entry {
count: u32,
last_used: u64,
}
#[derive(Serialize, Deserialize)]
struct LogRecord {
id: String,
ts: u64,
}
pub struct FrecencyStore {
snapshot_path: PathBuf,
log_path: PathBuf,
data: Mutex<HashMap<String, Entry>>,
log_count: Mutex<usize>,
compact_threshold: usize,
}
impl FrecencyStore {
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();
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
}
pub fn new_for_test() -> Arc<Self> {
Arc::new(Self {
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(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 Self::new_for_test();
};
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) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
{
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;
}
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}");
}
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();
let Some(entry) = data.get(id) else { return 0 };
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let age_secs = now.saturating_sub(entry.last_used);
entry.count * decay_factor(age_secs)
}
pub fn top_ids(&self, n: usize) -> Vec<String> {
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<ScoredId> = data
.iter()
.map(|(id, entry)| {
let age_secs = now.saturating_sub(entry.last_used);
ScoredId {
id: id.clone(),
score: entry.count * decay_factor(age_secs),
}
})
.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 < ONE_HOUR {
DECAY_RECENT
} else if age_secs < ONE_DAY {
DECAY_TODAY
} else {
DECAY_OLD
}
}

View File

@@ -1,317 +1,12 @@
use std::{path::Path, process::{Command, Stdio}, sync::Arc};
use std::os::unix::process::CommandExt;
mod cache;
pub mod frecency;
#[cfg(target_os = "linux")]
pub mod linux;
mod plugin;
mod scoring;
mod types;
use async_trait::async_trait;
use k_launcher_kernel::{Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult};
// --- 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>,
}
// --- Swappable source trait (Application layer principle) ---
pub trait DesktopEntrySource: Send + Sync {
fn entries(&self) -> Vec<DesktopEntry>;
}
// --- Cached entry (pre-computed at construction) ---
struct CachedEntry {
name: AppName,
name_lc: String,
icon: Option<String>,
on_execute: Arc<dyn Fn() + Send + Sync>,
}
// --- Plugin ---
pub struct AppsPlugin {
entries: Vec<CachedEntry>,
}
impl AppsPlugin {
pub fn new(source: impl DesktopEntrySource) -> Self {
let entries = source
.entries()
.into_iter()
.map(|e| {
let name_lc = e.name.as_str().to_lowercase();
let icon = e.icon.as_ref().and_then(|p| resolve_icon_path(p.as_str()));
let exec = e.exec.clone();
CachedEntry {
name_lc,
icon,
on_execute: Arc::new(move || {
let parts: Vec<&str> = exec.as_str().split_whitespace().collect();
if let Some((cmd, args)) = parts.split_first() {
let _ = unsafe {
Command::new(cmd)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.pre_exec(|| {
libc::setsid();
Ok(())
})
.spawn()
};
}
}),
name: e.name,
}
})
.collect();
Self { entries }
}
}
fn resolve_icon_path(name: &str) -> Option<String> {
if name.starts_with('/') && Path::new(name).exists() {
return Some(name.to_string());
}
let candidates = [
format!("/usr/share/pixmaps/{name}.png"),
format!("/usr/share/pixmaps/{name}.svg"),
format!("/usr/share/icons/hicolor/48x48/apps/{name}.png"),
format!("/usr/share/icons/hicolor/scalable/apps/{name}.svg"),
];
candidates.into_iter().find(|p| Path::new(p).exists())
}
fn score_match(name_lc: &str, query_lc: &str) -> Option<u32> {
if name_lc == query_lc {
Some(100)
} else if name_lc.starts_with(query_lc) {
Some(80)
} else if name_lc.contains(query_lc) {
Some(60)
} else {
None
}
}
#[async_trait]
impl Plugin for AppsPlugin {
fn name(&self) -> PluginName {
"apps"
}
async fn search(&self, query: &str) -> Vec<SearchResult> {
if query.is_empty() {
return vec![];
}
let query_lc = query.to_lowercase();
self.entries
.iter()
.filter_map(|e| {
score_match(&e.name_lc, &query_lc).map(|score| SearchResult {
id: ResultId::new(format!("app-{}", e.name.as_str())),
title: ResultTitle::new(e.name.as_str()),
description: None,
icon: e.icon.clone(),
score: Score::new(score),
on_execute: Arc::clone(&e.on_execute),
})
})
.collect()
}
}
// --- Filesystem source ---
pub struct FsDesktopEntrySource;
impl FsDesktopEntrySource {
pub fn new() -> Self {
Self
}
}
impl Default for FsDesktopEntrySource {
fn default() -> Self {
Self::new()
}
}
impl DesktopEntrySource for FsDesktopEntrySource {
fn entries(&self) -> Vec<DesktopEntry> {
let mut dirs = Vec::new();
if let Ok(xdg) = xdg::BaseDirectories::new() {
dirs.push(xdg.get_data_home().join("applications"));
for d in xdg.get_data_dirs() {
dirs.push(d.join("applications"));
}
}
let mut entries = Vec::new();
for dir in &dirs {
if let Ok(read_dir) = std::fs::read_dir(dir) {
for entry in read_dir.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
continue;
}
if let Some(de) = parse_desktop_file(&path) {
entries.push(de);
}
}
}
}
entries
}
}
fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
let content = std::fs::read_to_string(path).ok()?;
let mut in_section = false;
let mut name: Option<String> = None;
let mut exec: Option<String> = None;
let mut icon: Option<String> = None;
let mut is_application = false;
let mut no_display = false;
for line in content.lines() {
let line = line.trim();
if line == "[Desktop Entry]" {
in_section = true;
continue;
}
if line.starts_with('[') {
in_section = false;
continue;
}
if !in_section || line.starts_with('#') || line.is_empty() {
continue;
}
if let Some((key, value)) = line.split_once('=') {
match key.trim() {
"Name" if name.is_none() => name = Some(value.trim().to_string()),
"Exec" if exec.is_none() => exec = Some(value.trim().to_string()),
"Icon" if icon.is_none() => icon = Some(value.trim().to_string()),
"Type" if !is_application => is_application = value.trim() == "Application",
"NoDisplay" => no_display = value.trim().eq_ignore_ascii_case("true"),
_ => {}
}
}
}
if !is_application || no_display {
return None;
}
let exec_clean: String = exec?
.split_whitespace()
.filter(|s| !s.starts_with('%'))
.fold(String::new(), |mut acc, s| {
if !acc.is_empty() {
acc.push(' ');
}
acc.push_str(s);
acc
});
Some(DesktopEntry {
name: AppName::new(name?),
exec: ExecCommand::new(exec_clean),
icon: icon.map(IconPath::new),
})
}
// --- Tests ---
#[cfg(test)]
mod tests {
use super::*;
struct MockSource {
entries: Vec<(String, String)>, // (name, exec)
}
impl MockSource {
fn with(entries: Vec<(&str, &str)>) -> Self {
Self {
entries: entries
.into_iter()
.map(|(n, e)| (n.to_string(), e.to_string()))
.collect(),
}
}
}
impl DesktopEntrySource for MockSource {
fn entries(&self) -> Vec<DesktopEntry> {
self.entries
.iter()
.map(|(name, exec)| DesktopEntry {
name: AppName::new(name.clone()),
exec: ExecCommand::new(exec.clone()),
icon: None,
})
.collect()
}
}
#[tokio::test]
async fn apps_prefix_match() {
let source = MockSource::with(vec![("Firefox", "firefox")]);
let p = AppsPlugin::new(source);
let results = p.search("fire").await;
assert_eq!(results[0].title.as_str(), "Firefox");
}
#[tokio::test]
async fn apps_no_match_returns_empty() {
let source = MockSource::with(vec![("Firefox", "firefox")]);
let p = AppsPlugin::new(source);
assert!(p.search("zz").await.is_empty());
}
#[tokio::test]
async fn apps_empty_query_returns_empty() {
let source = MockSource::with(vec![("Firefox", "firefox")]);
let p = AppsPlugin::new(source);
assert!(p.search("").await.is_empty());
}
}
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

@@ -0,0 +1,191 @@
use std::path::Path;
use crate::scoring::humanize_category;
use crate::{AppName, DesktopEntry, DesktopEntrySource, ExecCommand, IconPath};
pub struct FsDesktopEntrySource;
impl FsDesktopEntrySource {
pub fn new() -> Self {
Self
}
}
impl Default for FsDesktopEntrySource {
fn default() -> Self {
Self::new()
}
}
impl DesktopEntrySource for FsDesktopEntrySource {
fn entries(&self) -> Vec<DesktopEntry> {
let mut dirs = Vec::new();
let xdg = xdg::BaseDirectories::new();
if let Some(data_home) = xdg.get_data_home() {
dirs.push(data_home.join("applications"));
}
for d in xdg.get_data_dirs() {
dirs.push(d.join("applications"));
}
let mut entries = Vec::new();
for dir in &dirs {
if let Ok(read_dir) = std::fs::read_dir(dir) {
for entry in read_dir.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
continue;
}
if let Some(de) = parse_desktop_file(&path) {
entries.push(de);
}
}
}
}
entries
}
}
pub fn clean_exec(exec: &str) -> String {
// Tokenize respecting double-quoted strings, then filter field codes.
let mut tokens: Vec<String> = Vec::new();
let mut chars = exec.chars().peekable();
while let Some(&ch) = chars.peek() {
if ch.is_whitespace() {
chars.next();
continue;
}
if ch == '"' {
// Consume opening quote
chars.next();
let mut token = String::from('"');
while let Some(&c) = chars.peek() {
chars.next();
if c == '"' {
token.push('"');
break;
}
token.push(c);
}
// Strip embedded field codes like %f inside the quoted string
// (between the quotes, before re-assembling)
let inner = &token[1..token.len().saturating_sub(1)];
let cleaned_inner: String = inner
.split_whitespace()
.filter(|s| !is_field_code(s))
.collect::<Vec<_>>()
.join(" ");
tokens.push(format!("\"{cleaned_inner}\""));
} else {
let mut token = String::new();
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
break;
}
chars.next();
token.push(c);
}
if !is_field_code(&token) {
tokens.push(token);
}
}
}
tokens.join(" ")
}
fn is_field_code(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 2 && b[0] == b'%' && b[1].is_ascii_alphabetic()
}
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());
}
for theme in ICON_THEMES {
if let Some(icon_path) = linicon::lookup_icon(name)
.from_theme(theme)
.with_size(ICON_LOOKUP_SIZE)
.find_map(|r| r.ok())
{
return Some(icon_path.path.to_string_lossy().into_owned());
}
}
// Fallback to pixmaps
let candidates = [
format!("{PIXMAPS_DIR}/{name}.png"),
format!("{PIXMAPS_DIR}/{name}.svg"),
];
candidates.into_iter().find(|p| Path::new(p).exists())
}
fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
let content = std::fs::read_to_string(path).ok()?;
let mut in_section = false;
let mut name: Option<String> = None;
let mut exec: Option<String> = None;
let mut icon: Option<String> = None;
let mut category: Option<String> = None;
let mut keywords: Vec<String> = Vec::new();
let mut is_application = false;
let mut no_display = false;
for line in content.lines() {
let line = line.trim();
if line == "[Desktop Entry]" {
in_section = true;
continue;
}
if line.starts_with('[') {
in_section = false;
continue;
}
if !in_section || line.starts_with('#') || line.is_empty() {
continue;
}
if let Some((key, value)) = line.split_once('=') {
match key.trim() {
"Name" if name.is_none() => name = Some(value.trim().to_string()),
"Exec" if exec.is_none() => exec = Some(value.trim().to_string()),
"Icon" if icon.is_none() => icon = Some(value.trim().to_string()),
"Type" if !is_application => is_application = value.trim() == "Application",
"NoDisplay" => no_display = value.trim().eq_ignore_ascii_case("true"),
"Categories" if category.is_none() => {
category = value
.trim()
.split(';')
.find(|s| !s.is_empty())
.map(|s| humanize_category(s.trim()));
}
"Keywords" if keywords.is_empty() => {
keywords = value
.trim()
.split(';')
.filter(|s| !s.is_empty())
.map(|s| s.trim().to_string())
.collect();
}
_ => {}
}
}
}
if !is_application || no_display {
return None;
}
let exec_clean: String = clean_exec(&exec?);
Some(DesktopEntry {
name: AppName::new(name?),
exec: ExecCommand::new(exec_clean),
icon: icon.map(IconPath::new),
category,
keywords,
})
}

View File

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

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,18 +1,18 @@
[package]
name = "plugin-calc"
version = "0.1.0"
version = "0.2.1"
edition = "2024"
[lib]
name = "plugin_calc"
path = "src/lib.rs"
[[bin]]
name = "plugin-calc"
path = "src/main.rs"
[dependencies]
async-trait = { workspace = true }
evalexpr = "11"
k-launcher-kernel = { path = "../../k-launcher-kernel" }
evalexpr = "13"
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,83 +1,4 @@
use std::sync::Arc;
mod eval;
mod plugin;
use async_trait::async_trait;
use k_launcher_kernel::{Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult};
pub struct CalcPlugin;
impl CalcPlugin {
pub fn new() -> Self {
Self
}
}
impl Default for CalcPlugin {
fn default() -> Self {
Self::new()
}
}
fn should_eval(query: &str) -> bool {
query
.chars()
.next()
.map(|c| c.is_ascii_digit() || c == '(' || c == '-')
.unwrap_or(false)
|| query.starts_with('=')
}
#[async_trait]
impl Plugin for CalcPlugin {
fn name(&self) -> PluginName {
"calc"
}
async fn search(&self, query: &str) -> Vec<SearchResult> {
if !should_eval(query) {
return vec![];
}
let expr = query.strip_prefix('=').unwrap_or(query);
match evalexpr::eval_number(expr) {
Ok(n) if n.is_finite() => {
let display = if n.fract() == 0.0 {
format!("= {}", n as i64)
} else {
format!("= {n}")
};
vec![SearchResult {
id: ResultId::new("calc-result"),
title: ResultTitle::new(display),
description: None,
icon: None,
score: Score::new(90),
on_execute: Arc::new(|| {}),
}]
}
_ => 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());
}
}
pub use plugin::*;

View File

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

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

@@ -0,0 +1,16 @@
[package]
name = "plugin-cmd"
version = "0.2.1"
edition = "2024"
[lib]
name = "plugin_cmd"
path = "src/lib.rs"
[dependencies]
async-trait = { workspace = true }
k-launcher-domain = { workspace = true }
[dev-dependencies]
k-launcher-domain = { workspace = true }
tokio = { workspace = true }

View File

@@ -0,0 +1,44 @@
use async_trait::async_trait;
use k_launcher_domain::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
const CMD_PREFIX: char = '>';
const RESULT_SCORE: u32 = 95;
pub struct CmdPlugin;
impl CmdPlugin {
pub fn new() -> Self {
Self
}
}
impl Default for CmdPlugin {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Plugin for CmdPlugin {
fn name(&self) -> &str {
"cmd"
}
async fn search(&self, query: &str) -> Vec<SearchResult> {
let Some(rest) = query.strip_prefix(CMD_PREFIX) else {
return vec![];
};
let cmd = rest.trim();
if cmd.is_empty() {
return vec![];
}
vec![SearchResult {
id: ResultId::new(format!("cmd-{cmd}")),
title: ResultTitle::new(format!("Run: {cmd}")),
description: None,
icon: None,
score: Score::new(RESULT_SCORE),
action: LaunchAction::SpawnInTerminal(cmd.to_string()),
}]
}
}

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,17 @@
[package]
name = "plugin-files"
version = "0.1.0"
version = "0.2.1"
edition = "2024"
[lib]
name = "plugin_files"
path = "src/lib.rs"
[dependencies]
async-trait = { workspace = true }
k-launcher-domain = { workspace = true }
tokio = { workspace = true }
[dev-dependencies]
k-launcher-domain = { workspace = true }
tokio = { workspace = true }

View File

@@ -0,0 +1,96 @@
mod platform;
use std::path::Path;
use std::sync::Arc;
use async_trait::async_trait;
use k_launcher_domain::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
const MAX_FILE_RESULTS: usize = 20;
const RESULT_SCORE: u32 = 50;
pub struct FilesPlugin;
impl FilesPlugin {
pub fn new() -> Self {
Self
}
}
impl Default for FilesPlugin {
fn default() -> Self {
Self::new()
}
}
fn expand_query(query: &str) -> Option<String> {
if query.starts_with("~/") {
let home = platform::home_dir()?;
Some(format!("{}{}", home, &query[1..]))
} else if query.starts_with('/') {
Some(query.to_string())
} else {
None
}
}
#[async_trait]
impl Plugin for FilesPlugin {
fn name(&self) -> &str {
"files"
}
async fn search(&self, query: &str) -> Vec<SearchResult> {
let expanded = match expand_query(query) {
Some(p) => p,
None => return vec![],
};
let path = Path::new(&expanded);
let (parent, prefix) = if path.is_dir() {
(path.to_path_buf(), String::new())
} else {
let parent = path.parent().unwrap_or(Path::new("/")).to_path_buf();
let prefix = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_lowercase();
(parent, prefix)
};
let entries = match std::fs::read_dir(&parent) {
Ok(e) => e,
Err(_) => return vec![],
};
entries
.filter_map(|e| e.ok())
.filter(|e| {
if prefix.is_empty() {
return true;
}
e.file_name()
.to_str()
.map(|n| n.to_lowercase().starts_with(&prefix))
.unwrap_or(false)
})
.take(MAX_FILE_RESULTS)
.map(|entry| {
let full_path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
let is_dir = full_path.is_dir();
let title = if is_dir { format!("{name}/") } else { name };
let path_str = full_path.to_string_lossy().to_string();
SearchResult {
id: ResultId::new(&path_str),
title: ResultTitle::new(title),
description: Some(Arc::from(path_str.as_str())),
icon: None,
score: Score::new(RESULT_SCORE),
action: LaunchAction::OpenPath(path_str),
}
})
.collect()
}
}

View File

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

View File

@@ -0,0 +1,9 @@
#[cfg(unix)]
pub fn home_dir() -> Option<String> {
std::env::var("HOME").ok()
}
#[cfg(windows)]
pub fn home_dir() -> Option<String> {
std::env::var("USERPROFILE").ok()
}

View File

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

@@ -0,0 +1,19 @@
[package]
name = "plugin-url"
version = "0.2.1"
edition = "2024"
[lib]
name = "plugin_url"
path = "src/lib.rs"
[[bin]]
name = "k-launcher-plugin-url"
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

@@ -0,0 +1,21 @@
use std::io::{self, BufRead, Write};
use plugin_url::{Query, search};
fn main() -> io::Result<()> {
let stdin = io::stdin();
let stdout = io::stdout();
let mut out = stdout.lock();
for line in stdin.lock().lines() {
let line = line?;
let q: Query = match serde_json::from_str(&line) {
Ok(q) => q,
Err(_) => continue,
};
let results = search(&q.query);
writeln!(out, "{}", serde_json::to_string(&results).unwrap())?;
out.flush()?;
}
Ok(())
}

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

89
docs/configuration.md Normal file
View File

@@ -0,0 +1,89 @@
# Configuration
Config file: `~/.config/k-launcher/config.toml`
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.
See [config.example.toml](../config.example.toml) for a ready-to-copy template with all options.
## Sections
### [window]
| 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 |
### [appearance]
| 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
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.
### [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"`

85
docs/install.md Normal file
View File

@@ -0,0 +1,85 @@
# Installation
## 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 and install
```bash
git clone https://github.com/GKaszewski/k-launcher
cd k-launcher
make install
```
This builds a release binary and copies it to `~/.local/bin/k-launcher`.
Ensure `~/.local/bin` is in your `$PATH`.
### Manual build
```bash
cargo build --release
cp target/release/k-launcher ~/.local/bin/
```
## Compositor Keybind
### Hyprland
Add to `~/.config/hypr/hyprland.conf`:
```
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`:
```ini
[Unit]
Description=k-launcher command palette
[Service]
ExecStart=%h/.local/bin/k-launcher
Restart=on-failure
[Install]
WantedBy=graphical-session.target
```
Then enable it:
```bash
systemctl --user enable --now k-launcher
```
## Verify
```bash
k-launcher --version
```

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

@@ -0,0 +1,223 @@
# Plugin Development
Plugins are queried concurrently — the kernel fans out every search to all enabled plugins and merges results by score.
There are two kinds of plugins:
- **External plugins** — executables that speak a JSON protocol over stdin/stdout. Any language, no compilation required. Recommended for community plugins.
- **Built-in plugins** — Rust crates compiled into the binary. For performance-critical or tightly integrated plugins.
---
## External Plugins
An external plugin is any executable that:
1. Reads a JSON object from stdin (one line per query)
2. Writes a JSON array of results to stdout (one line per response)
### Protocol
**Input** (one line, newline-terminated):
```json
{"query": "firefox"}
```
**Output** (one line, newline-terminated):
```json
[{"id":"app-firefox","title":"Firefox","score":80,"description":"Web Browser","action":{"type":"SpawnProcess","cmd":"firefox"}}]
```
The process is kept alive between queries — do **not** exit after each response.
### Action types
| `"type"` | Extra fields | Behavior |
|----------|-------------|---------|
| `SpawnProcess` | `"cmd"` | Launch process directly |
| `SpawnInTerminal` | `"cmd"` | Run command in terminal emulator |
| `CopyToClipboard` | `"text"` | Copy text to clipboard |
| `OpenPath` | `"path"` | Open file/dir with xdg-open |
### Optional result fields
| Field | Type | Description |
|-------|------|-------------|
| `description` | `string` | Secondary line shown below title |
| `icon` | `string` | Icon path (future use) |
### Enabling an external plugin
In `~/.config/k-launcher/config.toml`:
```toml
[[plugins.external]]
name = "my-plugin"
path = "/usr/lib/k-launcher/plugins/my-plugin"
args = [] # optional
timeout_secs = 5 # optional, default 5
```
Multiple `[[plugins.external]]` blocks are supported.
### Example: shell plugin
```bash
#!/usr/bin/env bash
# A plugin that greets the user.
while IFS= read -r line; do
query=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin)['query'])")
if [[ "$query" == hello* ]]; then
echo '[{"id":"greet","title":"Hello, World!","score":80,"action":{"type":"CopyToClipboard","text":"Hello, World!"}}]'
else
echo '[]'
fi
done
```
### Example: Python plugin
```python
#!/usr/bin/env python3
import sys, json
for line in sys.stdin:
query = json.loads(line)["query"]
results = []
if query.startswith("hello"):
results.append({
"id": "greet",
"title": "Hello, World!",
"score": 80,
"action": {"type": "CopyToClipboard", "text": "Hello, World!"},
})
print(json.dumps(results), flush=True)
```
---
## Built-in Plugins (compiled-in)
Built-in plugins implement the `Plugin` trait from `k-launcher-domain` as Rust crates compiled into the binary.
### 1. Create a new crate in the workspace
```bash
cargo new --lib crates/plugins/plugin-hello
```
Add it to the workspace root `Cargo.toml`:
```toml
[workspace]
members = [
# ...existing members...
"crates/plugins/plugin-hello",
]
```
### 2. Add dependencies
`crates/plugins/plugin-hello/Cargo.toml`:
```toml
[dependencies]
k-launcher-domain = { workspace = true }
async-trait = "0.1"
```
### 3. Implement the `Plugin` trait
`crates/plugins/plugin-hello/src/lib.rs`:
```rust
use std::sync::Arc;
use async_trait::async_trait;
use k_launcher_domain::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
pub struct HelloPlugin;
impl HelloPlugin {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl Plugin for HelloPlugin {
fn name(&self) -> &str {
"hello"
}
async fn search(&self, query: &str) -> Vec<SearchResult> {
if !query.starts_with("hello") {
return vec![];
}
vec![SearchResult {
id: ResultId::new("hello:world"),
title: ResultTitle::new("Hello, World!"),
description: Some(Arc::from("A greeting from the hello plugin")),
icon: None,
score: Score::new(80),
action: LaunchAction::CopyToClipboard("Hello, World!".to_string()),
}]
}
}
```
### 4. Wire up in main.rs
`crates/k-launcher/src/main.rs` — add alongside the existing plugins:
```rust
use plugin_hello::HelloPlugin;
// inside main():
plugins.push(Arc::new(HelloPlugin::new()));
```
Also add the dependency to `crates/k-launcher/Cargo.toml`:
```toml
[dependencies]
plugin-hello = { path = "../plugins/plugin-hello" }
```
---
## Reference
### `SearchResult` Fields
| Field | Type | Description |
|-------|------|-------------|
| `id` | `ResultId` | Unique stable ID (e.g. `"apps:firefox"`) |
| `title` | `ResultTitle` | Primary display text |
| `description` | `Option<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` |
### `LaunchAction` Variants
| Variant | Behavior |
|---------|----------|
| `SpawnProcess(String)` | Launch a process directly (e.g. app exec string) |
| `SpawnInTerminal(String)` | Run command inside a terminal emulator |
| `OpenPath(String)` | Open a file or directory with `xdg-open` |
| `CopyToClipboard(String)` | Copy text to clipboard |
### Scoring Guidance
| Score range | Match type |
|-------------|-----------|
| 100 | Exact match |
| 9099 | Calc/command result (always relevant) |
| 80 | Prefix match |
| 70 | Abbreviation match |
| 60 | Substring match |
| 50 | Keyword / loose match |
The kernel sorts all results from all plugins by score descending and truncates to `max_results` (default: 8).

50
docs/usage.md Normal file
View File

@@ -0,0 +1,50 @@
# Usage
## Running
```bash
k-launcher
```
## Keybinds
| Key | Action |
|-----|--------|
| Type | Filter results |
| `↑` / `↓` | Navigate list |
| `Enter` | Launch selected result |
| `Escape` | Close launcher |
## Built-in Plugins
### Apps
Type any app name to search installed applications. An empty query shows your most frequently launched apps (frecency-ranked top results).
### Calc
Type a math expression — the result appears instantly and is copied to clipboard on `Enter`.
```
2^10 + 5 → 1029
sqrt(144) → 12
sin(pi / 2) → 1
```
### Shell Command
Prefix your input with `>` to run a shell command in a terminal:
```
> echo hello
> htop
```
### Files
Start your query with `/` or `~/` to browse the filesystem:
```
/etc/hosts
~/Documents/report.pdf
```

Some files were not shown because too many files have changed in this diff Show More