v0.2.0
clean architecture refactor, performance, resilience, DX/UX architecture: - 13 crates with proper domain/application/infrastructure layers - domain crate: newtypes, ports (Plugin, AppLauncher), constants - kernel: pure orchestrator - shared UI state machine (k-launcher-ui-core) - merged plugin-api into domain as ports module - granular file structure (no monolithic lib.rs) - all tests extracted to tests/ directories features: - frecency boost in search results - empty query shows top frecent apps - append-only frecency log with configurable compaction - config-driven styling (all colors, sizes, debounce) - configurable terminal emulator, external plugin timeout - log rotation with max_log_files - loading indicator, descriptive placeholder text - graceful shutdown via iced::exit() + Plugin::shutdown() - --version flag, panic hook, signal handling (SIGINT/SIGTERM) - SpawnInTerminal in external plugin protocol performance: - ~1500 -> ~50 heap allocs per keystroke - reused Matcher, Pattern, char buffer across entries - Arc<str> for shared result fields - pre-filter before fuzzy matching - partial sort for top frecent IDs - cached lowercase names in entries resilience: - parking_lot (no mutex poisoning) - thiserror hierarchy (PluginError, ConfigError, AppError) - all silent error swallowing replaced with tracing::warn - config parse errors logged quality: - named constants (no magic strings/numbers) - named types (no anonymous tuples) - Rgba newtype with validation - domain newtype validation (debug_assert non-empty) - man page, LICENSE (MIT), PKGBUILD, example config - plugin development guide updated - make check (fmt + clippy + test), make dev (RUST_LOG=debug) style: format code for better readability in tests and function signatures fix: update build_entries function signature to ignore frecency parameter fix(review): bugs, arch violations, design smells P1 bugs: - unix_launcher: shell_split respects quoted args (was split_whitespace) - plugin-host: 5s timeout on external plugin search - ui: handle engine init panic, wire error state - ui-egui: read window config instead of always using defaults - plugin-url: use OpenPath action instead of SpawnProcess+xdg-open Architecture: - remove WindowConfig (mirror of WindowCfg); use WindowCfg directly - remove on_select closure from SearchResult (domain leakage) - remove LaunchAction::Custom; add Plugin::on_selected + SearchEngine::on_selected - apps: record frecency via on_selected instead of embedded closure Design smells: - frecency: extract decay_factor helper, write outside mutex - apps: remove cfg(test) cache_path hack; add new_for_test ctor - apps: stable ResultId using name+exec to prevent collision - files: stable ResultId using full path instead of index - plugin-host: remove k-launcher-os-bridge dep (WindowConfig gone) Update iced dependency in Cargo.toml to disable default features and add additional ones feat(app): enhance engine initialization with EngineHandle and update run function signature feat: production hardening (panic isolation, file logging, apps cache) - Kernel::search wraps each plugin in catch_unwind; panics are logged and return [] - init_logging() adds daily rolling file at ~/.local/share/k-launcher/logs/ - AppsPlugin caches entries to ~/.cache/k-launcher/apps.bin via bincode; stale-while-revalidate on subsequent launches - 57 tests pass refactor: remove client module and associated show command logic fix(app): format code for clarity in update function chore: update .gitignore and enhance README with compositor setup instructions chore(docs): remove unused screenshot file feature/prod-ready (#1) Reviewed-on: #1 fix(calc): remove ambiguous log alias, use ln/log2/log10 explicitly fix(calc): fix log/ln naming, cache math context, strengthen sin(pi) test feat(calc): add math functions (sqrt, sin, cos, etc.) and pi/e constants refactor(calc): rename preprocess, extend underscore test assertions feat(calc): strip underscore digit separators feat: update dependencies for improved compatibility and performance feat: add plugin-url for URL handling and open in browser functionality feat: add support for external plugins and enhance plugin management feat: add Makefile for build, run, and installation commands feat: add required features for k-launcher-egui and update dependencies feat: update README and add documentation for installation, configuration, usage, and plugin development feat: enhance configuration management and UI styling, remove unused theme module feat: add k-launcher-config crate for configuration management and integrate with existing components feat: add k-launcher-ui-egui crate for enhanced UI - Introduced a new crate `k-launcher-ui-egui` to provide a graphical user interface using eframe and egui. - Updated the workspace configuration in `Cargo.toml` to include the new crate. - Implemented the main application logic in `src/app.rs`, handling search functionality and user interactions. - Created a library entry point in `src/lib.rs` to expose the `run` function for launching the UI. - Modified the `k-launcher` crate to include a new binary target for the egui-based launcher. - Added a new main file `src/main_egui.rs` to initialize and run the egui UI with the existing kernel and launcher components. feat: implement OS bridge and enhance app launcher functionality feat: add FilesPlugin for file searching and integrate into KLauncher feat: implement frecency tracking for app usage and enhance search functionality feat: add CmdPlugin for executing terminal commands and update workspace configuration refactor: update dependencies and improve keyboard event handling in KLauncherApp refactor: simplify theme usage and enhance AppsPlugin structure feat: restructure k-launcher workspace and add core functionality - Updated Cargo.toml to include a new k-launcher crate and reorganized workspace members. - Introduced a README.md file detailing the project philosophy, architecture, and technical specifications. - Implemented a new Kernel struct in k-launcher-kernel for managing plugins and search functionality. - Created a Plugin trait for plugins to implement, allowing for asynchronous search operations. - Developed k-launcher-ui with an Iced-based UI for user interaction, including search input and result display. - Added AppsPlugin and CalcPlugin to handle application launching and basic calculations, respectively. - Established a theme module for UI styling, focusing on an Aero aesthetic. - Removed unnecessary main.rs files from plugin crates, streamlining the project structure. Initialize k-launcher project structure with multiple crates and basic configurations
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
target/
|
||||
.worktrees/
|
||||
docs/superpowers/
|
||||
.superpowers/
|
||||
|
||||
110
ARCHITECTURE.md
110
ARCHITECTURE.md
@@ -1,110 +0,0 @@
|
||||
# k-launcher Architecture
|
||||
|
||||
## Philosophy
|
||||
|
||||
- **TDD:** Red-Green-Refactor is mandatory. No functional code without a failing test first.
|
||||
- **Clean Architecture:** Strict layer separation — Domain, Application, Infrastructure, Main.
|
||||
- **Newtype Pattern:** All domain primitives wrapped (e.g. `struct Score(f64)`).
|
||||
- **Small Traits / ISP:** Many focused traits over one "God" trait.
|
||||
- **No Cyclic Dependencies:** Use IoC (define traits in higher-level modules, implement in lower-level).
|
||||
|
||||
---
|
||||
|
||||
## Workspace Structure
|
||||
|
||||
| Crate | Layer | Responsibility |
|
||||
|---|---|---|
|
||||
| `k-launcher-kernel` | Domain + Application | Newtypes (`ResultId`, `ResultTitle`, `Score`), `Plugin` trait, `SearchEngine` trait, `AppLauncher` port, `Kernel` use case |
|
||||
| `k-launcher-config` | Infrastructure | TOML config loading; `Config`, `WindowCfg`, `AppearanceCfg`, `PluginsCfg` structs |
|
||||
| `k-launcher-os-bridge` | Infrastructure | `UnixAppLauncher` (process spawning), `WindowConfig` adapter |
|
||||
| `k-launcher-plugin-host` | Infrastructure | `ExternalPlugin` — JSON-newline IPC protocol for out-of-process plugins |
|
||||
| `k-launcher-ui` | Infrastructure | iced 0.14 Elm-like UI (`KLauncherApp`, debounced async search, keyboard nav) |
|
||||
| `k-launcher-ui-egui` | Infrastructure | Alternative egui UI (feature-gated) |
|
||||
| `plugins/plugin-apps` | Infrastructure | XDG `.desktop` parser, frecency scoring, nucleo fuzzy matching |
|
||||
| `plugins/plugin-calc` | Infrastructure | `evalexpr`-based calculator |
|
||||
| `plugins/plugin-cmd` | Infrastructure | Shell command runner |
|
||||
| `plugins/plugin-files` | Infrastructure | File path search |
|
||||
| `plugins/plugin-url` | Infrastructure | URL opener |
|
||||
| `k-launcher` | Main/Entry | DI wiring, CLI arg parsing (`show` command), `run_ui()` composition root |
|
||||
|
||||
---
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
k-launcher (main)
|
||||
├── k-launcher-kernel (Domain/Application)
|
||||
├── k-launcher-config (Infrastructure — pure data, no kernel dep)
|
||||
├── k-launcher-os-bridge (Infrastructure)
|
||||
├── k-launcher-plugin-host (Infrastructure)
|
||||
├── k-launcher-ui (Infrastructure)
|
||||
└── plugins/* (Infrastructure)
|
||||
└── k-launcher-kernel
|
||||
```
|
||||
|
||||
All arrows point inward toward the kernel. The kernel has no external dependencies.
|
||||
|
||||
---
|
||||
|
||||
## Core Abstractions (kernel)
|
||||
|
||||
```rust
|
||||
// Plugin trait — implemented by every plugin
|
||||
async fn search(&self, query: &str) -> Vec<SearchResult>;
|
||||
|
||||
// SearchEngine trait — implemented by Kernel
|
||||
async fn search(&self, query: &str) -> Vec<SearchResult>;
|
||||
|
||||
// AppLauncher port — implemented by UnixAppLauncher in os-bridge
|
||||
fn execute(&self, action: &LaunchAction);
|
||||
|
||||
// DesktopEntrySource trait (plugin-apps) — swappable .desktop file source
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plugin System
|
||||
|
||||
Two kinds of plugins:
|
||||
|
||||
1. **In-process** — implement `Plugin` in Rust, linked at compile time.
|
||||
- `plugin-calc`, `plugin-apps`, `plugin-cmd`, `plugin-files`, `plugin-url`
|
||||
|
||||
2. **External / out-of-process** — `ExternalPlugin` in `k-launcher-plugin-host` communicates via JSON newline protocol over stdin/stdout.
|
||||
- Query: `{"query": "..."}`
|
||||
- Response: `[{"id": "...", "title": "...", "score": 1.0, "description": "...", "icon": "...", "action": "..."}]`
|
||||
|
||||
Plugins are enabled/disabled via `~/.config/k-launcher/config.toml`.
|
||||
|
||||
---
|
||||
|
||||
## Kernel (Application Use Case)
|
||||
|
||||
`Kernel::search` fans out to all registered plugins concurrently via `join_all`, merges results, sorts by `Score` descending, truncates to `max_results`.
|
||||
|
||||
---
|
||||
|
||||
## UI Architecture (iced 0.14 — Elm model)
|
||||
|
||||
- **State:** `KLauncherApp` holds engine ref, launcher ref, query string, results, selected index, appearance config.
|
||||
- **Messages:** `QueryChanged`, `ResultsReady`, `KeyPressed`
|
||||
- **Update:**
|
||||
- `QueryChanged` → spawns debounced async task (50 ms) → `ResultsReady`
|
||||
- Epoch guard prevents stale results from out-of-order responses
|
||||
- **View:** search bar + scrollable result list with icon support (SVG/raster)
|
||||
- **Subscription:** keyboard events — `Esc` = quit, `Enter` = launch, arrows = navigate
|
||||
- **Window:** transparent, undecorated, centered (Wayland-compatible)
|
||||
|
||||
---
|
||||
|
||||
## Frecency (plugin-apps)
|
||||
|
||||
`FrecencyStore` records app launches by ID. On empty query, returns top-5 frecent apps instead of search results.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
`~/.config/k-launcher/config.toml` — sections: `[window]`, `[appearance]`, `[search]`, `[plugins]`.
|
||||
|
||||
All fields have sane defaults; a missing file yields defaults without error.
|
||||
12
CLAUDE.md
12
CLAUDE.md
@@ -39,12 +39,12 @@
|
||||
|
||||
## 4. Layer Definitions
|
||||
|
||||
| Layer | Responsibility | Allowed Dependencies |
|
||||
| ------------------ | --------------------------------------------- | -------------------- |
|
||||
| **Domain** | Pure Business Logic, Entities, Value Objects. | None (Pure Rust) |
|
||||
| **Application** | Use Cases, Orchestration, Trait definitions. | Domain |
|
||||
| **Infrastructure** | Trait implementations (DB, HTTP clients). | Domain, Application |
|
||||
| **Main/API** | Entry point, Wire-up/DI, Routing. | All of the above |
|
||||
| Layer | Crates | Responsibility | Allowed Dependencies |
|
||||
| ------------------ | ----------------------------------------------- | --------------------------------------------- | -------------------- |
|
||||
| **Domain** | `k-launcher-domain` | Pure value types, newtypes, constants, port traits (Plugin, AppLauncher). | None (Pure Rust + serde + async-trait) |
|
||||
| **Application** | `k-launcher-kernel` | Kernel orchestrator. | Domain |
|
||||
| **Infrastructure** | `k-launcher-ui`, `k-launcher-ui-egui`, `k-launcher-ui-core`, `k-launcher-os-bridge`, `k-launcher-plugin-host`, `k-launcher-config`, all `plugin-*` crates | Trait implementations, UI, config, plugins. | Domain, Application |
|
||||
| **Main** | `k-launcher` | Entry point, DI wiring, logging. | All of the above |
|
||||
|
||||
---
|
||||
|
||||
|
||||
135
Cargo.lock
generated
135
Cargo.lock
generated
@@ -337,6 +337,15 @@ dependencies = [
|
||||
"objc2 0.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
||||
dependencies = [
|
||||
"objc2 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "built"
|
||||
version = "0.8.0"
|
||||
@@ -714,6 +723,17 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e162d0c2e2068eb736b71e5597eff0b9944e6b973cd9f37b6a288ab9bf20e300"
|
||||
|
||||
[[package]]
|
||||
name = "ctrlc"
|
||||
version = "3.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"nix 0.31.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
@@ -769,6 +789,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2 0.6.2",
|
||||
"libc",
|
||||
"objc2 0.6.4",
|
||||
]
|
||||
|
||||
@@ -1102,7 +1124,7 @@ version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75ae8b5984a4863d8a32109a848d038bd6d914f20f010cc141375f7a183c41cf"
|
||||
dependencies = [
|
||||
"nix",
|
||||
"nix 0.29.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2038,11 +2060,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "k-launcher"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"ctrlc",
|
||||
"dirs",
|
||||
"iced",
|
||||
"k-launcher-config",
|
||||
"k-launcher-domain",
|
||||
"k-launcher-kernel",
|
||||
"k-launcher-os-bridge",
|
||||
"k-launcher-plugin-host",
|
||||
@@ -2052,6 +2075,7 @@ dependencies = [
|
||||
"plugin-calc",
|
||||
"plugin-cmd",
|
||||
"plugin-files",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
@@ -2060,19 +2084,33 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "k-launcher-config"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"k-launcher-domain",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"toml",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "k-launcher-domain"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "k-launcher-kernel"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures",
|
||||
"k-launcher-domain",
|
||||
"plugin-calc",
|
||||
"plugin-cmd",
|
||||
"serde",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -2080,44 +2118,60 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "k-launcher-os-bridge"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"k-launcher-kernel",
|
||||
"k-launcher-domain",
|
||||
"libc",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "k-launcher-plugin-host"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"k-launcher-kernel",
|
||||
"k-launcher-domain",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "k-launcher-ui"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"iced",
|
||||
"k-launcher-config",
|
||||
"k-launcher-domain",
|
||||
"k-launcher-kernel",
|
||||
"k-launcher-os-bridge",
|
||||
"k-launcher-ui-core",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "k-launcher-ui-core"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"k-launcher-config",
|
||||
"k-launcher-domain",
|
||||
"k-launcher-kernel",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "k-launcher-ui-egui"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"k-launcher-config",
|
||||
"k-launcher-domain",
|
||||
"k-launcher-kernel",
|
||||
"k-launcher-ui-core",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2538,6 +2592,18 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nohash-hasher"
|
||||
version = "0.2.0"
|
||||
@@ -2708,7 +2774,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"libc",
|
||||
"objc2 0.5.2",
|
||||
"objc2-core-data",
|
||||
@@ -2737,7 +2803,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-core-location",
|
||||
"objc2-foundation 0.2.2",
|
||||
@@ -2749,7 +2815,7 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
@@ -2761,7 +2827,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
@@ -2796,7 +2862,7 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
"objc2-metal",
|
||||
@@ -2808,7 +2874,7 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-contacts",
|
||||
"objc2-foundation 0.2.2",
|
||||
@@ -2827,7 +2893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"dispatch",
|
||||
"libc",
|
||||
"objc2 0.5.2",
|
||||
@@ -2861,7 +2927,7 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
@@ -2874,7 +2940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
@@ -2886,7 +2952,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
"objc2-metal",
|
||||
@@ -2921,7 +2987,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data",
|
||||
@@ -2941,7 +3007,7 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
@@ -2953,7 +3019,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-core-location",
|
||||
"objc2-foundation 0.2.2",
|
||||
@@ -3102,14 +3168,15 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "plugin-apps"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bincode",
|
||||
"dirs",
|
||||
"k-launcher-kernel",
|
||||
"k-launcher-domain",
|
||||
"linicon",
|
||||
"nucleo-matcher",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -3119,35 +3186,35 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "plugin-calc"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"evalexpr",
|
||||
"k-launcher-kernel",
|
||||
"k-launcher-domain",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plugin-cmd"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"k-launcher-kernel",
|
||||
"k-launcher-domain",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plugin-files"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"k-launcher-kernel",
|
||||
"k-launcher-domain",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plugin-url"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5300,7 +5367,7 @@ dependencies = [
|
||||
"android-activity",
|
||||
"atomic-waker",
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"bytemuck",
|
||||
"calloop 0.13.0",
|
||||
"cfg_aliases",
|
||||
|
||||
26
Cargo.toml
26
Cargo.toml
@@ -2,10 +2,12 @@
|
||||
members = [
|
||||
"crates/k-launcher",
|
||||
"crates/k-launcher-config",
|
||||
"crates/k-launcher-domain",
|
||||
"crates/k-launcher-kernel",
|
||||
"crates/k-launcher-os-bridge",
|
||||
"crates/k-launcher-plugin-host",
|
||||
"crates/k-launcher-ui",
|
||||
"crates/k-launcher-ui-core",
|
||||
"crates/plugins/plugin-apps",
|
||||
"crates/plugins/plugin-calc",
|
||||
"crates/plugins/plugin-cmd",
|
||||
@@ -16,10 +18,12 @@ members = [
|
||||
default-members = [
|
||||
"crates/k-launcher",
|
||||
"crates/k-launcher-config",
|
||||
"crates/k-launcher-domain",
|
||||
"crates/k-launcher-kernel",
|
||||
"crates/k-launcher-os-bridge",
|
||||
"crates/k-launcher-plugin-host",
|
||||
"crates/k-launcher-ui",
|
||||
"crates/k-launcher-ui-core",
|
||||
"crates/plugins/plugin-apps",
|
||||
"crates/plugins/plugin-calc",
|
||||
"crates/plugins/plugin-cmd",
|
||||
@@ -29,13 +33,27 @@ default-members = [
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
k-launcher = { path = "crates/k-launcher" }
|
||||
k-launcher-config = { path = "crates/k-launcher-config" }
|
||||
k-launcher-domain = { path = "crates/k-launcher-domain" }
|
||||
k-launcher-kernel = { path = "crates/k-launcher-kernel" }
|
||||
k-launcher-os-bridge = { path = "crates/k-launcher-os-bridge" }
|
||||
k-launcher-plugin-host = { path = "crates/k-launcher-plugin-host" }
|
||||
k-launcher-ui = { path = "crates/k-launcher-ui" }
|
||||
k-launcher-ui-core = { path = "crates/k-launcher-ui-core" }
|
||||
k-launcher-ui-egui = { path = "crates/k-launcher-ui-egui" }
|
||||
plugin-apps = { path = "crates/plugins/plugin-apps" }
|
||||
plugin-calc = { path = "crates/plugins/plugin-calc" }
|
||||
plugin-cmd = { path = "crates/plugins/plugin-cmd" }
|
||||
plugin-files = { path = "crates/plugins/plugin-files" }
|
||||
plugin-url = { path = "crates/plugins/plugin-url" }
|
||||
|
||||
async-trait = "0.1"
|
||||
bincode = { version = "2", features = ["serde"] }
|
||||
dirs = "6.0"
|
||||
futures = "0.3"
|
||||
iced = { version = "0.14", default-features = false, features = ["image", "svg", "tokio", "tiny-skia", "wayland", "x11", "crisp", "web-colors", "thread-pool"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1.35", features = ["rt-multi-thread", "macros"] }
|
||||
toml = "1.0"
|
||||
thiserror = "2"
|
||||
parking_lot = "0.12"
|
||||
tracing = "0.1"
|
||||
ctrlc = "3"
|
||||
|
||||
21
LICENSE
Normal file
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
|
||||
EGUI_BIN := target/release/k-launcher-egui
|
||||
@@ -11,23 +11,19 @@ build-egui:
|
||||
cargo build --release -p k-launcher --features egui --bin k-launcher-egui
|
||||
|
||||
dev:
|
||||
cargo build
|
||||
RUST_LOG=debug cargo run
|
||||
|
||||
check:
|
||||
cargo check --workspace
|
||||
|
||||
clippy:
|
||||
cargo clippy --workspace -- -D warnings
|
||||
|
||||
fmt:
|
||||
cargo fmt --all
|
||||
|
||||
fmt-check:
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace -- -D warnings
|
||||
cargo test --workspace
|
||||
|
||||
test:
|
||||
cargo test --workspace
|
||||
|
||||
fmt:
|
||||
cargo fmt --all
|
||||
|
||||
run:
|
||||
cargo run --release
|
||||
|
||||
|
||||
69
README.md
69
README.md
@@ -1,28 +1,53 @@
|
||||
# k-launcher
|
||||
|
||||
A lightweight, GPU-accelerated command palette for Linux (Wayland/X11). Zero Electron — every pixel rendered via WGPU. Async search that never blocks the UI.
|
||||
A lightweight command palette for Linux (Wayland/X11). Fuzzy search, frecency ranking, plugin system. Written in Rust.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/GKaszewski/k-launcher
|
||||
cd k-launcher
|
||||
cargo build --release
|
||||
./target/release/k-launcher
|
||||
make install
|
||||
```
|
||||
|
||||
Or with cargo directly:
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
cp target/release/k-launcher ~/.local/bin/
|
||||
```
|
||||
|
||||
### Arch Linux (AUR)
|
||||
|
||||
```bash
|
||||
yay -S k-launcher
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
| Input | What it does | Example |
|
||||
|---|---|---|
|
||||
| any text | Fuzzy-search installed apps | `firefox` |
|
||||
| empty | Show most-used apps (frecency) | |
|
||||
| `>` prefix | Run shell command in terminal | `> htop` |
|
||||
| `=` or math | Evaluate expression, copy result | `2^10 + 5` |
|
||||
| `/` or `~/` | Browse filesystem | `~/Documents` |
|
||||
|
||||
## Keybinds
|
||||
|
||||
| Key | Action |
|
||||
| --------- | --------------- |
|
||||
| Type | Filter results |
|
||||
| `↑` / `↓` | Navigate |
|
||||
| `Enter` | Launch selected |
|
||||
|---|---|
|
||||
| `↑` / `↓` | Navigate results |
|
||||
| `Enter` | Launch / copy |
|
||||
| `Escape` | Close |
|
||||
|
||||
## Compositor Setup
|
||||
## Configuration
|
||||
|
||||
k-launcher uses a normal window; configure your compositor to float it.
|
||||
`~/.config/k-launcher/config.toml` — all fields optional, sensible defaults.
|
||||
|
||||
See [config.example.toml](config.example.toml) for all available options.
|
||||
|
||||
## Compositor Setup
|
||||
|
||||
**Hyprland** (`~/.config/hypr/hyprland.conf`):
|
||||
|
||||
@@ -39,24 +64,15 @@ for_window [app_id="k-launcher"] floating enable, move position center
|
||||
bindsym Mod4+space exec k-launcher
|
||||
```
|
||||
|
||||
## Built-in Plugins
|
||||
## Plugins
|
||||
|
||||
| Trigger | Plugin | Example |
|
||||
| ----------------- | ------ | -------------- |
|
||||
| (any text) | Apps | `firefox` |
|
||||
| number/expression | Calc | `2^10 + 5` |
|
||||
| `>` prefix | Shell | `> echo hello` |
|
||||
| `/` or `~/` | Files | `~/Documents` |
|
||||
|
||||
## External Plugins
|
||||
|
||||
Drop in community plugins — any language, no recompilation. Plugins are executables that communicate over stdin/stdout JSON:
|
||||
Built-in plugins (calc, apps, shell, files) are enabled by default. External plugins communicate via JSON over stdin/stdout — any language, no recompilation:
|
||||
|
||||
```toml
|
||||
# ~/.config/k-launcher/config.toml
|
||||
[[plugins.external]]
|
||||
name = "my-plugin"
|
||||
path = "/usr/lib/k-launcher/plugins/my-plugin"
|
||||
path = "/path/to/plugin"
|
||||
timeout_secs = 5
|
||||
```
|
||||
|
||||
See [Plugin Development](docs/plugin-development.md) for the full protocol.
|
||||
@@ -64,6 +80,11 @@ See [Plugin Development](docs/plugin-development.md) for the full protocol.
|
||||
## Docs
|
||||
|
||||
- [Installation](docs/install.md)
|
||||
- [Usage & Keybinds](docs/usage.md)
|
||||
- [Configuration & Theming](docs/configuration.md)
|
||||
- [Usage](docs/usage.md)
|
||||
- [Configuration](docs/configuration.md)
|
||||
- [Plugin Development](docs/plugin-development.md)
|
||||
- `man k-launcher`
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
52
config.example.toml
Normal file
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.0
|
||||
pkgrel=1
|
||||
pkgdesc='Wayland command palette launcher with fuzzy search, frecency, and plugin support'
|
||||
arch=('x86_64')
|
||||
url='https://github.com/GKaszewski/k-launcher'
|
||||
license=('MIT')
|
||||
depends=('gcc-libs')
|
||||
makedepends=('cargo' 'git')
|
||||
optdepends=(
|
||||
'wl-clipboard: clipboard support on Wayland'
|
||||
'xclip: clipboard support on X11'
|
||||
'xdg-utils: open files and URLs'
|
||||
)
|
||||
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
prepare() {
|
||||
cd "$pkgname-$pkgver"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "$pkgname-$pkgver"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo build --frozen --release -p k-launcher
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$pkgname-$pkgver"
|
||||
install -Dm755 "target/release/k-launcher" "$pkgdir/usr/bin/k-launcher"
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
install -Dm644 "man/k-launcher.1" "$pkgdir/usr/share/man/man1/k-launcher.1"
|
||||
gzip -9 "$pkgdir/usr/share/man/man1/k-launcher.1"
|
||||
install -Dm644 config.example.toml "$pkgdir/usr/share/doc/$pkgname/config.example.toml"
|
||||
install -Dm644 docs/plugin-development.md "$pkgdir/usr/share/doc/$pkgname/plugin-development.md"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "k-launcher-config"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
@@ -9,5 +9,11 @@ path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
dirs = { workspace = true }
|
||||
k-launcher-domain = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
toml = "1.0"
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
toml = "1.0"
|
||||
|
||||
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,191 +1,8 @@
|
||||
use serde::Deserialize;
|
||||
mod config;
|
||||
pub mod error;
|
||||
mod load;
|
||||
mod types;
|
||||
|
||||
// RGBA: [r, g, b, a] where r/g/b are 0–255 as f32, a is 0.0–1.0
|
||||
pub type Rgba = [f32; 4];
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub window: WindowCfg,
|
||||
pub appearance: AppearanceCfg,
|
||||
pub search: SearchCfg,
|
||||
pub plugins: PluginsCfg,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct WindowCfg {
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
pub decorations: bool,
|
||||
pub transparent: bool,
|
||||
pub resizable: bool,
|
||||
}
|
||||
|
||||
impl Default for WindowCfg {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
width: 600.0,
|
||||
height: 400.0,
|
||||
decorations: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AppearanceCfg {
|
||||
pub background_rgba: Rgba,
|
||||
pub border_rgba: Rgba,
|
||||
pub border_width: f32,
|
||||
pub border_radius: f32,
|
||||
pub search_font_size: f32,
|
||||
pub title_size: f32,
|
||||
pub desc_size: f32,
|
||||
pub row_radius: f32,
|
||||
pub placeholder: String,
|
||||
}
|
||||
|
||||
impl Default for AppearanceCfg {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
background_rgba: [20.0, 20.0, 30.0, 0.9],
|
||||
border_rgba: [229.0, 125.0, 33.0, 1.0],
|
||||
border_width: 1.0,
|
||||
border_radius: 8.0,
|
||||
search_font_size: 18.0,
|
||||
title_size: 15.0,
|
||||
desc_size: 12.0,
|
||||
row_radius: 4.0,
|
||||
placeholder: "Search...".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SearchCfg {
|
||||
pub max_results: usize,
|
||||
}
|
||||
|
||||
impl Default for SearchCfg {
|
||||
fn default() -> Self {
|
||||
Self { max_results: 8 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct ExternalPluginCfg {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct PluginsCfg {
|
||||
pub calc: bool,
|
||||
pub cmd: bool,
|
||||
pub files: bool,
|
||||
pub apps: bool,
|
||||
pub external: Vec<ExternalPluginCfg>,
|
||||
}
|
||||
|
||||
impl Default for PluginsCfg {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
calc: true,
|
||||
cmd: true,
|
||||
files: true,
|
||||
apps: true,
|
||||
external: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load() -> Config {
|
||||
let path = dirs::config_dir().map(|d| d.join("k-launcher").join("config.toml"));
|
||||
let Some(path) = path else {
|
||||
return Config::default();
|
||||
};
|
||||
let Ok(content) = std::fs::read_to_string(&path) else {
|
||||
return Config::default();
|
||||
};
|
||||
toml::from_str(&content).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_config_has_sane_values() {
|
||||
let cfg = Config::default();
|
||||
assert_eq!(cfg.search.max_results, 8);
|
||||
assert_eq!(cfg.window.width, 600.0);
|
||||
assert_eq!(cfg.window.height, 400.0);
|
||||
assert!(!cfg.window.decorations);
|
||||
assert!(cfg.window.transparent);
|
||||
assert!(!cfg.window.resizable);
|
||||
assert!(cfg.plugins.calc);
|
||||
assert!(cfg.plugins.apps);
|
||||
assert_eq!(cfg.appearance.search_font_size, 18.0);
|
||||
assert_eq!(cfg.appearance.placeholder, "Search...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_partial_toml_uses_defaults() {
|
||||
let toml = "[search]\nmax_results = 5\n";
|
||||
let cfg: Config = toml::from_str(toml).unwrap();
|
||||
assert_eq!(cfg.search.max_results, 5);
|
||||
assert_eq!(cfg.window.width, 600.0);
|
||||
assert_eq!(cfg.appearance.search_font_size, 18.0);
|
||||
assert!(cfg.plugins.apps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_full_toml_roundtrip() {
|
||||
let toml = r#"
|
||||
[window]
|
||||
width = 800.0
|
||||
height = 500.0
|
||||
decorations = true
|
||||
transparent = false
|
||||
resizable = true
|
||||
|
||||
[appearance]
|
||||
background_rgba = [10.0, 10.0, 20.0, 0.8]
|
||||
border_rgba = [100.0, 200.0, 255.0, 1.0]
|
||||
border_width = 2.0
|
||||
border_radius = 12.0
|
||||
search_font_size = 20.0
|
||||
title_size = 16.0
|
||||
desc_size = 13.0
|
||||
row_radius = 6.0
|
||||
placeholder = "Type here..."
|
||||
|
||||
[search]
|
||||
max_results = 12
|
||||
|
||||
[plugins]
|
||||
calc = false
|
||||
cmd = true
|
||||
files = false
|
||||
apps = true
|
||||
"#;
|
||||
let cfg: Config = toml::from_str(toml).unwrap();
|
||||
assert_eq!(cfg.window.width, 800.0);
|
||||
assert_eq!(cfg.window.height, 500.0);
|
||||
assert!(cfg.window.decorations);
|
||||
assert!(!cfg.window.transparent);
|
||||
assert_eq!(cfg.appearance.background_rgba, [10.0, 10.0, 20.0, 0.8]);
|
||||
assert_eq!(cfg.appearance.search_font_size, 20.0);
|
||||
assert_eq!(cfg.appearance.placeholder, "Type here...");
|
||||
assert_eq!(cfg.search.max_results, 12);
|
||||
assert!(!cfg.plugins.calc);
|
||||
assert!(!cfg.plugins.files);
|
||||
}
|
||||
}
|
||||
pub use config::*;
|
||||
pub use load::*;
|
||||
pub use types::*;
|
||||
|
||||
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 })
|
||||
}
|
||||
200
crates/k-launcher-config/src/types.rs
Normal file
200
crates/k-launcher-config/src/types.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Rgba {
|
||||
red: f32,
|
||||
green: f32,
|
||||
blue: f32,
|
||||
alpha: f32,
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for Rgba {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let [red, green, blue, alpha] = <[f32; 4]>::deserialize(deserializer)?;
|
||||
Ok(Self::new(red, green, blue, alpha))
|
||||
}
|
||||
}
|
||||
|
||||
impl Rgba {
|
||||
pub fn new(red: f32, green: f32, blue: f32, alpha: f32) -> Self {
|
||||
Self {
|
||||
red: red.clamp(0.0, 255.0),
|
||||
green: green.clamp(0.0, 255.0),
|
||||
blue: blue.clamp(0.0, 255.0),
|
||||
alpha: alpha.clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn red(&self) -> f32 {
|
||||
self.red
|
||||
}
|
||||
|
||||
pub fn green(&self) -> f32 {
|
||||
self.green
|
||||
}
|
||||
|
||||
pub fn blue(&self) -> f32 {
|
||||
self.blue
|
||||
}
|
||||
|
||||
pub fn alpha(&self) -> f32 {
|
||||
self.alpha
|
||||
}
|
||||
|
||||
pub fn red_u8(&self) -> u8 {
|
||||
self.red as u8
|
||||
}
|
||||
|
||||
pub fn green_u8(&self) -> u8 {
|
||||
self.green as u8
|
||||
}
|
||||
|
||||
pub fn blue_u8(&self) -> u8 {
|
||||
self.blue as u8
|
||||
}
|
||||
|
||||
pub fn alpha_byte(&self) -> u8 {
|
||||
(self.alpha * 255.0) as u8
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct WindowCfg {
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
pub decorations: bool,
|
||||
pub transparent: bool,
|
||||
pub resizable: bool,
|
||||
}
|
||||
|
||||
impl Default for WindowCfg {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
width: 600.0,
|
||||
height: 400.0,
|
||||
decorations: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AppearanceCfg {
|
||||
pub background_rgba: Rgba,
|
||||
pub border_rgba: Rgba,
|
||||
pub border_width: f32,
|
||||
pub border_radius: f32,
|
||||
pub search_font_size: f32,
|
||||
pub title_size: f32,
|
||||
pub desc_size: f32,
|
||||
pub row_radius: f32,
|
||||
pub placeholder: String,
|
||||
pub selected_row_rgba: Rgba,
|
||||
pub unselected_row_rgba: Rgba,
|
||||
pub description_rgba: Rgba,
|
||||
pub no_results_rgba: Rgba,
|
||||
pub error_rgba: Rgba,
|
||||
pub icon_size: f32,
|
||||
}
|
||||
|
||||
impl Default for AppearanceCfg {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
background_rgba: Rgba::new(20.0, 20.0, 30.0, 0.9),
|
||||
border_rgba: Rgba::new(229.0, 125.0, 33.0, 1.0),
|
||||
border_width: 1.0,
|
||||
border_radius: 8.0,
|
||||
search_font_size: 18.0,
|
||||
title_size: 15.0,
|
||||
desc_size: 12.0,
|
||||
row_radius: 4.0,
|
||||
placeholder: "Search apps, type > for commands, = for math".to_string(),
|
||||
selected_row_rgba: Rgba::new(229.0, 125.0, 33.0, 1.0),
|
||||
unselected_row_rgba: Rgba::new(255.0, 255.0, 255.0, 0.07),
|
||||
description_rgba: Rgba::new(210.0, 215.0, 230.0, 1.0),
|
||||
no_results_rgba: Rgba::new(180.0, 180.0, 200.0, 0.5),
|
||||
error_rgba: Rgba::new(255.0, 80.0, 80.0, 1.0),
|
||||
icon_size: 24.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SearchCfg {
|
||||
pub max_results: usize,
|
||||
pub debounce_ms: u64,
|
||||
pub frecency_compact_threshold: usize,
|
||||
}
|
||||
|
||||
impl Default for SearchCfg {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_results: 8,
|
||||
debounce_ms: 50,
|
||||
frecency_compact_threshold: 50,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct LoggingCfg {
|
||||
pub max_log_files: usize,
|
||||
}
|
||||
|
||||
impl Default for LoggingCfg {
|
||||
fn default() -> Self {
|
||||
Self { max_log_files: 7 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct TerminalCfg {
|
||||
pub cmd: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ExternalPluginCfg {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub args: Vec<String>,
|
||||
pub timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for ExternalPluginCfg {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: String::new(),
|
||||
path: String::new(),
|
||||
args: vec![],
|
||||
timeout_secs: 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct PluginsCfg {
|
||||
pub calc: bool,
|
||||
pub cmd: bool,
|
||||
pub files: bool,
|
||||
pub apps: bool,
|
||||
pub external: Vec<ExternalPluginCfg>,
|
||||
}
|
||||
|
||||
impl Default for PluginsCfg {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
calc: true,
|
||||
cmd: true,
|
||||
files: true,
|
||||
apps: true,
|
||||
external: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
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.0"
|
||||
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,11 +1,18 @@
|
||||
[package]
|
||||
name = "k-launcher-kernel"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
futures = "0.3"
|
||||
k-launcher-domain = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = { workspace = true }
|
||||
k-launcher-domain = { workspace = true }
|
||||
plugin-calc = { workspace = true }
|
||||
plugin-cmd = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
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,280 +1,3 @@
|
||||
use std::sync::Arc;
|
||||
mod kernel;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::future::join_all;
|
||||
|
||||
// --- Newtypes ---
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ResultId(String);
|
||||
|
||||
impl ResultId {
|
||||
pub fn new(id: impl Into<String>) -> Self {
|
||||
Self(id.into())
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ResultTitle(String);
|
||||
|
||||
impl ResultTitle {
|
||||
pub fn new(title: impl Into<String>) -> Self {
|
||||
Self(title.into())
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
|
||||
)]
|
||||
pub struct Score(u32);
|
||||
|
||||
impl Score {
|
||||
pub fn new(value: u32) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
pub fn value(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
// --- LaunchAction (port) ---
|
||||
|
||||
pub enum LaunchAction {
|
||||
SpawnProcess(String),
|
||||
SpawnInTerminal(String),
|
||||
OpenPath(String),
|
||||
CopyToClipboard(String),
|
||||
}
|
||||
|
||||
// --- AppLauncher port trait ---
|
||||
|
||||
pub trait AppLauncher: Send + Sync {
|
||||
fn execute(&self, action: &LaunchAction);
|
||||
}
|
||||
|
||||
// --- SearchResult ---
|
||||
|
||||
pub struct SearchResult {
|
||||
pub id: ResultId,
|
||||
pub title: ResultTitle,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub score: Score,
|
||||
pub action: LaunchAction,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SearchResult {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("SearchResult")
|
||||
.field("id", &self.id)
|
||||
.field("title", &self.title)
|
||||
.field("icon", &self.icon)
|
||||
.field("score", &self.score)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Plugin trait ---
|
||||
|
||||
#[async_trait]
|
||||
pub trait Plugin: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
async fn search(&self, query: &str) -> Vec<SearchResult>;
|
||||
fn on_selected(&self, _id: &ResultId) {}
|
||||
}
|
||||
|
||||
// --- SearchEngine port trait ---
|
||||
|
||||
#[async_trait]
|
||||
pub trait SearchEngine: Send + Sync {
|
||||
async fn search(&self, query: &str) -> Vec<SearchResult>;
|
||||
fn on_selected(&self, id: &ResultId);
|
||||
}
|
||||
|
||||
// --- NullSearchEngine ---
|
||||
|
||||
pub struct NullSearchEngine;
|
||||
|
||||
#[async_trait]
|
||||
impl SearchEngine for NullSearchEngine {
|
||||
async fn search(&self, _query: &str) -> Vec<SearchResult> {
|
||||
vec![]
|
||||
}
|
||||
fn on_selected(&self, _id: &ResultId) {}
|
||||
}
|
||||
|
||||
// --- Kernel (Application use case) ---
|
||||
|
||||
pub struct Kernel {
|
||||
plugins: Vec<Arc<dyn Plugin>>,
|
||||
max_results: usize,
|
||||
}
|
||||
|
||||
impl Kernel {
|
||||
pub fn new(plugins: Vec<Arc<dyn Plugin>>, max_results: usize) -> Self {
|
||||
Self {
|
||||
plugins,
|
||||
max_results,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_selected(&self, id: &ResultId) {
|
||||
for plugin in &self.plugins {
|
||||
plugin.on_selected(id);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||
use futures::FutureExt;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
|
||||
let futures = self
|
||||
.plugins
|
||||
.iter()
|
||||
.map(|p| AssertUnwindSafe(p.search(query)).catch_unwind());
|
||||
let outcomes = join_all(futures).await;
|
||||
let mut flat: Vec<SearchResult> = outcomes
|
||||
.into_iter()
|
||||
.zip(self.plugins.iter())
|
||||
.flat_map(|(outcome, plugin)| match outcome {
|
||||
Ok(results) => results,
|
||||
Err(_) => {
|
||||
tracing::error!(plugin = plugin.name(), "plugin panicked during search");
|
||||
vec![]
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
flat.sort_by(|a, b| b.score.cmp(&a.score));
|
||||
flat.truncate(self.max_results);
|
||||
flat
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SearchEngine for Kernel {
|
||||
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||
self.search(query).await
|
||||
}
|
||||
fn on_selected(&self, id: &ResultId) {
|
||||
self.on_selected(id);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct MockPlugin {
|
||||
results: Vec<(&'static str, u32)>,
|
||||
}
|
||||
|
||||
impl MockPlugin {
|
||||
fn returns(results: Vec<(&'static str, u32)>) -> Self {
|
||||
Self { results }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Plugin for MockPlugin {
|
||||
fn name(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
|
||||
async fn search(&self, _query: &str) -> Vec<SearchResult> {
|
||||
self.results
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (title, score))| SearchResult {
|
||||
id: ResultId::new(format!("id-{i}")),
|
||||
title: ResultTitle::new(*title),
|
||||
description: None,
|
||||
icon: None,
|
||||
score: Score::new(*score),
|
||||
action: LaunchAction::SpawnProcess("mock".to_string()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newtype_result_id() {
|
||||
assert_eq!(ResultId::new("x").as_str(), "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newtype_score() {
|
||||
assert_eq!(Score::new(42).value(), 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newtype_title() {
|
||||
assert_eq!(ResultTitle::new("hello").as_str(), "hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_kernel_returns_empty() {
|
||||
let k = Kernel::new(vec![], 8);
|
||||
assert!(k.search("x").await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kernel_sorts_by_score_desc() {
|
||||
let plugin = Arc::new(MockPlugin::returns(vec![
|
||||
("lower", 5),
|
||||
("higher", 10),
|
||||
("middle", 7),
|
||||
]));
|
||||
let k = Kernel::new(vec![plugin], 8);
|
||||
let results = k.search("q").await;
|
||||
assert_eq!(results[0].score.value(), 10);
|
||||
assert_eq!(results[1].score.value(), 7);
|
||||
assert_eq!(results[2].score.value(), 5);
|
||||
}
|
||||
|
||||
struct PanicPlugin;
|
||||
|
||||
#[async_trait]
|
||||
impl Plugin for PanicPlugin {
|
||||
fn name(&self) -> &str {
|
||||
"panic-plugin"
|
||||
}
|
||||
|
||||
async fn search(&self, _query: &str) -> Vec<SearchResult> {
|
||||
panic!("test panic");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kernel_continues_after_plugin_panic() {
|
||||
let panic_plugin = Arc::new(PanicPlugin);
|
||||
let normal_plugin = Arc::new(MockPlugin::returns(vec![("survivor", 5)]));
|
||||
let k = Kernel::new(vec![panic_plugin, normal_plugin], 8);
|
||||
let results = k.search("q").await;
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].title.as_str(), "survivor");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kernel_truncates_at_max_results() {
|
||||
let plugin = Arc::new(MockPlugin::returns(vec![
|
||||
("a", 10),
|
||||
("b", 9),
|
||||
("c", 8),
|
||||
("d", 7),
|
||||
("e", 6),
|
||||
]));
|
||||
let k = Kernel::new(vec![plugin], 3);
|
||||
let results = k.search("q").await;
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(results[0].score.value(), 10);
|
||||
assert_eq!(results[2].score.value(), 8);
|
||||
}
|
||||
}
|
||||
pub use kernel::*;
|
||||
|
||||
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,8 +1,9 @@
|
||||
[package]
|
||||
name = "k-launcher-os-bridge"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
k-launcher-kernel = { path = "../k-launcher-kernel" }
|
||||
k-launcher-domain = { workspace = true }
|
||||
libc = "0.2"
|
||||
tracing = { workspace = true }
|
||||
|
||||
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,2 +1,7 @@
|
||||
mod unix_launcher;
|
||||
pub use unix_launcher::UnixAppLauncher;
|
||||
mod launcher;
|
||||
mod shell;
|
||||
mod spawn;
|
||||
mod terminal;
|
||||
|
||||
pub use launcher::UnixAppLauncher;
|
||||
pub use shell::shell_split;
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
86
crates/k-launcher-os-bridge/src/terminal.rs
Normal file
86
crates/k-launcher-os-bridge/src/terminal.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
pub(crate) struct TerminalCommand {
|
||||
pub bin: String,
|
||||
pub exec_flag: Vec<String>,
|
||||
}
|
||||
|
||||
struct KnownTerminal {
|
||||
bin: &'static str,
|
||||
exec_flag: &'static str,
|
||||
}
|
||||
|
||||
const KNOWN_TERMINALS: &[KnownTerminal] = &[
|
||||
KnownTerminal {
|
||||
bin: "foot",
|
||||
exec_flag: "-e",
|
||||
},
|
||||
KnownTerminal {
|
||||
bin: "kitty",
|
||||
exec_flag: "-e",
|
||||
},
|
||||
KnownTerminal {
|
||||
bin: "alacritty",
|
||||
exec_flag: "-e",
|
||||
},
|
||||
KnownTerminal {
|
||||
bin: "wezterm",
|
||||
exec_flag: "start",
|
||||
},
|
||||
KnownTerminal {
|
||||
bin: "konsole",
|
||||
exec_flag: "-e",
|
||||
},
|
||||
KnownTerminal {
|
||||
bin: "xterm",
|
||||
exec_flag: "-e",
|
||||
},
|
||||
];
|
||||
|
||||
fn find_in_path(bin: &str) -> bool {
|
||||
std::env::var_os("PATH")
|
||||
.iter()
|
||||
.flat_map(|p| std::env::split_paths(p))
|
||||
.any(|dir| dir.join(bin).is_file())
|
||||
}
|
||||
|
||||
fn parse_term_cmd(s: &str) -> TerminalCommand {
|
||||
let mut parts = s.split_whitespace();
|
||||
let bin = parts.next().unwrap_or("").to_string();
|
||||
let exec_flag = parts.map(str::to_string).collect();
|
||||
TerminalCommand { bin, exec_flag }
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_terminal(configured: Option<&str>) -> Option<TerminalCommand> {
|
||||
if let Some(cmd) = configured.filter(|s| !s.is_empty()) {
|
||||
let term = parse_term_cmd(cmd);
|
||||
if !term.bin.is_empty() {
|
||||
return Some(term);
|
||||
}
|
||||
}
|
||||
if let Ok(val) = std::env::var("TERM_CMD") {
|
||||
let val = val.trim().to_string();
|
||||
if !val.is_empty() {
|
||||
let term = parse_term_cmd(&val);
|
||||
if !term.bin.is_empty() {
|
||||
return Some(term);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(val) = std::env::var("TERMINAL") {
|
||||
let bin = val.trim().to_string();
|
||||
if !bin.is_empty() {
|
||||
return Some(TerminalCommand {
|
||||
bin,
|
||||
exec_flag: vec!["-e".to_string()],
|
||||
});
|
||||
}
|
||||
}
|
||||
for terminal in KNOWN_TERMINALS {
|
||||
if find_in_path(terminal.bin) {
|
||||
return Some(TerminalCommand {
|
||||
bin: terminal.bin.to_string(),
|
||||
exec_flag: vec![terminal.exec_flag.to_string()],
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use k_launcher_kernel::{AppLauncher, LaunchAction};
|
||||
|
||||
fn shell_split(cmd: &str) -> Vec<String> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut in_quotes = false;
|
||||
|
||||
for ch in cmd.chars() {
|
||||
match ch {
|
||||
'"' => in_quotes = !in_quotes,
|
||||
' ' | '\t' if !in_quotes => {
|
||||
if !current.is_empty() {
|
||||
tokens.push(current.clone());
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
_ => current.push(ch),
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
tokens.push(current);
|
||||
}
|
||||
tokens
|
||||
}
|
||||
|
||||
fn parse_term_cmd(s: &str) -> (String, Vec<String>) {
|
||||
let mut parts = s.split_whitespace();
|
||||
let bin = parts.next().unwrap_or("").to_string();
|
||||
let args = parts.map(str::to_string).collect();
|
||||
(bin, args)
|
||||
}
|
||||
|
||||
fn which(bin: &str) -> bool {
|
||||
Command::new("which")
|
||||
.arg(bin)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn resolve_terminal() -> Option<(String, Vec<String>)> {
|
||||
if let Ok(val) = std::env::var("TERM_CMD") {
|
||||
let val = val.trim().to_string();
|
||||
if !val.is_empty() {
|
||||
let (bin, args) = parse_term_cmd(&val);
|
||||
if !bin.is_empty() {
|
||||
return Some((bin, args));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(val) = std::env::var("TERMINAL") {
|
||||
let bin = val.trim().to_string();
|
||||
if !bin.is_empty() {
|
||||
return Some((bin, vec!["-e".to_string()]));
|
||||
}
|
||||
}
|
||||
for (bin, flag) in &[
|
||||
("foot", "-e"),
|
||||
("kitty", "-e"),
|
||||
("alacritty", "-e"),
|
||||
("wezterm", "start"),
|
||||
("konsole", "-e"),
|
||||
("xterm", "-e"),
|
||||
] {
|
||||
if which(bin) {
|
||||
return Some((bin.to_string(), vec![flag.to_string()]));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub struct UnixAppLauncher;
|
||||
|
||||
impl UnixAppLauncher {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UnixAppLauncher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AppLauncher for UnixAppLauncher {
|
||||
fn execute(&self, action: &LaunchAction) {
|
||||
match action {
|
||||
LaunchAction::SpawnProcess(cmd) => {
|
||||
let parts = shell_split(cmd);
|
||||
if let Some((bin, args)) = parts.split_first() {
|
||||
let _ = unsafe {
|
||||
Command::new(bin)
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.pre_exec(|| {
|
||||
libc::setsid();
|
||||
Ok(())
|
||||
})
|
||||
.spawn()
|
||||
};
|
||||
}
|
||||
}
|
||||
LaunchAction::SpawnInTerminal(cmd) => {
|
||||
let Some((term_bin, term_args)) = resolve_terminal() else {
|
||||
return;
|
||||
};
|
||||
let _ = unsafe {
|
||||
Command::new(&term_bin)
|
||||
.args(&term_args)
|
||||
.arg("sh")
|
||||
.arg("-c")
|
||||
.arg(cmd)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.pre_exec(|| {
|
||||
libc::setsid();
|
||||
Ok(())
|
||||
})
|
||||
.spawn()
|
||||
};
|
||||
}
|
||||
LaunchAction::OpenPath(path) => {
|
||||
let _ = Command::new("xdg-open").arg(path).spawn();
|
||||
}
|
||||
LaunchAction::CopyToClipboard(val) => {
|
||||
if Command::new("wl-copy").arg(val).spawn().is_err() {
|
||||
use std::io::Write;
|
||||
if let Ok(mut child) = Command::new("xclip")
|
||||
.args(["-selection", "clipboard"])
|
||||
.stdin(Stdio::piped())
|
||||
.spawn()
|
||||
&& let Some(stdin) = child.stdin.as_mut()
|
||||
{
|
||||
let _ = stdin.write_all(val.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::shell_split;
|
||||
|
||||
#[test]
|
||||
fn split_simple() {
|
||||
assert_eq!(shell_split("firefox"), vec!["firefox"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_with_args() {
|
||||
assert_eq!(
|
||||
shell_split("firefox --new-window"),
|
||||
vec!["firefox", "--new-window"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_quoted_path() {
|
||||
assert_eq!(shell_split(r#""My App" --flag"#), vec!["My App", "--flag"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_quoted_with_spaces() {
|
||||
assert_eq!(
|
||||
shell_split(r#"env "FOO BAR" baz"#),
|
||||
vec!["env", "FOO BAR", "baz"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_empty() {
|
||||
assert!(shell_split("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_extra_whitespace() {
|
||||
assert_eq!(shell_split(" a b "), vec!["a", "b"]);
|
||||
}
|
||||
}
|
||||
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]
|
||||
name = "k-launcher-plugin-host"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
@@ -9,8 +9,11 @@ path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
k-launcher-kernel = { path = "../k-launcher-kernel" }
|
||||
k-launcher-domain = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["process", "io-util", "sync", "time"] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
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,218 +1,6 @@
|
||||
use async_trait::async_trait;
|
||||
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
|
||||
use tokio::process::{ChildStdin, ChildStdout, Command};
|
||||
use tokio::sync::Mutex;
|
||||
pub mod error;
|
||||
mod plugin;
|
||||
mod protocol;
|
||||
|
||||
// --- Protocol types ---
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Query {
|
||||
query: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ExternalResult {
|
||||
id: String,
|
||||
title: String,
|
||||
score: u32,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
action: ExternalAction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum ExternalAction {
|
||||
SpawnProcess { cmd: String },
|
||||
CopyToClipboard { text: String },
|
||||
OpenPath { path: String },
|
||||
}
|
||||
|
||||
// --- Process I/O handle ---
|
||||
|
||||
struct ProcessIo {
|
||||
stdin: BufWriter<ChildStdin>,
|
||||
stdout: BufReader<ChildStdout>,
|
||||
}
|
||||
|
||||
async fn do_search(
|
||||
io: &mut ProcessIo,
|
||||
query: &str,
|
||||
) -> Result<Vec<ExternalResult>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let line = serde_json::to_string(&Query {
|
||||
query: query.to_string(),
|
||||
})?;
|
||||
io.stdin.write_all(line.as_bytes()).await?;
|
||||
io.stdin.write_all(b"\n").await?;
|
||||
io.stdin.flush().await?;
|
||||
let mut response = String::new();
|
||||
io.stdout.read_line(&mut response).await?;
|
||||
Ok(serde_json::from_str(&response)?)
|
||||
}
|
||||
|
||||
// --- ExternalPlugin ---
|
||||
|
||||
pub struct ExternalPlugin {
|
||||
name: String,
|
||||
path: String,
|
||||
args: Vec<String>,
|
||||
inner: Mutex<Option<ProcessIo>>,
|
||||
}
|
||||
|
||||
impl ExternalPlugin {
|
||||
pub fn new(name: impl Into<String>, path: impl Into<String>, args: Vec<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
path: path.into(),
|
||||
args,
|
||||
inner: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn(&self) -> std::io::Result<ProcessIo> {
|
||||
let mut child = Command::new(&self.path)
|
||||
.args(&self.args)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()?;
|
||||
let stdin = BufWriter::new(child.stdin.take().unwrap());
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
Ok(ProcessIo { stdin, stdout })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Plugin for ExternalPlugin {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||
let mut guard = self.inner.lock().await;
|
||||
|
||||
if guard.is_none() {
|
||||
match self.spawn().await {
|
||||
Ok(io) => *guard = Some(io),
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to spawn plugin {}: {e}", self.name);
|
||||
return vec![];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = match guard.as_mut() {
|
||||
Some(io) => {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), do_search(io, query))
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
tracing::warn!("plugin {} search timed out", self.name);
|
||||
Err("timeout".into())
|
||||
})
|
||||
}
|
||||
None => unreachable!(),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(results) => results
|
||||
.into_iter()
|
||||
.map(|r| SearchResult {
|
||||
id: ResultId::new(r.id),
|
||||
title: ResultTitle::new(r.title),
|
||||
description: r.description,
|
||||
icon: r.icon,
|
||||
score: Score::new(r.score),
|
||||
action: match r.action {
|
||||
ExternalAction::SpawnProcess { cmd } => LaunchAction::SpawnProcess(cmd),
|
||||
ExternalAction::CopyToClipboard { text } => {
|
||||
LaunchAction::CopyToClipboard(text)
|
||||
}
|
||||
ExternalAction::OpenPath { path } => LaunchAction::OpenPath(path),
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
tracing::warn!("plugin {} error: {e}", self.name);
|
||||
*guard = None;
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn query_serializes_correctly() {
|
||||
let q = Query {
|
||||
query: "firefox".to_string(),
|
||||
};
|
||||
assert_eq!(serde_json::to_string(&q).unwrap(), r#"{"query":"firefox"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_parses_spawn_action() {
|
||||
let json = r#"[{"id":"1","title":"Firefox","score":80,"action":{"type":"SpawnProcess","cmd":"firefox"}}]"#;
|
||||
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].id, "1");
|
||||
assert_eq!(results[0].title, "Firefox");
|
||||
assert_eq!(results[0].score, 80);
|
||||
assert!(
|
||||
matches!(&results[0].action, ExternalAction::SpawnProcess { cmd } if cmd == "firefox")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_parses_copy_action() {
|
||||
let json = r#"[{"id":"c","title":"= 4","score":90,"action":{"type":"CopyToClipboard","text":"4"}}]"#;
|
||||
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
|
||||
assert!(
|
||||
matches!(&results[0].action, ExternalAction::CopyToClipboard { text } if text == "4")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_parses_open_path_action() {
|
||||
let json = r#"[{"id":"f","title":"/home/user","score":50,"action":{"type":"OpenPath","path":"/home/user"}}]"#;
|
||||
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
|
||||
assert!(
|
||||
matches!(&results[0].action, ExternalAction::OpenPath { path } if path == "/home/user")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_parses_optional_fields() {
|
||||
let json = r#"[{"id":"x","title":"X","score":10,"description":"desc","icon":"/icon.png","action":{"type":"SpawnProcess","cmd":"x"}}]"#;
|
||||
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(results[0].description.as_deref(), Some("desc"));
|
||||
assert_eq!(results[0].icon.as_deref(), Some("/icon.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_parses_missing_optional_fields() {
|
||||
let json =
|
||||
r#"[{"id":"x","title":"X","score":10,"action":{"type":"SpawnProcess","cmd":"x"}}]"#;
|
||||
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
|
||||
assert!(results[0].description.is_none());
|
||||
assert!(results[0].icon.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_is_err() {
|
||||
assert!(serde_json::from_str::<Vec<ExternalResult>>("not json").is_err());
|
||||
}
|
||||
|
||||
// Unused import suppression for Arc (used only in production code path)
|
||||
fn _assert_send_sync() {
|
||||
fn check<T: Send + Sync>() {}
|
||||
check::<ExternalPlugin>();
|
||||
}
|
||||
}
|
||||
pub use plugin::*;
|
||||
pub use protocol::*;
|
||||
|
||||
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.0"
|
||||
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]
|
||||
name = "k-launcher-ui-egui"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
@@ -10,6 +10,9 @@ path = "src/lib.rs"
|
||||
[dependencies]
|
||||
eframe = { version = "0.31", default-features = false, features = ["default_fonts", "wayland", "x11", "glow"] }
|
||||
egui = "0.31"
|
||||
k-launcher-config = { path = "../k-launcher-config" }
|
||||
k-launcher-kernel = { path = "../k-launcher-kernel" }
|
||||
k-launcher-config = { workspace = true }
|
||||
k-launcher-domain = { workspace = true }
|
||||
k-launcher-kernel = { workspace = true }
|
||||
k-launcher-ui-core = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -1,169 +1,153 @@
|
||||
use std::sync::{Arc, mpsc};
|
||||
|
||||
use egui::{Color32, Key, ViewportCommand};
|
||||
use k_launcher_kernel::{AppLauncher, SearchEngine, SearchResult};
|
||||
use egui::ViewportCommand;
|
||||
use k_launcher_config::AppearanceCfg;
|
||||
use k_launcher_domain::AppLauncher;
|
||||
use k_launcher_domain::SearchResult;
|
||||
use k_launcher_kernel::Kernel;
|
||||
use k_launcher_ui_core::{Action, Effect, LauncherState};
|
||||
|
||||
const BG: Color32 = Color32::from_rgba_premultiplied(20, 20, 30, 230);
|
||||
const BORDER_COLOR: Color32 = Color32::from_rgb(229, 125, 33);
|
||||
const SELECTED_BG: Color32 = Color32::from_rgba_premultiplied(0, 100, 140, 180);
|
||||
const DIM_TEXT: Color32 = Color32::from_rgb(180, 185, 200);
|
||||
use crate::input::{InputAction, process_input};
|
||||
use crate::render;
|
||||
use crate::style;
|
||||
|
||||
pub struct KLauncherApp {
|
||||
engine: Arc<dyn SearchEngine>,
|
||||
launcher: Arc<dyn AppLauncher>,
|
||||
query: String,
|
||||
results: Vec<SearchResult>,
|
||||
selected: usize,
|
||||
pub(crate) inner: LauncherState,
|
||||
rt: tokio::runtime::Handle,
|
||||
result_tx: mpsc::SyncSender<Vec<SearchResult>>,
|
||||
result_rx: mpsc::Receiver<Vec<SearchResult>>,
|
||||
pub(crate) result_rx: mpsc::Receiver<Vec<SearchResult>>,
|
||||
}
|
||||
|
||||
impl KLauncherApp {
|
||||
fn new(
|
||||
engine: Arc<dyn SearchEngine>,
|
||||
engine: Arc<Kernel>,
|
||||
launcher: Arc<dyn AppLauncher>,
|
||||
rt: tokio::runtime::Handle,
|
||||
cfg: AppearanceCfg,
|
||||
) -> Self {
|
||||
let (result_tx, result_rx) = mpsc::sync_channel(4);
|
||||
Self {
|
||||
engine,
|
||||
launcher,
|
||||
query: String::new(),
|
||||
results: vec![],
|
||||
selected: 0,
|
||||
const RESULT_CHANNEL_CAPACITY: usize = 4;
|
||||
let (result_tx, result_rx) = mpsc::sync_channel(RESULT_CHANNEL_CAPACITY);
|
||||
let mut inner = LauncherState::new(launcher, cfg, 0);
|
||||
|
||||
let effect = inner.handle(Action::EngineReady(engine));
|
||||
|
||||
let app = Self {
|
||||
inner,
|
||||
rt,
|
||||
result_tx,
|
||||
result_rx,
|
||||
}
|
||||
};
|
||||
|
||||
app.execute_effect(effect);
|
||||
app
|
||||
}
|
||||
|
||||
fn trigger_search(&self, query: String) {
|
||||
let engine = self.engine.clone();
|
||||
let Some(engine) = self.inner.engine().cloned() else {
|
||||
return;
|
||||
};
|
||||
let tx = self.result_tx.clone();
|
||||
self.rt.spawn(async move {
|
||||
let results = engine.search(&query).await;
|
||||
let _ = tx.send(results);
|
||||
if let Err(e) = tx.send(results) {
|
||||
tracing::warn!("search result channel closed: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn poll_search_results(&mut self) {
|
||||
if let Ok(results) = self.result_rx.try_recv() {
|
||||
self.inner.handle(Action::ResultsReady {
|
||||
epoch: self.inner.search_epoch(),
|
||||
results,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_effect(&self, effect: Effect) {
|
||||
match effect {
|
||||
Effect::TriggerSearch(q) => self.trigger_search(q),
|
||||
Effect::SearchAfterDebounce { query, .. } => self.trigger_search(query),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_action(&mut self, action: Action, ctx: &egui::Context) {
|
||||
let effect = self.inner.handle(action);
|
||||
match effect {
|
||||
Effect::LaunchAndExit(action) => {
|
||||
self.inner.launcher().execute(&action);
|
||||
ctx.send_viewport_cmd(ViewportCommand::Close);
|
||||
}
|
||||
Effect::Exit => {
|
||||
ctx.send_viewport_cmd(ViewportCommand::Close);
|
||||
}
|
||||
other => self.execute_effect(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_panel(&mut self, ctx: &egui::Context) {
|
||||
let cfg = self.inner.cfg().clone();
|
||||
egui::CentralPanel::default()
|
||||
.frame(style::outer_frame(&cfg))
|
||||
.show(ctx, |ui| {
|
||||
let query = self.inner.query().to_string();
|
||||
let mut query_buf = query;
|
||||
let response = render::render_search_bar(ui, &mut query_buf, &cfg);
|
||||
|
||||
if response.changed() {
|
||||
self.handle_action(Action::QueryChanged(query_buf), ctx);
|
||||
}
|
||||
|
||||
response.request_focus();
|
||||
ui.add_space(8.0);
|
||||
|
||||
if self.inner.is_loading() {
|
||||
render::render_loading_state(ui, &cfg);
|
||||
return;
|
||||
}
|
||||
|
||||
if self.inner.results().is_empty() && !self.inner.query().is_empty() {
|
||||
render::render_empty_state(ui, &cfg);
|
||||
return;
|
||||
}
|
||||
|
||||
render::render_result_list(ui, self.inner.results(), self.inner.selected(), &cfg);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for KLauncherApp {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
if let Ok(results) = self.result_rx.try_recv() {
|
||||
self.results = results;
|
||||
}
|
||||
self.poll_search_results();
|
||||
|
||||
let mut close = false;
|
||||
let mut launch_selected = false;
|
||||
|
||||
ctx.input(|i| {
|
||||
if i.key_pressed(Key::Escape) {
|
||||
close = true;
|
||||
}
|
||||
if i.key_pressed(Key::Enter) {
|
||||
launch_selected = true;
|
||||
}
|
||||
if i.key_pressed(Key::ArrowDown) {
|
||||
let len = self.results.len();
|
||||
if len > 0 {
|
||||
self.selected = (self.selected + 1).min(len - 1);
|
||||
}
|
||||
}
|
||||
if i.key_pressed(Key::ArrowUp) && self.selected > 0 {
|
||||
self.selected -= 1;
|
||||
}
|
||||
});
|
||||
|
||||
if close {
|
||||
ctx.send_viewport_cmd(ViewportCommand::Close);
|
||||
match process_input(ctx) {
|
||||
InputAction::Close => {
|
||||
self.handle_action(Action::Exit, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
if launch_selected {
|
||||
if let Some(result) = self.results.get(self.selected) {
|
||||
self.engine.on_selected(&result.id);
|
||||
self.launcher.execute(&result.action);
|
||||
}
|
||||
ctx.send_viewport_cmd(ViewportCommand::Close);
|
||||
InputAction::LaunchSelected => {
|
||||
self.handle_action(Action::LaunchSelected, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
let frame = egui::Frame::new()
|
||||
.fill(BG)
|
||||
.stroke(egui::Stroke::new(1.0, BORDER_COLOR))
|
||||
.inner_margin(egui::Margin::same(12))
|
||||
.corner_radius(egui::CornerRadius::same(8));
|
||||
|
||||
egui::CentralPanel::default().frame(frame).show(ctx, |ui| {
|
||||
let response = ui.add_sized(
|
||||
[ui.available_width(), 36.0],
|
||||
egui::TextEdit::singleline(&mut self.query)
|
||||
.hint_text("Search...")
|
||||
.font(egui::TextStyle::Heading),
|
||||
);
|
||||
|
||||
if response.changed() {
|
||||
self.selected = 0;
|
||||
self.trigger_search(self.query.clone());
|
||||
InputAction::MoveDown => {
|
||||
self.inner.handle(Action::MoveDown);
|
||||
}
|
||||
InputAction::MoveUp => {
|
||||
self.inner.handle(Action::MoveUp);
|
||||
}
|
||||
InputAction::None => {}
|
||||
}
|
||||
|
||||
response.request_focus();
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
if self.results.is_empty() && !self.query.is_empty() {
|
||||
ui.add_space(20.0);
|
||||
ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| {
|
||||
ui.colored_label(DIM_TEXT, "No results");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
for (i, result) in self.results.iter().enumerate() {
|
||||
let is_selected = i == self.selected;
|
||||
let bg = if is_selected {
|
||||
SELECTED_BG
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
};
|
||||
|
||||
let row_frame = egui::Frame::new()
|
||||
.fill(bg)
|
||||
.inner_margin(egui::Margin {
|
||||
left: 8,
|
||||
right: 8,
|
||||
top: 6,
|
||||
bottom: 6,
|
||||
})
|
||||
.corner_radius(egui::CornerRadius::same(4));
|
||||
|
||||
row_frame.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(8.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(result.title.as_str());
|
||||
if let Some(desc) = &result.description {
|
||||
ui.colored_label(DIM_TEXT, desc);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
});
|
||||
});
|
||||
self.render_panel(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
engine: Arc<dyn SearchEngine>,
|
||||
engine: Arc<Kernel>,
|
||||
launcher: Arc<dyn AppLauncher>,
|
||||
window_cfg: &k_launcher_config::WindowCfg,
|
||||
appearance_cfg: AppearanceCfg,
|
||||
) -> Result<(), eframe::Error> {
|
||||
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
|
||||
let handle = rt.handle().clone();
|
||||
@@ -179,8 +163,15 @@ pub fn run(
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"K-Launcher",
|
||||
k_launcher_domain::constants::APP_TITLE,
|
||||
options,
|
||||
Box::new(move |_cc| Ok(Box::new(KLauncherApp::new(engine, launcher, handle)))),
|
||||
Box::new(move |_cc| {
|
||||
Ok(Box::new(KLauncherApp::new(
|
||||
engine,
|
||||
launcher,
|
||||
handle,
|
||||
appearance_cfg,
|
||||
)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
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,13 +1,19 @@
|
||||
mod app;
|
||||
mod input;
|
||||
mod render;
|
||||
mod style;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use k_launcher_kernel::{AppLauncher, SearchEngine};
|
||||
use k_launcher_config::AppearanceCfg;
|
||||
use k_launcher_domain::AppLauncher;
|
||||
use k_launcher_kernel::Kernel;
|
||||
|
||||
pub fn run(
|
||||
engine: Arc<dyn SearchEngine>,
|
||||
engine: Arc<Kernel>,
|
||||
launcher: Arc<dyn AppLauncher>,
|
||||
window_cfg: &k_launcher_config::WindowCfg,
|
||||
) -> Result<(), eframe::Error> {
|
||||
app::run(engine, launcher, window_cfg)
|
||||
appearance_cfg: AppearanceCfg,
|
||||
) -> Result<(), String> {
|
||||
app::run(engine, launcher, window_cfg, appearance_cfg).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
58
crates/k-launcher-ui-egui/src/render.rs
Normal file
58
crates/k-launcher-ui-egui/src/render.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use egui::Ui;
|
||||
use k_launcher_config::AppearanceCfg;
|
||||
use k_launcher_domain::SearchResult;
|
||||
|
||||
use crate::style::{self, ROW_SPACING, SEARCH_BAR_HEIGHT, to_color32};
|
||||
|
||||
pub fn render_search_bar(ui: &mut Ui, query: &mut String, cfg: &AppearanceCfg) -> egui::Response {
|
||||
ui.add_sized(
|
||||
[ui.available_width(), SEARCH_BAR_HEIGHT],
|
||||
egui::TextEdit::singleline(query)
|
||||
.hint_text(&cfg.placeholder)
|
||||
.font(egui::TextStyle::Heading),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn render_loading_state(ui: &mut Ui, cfg: &AppearanceCfg) {
|
||||
ui.add_space(20.0);
|
||||
ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| {
|
||||
ui.colored_label(to_color32(&cfg.no_results_rgba), "Loading...");
|
||||
});
|
||||
}
|
||||
|
||||
pub fn render_empty_state(ui: &mut Ui, cfg: &AppearanceCfg) {
|
||||
ui.add_space(20.0);
|
||||
ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| {
|
||||
ui.colored_label(to_color32(&cfg.no_results_rgba), "No results");
|
||||
});
|
||||
}
|
||||
|
||||
pub fn render_result_list(
|
||||
ui: &mut Ui,
|
||||
results: &[SearchResult],
|
||||
selected: usize,
|
||||
cfg: &AppearanceCfg,
|
||||
) {
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
for (i, result) in results.iter().enumerate() {
|
||||
render_result_row(ui, result, i == selected, cfg);
|
||||
ui.add_space(ROW_SPACING);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn render_result_row(ui: &mut Ui, result: &SearchResult, is_selected: bool, cfg: &AppearanceCfg) {
|
||||
style::result_row_frame(is_selected, cfg).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(8.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(result.title.as_str());
|
||||
if let Some(desc) = &result.description {
|
||||
ui.colored_label(to_color32(&cfg.description_rgba), desc.as_ref());
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
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]
|
||||
name = "k-launcher-ui"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
@@ -8,8 +8,10 @@ name = "k_launcher_ui"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
iced = { workspace = true }
|
||||
k-launcher-config = { path = "../k-launcher-config" }
|
||||
k-launcher-kernel = { path = "../k-launcher-kernel" }
|
||||
k-launcher-os-bridge = { path = "../k-launcher-os-bridge" }
|
||||
iced = { version = "0.14", default-features = false, features = ["image", "svg", "tokio", "tiny-skia", "wayland", "x11", "crisp", "web-colors", "thread-pool"] }
|
||||
k-launcher-config = { workspace = true }
|
||||
k-launcher-domain = { workspace = true }
|
||||
k-launcher-kernel = { workspace = true }
|
||||
k-launcher-os-bridge = { workspace = true }
|
||||
k-launcher-ui-core = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use iced::{
|
||||
Border, Color, Element, Length, Size, Subscription, Task, event,
|
||||
keyboard::{Event as KeyEvent, Key, key::Named},
|
||||
widget::{Space, column, container, image, row, scrollable, svg, text, text_input},
|
||||
window,
|
||||
};
|
||||
use iced::{Size, Subscription, Task, event, keyboard::Event as KeyEvent, window};
|
||||
|
||||
use k_launcher_config::AppearanceCfg;
|
||||
use k_launcher_kernel::{AppLauncher, NullSearchEngine, SearchEngine, SearchResult};
|
||||
use k_launcher_domain::AppLauncher;
|
||||
use k_launcher_domain::SearchResult;
|
||||
use k_launcher_kernel::Kernel;
|
||||
use k_launcher_ui_core::LauncherState;
|
||||
|
||||
static INPUT_ID: std::sync::LazyLock<iced::widget::Id> =
|
||||
pub(crate) static INPUT_ID: std::sync::LazyLock<iced::widget::Id> =
|
||||
std::sync::LazyLock::new(|| iced::widget::Id::new("search"));
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct EngineHandle(Arc<dyn SearchEngine>);
|
||||
pub(crate) struct EngineHandle(pub(crate) Arc<Kernel>);
|
||||
|
||||
impl std::fmt::Debug for EngineHandle {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
@@ -22,250 +20,22 @@ impl std::fmt::Debug for EngineHandle {
|
||||
}
|
||||
}
|
||||
|
||||
fn rgba(c: &[f32; 4]) -> Color {
|
||||
Color::from_rgba8(c[0] as u8, c[1] as u8, c[2] as u8, c[3])
|
||||
}
|
||||
|
||||
pub struct KLauncherApp {
|
||||
engine: Arc<dyn SearchEngine>,
|
||||
launcher: Arc<dyn AppLauncher>,
|
||||
query: String,
|
||||
results: Arc<Vec<SearchResult>>,
|
||||
selected: usize,
|
||||
cfg: AppearanceCfg,
|
||||
error: Option<String>,
|
||||
search_epoch: u64,
|
||||
}
|
||||
|
||||
impl KLauncherApp {
|
||||
fn new(
|
||||
engine: Arc<dyn SearchEngine>,
|
||||
launcher: Arc<dyn AppLauncher>,
|
||||
cfg: AppearanceCfg,
|
||||
) -> Self {
|
||||
Self {
|
||||
engine,
|
||||
launcher,
|
||||
query: String::new(),
|
||||
results: Arc::new(vec![]),
|
||||
selected: 0,
|
||||
cfg,
|
||||
error: None,
|
||||
search_epoch: 0,
|
||||
}
|
||||
}
|
||||
pub(crate) struct KLauncherApp {
|
||||
pub(crate) inner: LauncherState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
pub(crate) enum Message {
|
||||
QueryChanged(String),
|
||||
ResultsReady(u64, Arc<Vec<SearchResult>>),
|
||||
ResultsReady {
|
||||
epoch: u64,
|
||||
results: Arc<Vec<SearchResult>>,
|
||||
},
|
||||
KeyPressed(KeyEvent),
|
||||
EngineReady(EngineHandle),
|
||||
EngineInitFailed(String),
|
||||
}
|
||||
|
||||
fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::QueryChanged(q) => {
|
||||
state.error = None;
|
||||
state.query = q.clone();
|
||||
state.selected = 0;
|
||||
state.search_epoch += 1;
|
||||
let epoch = state.search_epoch;
|
||||
let engine = state.engine.clone();
|
||||
Task::perform(
|
||||
async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
(epoch, engine.search(&q).await)
|
||||
},
|
||||
|(epoch, results)| Message::ResultsReady(epoch, Arc::new(results)),
|
||||
)
|
||||
}
|
||||
Message::ResultsReady(epoch, results) => {
|
||||
if epoch == state.search_epoch {
|
||||
state.results = results;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::EngineInitFailed(msg) => {
|
||||
state.error = Some(msg);
|
||||
Task::none()
|
||||
}
|
||||
Message::EngineReady(handle) => {
|
||||
state.engine = handle.0;
|
||||
if !state.query.is_empty() {
|
||||
let q = state.query.clone();
|
||||
return Task::done(Message::QueryChanged(q));
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::KeyPressed(event) => {
|
||||
let key = match event {
|
||||
KeyEvent::KeyPressed { key, .. } => key,
|
||||
_ => return Task::none(),
|
||||
};
|
||||
let Key::Named(named) = key else {
|
||||
return Task::none();
|
||||
};
|
||||
let len = state.results.len();
|
||||
match named {
|
||||
Named::Escape => {
|
||||
std::process::exit(0);
|
||||
}
|
||||
Named::ArrowDown => {
|
||||
if len > 0 {
|
||||
state.selected = (state.selected + 1).min(len - 1);
|
||||
}
|
||||
}
|
||||
Named::ArrowUp => {
|
||||
if state.selected > 0 {
|
||||
state.selected -= 1;
|
||||
}
|
||||
}
|
||||
Named::Enter => {
|
||||
if let Some(result) = state.results.get(state.selected) {
|
||||
state.engine.on_selected(&result.id);
|
||||
state.launcher.execute(&result.action);
|
||||
}
|
||||
std::process::exit(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(state: &KLauncherApp) -> Element<'_, Message> {
|
||||
let cfg = &state.cfg;
|
||||
let border_color = rgba(&cfg.border_rgba);
|
||||
|
||||
let search_bar = text_input(&cfg.placeholder, &state.query)
|
||||
.id(INPUT_ID.clone())
|
||||
.on_input(Message::QueryChanged)
|
||||
.padding(12)
|
||||
.size(cfg.search_font_size)
|
||||
.style(|theme, _status| {
|
||||
let mut s =
|
||||
iced::widget::text_input::default(theme, iced::widget::text_input::Status::Active);
|
||||
s.border = Border {
|
||||
color: Color::TRANSPARENT,
|
||||
width: 0.0,
|
||||
radius: 0.0.into(),
|
||||
};
|
||||
s
|
||||
});
|
||||
|
||||
let row_radius: f32 = cfg.row_radius;
|
||||
let title_size: f32 = cfg.title_size;
|
||||
let desc_size: f32 = cfg.desc_size;
|
||||
|
||||
let result_rows: Vec<Element<'_, Message>> = state
|
||||
.results
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, result)| {
|
||||
let is_selected = i == state.selected;
|
||||
let bg_color = if is_selected {
|
||||
border_color
|
||||
} else {
|
||||
Color::from_rgba8(255, 255, 255, 0.07)
|
||||
};
|
||||
let icon_el: Element<'_, Message> = match &result.icon {
|
||||
Some(p) if p.ends_with(".svg") => {
|
||||
svg(svg::Handle::from_path(p)).width(24).height(24).into()
|
||||
}
|
||||
Some(p) => image(image::Handle::from_path(p))
|
||||
.width(24)
|
||||
.height(24)
|
||||
.into(),
|
||||
None => Space::new().width(24).height(24).into(),
|
||||
};
|
||||
let title_col: Element<'_, Message> = if let Some(desc) = &result.description {
|
||||
column![
|
||||
text(result.title.as_str()).size(title_size),
|
||||
text(desc)
|
||||
.size(desc_size)
|
||||
.color(Color::from_rgba8(210, 215, 230, 1.0)),
|
||||
]
|
||||
.into()
|
||||
} else {
|
||||
text(result.title.as_str()).size(title_size).into()
|
||||
};
|
||||
container(row![icon_el, title_col].spacing(8).align_y(iced::Center))
|
||||
.width(Length::Fill)
|
||||
.padding([6, 12])
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(iced::Background::Color(bg_color)),
|
||||
border: Border {
|
||||
color: Color::TRANSPARENT,
|
||||
width: 0.0,
|
||||
radius: row_radius.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results_list = if state.results.is_empty() && !state.query.is_empty() {
|
||||
scrollable(
|
||||
container(
|
||||
text("No results")
|
||||
.size(title_size)
|
||||
.color(Color::from_rgba8(180, 180, 200, 0.5)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.align_x(iced::Center)
|
||||
.padding([20, 0]),
|
||||
)
|
||||
.height(Length::Fill)
|
||||
} else {
|
||||
scrollable(column(result_rows).spacing(2).width(Length::Fill)).height(Length::Fill)
|
||||
};
|
||||
|
||||
let maybe_error: Option<Element<'_, Message>> = state.error.as_ref().map(|msg| {
|
||||
container(
|
||||
text(msg.as_str())
|
||||
.size(12.0)
|
||||
.color(Color::from_rgba8(255, 80, 80, 1.0)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding([4, 12])
|
||||
.into()
|
||||
});
|
||||
|
||||
let mut content_children: Vec<Element<'_, Message>> =
|
||||
vec![search_bar.into(), results_list.into()];
|
||||
if let Some(err) = maybe_error {
|
||||
content_children.push(err);
|
||||
}
|
||||
let content = column(content_children)
|
||||
.spacing(8)
|
||||
.padding(12)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
let bg_color = rgba(&cfg.background_rgba);
|
||||
let border_width = cfg.border_width;
|
||||
let border_radius = cfg.border_radius;
|
||||
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(iced::Background::Color(bg_color)),
|
||||
border: Border {
|
||||
color: border_color,
|
||||
width: border_width,
|
||||
radius: border_radius.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
fn subscription(_state: &KLauncherApp) -> Subscription<Message> {
|
||||
event::listen_with(|ev, _status, _id| match ev {
|
||||
iced::Event::Keyboard(ke) => Some(Message::KeyPressed(ke)),
|
||||
@@ -274,18 +44,16 @@ fn subscription(_state: &KLauncherApp) -> Subscription<Message> {
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
engine_factory: Arc<dyn Fn() -> Arc<dyn SearchEngine> + Send + Sync>,
|
||||
engine_factory: Arc<dyn Fn() -> Arc<Kernel> + Send + Sync>,
|
||||
launcher: Arc<dyn AppLauncher>,
|
||||
window_cfg: &k_launcher_config::WindowCfg,
|
||||
appearance_cfg: AppearanceCfg,
|
||||
debounce_ms: u64,
|
||||
) -> iced::Result {
|
||||
iced::application(
|
||||
move || {
|
||||
let app = KLauncherApp::new(
|
||||
Arc::new(NullSearchEngine),
|
||||
launcher.clone(),
|
||||
appearance_cfg.clone(),
|
||||
);
|
||||
let inner = LauncherState::new(launcher.clone(), appearance_cfg.clone(), debounce_ms);
|
||||
let app = KLauncherApp { inner };
|
||||
let focus = iced::widget::operation::focus(INPUT_ID.clone());
|
||||
let ef = engine_factory.clone();
|
||||
let init = Task::perform(
|
||||
@@ -301,10 +69,10 @@ pub fn run(
|
||||
);
|
||||
(app, Task::batch([focus, init]))
|
||||
},
|
||||
update,
|
||||
view,
|
||||
crate::update::update,
|
||||
crate::view::view,
|
||||
)
|
||||
.title("K-Launcher")
|
||||
.title(k_launcher_domain::constants::APP_TITLE)
|
||||
.subscription(subscription)
|
||||
.window(window::Settings {
|
||||
size: Size::new(window_cfg.width, window_cfg.height),
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
mod app;
|
||||
mod style;
|
||||
mod update;
|
||||
mod view;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use k_launcher_config::{AppearanceCfg, WindowCfg};
|
||||
use k_launcher_kernel::{AppLauncher, SearchEngine};
|
||||
use k_launcher_config::{AppearanceCfg, SearchCfg, WindowCfg};
|
||||
use k_launcher_domain::AppLauncher;
|
||||
use k_launcher_kernel::Kernel;
|
||||
|
||||
pub fn run(
|
||||
engine_factory: Arc<dyn Fn() -> Arc<dyn SearchEngine> + Send + Sync>,
|
||||
engine_factory: Arc<dyn Fn() -> Arc<Kernel> + Send + Sync>,
|
||||
launcher: Arc<dyn AppLauncher>,
|
||||
window_cfg: &WindowCfg,
|
||||
appearance_cfg: AppearanceCfg,
|
||||
) -> iced::Result {
|
||||
app::run(engine_factory, launcher, window_cfg, appearance_cfg)
|
||||
search_cfg: &SearchCfg,
|
||||
) -> Result<(), String> {
|
||||
app::run(
|
||||
engine_factory,
|
||||
launcher,
|
||||
window_cfg,
|
||||
appearance_cfg,
|
||||
search_cfg.debounce_ms,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
164
crates/k-launcher-ui/src/view.rs
Normal file
164
crates/k-launcher-ui/src/view.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use iced::{
|
||||
Element, Length,
|
||||
widget::{Space, column, container, image, row, scrollable, svg, text, text_input},
|
||||
};
|
||||
|
||||
use k_launcher_config::AppearanceCfg;
|
||||
use k_launcher_domain::SearchResult;
|
||||
|
||||
use crate::app::{INPUT_ID, KLauncherApp, Message};
|
||||
use crate::style;
|
||||
|
||||
pub(crate) fn view(state: &KLauncherApp) -> Element<'_, Message> {
|
||||
let cfg = state.inner.cfg();
|
||||
|
||||
let mut content_children: Vec<Element<'_, Message>> =
|
||||
vec![search_bar(cfg, state.inner.query()), results_section(state)];
|
||||
if let Some(err) = state.inner.error() {
|
||||
content_children.push(error_bar(err, cfg));
|
||||
}
|
||||
|
||||
let content = column(content_children)
|
||||
.spacing(style::CONTENT_SPACING)
|
||||
.padding(style::CONTENT_PADDING)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(style::outer_container_style(
|
||||
style::rgba(&cfg.background_rgba),
|
||||
style::rgba(&cfg.border_rgba),
|
||||
cfg.border_width,
|
||||
cfg.border_radius,
|
||||
))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn search_bar<'a>(cfg: &'a AppearanceCfg, query: &'a str) -> Element<'a, Message> {
|
||||
text_input(&cfg.placeholder, query)
|
||||
.id(INPUT_ID.clone())
|
||||
.on_input(Message::QueryChanged)
|
||||
.padding(style::CONTENT_PADDING)
|
||||
.size(cfg.search_font_size)
|
||||
.style(style::search_input_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn results_section<'a>(state: &'a KLauncherApp) -> Element<'a, Message> {
|
||||
if state.inner.is_loading() {
|
||||
loading_state(state.inner.cfg())
|
||||
} else if state.inner.results().is_empty() && !state.inner.query().is_empty() {
|
||||
empty_state(state.inner.cfg())
|
||||
} else {
|
||||
result_list(state)
|
||||
}
|
||||
}
|
||||
|
||||
fn loading_state(cfg: &AppearanceCfg) -> Element<'static, Message> {
|
||||
container(
|
||||
text("Loading...")
|
||||
.size(cfg.title_size)
|
||||
.color(style::rgba(&cfg.no_results_rgba)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.align_x(iced::Center)
|
||||
.padding(style::EMPTY_STATE_PADDING)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn empty_state(cfg: &AppearanceCfg) -> Element<'static, Message> {
|
||||
scrollable(
|
||||
container(
|
||||
text("No results")
|
||||
.size(cfg.title_size)
|
||||
.color(style::rgba(&cfg.no_results_rgba)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.align_x(iced::Center)
|
||||
.padding(style::EMPTY_STATE_PADDING),
|
||||
)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn result_list<'a>(state: &'a KLauncherApp) -> Element<'a, Message> {
|
||||
let cfg = state.inner.cfg();
|
||||
let selected = state.inner.selected();
|
||||
let rows: Vec<Element<'_, Message>> = state
|
||||
.inner
|
||||
.results()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, result)| result_row(result, i == selected, cfg))
|
||||
.collect();
|
||||
|
||||
scrollable(column(rows).spacing(style::ROW_SPACING).width(Length::Fill))
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn result_row<'a>(
|
||||
result: &'a SearchResult,
|
||||
is_selected: bool,
|
||||
cfg: &AppearanceCfg,
|
||||
) -> Element<'a, Message> {
|
||||
let bg_color = if is_selected {
|
||||
style::rgba(&cfg.selected_row_rgba)
|
||||
} else {
|
||||
style::rgba(&cfg.unselected_row_rgba)
|
||||
};
|
||||
|
||||
container(
|
||||
row![result_icon(&result.icon, cfg), title_column(result, cfg)]
|
||||
.spacing(style::CONTENT_SPACING)
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding(style::ROW_PADDING)
|
||||
.style(style::result_row_style(bg_color, cfg.row_radius))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn result_icon<'a>(icon_path: &'a Option<Arc<str>>, cfg: &AppearanceCfg) -> Element<'a, Message> {
|
||||
let size = cfg.icon_size;
|
||||
match icon_path {
|
||||
Some(p) if p.ends_with(".svg") => svg(svg::Handle::from_path(p.as_ref()))
|
||||
.width(size)
|
||||
.height(size)
|
||||
.into(),
|
||||
Some(p) => image(image::Handle::from_path(p.as_ref()))
|
||||
.width(size)
|
||||
.height(size)
|
||||
.into(),
|
||||
None => Space::new().width(size).height(size).into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn title_column<'a>(result: &'a SearchResult, cfg: &AppearanceCfg) -> Element<'a, Message> {
|
||||
if let Some(desc) = &result.description {
|
||||
column![
|
||||
text(result.title.as_str()).size(cfg.title_size),
|
||||
text(desc.as_ref())
|
||||
.size(cfg.desc_size)
|
||||
.color(style::rgba(&cfg.description_rgba)),
|
||||
]
|
||||
.into()
|
||||
} else {
|
||||
text(result.title.as_str()).size(cfg.title_size).into()
|
||||
}
|
||||
}
|
||||
|
||||
fn error_bar<'a>(msg: &'a str, cfg: &AppearanceCfg) -> Element<'a, Message> {
|
||||
container(
|
||||
text(msg)
|
||||
.size(style::ERROR_FONT_SIZE)
|
||||
.color(style::rgba(&cfg.error_rgba)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding(style::ERROR_PADDING)
|
||||
.into()
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "k-launcher"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
default-run = "k-launcher"
|
||||
|
||||
@@ -23,19 +23,21 @@ required-features = ["egui"]
|
||||
egui = ["dep:k-launcher-ui-egui"]
|
||||
|
||||
[dependencies]
|
||||
iced = { workspace = true }
|
||||
k-launcher-config = { path = "../k-launcher-config" }
|
||||
k-launcher-kernel = { path = "../k-launcher-kernel" }
|
||||
k-launcher-plugin-host = { path = "../k-launcher-plugin-host" }
|
||||
k-launcher-os-bridge = { path = "../k-launcher-os-bridge" }
|
||||
k-launcher-ui = { path = "../k-launcher-ui" }
|
||||
k-launcher-ui-egui = { path = "../k-launcher-ui-egui", optional = true }
|
||||
plugin-apps = { path = "../plugins/plugin-apps" }
|
||||
plugin-calc = { path = "../plugins/plugin-calc" }
|
||||
plugin-cmd = { path = "../plugins/plugin-cmd" }
|
||||
plugin-files = { path = "../plugins/plugin-files" }
|
||||
k-launcher-config = { workspace = true }
|
||||
k-launcher-kernel = { workspace = true }
|
||||
k-launcher-domain = { workspace = true }
|
||||
k-launcher-plugin-host = { workspace = true }
|
||||
k-launcher-os-bridge = { workspace = true }
|
||||
k-launcher-ui = { workspace = true }
|
||||
k-launcher-ui-egui = { workspace = true, optional = true }
|
||||
plugin-apps = { workspace = true }
|
||||
plugin-calc = { workspace = true }
|
||||
plugin-cmd = { workspace = true }
|
||||
plugin-files = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
ctrlc = { workspace = true }
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
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,79 +1,46 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use k_launcher_kernel::Kernel;
|
||||
use k_launcher_os_bridge::UnixAppLauncher;
|
||||
use k_launcher_plugin_host::ExternalPlugin;
|
||||
#[cfg(target_os = "linux")]
|
||||
use plugin_apps::linux::FsDesktopEntrySource;
|
||||
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
|
||||
use plugin_calc::CalcPlugin;
|
||||
use plugin_cmd::CmdPlugin;
|
||||
use plugin_files::FilesPlugin;
|
||||
|
||||
fn init_logging() -> tracing_appender::non_blocking::WorkerGuard {
|
||||
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||
mod engine;
|
||||
mod error;
|
||||
mod logging;
|
||||
|
||||
let log_dir = dirs::data_local_dir()
|
||||
.map(|d| d.join("k-launcher/logs"))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("/tmp/k-launcher/logs"));
|
||||
std::fs::create_dir_all(&log_dir).ok();
|
||||
|
||||
let file_appender = tracing_appender::rolling::daily(&log_dir, "k-launcher.log");
|
||||
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
||||
|
||||
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
|
||||
.with(tracing_subscriber::fmt::layer().with_writer(non_blocking))
|
||||
.init();
|
||||
|
||||
guard
|
||||
}
|
||||
use error::AppError;
|
||||
|
||||
fn main() {
|
||||
let _guard = init_logging();
|
||||
if std::env::args().any(|a| a == "--version" || a == "-V") {
|
||||
println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = run_ui() {
|
||||
eprintln!("error: UI: {e}");
|
||||
let cfg = Arc::new(k_launcher_config::load());
|
||||
let _guard = logging::init_logging(&cfg);
|
||||
logging::install_panic_hook();
|
||||
|
||||
ctrlc::set_handler(|| {
|
||||
tracing::info!("received shutdown signal");
|
||||
std::process::exit(0);
|
||||
})
|
||||
.ok();
|
||||
|
||||
if let Err(e) = run_ui(cfg) {
|
||||
eprintln!("error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_engine(cfg: Arc<k_launcher_config::Config>) -> Arc<dyn k_launcher_kernel::SearchEngine> {
|
||||
let frecency = FrecencyStore::load();
|
||||
let mut plugins: Vec<Arc<dyn k_launcher_kernel::Plugin>> = vec![];
|
||||
if cfg.plugins.cmd {
|
||||
plugins.push(Arc::new(CmdPlugin::new()));
|
||||
}
|
||||
if cfg.plugins.calc {
|
||||
plugins.push(Arc::new(CalcPlugin::new()));
|
||||
}
|
||||
if cfg.plugins.files {
|
||||
plugins.push(Arc::new(FilesPlugin::new()));
|
||||
}
|
||||
if cfg.plugins.apps {
|
||||
plugins.push(Arc::new(AppsPlugin::new(
|
||||
FsDesktopEntrySource::new(),
|
||||
frecency,
|
||||
)));
|
||||
}
|
||||
for ext in &cfg.plugins.external {
|
||||
plugins.push(Arc::new(ExternalPlugin::new(
|
||||
&ext.name,
|
||||
&ext.path,
|
||||
ext.args.clone(),
|
||||
)));
|
||||
}
|
||||
Arc::new(Kernel::new(plugins, cfg.search.max_results))
|
||||
}
|
||||
|
||||
fn run_ui() -> iced::Result {
|
||||
let cfg = Arc::new(k_launcher_config::load());
|
||||
let launcher = Arc::new(UnixAppLauncher::new());
|
||||
fn run_ui(cfg: Arc<k_launcher_config::Config>) -> Result<(), AppError> {
|
||||
let launcher = Arc::new(UnixAppLauncher::new(cfg.terminal.cmd.clone()));
|
||||
let factory_cfg = cfg.clone();
|
||||
let factory: Arc<dyn Fn() -> Arc<dyn k_launcher_kernel::SearchEngine> + Send + Sync> =
|
||||
Arc::new(move || build_engine(factory_cfg.clone()));
|
||||
k_launcher_ui::run(factory, launcher, &cfg.window, cfg.appearance.clone())
|
||||
let factory: Arc<dyn Fn() -> Arc<k_launcher_kernel::Kernel> + Send + Sync> =
|
||||
Arc::new(move || engine::build_engine(factory_cfg.clone()));
|
||||
k_launcher_ui::run(
|
||||
factory,
|
||||
launcher,
|
||||
&cfg.window,
|
||||
cfg.appearance.clone(),
|
||||
&cfg.search,
|
||||
)
|
||||
.map_err(AppError::Ui)
|
||||
}
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use k_launcher_kernel::Kernel;
|
||||
use k_launcher_os_bridge::UnixAppLauncher;
|
||||
#[cfg(target_os = "linux")]
|
||||
use plugin_apps::linux::FsDesktopEntrySource;
|
||||
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
|
||||
use plugin_calc::CalcPlugin;
|
||||
use plugin_cmd::CmdPlugin;
|
||||
use plugin_files::FilesPlugin;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cfg = k_launcher_config::load();
|
||||
let launcher = Arc::new(UnixAppLauncher::new());
|
||||
let frecency = FrecencyStore::load();
|
||||
let kernel: Arc<dyn k_launcher_kernel::SearchEngine> = Arc::new(Kernel::new(
|
||||
vec![
|
||||
Arc::new(CmdPlugin::new()),
|
||||
Arc::new(CalcPlugin::new()),
|
||||
Arc::new(FilesPlugin::new()),
|
||||
Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)),
|
||||
],
|
||||
8,
|
||||
));
|
||||
k_launcher_ui_egui::run(kernel, launcher, &cfg.window)?;
|
||||
Ok(())
|
||||
mod engine;
|
||||
mod error;
|
||||
mod logging;
|
||||
|
||||
use error::AppError;
|
||||
|
||||
fn main() {
|
||||
if std::env::args().any(|a| a == "--version" || a == "-V") {
|
||||
println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
|
||||
return;
|
||||
}
|
||||
|
||||
let cfg = Arc::new(k_launcher_config::load());
|
||||
let _guard = logging::init_logging(&cfg);
|
||||
logging::install_panic_hook();
|
||||
|
||||
ctrlc::set_handler(|| {
|
||||
tracing::info!("received shutdown signal");
|
||||
std::process::exit(0);
|
||||
})
|
||||
.ok();
|
||||
|
||||
if let Err(e) = run_ui(cfg) {
|
||||
eprintln!("error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_ui(cfg: Arc<k_launcher_config::Config>) -> Result<(), AppError> {
|
||||
let launcher = Arc::new(UnixAppLauncher::new(cfg.terminal.cmd.clone()));
|
||||
let kernel = engine::build_engine(cfg.clone());
|
||||
k_launcher_ui_egui::run(kernel, launcher, &cfg.window, cfg.appearance.clone())
|
||||
.map_err(AppError::Ui)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "plugin-apps"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
@@ -9,10 +9,11 @@ path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
bincode = { workspace = true }
|
||||
bincode = { version = "2", features = ["serde"] }
|
||||
dirs = { workspace = true }
|
||||
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
||||
k-launcher-domain = { workspace = true }
|
||||
nucleo-matcher = "0.3"
|
||||
parking_lot = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = "1.0"
|
||||
tokio = { workspace = true }
|
||||
@@ -21,3 +22,7 @@ tracing = { workspace = true }
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
linicon = "2.3.0"
|
||||
xdg = "3"
|
||||
|
||||
[dev-dependencies]
|
||||
k-launcher-domain = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
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::{
|
||||
collections::HashMap,
|
||||
fs::{File, OpenOptions},
|
||||
io::{BufRead, BufReader, Write},
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
sync::Arc,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -13,41 +16,64 @@ struct Entry {
|
||||
last_used: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct LogRecord {
|
||||
id: String,
|
||||
ts: u64,
|
||||
}
|
||||
|
||||
pub struct FrecencyStore {
|
||||
path: PathBuf,
|
||||
snapshot_path: PathBuf,
|
||||
log_path: PathBuf,
|
||||
data: Mutex<HashMap<String, Entry>>,
|
||||
log_count: Mutex<usize>,
|
||||
compact_threshold: usize,
|
||||
}
|
||||
|
||||
impl FrecencyStore {
|
||||
pub fn new(path: PathBuf) -> Arc<Self> {
|
||||
let data = std::fs::read_to_string(&path)
|
||||
pub fn new(snapshot_path: PathBuf, compact_threshold: usize) -> Arc<Self> {
|
||||
let mut data: HashMap<String, Entry> = std::fs::read_to_string(&snapshot_path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
Arc::new(Self {
|
||||
path,
|
||||
|
||||
let log_path = snapshot_path.with_extension("log");
|
||||
let log_count = replay_log_into(&log_path, &mut data);
|
||||
|
||||
let store = Arc::new(Self {
|
||||
snapshot_path,
|
||||
log_path,
|
||||
data: Mutex::new(data),
|
||||
})
|
||||
log_count: Mutex::new(log_count),
|
||||
compact_threshold,
|
||||
});
|
||||
|
||||
if log_count >= compact_threshold {
|
||||
store.compact();
|
||||
}
|
||||
|
||||
store
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new_for_test() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
path: PathBuf::from("/dev/null"),
|
||||
snapshot_path: PathBuf::from("/dev/null"),
|
||||
log_path: PathBuf::from("/dev/null"),
|
||||
data: Mutex::new(HashMap::new()),
|
||||
log_count: Mutex::new(0),
|
||||
compact_threshold: usize::MAX,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load() -> Arc<Self> {
|
||||
pub fn load(compact_threshold: usize) -> Arc<Self> {
|
||||
let Some(data_home) = xdg::BaseDirectories::new().get_data_home() else {
|
||||
tracing::warn!("XDG_DATA_HOME unavailable; frecency disabled (in-memory only)");
|
||||
return Arc::new(Self {
|
||||
path: PathBuf::from("/dev/null"),
|
||||
data: Mutex::new(HashMap::new()),
|
||||
});
|
||||
return Self::new_for_test();
|
||||
};
|
||||
let path = data_home.join("k-launcher").join("frecency.json");
|
||||
Self::new(path)
|
||||
let path = data_home
|
||||
.join(k_launcher_domain::constants::APP_NAME)
|
||||
.join(k_launcher_domain::constants::FRECENCY_SNAPSHOT_FILENAME);
|
||||
Self::new(path, compact_threshold)
|
||||
}
|
||||
|
||||
pub fn record(&self, id: &str) {
|
||||
@@ -55,26 +81,72 @@ impl FrecencyStore {
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let json = {
|
||||
let mut data = self.data.lock().unwrap();
|
||||
{
|
||||
let mut data = self.data.lock();
|
||||
let entry = data.entry(id.to_string()).or_insert(Entry {
|
||||
count: 0,
|
||||
last_used: 0,
|
||||
});
|
||||
entry.count += 1;
|
||||
entry.last_used = now;
|
||||
serde_json::to_string(&*data).ok()
|
||||
}; // lock released here
|
||||
if let Some(json) = json {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let _ = std::fs::write(&self.path, json);
|
||||
self.append_log(id, now);
|
||||
}
|
||||
|
||||
fn append_log(&self, id: &str, ts: u64) {
|
||||
let record = LogRecord {
|
||||
id: id.to_string(),
|
||||
ts,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&record) {
|
||||
if let Some(parent) = self.log_path.parent()
|
||||
&& let Err(e) = std::fs::create_dir_all(parent)
|
||||
{
|
||||
tracing::warn!("failed to create frecency dir: {e}");
|
||||
}
|
||||
if let Ok(mut file) = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.log_path)
|
||||
&& let Err(e) = writeln!(file, "{json}")
|
||||
{
|
||||
tracing::warn!("failed to write frecency log: {e}");
|
||||
}
|
||||
}
|
||||
let mut count = self.log_count.lock();
|
||||
*count += 1;
|
||||
if *count >= self.compact_threshold {
|
||||
drop(count);
|
||||
self.compact();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compact(&self) {
|
||||
let json = {
|
||||
let data = self.data.lock();
|
||||
serde_json::to_string(&*data).ok()
|
||||
};
|
||||
if let Some(json) = json {
|
||||
if let Some(parent) = self.snapshot_path.parent()
|
||||
&& let Err(e) = std::fs::create_dir_all(parent)
|
||||
{
|
||||
tracing::warn!("failed to create frecency dir: {e}");
|
||||
}
|
||||
if std::fs::write(&self.snapshot_path, json).is_ok() {
|
||||
if let Err(e) = File::create(&self.log_path) {
|
||||
tracing::warn!("failed to truncate frecency log: {e}");
|
||||
}
|
||||
*self.log_count.lock() = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
self.compact();
|
||||
}
|
||||
|
||||
pub fn frecency_score(&self, id: &str) -> u32 {
|
||||
let data = self.data.lock().unwrap();
|
||||
let data = self.data.lock();
|
||||
let Some(entry) = data.get(id) else { return 0 };
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -85,70 +157,73 @@ impl FrecencyStore {
|
||||
}
|
||||
|
||||
pub fn top_ids(&self, n: usize) -> Vec<String> {
|
||||
let data = self.data.lock().unwrap();
|
||||
struct ScoredId {
|
||||
id: String,
|
||||
score: u32,
|
||||
}
|
||||
|
||||
let data = self.data.lock();
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let mut scored: Vec<(String, u32)> = data
|
||||
let mut scored: Vec<ScoredId> = data
|
||||
.iter()
|
||||
.map(|(id, entry)| {
|
||||
let age_secs = now.saturating_sub(entry.last_used);
|
||||
(id.clone(), entry.count * decay_factor(age_secs))
|
||||
ScoredId {
|
||||
id: id.clone(),
|
||||
score: entry.count * decay_factor(age_secs),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
scored.into_iter().take(n).map(|(id, _)| id).collect()
|
||||
|
||||
if scored.len() <= n {
|
||||
scored.sort_by_key(|s| std::cmp::Reverse(s.score));
|
||||
return scored.into_iter().map(|s| s.id).collect();
|
||||
}
|
||||
|
||||
scored.select_nth_unstable_by_key(n, |s| std::cmp::Reverse(s.score));
|
||||
scored.truncate(n);
|
||||
scored.sort_by_key(|s| std::cmp::Reverse(s.score));
|
||||
scored.into_iter().map(|s| s.id).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn replay_log_into(log_path: &PathBuf, data: &mut HashMap<String, Entry>) -> usize {
|
||||
let file = match File::open(log_path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
let mut count = 0;
|
||||
for line in BufReader::new(file).lines() {
|
||||
let Ok(line) = line else { continue };
|
||||
let Ok(record) = serde_json::from_str::<LogRecord>(&line) else {
|
||||
continue;
|
||||
};
|
||||
let entry = data.entry(record.id).or_insert(Entry {
|
||||
count: 0,
|
||||
last_used: 0,
|
||||
});
|
||||
entry.count += 1;
|
||||
entry.last_used = record.ts;
|
||||
count += 1;
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
const ONE_HOUR: u64 = 3600;
|
||||
const ONE_DAY: u64 = 86400;
|
||||
const DECAY_RECENT: u32 = 4;
|
||||
const DECAY_TODAY: u32 = 2;
|
||||
const DECAY_OLD: u32 = 1;
|
||||
|
||||
fn decay_factor(age_secs: u64) -> u32 {
|
||||
if age_secs < 3600 {
|
||||
4
|
||||
} else if age_secs < 86400 {
|
||||
2
|
||||
if age_secs < ONE_HOUR {
|
||||
DECAY_RECENT
|
||||
} else if age_secs < ONE_DAY {
|
||||
DECAY_TODAY
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_store() -> Arc<FrecencyStore> {
|
||||
Arc::new(FrecencyStore {
|
||||
path: PathBuf::from("/dev/null"),
|
||||
data: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_increments_count() {
|
||||
let store = make_store();
|
||||
store.record("app-firefox");
|
||||
store.record("app-firefox");
|
||||
let data = store.data.lock().unwrap();
|
||||
assert_eq!(data["app-firefox"].count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_updates_last_used() {
|
||||
let store = make_store();
|
||||
store.record("app-firefox");
|
||||
let data = store.data.lock().unwrap();
|
||||
assert!(data["app-firefox"].last_used > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_ids_returns_sorted_order() {
|
||||
let store = make_store();
|
||||
store.record("app-firefox");
|
||||
store.record("app-code");
|
||||
store.record("app-code");
|
||||
store.record("app-code");
|
||||
let top = store.top_ids(2);
|
||||
assert_eq!(top[0], "app-code");
|
||||
assert_eq!(top[1], "app-firefox");
|
||||
DECAY_OLD
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,521 +1,12 @@
|
||||
mod cache;
|
||||
pub mod frecency;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod linux;
|
||||
mod plugin;
|
||||
mod scoring;
|
||||
mod types;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
||||
|
||||
use crate::frecency::FrecencyStore;
|
||||
|
||||
// --- Domain newtypes ---
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppName(String);
|
||||
|
||||
impl AppName {
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExecCommand(String);
|
||||
|
||||
impl ExecCommand {
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IconPath(String);
|
||||
|
||||
impl IconPath {
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
// --- Desktop entry ---
|
||||
|
||||
pub struct DesktopEntry {
|
||||
pub name: AppName,
|
||||
pub exec: ExecCommand,
|
||||
pub icon: Option<IconPath>,
|
||||
pub category: Option<String>,
|
||||
pub keywords: Vec<String>,
|
||||
}
|
||||
|
||||
// --- Swappable source trait (Application layer principle) ---
|
||||
|
||||
pub trait DesktopEntrySource: Send + Sync {
|
||||
fn entries(&self) -> Vec<DesktopEntry>;
|
||||
}
|
||||
|
||||
// --- Cached entry (pre-computed at construction) ---
|
||||
|
||||
struct CachedEntry {
|
||||
id: String,
|
||||
name: AppName,
|
||||
keywords_lc: Vec<String>,
|
||||
category: Option<String>,
|
||||
icon: Option<String>,
|
||||
exec: String,
|
||||
}
|
||||
|
||||
// --- Serializable cache data (no closures) ---
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct CachedEntryData {
|
||||
id: String,
|
||||
name: String,
|
||||
keywords_lc: Vec<String>,
|
||||
category: Option<String>,
|
||||
icon: Option<String>,
|
||||
exec: String,
|
||||
}
|
||||
|
||||
fn cache_path() -> Option<PathBuf> {
|
||||
dirs::cache_dir().map(|d| d.join("k-launcher/apps.bin"))
|
||||
}
|
||||
|
||||
fn load_from_path(path: &Path) -> Option<HashMap<String, CachedEntry>> {
|
||||
let data = std::fs::read(path).ok()?;
|
||||
let (entries_data, _): (Vec<CachedEntryData>, _) =
|
||||
bincode::serde::decode_from_slice(&data, bincode::config::standard()).ok()?;
|
||||
let map = entries_data
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let cached = CachedEntry {
|
||||
id: e.id.clone(),
|
||||
name: AppName::new(e.name),
|
||||
keywords_lc: e.keywords_lc,
|
||||
category: e.category,
|
||||
icon: e.icon,
|
||||
exec: e.exec,
|
||||
};
|
||||
(e.id, cached)
|
||||
})
|
||||
.collect();
|
||||
Some(map)
|
||||
}
|
||||
|
||||
fn save_to_path(path: &Path, entries: &HashMap<String, CachedEntry>) {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).ok();
|
||||
}
|
||||
let data: Vec<CachedEntryData> = entries
|
||||
.values()
|
||||
.map(|e| CachedEntryData {
|
||||
id: e.id.clone(),
|
||||
name: e.name.as_str().to_string(),
|
||||
keywords_lc: e.keywords_lc.clone(),
|
||||
category: e.category.clone(),
|
||||
icon: e.icon.clone(),
|
||||
exec: e.exec.clone(),
|
||||
})
|
||||
.collect();
|
||||
if let Ok(encoded) = bincode::serde::encode_to_vec(&data, bincode::config::standard()) {
|
||||
std::fs::write(path, encoded).ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn build_entries(
|
||||
source: &impl DesktopEntrySource,
|
||||
_frecency: &Arc<FrecencyStore>,
|
||||
) -> HashMap<String, CachedEntry> {
|
||||
source
|
||||
.entries()
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let id = format!("app-{}:{}", e.name.as_str(), e.exec.as_str());
|
||||
let keywords_lc = e.keywords.iter().map(|k| k.to_lowercase()).collect();
|
||||
#[cfg(target_os = "linux")]
|
||||
let icon = e
|
||||
.icon
|
||||
.as_ref()
|
||||
.and_then(|p| linux::resolve_icon_path(p.as_str()));
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let icon: Option<String> = None;
|
||||
let exec = e.exec.as_str().to_string();
|
||||
let cached = CachedEntry {
|
||||
id: id.clone(),
|
||||
keywords_lc,
|
||||
category: e.category,
|
||||
icon,
|
||||
exec,
|
||||
name: e.name,
|
||||
};
|
||||
(id, cached)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// --- Plugin ---
|
||||
|
||||
pub struct AppsPlugin {
|
||||
entries: Arc<RwLock<HashMap<String, CachedEntry>>>,
|
||||
frecency: Arc<FrecencyStore>,
|
||||
}
|
||||
|
||||
impl AppsPlugin {
|
||||
pub fn new(source: impl DesktopEntrySource + 'static, frecency: Arc<FrecencyStore>) -> Self {
|
||||
Self::new_impl(source, frecency, cache_path())
|
||||
}
|
||||
|
||||
fn new_impl(
|
||||
source: impl DesktopEntrySource + 'static,
|
||||
frecency: Arc<FrecencyStore>,
|
||||
cp: Option<PathBuf>,
|
||||
) -> Self {
|
||||
let cached = cp.as_deref().and_then(load_from_path);
|
||||
|
||||
let entries = if let Some(from_cache) = cached {
|
||||
// Serve cache immediately; refresh in background.
|
||||
let map = Arc::new(RwLock::new(from_cache));
|
||||
let entries_bg = Arc::clone(&map);
|
||||
let frecency_bg = Arc::clone(&frecency);
|
||||
let cp_bg = cp.clone();
|
||||
std::thread::spawn(move || {
|
||||
let fresh = build_entries(&source, &frecency_bg);
|
||||
if let Some(path) = cp_bg {
|
||||
save_to_path(&path, &fresh);
|
||||
}
|
||||
*entries_bg.write().unwrap() = fresh;
|
||||
});
|
||||
map
|
||||
} else {
|
||||
// No cache: build synchronously, then persist.
|
||||
let initial = build_entries(&source, &frecency);
|
||||
if let Some(path) = &cp {
|
||||
save_to_path(path, &initial);
|
||||
}
|
||||
Arc::new(RwLock::new(initial))
|
||||
};
|
||||
|
||||
Self { entries, frecency }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn new_for_test(
|
||||
source: impl DesktopEntrySource + 'static,
|
||||
frecency: Arc<FrecencyStore>,
|
||||
) -> Self {
|
||||
Self::new_impl(source, frecency, None)
|
||||
}
|
||||
}
|
||||
|
||||
fn initials(name_lc: &str) -> String {
|
||||
name_lc
|
||||
.split_whitespace()
|
||||
.filter_map(|w| w.chars().next())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn score_match(name: &str, query: &str) -> Option<u32> {
|
||||
use nucleo_matcher::{
|
||||
Config, Matcher, Utf32Str,
|
||||
pattern::{CaseMatching, Normalization, Pattern},
|
||||
};
|
||||
|
||||
let mut matcher = Matcher::new(Config::DEFAULT);
|
||||
let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
|
||||
|
||||
let mut name_chars: Vec<char> = name.chars().collect();
|
||||
let haystack = Utf32Str::new(name, &mut name_chars);
|
||||
let score = pattern.score(haystack, &mut matcher);
|
||||
|
||||
if let Some(s) = score {
|
||||
let name_lc = name.to_lowercase();
|
||||
let query_lc = query.to_lowercase();
|
||||
let bonus: u32 = if initials(&name_lc).starts_with(&query_lc) {
|
||||
20
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Some(s.saturating_add(bonus))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn humanize_category(s: &str) -> String {
|
||||
let mut result = String::new();
|
||||
for ch in s.chars() {
|
||||
if ch.is_uppercase() && !result.is_empty() {
|
||||
result.push(' ');
|
||||
}
|
||||
result.push(ch);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Plugin for AppsPlugin {
|
||||
fn name(&self) -> &str {
|
||||
"apps"
|
||||
}
|
||||
|
||||
fn on_selected(&self, id: &ResultId) {
|
||||
self.frecency.record(id.as_str());
|
||||
}
|
||||
|
||||
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||
let entries = self.entries.read().unwrap();
|
||||
if query.is_empty() {
|
||||
return self
|
||||
.frecency
|
||||
.top_ids(5)
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let e = entries.get(id)?;
|
||||
let score = self.frecency.frecency_score(id).max(1);
|
||||
Some(SearchResult {
|
||||
id: ResultId::new(id),
|
||||
title: ResultTitle::new(e.name.as_str()),
|
||||
description: e.category.clone(),
|
||||
icon: e.icon.clone(),
|
||||
score: Score::new(score),
|
||||
action: LaunchAction::SpawnProcess(e.exec.clone()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
let query_lc = query.to_lowercase();
|
||||
entries
|
||||
.values()
|
||||
.filter_map(|e| {
|
||||
let score = score_match(e.name.as_str(), query).or_else(|| {
|
||||
e.keywords_lc
|
||||
.iter()
|
||||
.any(|k| k.contains(&query_lc))
|
||||
.then_some(50)
|
||||
})?;
|
||||
Some(SearchResult {
|
||||
id: ResultId::new(&e.id),
|
||||
title: ResultTitle::new(e.name.as_str()),
|
||||
description: e.category.clone(),
|
||||
icon: e.icon.clone(),
|
||||
score: Score::new(score),
|
||||
action: LaunchAction::SpawnProcess(e.exec.clone()),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ephemeral_frecency() -> Arc<FrecencyStore> {
|
||||
FrecencyStore::new_for_test()
|
||||
}
|
||||
|
||||
struct MockSource {
|
||||
entries: Vec<(String, String, Option<String>, Vec<String>)>, // (name, exec, category, keywords)
|
||||
}
|
||||
|
||||
impl MockSource {
|
||||
fn with(entries: Vec<(&str, &str)>) -> Self {
|
||||
Self {
|
||||
entries: entries
|
||||
.into_iter()
|
||||
.map(|(n, e)| (n.to_string(), e.to_string(), None, vec![]))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_categories(entries: Vec<(&str, &str, &str)>) -> Self {
|
||||
Self {
|
||||
entries: entries
|
||||
.into_iter()
|
||||
.map(|(n, e, c)| (n.to_string(), e.to_string(), Some(c.to_string()), vec![]))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_keywords(entries: Vec<(&str, &str, Vec<&str>)>) -> Self {
|
||||
Self {
|
||||
entries: entries
|
||||
.into_iter()
|
||||
.map(|(n, e, kw)| {
|
||||
(
|
||||
n.to_string(),
|
||||
e.to_string(),
|
||||
None,
|
||||
kw.into_iter().map(|s| s.to_string()).collect(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DesktopEntrySource for MockSource {
|
||||
fn entries(&self) -> Vec<DesktopEntry> {
|
||||
self.entries
|
||||
.iter()
|
||||
.map(|(name, exec, category, keywords)| DesktopEntry {
|
||||
name: AppName::new(name.clone()),
|
||||
exec: ExecCommand::new(exec.clone()),
|
||||
icon: None,
|
||||
category: category.clone(),
|
||||
keywords: keywords.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_prefix_match() {
|
||||
let p = AppsPlugin::new_for_test(
|
||||
MockSource::with(vec![("Firefox", "firefox")]),
|
||||
ephemeral_frecency(),
|
||||
);
|
||||
let results = p.search("fire").await;
|
||||
assert_eq!(results[0].title.as_str(), "Firefox");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_no_match_returns_empty() {
|
||||
let p = AppsPlugin::new_for_test(
|
||||
MockSource::with(vec![("Firefox", "firefox")]),
|
||||
ephemeral_frecency(),
|
||||
);
|
||||
assert!(p.search("zz").await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_empty_query_no_frecency_returns_empty() {
|
||||
let p = AppsPlugin::new_for_test(
|
||||
MockSource::with(vec![("Firefox", "firefox")]),
|
||||
ephemeral_frecency(),
|
||||
);
|
||||
assert!(p.search("").await.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_match_abbreviation() {
|
||||
assert_eq!(initials("visual studio code"), "vsc");
|
||||
assert!(score_match("visual studio code", "vsc").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_match_exact_beats_prefix() {
|
||||
let exact = score_match("firefox", "firefox");
|
||||
let prefix = score_match("firefox", "fire");
|
||||
let abbrev = score_match("gnu firefox", "gf");
|
||||
let substr = score_match("ice firefox", "fire");
|
||||
assert!(exact.is_some());
|
||||
assert!(prefix.is_some());
|
||||
assert!(abbrev.is_some());
|
||||
assert!(substr.is_some());
|
||||
assert!(exact.unwrap() > prefix.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_abbreviation_match() {
|
||||
let p = AppsPlugin::new_for_test(
|
||||
MockSource::with(vec![("Visual Studio Code", "code")]),
|
||||
ephemeral_frecency(),
|
||||
);
|
||||
let results = p.search("vsc").await;
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].title.as_str(), "Visual Studio Code");
|
||||
assert!(results[0].score.value() > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_keyword_match() {
|
||||
let p = AppsPlugin::new_for_test(
|
||||
MockSource::with_keywords(vec![("Code", "code", vec!["editor", "ide"])]),
|
||||
ephemeral_frecency(),
|
||||
);
|
||||
let results = p.search("editor").await;
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].score.value(), 50);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_fuzzy_typo_match() {
|
||||
let p = AppsPlugin::new_for_test(
|
||||
MockSource::with(vec![("Firefox", "firefox")]),
|
||||
ephemeral_frecency(),
|
||||
);
|
||||
let results = p.search("frefox").await;
|
||||
assert!(
|
||||
!results.is_empty(),
|
||||
"nucleo should fuzzy-match 'frefox' to 'Firefox'"
|
||||
);
|
||||
assert!(results[0].score.value() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn humanize_category_splits_camel_case() {
|
||||
assert_eq!(humanize_category("TextEditor"), "Text Editor");
|
||||
assert_eq!(humanize_category("WebBrowser"), "Web Browser");
|
||||
assert_eq!(humanize_category("Development"), "Development");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_category_appears_in_description() {
|
||||
let p = AppsPlugin::new_for_test(
|
||||
MockSource::with_categories(vec![("Code", "code", "Text Editor")]),
|
||||
ephemeral_frecency(),
|
||||
);
|
||||
let results = p.search("code").await;
|
||||
assert_eq!(results[0].description.as_deref(), Some("Text Editor"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_empty_query_returns_top_frecent() {
|
||||
let frecency = ephemeral_frecency();
|
||||
frecency.record("app-Code:code");
|
||||
frecency.record("app-Code:code");
|
||||
frecency.record("app-Firefox:firefox");
|
||||
let p = AppsPlugin::new_for_test(
|
||||
MockSource::with(vec![("Firefox", "firefox"), ("Code", "code")]),
|
||||
frecency,
|
||||
);
|
||||
let results = p.search("").await;
|
||||
assert_eq!(results.len(), 2);
|
||||
assert_eq!(results[0].title.as_str(), "Code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apps_loads_from_cache_when_source_is_empty() {
|
||||
let frecency = ephemeral_frecency();
|
||||
let cache_file =
|
||||
std::env::temp_dir().join(format!("k-launcher-test-{}.bin", std::process::id()));
|
||||
|
||||
// Build entries from a real source and save to temp path
|
||||
let source = MockSource::with(vec![("Firefox", "firefox")]);
|
||||
let entries = build_entries(&source, &frecency);
|
||||
save_to_path(&cache_file, &entries);
|
||||
|
||||
// Load from temp path — should contain Firefox
|
||||
let loaded = load_from_path(&cache_file).unwrap();
|
||||
assert!(loaded.contains_key("app-Firefox:firefox"));
|
||||
|
||||
std::fs::remove_file(&cache_file).ok();
|
||||
}
|
||||
}
|
||||
pub use cache::{CachedEntry, build_entries, load_from_path, save_to_path};
|
||||
pub use plugin::*;
|
||||
pub use scoring::{humanize_category, new_matcher, parse_pattern, score_match};
|
||||
pub use types::*;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::humanize_category;
|
||||
use crate::scoring::humanize_category;
|
||||
use crate::{AppName, DesktopEntry, DesktopEntrySource, ExecCommand, IconPath};
|
||||
|
||||
pub struct FsDesktopEntrySource;
|
||||
@@ -45,7 +45,7 @@ impl DesktopEntrySource for FsDesktopEntrySource {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clean_exec(exec: &str) -> String {
|
||||
pub fn clean_exec(exec: &str) -> String {
|
||||
// Tokenize respecting double-quoted strings, then filter field codes.
|
||||
let mut tokens: Vec<String> = Vec::new();
|
||||
let mut chars = exec.chars().peekable();
|
||||
@@ -99,16 +99,18 @@ fn is_field_code(s: &str) -> bool {
|
||||
b.len() == 2 && b[0] == b'%' && b[1].is_ascii_alphabetic()
|
||||
}
|
||||
|
||||
const ICON_LOOKUP_SIZE: u16 = 48;
|
||||
const ICON_THEMES: &[&str] = &["hicolor", "Adwaita", "breeze", "Papirus"];
|
||||
const PIXMAPS_DIR: &str = "/usr/share/pixmaps";
|
||||
|
||||
pub fn resolve_icon_path(name: &str) -> Option<String> {
|
||||
if name.starts_with('/') && Path::new(name).exists() {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
// Try linicon freedesktop theme traversal
|
||||
let themes = ["hicolor", "Adwaita", "breeze", "Papirus"];
|
||||
for theme in &themes {
|
||||
for theme in ICON_THEMES {
|
||||
if let Some(icon_path) = linicon::lookup_icon(name)
|
||||
.from_theme(theme)
|
||||
.with_size(48)
|
||||
.with_size(ICON_LOOKUP_SIZE)
|
||||
.find_map(|r| r.ok())
|
||||
{
|
||||
return Some(icon_path.path.to_string_lossy().into_owned());
|
||||
@@ -116,8 +118,8 @@ pub fn resolve_icon_path(name: &str) -> Option<String> {
|
||||
}
|
||||
// Fallback to pixmaps
|
||||
let candidates = [
|
||||
format!("/usr/share/pixmaps/{name}.png"),
|
||||
format!("/usr/share/pixmaps/{name}.svg"),
|
||||
format!("{PIXMAPS_DIR}/{name}.png"),
|
||||
format!("{PIXMAPS_DIR}/{name}.svg"),
|
||||
];
|
||||
candidates.into_iter().find(|p| Path::new(p).exists())
|
||||
}
|
||||
@@ -187,31 +189,3 @@ fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
|
||||
keywords,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod exec_tests {
|
||||
use super::clean_exec;
|
||||
|
||||
#[test]
|
||||
fn strips_bare_field_code() {
|
||||
assert_eq!(clean_exec("app --file %f"), "app --file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_multiple_field_codes() {
|
||||
assert_eq!(clean_exec("app %U --flag"), "app --flag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_quoted_value() {
|
||||
assert_eq!(
|
||||
clean_exec(r#"app --arg="value" %U"#),
|
||||
r#"app --arg="value""#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_plain_exec() {
|
||||
assert_eq!(clean_exec("firefox"), "firefox");
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
name = "plugin-calc"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
@@ -10,5 +10,9 @@ path = "src/lib.rs"
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
evalexpr = "13"
|
||||
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
||||
k-launcher-domain = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
k-launcher-domain = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
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;
|
||||
use evalexpr::eval_number_with_context;
|
||||
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
||||
use std::sync::LazyLock;
|
||||
mod eval;
|
||||
mod plugin;
|
||||
|
||||
pub struct CalcPlugin;
|
||||
|
||||
impl CalcPlugin {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CalcPlugin {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_numeric_separators(expr: &str) -> String {
|
||||
expr.replace('_', "")
|
||||
}
|
||||
|
||||
const MATH_FNS: &[&str] = &[
|
||||
"sqrt", "sin", "cos", "tan", "asin", "acos", "atan", "ln", "log2", "log10", "exp", "abs",
|
||||
"ceil", "floor", "round",
|
||||
];
|
||||
|
||||
fn should_eval(query: &str) -> bool {
|
||||
let q = query.strip_prefix('=').unwrap_or(query);
|
||||
q.chars()
|
||||
.next()
|
||||
.map(|c| c.is_ascii_digit() || c == '(' || c == '-')
|
||||
.unwrap_or(false)
|
||||
|| query.starts_with('=')
|
||||
|| MATH_FNS.iter().any(|f| q.starts_with(f))
|
||||
}
|
||||
|
||||
static MATH_CTX: LazyLock<evalexpr::HashMapContext<evalexpr::DefaultNumericTypes>> = LazyLock::new(
|
||||
|| {
|
||||
use evalexpr::*;
|
||||
context_map! {
|
||||
"pi" => float std::f64::consts::PI,
|
||||
"e" => float std::f64::consts::E,
|
||||
"sqrt" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.sqrt()))),
|
||||
"sin" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.sin()))),
|
||||
"cos" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.cos()))),
|
||||
"tan" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.tan()))),
|
||||
"asin" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.asin()))),
|
||||
"acos" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.acos()))),
|
||||
"atan" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.atan()))),
|
||||
"ln" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.ln()))),
|
||||
"log2" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.log2()))),
|
||||
"log10" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.log10()))),
|
||||
"exp" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.exp()))),
|
||||
"abs" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.abs()))),
|
||||
"ceil" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.ceil()))),
|
||||
"floor" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.floor()))),
|
||||
"round" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.round())))
|
||||
}
|
||||
.expect("static math context must be valid")
|
||||
},
|
||||
);
|
||||
|
||||
#[async_trait]
|
||||
impl Plugin for CalcPlugin {
|
||||
fn name(&self) -> &str {
|
||||
"calc"
|
||||
}
|
||||
|
||||
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||
if !should_eval(query) {
|
||||
return vec![];
|
||||
}
|
||||
let raw = query.strip_prefix('=').unwrap_or(query);
|
||||
let expr_owned = strip_numeric_separators(raw);
|
||||
let expr = expr_owned.as_str();
|
||||
match eval_number_with_context(expr, &*MATH_CTX) {
|
||||
Ok(n) if n.is_finite() => {
|
||||
let value_str = if n.fract() == 0.0 {
|
||||
format!("{}", n as i64)
|
||||
} else {
|
||||
format!("{n}")
|
||||
};
|
||||
let display = format!("= {value_str}");
|
||||
vec![SearchResult {
|
||||
id: ResultId::new("calc-result"),
|
||||
title: ResultTitle::new(display),
|
||||
description: Some(format!("{expr_owned} · Enter to copy")),
|
||||
icon: None,
|
||||
score: Score::new(90),
|
||||
action: LaunchAction::CopyToClipboard(value_str),
|
||||
}]
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn calc_valid_expr() {
|
||||
let p = CalcPlugin::new();
|
||||
let results = p.search("2+2").await;
|
||||
assert_eq!(results[0].title.as_str(), "= 4");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn calc_non_numeric_returns_empty() {
|
||||
let p = CalcPlugin::new();
|
||||
assert!(p.search("firefox").await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn calc_bad_expr_returns_empty() {
|
||||
let p = CalcPlugin::new();
|
||||
assert!(p.search("1/0").await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn calc_sqrt() {
|
||||
let p = CalcPlugin::new();
|
||||
let results = p.search("sqrt(9)").await;
|
||||
assert_eq!(results[0].title.as_str(), "= 3");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn calc_sin_pi() {
|
||||
let p = CalcPlugin::new();
|
||||
let results = p.search("sin(pi)").await;
|
||||
assert!(!results.is_empty());
|
||||
let title = results[0].title.as_str();
|
||||
let val: f64 = title.trim_start_matches("= ").parse().unwrap();
|
||||
assert!(val.abs() < 1e-10, "sin(pi) should be near zero, got {val}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn calc_underscore_separator() {
|
||||
let p = CalcPlugin::new();
|
||||
let results = p.search("1_000 * 2").await;
|
||||
assert_eq!(results[0].title.as_str(), "= 2000");
|
||||
assert_eq!(
|
||||
results[0].description.as_deref(),
|
||||
Some("1000 * 2 · Enter to copy")
|
||||
);
|
||||
assert!(matches!(
|
||||
&results[0].action,
|
||||
LaunchAction::CopyToClipboard(v) if v == "2000"
|
||||
));
|
||||
}
|
||||
}
|
||||
pub use plugin::*;
|
||||
|
||||
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]
|
||||
name = "plugin-cmd"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
@@ -9,7 +9,8 @@ path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
||||
k-launcher-domain = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
k-launcher-domain = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use async_trait::async_trait;
|
||||
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
||||
use k_launcher_domain::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
||||
|
||||
const CMD_PREFIX: char = '>';
|
||||
const RESULT_SCORE: u32 = 95;
|
||||
|
||||
pub struct CmdPlugin;
|
||||
|
||||
@@ -22,7 +25,7 @@ impl Plugin for CmdPlugin {
|
||||
}
|
||||
|
||||
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||
let Some(rest) = query.strip_prefix('>') else {
|
||||
let Some(rest) = query.strip_prefix(CMD_PREFIX) else {
|
||||
return vec![];
|
||||
};
|
||||
let cmd = rest.trim();
|
||||
@@ -34,36 +37,8 @@ impl Plugin for CmdPlugin {
|
||||
title: ResultTitle::new(format!("Run: {cmd}")),
|
||||
description: None,
|
||||
icon: None,
|
||||
score: Score::new(95),
|
||||
score: Score::new(RESULT_SCORE),
|
||||
action: LaunchAction::SpawnInTerminal(cmd.to_string()),
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_prefix_triggers() {
|
||||
let p = CmdPlugin::new();
|
||||
let results = p.search("> echo hello").await;
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].title.as_str(), "Run: echo hello");
|
||||
assert_eq!(results[0].score.value(), 95);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_empty_remainder_returns_empty() {
|
||||
let p = CmdPlugin::new();
|
||||
assert!(p.search(">").await.is_empty());
|
||||
assert!(p.search("> ").await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cmd_no_prefix_returns_empty() {
|
||||
let p = CmdPlugin::new();
|
||||
assert!(p.search("echo hello").await.is_empty());
|
||||
assert!(p.search("firefox").await.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
name = "plugin-files"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
@@ -9,5 +9,9 @@ path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
||||
k-launcher-domain = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
k-launcher-domain = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -2,8 +2,13 @@ mod platform;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
||||
use k_launcher_domain::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
||||
|
||||
const MAX_FILE_RESULTS: usize = 20;
|
||||
const RESULT_SCORE: u32 = 50;
|
||||
|
||||
pub struct FilesPlugin;
|
||||
|
||||
@@ -70,7 +75,7 @@ impl Plugin for FilesPlugin {
|
||||
.map(|n| n.to_lowercase().starts_with(&prefix))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.take(20)
|
||||
.take(MAX_FILE_RESULTS)
|
||||
.map(|entry| {
|
||||
let full_path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
@@ -80,30 +85,12 @@ impl Plugin for FilesPlugin {
|
||||
SearchResult {
|
||||
id: ResultId::new(&path_str),
|
||||
title: ResultTitle::new(title),
|
||||
description: Some(path_str.clone()),
|
||||
description: Some(Arc::from(path_str.as_str())),
|
||||
icon: None,
|
||||
score: Score::new(50),
|
||||
score: Score::new(RESULT_SCORE),
|
||||
action: LaunchAction::OpenPath(path_str),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn files_ignores_non_path_query() {
|
||||
let p = FilesPlugin::new();
|
||||
assert!(p.search("firefox").await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn files_handles_root() {
|
||||
let p = FilesPlugin::new();
|
||||
let results = p.search("/").await;
|
||||
assert!(!results.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
name = "plugin-url"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
name = "plugin_url"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "k-launcher-plugin-url"
|
||||
path = "src/main.rs"
|
||||
@@ -10,3 +14,6 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
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 serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Query {
|
||||
query: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Action {
|
||||
r#type: &'static str,
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Result {
|
||||
id: &'static str,
|
||||
title: &'static str,
|
||||
description: String,
|
||||
score: u32,
|
||||
action: Action,
|
||||
}
|
||||
|
||||
fn is_url(query: &str) -> bool {
|
||||
query.starts_with("http://") || query.starts_with("https://") || query.starts_with("www.")
|
||||
}
|
||||
|
||||
fn normalize(query: &str) -> String {
|
||||
if query.starts_with("www.") {
|
||||
format!("https://{query}")
|
||||
} else {
|
||||
query.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn search(query: &str) -> Vec<Result> {
|
||||
if !is_url(query) {
|
||||
return vec![];
|
||||
}
|
||||
let url = normalize(query);
|
||||
vec![Result {
|
||||
id: "url-open",
|
||||
title: "Open in Browser",
|
||||
description: url.clone(),
|
||||
score: 95,
|
||||
action: Action {
|
||||
r#type: "OpenPath",
|
||||
path: url.clone(),
|
||||
},
|
||||
}]
|
||||
}
|
||||
use plugin_url::{Query, search};
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
let stdin = io::stdin();
|
||||
@@ -68,63 +19,3 @@ fn main() -> io::Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_url_https() {
|
||||
assert!(is_url("https://example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_url_http() {
|
||||
assert!(is_url("http://example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_url_www() {
|
||||
assert!(is_url("www.foo.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_url_plain() {
|
||||
assert!(!is_url("firefox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_url_empty() {
|
||||
assert!(!is_url(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_www() {
|
||||
assert_eq!(normalize("www.foo.com"), "https://www.foo.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_https() {
|
||||
assert_eq!(normalize("https://example.com"), "https://example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_returns_result() {
|
||||
let results = search("https://example.com");
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].action.path, "https://example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_returns_empty() {
|
||||
assert!(search("firefox").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_serializes() {
|
||||
let results = search("https://example.com");
|
||||
let json = serde_json::to_string(&results).unwrap();
|
||||
assert!(json.contains("OpenPath"));
|
||||
assert!(json.contains("https://example.com"));
|
||||
}
|
||||
}
|
||||
|
||||
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`
|
||||
|
||||
The file is optional — all fields have defaults and missing sections fall back to defaults automatically. Create it manually if you want to customize behavior.
|
||||
The file is optional — all fields have defaults and missing sections fall back to defaults automatically. If the file exists but has a parse error, a warning is logged and defaults are used.
|
||||
|
||||
## Full Annotated Example
|
||||
See [config.example.toml](../config.example.toml) for a ready-to-copy template with all options.
|
||||
|
||||
```toml
|
||||
[window]
|
||||
width = 600.0 # window width in logical pixels
|
||||
height = 400.0 # window height in logical pixels
|
||||
decorations = false # show window title bar / frame
|
||||
transparent = true # allow background transparency
|
||||
resizable = false # allow manual resizing
|
||||
## Sections
|
||||
|
||||
[appearance]
|
||||
# RGBA: r/g/b are 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
|
||||
### [window]
|
||||
|
||||
[search]
|
||||
max_results = 8 # maximum results shown at once
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `width` | float | `600.0` | Window width in pixels |
|
||||
| `height` | float | `400.0` | Window height in pixels |
|
||||
| `decorations` | bool | `false` | Show window title bar |
|
||||
| `transparent` | bool | `true` | Enable background transparency |
|
||||
| `resizable` | bool | `false` | Allow manual resizing |
|
||||
|
||||
[plugins]
|
||||
calc = true # math expression evaluator
|
||||
cmd = true # shell command runner (> prefix)
|
||||
files = true # filesystem browser (/ or ~/ prefix)
|
||||
apps = true # XDG application launcher
|
||||
### [appearance]
|
||||
|
||||
# External (dynamic) plugins — repeat block for each plugin
|
||||
[[plugins.external]]
|
||||
name = "my-plugin" # display name / identifier
|
||||
path = "/path/to/my-plugin" # path to executable
|
||||
args = [] # optional extra arguments
|
||||
```
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `background_rgba` | [R,G,B,A] | `[20, 20, 30, 0.9]` | Main background color |
|
||||
| `border_rgba` | [R,G,B,A] | `[229, 125, 33, 1.0]` | Border/accent color |
|
||||
| `border_width` | float | `1.0` | Border thickness |
|
||||
| `border_radius` | float | `8.0` | Window corner radius |
|
||||
| `search_font_size` | float | `18.0` | Search input font size |
|
||||
| `title_size` | float | `15.0` | Result title font size |
|
||||
| `desc_size` | float | `12.0` | Result description font size |
|
||||
| `row_radius` | float | `4.0` | Result row corner radius |
|
||||
| `placeholder` | string | `"Search apps, ..."` | Search input placeholder |
|
||||
| `selected_row_rgba` | [R,G,B,A] | `[229, 125, 33, 1.0]` | Selected result background |
|
||||
| `unselected_row_rgba` | [R,G,B,A] | `[255, 255, 255, 0.07]` | Unselected result background |
|
||||
| `description_rgba` | [R,G,B,A] | `[210, 215, 230, 1.0]` | Description text color |
|
||||
| `no_results_rgba` | [R,G,B,A] | `[180, 180, 200, 0.5]` | "No results" text color |
|
||||
| `error_rgba` | [R,G,B,A] | `[255, 80, 80, 1.0]` | Error text color |
|
||||
| `icon_size` | float | `24.0` | App icon size in pixels |
|
||||
|
||||
## RGBA Format
|
||||
#### RGBA format
|
||||
|
||||
Colors use `[r, g, b, a]` arrays where:
|
||||
- `r`, `g`, `b` — red, green, blue channels as floats **0.0–255.0**
|
||||
- `a` — alpha (opacity) as a float **0.0–1.0**
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
## Prerequisites
|
||||
## Arch Linux (AUR)
|
||||
|
||||
```bash
|
||||
yay -S k-launcher
|
||||
```
|
||||
|
||||
## Build from Source
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Rust** stable toolchain — install via [rustup](https://rustup.rs)
|
||||
- **git**
|
||||
- A **Wayland** or **X11** compositor (Linux)
|
||||
|
||||
## Build from Source
|
||||
### Build and install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/GKaszewski/k-launcher
|
||||
cd k-launcher
|
||||
cargo build --release
|
||||
make install
|
||||
```
|
||||
|
||||
Binary location: `target/release/k-launcher`
|
||||
|
||||
### Optional: install to PATH
|
||||
|
||||
```bash
|
||||
cp target/release/k-launcher ~/.local/bin/
|
||||
```
|
||||
This builds a release binary and copies it to `~/.local/bin/k-launcher`.
|
||||
|
||||
Ensure `~/.local/bin` is in your `$PATH`.
|
||||
|
||||
## Autostart
|
||||
### Manual build
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
cp target/release/k-launcher ~/.local/bin/
|
||||
```
|
||||
|
||||
## Compositor Keybind
|
||||
|
||||
### Hyprland
|
||||
|
||||
Add to `~/.config/hypr/hyprland.conf`:
|
||||
|
||||
```
|
||||
exec-once = k-launcher
|
||||
windowrule = float, ^(k-launcher)$
|
||||
windowrule = center, ^(k-launcher)$
|
||||
bind = SUPER, Space, exec, k-launcher
|
||||
```
|
||||
|
||||
### Sway
|
||||
|
||||
Add to `~/.config/sway/config`:
|
||||
|
||||
```
|
||||
for_window [app_id="k-launcher"] floating enable, move position center
|
||||
bindsym Mod4+space exec k-launcher
|
||||
```
|
||||
|
||||
## Autostart (optional)
|
||||
|
||||
### systemd user service
|
||||
|
||||
Create `~/.config/systemd/user/k-launcher.service`:
|
||||
@@ -55,3 +77,9 @@ Then enable it:
|
||||
```bash
|
||||
systemctl --user enable --now k-launcher
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
k-launcher --version
|
||||
```
|
||||
|
||||
@@ -35,6 +35,7 @@ The process is kept alive between queries — do **not** exit after each respons
|
||||
| `"type"` | Extra fields | Behavior |
|
||||
|----------|-------------|---------|
|
||||
| `SpawnProcess` | `"cmd"` | Launch process directly |
|
||||
| `SpawnInTerminal` | `"cmd"` | Run command in terminal emulator |
|
||||
| `CopyToClipboard` | `"text"` | Copy text to clipboard |
|
||||
| `OpenPath` | `"path"` | Open file/dir with xdg-open |
|
||||
|
||||
@@ -54,6 +55,7 @@ In `~/.config/k-launcher/config.toml`:
|
||||
name = "my-plugin"
|
||||
path = "/usr/lib/k-launcher/plugins/my-plugin"
|
||||
args = [] # optional
|
||||
timeout_secs = 5 # optional, default 5
|
||||
```
|
||||
|
||||
Multiple `[[plugins.external]]` blocks are supported.
|
||||
@@ -96,7 +98,7 @@ for line in sys.stdin:
|
||||
|
||||
## Built-in Plugins (compiled-in)
|
||||
|
||||
Built-in plugins implement the `Plugin` trait from `k-launcher-kernel` as Rust crates compiled into the binary.
|
||||
Built-in plugins implement the `Plugin` trait from `k-launcher-domain` as Rust crates compiled into the binary.
|
||||
|
||||
### 1. Create a new crate in the workspace
|
||||
|
||||
@@ -120,7 +122,7 @@ members = [
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
||||
k-launcher-domain = { workspace = true }
|
||||
async-trait = "0.1"
|
||||
```
|
||||
|
||||
@@ -129,8 +131,10 @@ async-trait = "0.1"
|
||||
`crates/plugins/plugin-hello/src/lib.rs`:
|
||||
|
||||
```rust
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
||||
use k_launcher_domain::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
||||
|
||||
pub struct HelloPlugin;
|
||||
|
||||
@@ -154,11 +158,10 @@ impl Plugin for HelloPlugin {
|
||||
vec![SearchResult {
|
||||
id: ResultId::new("hello:world"),
|
||||
title: ResultTitle::new("Hello, World!"),
|
||||
description: Some("A greeting from the hello plugin".to_string()),
|
||||
description: Some(Arc::from("A greeting from the hello plugin")),
|
||||
icon: None,
|
||||
score: Score::new(80),
|
||||
action: LaunchAction::CopyToClipboard("Hello, World!".to_string()),
|
||||
on_select: None,
|
||||
}]
|
||||
}
|
||||
}
|
||||
@@ -192,11 +195,10 @@ plugin-hello = { path = "../plugins/plugin-hello" }
|
||||
|-------|------|-------------|
|
||||
| `id` | `ResultId` | Unique stable ID (e.g. `"apps:firefox"`) |
|
||||
| `title` | `ResultTitle` | Primary display text |
|
||||
| `description` | `Option<String>` | Secondary line shown below title |
|
||||
| `icon` | `Option<String>` | Icon name or path (currently unused in renderer) |
|
||||
| `description` | `Option<Arc<str>>` | Secondary line shown below title |
|
||||
| `icon` | `Option<Arc<str>>` | Icon name or path (currently unused in renderer) |
|
||||
| `score` | `Score(u32)` | Sort priority — higher wins |
|
||||
| `action` | `LaunchAction` | What happens on `Enter` |
|
||||
| `on_select` | `Option<Arc<dyn Fn()>>` | Optional side-effect on selection (e.g. frecency bump) |
|
||||
|
||||
### `LaunchAction` Variants
|
||||
|
||||
@@ -206,7 +208,6 @@ plugin-hello = { path = "../plugins/plugin-hello" }
|
||||
| `SpawnInTerminal(String)` | Run command inside a terminal emulator |
|
||||
| `OpenPath(String)` | Open a file or directory with `xdg-open` |
|
||||
| `CopyToClipboard(String)` | Copy text to clipboard |
|
||||
| `Custom(Arc<dyn Fn()>)` | Arbitrary closure |
|
||||
|
||||
### Scoring Guidance
|
||||
|
||||
|
||||
168
man/k-launcher.1
Normal file
168
man/k-launcher.1
Normal file
@@ -0,0 +1,168 @@
|
||||
.TH K\-LAUNCHER 1 "2026-07-24" "k-launcher 0.2.0" "User Commands"
|
||||
.SH NAME
|
||||
k\-launcher \- Wayland command palette launcher
|
||||
.SH SYNOPSIS
|
||||
.B k\-launcher
|
||||
.RB [ \-\-version ]
|
||||
.SH DESCRIPTION
|
||||
.B k\-launcher
|
||||
is a keyboard-driven application launcher for Wayland desktops. It provides
|
||||
fuzzy search over installed applications, a calculator, a file browser, and
|
||||
a shell command runner, all accessible from a single search bar.
|
||||
.PP
|
||||
Results are ranked by a combination of fuzzy match score and frecency (how
|
||||
frequently and recently an application was launched). On an empty query, the
|
||||
most frecent applications are displayed.
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
.BR \-\-version ", " \-V
|
||||
Print version information and exit.
|
||||
.SH USAGE
|
||||
.TP
|
||||
.B Type text
|
||||
Fuzzy-search installed applications by name or keywords.
|
||||
.TP
|
||||
.B > command
|
||||
Run a shell command in a terminal emulator.
|
||||
.TP
|
||||
.B = expression
|
||||
Evaluate a math expression. Supports +, \-, *, /, parentheses, and functions
|
||||
such as sqrt, sin, cos, tan, ln, log2, log10, abs, ceil, floor, round.
|
||||
Constants: pi, e. Result is copied to clipboard on Enter.
|
||||
.TP
|
||||
.B ~/path \fRor\fB /path
|
||||
Browse the filesystem. Tab-like prefix matching on directory entries.
|
||||
.SH KEYBOARD
|
||||
.TP
|
||||
.B Enter
|
||||
Launch the selected result (or copy to clipboard for calculator results).
|
||||
.TP
|
||||
.B Escape
|
||||
Close the launcher.
|
||||
.TP
|
||||
.B Arrow Up / Arrow Down
|
||||
Navigate through results.
|
||||
.SH CONFIGURATION
|
||||
Configuration is stored in
|
||||
.IR ~/.config/k\-launcher/config.toml .
|
||||
If the file is absent, sensible defaults are used. A parse error is logged
|
||||
as a warning and defaults are used.
|
||||
.PP
|
||||
See
|
||||
.I config.example.toml
|
||||
in the source repository for all available options.
|
||||
.SS [window]
|
||||
.TP
|
||||
.BR width " (float, default: 600.0)"
|
||||
Window width in pixels.
|
||||
.TP
|
||||
.BR height " (float, default: 400.0)"
|
||||
Window height in pixels.
|
||||
.TP
|
||||
.BR decorations " (bool, default: false)"
|
||||
Show window decorations.
|
||||
.TP
|
||||
.BR transparent " (bool, default: true)"
|
||||
Enable window transparency.
|
||||
.SS [appearance]
|
||||
.TP
|
||||
.BR background_rgba " (array, default: [20, 20, 30, 0.9])"
|
||||
Background color as [R, G, B, A] where RGB are 0\-255 and A is 0.0\-1.0.
|
||||
.TP
|
||||
.BR border_rgba " (array, default: [229, 125, 33, 1.0])"
|
||||
Border color.
|
||||
.TP
|
||||
.BR placeholder " (string)"
|
||||
Search bar placeholder text.
|
||||
.TP
|
||||
.BR icon_size " (float, default: 24.0)"
|
||||
Application icon size in pixels.
|
||||
.SS [search]
|
||||
.TP
|
||||
.BR max_results " (integer, default: 8)"
|
||||
Maximum number of results to display.
|
||||
.TP
|
||||
.BR debounce_ms " (integer, default: 50)"
|
||||
Milliseconds to wait after last keystroke before searching.
|
||||
.TP
|
||||
.BR frecency_compact_threshold " (integer, default: 50)"
|
||||
Number of frecency log entries before compacting to a snapshot.
|
||||
.SS [plugins]
|
||||
.TP
|
||||
.BR calc " (bool, default: true)"
|
||||
Enable the calculator plugin.
|
||||
.TP
|
||||
.BR cmd " (bool, default: true)"
|
||||
Enable the shell command plugin.
|
||||
.TP
|
||||
.BR files " (bool, default: true)"
|
||||
Enable the file browser plugin.
|
||||
.TP
|
||||
.BR apps " (bool, default: true)"
|
||||
Enable the application search plugin.
|
||||
.SS [[plugins.external]]
|
||||
External plugins are executables that communicate via JSON over stdin/stdout.
|
||||
.TP
|
||||
.BR name " (string, required)"
|
||||
Display name for the plugin.
|
||||
.TP
|
||||
.BR path " (string, required)"
|
||||
Path to the plugin executable.
|
||||
.TP
|
||||
.BR args " (array of strings, default: [])"
|
||||
Arguments to pass to the plugin.
|
||||
.TP
|
||||
.BR timeout_secs " (integer, default: 5)"
|
||||
Search timeout in seconds per query.
|
||||
.SS [logging]
|
||||
.TP
|
||||
.BR max_log_files " (integer, default: 7)"
|
||||
Number of daily log files to keep.
|
||||
.SS [terminal]
|
||||
.TP
|
||||
.BR cmd " (string, optional)"
|
||||
Terminal emulator command for
|
||||
.B > command
|
||||
execution. If unset, detected from
|
||||
.BR $TERM_CMD ,
|
||||
.BR $TERMINAL ,
|
||||
or PATH (foot, kitty, alacritty, wezterm, konsole, xterm).
|
||||
.SH FILES
|
||||
.TP
|
||||
.I ~/.config/k\-launcher/config.toml
|
||||
User configuration file.
|
||||
.TP
|
||||
.I ~/.local/share/k\-launcher/frecency.json
|
||||
Frecency snapshot (launch history).
|
||||
.TP
|
||||
.I ~/.local/share/k\-launcher/frecency.log
|
||||
Frecency append-only log (compacted periodically).
|
||||
.TP
|
||||
.I ~/.local/share/k\-launcher/logs/
|
||||
Daily log files.
|
||||
.TP
|
||||
.I ~/.cache/k\-launcher/apps.bin
|
||||
Cached desktop entry data (bincode).
|
||||
.SH PLUGINS
|
||||
See
|
||||
.I docs/plugin\-development.md
|
||||
in the source repository for the external plugin protocol and a guide to
|
||||
writing built-in plugins.
|
||||
.SH SIGNALS
|
||||
.TP
|
||||
.BR SIGINT ", " SIGTERM
|
||||
Graceful shutdown. Frecency data is compacted before exit.
|
||||
.SH EXIT STATUS
|
||||
.TP
|
||||
.B 0
|
||||
Normal exit.
|
||||
.TP
|
||||
.B 1
|
||||
Fatal error (UI initialization failure).
|
||||
.SH AUTHORS
|
||||
Written by Gabriel Kaszewski.
|
||||
.SH LICENSE
|
||||
MIT License. See LICENSE in the source repository.
|
||||
.SH SEE ALSO
|
||||
.BR wl\-copy (1),
|
||||
.BR xdg\-open (1)
|
||||
Reference in New Issue
Block a user