Compare commits

12 Commits

Author SHA1 Message Date
2feb3a2d96 style: apply rustfmt across workspace
Some checks failed
CI / test (pull_request) Failing after 5m30s
CI / clippy (pull_request) Failing after 5m2s
CI / fmt (pull_request) Successful in 26s
2026-03-15 20:02:12 +01:00
1e233aba4b feat(apps): replace simple scoring with nucleo fuzzy matcher
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 20:01:22 +01:00
c68f07d522 feat(ui): epoch-based debounce cancels stale search results 2026-03-15 20:00:17 +01:00
1e305dc5bf feat: add systemd units and show subcommand for daemon mode 2026-03-15 19:59:15 +01:00
68d18aad16 feat(ui): add error field and red status line display 2026-03-15 19:59:15 +01:00
aef33a53d7 feat(apps): use linicon for freedesktop icon theme traversal 2026-03-15 19:58:42 +01:00
a3c53a213b feat: init tracing_subscriber in main 2026-03-15 19:56:32 +01:00
d1122ff4f0 fix(apps): proper exec field-code stripping with quote-awareness 2026-03-15 19:56:02 +01:00
b68aef83ba fix(frecency): warn+disable instead of cwd fallback when XDG unavailable 2026-03-15 19:55:52 +01:00
574c355f82 docs: add compositor setup section and screenshot placeholder 2026-03-15 19:55:22 +01:00
fad9484ec6 packaging: add AUR PKGBUILD and .SRCINFO 2026-03-15 19:55:22 +01:00
22242f8f5c ci: add GitHub Actions CI and release workflows 2026-03-15 19:55:15 +01:00
96 changed files with 3129 additions and 4394 deletions

3
.gitignore vendored
View File

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

View File

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

1112
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,12 +2,10 @@
members = [ members = [
"crates/k-launcher", "crates/k-launcher",
"crates/k-launcher-config", "crates/k-launcher-config",
"crates/k-launcher-domain",
"crates/k-launcher-kernel", "crates/k-launcher-kernel",
"crates/k-launcher-os-bridge", "crates/k-launcher-os-bridge",
"crates/k-launcher-plugin-host", "crates/k-launcher-plugin-host",
"crates/k-launcher-ui", "crates/k-launcher-ui",
"crates/k-launcher-ui-core",
"crates/plugins/plugin-apps", "crates/plugins/plugin-apps",
"crates/plugins/plugin-calc", "crates/plugins/plugin-calc",
"crates/plugins/plugin-cmd", "crates/plugins/plugin-cmd",
@@ -15,45 +13,15 @@ members = [
"crates/k-launcher-ui-egui", "crates/k-launcher-ui-egui",
"crates/plugins/plugin-url", "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" resolver = "2"
[workspace.dependencies] [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" async-trait = "0.1"
dirs = "6.0" dirs = "6.0"
futures = "0.3"
iced = { version = "0.14", features = ["image", "svg", "tokio", "tiny-skia"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1.35", features = ["rt-multi-thread", "macros"] } tokio = { version = "1.35", features = ["rt-multi-thread", "macros"] }
thiserror = "2" toml = "1.0"
parking_lot = "0.12"
tracing = "0.1" tracing = "0.1"
ctrlc = "3"

21
LICENSE
View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

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

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

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

View File

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

View File

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

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

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

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

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

@@ -1,16 +0,0 @@
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,18 +1,10 @@
[package] [package]
name = "k-launcher-kernel" name = "k-launcher-kernel"
version = "0.2.1" version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
futures = "0.3" async-trait = { workspace = true }
k-launcher-domain = { workspace = true } futures = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
tokio = { 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

@@ -1,56 +0,0 @@
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,3 +1,221 @@
mod kernel; use std::sync::Arc;
pub use kernel::*; use async_trait::async_trait;
use futures::future::join_all;
// --- Newtypes ---
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct ResultId(String);
impl ResultId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ResultTitle(String);
impl ResultTitle {
pub fn new(title: impl Into<String>) -> Self {
Self(title.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
pub struct Score(u32);
impl Score {
pub fn new(value: u32) -> Self {
Self(value)
}
pub fn value(self) -> u32 {
self.0
}
}
// --- LaunchAction (port) ---
pub enum LaunchAction {
SpawnProcess(String),
SpawnInTerminal(String),
OpenPath(String),
CopyToClipboard(String),
Custom(Arc<dyn Fn() + Send + Sync>),
}
// --- AppLauncher port trait ---
pub trait AppLauncher: Send + Sync {
fn execute(&self, action: &LaunchAction);
}
// --- SearchResult ---
pub struct SearchResult {
pub id: ResultId,
pub title: ResultTitle,
pub description: Option<String>,
pub icon: Option<String>,
pub score: Score,
pub action: LaunchAction,
pub on_select: Option<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) -> &str;
async fn search(&self, query: &str) -> Vec<SearchResult>;
}
// --- SearchEngine port trait ---
#[async_trait]
pub trait SearchEngine: Send + Sync {
async fn search(&self, query: &str) -> Vec<SearchResult>;
}
// --- Kernel (Application use case) ---
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 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.truncate(self.max_results);
flat
}
}
#[async_trait]
impl SearchEngine for Kernel {
async fn search(&self, query: &str) -> Vec<SearchResult> {
self.search(query).await
}
}
// --- Tests ---
#[cfg(test)]
mod tests {
use super::*;
struct MockPlugin {
results: Vec<(&'static str, u32)>,
}
impl MockPlugin {
fn returns(results: Vec<(&'static str, u32)>) -> Self {
Self { results }
}
}
#[async_trait]
impl Plugin for MockPlugin {
fn name(&self) -> &str {
"mock"
}
async fn search(&self, _query: &str) -> Vec<SearchResult> {
self.results
.iter()
.enumerate()
.map(|(i, (title, score))| SearchResult {
id: ResultId::new(format!("id-{i}")),
title: ResultTitle::new(*title),
description: None,
icon: None,
score: Score::new(*score),
action: LaunchAction::Custom(Arc::new(|| {})),
on_select: None,
})
.collect()
}
}
#[test]
fn newtype_result_id() {
assert_eq!(ResultId::new("x").as_str(), "x");
}
#[test]
fn newtype_score() {
assert_eq!(Score::new(42).value(), 42);
}
#[test]
fn newtype_title() {
assert_eq!(ResultTitle::new("hello").as_str(), "hello");
}
#[tokio::test]
async fn empty_kernel_returns_empty() {
let k = Kernel::new(vec![], 8);
assert!(k.search("x").await.is_empty());
}
#[tokio::test]
async fn kernel_sorts_by_score_desc() {
let plugin = Arc::new(MockPlugin::returns(vec![
("lower", 5),
("higher", 10),
("middle", 7),
]));
let k = Kernel::new(vec![plugin], 8);
let results = k.search("q").await;
assert_eq!(results[0].score.value(), 10);
assert_eq!(results[1].score.value(), 7);
assert_eq!(results[2].score.value(), 5);
}
#[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,42 +0,0 @@
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

@@ -1,107 +0,0 @@
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,9 +1,9 @@
[package] [package]
name = "k-launcher-os-bridge" name = "k-launcher-os-bridge"
version = "0.2.1" version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
k-launcher-domain = { workspace = true } k-launcher-config = { path = "../k-launcher-config" }
k-launcher-kernel = { path = "../k-launcher-kernel" }
libc = "0.2" libc = "0.2"
tracing = { workspace = true }

View File

@@ -1,50 +0,0 @@
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,7 +1,23 @@
mod launcher; mod unix_launcher;
mod shell;
mod spawn;
mod terminal;
pub use launcher::UnixAppLauncher; pub use unix_launcher::UnixAppLauncher;
pub use shell::shell_split;
pub struct WindowConfig {
pub width: f32,
pub height: f32,
pub decorations: bool,
pub transparent: bool,
pub resizable: bool,
}
impl WindowConfig {
pub fn from_cfg(w: &k_launcher_config::WindowCfg) -> Self {
Self {
width: w.width,
height: w.height,
decorations: w.decorations,
transparent: w.transparent,
resizable: w.resizable,
}
}
}

View File

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

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

@@ -1,90 +0,0 @@
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,127 @@
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
use k_launcher_kernel::{AppLauncher, LaunchAction};
fn parse_term_cmd(s: &str) -> (String, Vec<String>) {
let mut parts = s.split_whitespace();
let bin = parts.next().unwrap_or("").to_string();
let args = parts.map(str::to_string).collect();
(bin, args)
}
fn which(bin: &str) -> bool {
Command::new("which")
.arg(bin)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn resolve_terminal() -> Option<(String, Vec<String>)> {
if let Ok(val) = std::env::var("TERM_CMD") {
let val = val.trim().to_string();
if !val.is_empty() {
let (bin, args) = parse_term_cmd(&val);
if !bin.is_empty() {
return Some((bin, args));
}
}
}
if let Ok(val) = std::env::var("TERMINAL") {
let bin = val.trim().to_string();
if !bin.is_empty() {
return Some((bin, vec!["-e".to_string()]));
}
}
for (bin, flag) in &[
("foot", "-e"),
("kitty", "-e"),
("alacritty", "-e"),
("wezterm", "start"),
("konsole", "-e"),
("xterm", "-e"),
] {
if which(bin) {
return Some((bin.to_string(), vec![flag.to_string()]));
}
}
None
}
pub struct UnixAppLauncher;
impl UnixAppLauncher {
pub fn new() -> Self {
Self
}
}
impl Default for UnixAppLauncher {
fn default() -> Self {
Self::new()
}
}
impl AppLauncher for UnixAppLauncher {
fn execute(&self, action: &LaunchAction) {
match action {
LaunchAction::SpawnProcess(cmd) => {
let parts: Vec<&str> = cmd.split_whitespace().collect();
if let Some((bin, args)) = parts.split_first() {
let _ = unsafe {
Command::new(bin)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.pre_exec(|| {
libc::setsid();
Ok(())
})
.spawn()
};
}
}
LaunchAction::SpawnInTerminal(cmd) => {
let Some((term_bin, term_args)) = resolve_terminal() else {
return;
};
let _ = unsafe {
Command::new(&term_bin)
.args(&term_args)
.arg("sh")
.arg("-c")
.arg(cmd)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.pre_exec(|| {
libc::setsid();
Ok(())
})
.spawn()
};
}
LaunchAction::OpenPath(path) => {
let _ = Command::new("xdg-open").arg(path).spawn();
}
LaunchAction::CopyToClipboard(val) => {
if Command::new("wl-copy").arg(val).spawn().is_err() {
use std::io::Write;
if let Ok(mut child) = Command::new("xclip")
.args(["-selection", "clipboard"])
.stdin(Stdio::piped())
.spawn()
&& let Some(stdin) = child.stdin.as_mut()
{
let _ = stdin.write_all(val.as_bytes());
}
}
}
LaunchAction::Custom(f) => f(),
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

View File

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

View File

@@ -1,177 +1,189 @@
use std::sync::{Arc, mpsc}; use std::sync::{Arc, mpsc};
use egui::ViewportCommand; use egui::{Color32, Key, ViewportCommand};
use k_launcher_config::AppearanceCfg; use k_launcher_kernel::{AppLauncher, SearchEngine, SearchResult};
use k_launcher_domain::AppLauncher; use k_launcher_os_bridge::WindowConfig;
use k_launcher_domain::SearchResult;
use k_launcher_kernel::Kernel;
use k_launcher_ui_core::{Action, Effect, LauncherState};
use crate::input::{InputAction, process_input}; const BG: Color32 = Color32::from_rgba_premultiplied(20, 20, 30, 230);
use crate::render; const BORDER_COLOR: Color32 = Color32::from_rgb(229, 125, 33);
use crate::style; const SELECTED_BG: Color32 = Color32::from_rgba_premultiplied(0, 100, 140, 180);
const DIM_TEXT: Color32 = Color32::from_rgb(180, 185, 200);
pub struct KLauncherApp { pub struct KLauncherApp {
pub(crate) inner: LauncherState, engine: Arc<dyn SearchEngine>,
launcher: Arc<dyn AppLauncher>,
query: String,
results: Vec<SearchResult>,
selected: usize,
rt: tokio::runtime::Handle, rt: tokio::runtime::Handle,
result_tx: mpsc::SyncSender<Vec<SearchResult>>, result_tx: mpsc::SyncSender<Vec<SearchResult>>,
pub(crate) result_rx: mpsc::Receiver<Vec<SearchResult>>, result_rx: mpsc::Receiver<Vec<SearchResult>>,
} }
impl KLauncherApp { impl KLauncherApp {
fn new( fn new(
engine: Arc<Kernel>, engine: Arc<dyn SearchEngine>,
launcher: Arc<dyn AppLauncher>, launcher: Arc<dyn AppLauncher>,
rt: tokio::runtime::Handle, rt: tokio::runtime::Handle,
cfg: AppearanceCfg,
) -> Self { ) -> Self {
const RESULT_CHANNEL_CAPACITY: usize = 4; let (result_tx, result_rx) = mpsc::sync_channel(4);
let (result_tx, result_rx) = mpsc::sync_channel(RESULT_CHANNEL_CAPACITY); Self {
let mut inner = LauncherState::new(launcher, cfg, 0); engine,
launcher,
let effect = inner.handle(Action::EngineReady(engine)); query: String::new(),
results: vec![],
let app = Self { selected: 0,
inner,
rt, rt,
result_tx, result_tx,
result_rx, result_rx,
}; }
app.execute_effect(effect);
app
} }
fn trigger_search(&self, query: String) { fn trigger_search(&self, query: String) {
let Some(engine) = self.inner.engine().cloned() else { let engine = self.engine.clone();
return;
};
let tx = self.result_tx.clone(); let tx = self.result_tx.clone();
self.rt.spawn(async move { self.rt.spawn(async move {
let results = engine.search(&query).await; let results = engine.search(&query).await;
if let Err(e) = tx.send(results) { let _ = 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 { impl eframe::App for KLauncherApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.poll_search_results(); if let Ok(results) = self.result_rx.try_recv() {
self.results = 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); let mut close = false;
let mut launch_selected = false;
ctx.input(|i| {
if i.key_pressed(Key::Escape) {
close = true;
}
if i.key_pressed(Key::Enter) {
launch_selected = true;
}
if i.key_pressed(Key::ArrowDown) {
let len = self.results.len();
if len > 0 {
self.selected = (self.selected + 1).min(len - 1);
}
}
if i.key_pressed(Key::ArrowUp) && self.selected > 0 {
self.selected -= 1;
}
});
if close {
ctx.send_viewport_cmd(ViewportCommand::Close);
return;
}
if launch_selected {
if let Some(result) = self.results.get(self.selected) {
if let Some(on_select) = &result.on_select {
on_select();
}
self.launcher.execute(&result.action);
}
ctx.send_viewport_cmd(ViewportCommand::Close);
return;
}
let frame = egui::Frame::new()
.fill(BG)
.stroke(egui::Stroke::new(1.0, BORDER_COLOR))
.inner_margin(egui::Margin::same(12))
.corner_radius(egui::CornerRadius::same(8));
egui::CentralPanel::default().frame(frame).show(ctx, |ui| {
let response = ui.add_sized(
[ui.available_width(), 36.0],
egui::TextEdit::singleline(&mut self.query)
.hint_text("Search...")
.font(egui::TextStyle::Heading),
);
if response.changed() {
self.selected = 0;
self.trigger_search(self.query.clone());
}
response.request_focus();
ui.add_space(8.0);
if self.results.is_empty() && !self.query.is_empty() {
ui.add_space(20.0);
ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| {
ui.colored_label(DIM_TEXT, "No results");
});
return;
}
egui::ScrollArea::vertical().show(ui, |ui| {
ui.set_width(ui.available_width());
for (i, result) in self.results.iter().enumerate() {
let is_selected = i == self.selected;
let bg = if is_selected {
SELECTED_BG
} else {
Color32::TRANSPARENT
};
let row_frame = egui::Frame::new()
.fill(bg)
.inner_margin(egui::Margin {
left: 8,
right: 8,
top: 6,
bottom: 6,
})
.corner_radius(egui::CornerRadius::same(4));
row_frame.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.vertical(|ui| {
ui.label(result.title.as_str());
if let Some(desc) = &result.description {
ui.colored_label(DIM_TEXT, desc);
}
});
});
});
ui.add_space(2.0);
}
});
});
} }
} }
pub fn run( pub fn run(
engine: Arc<Kernel>, engine: Arc<dyn SearchEngine>,
launcher: Arc<dyn AppLauncher>, launcher: Arc<dyn AppLauncher>,
window_cfg: &k_launcher_config::WindowCfg,
appearance_cfg: AppearanceCfg,
) -> Result<(), eframe::Error> { ) -> Result<(), eframe::Error> {
let wc = WindowConfig::from_cfg(&k_launcher_config::WindowCfg::default());
let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
let handle = rt.handle().clone(); let handle = rt.handle().clone();
let options = eframe::NativeOptions { let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default() viewport: egui::ViewportBuilder::default()
.with_inner_size([window_cfg.width, window_cfg.height]) .with_inner_size([wc.width, wc.height])
.with_decorations(window_cfg.decorations) .with_decorations(wc.decorations)
.with_transparent(window_cfg.transparent) .with_transparent(wc.transparent)
.with_resizable(window_cfg.resizable) .with_resizable(wc.resizable)
.with_always_on_top(), .with_always_on_top(),
..Default::default() ..Default::default()
}; };
eframe::run_native( eframe::run_native(
k_launcher_domain::constants::APP_TITLE, "K-Launcher",
options, options,
Box::new(move |_cc| { Box::new(move |_cc| Ok(Box::new(KLauncherApp::new(engine, launcher, handle)))),
Ok(Box::new(KLauncherApp::new(
engine,
launcher,
handle,
appearance_cfg,
)))
}),
) )
} }

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,10 @@
use std::io::Write;
pub fn send_show() -> Result<(), Box<dyn std::error::Error>> {
let runtime_dir =
std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string());
let socket_path = format!("{runtime_dir}/k-launcher.sock");
let mut stream = std::os::unix::net::UnixStream::connect(&socket_path)?;
stream.write_all(b"show\n")?;
Ok(())
}

View File

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

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

View File

@@ -1,53 +0,0 @@
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,46 +1,66 @@
mod client;
use std::sync::Arc; use std::sync::Arc;
use k_launcher_kernel::Kernel;
use k_launcher_os_bridge::UnixAppLauncher; use k_launcher_os_bridge::UnixAppLauncher;
use k_launcher_plugin_host::ExternalPlugin;
mod engine; #[cfg(target_os = "linux")]
mod error; use plugin_apps::linux::FsDesktopEntrySource;
mod logging; use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
use plugin_calc::CalcPlugin;
use error::AppError; use plugin_cmd::CmdPlugin;
use plugin_files::FilesPlugin;
fn main() { fn main() {
if std::env::args().any(|a| a == "--version" || a == "-V") { tracing_subscriber::fmt::init();
println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
let args: Vec<String> = std::env::args().collect();
if args.get(1).map(|s| s.as_str()) == Some("show") {
if let Err(e) = client::send_show() {
eprintln!("error: failed to send show command: {e}");
std::process::exit(1);
}
return; return;
} }
let cfg = Arc::new(k_launcher_config::load()); if let Err(e) = run_ui() {
let _guard = logging::init_logging(&cfg); eprintln!("error: UI: {e}");
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); std::process::exit(1);
} }
} }
fn run_ui(cfg: Arc<k_launcher_config::Config>) -> Result<(), AppError> { fn run_ui() -> iced::Result {
let launcher = Arc::new(UnixAppLauncher::new(cfg.terminal.cmd.clone())); let cfg = k_launcher_config::load();
let factory_cfg = cfg.clone(); let launcher = Arc::new(UnixAppLauncher::new());
let factory: Arc<dyn Fn() -> Arc<k_launcher_kernel::Kernel> + Send + Sync> = let frecency = FrecencyStore::load();
Arc::new(move || engine::build_engine(factory_cfg.clone()));
k_launcher_ui::run( let mut plugins: Vec<Arc<dyn k_launcher_kernel::Plugin>> = vec![];
factory, if cfg.plugins.cmd {
launcher, plugins.push(Arc::new(CmdPlugin::new()));
&cfg.window, }
cfg.appearance.clone(), if cfg.plugins.calc {
&cfg.search, plugins.push(Arc::new(CalcPlugin::new()));
) }
.map_err(AppError::Ui) 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(),
)));
}
let kernel: Arc<dyn k_launcher_kernel::SearchEngine> =
Arc::new(Kernel::new(plugins, cfg.search.max_results));
k_launcher_ui::run(kernel, launcher, &cfg.window, cfg.appearance)
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,399 @@
mod cache;
pub mod frecency; pub mod frecency;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub mod linux; pub mod linux;
mod plugin;
mod scoring;
mod types;
pub use cache::{CachedEntry, build_entries, load_from_path, save_to_path}; use std::{collections::HashMap, sync::Arc};
pub use plugin::*;
pub use scoring::{humanize_category, new_matcher, parse_pattern, score_match}; use async_trait::async_trait;
pub use types::*; use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
use crate::frecency::FrecencyStore;
// --- Domain newtypes ---
#[derive(Debug, Clone)]
pub struct AppName(String);
impl AppName {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct ExecCommand(String);
impl ExecCommand {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct IconPath(String);
impl IconPath {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
// --- Desktop entry ---
pub struct DesktopEntry {
pub name: AppName,
pub exec: ExecCommand,
pub icon: Option<IconPath>,
pub category: Option<String>,
pub keywords: Vec<String>,
}
// --- Swappable source trait (Application layer principle) ---
pub trait DesktopEntrySource: Send + Sync {
fn entries(&self) -> Vec<DesktopEntry>;
}
// --- Cached entry (pre-computed at construction) ---
struct CachedEntry {
id: String,
name: AppName,
keywords_lc: Vec<String>,
category: Option<String>,
icon: Option<String>,
exec: String,
on_select: Arc<dyn Fn() + Send + Sync>,
}
// --- Plugin ---
pub struct AppsPlugin {
entries: HashMap<String, CachedEntry>,
frecency: Arc<FrecencyStore>,
}
impl AppsPlugin {
pub fn new(source: impl DesktopEntrySource, frecency: Arc<FrecencyStore>) -> Self {
let entries = source
.entries()
.into_iter()
.map(|e| {
let id = format!("app-{}", e.name.as_str());
let keywords_lc = e.keywords.iter().map(|k| k.to_lowercase()).collect();
#[cfg(target_os = "linux")]
let icon = e
.icon
.as_ref()
.and_then(|p| linux::resolve_icon_path(p.as_str()));
#[cfg(not(target_os = "linux"))]
let icon: Option<String> = None;
let exec = e.exec.as_str().to_string();
let store = Arc::clone(&frecency);
let record_id = id.clone();
let on_select: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
store.record(&record_id);
});
let cached = CachedEntry {
id: id.clone(),
keywords_lc,
category: e.category,
icon,
exec,
on_select,
name: e.name,
};
(id, cached)
})
.collect();
Self { entries, frecency }
}
}
fn initials(name_lc: &str) -> String {
name_lc
.split_whitespace()
.filter_map(|w| w.chars().next())
.collect()
}
fn score_match(name: &str, query: &str) -> Option<u32> {
use nucleo_matcher::{
Config, Matcher, Utf32Str,
pattern::{CaseMatching, Normalization, Pattern},
};
let mut matcher = Matcher::new(Config::DEFAULT);
let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
let mut name_chars: Vec<char> = name.chars().collect();
let haystack = Utf32Str::new(name, &mut name_chars);
let score = pattern.score(haystack, &mut matcher);
if let Some(s) = score {
let name_lc = name.to_lowercase();
let query_lc = query.to_lowercase();
let bonus: u32 = if initials(&name_lc).starts_with(&query_lc) {
20
} else {
0
};
Some(s.saturating_add(bonus))
} else {
None
}
}
pub(crate) fn humanize_category(s: &str) -> String {
let mut result = String::new();
for ch in s.chars() {
if ch.is_uppercase() && !result.is_empty() {
result.push(' ');
}
result.push(ch);
}
result
}
#[async_trait]
impl Plugin for AppsPlugin {
fn name(&self) -> &str {
"apps"
}
async fn search(&self, query: &str) -> Vec<SearchResult> {
if query.is_empty() {
return self
.frecency
.top_ids(5)
.iter()
.filter_map(|id| {
let e = self.entries.get(id)?;
let score = self.frecency.frecency_score(id).max(1);
Some(SearchResult {
id: ResultId::new(id),
title: ResultTitle::new(e.name.as_str()),
description: e.category.clone(),
icon: e.icon.clone(),
score: Score::new(score),
action: LaunchAction::SpawnProcess(e.exec.clone()),
on_select: Some(Arc::clone(&e.on_select)),
})
})
.collect();
}
let query_lc = query.to_lowercase();
self.entries
.values()
.filter_map(|e| {
let score = score_match(e.name.as_str(), query).or_else(|| {
e.keywords_lc
.iter()
.any(|k| k.contains(&query_lc))
.then_some(50)
})?;
Some(SearchResult {
id: ResultId::new(&e.id),
title: ResultTitle::new(e.name.as_str()),
description: e.category.clone(),
icon: e.icon.clone(),
score: Score::new(score),
action: LaunchAction::SpawnProcess(e.exec.clone()),
on_select: Some(Arc::clone(&e.on_select)),
})
})
.collect()
}
}
// --- Tests ---
#[cfg(test)]
mod tests {
use super::*;
fn ephemeral_frecency() -> Arc<FrecencyStore> {
FrecencyStore::new_for_test()
}
struct MockSource {
entries: Vec<(String, String, Option<String>, Vec<String>)>, // (name, exec, category, keywords)
}
impl MockSource {
fn with(entries: Vec<(&str, &str)>) -> Self {
Self {
entries: entries
.into_iter()
.map(|(n, e)| (n.to_string(), e.to_string(), None, vec![]))
.collect(),
}
}
fn with_categories(entries: Vec<(&str, &str, &str)>) -> Self {
Self {
entries: entries
.into_iter()
.map(|(n, e, c)| (n.to_string(), e.to_string(), Some(c.to_string()), vec![]))
.collect(),
}
}
fn with_keywords(entries: Vec<(&str, &str, Vec<&str>)>) -> Self {
Self {
entries: entries
.into_iter()
.map(|(n, e, kw)| {
(
n.to_string(),
e.to_string(),
None,
kw.into_iter().map(|s| s.to_string()).collect(),
)
})
.collect(),
}
}
}
impl DesktopEntrySource for MockSource {
fn entries(&self) -> Vec<DesktopEntry> {
self.entries
.iter()
.map(|(name, exec, category, keywords)| DesktopEntry {
name: AppName::new(name.clone()),
exec: ExecCommand::new(exec.clone()),
icon: None,
category: category.clone(),
keywords: keywords.clone(),
})
.collect()
}
}
#[tokio::test]
async fn apps_prefix_match() {
let p = AppsPlugin::new(
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(
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(
MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
assert!(p.search("").await.is_empty());
}
#[test]
fn score_match_abbreviation() {
assert_eq!(initials("visual studio code"), "vsc");
assert!(score_match("visual studio code", "vsc").is_some());
}
#[test]
fn score_match_exact_beats_prefix_beats_abbrev_beats_substr() {
let exact = score_match("firefox", "firefox");
let prefix = score_match("firefox", "fire");
let abbrev = score_match("gnu firefox", "gf");
let substr = score_match("ice firefox", "fire");
assert!(exact.is_some());
assert!(prefix.is_some());
assert!(abbrev.is_some());
assert!(substr.is_some());
assert!(exact.unwrap() > prefix.unwrap());
}
#[tokio::test]
async fn apps_abbreviation_match() {
let p = AppsPlugin::new(
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(
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(
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(
MockSource::with_categories(vec![("Code", "code", "Text Editor")]),
ephemeral_frecency(),
);
let results = p.search("code").await;
assert_eq!(results[0].description.as_deref(), Some("Text Editor"));
}
#[tokio::test]
async fn apps_empty_query_returns_top_frecent() {
let frecency = ephemeral_frecency();
frecency.record("app-Code");
frecency.record("app-Code");
frecency.record("app-Firefox");
let p = AppsPlugin::new(
MockSource::with(vec![("Firefox", "firefox"), ("Code", "code")]),
frecency,
);
let results = p.search("").await;
assert_eq!(results.len(), 2);
assert_eq!(results[0].title.as_str(), "Code");
}
}

View File

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

View File

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,7 +35,6 @@ The process is kept alive between queries — do **not** exit after each respons
| `"type"` | Extra fields | Behavior | | `"type"` | Extra fields | Behavior |
|----------|-------------|---------| |----------|-------------|---------|
| `SpawnProcess` | `"cmd"` | Launch process directly | | `SpawnProcess` | `"cmd"` | Launch process directly |
| `SpawnInTerminal` | `"cmd"` | Run command in terminal emulator |
| `CopyToClipboard` | `"text"` | Copy text to clipboard | | `CopyToClipboard` | `"text"` | Copy text to clipboard |
| `OpenPath` | `"path"` | Open file/dir with xdg-open | | `OpenPath` | `"path"` | Open file/dir with xdg-open |
@@ -55,7 +54,6 @@ In `~/.config/k-launcher/config.toml`:
name = "my-plugin" name = "my-plugin"
path = "/usr/lib/k-launcher/plugins/my-plugin" path = "/usr/lib/k-launcher/plugins/my-plugin"
args = [] # optional args = [] # optional
timeout_secs = 5 # optional, default 5
``` ```
Multiple `[[plugins.external]]` blocks are supported. Multiple `[[plugins.external]]` blocks are supported.
@@ -98,7 +96,7 @@ for line in sys.stdin:
## Built-in Plugins (compiled-in) ## Built-in Plugins (compiled-in)
Built-in plugins implement the `Plugin` trait from `k-launcher-domain` as Rust crates compiled into the binary. Built-in plugins implement the `Plugin` trait from `k-launcher-kernel` as Rust crates compiled into the binary.
### 1. Create a new crate in the workspace ### 1. Create a new crate in the workspace
@@ -122,7 +120,7 @@ members = [
```toml ```toml
[dependencies] [dependencies]
k-launcher-domain = { workspace = true } k-launcher-kernel = { path = "../../k-launcher-kernel" }
async-trait = "0.1" async-trait = "0.1"
``` ```
@@ -131,10 +129,8 @@ async-trait = "0.1"
`crates/plugins/plugin-hello/src/lib.rs`: `crates/plugins/plugin-hello/src/lib.rs`:
```rust ```rust
use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use k_launcher_domain::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult}; use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
pub struct HelloPlugin; pub struct HelloPlugin;
@@ -158,10 +154,11 @@ impl Plugin for HelloPlugin {
vec![SearchResult { vec![SearchResult {
id: ResultId::new("hello:world"), id: ResultId::new("hello:world"),
title: ResultTitle::new("Hello, World!"), title: ResultTitle::new("Hello, World!"),
description: Some(Arc::from("A greeting from the hello plugin")), description: Some("A greeting from the hello plugin".to_string()),
icon: None, icon: None,
score: Score::new(80), score: Score::new(80),
action: LaunchAction::CopyToClipboard("Hello, World!".to_string()), action: LaunchAction::CopyToClipboard("Hello, World!".to_string()),
on_select: None,
}] }]
} }
} }
@@ -195,10 +192,11 @@ plugin-hello = { path = "../plugins/plugin-hello" }
|-------|------|-------------| |-------|------|-------------|
| `id` | `ResultId` | Unique stable ID (e.g. `"apps:firefox"`) | | `id` | `ResultId` | Unique stable ID (e.g. `"apps:firefox"`) |
| `title` | `ResultTitle` | Primary display text | | `title` | `ResultTitle` | Primary display text |
| `description` | `Option<Arc<str>>` | Secondary line shown below title | | `description` | `Option<String>` | Secondary line shown below title |
| `icon` | `Option<Arc<str>>` | Icon name or path (currently unused in renderer) | | `icon` | `Option<String>` | Icon name or path (currently unused in renderer) |
| `score` | `Score(u32)` | Sort priority — higher wins | | `score` | `Score(u32)` | Sort priority — higher wins |
| `action` | `LaunchAction` | What happens on `Enter` | | `action` | `LaunchAction` | What happens on `Enter` |
| `on_select` | `Option<Arc<dyn Fn()>>` | Optional side-effect on selection (e.g. frecency bump) |
### `LaunchAction` Variants ### `LaunchAction` Variants
@@ -208,6 +206,7 @@ plugin-hello = { path = "../plugins/plugin-hello" }
| `SpawnInTerminal(String)` | Run command inside a terminal emulator | | `SpawnInTerminal(String)` | Run command inside a terminal emulator |
| `OpenPath(String)` | Open a file or directory with `xdg-open` | | `OpenPath(String)` | Open a file or directory with `xdg-open` |
| `CopyToClipboard(String)` | Copy text to clipboard | | `CopyToClipboard(String)` | Copy text to clipboard |
| `Custom(Arc<dyn Fn()>)` | Arbitrary closure |
### Scoring Guidance ### Scoring Guidance

0
docs/screenshot.png Normal file
View File

View File

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