From 051d19d8785b8311cc783b00398a6f68bfffed4c Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 24 Jul 2026 13:42:14 +0200 Subject: [PATCH] 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 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: https://git.gabrielkaszewski.dev/GKaszewski/k-launcher/pulls/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 --- .gitignore | 2 + ARCHITECTURE.md | 110 ---- CLAUDE.md | 12 +- Cargo.lock | 135 +++-- Cargo.toml | 26 +- LICENSE | 21 + Makefile | 18 +- README.md | 73 ++- config.example.toml | 52 ++ contrib/PKGBUILD | 43 ++ crates/k-launcher-config/Cargo.toml | 10 +- crates/k-launcher-config/src/config.rs | 14 + crates/k-launcher-config/src/error.rs | 18 + crates/k-launcher-config/src/lib.rs | 197 +------ crates/k-launcher-config/src/load.rs | 25 + crates/k-launcher-config/src/types.rs | 200 +++++++ crates/k-launcher-config/tests/config.rs | 75 +++ crates/k-launcher-domain/Cargo.toml | 8 + crates/k-launcher-domain/src/action.rs | 7 + crates/k-launcher-domain/src/constants.rs | 6 + crates/k-launcher-domain/src/lib.rs | 10 + crates/k-launcher-domain/src/newtypes.rs | 46 ++ crates/k-launcher-domain/src/ports.rs | 15 + crates/k-launcher-domain/src/search_result.rs | 25 + crates/k-launcher-domain/tests/newtypes.rs | 16 + crates/k-launcher-kernel/Cargo.toml | 13 +- crates/k-launcher-kernel/src/kernel.rs | 56 ++ crates/k-launcher-kernel/src/lib.rs | 281 +--------- crates/k-launcher-kernel/tests/integration.rs | 42 ++ crates/k-launcher-kernel/tests/kernel.rs | 107 ++++ crates/k-launcher-os-bridge/Cargo.toml | 5 +- crates/k-launcher-os-bridge/src/launcher.rs | 50 ++ crates/k-launcher-os-bridge/src/lib.rs | 9 +- crates/k-launcher-os-bridge/src/shell.rs | 22 + crates/k-launcher-os-bridge/src/spawn.rs | 49 ++ crates/k-launcher-os-bridge/src/terminal.rs | 86 +++ .../k-launcher-os-bridge/src/unix_launcher.rs | 190 ------- .../k-launcher-os-bridge/tests/shell_split.rs | 37 ++ crates/k-launcher-plugin-host/Cargo.toml | 7 +- crates/k-launcher-plugin-host/src/error.rs | 13 + crates/k-launcher-plugin-host/src/lib.rs | 222 +------- crates/k-launcher-plugin-host/src/plugin.rs | 137 +++++ crates/k-launcher-plugin-host/src/protocol.rs | 27 + .../k-launcher-plugin-host/tests/protocol.rs | 70 +++ crates/k-launcher-ui-core/Cargo.toml | 9 + crates/k-launcher-ui-core/src/lib.rs | 171 ++++++ crates/k-launcher-ui-core/tests/state.rs | 158 ++++++ crates/k-launcher-ui-egui/Cargo.toml | 9 +- crates/k-launcher-ui-egui/src/app.rs | 249 ++++----- crates/k-launcher-ui-egui/src/input.rs | 25 + crates/k-launcher-ui-egui/src/lib.rs | 14 +- crates/k-launcher-ui-egui/src/render.rs | 58 ++ crates/k-launcher-ui-egui/src/style.rs | 39 ++ crates/k-launcher-ui/Cargo.toml | 12 +- crates/k-launcher-ui/src/app.rs | 274 +-------- crates/k-launcher-ui/src/lib.rs | 22 +- crates/k-launcher-ui/src/style.rs | 60 ++ crates/k-launcher-ui/src/update.rs | 77 +++ crates/k-launcher-ui/src/view.rs | 164 ++++++ crates/k-launcher/Cargo.toml | 26 +- crates/k-launcher/src/engine.rs | 39 ++ crates/k-launcher/src/error.rs | 7 + crates/k-launcher/src/logging.rs | 53 ++ crates/k-launcher/src/main.rs | 97 ++-- crates/k-launcher/src/main_egui.rs | 55 +- crates/plugins/plugin-apps/Cargo.toml | 11 +- crates/plugins/plugin-apps/src/cache.rs | 110 ++++ crates/plugins/plugin-apps/src/frecency.rs | 227 +++++--- crates/plugins/plugin-apps/src/lib.rs | 525 +----------------- crates/plugins/plugin-apps/src/linux.rs | 46 +- crates/plugins/plugin-apps/src/plugin.rs | 142 +++++ crates/plugins/plugin-apps/src/scoring.rs | 60 ++ crates/plugins/plugin-apps/src/types.rs | 53 ++ crates/plugins/plugin-apps/tests/frecency.rs | 21 + crates/plugins/plugin-apps/tests/linux.rs | 27 + crates/plugins/plugin-apps/tests/plugin.rs | 260 +++++++++ crates/plugins/plugin-calc/Cargo.toml | 8 +- crates/plugins/plugin-calc/src/eval.rs | 45 ++ crates/plugins/plugin-calc/src/lib.rs | 156 +----- crates/plugins/plugin-calc/src/plugin.rs | 59 ++ crates/plugins/plugin-calc/tests/calc.rs | 53 ++ crates/plugins/plugin-cmd/Cargo.toml | 5 +- crates/plugins/plugin-cmd/src/lib.rs | 37 +- crates/plugins/plugin-cmd/tests/cmd.rs | 25 + crates/plugins/plugin-files/Cargo.toml | 8 +- crates/plugins/plugin-files/src/lib.rs | 31 +- crates/plugins/plugin-files/tests/files.rs | 15 + crates/plugins/plugin-url/Cargo.toml | 9 +- crates/plugins/plugin-url/src/lib.rs | 60 ++ crates/plugins/plugin-url/src/main.rs | 111 +--- crates/plugins/plugin-url/tests/url.rs | 56 ++ docs/configuration.md | 114 ++-- docs/install.md | 52 +- docs/plugin-development.md | 21 +- man/k-launcher.1 | 168 ++++++ 95 files changed, 4129 insertions(+), 2591 deletions(-) delete mode 100644 ARCHITECTURE.md create mode 100644 LICENSE create mode 100644 config.example.toml create mode 100644 contrib/PKGBUILD create mode 100644 crates/k-launcher-config/src/config.rs create mode 100644 crates/k-launcher-config/src/error.rs create mode 100644 crates/k-launcher-config/src/load.rs create mode 100644 crates/k-launcher-config/src/types.rs create mode 100644 crates/k-launcher-config/tests/config.rs create mode 100644 crates/k-launcher-domain/Cargo.toml create mode 100644 crates/k-launcher-domain/src/action.rs create mode 100644 crates/k-launcher-domain/src/constants.rs create mode 100644 crates/k-launcher-domain/src/lib.rs create mode 100644 crates/k-launcher-domain/src/newtypes.rs create mode 100644 crates/k-launcher-domain/src/ports.rs create mode 100644 crates/k-launcher-domain/src/search_result.rs create mode 100644 crates/k-launcher-domain/tests/newtypes.rs create mode 100644 crates/k-launcher-kernel/src/kernel.rs create mode 100644 crates/k-launcher-kernel/tests/integration.rs create mode 100644 crates/k-launcher-kernel/tests/kernel.rs create mode 100644 crates/k-launcher-os-bridge/src/launcher.rs create mode 100644 crates/k-launcher-os-bridge/src/shell.rs create mode 100644 crates/k-launcher-os-bridge/src/spawn.rs create mode 100644 crates/k-launcher-os-bridge/src/terminal.rs delete mode 100644 crates/k-launcher-os-bridge/src/unix_launcher.rs create mode 100644 crates/k-launcher-os-bridge/tests/shell_split.rs create mode 100644 crates/k-launcher-plugin-host/src/error.rs create mode 100644 crates/k-launcher-plugin-host/src/plugin.rs create mode 100644 crates/k-launcher-plugin-host/src/protocol.rs create mode 100644 crates/k-launcher-plugin-host/tests/protocol.rs create mode 100644 crates/k-launcher-ui-core/Cargo.toml create mode 100644 crates/k-launcher-ui-core/src/lib.rs create mode 100644 crates/k-launcher-ui-core/tests/state.rs create mode 100644 crates/k-launcher-ui-egui/src/input.rs create mode 100644 crates/k-launcher-ui-egui/src/render.rs create mode 100644 crates/k-launcher-ui-egui/src/style.rs create mode 100644 crates/k-launcher-ui/src/style.rs create mode 100644 crates/k-launcher-ui/src/update.rs create mode 100644 crates/k-launcher-ui/src/view.rs create mode 100644 crates/k-launcher/src/engine.rs create mode 100644 crates/k-launcher/src/error.rs create mode 100644 crates/k-launcher/src/logging.rs create mode 100644 crates/plugins/plugin-apps/src/cache.rs create mode 100644 crates/plugins/plugin-apps/src/plugin.rs create mode 100644 crates/plugins/plugin-apps/src/scoring.rs create mode 100644 crates/plugins/plugin-apps/src/types.rs create mode 100644 crates/plugins/plugin-apps/tests/frecency.rs create mode 100644 crates/plugins/plugin-apps/tests/linux.rs create mode 100644 crates/plugins/plugin-apps/tests/plugin.rs create mode 100644 crates/plugins/plugin-calc/src/eval.rs create mode 100644 crates/plugins/plugin-calc/src/plugin.rs create mode 100644 crates/plugins/plugin-calc/tests/calc.rs create mode 100644 crates/plugins/plugin-cmd/tests/cmd.rs create mode 100644 crates/plugins/plugin-files/tests/files.rs create mode 100644 crates/plugins/plugin-url/src/lib.rs create mode 100644 crates/plugins/plugin-url/tests/url.rs create mode 100644 man/k-launcher.1 diff --git a/.gitignore b/.gitignore index 873c9dc..e00c8c4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ target/ .worktrees/ +docs/superpowers/ +.superpowers/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index 1206e91..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -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; - -// SearchEngine trait — implemented by Kernel -async fn search(&self, query: &str) -> Vec; - -// 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. diff --git a/CLAUDE.md b/CLAUDE.md index ca751a8..a152217 100644 --- a/CLAUDE.md +++ b/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 | --- diff --git a/Cargo.lock b/Cargo.lock index 541b0ae..c90d25d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index faca5af..2b350f3 100644 --- a/Cargo.toml +++ b/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" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..31bafd2 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile index c0355e7..26592d8 100644 --- a/Makefile +++ b/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 diff --git a/README.md b/README.md index df4aebb..94fbe2f 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,54 @@ # k-launcher -A lightweight, GPU-accelerated command palette for Linux (Wayland/X11). Zero Electron — every pixel rendered via WGPU. Async search that never blocks the UI. +A lightweight command palette for Linux (Wayland/X11). Fuzzy search, frecency ranking, plugin system. Written in Rust. ## Quick Start ```bash git clone https://github.com/GKaszewski/k-launcher cd k-launcher -cargo build --release -./target/release/k-launcher +make install ``` +Or with cargo directly: + +```bash +cargo build --release +cp target/release/k-launcher ~/.local/bin/ +``` + +### Arch Linux (AUR) + +```bash +yay -S k-launcher +``` + +## Usage + +| Input | What it does | Example | +|---|---|---| +| any text | Fuzzy-search installed apps | `firefox` | +| empty | Show most-used apps (frecency) | | +| `>` prefix | Run shell command in terminal | `> htop` | +| `=` or math | Evaluate expression, copy result | `2^10 + 5` | +| `/` or `~/` | Browse filesystem | `~/Documents` | + ## Keybinds -| Key | Action | -| --------- | --------------- | -| Type | Filter results | -| `↑` / `↓` | Navigate | -| `Enter` | Launch selected | -| `Escape` | Close | +| Key | Action | +|---|---| +| `↑` / `↓` | Navigate results | +| `Enter` | Launch / copy | +| `Escape` | Close | + +## Configuration + +`~/.config/k-launcher/config.toml` — all fields optional, sensible defaults. + +See [config.example.toml](config.example.toml) for all available options. ## Compositor Setup -k-launcher uses a normal window; configure your compositor to float it. - **Hyprland** (`~/.config/hypr/hyprland.conf`): ``` @@ -39,24 +64,15 @@ for_window [app_id="k-launcher"] floating enable, move position center bindsym Mod4+space exec k-launcher ``` -## Built-in Plugins +## Plugins -| Trigger | Plugin | Example | -| ----------------- | ------ | -------------- | -| (any text) | Apps | `firefox` | -| number/expression | Calc | `2^10 + 5` | -| `>` prefix | Shell | `> echo hello` | -| `/` or `~/` | Files | `~/Documents` | - -## External Plugins - -Drop in community plugins — any language, no recompilation. Plugins are executables that communicate over stdin/stdout JSON: +Built-in plugins (calc, apps, shell, files) are enabled by default. External plugins communicate via JSON over stdin/stdout — any language, no recompilation: ```toml -# ~/.config/k-launcher/config.toml [[plugins.external]] name = "my-plugin" -path = "/usr/lib/k-launcher/plugins/my-plugin" +path = "/path/to/plugin" +timeout_secs = 5 ``` See [Plugin Development](docs/plugin-development.md) for the full protocol. @@ -64,6 +80,11 @@ See [Plugin Development](docs/plugin-development.md) for the full protocol. ## Docs - [Installation](docs/install.md) -- [Usage & Keybinds](docs/usage.md) -- [Configuration & Theming](docs/configuration.md) +- [Usage](docs/usage.md) +- [Configuration](docs/configuration.md) - [Plugin Development](docs/plugin-development.md) +- `man k-launcher` + +## License + +[MIT](LICENSE) diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..d5cb1fe --- /dev/null +++ b/config.example.toml @@ -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" diff --git a/contrib/PKGBUILD b/contrib/PKGBUILD new file mode 100644 index 0000000..bae2f0b --- /dev/null +++ b/contrib/PKGBUILD @@ -0,0 +1,43 @@ +# Maintainer: Gabriel Kaszewski +# 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" +} diff --git a/crates/k-launcher-config/Cargo.toml b/crates/k-launcher-config/Cargo.toml index b2df0a2..127ce5e 100644 --- a/crates/k-launcher-config/Cargo.toml +++ b/crates/k-launcher-config/Cargo.toml @@ -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" diff --git a/crates/k-launcher-config/src/config.rs b/crates/k-launcher-config/src/config.rs new file mode 100644 index 0000000..936e4b0 --- /dev/null +++ b/crates/k-launcher-config/src/config.rs @@ -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, +} diff --git a/crates/k-launcher-config/src/error.rs b/crates/k-launcher-config/src/error.rs new file mode 100644 index 0000000..6cda9e1 --- /dev/null +++ b/crates/k-launcher-config/src/error.rs @@ -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, + }, +} diff --git a/crates/k-launcher-config/src/lib.rs b/crates/k-launcher-config/src/lib.rs index 21b184f..5e982c3 100644 --- a/crates/k-launcher-config/src/lib.rs +++ b/crates/k-launcher-config/src/lib.rs @@ -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, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(default)] -pub struct PluginsCfg { - pub calc: bool, - pub cmd: bool, - pub files: bool, - pub apps: bool, - pub external: Vec, -} - -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::*; diff --git a/crates/k-launcher-config/src/load.rs b/crates/k-launcher-config/src/load.rs new file mode 100644 index 0000000..e9fc6b2 --- /dev/null +++ b/crates/k-launcher-config/src/load.rs @@ -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 { + 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 }) +} diff --git a/crates/k-launcher-config/src/types.rs b/crates/k-launcher-config/src/types.rs new file mode 100644 index 0000000..ca12d5a --- /dev/null +++ b/crates/k-launcher-config/src/types.rs @@ -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>(deserializer: D) -> Result { + 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, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct ExternalPluginCfg { + pub name: String, + pub path: String, + pub args: Vec, + 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, +} + +impl Default for PluginsCfg { + fn default() -> Self { + Self { + calc: true, + cmd: true, + files: true, + apps: true, + external: vec![], + } + } +} diff --git a/crates/k-launcher-config/tests/config.rs b/crates/k-launcher-config/tests/config.rs new file mode 100644 index 0000000..d101b5b --- /dev/null +++ b/crates/k-launcher-config/tests/config.rs @@ -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); +} diff --git a/crates/k-launcher-domain/Cargo.toml b/crates/k-launcher-domain/Cargo.toml new file mode 100644 index 0000000..2d977ec --- /dev/null +++ b/crates/k-launcher-domain/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "k-launcher-domain" +version = "0.2.0" +edition = "2024" + +[dependencies] +async-trait = { workspace = true } +serde = { workspace = true } diff --git a/crates/k-launcher-domain/src/action.rs b/crates/k-launcher-domain/src/action.rs new file mode 100644 index 0000000..4686549 --- /dev/null +++ b/crates/k-launcher-domain/src/action.rs @@ -0,0 +1,7 @@ +#[derive(Clone)] +pub enum LaunchAction { + SpawnProcess(String), + SpawnInTerminal(String), + OpenPath(String), + CopyToClipboard(String), +} diff --git a/crates/k-launcher-domain/src/constants.rs b/crates/k-launcher-domain/src/constants.rs new file mode 100644 index 0000000..b3c73de --- /dev/null +++ b/crates/k-launcher-domain/src/constants.rs @@ -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"; diff --git a/crates/k-launcher-domain/src/lib.rs b/crates/k-launcher-domain/src/lib.rs new file mode 100644 index 0000000..bb4ee4b --- /dev/null +++ b/crates/k-launcher-domain/src/lib.rs @@ -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::*; diff --git a/crates/k-launcher-domain/src/newtypes.rs b/crates/k-launcher-domain/src/newtypes.rs new file mode 100644 index 0000000..c0f78b8 --- /dev/null +++ b/crates/k-launcher-domain/src/newtypes.rs @@ -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) -> 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) -> 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)) + } +} diff --git a/crates/k-launcher-domain/src/ports.rs b/crates/k-launcher-domain/src/ports.rs new file mode 100644 index 0000000..fa1d06a --- /dev/null +++ b/crates/k-launcher-domain/src/ports.rs @@ -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; + fn on_selected(&self, _id: &ResultId) {} + fn shutdown(&self) {} +} diff --git a/crates/k-launcher-domain/src/search_result.rs b/crates/k-launcher-domain/src/search_result.rs new file mode 100644 index 0000000..807f880 --- /dev/null +++ b/crates/k-launcher-domain/src/search_result.rs @@ -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>, + pub icon: Option>, + 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() + } +} diff --git a/crates/k-launcher-domain/tests/newtypes.rs b/crates/k-launcher-domain/tests/newtypes.rs new file mode 100644 index 0000000..6ddf0cc --- /dev/null +++ b/crates/k-launcher-domain/tests/newtypes.rs @@ -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"); +} diff --git a/crates/k-launcher-kernel/Cargo.toml b/crates/k-launcher-kernel/Cargo.toml index a6fda6d..5b0ca05 100644 --- a/crates/k-launcher-kernel/Cargo.toml +++ b/crates/k-launcher-kernel/Cargo.toml @@ -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 } diff --git a/crates/k-launcher-kernel/src/kernel.rs b/crates/k-launcher-kernel/src/kernel.rs new file mode 100644 index 0000000..16b23a9 --- /dev/null +++ b/crates/k-launcher-kernel/src/kernel.rs @@ -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>, + max_results: usize, +} + +impl Kernel { + pub fn new(plugins: Vec>, 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 { + 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 = 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 + } +} diff --git a/crates/k-launcher-kernel/src/lib.rs b/crates/k-launcher-kernel/src/lib.rs index b387376..b0e293e 100644 --- a/crates/k-launcher-kernel/src/lib.rs +++ b/crates/k-launcher-kernel/src/lib.rs @@ -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) -> 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) -> 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, - pub icon: Option, - 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; - fn on_selected(&self, _id: &ResultId) {} -} - -// --- SearchEngine port trait --- - -#[async_trait] -pub trait SearchEngine: Send + Sync { - async fn search(&self, query: &str) -> Vec; - fn on_selected(&self, id: &ResultId); -} - -// --- NullSearchEngine --- - -pub struct NullSearchEngine; - -#[async_trait] -impl SearchEngine for NullSearchEngine { - async fn search(&self, _query: &str) -> Vec { - vec![] - } - fn on_selected(&self, _id: &ResultId) {} -} - -// --- Kernel (Application use case) --- - -pub struct Kernel { - plugins: Vec>, - max_results: usize, -} - -impl Kernel { - pub fn new(plugins: Vec>, 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 { - 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 = 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 { - 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 { - 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 { - 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::*; diff --git a/crates/k-launcher-kernel/tests/integration.rs b/crates/k-launcher-kernel/tests/integration.rs new file mode 100644 index 0000000..954b5be --- /dev/null +++ b/crates/k-launcher-kernel/tests/integration.rs @@ -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()); +} diff --git a/crates/k-launcher-kernel/tests/kernel.rs b/crates/k-launcher-kernel/tests/kernel.rs new file mode 100644 index 0000000..b5e0d4e --- /dev/null +++ b/crates/k-launcher-kernel/tests/kernel.rs @@ -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, +} + +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 { + 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 { + 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); +} diff --git a/crates/k-launcher-os-bridge/Cargo.toml b/crates/k-launcher-os-bridge/Cargo.toml index 6c82834..c8bb1ba 100644 --- a/crates/k-launcher-os-bridge/Cargo.toml +++ b/crates/k-launcher-os-bridge/Cargo.toml @@ -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 } diff --git a/crates/k-launcher-os-bridge/src/launcher.rs b/crates/k-launcher-os-bridge/src/launcher.rs new file mode 100644 index 0000000..b0f299d --- /dev/null +++ b/crates/k-launcher-os-bridge/src/launcher.rs @@ -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, +} + +impl UnixAppLauncher { + pub fn new(terminal_cmd: Option) -> 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); +} diff --git a/crates/k-launcher-os-bridge/src/lib.rs b/crates/k-launcher-os-bridge/src/lib.rs index cf60b50..81a074e 100644 --- a/crates/k-launcher-os-bridge/src/lib.rs +++ b/crates/k-launcher-os-bridge/src/lib.rs @@ -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; diff --git a/crates/k-launcher-os-bridge/src/shell.rs b/crates/k-launcher-os-bridge/src/shell.rs new file mode 100644 index 0000000..48ef8ee --- /dev/null +++ b/crates/k-launcher-os-bridge/src/shell.rs @@ -0,0 +1,22 @@ +pub fn shell_split(cmd: &str) -> Vec { + 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 +} diff --git a/crates/k-launcher-os-bridge/src/spawn.rs b/crates/k-launcher-os-bridge/src/spawn.rs new file mode 100644 index 0000000..d9b2087 --- /dev/null +++ b/crates/k-launcher-os-bridge/src/spawn.rs @@ -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}"); + } +} diff --git a/crates/k-launcher-os-bridge/src/terminal.rs b/crates/k-launcher-os-bridge/src/terminal.rs new file mode 100644 index 0000000..74b352d --- /dev/null +++ b/crates/k-launcher-os-bridge/src/terminal.rs @@ -0,0 +1,86 @@ +pub(crate) struct TerminalCommand { + pub bin: String, + pub exec_flag: Vec, +} + +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 { + 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 +} diff --git a/crates/k-launcher-os-bridge/src/unix_launcher.rs b/crates/k-launcher-os-bridge/src/unix_launcher.rs deleted file mode 100644 index 375a03d..0000000 --- a/crates/k-launcher-os-bridge/src/unix_launcher.rs +++ /dev/null @@ -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 { - 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) { - 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)> { - 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"]); - } -} diff --git a/crates/k-launcher-os-bridge/tests/shell_split.rs b/crates/k-launcher-os-bridge/tests/shell_split.rs new file mode 100644 index 0000000..cff12f5 --- /dev/null +++ b/crates/k-launcher-os-bridge/tests/shell_split.rs @@ -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"]); +} diff --git a/crates/k-launcher-plugin-host/Cargo.toml b/crates/k-launcher-plugin-host/Cargo.toml index 92ba544..91c724c 100644 --- a/crates/k-launcher-plugin-host/Cargo.toml +++ b/crates/k-launcher-plugin-host/Cargo.toml @@ -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] diff --git a/crates/k-launcher-plugin-host/src/error.rs b/crates/k-launcher-plugin-host/src/error.rs new file mode 100644 index 0000000..f325ae5 --- /dev/null +++ b/crates/k-launcher-plugin-host/src/error.rs @@ -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), +} diff --git a/crates/k-launcher-plugin-host/src/lib.rs b/crates/k-launcher-plugin-host/src/lib.rs index f921562..f5c1f76 100644 --- a/crates/k-launcher-plugin-host/src/lib.rs +++ b/crates/k-launcher-plugin-host/src/lib.rs @@ -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, - #[serde(default)] - icon: Option, - 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, - stdout: BufReader, -} - -async fn do_search( - io: &mut ProcessIo, - query: &str, -) -> Result, Box> { - 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, - inner: Mutex>, -} - -impl ExternalPlugin { - pub fn new(name: impl Into, path: impl Into, args: Vec) -> Self { - Self { - name: name.into(), - path: path.into(), - args, - inner: Mutex::new(None), - } - } - - async fn spawn(&self) -> std::io::Result { - 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 { - 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 = 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 = 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 = 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 = 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 = 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::>("not json").is_err()); - } - - // Unused import suppression for Arc (used only in production code path) - fn _assert_send_sync() { - fn check() {} - check::(); - } -} +pub use plugin::*; +pub use protocol::*; diff --git a/crates/k-launcher-plugin-host/src/plugin.rs b/crates/k-launcher-plugin-host/src/plugin.rs new file mode 100644 index 0000000..4ea9332 --- /dev/null +++ b/crates/k-launcher-plugin-host/src/plugin.rs @@ -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, + stdout: BufReader, +} + +async fn do_search(io: &mut ProcessIo, query: &str) -> Result, 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, + timeout_secs: u64, + inner: Mutex>, +} + +impl ExternalPlugin { + pub fn new( + name: impl Into, + path: impl Into, + args: Vec, + 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 { + 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 { + 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![] + } + } + } +} diff --git a/crates/k-launcher-plugin-host/src/protocol.rs b/crates/k-launcher-plugin-host/src/protocol.rs new file mode 100644 index 0000000..940431a --- /dev/null +++ b/crates/k-launcher-plugin-host/src/protocol.rs @@ -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, + #[serde(default)] + pub icon: Option, + pub action: ExternalAction, +} + +#[derive(Deserialize)] +#[serde(tag = "type")] +pub enum ExternalAction { + SpawnProcess { cmd: String }, + SpawnInTerminal { cmd: String }, + CopyToClipboard { text: String }, + OpenPath { path: String }, +} diff --git a/crates/k-launcher-plugin-host/tests/protocol.rs b/crates/k-launcher-plugin-host/tests/protocol.rs new file mode 100644 index 0000000..872113e --- /dev/null +++ b/crates/k-launcher-plugin-host/tests/protocol.rs @@ -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 = 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 = 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 = 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 = 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 = 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 = 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::>("not json").is_err()); +} + +fn _assert_send_sync() { + fn check() {} + check::(); +} diff --git a/crates/k-launcher-ui-core/Cargo.toml b/crates/k-launcher-ui-core/Cargo.toml new file mode 100644 index 0000000..4f11f29 --- /dev/null +++ b/crates/k-launcher-ui-core/Cargo.toml @@ -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 } diff --git a/crates/k-launcher-ui-core/src/lib.rs b/crates/k-launcher-ui-core/src/lib.rs new file mode 100644 index 0000000..2d1df3e --- /dev/null +++ b/crates/k-launcher-ui-core/src/lib.rs @@ -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, + selected: usize, + engine: Option>, + launcher: Arc, + cfg: AppearanceCfg, + debounce_ms: u64, + search_epoch: u64, + error: Option, +} + +pub enum Action { + QueryChanged(String), + MoveDown, + MoveUp, + LaunchSelected, + Exit, + EngineReady(Arc), + EngineInitFailed(String), + ResultsReady { + epoch: u64, + results: Vec, + }, +} + +pub enum Effect { + SearchAfterDebounce { + query: String, + debounce_ms: u64, + epoch: u64, + }, + LaunchAndExit(LaunchAction), + Exit, + TriggerSearch(String), + None, +} + +impl LauncherState { + pub fn new(launcher: Arc, 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> { + self.engine.as_ref() + } + + pub fn launcher(&self) -> &Arc { + &self.launcher + } + + pub fn search_epoch(&self) -> u64 { + self.search_epoch + } + + fn shutdown_engine(&self) { + if let Some(engine) = &self.engine { + engine.shutdown(); + } + } +} diff --git a/crates/k-launcher-ui-core/tests/state.rs b/crates/k-launcher-ui-core/tests/state.rs new file mode 100644 index 0000000..c4c14e8 --- /dev/null +++ b/crates/k-launcher-ui-core/tests/state.rs @@ -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")); +} diff --git a/crates/k-launcher-ui-egui/Cargo.toml b/crates/k-launcher-ui-egui/Cargo.toml index 9c65657..b3e3be7 100644 --- a/crates/k-launcher-ui-egui/Cargo.toml +++ b/crates/k-launcher-ui-egui/Cargo.toml @@ -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 } diff --git a/crates/k-launcher-ui-egui/src/app.rs b/crates/k-launcher-ui-egui/src/app.rs index 5bfa1ff..9fb7070 100644 --- a/crates/k-launcher-ui-egui/src/app.rs +++ b/crates/k-launcher-ui-egui/src/app.rs @@ -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, - launcher: Arc, - query: String, - results: Vec, - selected: usize, + pub(crate) inner: LauncherState, rt: tokio::runtime::Handle, result_tx: mpsc::SyncSender>, - result_rx: mpsc::Receiver>, + pub(crate) result_rx: mpsc::Receiver>, } impl KLauncherApp { fn new( - engine: Arc, + engine: Arc, launcher: Arc, rt: tokio::runtime::Handle, + cfg: AppearanceCfg, ) -> Self { - let (result_tx, result_rx) = mpsc::sync_channel(4); - Self { - engine, - launcher, - query: String::new(), - results: vec![], - selected: 0, + const RESULT_CHANNEL_CAPACITY: usize = 4; + let (result_tx, result_rx) = mpsc::sync_channel(RESULT_CHANNEL_CAPACITY); + let mut inner = LauncherState::new(launcher, cfg, 0); + + let effect = inner.handle(Action::EngineReady(engine)); + + let app = Self { + inner, rt, result_tx, result_rx, - } + }; + + app.execute_effect(effect); + app } fn trigger_search(&self, query: String) { - let engine = self.engine.clone(); + let Some(engine) = self.inner.engine().cloned() else { + return; + }; let tx = self.result_tx.clone(); self.rt.spawn(async move { let results = engine.search(&query).await; - let _ = tx.send(results); + if let Err(e) = tx.send(results) { + tracing::warn!("search result channel closed: {e}"); + } }); } + + fn poll_search_results(&mut self) { + if let Ok(results) = self.result_rx.try_recv() { + self.inner.handle(Action::ResultsReady { + epoch: self.inner.search_epoch(), + results, + }); + } + } + + fn execute_effect(&self, effect: Effect) { + match effect { + Effect::TriggerSearch(q) => self.trigger_search(q), + Effect::SearchAfterDebounce { query, .. } => self.trigger_search(query), + _ => {} + } + } + + fn handle_action(&mut self, action: Action, ctx: &egui::Context) { + let effect = self.inner.handle(action); + match effect { + Effect::LaunchAndExit(action) => { + self.inner.launcher().execute(&action); + ctx.send_viewport_cmd(ViewportCommand::Close); + } + Effect::Exit => { + ctx.send_viewport_cmd(ViewportCommand::Close); + } + other => self.execute_effect(other), + } + } + + fn render_panel(&mut self, ctx: &egui::Context) { + let cfg = self.inner.cfg().clone(); + egui::CentralPanel::default() + .frame(style::outer_frame(&cfg)) + .show(ctx, |ui| { + let query = self.inner.query().to_string(); + let mut query_buf = query; + let response = render::render_search_bar(ui, &mut query_buf, &cfg); + + if response.changed() { + self.handle_action(Action::QueryChanged(query_buf), ctx); + } + + response.request_focus(); + ui.add_space(8.0); + + if self.inner.is_loading() { + render::render_loading_state(ui, &cfg); + return; + } + + if self.inner.results().is_empty() && !self.inner.query().is_empty() { + render::render_empty_state(ui, &cfg); + return; + } + + render::render_result_list(ui, self.inner.results(), self.inner.selected(), &cfg); + }); + } } impl eframe::App for KLauncherApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - if let Ok(results) = self.result_rx.try_recv() { - self.results = results; - } + self.poll_search_results(); - let mut close = false; - let mut launch_selected = false; - - ctx.input(|i| { - if i.key_pressed(Key::Escape) { - close = true; - } - if i.key_pressed(Key::Enter) { - launch_selected = true; - } - if i.key_pressed(Key::ArrowDown) { - let len = self.results.len(); - if len > 0 { - self.selected = (self.selected + 1).min(len - 1); - } - } - if i.key_pressed(Key::ArrowUp) && self.selected > 0 { - self.selected -= 1; - } - }); - - if close { - ctx.send_viewport_cmd(ViewportCommand::Close); - return; - } - - if launch_selected { - if let Some(result) = self.results.get(self.selected) { - self.engine.on_selected(&result.id); - self.launcher.execute(&result.action); - } - ctx.send_viewport_cmd(ViewportCommand::Close); - return; - } - - let frame = egui::Frame::new() - .fill(BG) - .stroke(egui::Stroke::new(1.0, BORDER_COLOR)) - .inner_margin(egui::Margin::same(12)) - .corner_radius(egui::CornerRadius::same(8)); - - egui::CentralPanel::default().frame(frame).show(ctx, |ui| { - let response = ui.add_sized( - [ui.available_width(), 36.0], - egui::TextEdit::singleline(&mut self.query) - .hint_text("Search...") - .font(egui::TextStyle::Heading), - ); - - if response.changed() { - self.selected = 0; - self.trigger_search(self.query.clone()); - } - - response.request_focus(); - - ui.add_space(8.0); - - if self.results.is_empty() && !self.query.is_empty() { - ui.add_space(20.0); - ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| { - ui.colored_label(DIM_TEXT, "No results"); - }); + match process_input(ctx) { + InputAction::Close => { + self.handle_action(Action::Exit, ctx); return; } + InputAction::LaunchSelected => { + self.handle_action(Action::LaunchSelected, ctx); + return; + } + InputAction::MoveDown => { + self.inner.handle(Action::MoveDown); + } + InputAction::MoveUp => { + self.inner.handle(Action::MoveUp); + } + InputAction::None => {} + } - egui::ScrollArea::vertical().show(ui, |ui| { - ui.set_width(ui.available_width()); - for (i, result) in self.results.iter().enumerate() { - let is_selected = i == self.selected; - let bg = if is_selected { - SELECTED_BG - } else { - Color32::TRANSPARENT - }; - - let row_frame = egui::Frame::new() - .fill(bg) - .inner_margin(egui::Margin { - left: 8, - right: 8, - top: 6, - bottom: 6, - }) - .corner_radius(egui::CornerRadius::same(4)); - - row_frame.show(ui, |ui| { - ui.set_width(ui.available_width()); - ui.horizontal(|ui| { - ui.add_space(8.0); - ui.vertical(|ui| { - ui.label(result.title.as_str()); - if let Some(desc) = &result.description { - ui.colored_label(DIM_TEXT, desc); - } - }); - }); - }); - - ui.add_space(2.0); - } - }); - }); + self.render_panel(ctx); } } pub fn run( - engine: Arc, + engine: Arc, launcher: Arc, 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, + ))) + }), ) } diff --git a/crates/k-launcher-ui-egui/src/input.rs b/crates/k-launcher-ui-egui/src/input.rs new file mode 100644 index 0000000..90c94e2 --- /dev/null +++ b/crates/k-launcher-ui-egui/src/input.rs @@ -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 + } + }) +} diff --git a/crates/k-launcher-ui-egui/src/lib.rs b/crates/k-launcher-ui-egui/src/lib.rs index 2f2d848..4d7afab 100644 --- a/crates/k-launcher-ui-egui/src/lib.rs +++ b/crates/k-launcher-ui-egui/src/lib.rs @@ -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, + engine: Arc, launcher: Arc, 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()) } diff --git a/crates/k-launcher-ui-egui/src/render.rs b/crates/k-launcher-ui-egui/src/render.rs new file mode 100644 index 0000000..dfbfd25 --- /dev/null +++ b/crates/k-launcher-ui-egui/src/render.rs @@ -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()); + } + }); + }); + }); +} diff --git a/crates/k-launcher-ui-egui/src/style.rs b/crates/k-launcher-ui-egui/src/style.rs new file mode 100644 index 0000000..74a9741 --- /dev/null +++ b/crates/k-launcher-ui-egui/src/style.rs @@ -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)) +} diff --git a/crates/k-launcher-ui/Cargo.toml b/crates/k-launcher-ui/Cargo.toml index 21dbb72..9e606c0 100644 --- a/crates/k-launcher-ui/Cargo.toml +++ b/crates/k-launcher-ui/Cargo.toml @@ -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 } diff --git a/crates/k-launcher-ui/src/app.rs b/crates/k-launcher-ui/src/app.rs index 6e15098..f5e9abe 100644 --- a/crates/k-launcher-ui/src/app.rs +++ b/crates/k-launcher-ui/src/app.rs @@ -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 = +pub(crate) static INPUT_ID: std::sync::LazyLock = std::sync::LazyLock::new(|| iced::widget::Id::new("search")); #[derive(Clone)] -pub(crate) struct EngineHandle(Arc); +pub(crate) struct EngineHandle(pub(crate) Arc); 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, - launcher: Arc, - query: String, - results: Arc>, - selected: usize, - cfg: AppearanceCfg, - error: Option, - search_epoch: u64, -} - -impl KLauncherApp { - fn new( - engine: Arc, - launcher: Arc, - 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>), + ResultsReady { + epoch: u64, + results: Arc>, + }, KeyPressed(KeyEvent), EngineReady(EngineHandle), EngineInitFailed(String), } -fn update(state: &mut KLauncherApp, message: Message) -> Task { - 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> = 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> = 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> = - 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 { 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 { } pub fn run( - engine_factory: Arc Arc + Send + Sync>, + engine_factory: Arc Arc + Send + Sync>, launcher: Arc, 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), diff --git a/crates/k-launcher-ui/src/lib.rs b/crates/k-launcher-ui/src/lib.rs index 4269f26..905a123 100644 --- a/crates/k-launcher-ui/src/lib.rs +++ b/crates/k-launcher-ui/src/lib.rs @@ -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 Arc + Send + Sync>, + engine_factory: Arc Arc + Send + Sync>, launcher: Arc, 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()) } diff --git a/crates/k-launcher-ui/src/style.rs b/crates/k-launcher-ui/src/style.rs new file mode 100644 index 0000000..8f22ade --- /dev/null +++ b/crates/k-launcher-ui/src/style.rs @@ -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() + } +} diff --git a/crates/k-launcher-ui/src/update.rs b/crates/k-launcher-ui/src/update.rs new file mode 100644 index 0000000..137a7f5 --- /dev/null +++ b/crates/k-launcher-ui/src/update.rs @@ -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 { + 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 { + 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 { + 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(), + } +} diff --git a/crates/k-launcher-ui/src/view.rs b/crates/k-launcher-ui/src/view.rs new file mode 100644 index 0000000..af07121 --- /dev/null +++ b/crates/k-launcher-ui/src/view.rs @@ -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> = + 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> = 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>, 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() +} diff --git a/crates/k-launcher/Cargo.toml b/crates/k-launcher/Cargo.toml index 1deff96..7e2d117 100644 --- a/crates/k-launcher/Cargo.toml +++ b/crates/k-launcher/Cargo.toml @@ -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"] } diff --git a/crates/k-launcher/src/engine.rs b/crates/k-launcher/src/engine.rs new file mode 100644 index 0000000..ee87cc9 --- /dev/null +++ b/crates/k-launcher/src/engine.rs @@ -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) -> Arc { + let frecency = FrecencyStore::load(cfg.search.frecency_compact_threshold); + let mut plugins: Vec> = 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)) +} diff --git a/crates/k-launcher/src/error.rs b/crates/k-launcher/src/error.rs new file mode 100644 index 0000000..e3c407b --- /dev/null +++ b/crates/k-launcher/src/error.rs @@ -0,0 +1,7 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AppError { + #[error("UI error: {0}")] + Ui(String), +} diff --git a/crates/k-launcher/src/logging.rs b/crates/k-launcher/src/logging.rs new file mode 100644 index 0000000..2386aff --- /dev/null +++ b/crates/k-launcher/src/logging.rs @@ -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::() { + s.clone() + } else { + "unknown panic".to_string() + }; + tracing::error!("PANIC at {location}: {payload}"); + default_hook(info); + })); +} diff --git a/crates/k-launcher/src/main.rs b/crates/k-launcher/src/main.rs index 355b36d..842d8fa 100644 --- a/crates/k-launcher/src/main.rs +++ b/crates/k-launcher/src/main.rs @@ -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) -> Arc { - let frecency = FrecencyStore::load(); - let mut plugins: Vec> = 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) -> Result<(), AppError> { + let launcher = Arc::new(UnixAppLauncher::new(cfg.terminal.cmd.clone())); let factory_cfg = cfg.clone(); - let factory: Arc Arc + Send + Sync> = - Arc::new(move || build_engine(factory_cfg.clone())); - k_launcher_ui::run(factory, launcher, &cfg.window, cfg.appearance.clone()) + let factory: Arc Arc + 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) } diff --git a/crates/k-launcher/src/main_egui.rs b/crates/k-launcher/src/main_egui.rs index f632b5c..379f1f3 100644 --- a/crates/k-launcher/src/main_egui.rs +++ b/crates/k-launcher/src/main_egui.rs @@ -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> { - let cfg = k_launcher_config::load(); - let launcher = Arc::new(UnixAppLauncher::new()); - let frecency = FrecencyStore::load(); - let kernel: Arc = 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) -> 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) } diff --git a/crates/plugins/plugin-apps/Cargo.toml b/crates/plugins/plugin-apps/Cargo.toml index e1549e8..a584cea 100644 --- a/crates/plugins/plugin-apps/Cargo.toml +++ b/crates/plugins/plugin-apps/Cargo.toml @@ -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 } diff --git a/crates/plugins/plugin-apps/src/cache.rs b/crates/plugins/plugin-apps/src/cache.rs new file mode 100644 index 0000000..79a18e4 --- /dev/null +++ b/crates/plugins/plugin-apps/src/cache.rs @@ -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, + pub(crate) category: Option>, + pub(crate) icon: Option>, + pub(crate) exec: String, +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct CachedEntryData { + id: String, + name: String, + name_lowercase: String, + keywords_lowercase: Vec, + category: Option, + icon: Option, + exec: String, +} + +pub fn cache_path() -> Option { + dirs::cache_dir().map(|d| d.join("k-launcher/apps.bin")) +} + +pub fn load_from_path(path: &Path) -> Option> { + let data = std::fs::read(path).ok()?; + let (entries_data, _): (Vec, _) = + 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) { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).ok(); + } + let data: Vec = 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, +) -> HashMap { + 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> = 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> = 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() +} diff --git a/crates/plugins/plugin-apps/src/frecency.rs b/crates/plugins/plugin-apps/src/frecency.rs index 6870364..0b5d09e 100644 --- a/crates/plugins/plugin-apps/src/frecency.rs +++ b/crates/plugins/plugin-apps/src/frecency.rs @@ -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>, + log_count: Mutex, + compact_threshold: usize, } impl FrecencyStore { - pub fn new(path: PathBuf) -> Arc { - let data = std::fs::read_to_string(&path) + pub fn new(snapshot_path: PathBuf, compact_threshold: usize) -> Arc { + let mut data: HashMap = 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 { 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 { + pub fn load(compact_threshold: usize) -> Arc { let Some(data_home) = xdg::BaseDirectories::new().get_data_home() else { tracing::warn!("XDG_DATA_HOME unavailable; frecency disabled (in-memory only)"); - return Arc::new(Self { - path: PathBuf::from("/dev/null"), - data: Mutex::new(HashMap::new()), - }); + return Self::new_for_test(); }; - let path = data_home.join("k-launcher").join("frecency.json"); - Self::new(path) + let path = data_home + .join(k_launcher_domain::constants::APP_NAME) + .join(k_launcher_domain::constants::FRECENCY_SNAPSHOT_FILENAME); + Self::new(path, compact_threshold) } pub fn record(&self, id: &str) { @@ -55,26 +81,72 @@ impl FrecencyStore { .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); - let json = { - let mut data = self.data.lock().unwrap(); + { + let mut data = self.data.lock(); let entry = data.entry(id.to_string()).or_insert(Entry { count: 0, last_used: 0, }); entry.count += 1; entry.last_used = now; - serde_json::to_string(&*data).ok() - }; // lock released here - if let Some(json) = json { - if let Some(parent) = self.path.parent() { - let _ = std::fs::create_dir_all(parent); + } + self.append_log(id, now); + } + + fn append_log(&self, id: &str, ts: u64) { + let record = LogRecord { + id: id.to_string(), + ts, + }; + if let Ok(json) = serde_json::to_string(&record) { + if let Some(parent) = self.log_path.parent() + && let Err(e) = std::fs::create_dir_all(parent) + { + tracing::warn!("failed to create frecency dir: {e}"); } - let _ = std::fs::write(&self.path, json); + if let Ok(mut file) = OpenOptions::new() + .create(true) + .append(true) + .open(&self.log_path) + && let Err(e) = writeln!(file, "{json}") + { + tracing::warn!("failed to write frecency log: {e}"); + } + } + let mut count = self.log_count.lock(); + *count += 1; + if *count >= self.compact_threshold { + drop(count); + self.compact(); } } + pub fn compact(&self) { + let json = { + let data = self.data.lock(); + serde_json::to_string(&*data).ok() + }; + if let Some(json) = json { + if let Some(parent) = self.snapshot_path.parent() + && let Err(e) = std::fs::create_dir_all(parent) + { + tracing::warn!("failed to create frecency dir: {e}"); + } + if std::fs::write(&self.snapshot_path, json).is_ok() { + if let Err(e) = File::create(&self.log_path) { + tracing::warn!("failed to truncate frecency log: {e}"); + } + *self.log_count.lock() = 0; + } + } + } + + pub fn shutdown(&self) { + self.compact(); + } + pub fn frecency_score(&self, id: &str) -> u32 { - let data = self.data.lock().unwrap(); + let data = self.data.lock(); let Some(entry) = data.get(id) else { return 0 }; let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -85,70 +157,73 @@ impl FrecencyStore { } pub fn top_ids(&self, n: usize) -> Vec { - 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 = 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) -> 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::(&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 { - 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 } } diff --git a/crates/plugins/plugin-apps/src/lib.rs b/crates/plugins/plugin-apps/src/lib.rs index 36a921f..13d6880 100644 --- a/crates/plugins/plugin-apps/src/lib.rs +++ b/crates/plugins/plugin-apps/src/lib.rs @@ -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) -> 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) -> 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) -> 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, - pub category: Option, - pub keywords: Vec, -} - -// --- Swappable source trait (Application layer principle) --- - -pub trait DesktopEntrySource: Send + Sync { - fn entries(&self) -> Vec; -} - -// --- Cached entry (pre-computed at construction) --- - -struct CachedEntry { - id: String, - name: AppName, - keywords_lc: Vec, - category: Option, - icon: Option, - exec: String, -} - -// --- Serializable cache data (no closures) --- - -#[derive(serde::Serialize, serde::Deserialize)] -struct CachedEntryData { - id: String, - name: String, - keywords_lc: Vec, - category: Option, - icon: Option, - exec: String, -} - -fn cache_path() -> Option { - dirs::cache_dir().map(|d| d.join("k-launcher/apps.bin")) -} - -fn load_from_path(path: &Path) -> Option> { - let data = std::fs::read(path).ok()?; - let (entries_data, _): (Vec, _) = - 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) { - if let Some(dir) = path.parent() { - std::fs::create_dir_all(dir).ok(); - } - let data: Vec = 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, -) -> HashMap { - 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 = 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>>, - frecency: Arc, -} - -impl AppsPlugin { - pub fn new(source: impl DesktopEntrySource + 'static, frecency: Arc) -> Self { - Self::new_impl(source, frecency, cache_path()) - } - - fn new_impl( - source: impl DesktopEntrySource + 'static, - frecency: Arc, - cp: Option, - ) -> 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, - ) -> 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 { - 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 = 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 { - 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::new_for_test() - } - - struct MockSource { - entries: Vec<(String, String, Option, Vec)>, // (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 { - 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::*; diff --git a/crates/plugins/plugin-apps/src/linux.rs b/crates/plugins/plugin-apps/src/linux.rs index 6aa8c07..f91c576 100644 --- a/crates/plugins/plugin-apps/src/linux.rs +++ b/crates/plugins/plugin-apps/src/linux.rs @@ -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 = 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 { 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 { } // 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 { 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"); - } -} diff --git a/crates/plugins/plugin-apps/src/plugin.rs b/crates/plugins/plugin-apps/src/plugin.rs new file mode 100644 index 0000000..cccb2d8 --- /dev/null +++ b/crates/plugins/plugin-apps/src/plugin.rs @@ -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>>, + frecency: Arc, +} + +impl AppsPlugin { + pub fn new(source: impl DesktopEntrySource + 'static, frecency: Arc) -> Self { + Self::new_impl(source, frecency, cache_path()) + } + + fn new_impl( + source: impl DesktopEntrySource + 'static, + frecency: Arc, + cp: Option, + ) -> 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, + ) -> 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 { + 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 = 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() + } +} diff --git a/crates/plugins/plugin-apps/src/scoring.rs b/crates/plugins/plugin-apps/src/scoring.rs new file mode 100644 index 0000000..ac97168 --- /dev/null +++ b/crates/plugins/plugin-apps/src/scoring.rs @@ -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, + name_lowercase: &str, + query_lowercase: &str, +) -> Option { + 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 +} diff --git a/crates/plugins/plugin-apps/src/types.rs b/crates/plugins/plugin-apps/src/types.rs new file mode 100644 index 0000000..480a8d1 --- /dev/null +++ b/crates/plugins/plugin-apps/src/types.rs @@ -0,0 +1,53 @@ +// --- Domain newtypes --- + +#[derive(Debug, Clone)] +pub struct AppName(String); + +impl AppName { + pub fn new(s: impl Into) -> 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) -> 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) -> 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, + pub category: Option, + pub keywords: Vec, +} + +// --- Swappable source trait (Application layer principle) --- + +pub trait DesktopEntrySource: Send + Sync { + fn entries(&self) -> Vec; +} diff --git a/crates/plugins/plugin-apps/tests/frecency.rs b/crates/plugins/plugin-apps/tests/frecency.rs new file mode 100644 index 0000000..106a66d --- /dev/null +++ b/crates/plugins/plugin-apps/tests/frecency.rs @@ -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"); +} diff --git a/crates/plugins/plugin-apps/tests/linux.rs b/crates/plugins/plugin-apps/tests/linux.rs new file mode 100644 index 0000000..dee837b --- /dev/null +++ b/crates/plugins/plugin-apps/tests/linux.rs @@ -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"); + } +} diff --git a/crates/plugins/plugin-apps/tests/plugin.rs b/crates/plugins/plugin-apps/tests/plugin.rs new file mode 100644 index 0000000..9d57394 --- /dev/null +++ b/crates/plugins/plugin-apps/tests/plugin.rs @@ -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::new_for_test() +} + +struct MockEntry { + name: String, + exec: String, + category: Option, + keywords: Vec, +} + +struct MockSource { + entries: Vec, +} + +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 { + 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(); +} diff --git a/crates/plugins/plugin-calc/Cargo.toml b/crates/plugins/plugin-calc/Cargo.toml index 0d4333c..1f17647 100644 --- a/crates/plugins/plugin-calc/Cargo.toml +++ b/crates/plugins/plugin-calc/Cargo.toml @@ -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 } diff --git a/crates/plugins/plugin-calc/src/eval.rs b/crates/plugins/plugin-calc/src/eval.rs new file mode 100644 index 0000000..e75802f --- /dev/null +++ b/crates/plugins/plugin-calc/src/eval.rs @@ -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> = + LazyLock::new(|| { + use evalexpr::*; + context_map! { + "pi" => float std::f64::consts::PI, + "e" => float std::f64::consts::E, + "sqrt" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.sqrt()))), + "sin" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.sin()))), + "cos" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.cos()))), + "tan" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.tan()))), + "asin" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.asin()))), + "acos" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.acos()))), + "atan" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.atan()))), + "ln" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.ln()))), + "log2" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.log2()))), + "log10" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.log10()))), + "exp" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.exp()))), + "abs" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.abs()))), + "ceil" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.ceil()))), + "floor" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.floor()))), + "round" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.round()))) + } + .expect("static math context must be valid") + }); diff --git a/crates/plugins/plugin-calc/src/lib.rs b/crates/plugins/plugin-calc/src/lib.rs index 3e823eb..11e9705 100644 --- a/crates/plugins/plugin-calc/src/lib.rs +++ b/crates/plugins/plugin-calc/src/lib.rs @@ -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> = LazyLock::new( - || { - use evalexpr::*; - context_map! { - "pi" => float std::f64::consts::PI, - "e" => float std::f64::consts::E, - "sqrt" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.sqrt()))), - "sin" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.sin()))), - "cos" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.cos()))), - "tan" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.tan()))), - "asin" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.asin()))), - "acos" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.acos()))), - "atan" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.atan()))), - "ln" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.ln()))), - "log2" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.log2()))), - "log10" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.log10()))), - "exp" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.exp()))), - "abs" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.abs()))), - "ceil" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.ceil()))), - "floor" => Function::new(|a: &Value| Ok(Value::from_float(a.as_number()?.floor()))), - "round" => Function::new(|a: &Value| 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 { - 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::*; diff --git a/crates/plugins/plugin-calc/src/plugin.rs b/crates/plugins/plugin-calc/src/plugin.rs new file mode 100644 index 0000000..d9bcf59 --- /dev/null +++ b/crates/plugins/plugin-calc/src/plugin.rs @@ -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 { + 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![], + } + } +} diff --git a/crates/plugins/plugin-calc/tests/calc.rs b/crates/plugins/plugin-calc/tests/calc.rs new file mode 100644 index 0000000..5c76222 --- /dev/null +++ b/crates/plugins/plugin-calc/tests/calc.rs @@ -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" + )); +} diff --git a/crates/plugins/plugin-cmd/Cargo.toml b/crates/plugins/plugin-cmd/Cargo.toml index fe3ead2..e6cd994 100644 --- a/crates/plugins/plugin-cmd/Cargo.toml +++ b/crates/plugins/plugin-cmd/Cargo.toml @@ -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 } diff --git a/crates/plugins/plugin-cmd/src/lib.rs b/crates/plugins/plugin-cmd/src/lib.rs index 50d0165..47aacf0 100644 --- a/crates/plugins/plugin-cmd/src/lib.rs +++ b/crates/plugins/plugin-cmd/src/lib.rs @@ -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 { - 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()); - } -} diff --git a/crates/plugins/plugin-cmd/tests/cmd.rs b/crates/plugins/plugin-cmd/tests/cmd.rs new file mode 100644 index 0000000..acdbd01 --- /dev/null +++ b/crates/plugins/plugin-cmd/tests/cmd.rs @@ -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()); +} diff --git a/crates/plugins/plugin-files/Cargo.toml b/crates/plugins/plugin-files/Cargo.toml index b410cbb..87a46ea 100644 --- a/crates/plugins/plugin-files/Cargo.toml +++ b/crates/plugins/plugin-files/Cargo.toml @@ -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 } diff --git a/crates/plugins/plugin-files/src/lib.rs b/crates/plugins/plugin-files/src/lib.rs index d4eb426..e760b8a 100644 --- a/crates/plugins/plugin-files/src/lib.rs +++ b/crates/plugins/plugin-files/src/lib.rs @@ -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()); - } -} diff --git a/crates/plugins/plugin-files/tests/files.rs b/crates/plugins/plugin-files/tests/files.rs new file mode 100644 index 0000000..e9f074b --- /dev/null +++ b/crates/plugins/plugin-files/tests/files.rs @@ -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()); +} diff --git a/crates/plugins/plugin-url/Cargo.toml b/crates/plugins/plugin-url/Cargo.toml index a415b69..f6a9882 100644 --- a/crates/plugins/plugin-url/Cargo.toml +++ b/crates/plugins/plugin-url/Cargo.toml @@ -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 } diff --git a/crates/plugins/plugin-url/src/lib.rs b/crates/plugins/plugin-url/src/lib.rs new file mode 100644 index 0000000..dbb6c4f --- /dev/null +++ b/crates/plugins/plugin-url/src/lib.rs @@ -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 { + 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, + }, + }] +} diff --git a/crates/plugins/plugin-url/src/main.rs b/crates/plugins/plugin-url/src/main.rs index 002bdc4..e11459b 100644 --- a/crates/plugins/plugin-url/src/main.rs +++ b/crates/plugins/plugin-url/src/main.rs @@ -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 { - 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")); - } -} diff --git a/crates/plugins/plugin-url/tests/url.rs b/crates/plugins/plugin-url/tests/url.rs new file mode 100644 index 0000000..e56ba26 --- /dev/null +++ b/crates/plugins/plugin-url/tests/url.rs @@ -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")); +} diff --git a/docs/configuration.md b/docs/configuration.md index fd0e488..f67b443 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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"` diff --git a/docs/install.md b/docs/install.md index 115a731..cbfcf33 100644 --- a/docs/install.md +++ b/docs/install.md @@ -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 +``` diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 0d6842f..d371676 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -35,6 +35,7 @@ The process is kept alive between queries — do **not** exit after each respons | `"type"` | Extra fields | Behavior | |----------|-------------|---------| | `SpawnProcess` | `"cmd"` | Launch process directly | +| `SpawnInTerminal` | `"cmd"` | Run command in terminal emulator | | `CopyToClipboard` | `"text"` | Copy text to clipboard | | `OpenPath` | `"path"` | Open file/dir with xdg-open | @@ -53,7 +54,8 @@ In `~/.config/k-launcher/config.toml`: [[plugins.external]] name = "my-plugin" path = "/usr/lib/k-launcher/plugins/my-plugin" -args = [] # optional +args = [] # optional +timeout_secs = 5 # optional, default 5 ``` Multiple `[[plugins.external]]` blocks are supported. @@ -96,7 +98,7 @@ for line in sys.stdin: ## Built-in Plugins (compiled-in) -Built-in plugins implement the `Plugin` trait from `k-launcher-kernel` as Rust crates compiled into the binary. +Built-in plugins implement the `Plugin` trait from `k-launcher-domain` as Rust crates compiled into the binary. ### 1. Create a new crate in the workspace @@ -120,7 +122,7 @@ members = [ ```toml [dependencies] -k-launcher-kernel = { path = "../../k-launcher-kernel" } +k-launcher-domain = { workspace = true } async-trait = "0.1" ``` @@ -129,8 +131,10 @@ async-trait = "0.1" `crates/plugins/plugin-hello/src/lib.rs`: ```rust +use std::sync::Arc; + use async_trait::async_trait; -use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult}; +use k_launcher_domain::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult}; pub struct HelloPlugin; @@ -154,11 +158,10 @@ impl Plugin for HelloPlugin { vec![SearchResult { id: ResultId::new("hello:world"), title: ResultTitle::new("Hello, World!"), - description: Some("A greeting from the hello plugin".to_string()), + description: Some(Arc::from("A greeting from the hello plugin")), icon: None, score: Score::new(80), action: LaunchAction::CopyToClipboard("Hello, World!".to_string()), - on_select: None, }] } } @@ -192,11 +195,10 @@ plugin-hello = { path = "../plugins/plugin-hello" } |-------|------|-------------| | `id` | `ResultId` | Unique stable ID (e.g. `"apps:firefox"`) | | `title` | `ResultTitle` | Primary display text | -| `description` | `Option` | Secondary line shown below title | -| `icon` | `Option` | Icon name or path (currently unused in renderer) | +| `description` | `Option>` | Secondary line shown below title | +| `icon` | `Option>` | Icon name or path (currently unused in renderer) | | `score` | `Score(u32)` | Sort priority — higher wins | | `action` | `LaunchAction` | What happens on `Enter` | -| `on_select` | `Option>` | 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)` | Arbitrary closure | ### Scoring Guidance diff --git a/man/k-launcher.1 b/man/k-launcher.1 new file mode 100644 index 0000000..4e5d6d5 --- /dev/null +++ b/man/k-launcher.1 @@ -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)