Compare commits
11 Commits
2e2351e084
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e773cdeaf | |||
| 3d2bd5f9fe | |||
| ff9b2b5712 | |||
| 38860762c0 | |||
| 248094f442 | |||
| bd356f27d1 | |||
| 58d0739cea | |||
| 12f1f541ae | |||
| bee429192f | |||
| 86e843f666 | |||
| 71b8e46ae6 |
39
.github/workflows/ci.yml
vendored
Normal file
39
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Install system deps
|
||||||
|
run: sudo apt-get install -y libwayland-dev libxkbcommon-dev pkg-config
|
||||||
|
- run: cargo test --workspace
|
||||||
|
|
||||||
|
clippy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: clippy
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Install system deps
|
||||||
|
run: sudo apt-get install -y libwayland-dev libxkbcommon-dev pkg-config
|
||||||
|
- run: cargo clippy --workspace -- -D warnings
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt
|
||||||
|
- run: cargo fmt --check
|
||||||
21
.github/workflows/release.yml
vendored
Normal file
21
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Install system deps
|
||||||
|
run: sudo apt-get install -y libwayland-dev libxkbcommon-dev pkg-config
|
||||||
|
- run: cargo build --release
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: k-launcher
|
||||||
|
path: target/release/k-launcher
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
target/
|
target/
|
||||||
|
.worktrees/
|
||||||
|
|||||||
110
ARCHITECTURE.md
Normal file
110
ARCHITECTURE.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# 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
Normal file
58
CLAUDE.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
## 1. Core Philosophy
|
||||||
|
|
||||||
|
- **Test-Driven Development (TDD):** No functional code is written without a failing test first. Red-Green-Refactor is the mandatory cycle.
|
||||||
|
- **Clean Architecture:** Maintain strict separation between Domain, Application, and Infrastructure layers.
|
||||||
|
- **Newtype Pattern:** Use the "Newtype" pattern for all domain primitives (e.g., `struct UserId(Uuid)`) to ensure type safety and prevent primitive obsession.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Structural Rules
|
||||||
|
|
||||||
|
### Dependency Management
|
||||||
|
|
||||||
|
- **Strict Unidirectionality:** Dependencies must only point inwards (towards the Domain).
|
||||||
|
- **No Cyclic Dependencies:** Use traits and Dependency Injection (DI) to break cycles. If two modules need each other, abstract the shared behavior into a trait or move common data to a lower-level module.
|
||||||
|
- **Feature Gating:** Organize the project into logical crates or modules that can be compiled independently.
|
||||||
|
|
||||||
|
### Traits and Decoupling
|
||||||
|
|
||||||
|
- **Swappable Infrastructure:** Define all external interactions (Database, API, File System) as traits in the Application layer.
|
||||||
|
- **Small Traits:** Adhere to the Interface Segregation Principle. Favor many specific traits over one "God" trait.
|
||||||
|
- **Mocking:** Use traits to allow easy mocking in unit tests without requiring a real database or network.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Rust Specifics & Clean Code
|
||||||
|
|
||||||
|
### Type Safety
|
||||||
|
|
||||||
|
- Avoid `String` or `i32` for domain concepts. Wrap them in structs.
|
||||||
|
- Use `Result` and `Option` explicitly. Minimize `unwrap()` and `expect()`—handle errors gracefully at the boundaries.
|
||||||
|
|
||||||
|
### Formatting & Style
|
||||||
|
|
||||||
|
- Follow standard `rustfmt` and `clippy` suggestions.
|
||||||
|
- Function names should be descriptive (e.g., `process_valid_order` instead of `handle_data`).
|
||||||
|
- Keep functions small (typically under 20-30 lines).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Layer Definitions
|
||||||
|
|
||||||
|
| Layer | 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.
|
||||||
1332
Cargo.lock
generated
1332
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
16
Cargo.toml
@@ -13,13 +13,27 @@ members = [
|
|||||||
"crates/k-launcher-ui-egui",
|
"crates/k-launcher-ui-egui",
|
||||||
"crates/plugins/plugin-url",
|
"crates/plugins/plugin-url",
|
||||||
]
|
]
|
||||||
|
default-members = [
|
||||||
|
"crates/k-launcher",
|
||||||
|
"crates/k-launcher-config",
|
||||||
|
"crates/k-launcher-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"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
bincode = { version = "2", features = ["serde"] }
|
||||||
dirs = "6.0"
|
dirs = "6.0"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
iced = { version = "0.14", features = ["image", "svg", "tokio", "tiny-skia"] }
|
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 = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tokio = { version = "1.35", features = ["rt-multi-thread", "macros"] }
|
tokio = { version = "1.35", features = ["rt-multi-thread", "macros"] }
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -2,10 +2,6 @@
|
|||||||
|
|
||||||
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, GPU-accelerated command palette for Linux (Wayland/X11). Zero Electron — every pixel rendered via WGPU. Async search that never blocks the UI.
|
||||||
|
|
||||||
```
|
|
||||||
[screenshot placeholder]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -24,6 +20,25 @@ cargo build --release
|
|||||||
| `Enter` | Launch selected |
|
| `Enter` | Launch selected |
|
||||||
| `Escape` | Close |
|
| `Escape` | Close |
|
||||||
|
|
||||||
|
## Compositor Setup
|
||||||
|
|
||||||
|
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)$
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
## Built-in Plugins
|
## Built-in Plugins
|
||||||
|
|
||||||
| Trigger | Plugin | Example |
|
| Trigger | Plugin | Example |
|
||||||
|
|||||||
@@ -107,8 +107,7 @@ impl Default for PluginsCfg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn load() -> Config {
|
pub fn load() -> Config {
|
||||||
let path = dirs::config_dir()
|
let path = dirs::config_dir().map(|d| d.join("k-launcher").join("config.toml"));
|
||||||
.map(|d| d.join("k-launcher").join("config.toml"));
|
|
||||||
let Some(path) = path else {
|
let Some(path) = path else {
|
||||||
return Config::default();
|
return Config::default();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ async-trait = { workspace = true }
|
|||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ impl ResultTitle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
|
#[derive(
|
||||||
|
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
|
||||||
|
)]
|
||||||
pub struct Score(u32);
|
pub struct Score(u32);
|
||||||
|
|
||||||
impl Score {
|
impl Score {
|
||||||
@@ -48,7 +50,6 @@ pub enum LaunchAction {
|
|||||||
SpawnInTerminal(String),
|
SpawnInTerminal(String),
|
||||||
OpenPath(String),
|
OpenPath(String),
|
||||||
CopyToClipboard(String),
|
CopyToClipboard(String),
|
||||||
Custom(Arc<dyn Fn() + Send + Sync>),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- AppLauncher port trait ---
|
// --- AppLauncher port trait ---
|
||||||
@@ -66,7 +67,6 @@ pub struct SearchResult {
|
|||||||
pub icon: Option<String>,
|
pub icon: Option<String>,
|
||||||
pub score: Score,
|
pub score: Score,
|
||||||
pub action: LaunchAction,
|
pub action: LaunchAction,
|
||||||
pub on_select: Option<Arc<dyn Fn() + Send + Sync>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for SearchResult {
|
impl std::fmt::Debug for SearchResult {
|
||||||
@@ -86,6 +86,7 @@ impl std::fmt::Debug for SearchResult {
|
|||||||
pub trait Plugin: Send + Sync {
|
pub trait Plugin: Send + Sync {
|
||||||
fn name(&self) -> &str;
|
fn name(&self) -> &str;
|
||||||
async fn search(&self, query: &str) -> Vec<SearchResult>;
|
async fn search(&self, query: &str) -> Vec<SearchResult>;
|
||||||
|
fn on_selected(&self, _id: &ResultId) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- SearchEngine port trait ---
|
// --- SearchEngine port trait ---
|
||||||
@@ -93,6 +94,19 @@ pub trait Plugin: Send + Sync {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait SearchEngine: Send + Sync {
|
pub trait SearchEngine: Send + Sync {
|
||||||
async fn search(&self, query: &str) -> Vec<SearchResult>;
|
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) ---
|
// --- Kernel (Application use case) ---
|
||||||
@@ -104,13 +118,38 @@ pub struct Kernel {
|
|||||||
|
|
||||||
impl Kernel {
|
impl Kernel {
|
||||||
pub fn new(plugins: Vec<Arc<dyn Plugin>>, max_results: usize) -> Self {
|
pub fn new(plugins: Vec<Arc<dyn Plugin>>, max_results: usize) -> Self {
|
||||||
Self { plugins, max_results }
|
Self {
|
||||||
|
plugins,
|
||||||
|
max_results,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_selected(&self, id: &ResultId) {
|
||||||
|
for plugin in &self.plugins {
|
||||||
|
plugin.on_selected(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn search(&self, query: &str) -> Vec<SearchResult> {
|
pub async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||||
let futures = self.plugins.iter().map(|p| p.search(query));
|
use futures::FutureExt;
|
||||||
let nested: Vec<Vec<SearchResult>> = join_all(futures).await;
|
use std::panic::AssertUnwindSafe;
|
||||||
let mut flat: Vec<SearchResult> = nested.into_iter().flatten().collect();
|
|
||||||
|
let futures = self
|
||||||
|
.plugins
|
||||||
|
.iter()
|
||||||
|
.map(|p| AssertUnwindSafe(p.search(query)).catch_unwind());
|
||||||
|
let outcomes = join_all(futures).await;
|
||||||
|
let mut flat: Vec<SearchResult> = outcomes
|
||||||
|
.into_iter()
|
||||||
|
.zip(self.plugins.iter())
|
||||||
|
.flat_map(|(outcome, plugin)| match outcome {
|
||||||
|
Ok(results) => results,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::error!(plugin = plugin.name(), "plugin panicked during search");
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
flat.sort_by(|a, b| b.score.cmp(&a.score));
|
flat.sort_by(|a, b| b.score.cmp(&a.score));
|
||||||
flat.truncate(self.max_results);
|
flat.truncate(self.max_results);
|
||||||
flat
|
flat
|
||||||
@@ -122,6 +161,9 @@ impl SearchEngine for Kernel {
|
|||||||
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||||
self.search(query).await
|
self.search(query).await
|
||||||
}
|
}
|
||||||
|
fn on_selected(&self, id: &ResultId) {
|
||||||
|
self.on_selected(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Tests ---
|
// --- Tests ---
|
||||||
@@ -156,8 +198,7 @@ mod tests {
|
|||||||
description: None,
|
description: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
score: Score::new(*score),
|
score: Score::new(*score),
|
||||||
action: LaunchAction::Custom(Arc::new(|| {})),
|
action: LaunchAction::SpawnProcess("mock".to_string()),
|
||||||
on_select: None,
|
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -198,6 +239,29 @@ mod tests {
|
|||||||
assert_eq!(results[2].score.value(), 5);
|
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]
|
#[tokio::test]
|
||||||
async fn kernel_truncates_at_max_results() {
|
async fn kernel_truncates_at_max_results() {
|
||||||
let plugin = Arc::new(MockPlugin::returns(vec![
|
let plugin = Arc::new(MockPlugin::returns(vec![
|
||||||
|
|||||||
@@ -4,6 +4,5 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
k-launcher-config = { path = "../k-launcher-config" }
|
|
||||||
k-launcher-kernel = { path = "../k-launcher-kernel" }
|
k-launcher-kernel = { path = "../k-launcher-kernel" }
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|||||||
@@ -1,23 +1,2 @@
|
|||||||
mod unix_launcher;
|
mod unix_launcher;
|
||||||
|
|
||||||
pub use unix_launcher::UnixAppLauncher;
|
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,31 @@
|
|||||||
use std::process::{Command, Stdio};
|
|
||||||
use std::os::unix::process::CommandExt;
|
use std::os::unix::process::CommandExt;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
use k_launcher_kernel::{AppLauncher, LaunchAction};
|
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>) {
|
fn parse_term_cmd(s: &str) -> (String, Vec<String>) {
|
||||||
let mut parts = s.split_whitespace();
|
let mut parts = s.split_whitespace();
|
||||||
let bin = parts.next().unwrap_or("").to_string();
|
let bin = parts.next().unwrap_or("").to_string();
|
||||||
@@ -69,7 +92,7 @@ impl AppLauncher for UnixAppLauncher {
|
|||||||
fn execute(&self, action: &LaunchAction) {
|
fn execute(&self, action: &LaunchAction) {
|
||||||
match action {
|
match action {
|
||||||
LaunchAction::SpawnProcess(cmd) => {
|
LaunchAction::SpawnProcess(cmd) => {
|
||||||
let parts: Vec<&str> = cmd.split_whitespace().collect();
|
let parts = shell_split(cmd);
|
||||||
if let Some((bin, args)) = parts.split_first() {
|
if let Some((bin, args)) = parts.split_first() {
|
||||||
let _ = unsafe {
|
let _ = unsafe {
|
||||||
Command::new(bin)
|
Command::new(bin)
|
||||||
@@ -77,21 +100,31 @@ impl AppLauncher for UnixAppLauncher {
|
|||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null())
|
||||||
.pre_exec(|| { libc::setsid(); Ok(()) })
|
.pre_exec(|| {
|
||||||
|
libc::setsid();
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
.spawn()
|
.spawn()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchAction::SpawnInTerminal(cmd) => {
|
LaunchAction::SpawnInTerminal(cmd) => {
|
||||||
let Some((term_bin, term_args)) = resolve_terminal() else { return };
|
let Some((term_bin, term_args)) = resolve_terminal() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let _ = unsafe {
|
let _ = unsafe {
|
||||||
Command::new(&term_bin)
|
Command::new(&term_bin)
|
||||||
.args(&term_args)
|
.args(&term_args)
|
||||||
.arg("sh").arg("-c").arg(cmd)
|
.arg("sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(cmd)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null())
|
||||||
.pre_exec(|| { libc::setsid(); Ok(()) })
|
.pre_exec(|| {
|
||||||
|
libc::setsid();
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
.spawn()
|
.spawn()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -111,7 +144,47 @@ 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" }
|
k-launcher-kernel = { path = "../k-launcher-kernel" }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["process", "io-util", "sync"] }
|
tokio = { workspace = true, features = ["process", "io-util", "sync", "time"] }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ async fn do_search(
|
|||||||
io: &mut ProcessIo,
|
io: &mut ProcessIo,
|
||||||
query: &str,
|
query: &str,
|
||||||
) -> Result<Vec<ExternalResult>, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<Vec<ExternalResult>, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let line = serde_json::to_string(&Query { query: query.to_string() })?;
|
let line = serde_json::to_string(&Query {
|
||||||
|
query: query.to_string(),
|
||||||
|
})?;
|
||||||
io.stdin.write_all(line.as_bytes()).await?;
|
io.stdin.write_all(line.as_bytes()).await?;
|
||||||
io.stdin.write_all(b"\n").await?;
|
io.stdin.write_all(b"\n").await?;
|
||||||
io.stdin.flush().await?;
|
io.stdin.flush().await?;
|
||||||
@@ -103,7 +105,14 @@ impl Plugin for ExternalPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let result = match guard.as_mut() {
|
let result = match guard.as_mut() {
|
||||||
Some(io) => do_search(io, query).await,
|
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!(),
|
None => unreachable!(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,7 +132,6 @@ impl Plugin for ExternalPlugin {
|
|||||||
}
|
}
|
||||||
ExternalAction::OpenPath { path } => LaunchAction::OpenPath(path),
|
ExternalAction::OpenPath { path } => LaunchAction::OpenPath(path),
|
||||||
},
|
},
|
||||||
on_select: None,
|
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -143,7 +151,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn query_serializes_correctly() {
|
fn query_serializes_correctly() {
|
||||||
let q = Query { query: "firefox".to_string() };
|
let q = Query {
|
||||||
|
query: "firefox".to_string(),
|
||||||
|
};
|
||||||
assert_eq!(serde_json::to_string(&q).unwrap(), r#"{"query":"firefox"}"#);
|
assert_eq!(serde_json::to_string(&q).unwrap(), r#"{"query":"firefox"}"#);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,21 +165,27 @@ mod tests {
|
|||||||
assert_eq!(results[0].id, "1");
|
assert_eq!(results[0].id, "1");
|
||||||
assert_eq!(results[0].title, "Firefox");
|
assert_eq!(results[0].title, "Firefox");
|
||||||
assert_eq!(results[0].score, 80);
|
assert_eq!(results[0].score, 80);
|
||||||
assert!(matches!(&results[0].action, ExternalAction::SpawnProcess { cmd } if cmd == "firefox"));
|
assert!(
|
||||||
|
matches!(&results[0].action, ExternalAction::SpawnProcess { cmd } if cmd == "firefox")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn result_parses_copy_action() {
|
fn result_parses_copy_action() {
|
||||||
let json = r#"[{"id":"c","title":"= 4","score":90,"action":{"type":"CopyToClipboard","text":"4"}}]"#;
|
let json = r#"[{"id":"c","title":"= 4","score":90,"action":{"type":"CopyToClipboard","text":"4"}}]"#;
|
||||||
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
|
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
|
||||||
assert!(matches!(&results[0].action, ExternalAction::CopyToClipboard { text } if text == "4"));
|
assert!(
|
||||||
|
matches!(&results[0].action, ExternalAction::CopyToClipboard { text } if text == "4")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn result_parses_open_path_action() {
|
fn result_parses_open_path_action() {
|
||||||
let json = r#"[{"id":"f","title":"/home/user","score":50,"action":{"type":"OpenPath","path":"/home/user"}}]"#;
|
let json = r#"[{"id":"f","title":"/home/user","score":50,"action":{"type":"OpenPath","path":"/home/user"}}]"#;
|
||||||
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
|
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
|
||||||
assert!(matches!(&results[0].action, ExternalAction::OpenPath { path } if path == "/home/user"));
|
assert!(
|
||||||
|
matches!(&results[0].action, ExternalAction::OpenPath { path } if path == "/home/user")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -182,7 +198,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn result_parses_missing_optional_fields() {
|
fn result_parses_missing_optional_fields() {
|
||||||
let json = r#"[{"id":"x","title":"X","score":10,"action":{"type":"SpawnProcess","cmd":"x"}}]"#;
|
let json =
|
||||||
|
r#"[{"id":"x","title":"X","score":10,"action":{"type":"SpawnProcess","cmd":"x"}}]"#;
|
||||||
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
|
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
|
||||||
assert!(results[0].description.is_none());
|
assert!(results[0].description.is_none());
|
||||||
assert!(results[0].icon.is_none());
|
assert!(results[0].icon.is_none());
|
||||||
|
|||||||
@@ -12,5 +12,4 @@ eframe = { version = "0.31", default-features = false, features = ["default_font
|
|||||||
egui = "0.31"
|
egui = "0.31"
|
||||||
k-launcher-config = { path = "../k-launcher-config" }
|
k-launcher-config = { path = "../k-launcher-config" }
|
||||||
k-launcher-kernel = { path = "../k-launcher-kernel" }
|
k-launcher-kernel = { path = "../k-launcher-kernel" }
|
||||||
k-launcher-os-bridge = { path = "../k-launcher-os-bridge" }
|
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ use std::sync::{Arc, mpsc};
|
|||||||
|
|
||||||
use egui::{Color32, Key, ViewportCommand};
|
use egui::{Color32, Key, ViewportCommand};
|
||||||
use k_launcher_kernel::{AppLauncher, SearchEngine, SearchResult};
|
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 BG: Color32 = Color32::from_rgba_premultiplied(20, 20, 30, 230);
|
||||||
const BORDER_COLOR: Color32 = Color32::from_rgb(229, 125, 33);
|
const BORDER_COLOR: Color32 = Color32::from_rgb(229, 125, 33);
|
||||||
@@ -83,9 +82,7 @@ impl eframe::App for KLauncherApp {
|
|||||||
|
|
||||||
if launch_selected {
|
if launch_selected {
|
||||||
if let Some(result) = self.results.get(self.selected) {
|
if let Some(result) = self.results.get(self.selected) {
|
||||||
if let Some(on_select) = &result.on_select {
|
self.engine.on_selected(&result.id);
|
||||||
on_select();
|
|
||||||
}
|
|
||||||
self.launcher.execute(&result.action);
|
self.launcher.execute(&result.action);
|
||||||
}
|
}
|
||||||
ctx.send_viewport_cmd(ViewportCommand::Close);
|
ctx.send_viewport_cmd(ViewportCommand::Close);
|
||||||
@@ -127,11 +124,20 @@ impl eframe::App for KLauncherApp {
|
|||||||
ui.set_width(ui.available_width());
|
ui.set_width(ui.available_width());
|
||||||
for (i, result) in self.results.iter().enumerate() {
|
for (i, result) in self.results.iter().enumerate() {
|
||||||
let is_selected = i == self.selected;
|
let is_selected = i == self.selected;
|
||||||
let bg = if is_selected { SELECTED_BG } else { Color32::TRANSPARENT };
|
let bg = if is_selected {
|
||||||
|
SELECTED_BG
|
||||||
|
} else {
|
||||||
|
Color32::TRANSPARENT
|
||||||
|
};
|
||||||
|
|
||||||
let row_frame = egui::Frame::new()
|
let row_frame = egui::Frame::new()
|
||||||
.fill(bg)
|
.fill(bg)
|
||||||
.inner_margin(egui::Margin { left: 8, right: 8, top: 6, bottom: 6 })
|
.inner_margin(egui::Margin {
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
top: 6,
|
||||||
|
bottom: 6,
|
||||||
|
})
|
||||||
.corner_radius(egui::CornerRadius::same(4));
|
.corner_radius(egui::CornerRadius::same(4));
|
||||||
|
|
||||||
row_frame.show(ui, |ui| {
|
row_frame.show(ui, |ui| {
|
||||||
@@ -157,17 +163,17 @@ impl eframe::App for KLauncherApp {
|
|||||||
pub fn run(
|
pub fn run(
|
||||||
engine: Arc<dyn SearchEngine>,
|
engine: Arc<dyn SearchEngine>,
|
||||||
launcher: Arc<dyn AppLauncher>,
|
launcher: Arc<dyn AppLauncher>,
|
||||||
|
window_cfg: &k_launcher_config::WindowCfg,
|
||||||
) -> Result<(), eframe::Error> {
|
) -> Result<(), eframe::Error> {
|
||||||
let wc = WindowConfig::from_cfg(&k_launcher_config::WindowCfg::default());
|
|
||||||
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
|
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
|
||||||
let handle = rt.handle().clone();
|
let handle = rt.handle().clone();
|
||||||
|
|
||||||
let options = eframe::NativeOptions {
|
let options = eframe::NativeOptions {
|
||||||
viewport: egui::ViewportBuilder::default()
|
viewport: egui::ViewportBuilder::default()
|
||||||
.with_inner_size([wc.width, wc.height])
|
.with_inner_size([window_cfg.width, window_cfg.height])
|
||||||
.with_decorations(wc.decorations)
|
.with_decorations(window_cfg.decorations)
|
||||||
.with_transparent(wc.transparent)
|
.with_transparent(window_cfg.transparent)
|
||||||
.with_resizable(wc.resizable)
|
.with_resizable(window_cfg.resizable)
|
||||||
.with_always_on_top(),
|
.with_always_on_top(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use k_launcher_kernel::{AppLauncher, SearchEngine};
|
|||||||
pub fn run(
|
pub fn run(
|
||||||
engine: Arc<dyn SearchEngine>,
|
engine: Arc<dyn SearchEngine>,
|
||||||
launcher: Arc<dyn AppLauncher>,
|
launcher: Arc<dyn AppLauncher>,
|
||||||
|
window_cfg: &k_launcher_config::WindowCfg,
|
||||||
) -> Result<(), eframe::Error> {
|
) -> Result<(), eframe::Error> {
|
||||||
app::run(engine, launcher)
|
app::run(engine, launcher, window_cfg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use iced::{
|
use iced::{
|
||||||
Border, Color, Element, Length, Size, Subscription, Task,
|
Border, Color, Element, Length, Size, Subscription, Task, event,
|
||||||
event,
|
|
||||||
keyboard::{Event as KeyEvent, Key, key::Named},
|
keyboard::{Event as KeyEvent, Key, key::Named},
|
||||||
widget::{column, container, image, row, scrollable, svg, text, text_input, Space},
|
widget::{Space, column, container, image, row, scrollable, svg, text, text_input},
|
||||||
window,
|
window,
|
||||||
};
|
};
|
||||||
|
|
||||||
use k_launcher_config::AppearanceCfg;
|
use k_launcher_config::AppearanceCfg;
|
||||||
use k_launcher_kernel::{AppLauncher, SearchEngine, SearchResult};
|
use k_launcher_kernel::{AppLauncher, NullSearchEngine, SearchEngine, SearchResult};
|
||||||
use k_launcher_os_bridge::WindowConfig;
|
|
||||||
|
|
||||||
static INPUT_ID: std::sync::LazyLock<iced::widget::Id> =
|
static INPUT_ID: std::sync::LazyLock<iced::widget::Id> =
|
||||||
std::sync::LazyLock::new(|| iced::widget::Id::new("search"));
|
std::sync::LazyLock::new(|| iced::widget::Id::new("search"));
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
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 {
|
fn rgba(c: &[f32; 4]) -> Color {
|
||||||
Color::from_rgba8(c[0] as u8, c[1] as u8, c[2] as u8, c[3])
|
Color::from_rgba8(c[0] as u8, c[1] as u8, c[2] as u8, c[3])
|
||||||
}
|
}
|
||||||
@@ -26,6 +33,8 @@ pub struct KLauncherApp {
|
|||||||
results: Arc<Vec<SearchResult>>,
|
results: Arc<Vec<SearchResult>>,
|
||||||
selected: usize,
|
selected: usize,
|
||||||
cfg: AppearanceCfg,
|
cfg: AppearanceCfg,
|
||||||
|
error: Option<String>,
|
||||||
|
search_epoch: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KLauncherApp {
|
impl KLauncherApp {
|
||||||
@@ -41,6 +50,8 @@ impl KLauncherApp {
|
|||||||
results: Arc::new(vec![]),
|
results: Arc::new(vec![]),
|
||||||
selected: 0,
|
selected: 0,
|
||||||
cfg,
|
cfg,
|
||||||
|
error: None,
|
||||||
|
search_epoch: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,23 +59,45 @@ impl KLauncherApp {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Message {
|
pub enum Message {
|
||||||
QueryChanged(String),
|
QueryChanged(String),
|
||||||
ResultsReady(Arc<Vec<SearchResult>>),
|
ResultsReady(u64, Arc<Vec<SearchResult>>),
|
||||||
KeyPressed(KeyEvent),
|
KeyPressed(KeyEvent),
|
||||||
|
EngineReady(EngineHandle),
|
||||||
|
EngineInitFailed(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
|
fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
|
||||||
match message {
|
match message {
|
||||||
Message::QueryChanged(q) => {
|
Message::QueryChanged(q) => {
|
||||||
|
state.error = None;
|
||||||
state.query = q.clone();
|
state.query = q.clone();
|
||||||
state.selected = 0;
|
state.selected = 0;
|
||||||
|
state.search_epoch += 1;
|
||||||
|
let epoch = state.search_epoch;
|
||||||
let engine = state.engine.clone();
|
let engine = state.engine.clone();
|
||||||
Task::perform(
|
Task::perform(
|
||||||
async move { engine.search(&q).await },
|
async move {
|
||||||
|results| Message::ResultsReady(Arc::new(results)),
|
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(results) => {
|
Message::ResultsReady(epoch, results) => {
|
||||||
state.results = 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()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::KeyPressed(event) => {
|
Message::KeyPressed(event) => {
|
||||||
@@ -77,7 +110,9 @@ fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
|
|||||||
};
|
};
|
||||||
let len = state.results.len();
|
let len = state.results.len();
|
||||||
match named {
|
match named {
|
||||||
Named::Escape => std::process::exit(0),
|
Named::Escape => {
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
Named::ArrowDown => {
|
Named::ArrowDown => {
|
||||||
if len > 0 {
|
if len > 0 {
|
||||||
state.selected = (state.selected + 1).min(len - 1);
|
state.selected = (state.selected + 1).min(len - 1);
|
||||||
@@ -90,9 +125,7 @@ fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
|
|||||||
}
|
}
|
||||||
Named::Enter => {
|
Named::Enter => {
|
||||||
if let Some(result) = state.results.get(state.selected) {
|
if let Some(result) = state.results.get(state.selected) {
|
||||||
if let Some(on_select) = &result.on_select {
|
state.engine.on_selected(&result.id);
|
||||||
on_select();
|
|
||||||
}
|
|
||||||
state.launcher.execute(&result.action);
|
state.launcher.execute(&result.action);
|
||||||
}
|
}
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
@@ -114,8 +147,13 @@ fn view(state: &KLauncherApp) -> Element<'_, Message> {
|
|||||||
.padding(12)
|
.padding(12)
|
||||||
.size(cfg.search_font_size)
|
.size(cfg.search_font_size)
|
||||||
.style(|theme, _status| {
|
.style(|theme, _status| {
|
||||||
let mut s = iced::widget::text_input::default(theme, iced::widget::text_input::Status::Active);
|
let mut s =
|
||||||
s.border = Border { color: Color::TRANSPARENT, width: 0.0, radius: 0.0.into() };
|
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
|
s
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,26 +173,27 @@ fn view(state: &KLauncherApp) -> Element<'_, Message> {
|
|||||||
Color::from_rgba8(255, 255, 255, 0.07)
|
Color::from_rgba8(255, 255, 255, 0.07)
|
||||||
};
|
};
|
||||||
let icon_el: Element<'_, Message> = match &result.icon {
|
let icon_el: Element<'_, Message> = match &result.icon {
|
||||||
Some(p) if p.ends_with(".svg") =>
|
Some(p) if p.ends_with(".svg") => {
|
||||||
svg(svg::Handle::from_path(p)).width(24).height(24).into(),
|
svg(svg::Handle::from_path(p)).width(24).height(24).into()
|
||||||
Some(p) =>
|
}
|
||||||
image(image::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(),
|
None => Space::new().width(24).height(24).into(),
|
||||||
};
|
};
|
||||||
let title_col: Element<'_, Message> = if let Some(desc) = &result.description {
|
let title_col: Element<'_, Message> = if let Some(desc) = &result.description {
|
||||||
column![
|
column![
|
||||||
text(result.title.as_str()).size(title_size),
|
text(result.title.as_str()).size(title_size),
|
||||||
text(desc).size(desc_size).color(Color::from_rgba8(210, 215, 230, 1.0)),
|
text(desc)
|
||||||
|
.size(desc_size)
|
||||||
|
.color(Color::from_rgba8(210, 215, 230, 1.0)),
|
||||||
]
|
]
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
text(result.title.as_str()).size(title_size).into()
|
text(result.title.as_str()).size(title_size).into()
|
||||||
};
|
};
|
||||||
container(
|
container(row![icon_el, title_col].spacing(8).align_y(iced::Center))
|
||||||
row![icon_el, title_col]
|
|
||||||
.spacing(8)
|
|
||||||
.align_y(iced::Center),
|
|
||||||
)
|
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.padding([6, 12])
|
.padding([6, 12])
|
||||||
.style(move |_theme| container::Style {
|
.style(move |_theme| container::Style {
|
||||||
@@ -186,7 +225,23 @@ fn view(state: &KLauncherApp) -> Element<'_, Message> {
|
|||||||
scrollable(column(result_rows).spacing(2).width(Length::Fill)).height(Length::Fill)
|
scrollable(column(result_rows).spacing(2).width(Length::Fill)).height(Length::Fill)
|
||||||
};
|
};
|
||||||
|
|
||||||
let content = column![search_bar, results_list]
|
let maybe_error: Option<Element<'_, Message>> = state.error.as_ref().map(|msg| {
|
||||||
|
container(
|
||||||
|
text(msg.as_str())
|
||||||
|
.size(12.0)
|
||||||
|
.color(Color::from_rgba8(255, 80, 80, 1.0)),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.padding([4, 12])
|
||||||
|
.into()
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut content_children: Vec<Element<'_, Message>> =
|
||||||
|
vec![search_bar.into(), results_list.into()];
|
||||||
|
if let Some(err) = maybe_error {
|
||||||
|
content_children.push(err);
|
||||||
|
}
|
||||||
|
let content = column(content_children)
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
@@ -219,30 +274,45 @@ fn subscription(_state: &KLauncherApp) -> Subscription<Message> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(
|
pub fn run(
|
||||||
engine: Arc<dyn SearchEngine>,
|
engine_factory: Arc<dyn Fn() -> Arc<dyn SearchEngine> + Send + Sync>,
|
||||||
launcher: Arc<dyn AppLauncher>,
|
launcher: Arc<dyn AppLauncher>,
|
||||||
window_cfg: &k_launcher_config::WindowCfg,
|
window_cfg: &k_launcher_config::WindowCfg,
|
||||||
appearance_cfg: AppearanceCfg,
|
appearance_cfg: AppearanceCfg,
|
||||||
) -> iced::Result {
|
) -> iced::Result {
|
||||||
let wc = WindowConfig::from_cfg(window_cfg);
|
|
||||||
iced::application(
|
iced::application(
|
||||||
move || {
|
move || {
|
||||||
let app = KLauncherApp::new(engine.clone(), launcher.clone(), appearance_cfg.clone());
|
let app = KLauncherApp::new(
|
||||||
|
Arc::new(NullSearchEngine),
|
||||||
|
launcher.clone(),
|
||||||
|
appearance_cfg.clone(),
|
||||||
|
);
|
||||||
let focus = iced::widget::operation::focus(INPUT_ID.clone());
|
let focus = iced::widget::operation::focus(INPUT_ID.clone());
|
||||||
(app, focus)
|
let ef = engine_factory.clone();
|
||||||
|
let init = Task::perform(
|
||||||
|
async move {
|
||||||
|
tokio::task::spawn_blocking(move || ef())
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Engine init failed: {e}"))
|
||||||
|
},
|
||||||
|
|result| match result {
|
||||||
|
Ok(e) => Message::EngineReady(EngineHandle(e)),
|
||||||
|
Err(msg) => Message::EngineInitFailed(msg),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
(app, Task::batch([focus, init]))
|
||||||
},
|
},
|
||||||
update,
|
update,
|
||||||
view,
|
view,
|
||||||
)
|
)
|
||||||
.title("K-Launcher")
|
.title("K-Launcher")
|
||||||
.subscription(subscription)
|
.subscription(subscription)
|
||||||
.window(window::Settings {
|
.window(window::Settings {
|
||||||
size: Size::new(wc.width, wc.height),
|
size: Size::new(window_cfg.width, window_cfg.height),
|
||||||
position: window::Position::Centered,
|
position: window::Position::Centered,
|
||||||
decorations: wc.decorations,
|
decorations: window_cfg.decorations,
|
||||||
transparent: wc.transparent,
|
transparent: window_cfg.transparent,
|
||||||
resizable: wc.resizable,
|
resizable: window_cfg.resizable,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ use k_launcher_config::{AppearanceCfg, WindowCfg};
|
|||||||
use k_launcher_kernel::{AppLauncher, SearchEngine};
|
use k_launcher_kernel::{AppLauncher, SearchEngine};
|
||||||
|
|
||||||
pub fn run(
|
pub fn run(
|
||||||
engine: Arc<dyn SearchEngine>,
|
engine_factory: Arc<dyn Fn() -> Arc<dyn SearchEngine> + Send + Sync>,
|
||||||
launcher: Arc<dyn AppLauncher>,
|
launcher: Arc<dyn AppLauncher>,
|
||||||
window_cfg: &WindowCfg,
|
window_cfg: &WindowCfg,
|
||||||
appearance_cfg: AppearanceCfg,
|
appearance_cfg: AppearanceCfg,
|
||||||
) -> iced::Result {
|
) -> iced::Result {
|
||||||
app::run(engine, launcher, window_cfg, appearance_cfg)
|
app::run(engine_factory, launcher, window_cfg, appearance_cfg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,4 +34,8 @@ plugin-apps = { path = "../plugins/plugin-apps" }
|
|||||||
plugin-calc = { path = "../plugins/plugin-calc" }
|
plugin-calc = { path = "../plugins/plugin-calc" }
|
||||||
plugin-cmd = { path = "../plugins/plugin-cmd" }
|
plugin-cmd = { path = "../plugins/plugin-cmd" }
|
||||||
plugin-files = { path = "../plugins/plugin-files" }
|
plugin-files = { path = "../plugins/plugin-files" }
|
||||||
|
dirs = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-appender = "0.2"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|||||||
@@ -3,31 +3,77 @@ use std::sync::Arc;
|
|||||||
use k_launcher_kernel::Kernel;
|
use k_launcher_kernel::Kernel;
|
||||||
use k_launcher_os_bridge::UnixAppLauncher;
|
use k_launcher_os_bridge::UnixAppLauncher;
|
||||||
use k_launcher_plugin_host::ExternalPlugin;
|
use k_launcher_plugin_host::ExternalPlugin;
|
||||||
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
use plugin_apps::linux::FsDesktopEntrySource;
|
use plugin_apps::linux::FsDesktopEntrySource;
|
||||||
|
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
|
||||||
use plugin_calc::CalcPlugin;
|
use plugin_calc::CalcPlugin;
|
||||||
use plugin_cmd::CmdPlugin;
|
use plugin_cmd::CmdPlugin;
|
||||||
use plugin_files::FilesPlugin;
|
use plugin_files::FilesPlugin;
|
||||||
|
|
||||||
fn main() -> iced::Result {
|
fn init_logging() -> tracing_appender::non_blocking::WorkerGuard {
|
||||||
let cfg = k_launcher_config::load();
|
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
let launcher = Arc::new(UnixAppLauncher::new());
|
|
||||||
let frecency = FrecencyStore::load();
|
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
if let Err(e) = run_ui() {
|
||||||
|
eprintln!("error: UI: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_engine(cfg: Arc<k_launcher_config::Config>) -> Arc<dyn k_launcher_kernel::SearchEngine> {
|
||||||
|
let frecency = FrecencyStore::load();
|
||||||
let mut plugins: Vec<Arc<dyn k_launcher_kernel::Plugin>> = vec![];
|
let mut plugins: Vec<Arc<dyn k_launcher_kernel::Plugin>> = vec![];
|
||||||
if cfg.plugins.cmd { plugins.push(Arc::new(CmdPlugin::new())); }
|
if cfg.plugins.cmd {
|
||||||
if cfg.plugins.calc { plugins.push(Arc::new(CalcPlugin::new())); }
|
plugins.push(Arc::new(CmdPlugin::new()));
|
||||||
if cfg.plugins.files { plugins.push(Arc::new(FilesPlugin::new())); }
|
}
|
||||||
if cfg.plugins.apps {
|
if cfg.plugins.calc {
|
||||||
plugins.push(Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)));
|
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 {
|
for ext in &cfg.plugins.external {
|
||||||
plugins.push(Arc::new(ExternalPlugin::new(&ext.name, &ext.path, ext.args.clone())));
|
plugins.push(Arc::new(ExternalPlugin::new(
|
||||||
|
&ext.name,
|
||||||
|
&ext.path,
|
||||||
|
ext.args.clone(),
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
Arc::new(Kernel::new(plugins, cfg.search.max_results))
|
||||||
let kernel: Arc<dyn k_launcher_kernel::SearchEngine> =
|
}
|
||||||
Arc::new(Kernel::new(plugins, cfg.search.max_results));
|
|
||||||
|
fn run_ui() -> iced::Result {
|
||||||
k_launcher_ui::run(kernel, launcher, &cfg.window, cfg.appearance)
|
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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,26 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use k_launcher_kernel::Kernel;
|
use k_launcher_kernel::Kernel;
|
||||||
use k_launcher_os_bridge::UnixAppLauncher;
|
use k_launcher_os_bridge::UnixAppLauncher;
|
||||||
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
use plugin_apps::linux::FsDesktopEntrySource;
|
use plugin_apps::linux::FsDesktopEntrySource;
|
||||||
|
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
|
||||||
use plugin_calc::CalcPlugin;
|
use plugin_calc::CalcPlugin;
|
||||||
use plugin_cmd::CmdPlugin;
|
use plugin_cmd::CmdPlugin;
|
||||||
use plugin_files::FilesPlugin;
|
use plugin_files::FilesPlugin;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let cfg = k_launcher_config::load();
|
||||||
let launcher = Arc::new(UnixAppLauncher::new());
|
let launcher = Arc::new(UnixAppLauncher::new());
|
||||||
let frecency = FrecencyStore::load();
|
let frecency = FrecencyStore::load();
|
||||||
let kernel: Arc<dyn k_launcher_kernel::SearchEngine> = Arc::new(Kernel::new(vec![
|
let kernel: Arc<dyn k_launcher_kernel::SearchEngine> = Arc::new(Kernel::new(
|
||||||
Arc::new(CmdPlugin::new()),
|
vec![
|
||||||
Arc::new(CalcPlugin::new()),
|
Arc::new(CmdPlugin::new()),
|
||||||
Arc::new(FilesPlugin::new()),
|
Arc::new(CalcPlugin::new()),
|
||||||
Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)),
|
Arc::new(FilesPlugin::new()),
|
||||||
], 8));
|
Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)),
|
||||||
k_launcher_ui_egui::run(kernel, launcher)?;
|
],
|
||||||
|
8,
|
||||||
|
));
|
||||||
|
k_launcher_ui_egui::run(kernel, launcher, &cfg.window)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ path = "src/lib.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
bincode = { workspace = true }
|
||||||
|
dirs = { workspace = true }
|
||||||
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
k-launcher-kernel = { path = "../../k-launcher-kernel" }
|
||||||
|
nucleo-matcher = "0.3"
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
linicon = "2.3.0"
|
||||||
xdg = "3"
|
xdg = "3"
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ impl FrecencyStore {
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| serde_json::from_str(&s).ok())
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
Arc::new(Self { path, data: Mutex::new(data) })
|
Arc::new(Self {
|
||||||
|
path,
|
||||||
|
data: Mutex::new(data),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -36,11 +39,14 @@ impl FrecencyStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn load() -> Arc<Self> {
|
pub fn load() -> Arc<Self> {
|
||||||
let path = xdg::BaseDirectories::new()
|
let Some(data_home) = xdg::BaseDirectories::new().get_data_home() else {
|
||||||
.get_data_home()
|
tracing::warn!("XDG_DATA_HOME unavailable; frecency disabled (in-memory only)");
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
return Arc::new(Self {
|
||||||
.join("k-launcher")
|
path: PathBuf::from("/dev/null"),
|
||||||
.join("frecency.json");
|
data: Mutex::new(HashMap::new()),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let path = data_home.join("k-launcher").join("frecency.json");
|
||||||
Self::new(path)
|
Self::new(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,14 +55,20 @@ impl FrecencyStore {
|
|||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_secs();
|
.as_secs();
|
||||||
let mut data = self.data.lock().unwrap();
|
let json = {
|
||||||
let entry = data.entry(id.to_string()).or_insert(Entry { count: 0, last_used: 0 });
|
let mut data = self.data.lock().unwrap();
|
||||||
entry.count += 1;
|
let entry = data.entry(id.to_string()).or_insert(Entry {
|
||||||
entry.last_used = now;
|
count: 0,
|
||||||
if let Some(parent) = self.path.parent() {
|
last_used: 0,
|
||||||
let _ = std::fs::create_dir_all(parent);
|
});
|
||||||
}
|
entry.count += 1;
|
||||||
if let Ok(json) = serde_json::to_string(&*data) {
|
entry.last_used = now;
|
||||||
|
serde_json::to_string(&*data).ok()
|
||||||
|
}; // lock released here
|
||||||
|
if let Some(json) = json {
|
||||||
|
if let Some(parent) = self.path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
let _ = std::fs::write(&self.path, json);
|
let _ = std::fs::write(&self.path, json);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,8 +81,7 @@ impl FrecencyStore {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_secs();
|
.as_secs();
|
||||||
let age_secs = now.saturating_sub(entry.last_used);
|
let age_secs = now.saturating_sub(entry.last_used);
|
||||||
let decay = if age_secs < 3600 { 4 } else if age_secs < 86400 { 2 } else { 1 };
|
entry.count * decay_factor(age_secs)
|
||||||
entry.count * decay
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn top_ids(&self, n: usize) -> Vec<String> {
|
pub fn top_ids(&self, n: usize) -> Vec<String> {
|
||||||
@@ -83,8 +94,7 @@ impl FrecencyStore {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|(id, entry)| {
|
.map(|(id, entry)| {
|
||||||
let age_secs = now.saturating_sub(entry.last_used);
|
let age_secs = now.saturating_sub(entry.last_used);
|
||||||
let decay = if age_secs < 3600 { 4 } else if age_secs < 86400 { 2 } else { 1 };
|
(id.clone(), entry.count * decay_factor(age_secs))
|
||||||
(id.clone(), entry.count * decay)
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
scored.sort_by(|a, b| b.1.cmp(&a.1));
|
scored.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
@@ -92,6 +102,16 @@ impl FrecencyStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn decay_factor(age_secs: u64) -> u32 {
|
||||||
|
if age_secs < 3600 {
|
||||||
|
4
|
||||||
|
} else if age_secs < 86400 {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ pub mod frecency;
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub mod linux;
|
pub mod linux;
|
||||||
|
|
||||||
use std::{collections::HashMap, sync::Arc};
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::{Arc, RwLock},
|
||||||
|
};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
|
||||||
@@ -68,67 +72,186 @@ pub trait DesktopEntrySource: Send + Sync {
|
|||||||
struct CachedEntry {
|
struct CachedEntry {
|
||||||
id: String,
|
id: String,
|
||||||
name: AppName,
|
name: AppName,
|
||||||
name_lc: String,
|
|
||||||
keywords_lc: Vec<String>,
|
keywords_lc: Vec<String>,
|
||||||
category: Option<String>,
|
category: Option<String>,
|
||||||
icon: Option<String>,
|
icon: Option<String>,
|
||||||
exec: String,
|
exec: String,
|
||||||
on_select: Arc<dyn Fn() + Send + Sync>,
|
}
|
||||||
|
|
||||||
|
// --- Serializable cache data (no closures) ---
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
|
struct CachedEntryData {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
keywords_lc: Vec<String>,
|
||||||
|
category: Option<String>,
|
||||||
|
icon: Option<String>,
|
||||||
|
exec: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_path() -> Option<PathBuf> {
|
||||||
|
dirs::cache_dir().map(|d| d.join("k-launcher/apps.bin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_from_path(path: &Path) -> Option<HashMap<String, CachedEntry>> {
|
||||||
|
let data = std::fs::read(path).ok()?;
|
||||||
|
let (entries_data, _): (Vec<CachedEntryData>, _) =
|
||||||
|
bincode::serde::decode_from_slice(&data, bincode::config::standard()).ok()?;
|
||||||
|
let map = entries_data
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| {
|
||||||
|
let cached = CachedEntry {
|
||||||
|
id: e.id.clone(),
|
||||||
|
name: AppName::new(e.name),
|
||||||
|
keywords_lc: e.keywords_lc,
|
||||||
|
category: e.category,
|
||||||
|
icon: e.icon,
|
||||||
|
exec: e.exec,
|
||||||
|
};
|
||||||
|
(e.id, cached)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Some(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_to_path(path: &Path, entries: &HashMap<String, CachedEntry>) {
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
std::fs::create_dir_all(dir).ok();
|
||||||
|
}
|
||||||
|
let data: Vec<CachedEntryData> = entries
|
||||||
|
.values()
|
||||||
|
.map(|e| CachedEntryData {
|
||||||
|
id: e.id.clone(),
|
||||||
|
name: e.name.as_str().to_string(),
|
||||||
|
keywords_lc: e.keywords_lc.clone(),
|
||||||
|
category: e.category.clone(),
|
||||||
|
icon: e.icon.clone(),
|
||||||
|
exec: e.exec.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if let Ok(encoded) = bincode::serde::encode_to_vec(&data, bincode::config::standard()) {
|
||||||
|
std::fs::write(path, encoded).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_entries(
|
||||||
|
source: &impl DesktopEntrySource,
|
||||||
|
_frecency: &Arc<FrecencyStore>,
|
||||||
|
) -> HashMap<String, CachedEntry> {
|
||||||
|
source
|
||||||
|
.entries()
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| {
|
||||||
|
let id = format!("app-{}:{}", e.name.as_str(), e.exec.as_str());
|
||||||
|
let keywords_lc = e.keywords.iter().map(|k| k.to_lowercase()).collect();
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let icon = e
|
||||||
|
.icon
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| linux::resolve_icon_path(p.as_str()));
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let icon: Option<String> = None;
|
||||||
|
let exec = e.exec.as_str().to_string();
|
||||||
|
let cached = CachedEntry {
|
||||||
|
id: id.clone(),
|
||||||
|
keywords_lc,
|
||||||
|
category: e.category,
|
||||||
|
icon,
|
||||||
|
exec,
|
||||||
|
name: e.name,
|
||||||
|
};
|
||||||
|
(id, cached)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Plugin ---
|
// --- Plugin ---
|
||||||
|
|
||||||
pub struct AppsPlugin {
|
pub struct AppsPlugin {
|
||||||
entries: HashMap<String, CachedEntry>,
|
entries: Arc<RwLock<HashMap<String, CachedEntry>>>,
|
||||||
frecency: Arc<FrecencyStore>,
|
frecency: Arc<FrecencyStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppsPlugin {
|
impl AppsPlugin {
|
||||||
pub fn new(source: impl DesktopEntrySource, frecency: Arc<FrecencyStore>) -> Self {
|
pub fn new(source: impl DesktopEntrySource + 'static, frecency: Arc<FrecencyStore>) -> Self {
|
||||||
let entries = source
|
Self::new_impl(source, frecency, cache_path())
|
||||||
.entries()
|
}
|
||||||
.into_iter()
|
|
||||||
.map(|e| {
|
fn new_impl(
|
||||||
let id = format!("app-{}", e.name.as_str());
|
source: impl DesktopEntrySource + 'static,
|
||||||
let name_lc = e.name.as_str().to_lowercase();
|
frecency: Arc<FrecencyStore>,
|
||||||
let keywords_lc = e.keywords.iter().map(|k| k.to_lowercase()).collect();
|
cp: Option<PathBuf>,
|
||||||
#[cfg(target_os = "linux")]
|
) -> Self {
|
||||||
let icon = e.icon.as_ref().and_then(|p| linux::resolve_icon_path(p.as_str()));
|
let cached = cp.as_deref().and_then(load_from_path);
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
let icon: Option<String> = None;
|
let entries = if let Some(from_cache) = cached {
|
||||||
let exec = e.exec.as_str().to_string();
|
// Serve cache immediately; refresh in background.
|
||||||
let store = Arc::clone(&frecency);
|
let map = Arc::new(RwLock::new(from_cache));
|
||||||
let record_id = id.clone();
|
let entries_bg = Arc::clone(&map);
|
||||||
let on_select: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
|
let frecency_bg = Arc::clone(&frecency);
|
||||||
store.record(&record_id);
|
let cp_bg = cp.clone();
|
||||||
});
|
std::thread::spawn(move || {
|
||||||
let cached = CachedEntry {
|
let fresh = build_entries(&source, &frecency_bg);
|
||||||
id: id.clone(),
|
if let Some(path) = cp_bg {
|
||||||
name_lc,
|
save_to_path(&path, &fresh);
|
||||||
keywords_lc,
|
}
|
||||||
category: e.category,
|
*entries_bg.write().unwrap() = fresh;
|
||||||
icon,
|
});
|
||||||
exec,
|
map
|
||||||
on_select,
|
} else {
|
||||||
name: e.name,
|
// No cache: build synchronously, then persist.
|
||||||
};
|
let initial = build_entries(&source, &frecency);
|
||||||
(id, cached)
|
if let Some(path) = &cp {
|
||||||
})
|
save_to_path(path, &initial);
|
||||||
.collect();
|
}
|
||||||
|
Arc::new(RwLock::new(initial))
|
||||||
|
};
|
||||||
|
|
||||||
Self { entries, frecency }
|
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 {
|
fn initials(name_lc: &str) -> String {
|
||||||
name_lc.split_whitespace().filter_map(|w| w.chars().next()).collect()
|
name_lc
|
||||||
|
.split_whitespace()
|
||||||
|
.filter_map(|w| w.chars().next())
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn score_match(name_lc: &str, query_lc: &str) -> Option<u32> {
|
fn score_match(name: &str, query: &str) -> Option<u32> {
|
||||||
if name_lc == query_lc { return Some(100); }
|
use nucleo_matcher::{
|
||||||
if name_lc.starts_with(query_lc) { return Some(80); }
|
Config, Matcher, Utf32Str,
|
||||||
if name_lc.contains(query_lc) { return Some(60); }
|
pattern::{CaseMatching, Normalization, Pattern},
|
||||||
if initials(name_lc).starts_with(query_lc) { return Some(70); }
|
};
|
||||||
None
|
|
||||||
|
let mut matcher = Matcher::new(Config::DEFAULT);
|
||||||
|
let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
|
||||||
|
|
||||||
|
let mut name_chars: Vec<char> = name.chars().collect();
|
||||||
|
let haystack = Utf32Str::new(name, &mut name_chars);
|
||||||
|
let score = pattern.score(haystack, &mut matcher);
|
||||||
|
|
||||||
|
if let Some(s) = score {
|
||||||
|
let name_lc = name.to_lowercase();
|
||||||
|
let query_lc = query.to_lowercase();
|
||||||
|
let bonus: u32 = if initials(&name_lc).starts_with(&query_lc) {
|
||||||
|
20
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
Some(s.saturating_add(bonus))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn humanize_category(s: &str) -> String {
|
pub(crate) fn humanize_category(s: &str) -> String {
|
||||||
@@ -148,12 +271,19 @@ impl Plugin for AppsPlugin {
|
|||||||
"apps"
|
"apps"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn on_selected(&self, id: &ResultId) {
|
||||||
|
self.frecency.record(id.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
async fn search(&self, query: &str) -> Vec<SearchResult> {
|
||||||
|
let entries = self.entries.read().unwrap();
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
return self.frecency.top_ids(5)
|
return self
|
||||||
|
.frecency
|
||||||
|
.top_ids(5)
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|id| {
|
.filter_map(|id| {
|
||||||
let e = self.entries.get(id)?;
|
let e = entries.get(id)?;
|
||||||
let score = self.frecency.frecency_score(id).max(1);
|
let score = self.frecency.frecency_score(id).max(1);
|
||||||
Some(SearchResult {
|
Some(SearchResult {
|
||||||
id: ResultId::new(id),
|
id: ResultId::new(id),
|
||||||
@@ -162,18 +292,20 @@ impl Plugin for AppsPlugin {
|
|||||||
icon: e.icon.clone(),
|
icon: e.icon.clone(),
|
||||||
score: Score::new(score),
|
score: Score::new(score),
|
||||||
action: LaunchAction::SpawnProcess(e.exec.clone()),
|
action: LaunchAction::SpawnProcess(e.exec.clone()),
|
||||||
on_select: Some(Arc::clone(&e.on_select)),
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
let query_lc = query.to_lowercase();
|
let query_lc = query.to_lowercase();
|
||||||
self.entries
|
entries
|
||||||
.values()
|
.values()
|
||||||
.filter_map(|e| {
|
.filter_map(|e| {
|
||||||
let score = score_match(&e.name_lc, &query_lc).or_else(|| {
|
let score = score_match(e.name.as_str(), query).or_else(|| {
|
||||||
e.keywords_lc.iter().any(|k| k.contains(&query_lc)).then_some(50)
|
e.keywords_lc
|
||||||
|
.iter()
|
||||||
|
.any(|k| k.contains(&query_lc))
|
||||||
|
.then_some(50)
|
||||||
})?;
|
})?;
|
||||||
Some(SearchResult {
|
Some(SearchResult {
|
||||||
id: ResultId::new(&e.id),
|
id: ResultId::new(&e.id),
|
||||||
@@ -182,7 +314,6 @@ impl Plugin for AppsPlugin {
|
|||||||
icon: e.icon.clone(),
|
icon: e.icon.clone(),
|
||||||
score: Score::new(score),
|
score: Score::new(score),
|
||||||
action: LaunchAction::SpawnProcess(e.exec.clone()),
|
action: LaunchAction::SpawnProcess(e.exec.clone()),
|
||||||
on_select: Some(Arc::clone(&e.on_select)),
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -226,7 +357,14 @@ mod tests {
|
|||||||
Self {
|
Self {
|
||||||
entries: entries
|
entries: entries
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(n, e, kw)| (n.to_string(), e.to_string(), None, kw.into_iter().map(|s| s.to_string()).collect()))
|
.map(|(n, e, kw)| {
|
||||||
|
(
|
||||||
|
n.to_string(),
|
||||||
|
e.to_string(),
|
||||||
|
None,
|
||||||
|
kw.into_iter().map(|s| s.to_string()).collect(),
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,52 +387,66 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn apps_prefix_match() {
|
async fn apps_prefix_match() {
|
||||||
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency());
|
let p = AppsPlugin::new_for_test(
|
||||||
|
MockSource::with(vec![("Firefox", "firefox")]),
|
||||||
|
ephemeral_frecency(),
|
||||||
|
);
|
||||||
let results = p.search("fire").await;
|
let results = p.search("fire").await;
|
||||||
assert_eq!(results[0].title.as_str(), "Firefox");
|
assert_eq!(results[0].title.as_str(), "Firefox");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn apps_no_match_returns_empty() {
|
async fn apps_no_match_returns_empty() {
|
||||||
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency());
|
let p = AppsPlugin::new_for_test(
|
||||||
|
MockSource::with(vec![("Firefox", "firefox")]),
|
||||||
|
ephemeral_frecency(),
|
||||||
|
);
|
||||||
assert!(p.search("zz").await.is_empty());
|
assert!(p.search("zz").await.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn apps_empty_query_no_frecency_returns_empty() {
|
async fn apps_empty_query_no_frecency_returns_empty() {
|
||||||
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency());
|
let p = AppsPlugin::new_for_test(
|
||||||
|
MockSource::with(vec![("Firefox", "firefox")]),
|
||||||
|
ephemeral_frecency(),
|
||||||
|
);
|
||||||
assert!(p.search("").await.is_empty());
|
assert!(p.search("").await.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn score_match_abbreviation() {
|
fn score_match_abbreviation() {
|
||||||
assert_eq!(initials("visual studio code"), "vsc");
|
assert_eq!(initials("visual studio code"), "vsc");
|
||||||
assert_eq!(score_match("visual studio code", "vsc"), Some(70));
|
assert!(score_match("visual studio code", "vsc").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn score_match_exact_beats_prefix_beats_abbrev_beats_substr() {
|
fn score_match_exact_beats_prefix() {
|
||||||
assert_eq!(score_match("firefox", "firefox"), Some(100));
|
let exact = score_match("firefox", "firefox");
|
||||||
assert_eq!(score_match("firefox", "fire"), Some(80));
|
let prefix = score_match("firefox", "fire");
|
||||||
assert_eq!(score_match("gnu firefox", "gf"), Some(70));
|
let abbrev = score_match("gnu firefox", "gf");
|
||||||
assert_eq!(score_match("ice firefox", "fire"), Some(60));
|
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]
|
#[tokio::test]
|
||||||
async fn apps_abbreviation_match() {
|
async fn apps_abbreviation_match() {
|
||||||
let p = AppsPlugin::new(
|
let p = AppsPlugin::new_for_test(
|
||||||
MockSource::with(vec![("Visual Studio Code", "code")]),
|
MockSource::with(vec![("Visual Studio Code", "code")]),
|
||||||
ephemeral_frecency(),
|
ephemeral_frecency(),
|
||||||
);
|
);
|
||||||
let results = p.search("vsc").await;
|
let results = p.search("vsc").await;
|
||||||
assert_eq!(results.len(), 1);
|
assert_eq!(results.len(), 1);
|
||||||
assert_eq!(results[0].title.as_str(), "Visual Studio Code");
|
assert_eq!(results[0].title.as_str(), "Visual Studio Code");
|
||||||
assert_eq!(results[0].score.value(), 70);
|
assert!(results[0].score.value() > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn apps_keyword_match() {
|
async fn apps_keyword_match() {
|
||||||
let p = AppsPlugin::new(
|
let p = AppsPlugin::new_for_test(
|
||||||
MockSource::with_keywords(vec![("Code", "code", vec!["editor", "ide"])]),
|
MockSource::with_keywords(vec![("Code", "code", vec!["editor", "ide"])]),
|
||||||
ephemeral_frecency(),
|
ephemeral_frecency(),
|
||||||
);
|
);
|
||||||
@@ -303,6 +455,20 @@ mod tests {
|
|||||||
assert_eq!(results[0].score.value(), 50);
|
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]
|
#[test]
|
||||||
fn humanize_category_splits_camel_case() {
|
fn humanize_category_splits_camel_case() {
|
||||||
assert_eq!(humanize_category("TextEditor"), "Text Editor");
|
assert_eq!(humanize_category("TextEditor"), "Text Editor");
|
||||||
@@ -312,7 +478,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn apps_category_appears_in_description() {
|
async fn apps_category_appears_in_description() {
|
||||||
let p = AppsPlugin::new(
|
let p = AppsPlugin::new_for_test(
|
||||||
MockSource::with_categories(vec![("Code", "code", "Text Editor")]),
|
MockSource::with_categories(vec![("Code", "code", "Text Editor")]),
|
||||||
ephemeral_frecency(),
|
ephemeral_frecency(),
|
||||||
);
|
);
|
||||||
@@ -323,10 +489,10 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn apps_empty_query_returns_top_frecent() {
|
async fn apps_empty_query_returns_top_frecent() {
|
||||||
let frecency = ephemeral_frecency();
|
let frecency = ephemeral_frecency();
|
||||||
frecency.record("app-Code");
|
frecency.record("app-Code:code");
|
||||||
frecency.record("app-Code");
|
frecency.record("app-Code:code");
|
||||||
frecency.record("app-Firefox");
|
frecency.record("app-Firefox:firefox");
|
||||||
let p = AppsPlugin::new(
|
let p = AppsPlugin::new_for_test(
|
||||||
MockSource::with(vec![("Firefox", "firefox"), ("Code", "code")]),
|
MockSource::with(vec![("Firefox", "firefox"), ("Code", "code")]),
|
||||||
frecency,
|
frecency,
|
||||||
);
|
);
|
||||||
@@ -334,4 +500,22 @@ mod tests {
|
|||||||
assert_eq!(results.len(), 2);
|
assert_eq!(results.len(), 2);
|
||||||
assert_eq!(results[0].title.as_str(), "Code");
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::{AppName, DesktopEntry, DesktopEntrySource, ExecCommand, IconPath};
|
|
||||||
use crate::humanize_category;
|
use crate::humanize_category;
|
||||||
|
use crate::{AppName, DesktopEntry, DesktopEntrySource, ExecCommand, IconPath};
|
||||||
|
|
||||||
pub struct FsDesktopEntrySource;
|
pub struct FsDesktopEntrySource;
|
||||||
|
|
||||||
@@ -45,15 +45,79 @@ impl DesktopEntrySource for FsDesktopEntrySource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn clean_exec(exec: &str) -> String {
|
||||||
|
// Tokenize respecting double-quoted strings, then filter field codes.
|
||||||
|
let mut tokens: Vec<String> = Vec::new();
|
||||||
|
let mut chars = exec.chars().peekable();
|
||||||
|
|
||||||
|
while let Some(&ch) = chars.peek() {
|
||||||
|
if ch.is_whitespace() {
|
||||||
|
chars.next();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ch == '"' {
|
||||||
|
// Consume opening quote
|
||||||
|
chars.next();
|
||||||
|
let mut token = String::from('"');
|
||||||
|
while let Some(&c) = chars.peek() {
|
||||||
|
chars.next();
|
||||||
|
if c == '"' {
|
||||||
|
token.push('"');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
token.push(c);
|
||||||
|
}
|
||||||
|
// Strip embedded field codes like %f inside the quoted string
|
||||||
|
// (between the quotes, before re-assembling)
|
||||||
|
let inner = &token[1..token.len().saturating_sub(1)];
|
||||||
|
let cleaned_inner: String = inner
|
||||||
|
.split_whitespace()
|
||||||
|
.filter(|s| !is_field_code(s))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
tokens.push(format!("\"{cleaned_inner}\""));
|
||||||
|
} else {
|
||||||
|
let mut token = String::new();
|
||||||
|
while let Some(&c) = chars.peek() {
|
||||||
|
if c.is_whitespace() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
chars.next();
|
||||||
|
token.push(c);
|
||||||
|
}
|
||||||
|
if !is_field_code(&token) {
|
||||||
|
tokens.push(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_field_code(s: &str) -> bool {
|
||||||
|
let b = s.as_bytes();
|
||||||
|
b.len() == 2 && b[0] == b'%' && b[1].is_ascii_alphabetic()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn resolve_icon_path(name: &str) -> Option<String> {
|
pub fn resolve_icon_path(name: &str) -> Option<String> {
|
||||||
if name.starts_with('/') && Path::new(name).exists() {
|
if name.starts_with('/') && Path::new(name).exists() {
|
||||||
return Some(name.to_string());
|
return Some(name.to_string());
|
||||||
}
|
}
|
||||||
|
// Try linicon freedesktop theme traversal
|
||||||
|
let themes = ["hicolor", "Adwaita", "breeze", "Papirus"];
|
||||||
|
for theme in &themes {
|
||||||
|
if let Some(icon_path) = linicon::lookup_icon(name)
|
||||||
|
.from_theme(theme)
|
||||||
|
.with_size(48)
|
||||||
|
.find_map(|r| r.ok())
|
||||||
|
{
|
||||||
|
return Some(icon_path.path.to_string_lossy().into_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to pixmaps
|
||||||
let candidates = [
|
let candidates = [
|
||||||
format!("/usr/share/pixmaps/{name}.png"),
|
format!("/usr/share/pixmaps/{name}.png"),
|
||||||
format!("/usr/share/pixmaps/{name}.svg"),
|
format!("/usr/share/pixmaps/{name}.svg"),
|
||||||
format!("/usr/share/icons/hicolor/48x48/apps/{name}.png"),
|
|
||||||
format!("/usr/share/icons/hicolor/scalable/apps/{name}.svg"),
|
|
||||||
];
|
];
|
||||||
candidates.into_iter().find(|p| Path::new(p).exists())
|
candidates.into_iter().find(|p| Path::new(p).exists())
|
||||||
}
|
}
|
||||||
@@ -90,13 +154,15 @@ fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
|
|||||||
"Type" if !is_application => is_application = value.trim() == "Application",
|
"Type" if !is_application => is_application = value.trim() == "Application",
|
||||||
"NoDisplay" => no_display = value.trim().eq_ignore_ascii_case("true"),
|
"NoDisplay" => no_display = value.trim().eq_ignore_ascii_case("true"),
|
||||||
"Categories" if category.is_none() => {
|
"Categories" if category.is_none() => {
|
||||||
category = value.trim()
|
category = value
|
||||||
|
.trim()
|
||||||
.split(';')
|
.split(';')
|
||||||
.find(|s| !s.is_empty())
|
.find(|s| !s.is_empty())
|
||||||
.map(|s| humanize_category(s.trim()));
|
.map(|s| humanize_category(s.trim()));
|
||||||
}
|
}
|
||||||
"Keywords" if keywords.is_empty() => {
|
"Keywords" if keywords.is_empty() => {
|
||||||
keywords = value.trim()
|
keywords = value
|
||||||
|
.trim()
|
||||||
.split(';')
|
.split(';')
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
@@ -111,16 +177,7 @@ fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let exec_clean: String = exec?
|
let exec_clean: String = clean_exec(&exec?);
|
||||||
.split_whitespace()
|
|
||||||
.filter(|s| !s.starts_with('%'))
|
|
||||||
.fold(String::new(), |mut acc, s| {
|
|
||||||
if !acc.is_empty() {
|
|
||||||
acc.push(' ');
|
|
||||||
}
|
|
||||||
acc.push_str(s);
|
|
||||||
acc
|
|
||||||
});
|
|
||||||
|
|
||||||
Some(DesktopEntry {
|
Some(DesktopEntry {
|
||||||
name: AppName::new(name?),
|
name: AppName::new(name?),
|
||||||
@@ -130,3 +187,31 @@ fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
|
|||||||
keywords,
|
keywords,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod exec_tests {
|
||||||
|
use super::clean_exec;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_bare_field_code() {
|
||||||
|
assert_eq!(clean_exec("app --file %f"), "app --file");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_multiple_field_codes() {
|
||||||
|
assert_eq!(clean_exec("app %U --flag"), "app --flag");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_quoted_value() {
|
||||||
|
assert_eq!(
|
||||||
|
clean_exec(r#"app --arg="value" %U"#),
|
||||||
|
r#"app --arg="value""#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handles_plain_exec() {
|
||||||
|
assert_eq!(clean_exec("firefox"), "firefox");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ fn strip_numeric_separators(expr: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MATH_FNS: &[&str] = &[
|
const MATH_FNS: &[&str] = &[
|
||||||
"sqrt", "sin", "cos", "tan", "asin", "acos", "atan",
|
"sqrt", "sin", "cos", "tan", "asin", "acos", "atan", "ln", "log2", "log10", "exp", "abs",
|
||||||
"ln", "log2", "log10", "exp", "abs", "ceil", "floor", "round",
|
"ceil", "floor", "round",
|
||||||
];
|
];
|
||||||
|
|
||||||
fn should_eval(query: &str) -> bool {
|
fn should_eval(query: &str) -> bool {
|
||||||
@@ -36,8 +36,8 @@ fn should_eval(query: &str) -> bool {
|
|||||||
|| MATH_FNS.iter().any(|f| q.starts_with(f))
|
|| MATH_FNS.iter().any(|f| q.starts_with(f))
|
||||||
}
|
}
|
||||||
|
|
||||||
static MATH_CTX: LazyLock<evalexpr::HashMapContext<evalexpr::DefaultNumericTypes>> =
|
static MATH_CTX: LazyLock<evalexpr::HashMapContext<evalexpr::DefaultNumericTypes>> = LazyLock::new(
|
||||||
LazyLock::new(|| {
|
|| {
|
||||||
use evalexpr::*;
|
use evalexpr::*;
|
||||||
context_map! {
|
context_map! {
|
||||||
"pi" => float std::f64::consts::PI,
|
"pi" => float std::f64::consts::PI,
|
||||||
@@ -59,7 +59,8 @@ static MATH_CTX: LazyLock<evalexpr::HashMapContext<evalexpr::DefaultNumericTypes
|
|||||||
"round" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.round())))
|
"round" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.round())))
|
||||||
}
|
}
|
||||||
.expect("static math context must be valid")
|
.expect("static math context must be valid")
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Plugin for CalcPlugin {
|
impl Plugin for CalcPlugin {
|
||||||
@@ -89,7 +90,6 @@ impl Plugin for CalcPlugin {
|
|||||||
icon: None,
|
icon: None,
|
||||||
score: Score::new(90),
|
score: Score::new(90),
|
||||||
action: LaunchAction::CopyToClipboard(value_str),
|
action: LaunchAction::CopyToClipboard(value_str),
|
||||||
on_select: None,
|
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ impl Plugin for CmdPlugin {
|
|||||||
icon: None,
|
icon: None,
|
||||||
score: Score::new(95),
|
score: Score::new(95),
|
||||||
action: LaunchAction::SpawnInTerminal(cmd.to_string()),
|
action: LaunchAction::SpawnInTerminal(cmd.to_string()),
|
||||||
on_select: None,
|
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,25 +71,19 @@ impl Plugin for FilesPlugin {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
})
|
})
|
||||||
.take(20)
|
.take(20)
|
||||||
.enumerate()
|
.map(|entry| {
|
||||||
.map(|(i, entry)| {
|
|
||||||
let full_path = entry.path();
|
let full_path = entry.path();
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
let is_dir = full_path.is_dir();
|
let is_dir = full_path.is_dir();
|
||||||
let title = if is_dir {
|
let title = if is_dir { format!("{name}/") } else { name };
|
||||||
format!("{name}/")
|
|
||||||
} else {
|
|
||||||
name
|
|
||||||
};
|
|
||||||
let path_str = full_path.to_string_lossy().to_string();
|
let path_str = full_path.to_string_lossy().to_string();
|
||||||
SearchResult {
|
SearchResult {
|
||||||
id: ResultId::new(format!("file-{i}")),
|
id: ResultId::new(&path_str),
|
||||||
title: ResultTitle::new(title),
|
title: ResultTitle::new(title),
|
||||||
description: Some(path_str.clone()),
|
description: Some(path_str.clone()),
|
||||||
icon: None,
|
icon: None,
|
||||||
score: Score::new(50),
|
score: Score::new(50),
|
||||||
action: LaunchAction::OpenPath(path_str),
|
action: LaunchAction::OpenPath(path_str),
|
||||||
on_select: None,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ struct Query {
|
|||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct Action {
|
struct Action {
|
||||||
r#type: &'static str,
|
r#type: &'static str,
|
||||||
cmd: String,
|
path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -45,8 +45,8 @@ fn search(query: &str) -> Vec<Result> {
|
|||||||
description: url.clone(),
|
description: url.clone(),
|
||||||
score: 95,
|
score: 95,
|
||||||
action: Action {
|
action: Action {
|
||||||
r#type: "SpawnProcess",
|
r#type: "OpenPath",
|
||||||
cmd: format!("xdg-open {url}"),
|
path: url.clone(),
|
||||||
},
|
},
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,7 @@ mod tests {
|
|||||||
fn search_returns_result() {
|
fn search_returns_result() {
|
||||||
let results = search("https://example.com");
|
let results = search("https://example.com");
|
||||||
assert_eq!(results.len(), 1);
|
assert_eq!(results.len(), 1);
|
||||||
assert_eq!(results[0].action.cmd, "xdg-open https://example.com");
|
assert_eq!(results[0].action.path, "https://example.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -124,7 +124,7 @@ mod tests {
|
|||||||
fn result_serializes() {
|
fn result_serializes() {
|
||||||
let results = search("https://example.com");
|
let results = search("https://example.com");
|
||||||
let json = serde_json::to_string(&results).unwrap();
|
let json = serde_json::to_string(&results).unwrap();
|
||||||
assert!(json.contains("SpawnProcess"));
|
assert!(json.contains("OpenPath"));
|
||||||
assert!(json.contains("xdg-open"));
|
assert!(json.contains("https://example.com"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
packaging/aur/.SRCINFO
Normal file
15
packaging/aur/.SRCINFO
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
pkgbase = k-launcher-bin
|
||||||
|
pkgdesc = GPU-accelerated command palette launcher for Linux (Wayland/X11)
|
||||||
|
pkgver = 0.1.0
|
||||||
|
pkgrel = 1
|
||||||
|
url = https://github.com/GKaszewski/k-launcher
|
||||||
|
arch = x86_64
|
||||||
|
license = MIT
|
||||||
|
depends = wayland
|
||||||
|
depends = libxkbcommon
|
||||||
|
provides = k-launcher
|
||||||
|
conflicts = k-launcher
|
||||||
|
source = k-launcher-0.1.0::https://github.com/GKaszewski/k-launcher/releases/download/v0.1.0/k-launcher
|
||||||
|
sha256sums = SKIP
|
||||||
|
|
||||||
|
pkgname = k-launcher-bin
|
||||||
17
packaging/aur/PKGBUILD
Normal file
17
packaging/aur/PKGBUILD
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Maintainer: k-launcher contributors
|
||||||
|
pkgname=k-launcher-bin
|
||||||
|
pkgver=0.1.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="GPU-accelerated command palette launcher for Linux (Wayland/X11)"
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://github.com/GKaszewski/k-launcher"
|
||||||
|
license=('MIT')
|
||||||
|
depends=('wayland' 'libxkbcommon')
|
||||||
|
provides=('k-launcher')
|
||||||
|
conflicts=('k-launcher')
|
||||||
|
source=("k-launcher-${pkgver}::https://github.com/GKaszewski/k-launcher/releases/download/v${pkgver}/k-launcher")
|
||||||
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
package() {
|
||||||
|
install -Dm755 "k-launcher-${pkgver}" "${pkgdir}/usr/bin/k-launcher"
|
||||||
|
}
|
||||||
11
packaging/systemd/k-launcher.service
Normal file
11
packaging/systemd/k-launcher.service
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=k-launcher command palette daemon
|
||||||
|
After=graphical-session.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/k-launcher
|
||||||
|
Restart=on-failure
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=graphical-session.target
|
||||||
8
packaging/systemd/k-launcher.socket
Normal file
8
packaging/systemd/k-launcher.socket
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=k-launcher IPC socket
|
||||||
|
|
||||||
|
[Socket]
|
||||||
|
ListenStream=%t/k-launcher.sock
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=sockets.target
|
||||||
Reference in New Issue
Block a user