Compare commits
12 Commits
master
...
2feb3a2d96
| Author | SHA1 | Date | |
|---|---|---|---|
| 2feb3a2d96 | |||
| 1e233aba4b | |||
| c68f07d522 | |||
| 1e305dc5bf | |||
| 68d18aad16 | |||
| aef33a53d7 | |||
| a3c53a213b | |||
| d1122ff4f0 | |||
| b68aef83ba | |||
| 574c355f82 | |||
| fad9484ec6 | |||
| 22242f8f5c |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1 @@
|
||||
target/
|
||||
.worktrees/
|
||||
|
||||
110
ARCHITECTURE.md
110
ARCHITECTURE.md
@@ -1,110 +0,0 @@
|
||||
# k-launcher Architecture
|
||||
|
||||
## Philosophy
|
||||
|
||||
- **TDD:** Red-Green-Refactor is mandatory. No functional code without a failing test first.
|
||||
- **Clean Architecture:** Strict layer separation — Domain, Application, Infrastructure, Main.
|
||||
- **Newtype Pattern:** All domain primitives wrapped (e.g. `struct Score(f64)`).
|
||||
- **Small Traits / ISP:** Many focused traits over one "God" trait.
|
||||
- **No Cyclic Dependencies:** Use IoC (define traits in higher-level modules, implement in lower-level).
|
||||
|
||||
---
|
||||
|
||||
## Workspace Structure
|
||||
|
||||
| Crate | Layer | Responsibility |
|
||||
|---|---|---|
|
||||
| `k-launcher-kernel` | Domain + Application | Newtypes (`ResultId`, `ResultTitle`, `Score`), `Plugin` trait, `SearchEngine` trait, `AppLauncher` port, `Kernel` use case |
|
||||
| `k-launcher-config` | Infrastructure | TOML config loading; `Config`, `WindowCfg`, `AppearanceCfg`, `PluginsCfg` structs |
|
||||
| `k-launcher-os-bridge` | Infrastructure | `UnixAppLauncher` (process spawning), `WindowConfig` adapter |
|
||||
| `k-launcher-plugin-host` | Infrastructure | `ExternalPlugin` — JSON-newline IPC protocol for out-of-process plugins |
|
||||
| `k-launcher-ui` | Infrastructure | iced 0.14 Elm-like UI (`KLauncherApp`, debounced async search, keyboard nav) |
|
||||
| `k-launcher-ui-egui` | Infrastructure | Alternative egui UI (feature-gated) |
|
||||
| `plugins/plugin-apps` | Infrastructure | XDG `.desktop` parser, frecency scoring, nucleo fuzzy matching |
|
||||
| `plugins/plugin-calc` | Infrastructure | `evalexpr`-based calculator |
|
||||
| `plugins/plugin-cmd` | Infrastructure | Shell command runner |
|
||||
| `plugins/plugin-files` | Infrastructure | File path search |
|
||||
| `plugins/plugin-url` | Infrastructure | URL opener |
|
||||
| `k-launcher` | Main/Entry | DI wiring, CLI arg parsing (`show` command), `run_ui()` composition root |
|
||||
|
||||
---
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
k-launcher (main)
|
||||
├── k-launcher-kernel (Domain/Application)
|
||||
├── k-launcher-config (Infrastructure — pure data, no kernel dep)
|
||||
├── k-launcher-os-bridge (Infrastructure)
|
||||
├── k-launcher-plugin-host (Infrastructure)
|
||||
├── k-launcher-ui (Infrastructure)
|
||||
└── plugins/* (Infrastructure)
|
||||
└── k-launcher-kernel
|
||||
```
|
||||
|
||||
All arrows point inward toward the kernel. The kernel has no external dependencies.
|
||||
|
||||
---
|
||||
|
||||
## Core Abstractions (kernel)
|
||||
|
||||
```rust
|
||||
// Plugin trait — implemented by every plugin
|
||||
async fn search(&self, query: &str) -> Vec<SearchResult>;
|
||||
|
||||
// SearchEngine trait — implemented by Kernel
|
||||
async fn search(&self, query: &str) -> Vec<SearchResult>;
|
||||
|
||||
// AppLauncher port — implemented by UnixAppLauncher in os-bridge
|
||||
fn execute(&self, action: &LaunchAction);
|
||||
|
||||
// DesktopEntrySource trait (plugin-apps) — swappable .desktop file source
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plugin System
|
||||
|
||||
Two kinds of plugins:
|
||||
|
||||
1. **In-process** — implement `Plugin` in Rust, linked at compile time.
|
||||
- `plugin-calc`, `plugin-apps`, `plugin-cmd`, `plugin-files`, `plugin-url`
|
||||
|
||||
2. **External / out-of-process** — `ExternalPlugin` in `k-launcher-plugin-host` communicates via JSON newline protocol over stdin/stdout.
|
||||
- Query: `{"query": "..."}`
|
||||
- Response: `[{"id": "...", "title": "...", "score": 1.0, "description": "...", "icon": "...", "action": "..."}]`
|
||||
|
||||
Plugins are enabled/disabled via `~/.config/k-launcher/config.toml`.
|
||||
|
||||
---
|
||||
|
||||
## Kernel (Application Use Case)
|
||||
|
||||
`Kernel::search` fans out to all registered plugins concurrently via `join_all`, merges results, sorts by `Score` descending, truncates to `max_results`.
|
||||
|
||||
---
|
||||
|
||||
## UI Architecture (iced 0.14 — Elm model)
|
||||
|
||||
- **State:** `KLauncherApp` holds engine ref, launcher ref, query string, results, selected index, appearance config.
|
||||
- **Messages:** `QueryChanged`, `ResultsReady`, `KeyPressed`
|
||||
- **Update:**
|
||||
- `QueryChanged` → spawns debounced async task (50 ms) → `ResultsReady`
|
||||
- Epoch guard prevents stale results from out-of-order responses
|
||||
- **View:** search bar + scrollable result list with icon support (SVG/raster)
|
||||
- **Subscription:** keyboard events — `Esc` = quit, `Enter` = launch, arrows = navigate
|
||||
- **Window:** transparent, undecorated, centered (Wayland-compatible)
|
||||
|
||||
---
|
||||
|
||||
## Frecency (plugin-apps)
|
||||
|
||||
`FrecencyStore` records app launches by ID. On empty query, returns top-5 frecent apps instead of search results.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
`~/.config/k-launcher/config.toml` — sections: `[window]`, `[appearance]`, `[search]`, `[plugins]`.
|
||||
|
||||
All fields have sane defaults; a missing file yields defaults without error.
|
||||
58
CLAUDE.md
58
CLAUDE.md
@@ -1,58 +0,0 @@
|
||||
## 1. Core Philosophy
|
||||
|
||||
- **Test-Driven Development (TDD):** No functional code is written without a failing test first. Red-Green-Refactor is the mandatory cycle.
|
||||
- **Clean Architecture:** Maintain strict separation between Domain, Application, and Infrastructure layers.
|
||||
- **Newtype Pattern:** Use the "Newtype" pattern for all domain primitives (e.g., `struct UserId(Uuid)`) to ensure type safety and prevent primitive obsession.
|
||||
|
||||
---
|
||||
|
||||
## 2. Structural Rules
|
||||
|
||||
### Dependency Management
|
||||
|
||||
- **Strict Unidirectionality:** Dependencies must only point inwards (towards the Domain).
|
||||
- **No Cyclic Dependencies:** Use traits and Dependency Injection (DI) to break cycles. If two modules need each other, abstract the shared behavior into a trait or move common data to a lower-level module.
|
||||
- **Feature Gating:** Organize the project into logical crates or modules that can be compiled independently.
|
||||
|
||||
### Traits and Decoupling
|
||||
|
||||
- **Swappable Infrastructure:** Define all external interactions (Database, API, File System) as traits in the Application layer.
|
||||
- **Small Traits:** Adhere to the Interface Segregation Principle. Favor many specific traits over one "God" trait.
|
||||
- **Mocking:** Use traits to allow easy mocking in unit tests without requiring a real database or network.
|
||||
|
||||
---
|
||||
|
||||
## 3. Rust Specifics & Clean Code
|
||||
|
||||
### Type Safety
|
||||
|
||||
- Avoid `String` or `i32` for domain concepts. Wrap them in structs.
|
||||
- Use `Result` and `Option` explicitly. Minimize `unwrap()` and `expect()`—handle errors gracefully at the boundaries.
|
||||
|
||||
### Formatting & Style
|
||||
|
||||
- Follow standard `rustfmt` and `clippy` suggestions.
|
||||
- Function names should be descriptive (e.g., `process_valid_order` instead of `handle_data`).
|
||||
- Keep functions small (typically under 20-30 lines).
|
||||
|
||||
---
|
||||
|
||||
## 4. Layer Definitions
|
||||
|
||||
| Layer | 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 |
|
||||
|
||||
---
|
||||
|
||||
## 5. TDD Workflow Requirement
|
||||
|
||||
1. **Write a Test:** Create a test in `src/lib.rs` or a `tests/` directory.
|
||||
2. **Define the Interface:** Use a trait or function signature to make the test compile (but fail).
|
||||
3. **Minimum Implementation:** Write just enough code to pass the test.
|
||||
4. **Refactor:** Clean up the logic, ensure no duplication, and check for "Newtype" opportunities.
|
||||
|
||||
> **Note on Cyclic Dependencies:** If an AI agent suggests a change that introduces a cycle, it must be rejected. Use **Inversion of Control** by defining a trait in the higher-level module that the lower-level module implements.
|
||||
1051
Cargo.lock
generated
1051
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
16
Cargo.toml
@@ -13,27 +13,13 @@ members = [
|
||||
"crates/k-launcher-ui-egui",
|
||||
"crates/plugins/plugin-url",
|
||||
]
|
||||
default-members = [
|
||||
"crates/k-launcher",
|
||||
"crates/k-launcher-config",
|
||||
"crates/k-launcher-kernel",
|
||||
"crates/k-launcher-os-bridge",
|
||||
"crates/k-launcher-plugin-host",
|
||||
"crates/k-launcher-ui",
|
||||
"crates/plugins/plugin-apps",
|
||||
"crates/plugins/plugin-calc",
|
||||
"crates/plugins/plugin-cmd",
|
||||
"crates/plugins/plugin-files",
|
||||
"crates/plugins/plugin-url",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
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"] }
|
||||
iced = { version = "0.14", features = ["image", "svg", "tokio", "tiny-skia"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1.35", features = ["rt-multi-thread", "macros"] }
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
A lightweight, GPU-accelerated command palette for Linux (Wayland/X11). Zero Electron — every pixel rendered via WGPU. Async search that never blocks the UI.
|
||||
|
||||

|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
@@ -25,7 +27,6 @@ cargo build --release
|
||||
k-launcher uses a normal window; configure your compositor to float it.
|
||||
|
||||
**Hyprland** (`~/.config/hypr/hyprland.conf`):
|
||||
|
||||
```
|
||||
windowrule = float, ^(k-launcher)$
|
||||
windowrule = center, ^(k-launcher)$
|
||||
@@ -33,7 +34,6 @@ bind = SUPER, Space, exec, k-launcher
|
||||
```
|
||||
|
||||
**Sway** (`~/.config/sway/config`):
|
||||
|
||||
```
|
||||
for_window [app_id="k-launcher"] floating enable, move position center
|
||||
bindsym Mod4+space exec k-launcher
|
||||
|
||||
@@ -8,4 +8,3 @@ async-trait = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -50,6 +50,7 @@ pub enum LaunchAction {
|
||||
SpawnInTerminal(String),
|
||||
OpenPath(String),
|
||||
CopyToClipboard(String),
|
||||
Custom(Arc<dyn Fn() + Send + Sync>),
|
||||
}
|
||||
|
||||
// --- AppLauncher port trait ---
|
||||
@@ -67,6 +68,7 @@ pub struct SearchResult {
|
||||
pub icon: Option<String>,
|
||||
pub score: Score,
|
||||
pub action: LaunchAction,
|
||||
pub on_select: Option<Arc<dyn Fn() + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SearchResult {
|
||||
@@ -86,7 +88,6 @@ impl std::fmt::Debug for SearchResult {
|
||||
pub trait Plugin: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
async fn search(&self, query: &str) -> Vec<SearchResult>;
|
||||
fn on_selected(&self, _id: &ResultId) {}
|
||||
}
|
||||
|
||||
// --- SearchEngine port trait ---
|
||||
@@ -94,19 +95,6 @@ pub trait Plugin: Send + Sync {
|
||||
#[async_trait]
|
||||
pub trait SearchEngine: Send + Sync {
|
||||
async fn search(&self, query: &str) -> Vec<SearchResult>;
|
||||
fn on_selected(&self, id: &ResultId);
|
||||
}
|
||||
|
||||
// --- NullSearchEngine ---
|
||||
|
||||
pub struct NullSearchEngine;
|
||||
|
||||
#[async_trait]
|
||||
impl SearchEngine for NullSearchEngine {
|
||||
async fn search(&self, _query: &str) -> Vec<SearchResult> {
|
||||
vec![]
|
||||
}
|
||||
fn on_selected(&self, _id: &ResultId) {}
|
||||
}
|
||||
|
||||
// --- Kernel (Application use case) ---
|
||||
@@ -124,32 +112,10 @@ impl Kernel {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_selected(&self, id: &ResultId) {
|
||||
for plugin in &self.plugins {
|
||||
plugin.on_selected(id);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||
use futures::FutureExt;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
|
||||
let futures = self
|
||||
.plugins
|
||||
.iter()
|
||||
.map(|p| AssertUnwindSafe(p.search(query)).catch_unwind());
|
||||
let outcomes = join_all(futures).await;
|
||||
let mut flat: Vec<SearchResult> = outcomes
|
||||
.into_iter()
|
||||
.zip(self.plugins.iter())
|
||||
.flat_map(|(outcome, plugin)| match outcome {
|
||||
Ok(results) => results,
|
||||
Err(_) => {
|
||||
tracing::error!(plugin = plugin.name(), "plugin panicked during search");
|
||||
vec![]
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let futures = self.plugins.iter().map(|p| p.search(query));
|
||||
let nested: Vec<Vec<SearchResult>> = join_all(futures).await;
|
||||
let mut flat: Vec<SearchResult> = nested.into_iter().flatten().collect();
|
||||
flat.sort_by(|a, b| b.score.cmp(&a.score));
|
||||
flat.truncate(self.max_results);
|
||||
flat
|
||||
@@ -161,9 +127,6 @@ impl SearchEngine for Kernel {
|
||||
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||
self.search(query).await
|
||||
}
|
||||
fn on_selected(&self, id: &ResultId) {
|
||||
self.on_selected(id);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
@@ -198,7 +161,8 @@ mod tests {
|
||||
description: None,
|
||||
icon: None,
|
||||
score: Score::new(*score),
|
||||
action: LaunchAction::SpawnProcess("mock".to_string()),
|
||||
action: LaunchAction::Custom(Arc::new(|| {})),
|
||||
on_select: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -239,29 +203,6 @@ mod tests {
|
||||
assert_eq!(results[2].score.value(), 5);
|
||||
}
|
||||
|
||||
struct PanicPlugin;
|
||||
|
||||
#[async_trait]
|
||||
impl Plugin for PanicPlugin {
|
||||
fn name(&self) -> &str {
|
||||
"panic-plugin"
|
||||
}
|
||||
|
||||
async fn search(&self, _query: &str) -> Vec<SearchResult> {
|
||||
panic!("test panic");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kernel_continues_after_plugin_panic() {
|
||||
let panic_plugin = Arc::new(PanicPlugin);
|
||||
let normal_plugin = Arc::new(MockPlugin::returns(vec![("survivor", 5)]));
|
||||
let k = Kernel::new(vec![panic_plugin, normal_plugin], 8);
|
||||
let results = k.search("q").await;
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].title.as_str(), "survivor");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kernel_truncates_at_max_results() {
|
||||
let plugin = Arc::new(MockPlugin::returns(vec![
|
||||
|
||||
@@ -4,5 +4,6 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
k-launcher-config = { path = "../k-launcher-config" }
|
||||
k-launcher-kernel = { path = "../k-launcher-kernel" }
|
||||
libc = "0.2"
|
||||
|
||||
@@ -1,2 +1,23 @@
|
||||
mod unix_launcher;
|
||||
|
||||
pub use unix_launcher::UnixAppLauncher;
|
||||
|
||||
pub struct WindowConfig {
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
pub decorations: bool,
|
||||
pub transparent: bool,
|
||||
pub resizable: bool,
|
||||
}
|
||||
|
||||
impl WindowConfig {
|
||||
pub fn from_cfg(w: &k_launcher_config::WindowCfg) -> Self {
|
||||
Self {
|
||||
width: w.width,
|
||||
height: w.height,
|
||||
decorations: w.decorations,
|
||||
transparent: w.transparent,
|
||||
resizable: w.resizable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,29 +3,6 @@ use std::process::{Command, Stdio};
|
||||
|
||||
use k_launcher_kernel::{AppLauncher, LaunchAction};
|
||||
|
||||
fn shell_split(cmd: &str) -> Vec<String> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut in_quotes = false;
|
||||
|
||||
for ch in cmd.chars() {
|
||||
match ch {
|
||||
'"' => in_quotes = !in_quotes,
|
||||
' ' | '\t' if !in_quotes => {
|
||||
if !current.is_empty() {
|
||||
tokens.push(current.clone());
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
_ => current.push(ch),
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
tokens.push(current);
|
||||
}
|
||||
tokens
|
||||
}
|
||||
|
||||
fn parse_term_cmd(s: &str) -> (String, Vec<String>) {
|
||||
let mut parts = s.split_whitespace();
|
||||
let bin = parts.next().unwrap_or("").to_string();
|
||||
@@ -92,7 +69,7 @@ impl AppLauncher for UnixAppLauncher {
|
||||
fn execute(&self, action: &LaunchAction) {
|
||||
match action {
|
||||
LaunchAction::SpawnProcess(cmd) => {
|
||||
let parts = shell_split(cmd);
|
||||
let parts: Vec<&str> = cmd.split_whitespace().collect();
|
||||
if let Some((bin, args)) = parts.split_first() {
|
||||
let _ = unsafe {
|
||||
Command::new(bin)
|
||||
@@ -144,47 +121,7 @@ impl AppLauncher for UnixAppLauncher {
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchAction::Custom(f) => f(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,5 @@ async-trait = { workspace = true }
|
||||
k-launcher-kernel = { path = "../k-launcher-kernel" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["process", "io-util", "sync", "time"] }
|
||||
tokio = { workspace = true, features = ["process", "io-util", "sync"] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -105,14 +105,7 @@ impl Plugin for ExternalPlugin {
|
||||
}
|
||||
|
||||
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())
|
||||
})
|
||||
}
|
||||
Some(io) => do_search(io, query).await,
|
||||
None => unreachable!(),
|
||||
};
|
||||
|
||||
@@ -132,6 +125,7 @@ impl Plugin for ExternalPlugin {
|
||||
}
|
||||
ExternalAction::OpenPath { path } => LaunchAction::OpenPath(path),
|
||||
},
|
||||
on_select: None,
|
||||
})
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
|
||||
@@ -12,4 +12,5 @@ eframe = { version = "0.31", default-features = false, features = ["default_font
|
||||
egui = "0.31"
|
||||
k-launcher-config = { path = "../k-launcher-config" }
|
||||
k-launcher-kernel = { path = "../k-launcher-kernel" }
|
||||
k-launcher-os-bridge = { path = "../k-launcher-os-bridge" }
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::sync::{Arc, mpsc};
|
||||
|
||||
use egui::{Color32, Key, ViewportCommand};
|
||||
use k_launcher_kernel::{AppLauncher, SearchEngine, SearchResult};
|
||||
use k_launcher_os_bridge::WindowConfig;
|
||||
|
||||
const BG: Color32 = Color32::from_rgba_premultiplied(20, 20, 30, 230);
|
||||
const BORDER_COLOR: Color32 = Color32::from_rgb(229, 125, 33);
|
||||
@@ -82,7 +83,9 @@ impl eframe::App for KLauncherApp {
|
||||
|
||||
if launch_selected {
|
||||
if let Some(result) = self.results.get(self.selected) {
|
||||
self.engine.on_selected(&result.id);
|
||||
if let Some(on_select) = &result.on_select {
|
||||
on_select();
|
||||
}
|
||||
self.launcher.execute(&result.action);
|
||||
}
|
||||
ctx.send_viewport_cmd(ViewportCommand::Close);
|
||||
@@ -163,17 +166,17 @@ impl eframe::App for KLauncherApp {
|
||||
pub fn run(
|
||||
engine: Arc<dyn SearchEngine>,
|
||||
launcher: Arc<dyn AppLauncher>,
|
||||
window_cfg: &k_launcher_config::WindowCfg,
|
||||
) -> Result<(), eframe::Error> {
|
||||
let wc = WindowConfig::from_cfg(&k_launcher_config::WindowCfg::default());
|
||||
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
|
||||
let handle = rt.handle().clone();
|
||||
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_inner_size([window_cfg.width, window_cfg.height])
|
||||
.with_decorations(window_cfg.decorations)
|
||||
.with_transparent(window_cfg.transparent)
|
||||
.with_resizable(window_cfg.resizable)
|
||||
.with_inner_size([wc.width, wc.height])
|
||||
.with_decorations(wc.decorations)
|
||||
.with_transparent(wc.transparent)
|
||||
.with_resizable(wc.resizable)
|
||||
.with_always_on_top(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -7,7 +7,6 @@ use k_launcher_kernel::{AppLauncher, SearchEngine};
|
||||
pub fn run(
|
||||
engine: Arc<dyn SearchEngine>,
|
||||
launcher: Arc<dyn AppLauncher>,
|
||||
window_cfg: &k_launcher_config::WindowCfg,
|
||||
) -> Result<(), eframe::Error> {
|
||||
app::run(engine, launcher, window_cfg)
|
||||
app::run(engine, launcher)
|
||||
}
|
||||
|
||||
@@ -8,20 +8,12 @@ use iced::{
|
||||
};
|
||||
|
||||
use k_launcher_config::AppearanceCfg;
|
||||
use k_launcher_kernel::{AppLauncher, NullSearchEngine, SearchEngine, SearchResult};
|
||||
use k_launcher_kernel::{AppLauncher, SearchEngine, SearchResult};
|
||||
use k_launcher_os_bridge::WindowConfig;
|
||||
|
||||
static INPUT_ID: std::sync::LazyLock<iced::widget::Id> =
|
||||
std::sync::LazyLock::new(|| iced::widget::Id::new("search"));
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct EngineHandle(Arc<dyn SearchEngine>);
|
||||
|
||||
impl std::fmt::Debug for EngineHandle {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("EngineHandle")
|
||||
}
|
||||
}
|
||||
|
||||
fn rgba(c: &[f32; 4]) -> Color {
|
||||
Color::from_rgba8(c[0] as u8, c[1] as u8, c[2] as u8, c[3])
|
||||
}
|
||||
@@ -61,8 +53,6 @@ pub enum Message {
|
||||
QueryChanged(String),
|
||||
ResultsReady(u64, Arc<Vec<SearchResult>>),
|
||||
KeyPressed(KeyEvent),
|
||||
EngineReady(EngineHandle),
|
||||
EngineInitFailed(String),
|
||||
}
|
||||
|
||||
fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
|
||||
@@ -88,18 +78,6 @@ fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
|
||||
}
|
||||
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,
|
||||
@@ -110,9 +88,7 @@ fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
|
||||
};
|
||||
let len = state.results.len();
|
||||
match named {
|
||||
Named::Escape => {
|
||||
std::process::exit(0);
|
||||
}
|
||||
Named::Escape => std::process::exit(0),
|
||||
Named::ArrowDown => {
|
||||
if len > 0 {
|
||||
state.selected = (state.selected + 1).min(len - 1);
|
||||
@@ -125,7 +101,9 @@ fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
|
||||
}
|
||||
Named::Enter => {
|
||||
if let Some(result) = state.results.get(state.selected) {
|
||||
state.engine.on_selected(&result.id);
|
||||
if let Some(on_select) = &result.on_select {
|
||||
on_select();
|
||||
}
|
||||
state.launcher.execute(&result.action);
|
||||
}
|
||||
std::process::exit(0);
|
||||
@@ -274,32 +252,17 @@ fn subscription(_state: &KLauncherApp) -> Subscription<Message> {
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
engine_factory: Arc<dyn Fn() -> Arc<dyn SearchEngine> + Send + Sync>,
|
||||
engine: Arc<dyn SearchEngine>,
|
||||
launcher: Arc<dyn AppLauncher>,
|
||||
window_cfg: &k_launcher_config::WindowCfg,
|
||||
appearance_cfg: AppearanceCfg,
|
||||
) -> iced::Result {
|
||||
let wc = WindowConfig::from_cfg(window_cfg);
|
||||
iced::application(
|
||||
move || {
|
||||
let app = KLauncherApp::new(
|
||||
Arc::new(NullSearchEngine),
|
||||
launcher.clone(),
|
||||
appearance_cfg.clone(),
|
||||
);
|
||||
let app = KLauncherApp::new(engine.clone(), launcher.clone(), appearance_cfg.clone());
|
||||
let focus = iced::widget::operation::focus(INPUT_ID.clone());
|
||||
let ef = engine_factory.clone();
|
||||
let init = Task::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || ef())
|
||||
.await
|
||||
.map_err(|e| format!("Engine init failed: {e}"))
|
||||
},
|
||||
|result| match result {
|
||||
Ok(e) => Message::EngineReady(EngineHandle(e)),
|
||||
Err(msg) => Message::EngineInitFailed(msg),
|
||||
},
|
||||
);
|
||||
(app, Task::batch([focus, init]))
|
||||
(app, focus)
|
||||
},
|
||||
update,
|
||||
view,
|
||||
@@ -307,11 +270,11 @@ pub fn run(
|
||||
.title("K-Launcher")
|
||||
.subscription(subscription)
|
||||
.window(window::Settings {
|
||||
size: Size::new(window_cfg.width, window_cfg.height),
|
||||
size: Size::new(wc.width, wc.height),
|
||||
position: window::Position::Centered,
|
||||
decorations: window_cfg.decorations,
|
||||
transparent: window_cfg.transparent,
|
||||
resizable: window_cfg.resizable,
|
||||
decorations: wc.decorations,
|
||||
transparent: wc.transparent,
|
||||
resizable: wc.resizable,
|
||||
..Default::default()
|
||||
})
|
||||
.run()
|
||||
|
||||
@@ -6,10 +6,10 @@ use k_launcher_config::{AppearanceCfg, WindowCfg};
|
||||
use k_launcher_kernel::{AppLauncher, SearchEngine};
|
||||
|
||||
pub fn run(
|
||||
engine_factory: Arc<dyn Fn() -> Arc<dyn SearchEngine> + Send + Sync>,
|
||||
engine: Arc<dyn SearchEngine>,
|
||||
launcher: Arc<dyn AppLauncher>,
|
||||
window_cfg: &WindowCfg,
|
||||
appearance_cfg: AppearanceCfg,
|
||||
) -> iced::Result {
|
||||
app::run(engine_factory, launcher, window_cfg, appearance_cfg)
|
||||
app::run(engine, launcher, window_cfg, appearance_cfg)
|
||||
}
|
||||
|
||||
@@ -34,8 +34,5 @@ plugin-apps = { path = "../plugins/plugin-apps" }
|
||||
plugin-calc = { path = "../plugins/plugin-calc" }
|
||||
plugin-cmd = { path = "../plugins/plugin-cmd" }
|
||||
plugin-files = { path = "../plugins/plugin-files" }
|
||||
dirs = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
10
crates/k-launcher/src/client.rs
Normal file
10
crates/k-launcher/src/client.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use std::io::Write;
|
||||
|
||||
pub fn send_show() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let runtime_dir =
|
||||
std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/run/user/1000".to_string());
|
||||
let socket_path = format!("{runtime_dir}/k-launcher.sock");
|
||||
let mut stream = std::os::unix::net::UnixStream::connect(&socket_path)?;
|
||||
stream.write_all(b"show\n")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
mod client;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use k_launcher_kernel::Kernel;
|
||||
@@ -10,30 +12,17 @@ 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};
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let _guard = init_logging();
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.get(1).map(|s| s.as_str()) == Some("show") {
|
||||
if let Err(e) = client::send_show() {
|
||||
eprintln!("error: failed to send show command: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = run_ui() {
|
||||
eprintln!("error: UI: {e}");
|
||||
@@ -41,8 +30,11 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_engine(cfg: Arc<k_launcher_config::Config>) -> Arc<dyn k_launcher_kernel::SearchEngine> {
|
||||
fn run_ui() -> iced::Result {
|
||||
let cfg = k_launcher_config::load();
|
||||
let launcher = Arc::new(UnixAppLauncher::new());
|
||||
let frecency = FrecencyStore::load();
|
||||
|
||||
let mut plugins: Vec<Arc<dyn k_launcher_kernel::Plugin>> = vec![];
|
||||
if cfg.plugins.cmd {
|
||||
plugins.push(Arc::new(CmdPlugin::new()));
|
||||
@@ -66,14 +58,9 @@ fn build_engine(cfg: Arc<k_launcher_config::Config>) -> Arc<dyn k_launcher_kerne
|
||||
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());
|
||||
let factory_cfg = cfg.clone();
|
||||
let factory: Arc<dyn Fn() -> Arc<dyn k_launcher_kernel::SearchEngine> + Send + Sync> =
|
||||
Arc::new(move || build_engine(factory_cfg.clone()));
|
||||
k_launcher_ui::run(factory, launcher, &cfg.window, cfg.appearance.clone())
|
||||
let kernel: Arc<dyn k_launcher_kernel::SearchEngine> =
|
||||
Arc::new(Kernel::new(plugins, cfg.search.max_results));
|
||||
|
||||
k_launcher_ui::run(kernel, launcher, &cfg.window, cfg.appearance)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ use plugin_cmd::CmdPlugin;
|
||||
use plugin_files::FilesPlugin;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cfg = k_launcher_config::load();
|
||||
let launcher = Arc::new(UnixAppLauncher::new());
|
||||
let frecency = FrecencyStore::load();
|
||||
let kernel: Arc<dyn k_launcher_kernel::SearchEngine> = Arc::new(Kernel::new(
|
||||
@@ -22,6 +21,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
],
|
||||
8,
|
||||
));
|
||||
k_launcher_ui_egui::run(kernel, launcher, &cfg.window)?;
|
||||
k_launcher_ui_egui::run(kernel, launcher)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
bincode = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
||||
nucleo-matcher = "0.3"
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -55,7 +55,6 @@ impl FrecencyStore {
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let json = {
|
||||
let mut data = self.data.lock().unwrap();
|
||||
let entry = data.entry(id.to_string()).or_insert(Entry {
|
||||
count: 0,
|
||||
@@ -63,12 +62,10 @@ impl FrecencyStore {
|
||||
});
|
||||
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);
|
||||
}
|
||||
if let Ok(json) = serde_json::to_string(&*data) {
|
||||
let _ = std::fs::write(&self.path, json);
|
||||
}
|
||||
}
|
||||
@@ -81,7 +78,14 @@ impl FrecencyStore {
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let age_secs = now.saturating_sub(entry.last_used);
|
||||
entry.count * decay_factor(age_secs)
|
||||
let decay = if age_secs < 3600 {
|
||||
4
|
||||
} else if age_secs < 86400 {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
entry.count * decay
|
||||
}
|
||||
|
||||
pub fn top_ids(&self, n: usize) -> Vec<String> {
|
||||
@@ -94,21 +98,18 @@ impl FrecencyStore {
|
||||
.iter()
|
||||
.map(|(id, entry)| {
|
||||
let age_secs = now.saturating_sub(entry.last_used);
|
||||
(id.clone(), 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()
|
||||
}
|
||||
}
|
||||
|
||||
fn decay_factor(age_secs: u64) -> u32 {
|
||||
if age_secs < 3600 {
|
||||
let decay = if age_secs < 3600 {
|
||||
4
|
||||
} else if age_secs < 86400 {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
(id.clone(), entry.count * decay)
|
||||
})
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
scored.into_iter().take(n).map(|(id, _)| id).collect()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,7 @@ pub mod frecency;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod linux;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
||||
@@ -76,74 +72,23 @@ struct CachedEntry {
|
||||
category: Option<String>,
|
||||
icon: Option<String>,
|
||||
exec: String,
|
||||
on_select: Arc<dyn Fn() + Send + Sync>,
|
||||
}
|
||||
|
||||
// --- Serializable cache data (no closures) ---
|
||||
// --- Plugin ---
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct CachedEntryData {
|
||||
id: String,
|
||||
name: String,
|
||||
keywords_lc: Vec<String>,
|
||||
category: Option<String>,
|
||||
icon: Option<String>,
|
||||
exec: String,
|
||||
pub struct AppsPlugin {
|
||||
entries: HashMap<String, CachedEntry>,
|
||||
frecency: Arc<FrecencyStore>,
|
||||
}
|
||||
|
||||
fn cache_path() -> Option<PathBuf> {
|
||||
dirs::cache_dir().map(|d| d.join("k-launcher/apps.bin"))
|
||||
}
|
||||
|
||||
fn load_from_path(path: &Path) -> Option<HashMap<String, CachedEntry>> {
|
||||
let data = std::fs::read(path).ok()?;
|
||||
let (entries_data, _): (Vec<CachedEntryData>, _) =
|
||||
bincode::serde::decode_from_slice(&data, bincode::config::standard()).ok()?;
|
||||
let map = entries_data
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let cached = CachedEntry {
|
||||
id: e.id.clone(),
|
||||
name: AppName::new(e.name),
|
||||
keywords_lc: e.keywords_lc,
|
||||
category: e.category,
|
||||
icon: e.icon,
|
||||
exec: e.exec,
|
||||
};
|
||||
(e.id, cached)
|
||||
})
|
||||
.collect();
|
||||
Some(map)
|
||||
}
|
||||
|
||||
fn save_to_path(path: &Path, entries: &HashMap<String, CachedEntry>) {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).ok();
|
||||
}
|
||||
let data: Vec<CachedEntryData> = entries
|
||||
.values()
|
||||
.map(|e| CachedEntryData {
|
||||
id: e.id.clone(),
|
||||
name: e.name.as_str().to_string(),
|
||||
keywords_lc: e.keywords_lc.clone(),
|
||||
category: e.category.clone(),
|
||||
icon: e.icon.clone(),
|
||||
exec: e.exec.clone(),
|
||||
})
|
||||
.collect();
|
||||
if let Ok(encoded) = bincode::serde::encode_to_vec(&data, bincode::config::standard()) {
|
||||
std::fs::write(path, encoded).ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn build_entries(
|
||||
source: &impl DesktopEntrySource,
|
||||
_frecency: &Arc<FrecencyStore>,
|
||||
) -> HashMap<String, CachedEntry> {
|
||||
source
|
||||
impl AppsPlugin {
|
||||
pub fn new(source: impl DesktopEntrySource, frecency: Arc<FrecencyStore>) -> Self {
|
||||
let entries = source
|
||||
.entries()
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let id = format!("app-{}:{}", e.name.as_str(), e.exec.as_str());
|
||||
let id = format!("app-{}", e.name.as_str());
|
||||
let keywords_lc = e.keywords.iter().map(|k| k.to_lowercase()).collect();
|
||||
#[cfg(target_os = "linux")]
|
||||
let icon = e
|
||||
@@ -153,71 +98,25 @@ fn build_entries(
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let icon: Option<String> = None;
|
||||
let exec = e.exec.as_str().to_string();
|
||||
let store = Arc::clone(&frecency);
|
||||
let record_id = id.clone();
|
||||
let on_select: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
|
||||
store.record(&record_id);
|
||||
});
|
||||
let cached = CachedEntry {
|
||||
id: id.clone(),
|
||||
keywords_lc,
|
||||
category: e.category,
|
||||
icon,
|
||||
exec,
|
||||
on_select,
|
||||
name: e.name,
|
||||
};
|
||||
(id, cached)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// --- Plugin ---
|
||||
|
||||
pub struct AppsPlugin {
|
||||
entries: Arc<RwLock<HashMap<String, CachedEntry>>>,
|
||||
frecency: Arc<FrecencyStore>,
|
||||
}
|
||||
|
||||
impl AppsPlugin {
|
||||
pub fn new(source: impl DesktopEntrySource + 'static, frecency: Arc<FrecencyStore>) -> Self {
|
||||
Self::new_impl(source, frecency, cache_path())
|
||||
}
|
||||
|
||||
fn new_impl(
|
||||
source: impl DesktopEntrySource + 'static,
|
||||
frecency: Arc<FrecencyStore>,
|
||||
cp: Option<PathBuf>,
|
||||
) -> Self {
|
||||
let cached = cp.as_deref().and_then(load_from_path);
|
||||
|
||||
let entries = if let Some(from_cache) = cached {
|
||||
// Serve cache immediately; refresh in background.
|
||||
let map = Arc::new(RwLock::new(from_cache));
|
||||
let entries_bg = Arc::clone(&map);
|
||||
let frecency_bg = Arc::clone(&frecency);
|
||||
let cp_bg = cp.clone();
|
||||
std::thread::spawn(move || {
|
||||
let fresh = build_entries(&source, &frecency_bg);
|
||||
if let Some(path) = cp_bg {
|
||||
save_to_path(&path, &fresh);
|
||||
}
|
||||
*entries_bg.write().unwrap() = fresh;
|
||||
});
|
||||
map
|
||||
} else {
|
||||
// No cache: build synchronously, then persist.
|
||||
let initial = build_entries(&source, &frecency);
|
||||
if let Some(path) = &cp {
|
||||
save_to_path(path, &initial);
|
||||
}
|
||||
Arc::new(RwLock::new(initial))
|
||||
};
|
||||
|
||||
.collect();
|
||||
Self { entries, frecency }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn new_for_test(
|
||||
source: impl DesktopEntrySource + 'static,
|
||||
frecency: Arc<FrecencyStore>,
|
||||
) -> Self {
|
||||
Self::new_impl(source, frecency, None)
|
||||
}
|
||||
}
|
||||
|
||||
fn initials(name_lc: &str) -> String {
|
||||
@@ -271,19 +170,14 @@ impl Plugin for AppsPlugin {
|
||||
"apps"
|
||||
}
|
||||
|
||||
fn on_selected(&self, id: &ResultId) {
|
||||
self.frecency.record(id.as_str());
|
||||
}
|
||||
|
||||
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||
let entries = self.entries.read().unwrap();
|
||||
if query.is_empty() {
|
||||
return self
|
||||
.frecency
|
||||
.top_ids(5)
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let e = entries.get(id)?;
|
||||
let e = self.entries.get(id)?;
|
||||
let score = self.frecency.frecency_score(id).max(1);
|
||||
Some(SearchResult {
|
||||
id: ResultId::new(id),
|
||||
@@ -292,13 +186,14 @@ impl Plugin for AppsPlugin {
|
||||
icon: e.icon.clone(),
|
||||
score: Score::new(score),
|
||||
action: LaunchAction::SpawnProcess(e.exec.clone()),
|
||||
on_select: Some(Arc::clone(&e.on_select)),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
let query_lc = query.to_lowercase();
|
||||
entries
|
||||
self.entries
|
||||
.values()
|
||||
.filter_map(|e| {
|
||||
let score = score_match(e.name.as_str(), query).or_else(|| {
|
||||
@@ -314,6 +209,7 @@ impl Plugin for AppsPlugin {
|
||||
icon: e.icon.clone(),
|
||||
score: Score::new(score),
|
||||
action: LaunchAction::SpawnProcess(e.exec.clone()),
|
||||
on_select: Some(Arc::clone(&e.on_select)),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -387,7 +283,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_prefix_match() {
|
||||
let p = AppsPlugin::new_for_test(
|
||||
let p = AppsPlugin::new(
|
||||
MockSource::with(vec![("Firefox", "firefox")]),
|
||||
ephemeral_frecency(),
|
||||
);
|
||||
@@ -397,7 +293,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_no_match_returns_empty() {
|
||||
let p = AppsPlugin::new_for_test(
|
||||
let p = AppsPlugin::new(
|
||||
MockSource::with(vec![("Firefox", "firefox")]),
|
||||
ephemeral_frecency(),
|
||||
);
|
||||
@@ -406,7 +302,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_empty_query_no_frecency_returns_empty() {
|
||||
let p = AppsPlugin::new_for_test(
|
||||
let p = AppsPlugin::new(
|
||||
MockSource::with(vec![("Firefox", "firefox")]),
|
||||
ephemeral_frecency(),
|
||||
);
|
||||
@@ -420,7 +316,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_match_exact_beats_prefix() {
|
||||
fn score_match_exact_beats_prefix_beats_abbrev_beats_substr() {
|
||||
let exact = score_match("firefox", "firefox");
|
||||
let prefix = score_match("firefox", "fire");
|
||||
let abbrev = score_match("gnu firefox", "gf");
|
||||
@@ -434,7 +330,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_abbreviation_match() {
|
||||
let p = AppsPlugin::new_for_test(
|
||||
let p = AppsPlugin::new(
|
||||
MockSource::with(vec![("Visual Studio Code", "code")]),
|
||||
ephemeral_frecency(),
|
||||
);
|
||||
@@ -446,7 +342,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_keyword_match() {
|
||||
let p = AppsPlugin::new_for_test(
|
||||
let p = AppsPlugin::new(
|
||||
MockSource::with_keywords(vec![("Code", "code", vec!["editor", "ide"])]),
|
||||
ephemeral_frecency(),
|
||||
);
|
||||
@@ -457,7 +353,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_fuzzy_typo_match() {
|
||||
let p = AppsPlugin::new_for_test(
|
||||
let p = AppsPlugin::new(
|
||||
MockSource::with(vec![("Firefox", "firefox")]),
|
||||
ephemeral_frecency(),
|
||||
);
|
||||
@@ -478,7 +374,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_category_appears_in_description() {
|
||||
let p = AppsPlugin::new_for_test(
|
||||
let p = AppsPlugin::new(
|
||||
MockSource::with_categories(vec![("Code", "code", "Text Editor")]),
|
||||
ephemeral_frecency(),
|
||||
);
|
||||
@@ -489,10 +385,10 @@ mod tests {
|
||||
#[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(
|
||||
frecency.record("app-Code");
|
||||
frecency.record("app-Code");
|
||||
frecency.record("app-Firefox");
|
||||
let p = AppsPlugin::new(
|
||||
MockSource::with(vec![("Firefox", "firefox"), ("Code", "code")]),
|
||||
frecency,
|
||||
);
|
||||
@@ -500,22 +396,4 @@ mod tests {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ impl Plugin for CalcPlugin {
|
||||
icon: None,
|
||||
score: Score::new(90),
|
||||
action: LaunchAction::CopyToClipboard(value_str),
|
||||
on_select: None,
|
||||
}]
|
||||
}
|
||||
_ => vec![],
|
||||
|
||||
@@ -36,6 +36,7 @@ impl Plugin for CmdPlugin {
|
||||
icon: None,
|
||||
score: Score::new(95),
|
||||
action: LaunchAction::SpawnInTerminal(cmd.to_string()),
|
||||
on_select: None,
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,19 +71,21 @@ impl Plugin for FilesPlugin {
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.take(20)
|
||||
.map(|entry| {
|
||||
.enumerate()
|
||||
.map(|(i, entry)| {
|
||||
let full_path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let is_dir = full_path.is_dir();
|
||||
let title = if is_dir { format!("{name}/") } else { name };
|
||||
let path_str = full_path.to_string_lossy().to_string();
|
||||
SearchResult {
|
||||
id: ResultId::new(&path_str),
|
||||
id: ResultId::new(format!("file-{i}")),
|
||||
title: ResultTitle::new(title),
|
||||
description: Some(path_str.clone()),
|
||||
icon: None,
|
||||
score: Score::new(50),
|
||||
action: LaunchAction::OpenPath(path_str),
|
||||
on_select: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -10,7 +10,7 @@ struct Query {
|
||||
#[derive(Serialize)]
|
||||
struct Action {
|
||||
r#type: &'static str,
|
||||
path: String,
|
||||
cmd: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -45,8 +45,8 @@ fn search(query: &str) -> Vec<Result> {
|
||||
description: url.clone(),
|
||||
score: 95,
|
||||
action: Action {
|
||||
r#type: "OpenPath",
|
||||
path: url.clone(),
|
||||
r#type: "SpawnProcess",
|
||||
cmd: format!("xdg-open {url}"),
|
||||
},
|
||||
}]
|
||||
}
|
||||
@@ -112,7 +112,7 @@ mod tests {
|
||||
fn search_returns_result() {
|
||||
let results = search("https://example.com");
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].action.path, "https://example.com");
|
||||
assert_eq!(results[0].action.cmd, "xdg-open https://example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -124,7 +124,7 @@ mod tests {
|
||||
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"));
|
||||
assert!(json.contains("SpawnProcess"));
|
||||
assert!(json.contains("xdg-open"));
|
||||
}
|
||||
}
|
||||
|
||||
0
docs/screenshot.png
Normal file
0
docs/screenshot.png
Normal file
Reference in New Issue
Block a user