Compare commits
14 Commits
2e2351e084
...
v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fc21ede97 | |||
| fa5d38107a | |||
| 051d19d878 | |||
| 2e773cdeaf | |||
| 3d2bd5f9fe | |||
| ff9b2b5712 | |||
| 38860762c0 | |||
| 248094f442 | |||
| bd356f27d1 | |||
| 58d0739cea | |||
| 12f1f541ae | |||
| bee429192f | |||
| 86e843f666 | |||
| 71b8e46ae6 |
39
.github/workflows/ci.yml
vendored
Normal file
39
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Install system deps
|
||||||
|
run: sudo apt-get install -y libwayland-dev libxkbcommon-dev pkg-config
|
||||||
|
- run: cargo test --workspace
|
||||||
|
|
||||||
|
clippy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: clippy
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Install system deps
|
||||||
|
run: sudo apt-get install -y libwayland-dev libxkbcommon-dev pkg-config
|
||||||
|
- run: cargo clippy --workspace -- -D warnings
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt
|
||||||
|
- run: cargo fmt --check
|
||||||
21
.github/workflows/release.yml
vendored
Normal file
21
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Install system deps
|
||||||
|
run: sudo apt-get install -y libwayland-dev libxkbcommon-dev pkg-config
|
||||||
|
- run: cargo build --release
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: k-launcher
|
||||||
|
path: target/release/k-launcher
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1 +1,4 @@
|
|||||||
target/
|
target/
|
||||||
|
.worktrees/
|
||||||
|
docs/superpowers/
|
||||||
|
.superpowers/
|
||||||
|
|||||||
58
CLAUDE.md
Normal file
58
CLAUDE.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
## 1. Core Philosophy
|
||||||
|
|
||||||
|
- **Test-Driven Development (TDD):** No functional code is written without a failing test first. Red-Green-Refactor is the mandatory cycle.
|
||||||
|
- **Clean Architecture:** Maintain strict separation between Domain, Application, and Infrastructure layers.
|
||||||
|
- **Newtype Pattern:** Use the "Newtype" pattern for all domain primitives (e.g., `struct UserId(Uuid)`) to ensure type safety and prevent primitive obsession.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Structural Rules
|
||||||
|
|
||||||
|
### Dependency Management
|
||||||
|
|
||||||
|
- **Strict Unidirectionality:** Dependencies must only point inwards (towards the Domain).
|
||||||
|
- **No Cyclic Dependencies:** Use traits and Dependency Injection (DI) to break cycles. If two modules need each other, abstract the shared behavior into a trait or move common data to a lower-level module.
|
||||||
|
- **Feature Gating:** Organize the project into logical crates or modules that can be compiled independently.
|
||||||
|
|
||||||
|
### Traits and Decoupling
|
||||||
|
|
||||||
|
- **Swappable Infrastructure:** Define all external interactions (Database, API, File System) as traits in the Application layer.
|
||||||
|
- **Small Traits:** Adhere to the Interface Segregation Principle. Favor many specific traits over one "God" trait.
|
||||||
|
- **Mocking:** Use traits to allow easy mocking in unit tests without requiring a real database or network.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Rust Specifics & Clean Code
|
||||||
|
|
||||||
|
### Type Safety
|
||||||
|
|
||||||
|
- Avoid `String` or `i32` for domain concepts. Wrap them in structs.
|
||||||
|
- Use `Result` and `Option` explicitly. Minimize `unwrap()` and `expect()`—handle errors gracefully at the boundaries.
|
||||||
|
|
||||||
|
### Formatting & Style
|
||||||
|
|
||||||
|
- Follow standard `rustfmt` and `clippy` suggestions.
|
||||||
|
- Function names should be descriptive (e.g., `process_valid_order` instead of `handle_data`).
|
||||||
|
- Keep functions small (typically under 20-30 lines).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Layer Definitions
|
||||||
|
|
||||||
|
| Layer | Crates | Responsibility | Allowed Dependencies |
|
||||||
|
| ------------------ | ----------------------------------------------- | --------------------------------------------- | -------------------- |
|
||||||
|
| **Domain** | `k-launcher-domain` | Pure value types, newtypes, constants, port traits (Plugin, AppLauncher). | None (Pure Rust + serde + async-trait) |
|
||||||
|
| **Application** | `k-launcher-kernel` | Kernel orchestrator. | Domain |
|
||||||
|
| **Infrastructure** | `k-launcher-ui`, `k-launcher-ui-egui`, `k-launcher-ui-core`, `k-launcher-os-bridge`, `k-launcher-plugin-host`, `k-launcher-config`, all `plugin-*` crates | Trait implementations, UI, config, plugins. | Domain, Application |
|
||||||
|
| **Main** | `k-launcher` | Entry point, DI wiring, logging. | All of the above |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. TDD Workflow Requirement
|
||||||
|
|
||||||
|
1. **Write a Test:** Create a test in `src/lib.rs` or a `tests/` directory.
|
||||||
|
2. **Define the Interface:** Use a trait or function signature to make the test compile (but fail).
|
||||||
|
3. **Minimum Implementation:** Write just enough code to pass the test.
|
||||||
|
4. **Refactor:** Clean up the logic, ensure no duplication, and check for "Newtype" opportunities.
|
||||||
|
|
||||||
|
> **Note on Cyclic Dependencies:** If an AI agent suggests a change that introduces a cycle, it must be rejected. Use **Inversion of Control** by defining a trait in the higher-level module that the lower-level module implements.
|
||||||
1391
Cargo.lock
generated
1391
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
38
Cargo.toml
38
Cargo.toml
@@ -2,10 +2,12 @@
|
|||||||
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",
|
||||||
@@ -13,15 +15,45 @@ 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"] }
|
||||||
toml = "1.0"
|
thiserror = "2"
|
||||||
|
parking_lot = "0.12"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
ctrlc = "3"
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Gabriel Kaszewski
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
18
Makefile
18
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: build build-egui dev check clippy fmt fmt-check test run run-egui install install-egui clean
|
.PHONY: build build-egui dev check test fmt run run-egui install install-egui clean
|
||||||
|
|
||||||
RELEASE_BIN := target/release/k-launcher
|
RELEASE_BIN := target/release/k-launcher
|
||||||
EGUI_BIN := target/release/k-launcher-egui
|
EGUI_BIN := target/release/k-launcher-egui
|
||||||
@@ -11,23 +11,19 @@ build-egui:
|
|||||||
cargo build --release -p k-launcher --features egui --bin k-launcher-egui
|
cargo build --release -p k-launcher --features egui --bin k-launcher-egui
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
cargo build
|
RUST_LOG=debug cargo run
|
||||||
|
|
||||||
check:
|
check:
|
||||||
cargo check --workspace
|
|
||||||
|
|
||||||
clippy:
|
|
||||||
cargo clippy --workspace -- -D warnings
|
|
||||||
|
|
||||||
fmt:
|
|
||||||
cargo fmt --all
|
|
||||||
|
|
||||||
fmt-check:
|
|
||||||
cargo fmt --all -- --check
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy --workspace -- -D warnings
|
||||||
|
cargo test --workspace
|
||||||
|
|
||||||
test:
|
test:
|
||||||
cargo test --workspace
|
cargo test --workspace
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
cargo fmt --all
|
||||||
|
|
||||||
run:
|
run:
|
||||||
cargo run --release
|
cargo run --release
|
||||||
|
|
||||||
|
|||||||
84
README.md
84
README.md
@@ -1,47 +1,78 @@
|
|||||||
# k-launcher
|
# k-launcher
|
||||||
|
|
||||||
A lightweight, GPU-accelerated command palette for Linux (Wayland/X11). Zero Electron — every pixel rendered via WGPU. Async search that never blocks the UI.
|
A lightweight command palette for Linux (Wayland/X11). Fuzzy search, frecency ranking, plugin system. Written in Rust.
|
||||||
|
|
||||||
```
|
|
||||||
[screenshot placeholder]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
||||||
cargo build --release
|
make install
|
||||||
./target/release/k-launcher
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Or with cargo directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
cp target/release/k-launcher ~/.local/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Arch Linux (AUR)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yay -S k-launcher
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
| Input | What it does | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| any text | Fuzzy-search installed apps | `firefox` |
|
||||||
|
| empty | Show most-used apps (frecency) | |
|
||||||
|
| `>` prefix | Run shell command in terminal | `> htop` |
|
||||||
|
| `=` or math | Evaluate expression, copy result | `2^10 + 5` |
|
||||||
|
| `/` or `~/` | Browse filesystem | `~/Documents` |
|
||||||
|
|
||||||
## Keybinds
|
## Keybinds
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
| --------- | --------------- |
|
|---|---|
|
||||||
| Type | Filter results |
|
| `↑` / `↓` | Navigate results |
|
||||||
| `↑` / `↓` | Navigate |
|
| `Enter` | Launch / copy |
|
||||||
| `Enter` | Launch selected |
|
|
||||||
| `Escape` | Close |
|
| `Escape` | Close |
|
||||||
|
|
||||||
## Built-in Plugins
|
## Configuration
|
||||||
|
|
||||||
| Trigger | Plugin | Example |
|
`~/.config/k-launcher/config.toml` — all fields optional, sensible defaults.
|
||||||
| ----------------- | ------ | -------------- |
|
|
||||||
| (any text) | Apps | `firefox` |
|
|
||||||
| number/expression | Calc | `2^10 + 5` |
|
|
||||||
| `>` prefix | Shell | `> echo hello` |
|
|
||||||
| `/` or `~/` | Files | `~/Documents` |
|
|
||||||
|
|
||||||
## External Plugins
|
See [config.example.toml](config.example.toml) for all available options.
|
||||||
|
|
||||||
Drop in community plugins — any language, no recompilation. Plugins are executables that communicate over stdin/stdout JSON:
|
## Compositor Setup
|
||||||
|
|
||||||
|
**Hyprland** (`~/.config/hypr/hyprland.conf`):
|
||||||
|
|
||||||
|
```
|
||||||
|
windowrule = float, ^(k-launcher)$
|
||||||
|
windowrule = center, ^(k-launcher)$
|
||||||
|
bind = SUPER, Space, exec, k-launcher
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sway** (`~/.config/sway/config`):
|
||||||
|
|
||||||
|
```
|
||||||
|
for_window [app_id="k-launcher"] floating enable, move position center
|
||||||
|
bindsym Mod4+space exec k-launcher
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plugins
|
||||||
|
|
||||||
|
Built-in plugins (calc, apps, shell, files) are enabled by default. External plugins communicate via JSON over stdin/stdout — any language, no recompilation:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
# ~/.config/k-launcher/config.toml
|
|
||||||
[[plugins.external]]
|
[[plugins.external]]
|
||||||
name = "my-plugin"
|
name = "my-plugin"
|
||||||
path = "/usr/lib/k-launcher/plugins/my-plugin"
|
path = "/path/to/plugin"
|
||||||
|
timeout_secs = 5
|
||||||
```
|
```
|
||||||
|
|
||||||
See [Plugin Development](docs/plugin-development.md) for the full protocol.
|
See [Plugin Development](docs/plugin-development.md) for the full protocol.
|
||||||
@@ -49,6 +80,11 @@ See [Plugin Development](docs/plugin-development.md) for the full protocol.
|
|||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
- [Installation](docs/install.md)
|
- [Installation](docs/install.md)
|
||||||
- [Usage & Keybinds](docs/usage.md)
|
- [Usage](docs/usage.md)
|
||||||
- [Configuration & Theming](docs/configuration.md)
|
- [Configuration](docs/configuration.md)
|
||||||
- [Plugin Development](docs/plugin-development.md)
|
- [Plugin Development](docs/plugin-development.md)
|
||||||
|
- `man k-launcher`
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
|
|||||||
52
config.example.toml
Normal file
52
config.example.toml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# k-launcher configuration
|
||||||
|
# Copy to ~/.config/k-launcher/config.toml
|
||||||
|
|
||||||
|
[window]
|
||||||
|
width = 600.0
|
||||||
|
height = 400.0
|
||||||
|
decorations = false
|
||||||
|
transparent = true
|
||||||
|
resizable = false
|
||||||
|
|
||||||
|
[appearance]
|
||||||
|
background_rgba = [20.0, 20.0, 30.0, 0.9]
|
||||||
|
border_rgba = [229.0, 125.0, 33.0, 1.0]
|
||||||
|
border_width = 1.0
|
||||||
|
border_radius = 8.0
|
||||||
|
search_font_size = 18.0
|
||||||
|
title_size = 15.0
|
||||||
|
desc_size = 12.0
|
||||||
|
row_radius = 4.0
|
||||||
|
placeholder = "Search apps, type > for commands, = for math"
|
||||||
|
selected_row_rgba = [229.0, 125.0, 33.0, 1.0]
|
||||||
|
unselected_row_rgba = [255.0, 255.0, 255.0, 0.07]
|
||||||
|
description_rgba = [210.0, 215.0, 230.0, 1.0]
|
||||||
|
no_results_rgba = [180.0, 180.0, 200.0, 0.5]
|
||||||
|
error_rgba = [255.0, 80.0, 80.0, 1.0]
|
||||||
|
icon_size = 24.0
|
||||||
|
|
||||||
|
[search]
|
||||||
|
max_results = 8
|
||||||
|
debounce_ms = 50
|
||||||
|
frecency_compact_threshold = 50
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
calc = true
|
||||||
|
cmd = true
|
||||||
|
files = true
|
||||||
|
apps = true
|
||||||
|
|
||||||
|
# External plugins (can have multiple [[plugins.external]] blocks)
|
||||||
|
# [[plugins.external]]
|
||||||
|
# name = "my-plugin"
|
||||||
|
# path = "/path/to/plugin-binary"
|
||||||
|
# args = []
|
||||||
|
# timeout_secs = 5
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
max_log_files = 7
|
||||||
|
|
||||||
|
[terminal]
|
||||||
|
# Override terminal emulator for > commands
|
||||||
|
# Default: auto-detect from $TERM_CMD, $TERMINAL, or PATH
|
||||||
|
# cmd = "kitty -e"
|
||||||
43
contrib/PKGBUILD
Normal file
43
contrib/PKGBUILD
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Maintainer: Gabriel Kaszewski <gabriel@gabrielkaszewski.dev>
|
||||||
|
# AUR package for k-launcher
|
||||||
|
# Copy this file as PKGBUILD to your AUR repo
|
||||||
|
|
||||||
|
pkgname=k-launcher
|
||||||
|
pkgver=0.2.1
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc='Wayland command palette launcher with fuzzy search, frecency, and plugin support'
|
||||||
|
arch=('x86_64')
|
||||||
|
url='https://github.com/GKaszewski/k-launcher'
|
||||||
|
license=('MIT')
|
||||||
|
depends=('gcc-libs')
|
||||||
|
makedepends=('cargo' 'git')
|
||||||
|
optdepends=(
|
||||||
|
'wl-clipboard: clipboard support on Wayland'
|
||||||
|
'xclip: clipboard support on X11'
|
||||||
|
'xdg-utils: open files and URLs'
|
||||||
|
)
|
||||||
|
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
|
||||||
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
prepare() {
|
||||||
|
cd "$pkgname-$pkgver"
|
||||||
|
export RUSTUP_TOOLCHAIN=stable
|
||||||
|
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$pkgname-$pkgver"
|
||||||
|
export RUSTUP_TOOLCHAIN=stable
|
||||||
|
export CARGO_TARGET_DIR=target
|
||||||
|
cargo build --frozen --release -p k-launcher
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$pkgname-$pkgver"
|
||||||
|
install -Dm755 "target/release/k-launcher" "$pkgdir/usr/bin/k-launcher"
|
||||||
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
|
install -Dm644 "man/k-launcher.1" "$pkgdir/usr/share/man/man1/k-launcher.1"
|
||||||
|
gzip -9 "$pkgdir/usr/share/man/man1/k-launcher.1"
|
||||||
|
install -Dm644 config.example.toml "$pkgdir/usr/share/doc/$pkgname/config.example.toml"
|
||||||
|
install -Dm644 docs/plugin-development.md "$pkgdir/usr/share/doc/$pkgname/plugin-development.md"
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "k-launcher-config"
|
name = "k-launcher-config"
|
||||||
version = "0.1.0"
|
version = "0.2.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -9,5 +9,11 @@ path = "src/lib.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
|
k-launcher-domain = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
toml = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
toml = "1.0"
|
||||||
|
tracing = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
toml = "1.0"
|
||||||
|
|||||||
14
crates/k-launcher-config/src/config.rs
Normal file
14
crates/k-launcher-config/src/config.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::types::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Config {
|
||||||
|
pub window: WindowCfg,
|
||||||
|
pub appearance: AppearanceCfg,
|
||||||
|
pub search: SearchCfg,
|
||||||
|
pub plugins: PluginsCfg,
|
||||||
|
pub logging: LoggingCfg,
|
||||||
|
pub terminal: TerminalCfg,
|
||||||
|
}
|
||||||
18
crates/k-launcher-config/src/error.rs
Normal file
18
crates/k-launcher-config/src/error.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
#[error("config directory not found")]
|
||||||
|
NoDirFound,
|
||||||
|
#[error("failed to read config at {path}: {source}")]
|
||||||
|
ReadFailed {
|
||||||
|
path: PathBuf,
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
#[error("failed to parse config at {path}: {source}")]
|
||||||
|
ParseFailed {
|
||||||
|
path: PathBuf,
|
||||||
|
source: toml::de::Error,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,192 +1,8 @@
|
|||||||
use serde::Deserialize;
|
mod config;
|
||||||
|
pub mod error;
|
||||||
|
mod load;
|
||||||
|
mod types;
|
||||||
|
|
||||||
// RGBA: [r, g, b, a] where r/g/b are 0–255 as f32, a is 0.0–1.0
|
pub use config::*;
|
||||||
pub type Rgba = [f32; 4];
|
pub use load::*;
|
||||||
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
25
crates/k-launcher-config/src/load.rs
Normal file
25
crates/k-launcher-config/src/load.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
use k_launcher_domain::constants::{APP_NAME, CONFIG_FILENAME};
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::error::ConfigError;
|
||||||
|
|
||||||
|
pub fn load() -> Config {
|
||||||
|
match try_load() {
|
||||||
|
Ok(cfg) => cfg,
|
||||||
|
Err(ConfigError::NoDirFound | ConfigError::ReadFailed { .. }) => Config::default(),
|
||||||
|
Err(e @ ConfigError::ParseFailed { .. }) => {
|
||||||
|
tracing::warn!("{e}");
|
||||||
|
Config::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_load() -> Result<Config, ConfigError> {
|
||||||
|
let dir = dirs::config_dir().ok_or(ConfigError::NoDirFound)?;
|
||||||
|
let path = dir.join(APP_NAME).join(CONFIG_FILENAME);
|
||||||
|
let content = std::fs::read_to_string(&path).map_err(|e| ConfigError::ReadFailed {
|
||||||
|
path: path.clone(),
|
||||||
|
source: e,
|
||||||
|
})?;
|
||||||
|
toml::from_str(&content).map_err(|e| ConfigError::ParseFailed { path, source: e })
|
||||||
|
}
|
||||||
206
crates/k-launcher-config/src/types.rs
Normal file
206
crates/k-launcher-config/src/types.rs
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct Rgba {
|
||||||
|
red: f32,
|
||||||
|
green: f32,
|
||||||
|
blue: f32,
|
||||||
|
alpha: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> serde::Deserialize<'de> for Rgba {
|
||||||
|
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
let [red, green, blue, alpha] = <[f32; 4]>::deserialize(deserializer)?;
|
||||||
|
Ok(Self::new(red, green, blue, alpha))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rgba {
|
||||||
|
pub fn new(red: f32, green: f32, blue: f32, alpha: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
red: red.clamp(0.0, 255.0),
|
||||||
|
green: green.clamp(0.0, 255.0),
|
||||||
|
blue: blue.clamp(0.0, 255.0),
|
||||||
|
alpha: alpha.clamp(0.0, 1.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn red(&self) -> f32 {
|
||||||
|
self.red
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn green(&self) -> f32 {
|
||||||
|
self.green
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn blue(&self) -> f32 {
|
||||||
|
self.blue
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn alpha(&self) -> f32 {
|
||||||
|
self.alpha
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn red_u8(&self) -> u8 {
|
||||||
|
self.red as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn green_u8(&self) -> u8 {
|
||||||
|
self.green as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn blue_u8(&self) -> u8 {
|
||||||
|
self.blue as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn alpha_byte(&self) -> u8 {
|
||||||
|
(self.alpha * 255.0) as u8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct WindowCfg {
|
||||||
|
pub width: f32,
|
||||||
|
pub height: f32,
|
||||||
|
pub decorations: bool,
|
||||||
|
pub transparent: bool,
|
||||||
|
pub resizable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WindowCfg {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
width: 600.0,
|
||||||
|
height: 400.0,
|
||||||
|
decorations: false,
|
||||||
|
transparent: true,
|
||||||
|
resizable: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct AppearanceCfg {
|
||||||
|
pub background_rgba: Rgba,
|
||||||
|
pub border_rgba: Rgba,
|
||||||
|
pub border_width: f32,
|
||||||
|
pub border_radius: f32,
|
||||||
|
pub search_font_size: f32,
|
||||||
|
pub title_size: f32,
|
||||||
|
pub desc_size: f32,
|
||||||
|
pub row_radius: f32,
|
||||||
|
pub placeholder: String,
|
||||||
|
pub selected_row_rgba: Rgba,
|
||||||
|
pub selected_text_rgba: Rgba,
|
||||||
|
pub selected_description_rgba: Rgba,
|
||||||
|
pub unselected_row_rgba: Rgba,
|
||||||
|
pub text_rgba: Rgba,
|
||||||
|
pub description_rgba: Rgba,
|
||||||
|
pub no_results_rgba: Rgba,
|
||||||
|
pub error_rgba: Rgba,
|
||||||
|
pub icon_size: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppearanceCfg {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
background_rgba: Rgba::new(20.0, 20.0, 30.0, 0.9),
|
||||||
|
border_rgba: Rgba::new(229.0, 125.0, 33.0, 1.0),
|
||||||
|
border_width: 1.0,
|
||||||
|
border_radius: 8.0,
|
||||||
|
search_font_size: 18.0,
|
||||||
|
title_size: 15.0,
|
||||||
|
desc_size: 12.0,
|
||||||
|
row_radius: 4.0,
|
||||||
|
placeholder: "Search apps, type > for commands, = for math".to_string(),
|
||||||
|
selected_row_rgba: Rgba::new(229.0, 125.0, 33.0, 1.0),
|
||||||
|
selected_text_rgba: Rgba::new(255.0, 255.0, 255.0, 1.0),
|
||||||
|
selected_description_rgba: Rgba::new(240.0, 240.0, 240.0, 0.9),
|
||||||
|
unselected_row_rgba: Rgba::new(255.0, 255.0, 255.0, 0.07),
|
||||||
|
text_rgba: Rgba::new(255.0, 255.0, 255.0, 1.0),
|
||||||
|
description_rgba: Rgba::new(210.0, 215.0, 230.0, 1.0),
|
||||||
|
no_results_rgba: Rgba::new(180.0, 180.0, 200.0, 0.5),
|
||||||
|
error_rgba: Rgba::new(255.0, 80.0, 80.0, 1.0),
|
||||||
|
icon_size: 24.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct SearchCfg {
|
||||||
|
pub max_results: usize,
|
||||||
|
pub debounce_ms: u64,
|
||||||
|
pub frecency_compact_threshold: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SearchCfg {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_results: 8,
|
||||||
|
debounce_ms: 50,
|
||||||
|
frecency_compact_threshold: 50,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct LoggingCfg {
|
||||||
|
pub max_log_files: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LoggingCfg {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { max_log_files: 7 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
|
pub struct TerminalCfg {
|
||||||
|
pub cmd: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct ExternalPluginCfg {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub args: Vec<String>,
|
||||||
|
pub timeout_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ExternalPluginCfg {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
name: String::new(),
|
||||||
|
path: String::new(),
|
||||||
|
args: vec![],
|
||||||
|
timeout_secs: 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct PluginsCfg {
|
||||||
|
pub calc: bool,
|
||||||
|
pub cmd: bool,
|
||||||
|
pub files: bool,
|
||||||
|
pub apps: bool,
|
||||||
|
pub external: Vec<ExternalPluginCfg>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PluginsCfg {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
calc: true,
|
||||||
|
cmd: true,
|
||||||
|
files: true,
|
||||||
|
apps: true,
|
||||||
|
external: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
crates/k-launcher-config/tests/config.rs
Normal file
75
crates/k-launcher-config/tests/config.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
use k_launcher_config::Config;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_config_has_sane_values() {
|
||||||
|
let cfg = Config::default();
|
||||||
|
assert_eq!(cfg.search.max_results, 8);
|
||||||
|
assert_eq!(cfg.window.width, 600.0);
|
||||||
|
assert_eq!(cfg.window.height, 400.0);
|
||||||
|
assert!(!cfg.window.decorations);
|
||||||
|
assert!(cfg.window.transparent);
|
||||||
|
assert!(!cfg.window.resizable);
|
||||||
|
assert!(cfg.plugins.calc);
|
||||||
|
assert!(cfg.plugins.apps);
|
||||||
|
assert_eq!(cfg.appearance.search_font_size, 18.0);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.appearance.placeholder,
|
||||||
|
"Search apps, type > for commands, = for math"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_partial_toml_uses_defaults() {
|
||||||
|
let toml_str = "[search]\nmax_results = 5\n";
|
||||||
|
let cfg: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
assert_eq!(cfg.search.max_results, 5);
|
||||||
|
assert_eq!(cfg.window.width, 600.0);
|
||||||
|
assert_eq!(cfg.appearance.search_font_size, 18.0);
|
||||||
|
assert!(cfg.plugins.apps);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_full_toml_roundtrip() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[window]
|
||||||
|
width = 800.0
|
||||||
|
height = 500.0
|
||||||
|
decorations = true
|
||||||
|
transparent = false
|
||||||
|
resizable = true
|
||||||
|
|
||||||
|
[appearance]
|
||||||
|
background_rgba = [10.0, 10.0, 20.0, 0.8]
|
||||||
|
border_rgba = [100.0, 200.0, 255.0, 1.0]
|
||||||
|
border_width = 2.0
|
||||||
|
border_radius = 12.0
|
||||||
|
search_font_size = 20.0
|
||||||
|
title_size = 16.0
|
||||||
|
desc_size = 13.0
|
||||||
|
row_radius = 6.0
|
||||||
|
placeholder = "Type here..."
|
||||||
|
|
||||||
|
[search]
|
||||||
|
max_results = 12
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
calc = false
|
||||||
|
cmd = true
|
||||||
|
files = false
|
||||||
|
apps = true
|
||||||
|
"#;
|
||||||
|
let cfg: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
assert_eq!(cfg.window.width, 800.0);
|
||||||
|
assert_eq!(cfg.window.height, 500.0);
|
||||||
|
assert!(cfg.window.decorations);
|
||||||
|
assert!(!cfg.window.transparent);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.appearance.background_rgba,
|
||||||
|
k_launcher_config::Rgba::new(10.0, 10.0, 20.0, 0.8)
|
||||||
|
);
|
||||||
|
assert_eq!(cfg.appearance.search_font_size, 20.0);
|
||||||
|
assert_eq!(cfg.appearance.placeholder, "Type here...");
|
||||||
|
assert_eq!(cfg.search.max_results, 12);
|
||||||
|
assert!(!cfg.plugins.calc);
|
||||||
|
assert!(!cfg.plugins.files);
|
||||||
|
}
|
||||||
8
crates/k-launcher-domain/Cargo.toml
Normal file
8
crates/k-launcher-domain/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "k-launcher-domain"
|
||||||
|
version = "0.2.1"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
7
crates/k-launcher-domain/src/action.rs
Normal file
7
crates/k-launcher-domain/src/action.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#[derive(Clone)]
|
||||||
|
pub enum LaunchAction {
|
||||||
|
SpawnProcess(String),
|
||||||
|
SpawnInTerminal(String),
|
||||||
|
OpenPath(String),
|
||||||
|
CopyToClipboard(String),
|
||||||
|
}
|
||||||
6
crates/k-launcher-domain/src/constants.rs
Normal file
6
crates/k-launcher-domain/src/constants.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pub const APP_NAME: &str = "k-launcher";
|
||||||
|
pub const APP_TITLE: &str = "K-Launcher";
|
||||||
|
pub const CONFIG_FILENAME: &str = "config.toml";
|
||||||
|
pub const FRECENCY_SNAPSHOT_FILENAME: &str = "frecency.json";
|
||||||
|
pub const LOG_DIR_NAME: &str = "logs";
|
||||||
|
pub const LOG_FILE_PREFIX: &str = "k-launcher.log";
|
||||||
10
crates/k-launcher-domain/src/lib.rs
Normal file
10
crates/k-launcher-domain/src/lib.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
mod action;
|
||||||
|
pub mod constants;
|
||||||
|
mod newtypes;
|
||||||
|
pub mod ports;
|
||||||
|
mod search_result;
|
||||||
|
|
||||||
|
pub use action::*;
|
||||||
|
pub use newtypes::*;
|
||||||
|
pub use ports::*;
|
||||||
|
pub use search_result::*;
|
||||||
46
crates/k-launcher-domain/src/newtypes.rs
Normal file
46
crates/k-launcher-domain/src/newtypes.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct ResultId(String);
|
||||||
|
|
||||||
|
impl ResultId {
|
||||||
|
pub fn new(id: impl Into<String>) -> Self {
|
||||||
|
let id = id.into();
|
||||||
|
debug_assert!(!id.is_empty(), "ResultId must not be empty");
|
||||||
|
Self(id)
|
||||||
|
}
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct ResultTitle(String);
|
||||||
|
|
||||||
|
impl ResultTitle {
|
||||||
|
pub fn new(title: impl Into<String>) -> Self {
|
||||||
|
let title = title.into();
|
||||||
|
debug_assert!(!title.is_empty(), "ResultTitle must not be empty");
|
||||||
|
Self(title)
|
||||||
|
}
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
|
||||||
|
)]
|
||||||
|
pub struct Score(u32);
|
||||||
|
|
||||||
|
impl Score {
|
||||||
|
pub const MAX: Self = Self(u32::MAX);
|
||||||
|
|
||||||
|
pub fn new(value: u32) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
pub fn value(self) -> u32 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
pub fn saturating_add(self, other: u32) -> Self {
|
||||||
|
Self(self.0.saturating_add(other))
|
||||||
|
}
|
||||||
|
}
|
||||||
15
crates/k-launcher-domain/src/ports.rs
Normal file
15
crates/k-launcher-domain/src/ports.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::{LaunchAction, ResultId, SearchResult};
|
||||||
|
|
||||||
|
pub trait AppLauncher: Send + Sync {
|
||||||
|
fn execute(&self, action: &LaunchAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Plugin: Send + Sync {
|
||||||
|
fn name(&self) -> &str;
|
||||||
|
async fn search(&self, query: &str) -> Vec<SearchResult>;
|
||||||
|
fn on_selected(&self, _id: &ResultId) {}
|
||||||
|
fn shutdown(&self) {}
|
||||||
|
}
|
||||||
25
crates/k-launcher-domain/src/search_result.rs
Normal file
25
crates/k-launcher-domain/src/search_result.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::action::LaunchAction;
|
||||||
|
use crate::newtypes::{ResultId, ResultTitle, Score};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SearchResult {
|
||||||
|
pub id: ResultId,
|
||||||
|
pub title: ResultTitle,
|
||||||
|
pub description: Option<Arc<str>>,
|
||||||
|
pub icon: Option<Arc<str>>,
|
||||||
|
pub score: Score,
|
||||||
|
pub action: LaunchAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for SearchResult {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("SearchResult")
|
||||||
|
.field("id", &self.id)
|
||||||
|
.field("title", &self.title)
|
||||||
|
.field("icon", &self.icon)
|
||||||
|
.field("score", &self.score)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
16
crates/k-launcher-domain/tests/newtypes.rs
Normal file
16
crates/k-launcher-domain/tests/newtypes.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
use k_launcher_domain::{ResultId, ResultTitle, Score};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn newtype_result_id() {
|
||||||
|
assert_eq!(ResultId::new("x").as_str(), "x");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn newtype_score() {
|
||||||
|
assert_eq!(Score::new(42).value(), 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn newtype_title() {
|
||||||
|
assert_eq!(ResultTitle::new("hello").as_str(), "hello");
|
||||||
|
}
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "k-launcher-kernel"
|
name = "k-launcher-kernel"
|
||||||
version = "0.1.0"
|
version = "0.2.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = { workspace = true }
|
futures = "0.3"
|
||||||
futures = { workspace = true }
|
k-launcher-domain = { 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 }
|
||||||
|
|||||||
56
crates/k-launcher-kernel/src/kernel.rs
Normal file
56
crates/k-launcher-kernel/src/kernel.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use futures::future::join_all;
|
||||||
|
|
||||||
|
use k_launcher_domain::{Plugin, ResultId, SearchResult};
|
||||||
|
|
||||||
|
pub struct Kernel {
|
||||||
|
plugins: Vec<Arc<dyn Plugin>>,
|
||||||
|
max_results: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Kernel {
|
||||||
|
pub fn new(plugins: Vec<Arc<dyn Plugin>>, max_results: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
plugins,
|
||||||
|
max_results,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_selected(&self, id: &ResultId) {
|
||||||
|
for plugin in &self.plugins {
|
||||||
|
plugin.on_selected(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shutdown(&self) {
|
||||||
|
for plugin in &self.plugins {
|
||||||
|
plugin.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||||
|
use futures::FutureExt;
|
||||||
|
use std::panic::AssertUnwindSafe;
|
||||||
|
|
||||||
|
let futures = self
|
||||||
|
.plugins
|
||||||
|
.iter()
|
||||||
|
.map(|p| AssertUnwindSafe(p.search(query)).catch_unwind());
|
||||||
|
let outcomes = join_all(futures).await;
|
||||||
|
let mut flat: Vec<SearchResult> = outcomes
|
||||||
|
.into_iter()
|
||||||
|
.zip(self.plugins.iter())
|
||||||
|
.flat_map(|(outcome, plugin)| match outcome {
|
||||||
|
Ok(results) => results,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::error!(plugin = plugin.name(), "plugin panicked during search");
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
flat.sort_by_key(|r| std::cmp::Reverse(r.score));
|
||||||
|
flat.truncate(self.max_results);
|
||||||
|
flat
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,216 +1,3 @@
|
|||||||
use std::sync::Arc;
|
mod kernel;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
pub use kernel::*;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
42
crates/k-launcher-kernel/tests/integration.rs
Normal file
42
crates/k-launcher-kernel/tests/integration.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use k_launcher_kernel::Kernel;
|
||||||
|
use plugin_calc::CalcPlugin;
|
||||||
|
use plugin_cmd::CmdPlugin;
|
||||||
|
|
||||||
|
fn make_kernel() -> Kernel {
|
||||||
|
Kernel::new(
|
||||||
|
vec![Arc::new(CalcPlugin::new()), Arc::new(CmdPlugin::new())],
|
||||||
|
8,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_pipeline_calc() {
|
||||||
|
let kernel = make_kernel();
|
||||||
|
let results = kernel.search("2+2").await;
|
||||||
|
assert!(!results.is_empty());
|
||||||
|
assert_eq!(results[0].title.as_str(), "= 4");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_pipeline_cmd() {
|
||||||
|
let kernel = make_kernel();
|
||||||
|
let results = kernel.search("> echo hello").await;
|
||||||
|
assert!(!results.is_empty());
|
||||||
|
assert_eq!(results[0].title.as_str(), "Run: echo hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_pipeline_no_match() {
|
||||||
|
let kernel = make_kernel();
|
||||||
|
let results = kernel.search("xyzzy").await;
|
||||||
|
assert!(results.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn full_pipeline_empty_query() {
|
||||||
|
let kernel = make_kernel();
|
||||||
|
let results = kernel.search("").await;
|
||||||
|
assert!(results.is_empty());
|
||||||
|
}
|
||||||
107
crates/k-launcher-kernel/tests/kernel.rs
Normal file
107
crates/k-launcher-kernel/tests/kernel.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use k_launcher_domain::Plugin;
|
||||||
|
use k_launcher_domain::{LaunchAction, ResultId, ResultTitle, Score, SearchResult};
|
||||||
|
use k_launcher_kernel::Kernel;
|
||||||
|
|
||||||
|
struct MockResult {
|
||||||
|
title: &'static str,
|
||||||
|
score: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockPlugin {
|
||||||
|
results: Vec<MockResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockPlugin {
|
||||||
|
fn returns(results: Vec<(&'static str, u32)>) -> Self {
|
||||||
|
Self {
|
||||||
|
results: results
|
||||||
|
.into_iter()
|
||||||
|
.map(|(title, score)| MockResult { title, score })
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Plugin for MockPlugin {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"mock"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search(&self, _query: &str) -> Vec<SearchResult> {
|
||||||
|
self.results
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, r)| SearchResult {
|
||||||
|
id: ResultId::new(format!("id-{i}")),
|
||||||
|
title: ResultTitle::new(r.title),
|
||||||
|
description: None,
|
||||||
|
icon: None,
|
||||||
|
score: Score::new(r.score),
|
||||||
|
action: LaunchAction::SpawnProcess("mock".to_string()),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_kernel_returns_empty() {
|
||||||
|
let k = Kernel::new(vec![], 8);
|
||||||
|
assert!(k.search("x").await.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn kernel_sorts_by_score_desc() {
|
||||||
|
let plugin = Arc::new(MockPlugin::returns(vec![
|
||||||
|
("lower", 5),
|
||||||
|
("higher", 10),
|
||||||
|
("middle", 7),
|
||||||
|
]));
|
||||||
|
let k = Kernel::new(vec![plugin], 8);
|
||||||
|
let results = k.search("q").await;
|
||||||
|
assert_eq!(results[0].score.value(), 10);
|
||||||
|
assert_eq!(results[1].score.value(), 7);
|
||||||
|
assert_eq!(results[2].score.value(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PanicPlugin;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Plugin for PanicPlugin {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"panic-plugin"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search(&self, _query: &str) -> Vec<SearchResult> {
|
||||||
|
panic!("test panic");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn kernel_continues_after_plugin_panic() {
|
||||||
|
let panic_plugin = Arc::new(PanicPlugin);
|
||||||
|
let normal_plugin = Arc::new(MockPlugin::returns(vec![("survivor", 5)]));
|
||||||
|
let k = Kernel::new(vec![panic_plugin, normal_plugin], 8);
|
||||||
|
let results = k.search("q").await;
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].title.as_str(), "survivor");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn kernel_truncates_at_max_results() {
|
||||||
|
let plugin = Arc::new(MockPlugin::returns(vec![
|
||||||
|
("a", 10),
|
||||||
|
("b", 9),
|
||||||
|
("c", 8),
|
||||||
|
("d", 7),
|
||||||
|
("e", 6),
|
||||||
|
]));
|
||||||
|
let k = Kernel::new(vec![plugin], 3);
|
||||||
|
let results = k.search("q").await;
|
||||||
|
assert_eq!(results.len(), 3);
|
||||||
|
assert_eq!(results[0].score.value(), 10);
|
||||||
|
assert_eq!(results[2].score.value(), 8);
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "k-launcher-os-bridge"
|
name = "k-launcher-os-bridge"
|
||||||
version = "0.1.0"
|
version = "0.2.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
k-launcher-config = { path = "../k-launcher-config" }
|
k-launcher-domain = { workspace = true }
|
||||||
k-launcher-kernel = { path = "../k-launcher-kernel" }
|
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
tracing = { workspace = true }
|
||||||
|
|||||||
50
crates/k-launcher-os-bridge/src/launcher.rs
Normal file
50
crates/k-launcher-os-bridge/src/launcher.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
use k_launcher_domain::{AppLauncher, LaunchAction};
|
||||||
|
|
||||||
|
use crate::shell::shell_split;
|
||||||
|
use crate::spawn::{copy_to_clipboard, open_path, spawn_detached};
|
||||||
|
use crate::terminal::resolve_terminal;
|
||||||
|
|
||||||
|
pub struct UnixAppLauncher {
|
||||||
|
terminal_cmd: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UnixAppLauncher {
|
||||||
|
pub fn new(terminal_cmd: Option<String>) -> Self {
|
||||||
|
Self { terminal_cmd }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppLauncher for UnixAppLauncher {
|
||||||
|
fn execute(&self, action: &LaunchAction) {
|
||||||
|
match action {
|
||||||
|
LaunchAction::SpawnProcess(cmd) => spawn_command(cmd),
|
||||||
|
LaunchAction::SpawnInTerminal(cmd) => {
|
||||||
|
spawn_in_terminal(cmd, self.terminal_cmd.as_deref())
|
||||||
|
}
|
||||||
|
LaunchAction::OpenPath(path) => open_path(path),
|
||||||
|
LaunchAction::CopyToClipboard(val) => copy_to_clipboard(val),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_command(cmd: &str) {
|
||||||
|
let parts = shell_split(cmd);
|
||||||
|
if let Some((bin, args)) = parts.split_first() {
|
||||||
|
spawn_detached(bin, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_in_terminal(cmd: &str, configured: Option<&str>) {
|
||||||
|
let Some(terminal) = resolve_terminal(configured) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut args = terminal.exec_flag;
|
||||||
|
const SHELL: &str = "sh";
|
||||||
|
const SHELL_CMD_FLAG: &str = "-c";
|
||||||
|
args.extend([
|
||||||
|
SHELL.to_string(),
|
||||||
|
SHELL_CMD_FLAG.to_string(),
|
||||||
|
cmd.to_string(),
|
||||||
|
]);
|
||||||
|
spawn_detached(&terminal.bin, &args);
|
||||||
|
}
|
||||||
@@ -1,23 +1,7 @@
|
|||||||
mod unix_launcher;
|
mod launcher;
|
||||||
|
mod shell;
|
||||||
|
mod spawn;
|
||||||
|
mod terminal;
|
||||||
|
|
||||||
pub use unix_launcher::UnixAppLauncher;
|
pub use 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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
22
crates/k-launcher-os-bridge/src/shell.rs
Normal file
22
crates/k-launcher-os-bridge/src/shell.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
pub fn shell_split(cmd: &str) -> Vec<String> {
|
||||||
|
let mut tokens = Vec::new();
|
||||||
|
let mut current = String::new();
|
||||||
|
let mut in_quotes = false;
|
||||||
|
|
||||||
|
for ch in cmd.chars() {
|
||||||
|
match ch {
|
||||||
|
'"' => in_quotes = !in_quotes,
|
||||||
|
' ' | '\t' if !in_quotes => {
|
||||||
|
if !current.is_empty() {
|
||||||
|
tokens.push(current.clone());
|
||||||
|
current.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => current.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !current.is_empty() {
|
||||||
|
tokens.push(current);
|
||||||
|
}
|
||||||
|
tokens
|
||||||
|
}
|
||||||
49
crates/k-launcher-os-bridge/src/spawn.rs
Normal file
49
crates/k-launcher-os-bridge/src/spawn.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
use std::io::Write;
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
const XDG_OPEN: &str = "xdg-open";
|
||||||
|
const WL_COPY: &str = "wl-copy";
|
||||||
|
const XCLIP: &str = "xclip";
|
||||||
|
|
||||||
|
pub(crate) fn spawn_detached(bin: &str, args: &[String]) {
|
||||||
|
// SAFETY: setsid() is async-signal-safe; called in forked child before exec
|
||||||
|
if let Err(e) = unsafe {
|
||||||
|
Command::new(bin)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.pre_exec(|| {
|
||||||
|
libc::setsid();
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.spawn()
|
||||||
|
} {
|
||||||
|
tracing::warn!("failed to spawn detached process '{bin}': {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn open_path(path: &str) {
|
||||||
|
if let Err(e) = Command::new(XDG_OPEN).arg(path).spawn() {
|
||||||
|
tracing::warn!("failed to open path '{path}': {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn copy_to_clipboard(val: &str) {
|
||||||
|
if Command::new(WL_COPY).arg(val).spawn().is_err() {
|
||||||
|
copy_to_clipboard_xclip(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_to_clipboard_xclip(val: &str) {
|
||||||
|
if let Ok(mut child) = Command::new(XCLIP)
|
||||||
|
.args(["-selection", "clipboard"])
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
&& let Some(stdin) = child.stdin.as_mut()
|
||||||
|
&& let Err(e) = stdin.write_all(val.as_bytes())
|
||||||
|
{
|
||||||
|
tracing::warn!("failed to write to xclip stdin: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
90
crates/k-launcher-os-bridge/src/terminal.rs
Normal file
90
crates/k-launcher-os-bridge/src/terminal.rs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
pub(crate) struct TerminalCommand {
|
||||||
|
pub bin: String,
|
||||||
|
pub exec_flag: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct KnownTerminal {
|
||||||
|
bin: &'static str,
|
||||||
|
exec_flag: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
const KNOWN_TERMINALS: &[KnownTerminal] = &[
|
||||||
|
KnownTerminal {
|
||||||
|
bin: "ghostty",
|
||||||
|
exec_flag: "-e",
|
||||||
|
},
|
||||||
|
KnownTerminal {
|
||||||
|
bin: "foot",
|
||||||
|
exec_flag: "-e",
|
||||||
|
},
|
||||||
|
KnownTerminal {
|
||||||
|
bin: "kitty",
|
||||||
|
exec_flag: "-e",
|
||||||
|
},
|
||||||
|
KnownTerminal {
|
||||||
|
bin: "alacritty",
|
||||||
|
exec_flag: "-e",
|
||||||
|
},
|
||||||
|
KnownTerminal {
|
||||||
|
bin: "wezterm",
|
||||||
|
exec_flag: "start",
|
||||||
|
},
|
||||||
|
KnownTerminal {
|
||||||
|
bin: "konsole",
|
||||||
|
exec_flag: "-e",
|
||||||
|
},
|
||||||
|
KnownTerminal {
|
||||||
|
bin: "xterm",
|
||||||
|
exec_flag: "-e",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
fn find_in_path(bin: &str) -> bool {
|
||||||
|
std::env::var_os("PATH")
|
||||||
|
.iter()
|
||||||
|
.flat_map(|p| std::env::split_paths(p))
|
||||||
|
.any(|dir| dir.join(bin).is_file())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_term_cmd(s: &str) -> TerminalCommand {
|
||||||
|
let mut parts = s.split_whitespace();
|
||||||
|
let bin = parts.next().unwrap_or("").to_string();
|
||||||
|
let exec_flag = parts.map(str::to_string).collect();
|
||||||
|
TerminalCommand { bin, exec_flag }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_terminal(configured: Option<&str>) -> Option<TerminalCommand> {
|
||||||
|
if let Some(cmd) = configured.filter(|s| !s.is_empty()) {
|
||||||
|
let term = parse_term_cmd(cmd);
|
||||||
|
if !term.bin.is_empty() {
|
||||||
|
return Some(term);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(val) = std::env::var("TERM_CMD") {
|
||||||
|
let val = val.trim().to_string();
|
||||||
|
if !val.is_empty() {
|
||||||
|
let term = parse_term_cmd(&val);
|
||||||
|
if !term.bin.is_empty() {
|
||||||
|
return Some(term);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(val) = std::env::var("TERMINAL") {
|
||||||
|
let bin = val.trim().to_string();
|
||||||
|
if !bin.is_empty() {
|
||||||
|
return Some(TerminalCommand {
|
||||||
|
bin,
|
||||||
|
exec_flag: vec!["-e".to_string()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for terminal in KNOWN_TERMINALS {
|
||||||
|
if find_in_path(terminal.bin) {
|
||||||
|
return Some(TerminalCommand {
|
||||||
|
bin: terminal.bin.to_string(),
|
||||||
|
exec_flag: vec![terminal.exec_flag.to_string()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
use std::process::{Command, Stdio};
|
|
||||||
use std::os::unix::process::CommandExt;
|
|
||||||
|
|
||||||
use k_launcher_kernel::{AppLauncher, LaunchAction};
|
|
||||||
|
|
||||||
fn parse_term_cmd(s: &str) -> (String, Vec<String>) {
|
|
||||||
let mut parts = s.split_whitespace();
|
|
||||||
let bin = parts.next().unwrap_or("").to_string();
|
|
||||||
let args = parts.map(str::to_string).collect();
|
|
||||||
(bin, args)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn which(bin: &str) -> bool {
|
|
||||||
Command::new("which")
|
|
||||||
.arg(bin)
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.status()
|
|
||||||
.map(|s| s.success())
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_terminal() -> Option<(String, Vec<String>)> {
|
|
||||||
if let Ok(val) = std::env::var("TERM_CMD") {
|
|
||||||
let val = val.trim().to_string();
|
|
||||||
if !val.is_empty() {
|
|
||||||
let (bin, args) = parse_term_cmd(&val);
|
|
||||||
if !bin.is_empty() {
|
|
||||||
return Some((bin, args));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Ok(val) = std::env::var("TERMINAL") {
|
|
||||||
let bin = val.trim().to_string();
|
|
||||||
if !bin.is_empty() {
|
|
||||||
return Some((bin, vec!["-e".to_string()]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (bin, flag) in &[
|
|
||||||
("foot", "-e"),
|
|
||||||
("kitty", "-e"),
|
|
||||||
("alacritty", "-e"),
|
|
||||||
("wezterm", "start"),
|
|
||||||
("konsole", "-e"),
|
|
||||||
("xterm", "-e"),
|
|
||||||
] {
|
|
||||||
if which(bin) {
|
|
||||||
return Some((bin.to_string(), vec![flag.to_string()]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct UnixAppLauncher;
|
|
||||||
|
|
||||||
impl UnixAppLauncher {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for UnixAppLauncher {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppLauncher for UnixAppLauncher {
|
|
||||||
fn execute(&self, action: &LaunchAction) {
|
|
||||||
match action {
|
|
||||||
LaunchAction::SpawnProcess(cmd) => {
|
|
||||||
let parts: Vec<&str> = cmd.split_whitespace().collect();
|
|
||||||
if let Some((bin, args)) = parts.split_first() {
|
|
||||||
let _ = unsafe {
|
|
||||||
Command::new(bin)
|
|
||||||
.args(args)
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.pre_exec(|| { libc::setsid(); Ok(()) })
|
|
||||||
.spawn()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchAction::SpawnInTerminal(cmd) => {
|
|
||||||
let Some((term_bin, term_args)) = resolve_terminal() else { return };
|
|
||||||
let _ = unsafe {
|
|
||||||
Command::new(&term_bin)
|
|
||||||
.args(&term_args)
|
|
||||||
.arg("sh").arg("-c").arg(cmd)
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.pre_exec(|| { libc::setsid(); Ok(()) })
|
|
||||||
.spawn()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
LaunchAction::OpenPath(path) => {
|
|
||||||
let _ = Command::new("xdg-open").arg(path).spawn();
|
|
||||||
}
|
|
||||||
LaunchAction::CopyToClipboard(val) => {
|
|
||||||
if Command::new("wl-copy").arg(val).spawn().is_err() {
|
|
||||||
use std::io::Write;
|
|
||||||
if let Ok(mut child) = Command::new("xclip")
|
|
||||||
.args(["-selection", "clipboard"])
|
|
||||||
.stdin(Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
&& let Some(stdin) = child.stdin.as_mut()
|
|
||||||
{
|
|
||||||
let _ = stdin.write_all(val.as_bytes());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchAction::Custom(f) => f(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
37
crates/k-launcher-os-bridge/tests/shell_split.rs
Normal file
37
crates/k-launcher-os-bridge/tests/shell_split.rs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
use k_launcher_os_bridge::shell_split;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_simple() {
|
||||||
|
assert_eq!(shell_split("firefox"), vec!["firefox"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_with_args() {
|
||||||
|
assert_eq!(
|
||||||
|
shell_split("firefox --new-window"),
|
||||||
|
vec!["firefox", "--new-window"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_quoted_path() {
|
||||||
|
assert_eq!(shell_split(r#""My App" --flag"#), vec!["My App", "--flag"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_quoted_with_spaces() {
|
||||||
|
assert_eq!(
|
||||||
|
shell_split(r#"env "FOO BAR" baz"#),
|
||||||
|
vec!["env", "FOO BAR", "baz"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_empty() {
|
||||||
|
assert!(shell_split("").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_extra_whitespace() {
|
||||||
|
assert_eq!(shell_split(" a b "), vec!["a", "b"]);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "k-launcher-plugin-host"
|
name = "k-launcher-plugin-host"
|
||||||
version = "0.1.0"
|
version = "0.2.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -9,8 +9,11 @@ path = "src/lib.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
k-launcher-kernel = { path = "../k-launcher-kernel" }
|
k-launcher-domain = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["process", "io-util", "sync"] }
|
thiserror = { workspace = true }
|
||||||
|
tokio = { workspace = true, features = ["process", "io-util", "sync", "time"] }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
|||||||
13
crates/k-launcher-plugin-host/src/error.rs
Normal file
13
crates/k-launcher-plugin-host/src/error.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum PluginError {
|
||||||
|
#[error("plugin spawn failed: {0}")]
|
||||||
|
SpawnFailed(#[from] std::io::Error),
|
||||||
|
#[error("search timed out after {timeout_secs}s")]
|
||||||
|
Timeout { timeout_secs: u64 },
|
||||||
|
#[error("protocol error: {0}")]
|
||||||
|
Protocol(String),
|
||||||
|
#[error("plugin process error: {0}")]
|
||||||
|
ProcessError(String),
|
||||||
|
}
|
||||||
@@ -1,201 +1,6 @@
|
|||||||
use async_trait::async_trait;
|
pub mod error;
|
||||||
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
mod plugin;
|
||||||
use serde::{Deserialize, Serialize};
|
mod protocol;
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
|
|
||||||
use tokio::process::{ChildStdin, ChildStdout, Command};
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
|
|
||||||
// --- Protocol types ---
|
pub use plugin::*;
|
||||||
|
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>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
137
crates/k-launcher-plugin-host/src/plugin.rs
Normal file
137
crates/k-launcher-plugin-host/src/plugin.rs
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use k_launcher_domain::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
|
||||||
|
use tokio::process::{ChildStdin, ChildStdout, Command};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::error::PluginError;
|
||||||
|
use crate::protocol::{ExternalAction, ExternalResult, Query};
|
||||||
|
|
||||||
|
struct ProcessIo {
|
||||||
|
stdin: BufWriter<ChildStdin>,
|
||||||
|
stdout: BufReader<ChildStdout>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_search(io: &mut ProcessIo, query: &str) -> Result<Vec<ExternalResult>, PluginError> {
|
||||||
|
let line = serde_json::to_string(&Query {
|
||||||
|
query: query.to_string(),
|
||||||
|
})
|
||||||
|
.map_err(|e| PluginError::Protocol(e.to_string()))?;
|
||||||
|
io.stdin
|
||||||
|
.write_all(line.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|e| PluginError::ProcessError(e.to_string()))?;
|
||||||
|
io.stdin
|
||||||
|
.write_all(b"\n")
|
||||||
|
.await
|
||||||
|
.map_err(|e| PluginError::ProcessError(e.to_string()))?;
|
||||||
|
io.stdin
|
||||||
|
.flush()
|
||||||
|
.await
|
||||||
|
.map_err(|e| PluginError::ProcessError(e.to_string()))?;
|
||||||
|
let mut response = String::new();
|
||||||
|
io.stdout
|
||||||
|
.read_line(&mut response)
|
||||||
|
.await
|
||||||
|
.map_err(|e| PluginError::ProcessError(e.to_string()))?;
|
||||||
|
serde_json::from_str(&response).map_err(|e| PluginError::Protocol(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ExternalPlugin {
|
||||||
|
name: String,
|
||||||
|
path: String,
|
||||||
|
args: Vec<String>,
|
||||||
|
timeout_secs: u64,
|
||||||
|
inner: Mutex<Option<ProcessIo>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExternalPlugin {
|
||||||
|
pub fn new(
|
||||||
|
name: impl Into<String>,
|
||||||
|
path: impl Into<String>,
|
||||||
|
args: Vec<String>,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
path: path.into(),
|
||||||
|
args,
|
||||||
|
timeout_secs,
|
||||||
|
inner: Mutex::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn(&self) -> std::io::Result<ProcessIo> {
|
||||||
|
let mut child = Command::new(&self.path)
|
||||||
|
.args(&self.args)
|
||||||
|
.stdin(std::process::Stdio::piped())
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.spawn()?;
|
||||||
|
let stdin = BufWriter::new(child.stdin.take().unwrap());
|
||||||
|
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||||
|
Ok(ProcessIo { stdin, stdout })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Plugin for ExternalPlugin {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||||
|
let mut guard = self.inner.lock().await;
|
||||||
|
|
||||||
|
if guard.is_none() {
|
||||||
|
match self.spawn().await {
|
||||||
|
Ok(io) => *guard = Some(io),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("failed to spawn plugin {}: {e}", self.name);
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = match guard.as_mut() {
|
||||||
|
Some(io) => tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(self.timeout_secs),
|
||||||
|
do_search(io, query),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or(Err(PluginError::Timeout {
|
||||||
|
timeout_secs: self.timeout_secs,
|
||||||
|
})),
|
||||||
|
None => unreachable!(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(results) => results
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| SearchResult {
|
||||||
|
id: ResultId::new(r.id),
|
||||||
|
title: ResultTitle::new(r.title),
|
||||||
|
description: r.description.map(Arc::from),
|
||||||
|
icon: r.icon.map(Arc::from),
|
||||||
|
score: Score::new(r.score),
|
||||||
|
action: match r.action {
|
||||||
|
ExternalAction::SpawnProcess { cmd } => LaunchAction::SpawnProcess(cmd),
|
||||||
|
ExternalAction::SpawnInTerminal { cmd } => {
|
||||||
|
LaunchAction::SpawnInTerminal(cmd)
|
||||||
|
}
|
||||||
|
ExternalAction::CopyToClipboard { text } => {
|
||||||
|
LaunchAction::CopyToClipboard(text)
|
||||||
|
}
|
||||||
|
ExternalAction::OpenPath { path } => LaunchAction::OpenPath(path),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("plugin {} error: {e}", self.name);
|
||||||
|
*guard = None;
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
crates/k-launcher-plugin-host/src/protocol.rs
Normal file
27
crates/k-launcher-plugin-host/src/protocol.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Query {
|
||||||
|
pub query: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ExternalResult {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub score: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub icon: Option<String>,
|
||||||
|
pub action: ExternalAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum ExternalAction {
|
||||||
|
SpawnProcess { cmd: String },
|
||||||
|
SpawnInTerminal { cmd: String },
|
||||||
|
CopyToClipboard { text: String },
|
||||||
|
OpenPath { path: String },
|
||||||
|
}
|
||||||
70
crates/k-launcher-plugin-host/tests/protocol.rs
Normal file
70
crates/k-launcher-plugin-host/tests/protocol.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
use k_launcher_plugin_host::{ExternalAction, ExternalPlugin, ExternalResult, Query};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn query_serializes_correctly() {
|
||||||
|
let q = Query {
|
||||||
|
query: "firefox".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(serde_json::to_string(&q).unwrap(), r#"{"query":"firefox"}"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn result_parses_spawn_action() {
|
||||||
|
let json = r#"[{"id":"1","title":"Firefox","score":80,"action":{"type":"SpawnProcess","cmd":"firefox"}}]"#;
|
||||||
|
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].id, "1");
|
||||||
|
assert_eq!(results[0].title, "Firefox");
|
||||||
|
assert_eq!(results[0].score, 80);
|
||||||
|
assert!(matches!(&results[0].action, ExternalAction::SpawnProcess { cmd } if cmd == "firefox"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn result_parses_copy_action() {
|
||||||
|
let json =
|
||||||
|
r#"[{"id":"c","title":"= 4","score":90,"action":{"type":"CopyToClipboard","text":"4"}}]"#;
|
||||||
|
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(matches!(&results[0].action, ExternalAction::CopyToClipboard { text } if text == "4"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn result_parses_open_path_action() {
|
||||||
|
let json = r#"[{"id":"f","title":"/home/user","score":50,"action":{"type":"OpenPath","path":"/home/user"}}]"#;
|
||||||
|
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(&results[0].action, ExternalAction::OpenPath { path } if path == "/home/user")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn result_parses_spawn_in_terminal_action() {
|
||||||
|
let json = r#"[{"id":"t","title":"htop","score":70,"action":{"type":"SpawnInTerminal","cmd":"htop"}}]"#;
|
||||||
|
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(matches!(&results[0].action, ExternalAction::SpawnInTerminal { cmd } if cmd == "htop"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn result_parses_optional_fields() {
|
||||||
|
let json = r#"[{"id":"x","title":"X","score":10,"description":"desc","icon":"/icon.png","action":{"type":"SpawnProcess","cmd":"x"}}]"#;
|
||||||
|
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(results[0].description.as_deref(), Some("desc"));
|
||||||
|
assert_eq!(results[0].icon.as_deref(), Some("/icon.png"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn result_parses_missing_optional_fields() {
|
||||||
|
let json = r#"[{"id":"x","title":"X","score":10,"action":{"type":"SpawnProcess","cmd":"x"}}]"#;
|
||||||
|
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(results[0].description.is_none());
|
||||||
|
assert!(results[0].icon.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_json_is_err() {
|
||||||
|
assert!(serde_json::from_str::<Vec<ExternalResult>>("not json").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn _assert_send_sync() {
|
||||||
|
fn check<T: Send + Sync>() {}
|
||||||
|
check::<ExternalPlugin>();
|
||||||
|
}
|
||||||
9
crates/k-launcher-ui-core/Cargo.toml
Normal file
9
crates/k-launcher-ui-core/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[package]
|
||||||
|
name = "k-launcher-ui-core"
|
||||||
|
version = "0.2.1"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
k-launcher-config = { workspace = true }
|
||||||
|
k-launcher-domain = { workspace = true }
|
||||||
|
k-launcher-kernel = { workspace = true }
|
||||||
171
crates/k-launcher-ui-core/src/lib.rs
Normal file
171
crates/k-launcher-ui-core/src/lib.rs
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use k_launcher_config::AppearanceCfg;
|
||||||
|
use k_launcher_domain::AppLauncher;
|
||||||
|
use k_launcher_domain::{LaunchAction, SearchResult};
|
||||||
|
use k_launcher_kernel::Kernel;
|
||||||
|
|
||||||
|
pub struct LauncherState {
|
||||||
|
query: String,
|
||||||
|
results: Vec<SearchResult>,
|
||||||
|
selected: usize,
|
||||||
|
engine: Option<Arc<Kernel>>,
|
||||||
|
launcher: Arc<dyn AppLauncher>,
|
||||||
|
cfg: AppearanceCfg,
|
||||||
|
debounce_ms: u64,
|
||||||
|
search_epoch: u64,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Action {
|
||||||
|
QueryChanged(String),
|
||||||
|
MoveDown,
|
||||||
|
MoveUp,
|
||||||
|
LaunchSelected,
|
||||||
|
Exit,
|
||||||
|
EngineReady(Arc<Kernel>),
|
||||||
|
EngineInitFailed(String),
|
||||||
|
ResultsReady {
|
||||||
|
epoch: u64,
|
||||||
|
results: Vec<SearchResult>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Effect {
|
||||||
|
SearchAfterDebounce {
|
||||||
|
query: String,
|
||||||
|
debounce_ms: u64,
|
||||||
|
epoch: u64,
|
||||||
|
},
|
||||||
|
LaunchAndExit(LaunchAction),
|
||||||
|
Exit,
|
||||||
|
TriggerSearch(String),
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LauncherState {
|
||||||
|
pub fn new(launcher: Arc<dyn AppLauncher>, cfg: AppearanceCfg, debounce_ms: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
query: String::new(),
|
||||||
|
results: vec![],
|
||||||
|
selected: 0,
|
||||||
|
engine: None,
|
||||||
|
launcher,
|
||||||
|
cfg,
|
||||||
|
debounce_ms,
|
||||||
|
search_epoch: 0,
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle(&mut self, action: Action) -> Effect {
|
||||||
|
match action {
|
||||||
|
Action::QueryChanged(q) => {
|
||||||
|
self.query = q;
|
||||||
|
self.selected = 0;
|
||||||
|
self.error = None;
|
||||||
|
|
||||||
|
let Some(_engine) = &self.engine else {
|
||||||
|
return Effect::None;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.search_epoch += 1;
|
||||||
|
Effect::SearchAfterDebounce {
|
||||||
|
query: self.query.clone(),
|
||||||
|
debounce_ms: self.debounce_ms,
|
||||||
|
epoch: self.search_epoch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Action::MoveDown => {
|
||||||
|
let len = self.results.len();
|
||||||
|
if len > 0 {
|
||||||
|
self.selected = (self.selected + 1).min(len - 1);
|
||||||
|
}
|
||||||
|
Effect::None
|
||||||
|
}
|
||||||
|
|
||||||
|
Action::MoveUp => {
|
||||||
|
self.selected = self.selected.saturating_sub(1);
|
||||||
|
Effect::None
|
||||||
|
}
|
||||||
|
|
||||||
|
Action::LaunchSelected => {
|
||||||
|
if let Some(result) = self.results.get(self.selected) {
|
||||||
|
if let Some(engine) = &self.engine {
|
||||||
|
engine.on_selected(&result.id);
|
||||||
|
}
|
||||||
|
let action = result.action.clone();
|
||||||
|
self.shutdown_engine();
|
||||||
|
return Effect::LaunchAndExit(action);
|
||||||
|
}
|
||||||
|
self.shutdown_engine();
|
||||||
|
Effect::Exit
|
||||||
|
}
|
||||||
|
|
||||||
|
Action::Exit => {
|
||||||
|
self.shutdown_engine();
|
||||||
|
Effect::Exit
|
||||||
|
}
|
||||||
|
|
||||||
|
Action::EngineReady(kernel) => {
|
||||||
|
self.engine = Some(kernel);
|
||||||
|
Effect::TriggerSearch(self.query.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
Action::EngineInitFailed(msg) => {
|
||||||
|
self.error = Some(msg);
|
||||||
|
Effect::None
|
||||||
|
}
|
||||||
|
|
||||||
|
Action::ResultsReady { epoch, results } => {
|
||||||
|
if epoch == self.search_epoch {
|
||||||
|
self.results = results;
|
||||||
|
}
|
||||||
|
Effect::None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn query(&self) -> &str {
|
||||||
|
&self.query
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn results(&self) -> &[SearchResult] {
|
||||||
|
&self.results
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected(&self) -> usize {
|
||||||
|
self.selected
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cfg(&self) -> &AppearanceCfg {
|
||||||
|
&self.cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error(&self) -> Option<&str> {
|
||||||
|
self.error.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_loading(&self) -> bool {
|
||||||
|
self.engine.is_none() && self.error.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn engine(&self) -> Option<&Arc<Kernel>> {
|
||||||
|
self.engine.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn launcher(&self) -> &Arc<dyn AppLauncher> {
|
||||||
|
&self.launcher
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn search_epoch(&self) -> u64 {
|
||||||
|
self.search_epoch
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shutdown_engine(&self) {
|
||||||
|
if let Some(engine) = &self.engine {
|
||||||
|
engine.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
158
crates/k-launcher-ui-core/tests/state.rs
Normal file
158
crates/k-launcher-ui-core/tests/state.rs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use k_launcher_config::AppearanceCfg;
|
||||||
|
use k_launcher_domain::AppLauncher;
|
||||||
|
use k_launcher_domain::*;
|
||||||
|
use k_launcher_kernel::Kernel;
|
||||||
|
use k_launcher_ui_core::{Action, Effect, LauncherState};
|
||||||
|
|
||||||
|
struct NoopLauncher;
|
||||||
|
|
||||||
|
impl AppLauncher for NoopLauncher {
|
||||||
|
fn execute(&self, _action: &LaunchAction) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_state() -> LauncherState {
|
||||||
|
LauncherState::new(Arc::new(NoopLauncher), AppearanceCfg::default(), 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_result(id: &str) -> SearchResult {
|
||||||
|
SearchResult {
|
||||||
|
id: ResultId::new(id),
|
||||||
|
title: ResultTitle::new(id),
|
||||||
|
description: None,
|
||||||
|
icon: None,
|
||||||
|
score: Score::new(100),
|
||||||
|
action: LaunchAction::CopyToClipboard(id.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_state_with_engine() -> LauncherState {
|
||||||
|
let kernel = Arc::new(Kernel::new(vec![], 10));
|
||||||
|
let mut state = make_state();
|
||||||
|
let _ = state.handle(Action::EngineReady(kernel));
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_down_clamps_to_last_result() {
|
||||||
|
let mut state = make_state();
|
||||||
|
state.handle(Action::ResultsReady {
|
||||||
|
epoch: 0,
|
||||||
|
results: vec![make_result("a"), make_result("b"), make_result("c")],
|
||||||
|
});
|
||||||
|
|
||||||
|
state.handle(Action::MoveDown);
|
||||||
|
state.handle(Action::MoveDown);
|
||||||
|
state.handle(Action::MoveDown);
|
||||||
|
state.handle(Action::MoveDown);
|
||||||
|
|
||||||
|
assert_eq!(state.selected(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_up_does_not_go_below_zero() {
|
||||||
|
let mut state = make_state();
|
||||||
|
state.handle(Action::ResultsReady {
|
||||||
|
epoch: 0,
|
||||||
|
results: vec![make_result("a"), make_result("b")],
|
||||||
|
});
|
||||||
|
|
||||||
|
state.handle(Action::MoveUp);
|
||||||
|
state.handle(Action::MoveUp);
|
||||||
|
|
||||||
|
assert_eq!(state.selected(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn query_changed_resets_selected() {
|
||||||
|
let mut state = make_state_with_engine();
|
||||||
|
state.handle(Action::ResultsReady {
|
||||||
|
epoch: state.search_epoch(),
|
||||||
|
results: vec![make_result("a"), make_result("b"), make_result("c")],
|
||||||
|
});
|
||||||
|
state.handle(Action::MoveDown);
|
||||||
|
state.handle(Action::MoveDown);
|
||||||
|
assert_eq!(state.selected(), 2);
|
||||||
|
|
||||||
|
state.handle(Action::QueryChanged("new".to_string()));
|
||||||
|
assert_eq!(state.selected(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn engine_ready_returns_trigger_search() {
|
||||||
|
let mut state = make_state();
|
||||||
|
let kernel = Arc::new(Kernel::new(vec![], 10));
|
||||||
|
let effect = state.handle(Action::EngineReady(kernel));
|
||||||
|
|
||||||
|
assert!(matches!(effect, Effect::TriggerSearch(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn launch_selected_with_no_results_returns_exit() {
|
||||||
|
let mut state = make_state_with_engine();
|
||||||
|
let effect = state.handle(Action::LaunchSelected);
|
||||||
|
|
||||||
|
assert!(matches!(effect, Effect::Exit));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn results_ready_with_wrong_epoch_is_ignored() {
|
||||||
|
let mut state = make_state_with_engine();
|
||||||
|
let current_epoch = state.search_epoch();
|
||||||
|
|
||||||
|
state.handle(Action::ResultsReady {
|
||||||
|
epoch: current_epoch + 999,
|
||||||
|
results: vec![make_result("stale")],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(state.results().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn query_changed_without_engine_returns_none() {
|
||||||
|
let mut state = make_state();
|
||||||
|
let effect = state.handle(Action::QueryChanged("hello".to_string()));
|
||||||
|
|
||||||
|
assert!(matches!(effect, Effect::None));
|
||||||
|
assert_eq!(state.query(), "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn query_changed_with_engine_returns_search_after_debounce() {
|
||||||
|
let mut state = make_state_with_engine();
|
||||||
|
let effect = state.handle(Action::QueryChanged("test".to_string()));
|
||||||
|
|
||||||
|
match effect {
|
||||||
|
Effect::SearchAfterDebounce {
|
||||||
|
query,
|
||||||
|
debounce_ms,
|
||||||
|
epoch,
|
||||||
|
} => {
|
||||||
|
assert_eq!(query, "test");
|
||||||
|
assert_eq!(debounce_ms, 50);
|
||||||
|
assert_eq!(epoch, state.search_epoch());
|
||||||
|
}
|
||||||
|
_ => panic!("expected SearchAfterDebounce"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn launch_selected_with_results_returns_launch_and_exit() {
|
||||||
|
let mut state = make_state_with_engine();
|
||||||
|
state.handle(Action::ResultsReady {
|
||||||
|
epoch: state.search_epoch(),
|
||||||
|
results: vec![make_result("app1")],
|
||||||
|
});
|
||||||
|
|
||||||
|
let effect = state.handle(Action::LaunchSelected);
|
||||||
|
assert!(matches!(effect, Effect::LaunchAndExit(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn engine_init_failed_sets_error() {
|
||||||
|
let mut state = make_state();
|
||||||
|
state.handle(Action::EngineInitFailed("boom".to_string()));
|
||||||
|
|
||||||
|
assert_eq!(state.error(), Some("boom"));
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "k-launcher-ui-egui"
|
name = "k-launcher-ui-egui"
|
||||||
version = "0.1.0"
|
version = "0.2.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -10,7 +10,9 @@ 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 = { path = "../k-launcher-config" }
|
k-launcher-config = { workspace = true }
|
||||||
k-launcher-kernel = { path = "../k-launcher-kernel" }
|
k-launcher-domain = { workspace = true }
|
||||||
k-launcher-os-bridge = { path = "../k-launcher-os-bridge" }
|
k-launcher-kernel = { workspace = true }
|
||||||
|
k-launcher-ui-core = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
|||||||
@@ -1,180 +1,177 @@
|
|||||||
use std::sync::{Arc, mpsc};
|
use std::sync::{Arc, mpsc};
|
||||||
|
|
||||||
use egui::{Color32, Key, ViewportCommand};
|
use egui::ViewportCommand;
|
||||||
use k_launcher_kernel::{AppLauncher, SearchEngine, SearchResult};
|
use k_launcher_config::AppearanceCfg;
|
||||||
use k_launcher_os_bridge::WindowConfig;
|
use k_launcher_domain::AppLauncher;
|
||||||
|
use k_launcher_domain::SearchResult;
|
||||||
|
use k_launcher_kernel::Kernel;
|
||||||
|
use k_launcher_ui_core::{Action, Effect, LauncherState};
|
||||||
|
|
||||||
const BG: Color32 = Color32::from_rgba_premultiplied(20, 20, 30, 230);
|
use crate::input::{InputAction, process_input};
|
||||||
const BORDER_COLOR: Color32 = Color32::from_rgb(229, 125, 33);
|
use crate::render;
|
||||||
const SELECTED_BG: Color32 = Color32::from_rgba_premultiplied(0, 100, 140, 180);
|
use crate::style;
|
||||||
const DIM_TEXT: Color32 = Color32::from_rgb(180, 185, 200);
|
|
||||||
|
|
||||||
pub struct KLauncherApp {
|
pub struct KLauncherApp {
|
||||||
engine: Arc<dyn SearchEngine>,
|
pub(crate) inner: LauncherState,
|
||||||
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>>,
|
||||||
result_rx: mpsc::Receiver<Vec<SearchResult>>,
|
pub(crate) result_rx: mpsc::Receiver<Vec<SearchResult>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KLauncherApp {
|
impl KLauncherApp {
|
||||||
fn new(
|
fn new(
|
||||||
engine: Arc<dyn SearchEngine>,
|
engine: Arc<Kernel>,
|
||||||
launcher: Arc<dyn AppLauncher>,
|
launcher: Arc<dyn AppLauncher>,
|
||||||
rt: tokio::runtime::Handle,
|
rt: tokio::runtime::Handle,
|
||||||
|
cfg: AppearanceCfg,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let (result_tx, result_rx) = mpsc::sync_channel(4);
|
const RESULT_CHANNEL_CAPACITY: usize = 4;
|
||||||
Self {
|
let (result_tx, result_rx) = mpsc::sync_channel(RESULT_CHANNEL_CAPACITY);
|
||||||
engine,
|
let mut inner = LauncherState::new(launcher, cfg, 0);
|
||||||
launcher,
|
|
||||||
query: String::new(),
|
let effect = inner.handle(Action::EngineReady(engine));
|
||||||
results: vec![],
|
|
||||||
selected: 0,
|
let app = Self {
|
||||||
|
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 engine = self.engine.clone();
|
let Some(engine) = self.inner.engine().cloned() else {
|
||||||
|
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;
|
||||||
let _ = tx.send(results);
|
if let Err(e) = tx.send(results) {
|
||||||
|
tracing::warn!("search result channel closed: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_search_results(&mut self) {
|
||||||
|
if let Ok(results) = self.result_rx.try_recv() {
|
||||||
|
self.inner.handle(Action::ResultsReady {
|
||||||
|
epoch: self.inner.search_epoch(),
|
||||||
|
results,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_effect(&self, effect: Effect) {
|
||||||
|
match effect {
|
||||||
|
Effect::TriggerSearch(q) => self.trigger_search(q),
|
||||||
|
Effect::SearchAfterDebounce { query, .. } => self.trigger_search(query),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_action(&mut self, action: Action, ctx: &egui::Context) {
|
||||||
|
let effect = self.inner.handle(action);
|
||||||
|
match effect {
|
||||||
|
Effect::LaunchAndExit(action) => {
|
||||||
|
self.inner.launcher().execute(&action);
|
||||||
|
ctx.send_viewport_cmd(ViewportCommand::Close);
|
||||||
|
}
|
||||||
|
Effect::Exit => {
|
||||||
|
ctx.send_viewport_cmd(ViewportCommand::Close);
|
||||||
|
}
|
||||||
|
other => self.execute_effect(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_panel(&mut self, ctx: &egui::Context) {
|
||||||
|
let cfg = self.inner.cfg().clone();
|
||||||
|
egui::CentralPanel::default()
|
||||||
|
.frame(style::outer_frame(&cfg))
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
let query = self.inner.query().to_string();
|
||||||
|
let mut query_buf = query;
|
||||||
|
let response = render::render_search_bar(ui, &mut query_buf, &cfg);
|
||||||
|
|
||||||
|
if response.changed() {
|
||||||
|
self.handle_action(Action::QueryChanged(query_buf), ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
response.request_focus();
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
if self.inner.is_loading() {
|
||||||
|
render::render_loading_state(ui, &cfg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.inner.results().is_empty() && !self.inner.query().is_empty() {
|
||||||
|
render::render_empty_state(ui, &cfg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
render::render_result_list(ui, self.inner.results(), self.inner.selected(), &cfg);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl eframe::App for KLauncherApp {
|
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) {
|
||||||
if let Ok(results) = self.result_rx.try_recv() {
|
self.poll_search_results();
|
||||||
self.results = results;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut close = false;
|
match process_input(ctx) {
|
||||||
let mut launch_selected = false;
|
InputAction::Close => {
|
||||||
|
self.handle_action(Action::Exit, ctx);
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
InputAction::LaunchSelected => {
|
||||||
if launch_selected {
|
self.handle_action(Action::LaunchSelected, ctx);
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
InputAction::MoveDown => {
|
||||||
let frame = egui::Frame::new()
|
self.inner.handle(Action::MoveDown);
|
||||||
.fill(BG)
|
}
|
||||||
.stroke(egui::Stroke::new(1.0, BORDER_COLOR))
|
InputAction::MoveUp => {
|
||||||
.inner_margin(egui::Margin::same(12))
|
self.inner.handle(Action::MoveUp);
|
||||||
.corner_radius(egui::CornerRadius::same(8));
|
}
|
||||||
|
InputAction::None => {}
|
||||||
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();
|
self.render_panel(ctx);
|
||||||
|
|
||||||
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<dyn SearchEngine>,
|
engine: Arc<Kernel>,
|
||||||
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([wc.width, wc.height])
|
.with_inner_size([window_cfg.width, window_cfg.height])
|
||||||
.with_decorations(wc.decorations)
|
.with_decorations(window_cfg.decorations)
|
||||||
.with_transparent(wc.transparent)
|
.with_transparent(window_cfg.transparent)
|
||||||
.with_resizable(wc.resizable)
|
.with_resizable(window_cfg.resizable)
|
||||||
.with_always_on_top(),
|
.with_always_on_top(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
eframe::run_native(
|
eframe::run_native(
|
||||||
"K-Launcher",
|
k_launcher_domain::constants::APP_TITLE,
|
||||||
options,
|
options,
|
||||||
Box::new(move |_cc| Ok(Box::new(KLauncherApp::new(engine, launcher, handle)))),
|
Box::new(move |_cc| {
|
||||||
|
Ok(Box::new(KLauncherApp::new(
|
||||||
|
engine,
|
||||||
|
launcher,
|
||||||
|
handle,
|
||||||
|
appearance_cfg,
|
||||||
|
)))
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
25
crates/k-launcher-ui-egui/src/input.rs
Normal file
25
crates/k-launcher-ui-egui/src/input.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
use egui::Key;
|
||||||
|
|
||||||
|
pub enum InputAction {
|
||||||
|
Close,
|
||||||
|
LaunchSelected,
|
||||||
|
MoveDown,
|
||||||
|
MoveUp,
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process_input(ctx: &egui::Context) -> InputAction {
|
||||||
|
ctx.input(|i| {
|
||||||
|
if i.key_pressed(Key::Escape) {
|
||||||
|
InputAction::Close
|
||||||
|
} else if i.key_pressed(Key::Enter) {
|
||||||
|
InputAction::LaunchSelected
|
||||||
|
} else if i.key_pressed(Key::ArrowDown) {
|
||||||
|
InputAction::MoveDown
|
||||||
|
} else if i.key_pressed(Key::ArrowUp) {
|
||||||
|
InputAction::MoveUp
|
||||||
|
} else {
|
||||||
|
InputAction::None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
mod app;
|
mod app;
|
||||||
|
mod input;
|
||||||
|
mod render;
|
||||||
|
mod style;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use k_launcher_kernel::{AppLauncher, SearchEngine};
|
use k_launcher_config::AppearanceCfg;
|
||||||
|
use k_launcher_domain::AppLauncher;
|
||||||
|
use k_launcher_kernel::Kernel;
|
||||||
|
|
||||||
pub fn run(
|
pub fn run(
|
||||||
engine: Arc<dyn SearchEngine>,
|
engine: Arc<Kernel>,
|
||||||
launcher: Arc<dyn AppLauncher>,
|
launcher: Arc<dyn AppLauncher>,
|
||||||
) -> Result<(), eframe::Error> {
|
window_cfg: &k_launcher_config::WindowCfg,
|
||||||
app::run(engine, launcher)
|
appearance_cfg: AppearanceCfg,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
app::run(engine, launcher, window_cfg, appearance_cfg).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
69
crates/k-launcher-ui-egui/src/render.rs
Normal file
69
crates/k-launcher-ui-egui/src/render.rs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
use egui::Ui;
|
||||||
|
use k_launcher_config::AppearanceCfg;
|
||||||
|
use k_launcher_domain::SearchResult;
|
||||||
|
|
||||||
|
use crate::style::{self, ROW_SPACING, SEARCH_BAR_HEIGHT, to_color32};
|
||||||
|
|
||||||
|
pub fn render_search_bar(ui: &mut Ui, query: &mut String, cfg: &AppearanceCfg) -> egui::Response {
|
||||||
|
ui.add_sized(
|
||||||
|
[ui.available_width(), SEARCH_BAR_HEIGHT],
|
||||||
|
egui::TextEdit::singleline(query)
|
||||||
|
.hint_text(&cfg.placeholder)
|
||||||
|
.font(egui::TextStyle::Heading),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_loading_state(ui: &mut Ui, cfg: &AppearanceCfg) {
|
||||||
|
ui.add_space(20.0);
|
||||||
|
ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| {
|
||||||
|
ui.colored_label(to_color32(&cfg.no_results_rgba), "Loading...");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_empty_state(ui: &mut Ui, cfg: &AppearanceCfg) {
|
||||||
|
ui.add_space(20.0);
|
||||||
|
ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| {
|
||||||
|
ui.colored_label(to_color32(&cfg.no_results_rgba), "No results");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_result_list(
|
||||||
|
ui: &mut Ui,
|
||||||
|
results: &[SearchResult],
|
||||||
|
selected: usize,
|
||||||
|
cfg: &AppearanceCfg,
|
||||||
|
) {
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
ui.set_width(ui.available_width());
|
||||||
|
for (i, result) in results.iter().enumerate() {
|
||||||
|
render_result_row(ui, result, i == selected, cfg);
|
||||||
|
ui.add_space(ROW_SPACING);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_result_row(ui: &mut Ui, result: &SearchResult, is_selected: bool, cfg: &AppearanceCfg) {
|
||||||
|
let title_color = if is_selected {
|
||||||
|
to_color32(&cfg.selected_text_rgba)
|
||||||
|
} else {
|
||||||
|
to_color32(&cfg.text_rgba)
|
||||||
|
};
|
||||||
|
let desc_color = if is_selected {
|
||||||
|
to_color32(&cfg.selected_description_rgba)
|
||||||
|
} else {
|
||||||
|
to_color32(&cfg.description_rgba)
|
||||||
|
};
|
||||||
|
|
||||||
|
style::result_row_frame(is_selected, cfg).show(ui, |ui| {
|
||||||
|
ui.set_width(ui.available_width());
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.colored_label(title_color, result.title.as_str());
|
||||||
|
if let Some(desc) = &result.description {
|
||||||
|
ui.colored_label(desc_color, desc.as_ref());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
39
crates/k-launcher-ui-egui/src/style.rs
Normal file
39
crates/k-launcher-ui-egui/src/style.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
use egui::{Color32, CornerRadius, Frame, Margin, Stroke};
|
||||||
|
use k_launcher_config::AppearanceCfg;
|
||||||
|
|
||||||
|
pub const SEARCH_BAR_HEIGHT: f32 = 36.0;
|
||||||
|
pub const CONTENT_MARGIN: i8 = 12;
|
||||||
|
pub const ROW_SPACING: f32 = 2.0;
|
||||||
|
|
||||||
|
const ROW_PADDING_VERTICAL: i8 = 6;
|
||||||
|
const ROW_PADDING_HORIZONTAL: i8 = 8;
|
||||||
|
|
||||||
|
pub(crate) fn to_color32(c: &k_launcher_config::Rgba) -> Color32 {
|
||||||
|
Color32::from_rgba_unmultiplied(c.red_u8(), c.green_u8(), c.blue_u8(), c.alpha_byte())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn outer_frame(cfg: &AppearanceCfg) -> Frame {
|
||||||
|
Frame::new()
|
||||||
|
.fill(to_color32(&cfg.background_rgba))
|
||||||
|
.stroke(Stroke::new(cfg.border_width, to_color32(&cfg.border_rgba)))
|
||||||
|
.inner_margin(Margin::same(CONTENT_MARGIN))
|
||||||
|
.corner_radius(CornerRadius::same(cfg.border_radius as u8))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn result_row_frame(is_selected: bool, cfg: &AppearanceCfg) -> Frame {
|
||||||
|
let bg = if is_selected {
|
||||||
|
to_color32(&cfg.selected_row_rgba)
|
||||||
|
} else {
|
||||||
|
to_color32(&cfg.unselected_row_rgba)
|
||||||
|
};
|
||||||
|
|
||||||
|
Frame::new()
|
||||||
|
.fill(bg)
|
||||||
|
.inner_margin(Margin {
|
||||||
|
left: ROW_PADDING_HORIZONTAL,
|
||||||
|
right: ROW_PADDING_HORIZONTAL,
|
||||||
|
top: ROW_PADDING_VERTICAL,
|
||||||
|
bottom: ROW_PADDING_VERTICAL,
|
||||||
|
})
|
||||||
|
.corner_radius(CornerRadius::same(cfg.row_radius as u8))
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "k-launcher-ui"
|
name = "k-launcher-ui"
|
||||||
version = "0.1.0"
|
version = "0.2.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -8,8 +8,10 @@ name = "k_launcher_ui"
|
|||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
iced = { workspace = true }
|
iced = { version = "0.14", default-features = false, features = ["image", "svg", "tokio", "tiny-skia", "wayland", "x11", "crisp", "web-colors", "thread-pool"] }
|
||||||
k-launcher-config = { path = "../k-launcher-config" }
|
k-launcher-config = { workspace = true }
|
||||||
k-launcher-kernel = { path = "../k-launcher-kernel" }
|
k-launcher-domain = { workspace = true }
|
||||||
k-launcher-os-bridge = { path = "../k-launcher-os-bridge" }
|
k-launcher-kernel = { workspace = true }
|
||||||
|
k-launcher-os-bridge = { workspace = true }
|
||||||
|
k-launcher-ui-core = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -1,214 +1,39 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use iced::{
|
use iced::{Size, Subscription, Task, event, keyboard::Event as KeyEvent, window};
|
||||||
Border, Color, Element, Length, Size, Subscription, Task,
|
|
||||||
event,
|
|
||||||
keyboard::{Event as KeyEvent, Key, key::Named},
|
|
||||||
widget::{column, container, image, row, scrollable, svg, text, text_input, Space},
|
|
||||||
window,
|
|
||||||
};
|
|
||||||
|
|
||||||
use k_launcher_config::AppearanceCfg;
|
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::LauncherState;
|
||||||
|
|
||||||
static INPUT_ID: std::sync::LazyLock<iced::widget::Id> =
|
pub(crate) static INPUT_ID: std::sync::LazyLock<iced::widget::Id> =
|
||||||
std::sync::LazyLock::new(|| iced::widget::Id::new("search"));
|
std::sync::LazyLock::new(|| iced::widget::Id::new("search"));
|
||||||
|
|
||||||
fn rgba(c: &[f32; 4]) -> Color {
|
#[derive(Clone)]
|
||||||
Color::from_rgba8(c[0] as u8, c[1] as u8, c[2] as u8, c[3])
|
pub(crate) struct EngineHandle(pub(crate) Arc<Kernel>);
|
||||||
|
|
||||||
|
impl std::fmt::Debug for EngineHandle {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str("EngineHandle")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct KLauncherApp {
|
pub(crate) struct KLauncherApp {
|
||||||
engine: Arc<dyn SearchEngine>,
|
pub(crate) inner: LauncherState,
|
||||||
launcher: Arc<dyn AppLauncher>,
|
|
||||||
query: String,
|
|
||||||
results: Arc<Vec<SearchResult>>,
|
|
||||||
selected: usize,
|
|
||||||
cfg: AppearanceCfg,
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Message {
|
pub(crate) enum Message {
|
||||||
QueryChanged(String),
|
QueryChanged(String),
|
||||||
ResultsReady(Arc<Vec<SearchResult>>),
|
ResultsReady {
|
||||||
|
epoch: u64,
|
||||||
|
results: Arc<Vec<SearchResult>>,
|
||||||
|
},
|
||||||
KeyPressed(KeyEvent),
|
KeyPressed(KeyEvent),
|
||||||
}
|
EngineReady(EngineHandle),
|
||||||
|
EngineInitFailed(String),
|
||||||
fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
|
|
||||||
match message {
|
|
||||||
Message::QueryChanged(q) => {
|
|
||||||
state.query = q.clone();
|
|
||||||
state.selected = 0;
|
|
||||||
let engine = state.engine.clone();
|
|
||||||
Task::perform(
|
|
||||||
async move { engine.search(&q).await },
|
|
||||||
|results| Message::ResultsReady(Arc::new(results)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Message::ResultsReady(results) => {
|
|
||||||
state.results = results;
|
|
||||||
Task::none()
|
|
||||||
}
|
|
||||||
Message::KeyPressed(event) => {
|
|
||||||
let key = match event {
|
|
||||||
KeyEvent::KeyPressed { key, .. } => key,
|
|
||||||
_ => return Task::none(),
|
|
||||||
};
|
|
||||||
let Key::Named(named) = key else {
|
|
||||||
return Task::none();
|
|
||||||
};
|
|
||||||
let len = state.results.len();
|
|
||||||
match named {
|
|
||||||
Named::Escape => std::process::exit(0),
|
|
||||||
Named::ArrowDown => {
|
|
||||||
if len > 0 {
|
|
||||||
state.selected = (state.selected + 1).min(len - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Named::ArrowUp => {
|
|
||||||
if state.selected > 0 {
|
|
||||||
state.selected -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Named::Enter => {
|
|
||||||
if let Some(result) = state.results.get(state.selected) {
|
|
||||||
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 content = column![search_bar, results_list]
|
|
||||||
.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> {
|
||||||
@@ -219,29 +44,42 @@ fn subscription(_state: &KLauncherApp) -> Subscription<Message> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(
|
pub fn run(
|
||||||
engine: Arc<dyn SearchEngine>,
|
engine_factory: Arc<dyn Fn() -> Arc<Kernel> + Send + Sync>,
|
||||||
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 app = KLauncherApp::new(engine.clone(), launcher.clone(), appearance_cfg.clone());
|
let inner = LauncherState::new(launcher.clone(), appearance_cfg.clone(), debounce_ms);
|
||||||
|
let app = KLauncherApp { inner };
|
||||||
let focus = iced::widget::operation::focus(INPUT_ID.clone());
|
let focus = iced::widget::operation::focus(INPUT_ID.clone());
|
||||||
(app, focus)
|
let ef = engine_factory.clone();
|
||||||
|
let init = Task::perform(
|
||||||
|
async move {
|
||||||
|
tokio::task::spawn_blocking(move || ef())
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Engine init failed: {e}"))
|
||||||
},
|
},
|
||||||
update,
|
|result| match result {
|
||||||
view,
|
Ok(e) => Message::EngineReady(EngineHandle(e)),
|
||||||
|
Err(msg) => Message::EngineInitFailed(msg),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
(app, Task::batch([focus, init]))
|
||||||
|
},
|
||||||
|
crate::update::update,
|
||||||
|
crate::view::view,
|
||||||
)
|
)
|
||||||
.title("K-Launcher")
|
.title(k_launcher_domain::constants::APP_TITLE)
|
||||||
.subscription(subscription)
|
.subscription(subscription)
|
||||||
.window(window::Settings {
|
.window(window::Settings {
|
||||||
size: Size::new(wc.width, wc.height),
|
size: Size::new(window_cfg.width, window_cfg.height),
|
||||||
position: window::Position::Centered,
|
position: window::Position::Centered,
|
||||||
decorations: wc.decorations,
|
decorations: window_cfg.decorations,
|
||||||
transparent: wc.transparent,
|
transparent: window_cfg.transparent,
|
||||||
resizable: wc.resizable,
|
resizable: window_cfg.resizable,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.run()
|
.run()
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
mod app;
|
mod app;
|
||||||
|
mod style;
|
||||||
|
mod update;
|
||||||
|
mod view;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use k_launcher_config::{AppearanceCfg, WindowCfg};
|
use k_launcher_config::{AppearanceCfg, SearchCfg, WindowCfg};
|
||||||
use k_launcher_kernel::{AppLauncher, SearchEngine};
|
use k_launcher_domain::AppLauncher;
|
||||||
|
use k_launcher_kernel::Kernel;
|
||||||
|
|
||||||
pub fn run(
|
pub fn run(
|
||||||
engine: Arc<dyn SearchEngine>,
|
engine_factory: Arc<dyn Fn() -> Arc<Kernel> + Send + Sync>,
|
||||||
launcher: Arc<dyn AppLauncher>,
|
launcher: Arc<dyn AppLauncher>,
|
||||||
window_cfg: &WindowCfg,
|
window_cfg: &WindowCfg,
|
||||||
appearance_cfg: AppearanceCfg,
|
appearance_cfg: AppearanceCfg,
|
||||||
) -> iced::Result {
|
search_cfg: &SearchCfg,
|
||||||
app::run(engine, launcher, window_cfg, appearance_cfg)
|
) -> Result<(), String> {
|
||||||
|
app::run(
|
||||||
|
engine_factory,
|
||||||
|
launcher,
|
||||||
|
window_cfg,
|
||||||
|
appearance_cfg,
|
||||||
|
search_cfg.debounce_ms,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
60
crates/k-launcher-ui/src/style.rs
Normal file
60
crates/k-launcher-ui/src/style.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
use iced::{Border, Color, widget::container, widget::text_input};
|
||||||
|
|
||||||
|
// ---- layout constants ----
|
||||||
|
pub(crate) const ROW_PADDING: [u16; 2] = [6, 12];
|
||||||
|
pub(crate) const ROW_SPACING: f32 = 2.0;
|
||||||
|
pub(crate) const CONTENT_PADDING: u16 = 12;
|
||||||
|
pub(crate) const CONTENT_SPACING: f32 = 8.0;
|
||||||
|
pub(crate) const EMPTY_STATE_PADDING: [u16; 2] = [20, 0];
|
||||||
|
pub(crate) const ERROR_FONT_SIZE: f32 = 12.0;
|
||||||
|
pub(crate) const ERROR_PADDING: [u16; 2] = [4, 12];
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
pub(crate) fn rgba(c: &k_launcher_config::Rgba) -> Color {
|
||||||
|
Color::from_rgba8(c.red_u8(), c.green_u8(), c.blue_u8(), c.alpha())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn search_input_style(
|
||||||
|
theme: &iced::Theme,
|
||||||
|
_status: text_input::Status,
|
||||||
|
) -> text_input::Style {
|
||||||
|
let mut s = text_input::default(theme, text_input::Status::Active);
|
||||||
|
s.border = Border {
|
||||||
|
color: Color::TRANSPARENT,
|
||||||
|
width: 0.0,
|
||||||
|
radius: 0.0.into(),
|
||||||
|
};
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn result_row_style(
|
||||||
|
bg_color: Color,
|
||||||
|
row_radius: f32,
|
||||||
|
) -> impl Fn(&iced::Theme) -> container::Style {
|
||||||
|
move |_theme| container::Style {
|
||||||
|
background: Some(iced::Background::Color(bg_color)),
|
||||||
|
border: Border {
|
||||||
|
color: Color::TRANSPARENT,
|
||||||
|
width: 0.0,
|
||||||
|
radius: row_radius.into(),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn outer_container_style(
|
||||||
|
bg: Color,
|
||||||
|
border_color: Color,
|
||||||
|
width: f32,
|
||||||
|
radius: f32,
|
||||||
|
) -> impl Fn(&iced::Theme) -> container::Style {
|
||||||
|
move |_theme| container::Style {
|
||||||
|
background: Some(iced::Background::Color(bg)),
|
||||||
|
border: Border {
|
||||||
|
color: border_color,
|
||||||
|
width,
|
||||||
|
radius: radius.into(),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
77
crates/k-launcher-ui/src/update.rs
Normal file
77
crates/k-launcher-ui/src/update.rs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use iced::{
|
||||||
|
Task,
|
||||||
|
keyboard::{Event as KeyEvent, Key, key::Named},
|
||||||
|
};
|
||||||
|
|
||||||
|
use k_launcher_ui_core::{Action, Effect};
|
||||||
|
|
||||||
|
use crate::app::{KLauncherApp, Message};
|
||||||
|
|
||||||
|
pub(crate) fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
|
||||||
|
let action = match message {
|
||||||
|
Message::QueryChanged(q) => Action::QueryChanged(q),
|
||||||
|
Message::ResultsReady { epoch, results } => {
|
||||||
|
let results = Arc::try_unwrap(results).unwrap_or_else(|arc| (*arc).clone());
|
||||||
|
Action::ResultsReady { epoch, results }
|
||||||
|
}
|
||||||
|
Message::EngineInitFailed(msg) => Action::EngineInitFailed(msg),
|
||||||
|
Message::EngineReady(handle) => Action::EngineReady(handle.0),
|
||||||
|
Message::KeyPressed(event) => match map_key_event(event) {
|
||||||
|
Some(a) => a,
|
||||||
|
None => return Task::none(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let effect = state.inner.handle(action);
|
||||||
|
execute_effect(state, effect)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_key_event(event: KeyEvent) -> Option<Action> {
|
||||||
|
let key = match event {
|
||||||
|
KeyEvent::KeyPressed { key, .. } => key,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let Key::Named(named) = key else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
match named {
|
||||||
|
Named::Escape => Some(Action::Exit),
|
||||||
|
Named::ArrowDown => Some(Action::MoveDown),
|
||||||
|
Named::ArrowUp => Some(Action::MoveUp),
|
||||||
|
Named::Enter => Some(Action::LaunchSelected),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_effect(state: &KLauncherApp, effect: Effect) -> Task<Message> {
|
||||||
|
match effect {
|
||||||
|
Effect::SearchAfterDebounce {
|
||||||
|
query,
|
||||||
|
debounce_ms,
|
||||||
|
epoch,
|
||||||
|
} => {
|
||||||
|
let Some(engine) = state.inner.engine().cloned() else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
Task::perform(
|
||||||
|
async move {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(debounce_ms)).await;
|
||||||
|
(epoch, engine.search(&query).await)
|
||||||
|
},
|
||||||
|
|(epoch, results)| Message::ResultsReady {
|
||||||
|
epoch,
|
||||||
|
results: Arc::new(results),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Effect::LaunchAndExit(action) => {
|
||||||
|
state.inner.launcher().execute(&action);
|
||||||
|
iced::exit()
|
||||||
|
}
|
||||||
|
Effect::Exit => iced::exit(),
|
||||||
|
Effect::TriggerSearch(q) => Task::done(Message::QueryChanged(q)),
|
||||||
|
Effect::None => Task::none(),
|
||||||
|
}
|
||||||
|
}
|
||||||
185
crates/k-launcher-ui/src/view.rs
Normal file
185
crates/k-launcher-ui/src/view.rs
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use iced::{
|
||||||
|
Element, Length,
|
||||||
|
widget::{Space, column, container, image, row, scrollable, svg, text, text_input},
|
||||||
|
};
|
||||||
|
|
||||||
|
use k_launcher_config::AppearanceCfg;
|
||||||
|
use k_launcher_domain::SearchResult;
|
||||||
|
|
||||||
|
use crate::app::{INPUT_ID, KLauncherApp, Message};
|
||||||
|
use crate::style;
|
||||||
|
|
||||||
|
pub(crate) fn view(state: &KLauncherApp) -> Element<'_, Message> {
|
||||||
|
let cfg = state.inner.cfg();
|
||||||
|
|
||||||
|
let mut content_children: Vec<Element<'_, Message>> =
|
||||||
|
vec![search_bar(cfg, state.inner.query()), results_section(state)];
|
||||||
|
if let Some(err) = state.inner.error() {
|
||||||
|
content_children.push(error_bar(err, cfg));
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = column(content_children)
|
||||||
|
.spacing(style::CONTENT_SPACING)
|
||||||
|
.padding(style::CONTENT_PADDING)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill);
|
||||||
|
|
||||||
|
container(content)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.style(style::outer_container_style(
|
||||||
|
style::rgba(&cfg.background_rgba),
|
||||||
|
style::rgba(&cfg.border_rgba),
|
||||||
|
cfg.border_width,
|
||||||
|
cfg.border_radius,
|
||||||
|
))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_bar<'a>(cfg: &'a AppearanceCfg, query: &'a str) -> Element<'a, Message> {
|
||||||
|
text_input(&cfg.placeholder, query)
|
||||||
|
.id(INPUT_ID.clone())
|
||||||
|
.on_input(Message::QueryChanged)
|
||||||
|
.padding(style::CONTENT_PADDING)
|
||||||
|
.size(cfg.search_font_size)
|
||||||
|
.style(style::search_input_style)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn results_section<'a>(state: &'a KLauncherApp) -> Element<'a, Message> {
|
||||||
|
if state.inner.is_loading() {
|
||||||
|
loading_state(state.inner.cfg())
|
||||||
|
} else if state.inner.results().is_empty() && !state.inner.query().is_empty() {
|
||||||
|
empty_state(state.inner.cfg())
|
||||||
|
} else {
|
||||||
|
result_list(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn loading_state(cfg: &AppearanceCfg) -> Element<'static, Message> {
|
||||||
|
container(
|
||||||
|
text("Loading...")
|
||||||
|
.size(cfg.title_size)
|
||||||
|
.color(style::rgba(&cfg.no_results_rgba)),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.align_x(iced::Center)
|
||||||
|
.padding(style::EMPTY_STATE_PADDING)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_state(cfg: &AppearanceCfg) -> Element<'static, Message> {
|
||||||
|
scrollable(
|
||||||
|
container(
|
||||||
|
text("No results")
|
||||||
|
.size(cfg.title_size)
|
||||||
|
.color(style::rgba(&cfg.no_results_rgba)),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.align_x(iced::Center)
|
||||||
|
.padding(style::EMPTY_STATE_PADDING),
|
||||||
|
)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn result_list<'a>(state: &'a KLauncherApp) -> Element<'a, Message> {
|
||||||
|
let cfg = state.inner.cfg();
|
||||||
|
let selected = state.inner.selected();
|
||||||
|
let rows: Vec<Element<'_, Message>> = state
|
||||||
|
.inner
|
||||||
|
.results()
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, result)| result_row(result, i == selected, cfg))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
scrollable(column(rows).spacing(style::ROW_SPACING).width(Length::Fill))
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn result_row<'a>(
|
||||||
|
result: &'a SearchResult,
|
||||||
|
is_selected: bool,
|
||||||
|
cfg: &AppearanceCfg,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let bg_color = if is_selected {
|
||||||
|
style::rgba(&cfg.selected_row_rgba)
|
||||||
|
} else {
|
||||||
|
style::rgba(&cfg.unselected_row_rgba)
|
||||||
|
};
|
||||||
|
|
||||||
|
container(
|
||||||
|
row![
|
||||||
|
result_icon(&result.icon, cfg),
|
||||||
|
title_column(result, is_selected, cfg)
|
||||||
|
]
|
||||||
|
.spacing(style::CONTENT_SPACING)
|
||||||
|
.align_y(iced::Center),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.padding(style::ROW_PADDING)
|
||||||
|
.style(style::result_row_style(bg_color, cfg.row_radius))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn result_icon<'a>(icon_path: &'a Option<Arc<str>>, cfg: &AppearanceCfg) -> Element<'a, Message> {
|
||||||
|
let size = cfg.icon_size;
|
||||||
|
match icon_path {
|
||||||
|
Some(p) if p.ends_with(".svg") => svg(svg::Handle::from_path(p.as_ref()))
|
||||||
|
.width(size)
|
||||||
|
.height(size)
|
||||||
|
.into(),
|
||||||
|
Some(p) => image(image::Handle::from_path(p.as_ref()))
|
||||||
|
.width(size)
|
||||||
|
.height(size)
|
||||||
|
.into(),
|
||||||
|
None => Space::new().width(size).height(size).into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title_column<'a>(
|
||||||
|
result: &'a SearchResult,
|
||||||
|
is_selected: bool,
|
||||||
|
cfg: &AppearanceCfg,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let title_color = if is_selected {
|
||||||
|
style::rgba(&cfg.selected_text_rgba)
|
||||||
|
} else {
|
||||||
|
style::rgba(&cfg.text_rgba)
|
||||||
|
};
|
||||||
|
let desc_color = if is_selected {
|
||||||
|
style::rgba(&cfg.selected_description_rgba)
|
||||||
|
} else {
|
||||||
|
style::rgba(&cfg.description_rgba)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(desc) = &result.description {
|
||||||
|
column![
|
||||||
|
text(result.title.as_str())
|
||||||
|
.size(cfg.title_size)
|
||||||
|
.color(title_color),
|
||||||
|
text(desc.as_ref()).size(cfg.desc_size).color(desc_color),
|
||||||
|
]
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
text(result.title.as_str())
|
||||||
|
.size(cfg.title_size)
|
||||||
|
.color(title_color)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_bar<'a>(msg: &'a str, cfg: &AppearanceCfg) -> Element<'a, Message> {
|
||||||
|
container(
|
||||||
|
text(msg)
|
||||||
|
.size(style::ERROR_FONT_SIZE)
|
||||||
|
.color(style::rgba(&cfg.error_rgba)),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.padding(style::ERROR_PADDING)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "k-launcher"
|
name = "k-launcher"
|
||||||
version = "0.1.0"
|
version = "0.2.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
default-run = "k-launcher"
|
default-run = "k-launcher"
|
||||||
|
|
||||||
@@ -23,15 +23,21 @@ required-features = ["egui"]
|
|||||||
egui = ["dep:k-launcher-ui-egui"]
|
egui = ["dep:k-launcher-ui-egui"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
iced = { workspace = true }
|
k-launcher-config = { workspace = true }
|
||||||
k-launcher-config = { path = "../k-launcher-config" }
|
k-launcher-kernel = { workspace = true }
|
||||||
k-launcher-kernel = { path = "../k-launcher-kernel" }
|
k-launcher-domain = { workspace = true }
|
||||||
k-launcher-plugin-host = { path = "../k-launcher-plugin-host" }
|
k-launcher-plugin-host = { workspace = true }
|
||||||
k-launcher-os-bridge = { path = "../k-launcher-os-bridge" }
|
k-launcher-os-bridge = { workspace = true }
|
||||||
k-launcher-ui = { path = "../k-launcher-ui" }
|
k-launcher-ui = { workspace = true }
|
||||||
k-launcher-ui-egui = { path = "../k-launcher-ui-egui", optional = true }
|
k-launcher-ui-egui = { workspace = true, optional = true }
|
||||||
plugin-apps = { path = "../plugins/plugin-apps" }
|
plugin-apps = { workspace = true }
|
||||||
plugin-calc = { path = "../plugins/plugin-calc" }
|
plugin-calc = { workspace = true }
|
||||||
plugin-cmd = { path = "../plugins/plugin-cmd" }
|
plugin-cmd = { workspace = true }
|
||||||
plugin-files = { path = "../plugins/plugin-files" }
|
plugin-files = { workspace = true }
|
||||||
|
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"] }
|
||||||
|
|||||||
39
crates/k-launcher/src/engine.rs
Normal file
39
crates/k-launcher/src/engine.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use k_launcher_kernel::Kernel;
|
||||||
|
use k_launcher_plugin_host::ExternalPlugin;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use plugin_apps::linux::FsDesktopEntrySource;
|
||||||
|
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
|
||||||
|
use plugin_calc::CalcPlugin;
|
||||||
|
use plugin_cmd::CmdPlugin;
|
||||||
|
use plugin_files::FilesPlugin;
|
||||||
|
|
||||||
|
pub(crate) fn build_engine(cfg: Arc<k_launcher_config::Config>) -> Arc<Kernel> {
|
||||||
|
let frecency = FrecencyStore::load(cfg.search.frecency_compact_threshold);
|
||||||
|
let mut plugins: Vec<Arc<dyn k_launcher_domain::Plugin>> = vec![];
|
||||||
|
if cfg.plugins.cmd {
|
||||||
|
plugins.push(Arc::new(CmdPlugin::new()));
|
||||||
|
}
|
||||||
|
if cfg.plugins.calc {
|
||||||
|
plugins.push(Arc::new(CalcPlugin::new()));
|
||||||
|
}
|
||||||
|
if cfg.plugins.files {
|
||||||
|
plugins.push(Arc::new(FilesPlugin::new()));
|
||||||
|
}
|
||||||
|
if cfg.plugins.apps {
|
||||||
|
plugins.push(Arc::new(AppsPlugin::new(
|
||||||
|
FsDesktopEntrySource::new(),
|
||||||
|
frecency,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
for ext in &cfg.plugins.external {
|
||||||
|
plugins.push(Arc::new(ExternalPlugin::new(
|
||||||
|
&ext.name,
|
||||||
|
&ext.path,
|
||||||
|
ext.args.clone(),
|
||||||
|
ext.timeout_secs,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Arc::new(Kernel::new(plugins, cfg.search.max_results))
|
||||||
|
}
|
||||||
7
crates/k-launcher/src/error.rs
Normal file
7
crates/k-launcher/src/error.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("UI error: {0}")]
|
||||||
|
Ui(String),
|
||||||
|
}
|
||||||
53
crates/k-launcher/src/logging.rs
Normal file
53
crates/k-launcher/src/logging.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use k_launcher_domain::constants::{APP_NAME, LOG_DIR_NAME, LOG_FILE_PREFIX};
|
||||||
|
|
||||||
|
const FALLBACK_LOG_DIR: &str = "/tmp/k-launcher/logs";
|
||||||
|
const DEFAULT_LOG_LEVEL: &str = "info";
|
||||||
|
|
||||||
|
pub(crate) fn init_logging(
|
||||||
|
cfg: &k_launcher_config::Config,
|
||||||
|
) -> tracing_appender::non_blocking::WorkerGuard {
|
||||||
|
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
let log_dir = dirs::data_local_dir()
|
||||||
|
.map(|d| d.join(APP_NAME).join(LOG_DIR_NAME))
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from(FALLBACK_LOG_DIR));
|
||||||
|
std::fs::create_dir_all(&log_dir).ok();
|
||||||
|
|
||||||
|
let file_appender = tracing_appender::rolling::RollingFileAppender::builder()
|
||||||
|
.rotation(tracing_appender::rolling::Rotation::DAILY)
|
||||||
|
.filename_prefix(LOG_FILE_PREFIX)
|
||||||
|
.max_log_files(cfg.logging.max_log_files)
|
||||||
|
.build(&log_dir)
|
||||||
|
.expect("log appender");
|
||||||
|
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
||||||
|
|
||||||
|
let env_filter =
|
||||||
|
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(DEFAULT_LOG_LEVEL));
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(env_filter)
|
||||||
|
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
|
||||||
|
.with(tracing_subscriber::fmt::layer().with_writer(non_blocking))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
guard
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn install_panic_hook() {
|
||||||
|
let default_hook = std::panic::take_hook();
|
||||||
|
std::panic::set_hook(Box::new(move |info| {
|
||||||
|
let location = info
|
||||||
|
.location()
|
||||||
|
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let payload = if let Some(s) = info.payload().downcast_ref::<&str>() {
|
||||||
|
(*s).to_string()
|
||||||
|
} else if let Some(s) = info.payload().downcast_ref::<String>() {
|
||||||
|
s.clone()
|
||||||
|
} else {
|
||||||
|
"unknown panic".to_string()
|
||||||
|
};
|
||||||
|
tracing::error!("PANIC at {location}: {payload}");
|
||||||
|
default_hook(info);
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -1,33 +1,46 @@
|
|||||||
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;
|
|
||||||
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
use plugin_apps::linux::FsDesktopEntrySource;
|
|
||||||
use plugin_calc::CalcPlugin;
|
|
||||||
use plugin_cmd::CmdPlugin;
|
|
||||||
use plugin_files::FilesPlugin;
|
|
||||||
|
|
||||||
fn main() -> iced::Result {
|
mod engine;
|
||||||
let cfg = k_launcher_config::load();
|
mod error;
|
||||||
let launcher = Arc::new(UnixAppLauncher::new());
|
mod logging;
|
||||||
let frecency = FrecencyStore::load();
|
|
||||||
|
|
||||||
let mut plugins: Vec<Arc<dyn k_launcher_kernel::Plugin>> = vec![];
|
use error::AppError;
|
||||||
if cfg.plugins.cmd { plugins.push(Arc::new(CmdPlugin::new())); }
|
|
||||||
if cfg.plugins.calc { plugins.push(Arc::new(CalcPlugin::new())); }
|
fn main() {
|
||||||
if cfg.plugins.files { plugins.push(Arc::new(FilesPlugin::new())); }
|
if std::env::args().any(|a| a == "--version" || a == "-V") {
|
||||||
if cfg.plugins.apps {
|
println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
|
||||||
plugins.push(Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)));
|
return;
|
||||||
}
|
|
||||||
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> =
|
let cfg = Arc::new(k_launcher_config::load());
|
||||||
Arc::new(Kernel::new(plugins, cfg.search.max_results));
|
let _guard = logging::init_logging(&cfg);
|
||||||
|
logging::install_panic_hook();
|
||||||
|
|
||||||
k_launcher_ui::run(kernel, launcher, &cfg.window, cfg.appearance)
|
ctrlc::set_handler(|| {
|
||||||
|
tracing::info!("received shutdown signal");
|
||||||
|
std::process::exit(0);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
if let Err(e) = run_ui(cfg) {
|
||||||
|
eprintln!("error: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_ui(cfg: Arc<k_launcher_config::Config>) -> Result<(), AppError> {
|
||||||
|
let launcher = Arc::new(UnixAppLauncher::new(cfg.terminal.cmd.clone()));
|
||||||
|
let factory_cfg = cfg.clone();
|
||||||
|
let factory: Arc<dyn Fn() -> Arc<k_launcher_kernel::Kernel> + Send + Sync> =
|
||||||
|
Arc::new(move || engine::build_engine(factory_cfg.clone()));
|
||||||
|
k_launcher_ui::run(
|
||||||
|
factory,
|
||||||
|
launcher,
|
||||||
|
&cfg.window,
|
||||||
|
cfg.appearance.clone(),
|
||||||
|
&cfg.search,
|
||||||
|
)
|
||||||
|
.map_err(AppError::Ui)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,38 @@
|
|||||||
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 plugin_apps::{AppsPlugin, frecency::FrecencyStore};
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
use plugin_apps::linux::FsDesktopEntrySource;
|
|
||||||
use plugin_calc::CalcPlugin;
|
|
||||||
use plugin_cmd::CmdPlugin;
|
|
||||||
use plugin_files::FilesPlugin;
|
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
mod engine;
|
||||||
let launcher = Arc::new(UnixAppLauncher::new());
|
mod error;
|
||||||
let frecency = FrecencyStore::load();
|
mod logging;
|
||||||
let kernel: Arc<dyn k_launcher_kernel::SearchEngine> = Arc::new(Kernel::new(vec![
|
|
||||||
Arc::new(CmdPlugin::new()),
|
use error::AppError;
|
||||||
Arc::new(CalcPlugin::new()),
|
|
||||||
Arc::new(FilesPlugin::new()),
|
fn main() {
|
||||||
Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)),
|
if std::env::args().any(|a| a == "--version" || a == "-V") {
|
||||||
], 8));
|
println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
|
||||||
k_launcher_ui_egui::run(kernel, launcher)?;
|
return;
|
||||||
Ok(())
|
}
|
||||||
|
|
||||||
|
let cfg = Arc::new(k_launcher_config::load());
|
||||||
|
let _guard = logging::init_logging(&cfg);
|
||||||
|
logging::install_panic_hook();
|
||||||
|
|
||||||
|
ctrlc::set_handler(|| {
|
||||||
|
tracing::info!("received shutdown signal");
|
||||||
|
std::process::exit(0);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
if let Err(e) = run_ui(cfg) {
|
||||||
|
eprintln!("error: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_ui(cfg: Arc<k_launcher_config::Config>) -> Result<(), AppError> {
|
||||||
|
let launcher = Arc::new(UnixAppLauncher::new(cfg.terminal.cmd.clone()));
|
||||||
|
let kernel = engine::build_engine(cfg.clone());
|
||||||
|
k_launcher_ui_egui::run(kernel, launcher, &cfg.window, cfg.appearance.clone())
|
||||||
|
.map_err(AppError::Ui)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "plugin-apps"
|
name = "plugin-apps"
|
||||||
version = "0.1.0"
|
version = "0.2.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -9,10 +9,20 @@ path = "src/lib.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
bincode = { version = "2", features = ["serde"] }
|
||||||
|
dirs = { workspace = true }
|
||||||
|
k-launcher-domain = { workspace = true }
|
||||||
|
nucleo-matcher = "0.3"
|
||||||
|
parking_lot = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
linicon = "2.3.0"
|
||||||
xdg = "3"
|
xdg = "3"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
k-launcher-domain = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
|||||||
110
crates/plugins/plugin-apps/src/cache.rs
Normal file
110
crates/plugins/plugin-apps/src/cache.rs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::Arc,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::frecency::FrecencyStore;
|
||||||
|
use crate::types::{AppName, DesktopEntrySource};
|
||||||
|
|
||||||
|
pub struct CachedEntry {
|
||||||
|
pub(crate) id: String,
|
||||||
|
pub(crate) name: AppName,
|
||||||
|
pub(crate) name_lowercase: String,
|
||||||
|
pub(crate) keywords_lowercase: Vec<String>,
|
||||||
|
pub(crate) category: Option<Arc<str>>,
|
||||||
|
pub(crate) icon: Option<Arc<str>>,
|
||||||
|
pub(crate) exec: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
|
struct CachedEntryData {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
name_lowercase: String,
|
||||||
|
keywords_lowercase: Vec<String>,
|
||||||
|
category: Option<String>,
|
||||||
|
icon: Option<String>,
|
||||||
|
exec: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cache_path() -> Option<PathBuf> {
|
||||||
|
dirs::cache_dir().map(|d| d.join("k-launcher/apps.bin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_from_path(path: &Path) -> Option<HashMap<String, CachedEntry>> {
|
||||||
|
let data = std::fs::read(path).ok()?;
|
||||||
|
let (entries_data, _): (Vec<CachedEntryData>, _) =
|
||||||
|
bincode::serde::decode_from_slice(&data, bincode::config::standard()).ok()?;
|
||||||
|
let map = entries_data
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| {
|
||||||
|
let cached = CachedEntry {
|
||||||
|
id: e.id.clone(),
|
||||||
|
name: AppName::new(e.name),
|
||||||
|
name_lowercase: e.name_lowercase,
|
||||||
|
keywords_lowercase: e.keywords_lowercase,
|
||||||
|
category: e.category.map(Arc::from),
|
||||||
|
icon: e.icon.map(Arc::from),
|
||||||
|
exec: e.exec,
|
||||||
|
};
|
||||||
|
(e.id, cached)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Some(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_to_path(path: &Path, entries: &HashMap<String, CachedEntry>) {
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
std::fs::create_dir_all(dir).ok();
|
||||||
|
}
|
||||||
|
let data: Vec<CachedEntryData> = entries
|
||||||
|
.values()
|
||||||
|
.map(|e| CachedEntryData {
|
||||||
|
id: e.id.clone(),
|
||||||
|
name: e.name.as_str().to_string(),
|
||||||
|
name_lowercase: e.name_lowercase.clone(),
|
||||||
|
keywords_lowercase: e.keywords_lowercase.clone(),
|
||||||
|
category: e.category.as_deref().map(str::to_string),
|
||||||
|
icon: e.icon.as_deref().map(str::to_string),
|
||||||
|
exec: e.exec.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if let Ok(encoded) = bincode::serde::encode_to_vec(&data, bincode::config::standard()) {
|
||||||
|
std::fs::write(path, encoded).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_entries(
|
||||||
|
source: &impl DesktopEntrySource,
|
||||||
|
_frecency: &Arc<FrecencyStore>,
|
||||||
|
) -> HashMap<String, CachedEntry> {
|
||||||
|
source
|
||||||
|
.entries()
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| {
|
||||||
|
let id = format!("app-{}:{}", e.name.as_str(), e.exec.as_str());
|
||||||
|
let name_lowercase = e.name.as_str().to_lowercase();
|
||||||
|
let keywords_lowercase = e.keywords.iter().map(|k| k.to_lowercase()).collect();
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let icon: Option<Arc<str>> = e
|
||||||
|
.icon
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| crate::linux::resolve_icon_path(p.as_str()))
|
||||||
|
.map(Arc::from);
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let icon: Option<Arc<str>> = None;
|
||||||
|
let exec = e.exec.as_str().to_string();
|
||||||
|
let cached = CachedEntry {
|
||||||
|
id: id.clone(),
|
||||||
|
name_lowercase,
|
||||||
|
keywords_lowercase,
|
||||||
|
category: e.category.map(Arc::from),
|
||||||
|
icon,
|
||||||
|
exec,
|
||||||
|
name: e.name,
|
||||||
|
};
|
||||||
|
(id, cached)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
|
fs::{File, OpenOptions},
|
||||||
|
io::{BufRead, BufReader, Write},
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
sync::{Arc, Mutex},
|
sync::Arc,
|
||||||
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)]
|
||||||
@@ -13,35 +16,64 @@ struct Entry {
|
|||||||
last_used: u64,
|
last_used: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct LogRecord {
|
||||||
|
id: String,
|
||||||
|
ts: u64,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct FrecencyStore {
|
pub struct FrecencyStore {
|
||||||
path: PathBuf,
|
snapshot_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(path: PathBuf) -> Arc<Self> {
|
pub fn new(snapshot_path: PathBuf, compact_threshold: usize) -> Arc<Self> {
|
||||||
let data = std::fs::read_to_string(&path)
|
let mut data: HashMap<String, Entry> = std::fs::read_to_string(&snapshot_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();
|
||||||
Arc::new(Self { path, data: Mutex::new(data) })
|
|
||||||
|
let log_path = snapshot_path.with_extension("log");
|
||||||
|
let log_count = replay_log_into(&log_path, &mut data);
|
||||||
|
|
||||||
|
let store = Arc::new(Self {
|
||||||
|
snapshot_path,
|
||||||
|
log_path,
|
||||||
|
data: Mutex::new(data),
|
||||||
|
log_count: Mutex::new(log_count),
|
||||||
|
compact_threshold,
|
||||||
|
});
|
||||||
|
|
||||||
|
if log_count >= compact_threshold {
|
||||||
|
store.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
store
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub fn new_for_test() -> Arc<Self> {
|
pub fn new_for_test() -> Arc<Self> {
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
path: PathBuf::from("/dev/null"),
|
snapshot_path: PathBuf::from("/dev/null"),
|
||||||
|
log_path: PathBuf::from("/dev/null"),
|
||||||
data: Mutex::new(HashMap::new()),
|
data: Mutex::new(HashMap::new()),
|
||||||
|
log_count: Mutex::new(0),
|
||||||
|
compact_threshold: usize::MAX,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load() -> Arc<Self> {
|
pub fn load(compact_threshold: usize) -> Arc<Self> {
|
||||||
let path = xdg::BaseDirectories::new()
|
let Some(data_home) = xdg::BaseDirectories::new().get_data_home() else {
|
||||||
.get_data_home()
|
tracing::warn!("XDG_DATA_HOME unavailable; frecency disabled (in-memory only)");
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
return Self::new_for_test();
|
||||||
.join("k-launcher")
|
};
|
||||||
.join("frecency.json");
|
let path = data_home
|
||||||
Self::new(path)
|
.join(k_launcher_domain::constants::APP_NAME)
|
||||||
|
.join(k_launcher_domain::constants::FRECENCY_SNAPSHOT_FILENAME);
|
||||||
|
Self::new(path, compact_threshold)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record(&self, id: &str) {
|
pub fn record(&self, id: &str) {
|
||||||
@@ -49,86 +81,149 @@ 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 entry = data.entry(id.to_string()).or_insert(Entry { count: 0, last_used: 0 });
|
let mut data = self.data.lock();
|
||||||
|
let entry = data.entry(id.to_string()).or_insert(Entry {
|
||||||
|
count: 0,
|
||||||
|
last_used: 0,
|
||||||
|
});
|
||||||
entry.count += 1;
|
entry.count += 1;
|
||||||
entry.last_used = now;
|
entry.last_used = now;
|
||||||
if let Some(parent) = self.path.parent() {
|
|
||||||
let _ = std::fs::create_dir_all(parent);
|
|
||||||
}
|
}
|
||||||
if let Ok(json) = serde_json::to_string(&*data) {
|
self.append_log(id, now);
|
||||||
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().unwrap();
|
let data = self.data.lock();
|
||||||
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);
|
||||||
let decay = if age_secs < 3600 { 4 } else if age_secs < 86400 { 2 } else { 1 };
|
entry.count * decay_factor(age_secs)
|
||||||
entry.count * decay
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn top_ids(&self, n: usize) -> Vec<String> {
|
pub fn top_ids(&self, n: usize) -> Vec<String> {
|
||||||
let data = self.data.lock().unwrap();
|
struct ScoredId {
|
||||||
|
id: String,
|
||||||
|
score: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = self.data.lock();
|
||||||
let now = SystemTime::now()
|
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<(String, u32)> = data
|
let mut scored: Vec<ScoredId> = 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);
|
||||||
let decay = if age_secs < 3600 { 4 } else if age_secs < 86400 { 2 } else { 1 };
|
ScoredId {
|
||||||
(id.clone(), entry.count * decay)
|
id: id.clone(),
|
||||||
|
score: entry.count * decay_factor(age_secs),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
scored.sort_by(|a, b| b.1.cmp(&a.1));
|
|
||||||
scored.into_iter().take(n).map(|(id, _)| id).collect()
|
if scored.len() <= n {
|
||||||
|
scored.sort_by_key(|s| std::cmp::Reverse(s.score));
|
||||||
|
return scored.into_iter().map(|s| s.id).collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
scored.select_nth_unstable_by_key(n, |s| std::cmp::Reverse(s.score));
|
||||||
|
scored.truncate(n);
|
||||||
|
scored.sort_by_key(|s| std::cmp::Reverse(s.score));
|
||||||
|
scored.into_iter().map(|s| s.id).collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
fn replay_log_into(log_path: &PathBuf, data: &mut HashMap<String, Entry>) -> usize {
|
||||||
mod tests {
|
let file = match File::open(log_path) {
|
||||||
use super::*;
|
Ok(f) => f,
|
||||||
|
Err(_) => return 0,
|
||||||
fn make_store() -> Arc<FrecencyStore> {
|
};
|
||||||
Arc::new(FrecencyStore {
|
let mut count = 0;
|
||||||
path: PathBuf::from("/dev/null"),
|
for line in BufReader::new(file).lines() {
|
||||||
data: Mutex::new(HashMap::new()),
|
let Ok(line) = line else { continue };
|
||||||
})
|
let Ok(record) = serde_json::from_str::<LogRecord>(&line) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let entry = data.entry(record.id).or_insert(Entry {
|
||||||
|
count: 0,
|
||||||
|
last_used: 0,
|
||||||
|
});
|
||||||
|
entry.count += 1;
|
||||||
|
entry.last_used = record.ts;
|
||||||
|
count += 1;
|
||||||
}
|
}
|
||||||
|
count
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
const ONE_HOUR: u64 = 3600;
|
||||||
fn record_increments_count() {
|
const ONE_DAY: u64 = 86400;
|
||||||
let store = make_store();
|
const DECAY_RECENT: u32 = 4;
|
||||||
store.record("app-firefox");
|
const DECAY_TODAY: u32 = 2;
|
||||||
store.record("app-firefox");
|
const DECAY_OLD: u32 = 1;
|
||||||
let data = store.data.lock().unwrap();
|
|
||||||
assert_eq!(data["app-firefox"].count, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
fn decay_factor(age_secs: u64) -> u32 {
|
||||||
fn record_updates_last_used() {
|
if age_secs < ONE_HOUR {
|
||||||
let store = make_store();
|
DECAY_RECENT
|
||||||
store.record("app-firefox");
|
} else if age_secs < ONE_DAY {
|
||||||
let data = store.data.lock().unwrap();
|
DECAY_TODAY
|
||||||
assert!(data["app-firefox"].last_used > 0);
|
} else {
|
||||||
}
|
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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,337 +1,12 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
use std::{collections::HashMap, sync::Arc};
|
pub use cache::{CachedEntry, build_entries, load_from_path, save_to_path};
|
||||||
|
pub use plugin::*;
|
||||||
use async_trait::async_trait;
|
pub use scoring::{humanize_category, new_matcher, parse_pattern, score_match};
|
||||||
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
pub use types::*;
|
||||||
|
|
||||||
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,
|
|
||||||
name_lc: String,
|
|
||||||
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 name_lc = e.name.as_str().to_lowercase();
|
|
||||||
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(),
|
|
||||||
name_lc,
|
|
||||||
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_lc: &str, query_lc: &str) -> Option<u32> {
|
|
||||||
if name_lc == query_lc { return Some(100); }
|
|
||||||
if name_lc.starts_with(query_lc) { return Some(80); }
|
|
||||||
if name_lc.contains(query_lc) { return Some(60); }
|
|
||||||
if initials(name_lc).starts_with(query_lc) { return Some(70); }
|
|
||||||
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_lc, &query_lc).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_eq!(score_match("visual studio code", "vsc"), Some(70));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn score_match_exact_beats_prefix_beats_abbrev_beats_substr() {
|
|
||||||
assert_eq!(score_match("firefox", "firefox"), Some(100));
|
|
||||||
assert_eq!(score_match("firefox", "fire"), Some(80));
|
|
||||||
assert_eq!(score_match("gnu firefox", "gf"), Some(70));
|
|
||||||
assert_eq!(score_match("ice firefox", "fire"), Some(60));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn apps_abbreviation_match() {
|
|
||||||
let p = AppsPlugin::new(
|
|
||||||
MockSource::with(vec![("Visual Studio Code", "code")]),
|
|
||||||
ephemeral_frecency(),
|
|
||||||
);
|
|
||||||
let results = p.search("vsc").await;
|
|
||||||
assert_eq!(results.len(), 1);
|
|
||||||
assert_eq!(results[0].title.as_str(), "Visual Studio Code");
|
|
||||||
assert_eq!(results[0].score.value(), 70);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn apps_keyword_match() {
|
|
||||||
let p = AppsPlugin::new(
|
|
||||||
MockSource::with_keywords(vec![("Code", "code", vec!["editor", "ide"])]),
|
|
||||||
ephemeral_frecency(),
|
|
||||||
);
|
|
||||||
let results = p.search("editor").await;
|
|
||||||
assert_eq!(results.len(), 1);
|
|
||||||
assert_eq!(results[0].score.value(), 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn humanize_category_splits_camel_case() {
|
|
||||||
assert_eq!(humanize_category("TextEditor"), "Text Editor");
|
|
||||||
assert_eq!(humanize_category("WebBrowser"), "Web Browser");
|
|
||||||
assert_eq!(humanize_category("Development"), "Development");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn apps_category_appears_in_description() {
|
|
||||||
let p = AppsPlugin::new(
|
|
||||||
MockSource::with_categories(vec![("Code", "code", "Text Editor")]),
|
|
||||||
ephemeral_frecency(),
|
|
||||||
);
|
|
||||||
let results = p.search("code").await;
|
|
||||||
assert_eq!(results[0].description.as_deref(), Some("Text Editor"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn apps_empty_query_returns_top_frecent() {
|
|
||||||
let frecency = ephemeral_frecency();
|
|
||||||
frecency.record("app-Code");
|
|
||||||
frecency.record("app-Code");
|
|
||||||
frecency.record("app-Firefox");
|
|
||||||
let p = AppsPlugin::new(
|
|
||||||
MockSource::with(vec![("Firefox", "firefox"), ("Code", "code")]),
|
|
||||||
frecency,
|
|
||||||
);
|
|
||||||
let results = p.search("").await;
|
|
||||||
assert_eq!(results.len(), 2);
|
|
||||||
assert_eq!(results[0].title.as_str(), "Code");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::scoring::humanize_category;
|
||||||
use crate::{AppName, DesktopEntry, DesktopEntrySource, ExecCommand, IconPath};
|
use crate::{AppName, DesktopEntry, DesktopEntrySource, ExecCommand, IconPath};
|
||||||
use crate::humanize_category;
|
|
||||||
|
|
||||||
pub struct FsDesktopEntrySource;
|
pub struct FsDesktopEntrySource;
|
||||||
|
|
||||||
@@ -45,15 +45,81 @@ impl DesktopEntrySource for FsDesktopEntrySource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn clean_exec(exec: &str) -> String {
|
||||||
|
// Tokenize respecting double-quoted strings, then filter field codes.
|
||||||
|
let mut tokens: Vec<String> = Vec::new();
|
||||||
|
let mut chars = exec.chars().peekable();
|
||||||
|
|
||||||
|
while let Some(&ch) = chars.peek() {
|
||||||
|
if ch.is_whitespace() {
|
||||||
|
chars.next();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ch == '"' {
|
||||||
|
// Consume opening quote
|
||||||
|
chars.next();
|
||||||
|
let mut token = String::from('"');
|
||||||
|
while let Some(&c) = chars.peek() {
|
||||||
|
chars.next();
|
||||||
|
if c == '"' {
|
||||||
|
token.push('"');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
token.push(c);
|
||||||
|
}
|
||||||
|
// Strip embedded field codes like %f inside the quoted string
|
||||||
|
// (between the quotes, before re-assembling)
|
||||||
|
let inner = &token[1..token.len().saturating_sub(1)];
|
||||||
|
let cleaned_inner: String = inner
|
||||||
|
.split_whitespace()
|
||||||
|
.filter(|s| !is_field_code(s))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
tokens.push(format!("\"{cleaned_inner}\""));
|
||||||
|
} else {
|
||||||
|
let mut token = String::new();
|
||||||
|
while let Some(&c) = chars.peek() {
|
||||||
|
if c.is_whitespace() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
chars.next();
|
||||||
|
token.push(c);
|
||||||
|
}
|
||||||
|
if !is_field_code(&token) {
|
||||||
|
tokens.push(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_field_code(s: &str) -> bool {
|
||||||
|
let b = s.as_bytes();
|
||||||
|
b.len() == 2 && b[0] == b'%' && b[1].is_ascii_alphabetic()
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICON_LOOKUP_SIZE: u16 = 48;
|
||||||
|
const ICON_THEMES: &[&str] = &["hicolor", "Adwaita", "breeze", "Papirus"];
|
||||||
|
const PIXMAPS_DIR: &str = "/usr/share/pixmaps";
|
||||||
|
|
||||||
pub fn resolve_icon_path(name: &str) -> Option<String> {
|
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 {
|
||||||
|
if let Some(icon_path) = linicon::lookup_icon(name)
|
||||||
|
.from_theme(theme)
|
||||||
|
.with_size(ICON_LOOKUP_SIZE)
|
||||||
|
.find_map(|r| r.ok())
|
||||||
|
{
|
||||||
|
return Some(icon_path.path.to_string_lossy().into_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to pixmaps
|
||||||
let candidates = [
|
let candidates = [
|
||||||
format!("/usr/share/pixmaps/{name}.png"),
|
format!("{PIXMAPS_DIR}/{name}.png"),
|
||||||
format!("/usr/share/pixmaps/{name}.svg"),
|
format!("{PIXMAPS_DIR}/{name}.svg"),
|
||||||
format!("/usr/share/icons/hicolor/48x48/apps/{name}.png"),
|
|
||||||
format!("/usr/share/icons/hicolor/scalable/apps/{name}.svg"),
|
|
||||||
];
|
];
|
||||||
candidates.into_iter().find(|p| Path::new(p).exists())
|
candidates.into_iter().find(|p| Path::new(p).exists())
|
||||||
}
|
}
|
||||||
@@ -90,13 +156,15 @@ fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
|
|||||||
"Type" if !is_application => is_application = value.trim() == "Application",
|
"Type" if !is_application => is_application = value.trim() == "Application",
|
||||||
"NoDisplay" => no_display = value.trim().eq_ignore_ascii_case("true"),
|
"NoDisplay" => no_display = value.trim().eq_ignore_ascii_case("true"),
|
||||||
"Categories" if category.is_none() => {
|
"Categories" if category.is_none() => {
|
||||||
category = value.trim()
|
category = value
|
||||||
|
.trim()
|
||||||
.split(';')
|
.split(';')
|
||||||
.find(|s| !s.is_empty())
|
.find(|s| !s.is_empty())
|
||||||
.map(|s| humanize_category(s.trim()));
|
.map(|s| humanize_category(s.trim()));
|
||||||
}
|
}
|
||||||
"Keywords" if keywords.is_empty() => {
|
"Keywords" if keywords.is_empty() => {
|
||||||
keywords = value.trim()
|
keywords = value
|
||||||
|
.trim()
|
||||||
.split(';')
|
.split(';')
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
@@ -111,16 +179,7 @@ fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let exec_clean: String = exec?
|
let exec_clean: String = clean_exec(&exec?);
|
||||||
.split_whitespace()
|
|
||||||
.filter(|s| !s.starts_with('%'))
|
|
||||||
.fold(String::new(), |mut acc, s| {
|
|
||||||
if !acc.is_empty() {
|
|
||||||
acc.push(' ');
|
|
||||||
}
|
|
||||||
acc.push_str(s);
|
|
||||||
acc
|
|
||||||
});
|
|
||||||
|
|
||||||
Some(DesktopEntry {
|
Some(DesktopEntry {
|
||||||
name: AppName::new(name?),
|
name: AppName::new(name?),
|
||||||
|
|||||||
142
crates/plugins/plugin-apps/src/plugin.rs
Normal file
142
crates/plugins/plugin-apps/src/plugin.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
use std::{collections::HashMap, path::PathBuf, sync::Arc};
|
||||||
|
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use k_launcher_domain::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
||||||
|
|
||||||
|
use crate::cache::{CachedEntry, build_entries, cache_path, load_from_path, save_to_path};
|
||||||
|
use crate::frecency::FrecencyStore;
|
||||||
|
use crate::scoring::{new_matcher, parse_pattern, score_match};
|
||||||
|
use crate::types::DesktopEntrySource;
|
||||||
|
|
||||||
|
const FRECENT_RESULTS_COUNT: usize = 5;
|
||||||
|
const KEYWORD_MATCH_SCORE: u32 = 50;
|
||||||
|
|
||||||
|
pub struct AppsPlugin {
|
||||||
|
entries: Arc<RwLock<HashMap<String, CachedEntry>>>,
|
||||||
|
frecency: Arc<FrecencyStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppsPlugin {
|
||||||
|
pub fn new(source: impl DesktopEntrySource + 'static, frecency: Arc<FrecencyStore>) -> Self {
|
||||||
|
Self::new_impl(source, frecency, cache_path())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_impl(
|
||||||
|
source: impl DesktopEntrySource + 'static,
|
||||||
|
frecency: Arc<FrecencyStore>,
|
||||||
|
cp: Option<PathBuf>,
|
||||||
|
) -> Self {
|
||||||
|
let cached = cp.as_deref().and_then(load_from_path);
|
||||||
|
|
||||||
|
let entries = if let Some(from_cache) = cached {
|
||||||
|
// Serve cache immediately; refresh in background.
|
||||||
|
let map = Arc::new(RwLock::new(from_cache));
|
||||||
|
let entries_bg = Arc::clone(&map);
|
||||||
|
let frecency_bg = Arc::clone(&frecency);
|
||||||
|
let cp_bg = cp.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let fresh = build_entries(&source, &frecency_bg);
|
||||||
|
if let Some(path) = cp_bg {
|
||||||
|
save_to_path(&path, &fresh);
|
||||||
|
}
|
||||||
|
*entries_bg.write() = fresh;
|
||||||
|
});
|
||||||
|
map
|
||||||
|
} else {
|
||||||
|
// No cache: build synchronously, then persist.
|
||||||
|
let initial = build_entries(&source, &frecency);
|
||||||
|
if let Some(path) = &cp {
|
||||||
|
save_to_path(path, &initial);
|
||||||
|
}
|
||||||
|
Arc::new(RwLock::new(initial))
|
||||||
|
};
|
||||||
|
|
||||||
|
Self { entries, frecency }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_for_test(
|
||||||
|
source: impl DesktopEntrySource + 'static,
|
||||||
|
frecency: Arc<FrecencyStore>,
|
||||||
|
) -> Self {
|
||||||
|
Self::new_impl(source, frecency, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Plugin for AppsPlugin {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"apps"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_selected(&self, id: &ResultId) {
|
||||||
|
self.frecency.record(id.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shutdown(&self) {
|
||||||
|
self.frecency.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||||
|
let entries = self.entries.read();
|
||||||
|
if query.is_empty() {
|
||||||
|
return self
|
||||||
|
.frecency
|
||||||
|
.top_ids(FRECENT_RESULTS_COUNT)
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| {
|
||||||
|
let e = entries.get(id)?;
|
||||||
|
let score = self.frecency.frecency_score(id).max(1);
|
||||||
|
Some(SearchResult {
|
||||||
|
id: ResultId::new(id),
|
||||||
|
title: ResultTitle::new(e.name.as_str()),
|
||||||
|
description: e.category.clone(),
|
||||||
|
icon: e.icon.clone(),
|
||||||
|
score: Score::new(score),
|
||||||
|
action: LaunchAction::SpawnProcess(e.exec.clone()),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
let query_lowercase = query.to_lowercase();
|
||||||
|
let first_char = query_lowercase.chars().next().unwrap_or_default();
|
||||||
|
let mut matcher = new_matcher();
|
||||||
|
let pattern = parse_pattern(query);
|
||||||
|
let mut char_buf: Vec<char> = Vec::with_capacity(64);
|
||||||
|
|
||||||
|
entries
|
||||||
|
.values()
|
||||||
|
.filter(|e| {
|
||||||
|
e.name_lowercase.contains(first_char)
|
||||||
|
|| e.keywords_lowercase.iter().any(|k| k.contains(first_char))
|
||||||
|
})
|
||||||
|
.filter_map(|e| {
|
||||||
|
let match_score = score_match(
|
||||||
|
&mut matcher,
|
||||||
|
&pattern,
|
||||||
|
e.name.as_str(),
|
||||||
|
&mut char_buf,
|
||||||
|
&e.name_lowercase,
|
||||||
|
&query_lowercase,
|
||||||
|
)
|
||||||
|
.or_else(|| {
|
||||||
|
e.keywords_lowercase
|
||||||
|
.iter()
|
||||||
|
.any(|k| k.contains(query_lowercase.as_str()))
|
||||||
|
.then_some(KEYWORD_MATCH_SCORE)
|
||||||
|
})?;
|
||||||
|
let frecency_boost = self.frecency.frecency_score(&e.id);
|
||||||
|
Some(SearchResult {
|
||||||
|
id: ResultId::new(&e.id),
|
||||||
|
title: ResultTitle::new(e.name.as_str()),
|
||||||
|
description: e.category.clone(),
|
||||||
|
icon: e.icon.clone(),
|
||||||
|
score: Score::new(match_score.saturating_add(frecency_boost)),
|
||||||
|
action: LaunchAction::SpawnProcess(e.exec.clone()),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
60
crates/plugins/plugin-apps/src/scoring.rs
Normal file
60
crates/plugins/plugin-apps/src/scoring.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
pub use nucleo_matcher::Matcher;
|
||||||
|
use nucleo_matcher::{
|
||||||
|
Config, Utf32Str,
|
||||||
|
pattern::{CaseMatching, Normalization, Pattern},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn new_matcher() -> Matcher {
|
||||||
|
Matcher::new(Config::DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_pattern(query: &str) -> Pattern {
|
||||||
|
Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches_initials(name_lowercase: &str, query_lowercase: &str) -> bool {
|
||||||
|
let mut initials = name_lowercase
|
||||||
|
.split_whitespace()
|
||||||
|
.filter_map(|w| w.chars().next());
|
||||||
|
let mut query_chars = query_lowercase.chars();
|
||||||
|
|
||||||
|
for expected in &mut query_chars {
|
||||||
|
match initials.next() {
|
||||||
|
Some(initial) if initial == expected => continue,
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIALS_BONUS: u32 = 20;
|
||||||
|
|
||||||
|
pub fn score_match(
|
||||||
|
matcher: &mut Matcher,
|
||||||
|
pattern: &Pattern,
|
||||||
|
name: &str,
|
||||||
|
char_buf: &mut Vec<char>,
|
||||||
|
name_lowercase: &str,
|
||||||
|
query_lowercase: &str,
|
||||||
|
) -> Option<u32> {
|
||||||
|
let haystack = Utf32Str::new(name, char_buf);
|
||||||
|
let score = pattern.score(haystack, matcher)?;
|
||||||
|
|
||||||
|
let bonus = if matches_initials(name_lowercase, query_lowercase) {
|
||||||
|
INITIALS_BONUS
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
Some(score.saturating_add(bonus))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn humanize_category(s: &str) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
for ch in s.chars() {
|
||||||
|
if ch.is_uppercase() && !result.is_empty() {
|
||||||
|
result.push(' ');
|
||||||
|
}
|
||||||
|
result.push(ch);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
53
crates/plugins/plugin-apps/src/types.rs
Normal file
53
crates/plugins/plugin-apps/src/types.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// --- Domain newtypes ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AppName(String);
|
||||||
|
|
||||||
|
impl AppName {
|
||||||
|
pub fn new(s: impl Into<String>) -> Self {
|
||||||
|
Self(s.into())
|
||||||
|
}
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ExecCommand(String);
|
||||||
|
|
||||||
|
impl ExecCommand {
|
||||||
|
pub fn new(s: impl Into<String>) -> Self {
|
||||||
|
Self(s.into())
|
||||||
|
}
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct IconPath(String);
|
||||||
|
|
||||||
|
impl IconPath {
|
||||||
|
pub fn new(s: impl Into<String>) -> Self {
|
||||||
|
Self(s.into())
|
||||||
|
}
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Desktop entry ---
|
||||||
|
|
||||||
|
pub struct DesktopEntry {
|
||||||
|
pub name: AppName,
|
||||||
|
pub exec: ExecCommand,
|
||||||
|
pub icon: Option<IconPath>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
pub keywords: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Swappable source trait (Application layer principle) ---
|
||||||
|
|
||||||
|
pub trait DesktopEntrySource: Send + Sync {
|
||||||
|
fn entries(&self) -> Vec<DesktopEntry>;
|
||||||
|
}
|
||||||
21
crates/plugins/plugin-apps/tests/frecency.rs
Normal file
21
crates/plugins/plugin-apps/tests/frecency.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use plugin_apps::frecency::FrecencyStore;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_increments_count() {
|
||||||
|
let store = FrecencyStore::new_for_test();
|
||||||
|
store.record("app-firefox");
|
||||||
|
store.record("app-firefox");
|
||||||
|
assert!(store.frecency_score("app-firefox") > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn top_ids_returns_sorted_order() {
|
||||||
|
let store = FrecencyStore::new_for_test();
|
||||||
|
store.record("app-firefox");
|
||||||
|
store.record("app-code");
|
||||||
|
store.record("app-code");
|
||||||
|
store.record("app-code");
|
||||||
|
let top = store.top_ids(2);
|
||||||
|
assert_eq!(top[0], "app-code");
|
||||||
|
assert_eq!(top[1], "app-firefox");
|
||||||
|
}
|
||||||
27
crates/plugins/plugin-apps/tests/linux.rs
Normal file
27
crates/plugins/plugin-apps/tests/linux.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod linux_tests {
|
||||||
|
use plugin_apps::linux::clean_exec;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_bare_field_code() {
|
||||||
|
assert_eq!(clean_exec("app --file %f"), "app --file");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_multiple_field_codes() {
|
||||||
|
assert_eq!(clean_exec("app %U --flag"), "app --flag");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_quoted_value() {
|
||||||
|
assert_eq!(
|
||||||
|
clean_exec(r#"app --arg="value" %U"#),
|
||||||
|
r#"app --arg="value""#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handles_plain_exec() {
|
||||||
|
assert_eq!(clean_exec("firefox"), "firefox");
|
||||||
|
}
|
||||||
|
}
|
||||||
260
crates/plugins/plugin-apps/tests/plugin.rs
Normal file
260
crates/plugins/plugin-apps/tests/plugin.rs
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use k_launcher_domain::Plugin;
|
||||||
|
use plugin_apps::frecency::FrecencyStore;
|
||||||
|
use plugin_apps::{
|
||||||
|
AppName, AppsPlugin, DesktopEntry, DesktopEntrySource, ExecCommand, build_entries,
|
||||||
|
humanize_category, load_from_path, new_matcher, parse_pattern, save_to_path, score_match,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn ephemeral_frecency() -> Arc<FrecencyStore> {
|
||||||
|
FrecencyStore::new_for_test()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockEntry {
|
||||||
|
name: String,
|
||||||
|
exec: String,
|
||||||
|
category: Option<String>,
|
||||||
|
keywords: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockSource {
|
||||||
|
entries: Vec<MockEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockSource {
|
||||||
|
fn with(entries: Vec<(&str, &str)>) -> Self {
|
||||||
|
Self {
|
||||||
|
entries: entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|(n, e)| MockEntry {
|
||||||
|
name: n.to_string(),
|
||||||
|
exec: e.to_string(),
|
||||||
|
category: None,
|
||||||
|
keywords: vec![],
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_categories(entries: Vec<(&str, &str, &str)>) -> Self {
|
||||||
|
Self {
|
||||||
|
entries: entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|(n, e, c)| MockEntry {
|
||||||
|
name: n.to_string(),
|
||||||
|
exec: e.to_string(),
|
||||||
|
category: Some(c.to_string()),
|
||||||
|
keywords: vec![],
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_keywords(entries: Vec<(&str, &str, Vec<&str>)>) -> Self {
|
||||||
|
Self {
|
||||||
|
entries: entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|(n, e, kw)| MockEntry {
|
||||||
|
name: n.to_string(),
|
||||||
|
exec: e.to_string(),
|
||||||
|
category: None,
|
||||||
|
keywords: kw.into_iter().map(|s| s.to_string()).collect(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DesktopEntrySource for MockSource {
|
||||||
|
fn entries(&self) -> Vec<DesktopEntry> {
|
||||||
|
self.entries
|
||||||
|
.iter()
|
||||||
|
.map(|e| DesktopEntry {
|
||||||
|
name: AppName::new(e.name.clone()),
|
||||||
|
exec: ExecCommand::new(e.exec.clone()),
|
||||||
|
icon: None,
|
||||||
|
category: e.category.clone(),
|
||||||
|
keywords: e.keywords.clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn apps_prefix_match() {
|
||||||
|
let p = AppsPlugin::new_for_test(
|
||||||
|
MockSource::with(vec![("Firefox", "firefox")]),
|
||||||
|
ephemeral_frecency(),
|
||||||
|
);
|
||||||
|
let results = p.search("fire").await;
|
||||||
|
assert_eq!(results[0].title.as_str(), "Firefox");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn apps_no_match_returns_empty() {
|
||||||
|
let p = AppsPlugin::new_for_test(
|
||||||
|
MockSource::with(vec![("Firefox", "firefox")]),
|
||||||
|
ephemeral_frecency(),
|
||||||
|
);
|
||||||
|
assert!(p.search("zz").await.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn apps_empty_query_no_frecency_returns_empty() {
|
||||||
|
let p = AppsPlugin::new_for_test(
|
||||||
|
MockSource::with(vec![("Firefox", "firefox")]),
|
||||||
|
ephemeral_frecency(),
|
||||||
|
);
|
||||||
|
assert!(p.search("").await.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn score_match_abbreviation() {
|
||||||
|
let mut matcher = new_matcher();
|
||||||
|
let pattern = parse_pattern("vsc");
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
assert!(
|
||||||
|
score_match(
|
||||||
|
&mut matcher,
|
||||||
|
&pattern,
|
||||||
|
"visual studio code",
|
||||||
|
&mut buf,
|
||||||
|
"visual studio code",
|
||||||
|
"vsc"
|
||||||
|
)
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn score_match_exact_beats_prefix() {
|
||||||
|
let mut matcher = new_matcher();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let exact_pattern = parse_pattern("firefox");
|
||||||
|
let fire_pattern = parse_pattern("fire");
|
||||||
|
let gf_pattern = parse_pattern("gf");
|
||||||
|
|
||||||
|
let exact = score_match(
|
||||||
|
&mut matcher,
|
||||||
|
&exact_pattern,
|
||||||
|
"firefox",
|
||||||
|
&mut buf,
|
||||||
|
"firefox",
|
||||||
|
"firefox",
|
||||||
|
);
|
||||||
|
let prefix = score_match(
|
||||||
|
&mut matcher,
|
||||||
|
&fire_pattern,
|
||||||
|
"firefox",
|
||||||
|
&mut buf,
|
||||||
|
"firefox",
|
||||||
|
"fire",
|
||||||
|
);
|
||||||
|
let abbrev = score_match(
|
||||||
|
&mut matcher,
|
||||||
|
&gf_pattern,
|
||||||
|
"gnu firefox",
|
||||||
|
&mut buf,
|
||||||
|
"gnu firefox",
|
||||||
|
"gf",
|
||||||
|
);
|
||||||
|
let substr = score_match(
|
||||||
|
&mut matcher,
|
||||||
|
&fire_pattern,
|
||||||
|
"ice firefox",
|
||||||
|
&mut buf,
|
||||||
|
"ice firefox",
|
||||||
|
"fire",
|
||||||
|
);
|
||||||
|
assert!(exact.is_some());
|
||||||
|
assert!(prefix.is_some());
|
||||||
|
assert!(abbrev.is_some());
|
||||||
|
assert!(substr.is_some());
|
||||||
|
assert!(exact.unwrap() > prefix.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn apps_abbreviation_match() {
|
||||||
|
let p = AppsPlugin::new_for_test(
|
||||||
|
MockSource::with(vec![("Visual Studio Code", "code")]),
|
||||||
|
ephemeral_frecency(),
|
||||||
|
);
|
||||||
|
let results = p.search("vsc").await;
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].title.as_str(), "Visual Studio Code");
|
||||||
|
assert!(results[0].score.value() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn apps_keyword_match() {
|
||||||
|
let p = AppsPlugin::new_for_test(
|
||||||
|
MockSource::with_keywords(vec![("Code", "code", vec!["editor", "ide"])]),
|
||||||
|
ephemeral_frecency(),
|
||||||
|
);
|
||||||
|
let results = p.search("editor").await;
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].score.value(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn apps_fuzzy_typo_match() {
|
||||||
|
let p = AppsPlugin::new_for_test(
|
||||||
|
MockSource::with(vec![("Firefox", "firefox")]),
|
||||||
|
ephemeral_frecency(),
|
||||||
|
);
|
||||||
|
let results = p.search("frefox").await;
|
||||||
|
assert!(
|
||||||
|
!results.is_empty(),
|
||||||
|
"nucleo should fuzzy-match 'frefox' to 'Firefox'"
|
||||||
|
);
|
||||||
|
assert!(results[0].score.value() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn humanize_category_splits_camel_case() {
|
||||||
|
assert_eq!(humanize_category("TextEditor"), "Text Editor");
|
||||||
|
assert_eq!(humanize_category("WebBrowser"), "Web Browser");
|
||||||
|
assert_eq!(humanize_category("Development"), "Development");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn apps_category_appears_in_description() {
|
||||||
|
let p = AppsPlugin::new_for_test(
|
||||||
|
MockSource::with_categories(vec![("Code", "code", "Text Editor")]),
|
||||||
|
ephemeral_frecency(),
|
||||||
|
);
|
||||||
|
let results = p.search("code").await;
|
||||||
|
assert_eq!(results[0].description.as_deref(), Some("Text Editor"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn apps_empty_query_returns_top_frecent() {
|
||||||
|
let frecency = ephemeral_frecency();
|
||||||
|
frecency.record("app-Code:code");
|
||||||
|
frecency.record("app-Code:code");
|
||||||
|
frecency.record("app-Firefox:firefox");
|
||||||
|
let p = AppsPlugin::new_for_test(
|
||||||
|
MockSource::with(vec![("Firefox", "firefox"), ("Code", "code")]),
|
||||||
|
frecency,
|
||||||
|
);
|
||||||
|
let results = p.search("").await;
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
assert_eq!(results[0].title.as_str(), "Code");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apps_loads_from_cache_when_source_is_empty() {
|
||||||
|
let frecency = ephemeral_frecency();
|
||||||
|
let cache_file =
|
||||||
|
std::env::temp_dir().join(format!("k-launcher-test-{}.bin", std::process::id()));
|
||||||
|
|
||||||
|
let source = MockSource::with(vec![("Firefox", "firefox")]);
|
||||||
|
let entries = build_entries(&source, &frecency);
|
||||||
|
save_to_path(&cache_file, &entries);
|
||||||
|
|
||||||
|
let loaded = load_from_path(&cache_file).unwrap();
|
||||||
|
assert!(loaded.contains_key("app-Firefox:firefox"));
|
||||||
|
|
||||||
|
std::fs::remove_file(&cache_file).ok();
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "plugin-calc"
|
name = "plugin-calc"
|
||||||
version = "0.1.0"
|
version = "0.2.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -10,5 +10,9 @@ path = "src/lib.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
evalexpr = "13"
|
evalexpr = "13"
|
||||||
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
k-launcher-domain = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
k-launcher-domain = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
45
crates/plugins/plugin-calc/src/eval.rs
Normal file
45
crates/plugins/plugin-calc/src/eval.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub(crate) const MATH_FNS: &[&str] = &[
|
||||||
|
"sqrt", "sin", "cos", "tan", "asin", "acos", "atan", "ln", "log2", "log10", "exp", "abs",
|
||||||
|
"ceil", "floor", "round",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub(crate) fn strip_numeric_separators(expr: &str) -> String {
|
||||||
|
expr.replace('_', "")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn should_eval(query: &str) -> bool {
|
||||||
|
let q = query.strip_prefix('=').unwrap_or(query);
|
||||||
|
q.chars()
|
||||||
|
.next()
|
||||||
|
.map(|c| c.is_ascii_digit() || c == '(' || c == '-')
|
||||||
|
.unwrap_or(false)
|
||||||
|
|| query.starts_with('=')
|
||||||
|
|| MATH_FNS.iter().any(|f| q.starts_with(f))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) static MATH_CTX: LazyLock<evalexpr::HashMapContext<evalexpr::DefaultNumericTypes>> =
|
||||||
|
LazyLock::new(|| {
|
||||||
|
use evalexpr::*;
|
||||||
|
context_map! {
|
||||||
|
"pi" => float std::f64::consts::PI,
|
||||||
|
"e" => float std::f64::consts::E,
|
||||||
|
"sqrt" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.sqrt()))),
|
||||||
|
"sin" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.sin()))),
|
||||||
|
"cos" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.cos()))),
|
||||||
|
"tan" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.tan()))),
|
||||||
|
"asin" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.asin()))),
|
||||||
|
"acos" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.acos()))),
|
||||||
|
"atan" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.atan()))),
|
||||||
|
"ln" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.ln()))),
|
||||||
|
"log2" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.log2()))),
|
||||||
|
"log10" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.log10()))),
|
||||||
|
"exp" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.exp()))),
|
||||||
|
"abs" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.abs()))),
|
||||||
|
"ceil" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.ceil()))),
|
||||||
|
"floor" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.floor()))),
|
||||||
|
"round" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.round())))
|
||||||
|
}
|
||||||
|
.expect("static math context must be valid")
|
||||||
|
});
|
||||||
@@ -1,154 +1,4 @@
|
|||||||
use async_trait::async_trait;
|
mod eval;
|
||||||
use evalexpr::eval_number_with_context;
|
mod plugin;
|
||||||
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
|
||||||
use std::sync::LazyLock;
|
|
||||||
|
|
||||||
pub struct CalcPlugin;
|
pub use plugin::*;
|
||||||
|
|
||||||
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"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
59
crates/plugins/plugin-calc/src/plugin.rs
Normal file
59
crates/plugins/plugin-calc/src/plugin.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use evalexpr::eval_number_with_context;
|
||||||
|
use k_launcher_domain::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
||||||
|
|
||||||
|
use crate::eval::{MATH_CTX, should_eval, strip_numeric_separators};
|
||||||
|
|
||||||
|
const RESULT_ID: &str = "calc-result";
|
||||||
|
const RESULT_SCORE: u32 = 90;
|
||||||
|
|
||||||
|
pub struct CalcPlugin;
|
||||||
|
|
||||||
|
impl CalcPlugin {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CalcPlugin {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Plugin for CalcPlugin {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"calc"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||||
|
if !should_eval(query) {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
let raw = query.strip_prefix('=').unwrap_or(query);
|
||||||
|
let expr_owned = strip_numeric_separators(raw);
|
||||||
|
let expr = expr_owned.as_str();
|
||||||
|
match eval_number_with_context(expr, &*MATH_CTX) {
|
||||||
|
Ok(n) if n.is_finite() => {
|
||||||
|
let value_str = if n.fract() == 0.0 {
|
||||||
|
format!("{}", n as i64)
|
||||||
|
} else {
|
||||||
|
format!("{n}")
|
||||||
|
};
|
||||||
|
let display = format!("= {value_str}");
|
||||||
|
vec![SearchResult {
|
||||||
|
id: ResultId::new(RESULT_ID),
|
||||||
|
title: ResultTitle::new(display),
|
||||||
|
description: Some(Arc::from(format!("{expr_owned} · Enter to copy"))),
|
||||||
|
icon: None,
|
||||||
|
score: Score::new(RESULT_SCORE),
|
||||||
|
action: LaunchAction::CopyToClipboard(value_str),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
_ => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
crates/plugins/plugin-calc/tests/calc.rs
Normal file
53
crates/plugins/plugin-calc/tests/calc.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use k_launcher_domain::{LaunchAction, Plugin};
|
||||||
|
use plugin_calc::CalcPlugin;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn calc_valid_expr() {
|
||||||
|
let p = CalcPlugin::new();
|
||||||
|
let results = p.search("2+2").await;
|
||||||
|
assert_eq!(results[0].title.as_str(), "= 4");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn calc_non_numeric_returns_empty() {
|
||||||
|
let p = CalcPlugin::new();
|
||||||
|
assert!(p.search("firefox").await.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn calc_bad_expr_returns_empty() {
|
||||||
|
let p = CalcPlugin::new();
|
||||||
|
assert!(p.search("1/0").await.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn calc_sqrt() {
|
||||||
|
let p = CalcPlugin::new();
|
||||||
|
let results = p.search("sqrt(9)").await;
|
||||||
|
assert_eq!(results[0].title.as_str(), "= 3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn calc_sin_pi() {
|
||||||
|
let p = CalcPlugin::new();
|
||||||
|
let results = p.search("sin(pi)").await;
|
||||||
|
assert!(!results.is_empty());
|
||||||
|
let title = results[0].title.as_str();
|
||||||
|
let val: f64 = title.trim_start_matches("= ").parse().unwrap();
|
||||||
|
assert!(val.abs() < 1e-10, "sin(pi) should be near zero, got {val}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn calc_underscore_separator() {
|
||||||
|
let p = CalcPlugin::new();
|
||||||
|
let results = p.search("1_000 * 2").await;
|
||||||
|
assert_eq!(results[0].title.as_str(), "= 2000");
|
||||||
|
assert_eq!(
|
||||||
|
results[0].description.as_deref(),
|
||||||
|
Some("1000 * 2 · Enter to copy")
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
&results[0].action,
|
||||||
|
LaunchAction::CopyToClipboard(v) if v == "2000"
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "plugin-cmd"
|
name = "plugin-cmd"
|
||||||
version = "0.1.0"
|
version = "0.2.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -9,7 +9,8 @@ path = "src/lib.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
k-launcher-domain = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
k-launcher-domain = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
use k_launcher_domain::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
||||||
|
|
||||||
|
const CMD_PREFIX: char = '>';
|
||||||
|
const RESULT_SCORE: u32 = 95;
|
||||||
|
|
||||||
pub struct CmdPlugin;
|
pub struct CmdPlugin;
|
||||||
|
|
||||||
@@ -22,7 +25,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('>') else {
|
let Some(rest) = query.strip_prefix(CMD_PREFIX) else {
|
||||||
return vec![];
|
return vec![];
|
||||||
};
|
};
|
||||||
let cmd = rest.trim();
|
let cmd = rest.trim();
|
||||||
@@ -34,37 +37,8 @@ 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(95),
|
score: Score::new(RESULT_SCORE),
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
25
crates/plugins/plugin-cmd/tests/cmd.rs
Normal file
25
crates/plugins/plugin-cmd/tests/cmd.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
use k_launcher_domain::Plugin;
|
||||||
|
use plugin_cmd::CmdPlugin;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cmd_prefix_triggers() {
|
||||||
|
let p = CmdPlugin::new();
|
||||||
|
let results = p.search("> echo hello").await;
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].title.as_str(), "Run: echo hello");
|
||||||
|
assert_eq!(results[0].score.value(), 95);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cmd_empty_remainder_returns_empty() {
|
||||||
|
let p = CmdPlugin::new();
|
||||||
|
assert!(p.search(">").await.is_empty());
|
||||||
|
assert!(p.search("> ").await.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cmd_no_prefix_returns_empty() {
|
||||||
|
let p = CmdPlugin::new();
|
||||||
|
assert!(p.search("echo hello").await.is_empty());
|
||||||
|
assert!(p.search("firefox").await.is_empty());
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "plugin-files"
|
name = "plugin-files"
|
||||||
version = "0.1.0"
|
version = "0.2.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -9,5 +9,9 @@ path = "src/lib.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
k-launcher-domain = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
k-launcher-domain = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ 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_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
use k_launcher_domain::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
||||||
|
|
||||||
|
const MAX_FILE_RESULTS: usize = 20;
|
||||||
|
const RESULT_SCORE: u32 = 50;
|
||||||
|
|
||||||
pub struct FilesPlugin;
|
pub struct FilesPlugin;
|
||||||
|
|
||||||
@@ -70,46 +75,22 @@ 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(20)
|
.take(MAX_FILE_RESULTS)
|
||||||
.enumerate()
|
.map(|entry| {
|
||||||
.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 {
|
let title = if is_dir { format!("{name}/") } else { name };
|
||||||
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(format!("file-{i}")),
|
id: ResultId::new(&path_str),
|
||||||
title: ResultTitle::new(title),
|
title: ResultTitle::new(title),
|
||||||
description: Some(path_str.clone()),
|
description: Some(Arc::from(path_str.as_str())),
|
||||||
icon: None,
|
icon: None,
|
||||||
score: Score::new(50),
|
score: Score::new(RESULT_SCORE),
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
15
crates/plugins/plugin-files/tests/files.rs
Normal file
15
crates/plugins/plugin-files/tests/files.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
use k_launcher_domain::Plugin;
|
||||||
|
use plugin_files::FilesPlugin;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn files_ignores_non_path_query() {
|
||||||
|
let p = FilesPlugin::new();
|
||||||
|
assert!(p.search("firefox").await.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn files_handles_root() {
|
||||||
|
let p = FilesPlugin::new();
|
||||||
|
let results = p.search("/").await;
|
||||||
|
assert!(!results.is_empty());
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "plugin-url"
|
name = "plugin-url"
|
||||||
version = "0.1.0"
|
version = "0.2.1"
|
||||||
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"
|
||||||
@@ -10,3 +14,6 @@ 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 }
|
||||||
|
|||||||
60
crates/plugins/plugin-url/src/lib.rs
Normal file
60
crates/plugins/plugin-url/src/lib.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Query {
|
||||||
|
pub query: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Action {
|
||||||
|
pub r#type: &'static str,
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct UrlResult {
|
||||||
|
pub id: &'static str,
|
||||||
|
pub title: &'static str,
|
||||||
|
pub description: String,
|
||||||
|
pub score: u32,
|
||||||
|
pub action: Action,
|
||||||
|
}
|
||||||
|
|
||||||
|
const HTTP_PREFIX: &str = "http://";
|
||||||
|
const HTTPS_PREFIX: &str = "https://";
|
||||||
|
const WWW_PREFIX: &str = "www.";
|
||||||
|
const RESULT_ID: &str = "url-open";
|
||||||
|
const RESULT_TITLE: &str = "Open in Browser";
|
||||||
|
const RESULT_SCORE: u32 = 95;
|
||||||
|
const ACTION_TYPE: &str = "OpenPath";
|
||||||
|
|
||||||
|
pub fn is_url(query: &str) -> bool {
|
||||||
|
query.starts_with(HTTP_PREFIX)
|
||||||
|
|| query.starts_with(HTTPS_PREFIX)
|
||||||
|
|| query.starts_with(WWW_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize(query: &str) -> String {
|
||||||
|
if query.starts_with(WWW_PREFIX) {
|
||||||
|
format!("{HTTPS_PREFIX}{query}")
|
||||||
|
} else {
|
||||||
|
query.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn search(query: &str) -> Vec<UrlResult> {
|
||||||
|
if !is_url(query) {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
let url = normalize(query);
|
||||||
|
vec![UrlResult {
|
||||||
|
id: RESULT_ID,
|
||||||
|
title: RESULT_TITLE,
|
||||||
|
description: url.clone(),
|
||||||
|
score: RESULT_SCORE,
|
||||||
|
action: Action {
|
||||||
|
r#type: ACTION_TYPE,
|
||||||
|
path: url,
|
||||||
|
},
|
||||||
|
}]
|
||||||
|
}
|
||||||
@@ -1,55 +1,6 @@
|
|||||||
use std::io::{self, BufRead, Write};
|
use std::io::{self, BufRead, Write};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use plugin_url::{Query, search};
|
||||||
|
|
||||||
#[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();
|
||||||
@@ -68,63 +19,3 @@ 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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
56
crates/plugins/plugin-url/tests/url.rs
Normal file
56
crates/plugins/plugin-url/tests/url.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
use plugin_url::{is_url, normalize, search};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_url_https() {
|
||||||
|
assert!(is_url("https://example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_url_http() {
|
||||||
|
assert!(is_url("http://example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_url_www() {
|
||||||
|
assert!(is_url("www.foo.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_url_plain() {
|
||||||
|
assert!(!is_url("firefox"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_url_empty() {
|
||||||
|
assert!(!is_url(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_www() {
|
||||||
|
assert_eq!(normalize("www.foo.com"), "https://www.foo.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_https() {
|
||||||
|
assert_eq!(normalize("https://example.com"), "https://example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_returns_result() {
|
||||||
|
let results = search("https://example.com");
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].action.path, "https://example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_returns_empty() {
|
||||||
|
assert!(search("firefox").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn result_serializes() {
|
||||||
|
let results = search("https://example.com");
|
||||||
|
let json = serde_json::to_string(&results).unwrap();
|
||||||
|
assert!(json.contains("OpenPath"));
|
||||||
|
assert!(json.contains("https://example.com"));
|
||||||
|
}
|
||||||
@@ -2,50 +2,88 @@
|
|||||||
|
|
||||||
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. Create it manually if you want to customize behavior.
|
The file is optional — all fields have defaults and missing sections fall back to defaults automatically. If the file exists but has a parse error, a warning is logged and defaults are used.
|
||||||
|
|
||||||
## Full Annotated Example
|
See [config.example.toml](../config.example.toml) for a ready-to-copy template with all options.
|
||||||
|
|
||||||
```toml
|
## Sections
|
||||||
[window]
|
|
||||||
width = 600.0 # window width in logical pixels
|
|
||||||
height = 400.0 # window height in logical pixels
|
|
||||||
decorations = false # show window title bar / frame
|
|
||||||
transparent = true # allow background transparency
|
|
||||||
resizable = false # allow manual resizing
|
|
||||||
|
|
||||||
[appearance]
|
### [window]
|
||||||
# RGBA: r/g/b are 0–255 as floats, a is 0.0–1.0
|
|
||||||
background_rgba = [20.0, 20.0, 30.0, 0.9] # main background
|
|
||||||
border_rgba = [229.0, 125.0, 33.0, 1.0] # accent/border color
|
|
||||||
border_width = 1.0 # border thickness in pixels
|
|
||||||
border_radius = 8.0 # corner radius of the window
|
|
||||||
search_font_size = 18.0 # font size of the search input
|
|
||||||
title_size = 15.0 # font size of result titles
|
|
||||||
desc_size = 12.0 # font size of result descriptions
|
|
||||||
row_radius = 4.0 # corner radius of result rows
|
|
||||||
placeholder = "Search..." # search input placeholder text
|
|
||||||
|
|
||||||
[search]
|
| Field | Type | Default | Description |
|
||||||
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 |
|
||||||
|
|
||||||
[plugins]
|
### [appearance]
|
||||||
calc = true # math expression evaluator
|
|
||||||
cmd = true # shell command runner (> prefix)
|
|
||||||
files = true # filesystem browser (/ or ~/ prefix)
|
|
||||||
apps = true # XDG application launcher
|
|
||||||
|
|
||||||
# External (dynamic) plugins — repeat block for each plugin
|
| Field | Type | Default | Description |
|
||||||
[[plugins.external]]
|
|---|---|---|---|
|
||||||
name = "my-plugin" # display name / identifier
|
| `background_rgba` | [R,G,B,A] | `[20, 20, 30, 0.9]` | Main background color |
|
||||||
path = "/path/to/my-plugin" # path to executable
|
| `border_rgba` | [R,G,B,A] | `[229, 125, 33, 1.0]` | Border/accent color |
|
||||||
args = [] # optional extra arguments
|
| `border_width` | float | `1.0` | Border thickness |
|
||||||
```
|
| `border_radius` | float | `8.0` | Window corner radius |
|
||||||
|
| `search_font_size` | float | `18.0` | Search input font size |
|
||||||
|
| `title_size` | float | `15.0` | Result title font size |
|
||||||
|
| `desc_size` | float | `12.0` | Result description font size |
|
||||||
|
| `row_radius` | float | `4.0` | Result row corner radius |
|
||||||
|
| `placeholder` | string | `"Search apps, ..."` | Search input placeholder |
|
||||||
|
| `selected_row_rgba` | [R,G,B,A] | `[229, 125, 33, 1.0]` | Selected result background |
|
||||||
|
| `unselected_row_rgba` | [R,G,B,A] | `[255, 255, 255, 0.07]` | Unselected result background |
|
||||||
|
| `description_rgba` | [R,G,B,A] | `[210, 215, 230, 1.0]` | Description text color |
|
||||||
|
| `no_results_rgba` | [R,G,B,A] | `[180, 180, 200, 0.5]` | "No results" text color |
|
||||||
|
| `error_rgba` | [R,G,B,A] | `[255, 80, 80, 1.0]` | Error text color |
|
||||||
|
| `icon_size` | float | `24.0` | App icon size in pixels |
|
||||||
|
|
||||||
## RGBA Format
|
#### RGBA format
|
||||||
|
|
||||||
Colors use `[r, g, b, a]` arrays where:
|
Colors use `[R, G, B, A]` arrays where R/G/B are 0–255 (as floats) and A is 0.0–1.0 (opacity). Values are clamped to valid ranges.
|
||||||
- `r`, `g`, `b` — red, green, blue channels as floats **0.0–255.0**
|
|
||||||
- `a` — alpha (opacity) as a float **0.0–1.0**
|
|
||||||
|
|
||||||
Example — semi-transparent white: `[255.0, 255.0, 255.0, 0.5]`
|
### [search]
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `max_results` | integer | `8` | Maximum results shown |
|
||||||
|
| `debounce_ms` | integer | `50` | Milliseconds to wait after last keystroke before searching |
|
||||||
|
| `frecency_compact_threshold` | integer | `50` | Frecency log entries before compacting to snapshot |
|
||||||
|
|
||||||
|
### [plugins]
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `calc` | bool | `true` | Calculator plugin |
|
||||||
|
| `cmd` | bool | `true` | Shell command plugin |
|
||||||
|
| `files` | bool | `true` | File browser plugin |
|
||||||
|
| `apps` | bool | `true` | Application search plugin |
|
||||||
|
|
||||||
|
### [[plugins.external]]
|
||||||
|
|
||||||
|
Repeatable block for external plugins.
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `name` | string | required | Plugin display name |
|
||||||
|
| `path` | string | required | Path to plugin executable |
|
||||||
|
| `args` | string[] | `[]` | Arguments to pass |
|
||||||
|
| `timeout_secs` | integer | `5` | Search timeout per query |
|
||||||
|
|
||||||
|
### [logging]
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `max_log_files` | integer | `7` | Daily log files to keep |
|
||||||
|
|
||||||
|
Logs are stored in `~/.local/share/k-launcher/logs/`.
|
||||||
|
|
||||||
|
### [terminal]
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `cmd` | string | auto-detect | Terminal emulator for `>` commands |
|
||||||
|
|
||||||
|
If unset, detected from `$TERM_CMD`, `$TERMINAL`, or PATH (foot, kitty, alacritty, wezterm, konsole, xterm).
|
||||||
|
|
||||||
|
Example: `cmd = "kitty -e"`
|
||||||
|
|||||||
@@ -1,39 +1,61 @@
|
|||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
## Prerequisites
|
## Arch Linux (AUR)
|
||||||
|
|
||||||
|
```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 from Source
|
### Build and install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/GKaszewski/k-launcher
|
git clone https://github.com/GKaszewski/k-launcher
|
||||||
cd k-launcher
|
cd k-launcher
|
||||||
cargo build --release
|
make install
|
||||||
```
|
```
|
||||||
|
|
||||||
Binary location: `target/release/k-launcher`
|
This builds a release binary and copies it to `~/.local/bin/k-launcher`.
|
||||||
|
|
||||||
### Optional: install to PATH
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp target/release/k-launcher ~/.local/bin/
|
|
||||||
```
|
|
||||||
|
|
||||||
Ensure `~/.local/bin` is in your `$PATH`.
|
Ensure `~/.local/bin` is in your `$PATH`.
|
||||||
|
|
||||||
## Autostart
|
### Manual build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
cp target/release/k-launcher ~/.local/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compositor Keybind
|
||||||
|
|
||||||
### Hyprland
|
### Hyprland
|
||||||
|
|
||||||
Add to `~/.config/hypr/hyprland.conf`:
|
Add to `~/.config/hypr/hyprland.conf`:
|
||||||
|
|
||||||
```
|
```
|
||||||
exec-once = k-launcher
|
windowrule = float, ^(k-launcher)$
|
||||||
|
windowrule = center, ^(k-launcher)$
|
||||||
|
bind = SUPER, Space, exec, k-launcher
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Sway
|
||||||
|
|
||||||
|
Add to `~/.config/sway/config`:
|
||||||
|
|
||||||
|
```
|
||||||
|
for_window [app_id="k-launcher"] floating enable, move position center
|
||||||
|
bindsym Mod4+space exec k-launcher
|
||||||
|
```
|
||||||
|
|
||||||
|
## Autostart (optional)
|
||||||
|
|
||||||
### systemd user service
|
### systemd user service
|
||||||
|
|
||||||
Create `~/.config/systemd/user/k-launcher.service`:
|
Create `~/.config/systemd/user/k-launcher.service`:
|
||||||
@@ -55,3 +77,9 @@ Then enable it:
|
|||||||
```bash
|
```bash
|
||||||
systemctl --user enable --now k-launcher
|
systemctl --user enable --now k-launcher
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
k-launcher --version
|
||||||
|
```
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ 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 |
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ 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.
|
||||||
@@ -96,7 +98,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-kernel` as Rust crates compiled into the binary.
|
Built-in plugins implement the `Plugin` trait from `k-launcher-domain` as Rust crates compiled into the binary.
|
||||||
|
|
||||||
### 1. Create a new crate in the workspace
|
### 1. Create a new crate in the workspace
|
||||||
|
|
||||||
@@ -120,7 +122,7 @@ members = [
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
k-launcher-domain = { workspace = true }
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -129,8 +131,10 @@ 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_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
use k_launcher_domain::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
||||||
|
|
||||||
pub struct HelloPlugin;
|
pub struct HelloPlugin;
|
||||||
|
|
||||||
@@ -154,11 +158,10 @@ 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("A greeting from the hello plugin".to_string()),
|
description: Some(Arc::from("A greeting from the hello plugin")),
|
||||||
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,
|
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,11 +195,10 @@ 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<String>` | Secondary line shown below title |
|
| `description` | `Option<Arc<str>>` | Secondary line shown below title |
|
||||||
| `icon` | `Option<String>` | Icon name or path (currently unused in renderer) |
|
| `icon` | `Option<Arc<str>>` | 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
|
||||||
|
|
||||||
@@ -206,7 +208,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
168
man/k-launcher.1
Normal file
168
man/k-launcher.1
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
.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)
|
||||||
15
packaging/aur/.SRCINFO
Normal file
15
packaging/aur/.SRCINFO
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
pkgbase = k-launcher-bin
|
||||||
|
pkgdesc = GPU-accelerated command palette launcher for Linux (Wayland/X11)
|
||||||
|
pkgver = 0.1.0
|
||||||
|
pkgrel = 1
|
||||||
|
url = https://github.com/GKaszewski/k-launcher
|
||||||
|
arch = x86_64
|
||||||
|
license = MIT
|
||||||
|
depends = wayland
|
||||||
|
depends = libxkbcommon
|
||||||
|
provides = k-launcher
|
||||||
|
conflicts = k-launcher
|
||||||
|
source = k-launcher-0.1.0::https://github.com/GKaszewski/k-launcher/releases/download/v0.1.0/k-launcher
|
||||||
|
sha256sums = SKIP
|
||||||
|
|
||||||
|
pkgname = k-launcher-bin
|
||||||
17
packaging/aur/PKGBUILD
Normal file
17
packaging/aur/PKGBUILD
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Maintainer: k-launcher contributors
|
||||||
|
pkgname=k-launcher-bin
|
||||||
|
pkgver=0.1.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="GPU-accelerated command palette launcher for Linux (Wayland/X11)"
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://github.com/GKaszewski/k-launcher"
|
||||||
|
license=('MIT')
|
||||||
|
depends=('wayland' 'libxkbcommon')
|
||||||
|
provides=('k-launcher')
|
||||||
|
conflicts=('k-launcher')
|
||||||
|
source=("k-launcher-${pkgver}::https://github.com/GKaszewski/k-launcher/releases/download/v${pkgver}/k-launcher")
|
||||||
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
package() {
|
||||||
|
install -Dm755 "k-launcher-${pkgver}" "${pkgdir}/usr/bin/k-launcher"
|
||||||
|
}
|
||||||
11
packaging/systemd/k-launcher.service
Normal file
11
packaging/systemd/k-launcher.service
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=k-launcher command palette daemon
|
||||||
|
After=graphical-session.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/k-launcher
|
||||||
|
Restart=on-failure
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=graphical-session.target
|
||||||
8
packaging/systemd/k-launcher.socket
Normal file
8
packaging/systemd/k-launcher.socket
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=k-launcher IPC socket
|
||||||
|
|
||||||
|
[Socket]
|
||||||
|
ListenStream=%t/k-launcher.sock
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=sockets.target
|
||||||
Reference in New Issue
Block a user