feat: restructure k-launcher workspace and add core functionality

- Updated Cargo.toml to include a new k-launcher crate and reorganized workspace members.
- Introduced a README.md file detailing the project philosophy, architecture, and technical specifications.
- Implemented a new Kernel struct in k-launcher-kernel for managing plugins and search functionality.
- Created a Plugin trait for plugins to implement, allowing for asynchronous search operations.
- Developed k-launcher-ui with an Iced-based UI for user interaction, including search input and result display.
- Added AppsPlugin and CalcPlugin to handle application launching and basic calculations, respectively.
- Established a theme module for UI styling, focusing on an Aero aesthetic.
- Removed unnecessary main.rs files from plugin crates, streamlining the project structure.
This commit is contained in:
2026-03-15 16:20:36 +01:00
parent 4c3be17b77
commit 1ac9dde347
19 changed files with 6077 additions and 28 deletions

5168
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,19 @@
[workspace] [workspace]
members = ["crates/k-launcher-kernel", "crates/k-launcher-os-bridge", "crates/k-launcher-ui", "crates/plugins/plugin-apps", "crates/plugins/plugin-calc", "crates/plugins/plugin-files"] members = [
"crates/k-launcher",
"crates/k-launcher-kernel",
"crates/k-launcher-os-bridge",
"crates/k-launcher-ui",
"crates/plugins/plugin-apps",
"crates/plugins/plugin-calc",
"crates/plugins/plugin-files",
]
resolver = "2" resolver = "2"
[workspace.dependencies] [workspace.dependencies]
iced = { version = "0.14", features = ["canvas", "tokio", "wgpu"] } async-trait = "0.1"
tokio = { version = "1.35", features = ["full"] } futures = "0.3"
iced = { version = "0.14", features = ["canvas", "image", "svg", "tokio", "wgpu"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
tracing = "0.1" # For high-performance logging tokio = { version = "1.35", features = ["full"] }
tracing = "0.1"

56
README.md Normal file
View File

@@ -0,0 +1,56 @@
# K-Launcher
K-Launcher is a lightweight, GPU-accelerated command palette for Linux (Wayland/X11), macOS, and Windows. It reimagines the "Spotlight" experience through the lens of Frutiger Aero—focusing on gloss, glass, and skeuomorphism—powered by a non-blocking, multi-threaded Rust kernel.
## Core Philosophy
- Zero Webview: No Chromium, no Electron. Every pixel is rendered via WGPU (Iced) for sub-5ms input-to-render latency.
- Async-First: Search queries never block the UI. If the file-searcher is indexing, the calculator still feels instant.
- The "Aero" Standard: Deep support for Gaussian blur (via Layer Shell), linear gradients, and high-gloss textures.
## High-Level Architecture
We are utilizing a "Hub-and-Spoke" model within a Cargo Workspace. The k-launcher-kernel acts as the central hub, dispatching user input to various "Spokes" (Plugins).
### The Crate Hierarchy
| Crate | Responsibility | Key Dependencies |
| -------------------------- | --------------------------------------------------------- | -------------------------------------- |
| **`k-launcher`** | The entry-point binary. Glues everything together. | `k-launcher-ui`, `k-launcher-kernel` |
| **`k-launcher-ui`** | The Iced-based view layer. Handles animations/theming. | `iced`, `lyon` (for vector paths) |
| **`k-launcher-kernel`** | The "Brain." Manages state, history, and plugin dispatch. | `tokio`, `tracing` |
| **`k-launcher-os-bridge`** | OS-specific windowing (Layer Shell for Wayland, Win32). | `iced_layershell`, `raw-window-handle` |
| **`plugins/*`** | Individual features (Calc, Files, Apps, Web). | `plugin-api` (Shared traits) |
## Data & Communication Flow
K-Launcher operates on an Event loop.
```
sequenceDiagram
participant User
participant UI as k-launcher-ui
participant Kernel as k-launcher-kernel
participant Plugins as plugin-file-search
User->>UI: Types "p"
UI->>Kernel: QueryUpdate("p")
par Parallel Search
Kernel->>Plugins: async search("p")
Plugins-->>Kernel: List<SearchResult>
end
Kernel->>UI: NewResults(Vec)
UI-->>User: Render Glass Result List
```
## Technical Specifications
To ensure "Plug and Play" capability, all features must implement the `Plugin` trait. This allows the user to swap the default `file-searcher` for something like `fzf` or `plocate` without recompiling the UI.
To achieve the 2000s aesthetic without a browser:
- Background Blur: On Wayland, we request blur through the org_kde_kwin_blur or fractional-scale protocols.
- Shaders: We will use Iceds canvas to draw glossy "shine" overlays that respond to mouse hovering.
- Icons: We will prefer .svg and .png with high-depth shadows over flat icon fonts.

View File

@@ -4,3 +4,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
async-trait = { workspace = true }
futures = { workspace = true }
serde = { workspace = true }
tokio = { workspace = true }

View File

@@ -1,14 +1,168 @@
pub fn add(left: u64, right: u64) -> u64 { use std::sync::Arc;
left + right
use async_trait::async_trait;
use futures::future::join_all;
pub type PluginName = &'static str;
// --- Newtypes ---
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct ResultId(String);
impl ResultId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
} }
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ResultTitle(String);
impl ResultTitle {
pub fn new(title: impl Into<String>) -> Self {
Self(title.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
pub struct Score(u32);
impl Score {
pub fn new(value: u32) -> Self {
Self(value)
}
pub fn value(self) -> u32 {
self.0
}
}
// --- SearchResult ---
pub struct SearchResult {
pub id: ResultId,
pub title: ResultTitle,
pub description: Option<String>,
pub icon: Option<String>,
pub score: Score,
pub on_execute: Arc<dyn Fn() + Send + Sync>,
}
impl std::fmt::Debug for SearchResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SearchResult")
.field("id", &self.id)
.field("title", &self.title)
.field("icon", &self.icon)
.field("score", &self.score)
.finish_non_exhaustive()
}
}
// --- Plugin trait ---
#[async_trait]
pub trait Plugin: Send + Sync {
fn name(&self) -> PluginName;
async fn search(&self, query: &str) -> Vec<SearchResult>;
}
// --- Kernel (Application use case) ---
pub struct Kernel {
plugins: Vec<Arc<dyn Plugin>>,
}
impl Kernel {
pub fn new(plugins: Vec<Arc<dyn Plugin>>) -> Self {
Self { plugins }
}
pub async fn search(&self, query: &str) -> Vec<SearchResult> {
let futures = self.plugins.iter().map(|p| p.search(query));
let nested: Vec<Vec<SearchResult>> = join_all(futures).await;
let mut flat: Vec<SearchResult> = nested.into_iter().flatten().collect();
flat.sort_by(|a, b| b.score.cmp(&a.score));
flat
}
}
// --- Tests ---
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
struct MockPlugin {
results: Vec<(&'static str, u32)>,
}
impl MockPlugin {
fn returns(results: Vec<(&'static str, u32)>) -> Self {
Self { results }
}
}
#[async_trait]
impl Plugin for MockPlugin {
fn name(&self) -> PluginName {
"mock"
}
async fn search(&self, _query: &str) -> Vec<SearchResult> {
self.results
.iter()
.enumerate()
.map(|(i, (title, score))| SearchResult {
id: ResultId::new(format!("id-{i}")),
title: ResultTitle::new(*title),
description: None,
icon: None,
score: Score::new(*score),
on_execute: Arc::new(|| {}),
})
.collect()
}
}
#[test] #[test]
fn it_works() { fn newtype_result_id() {
let result = add(2, 2); assert_eq!(ResultId::new("x").as_str(), "x");
assert_eq!(result, 4); }
#[test]
fn newtype_score() {
assert_eq!(Score::new(42).value(), 42);
}
#[test]
fn newtype_title() {
assert_eq!(ResultTitle::new("hello").as_str(), "hello");
}
#[tokio::test]
async fn empty_kernel_returns_empty() {
let k = Kernel::new(vec![]);
assert!(k.search("x").await.is_empty());
}
#[tokio::test]
async fn kernel_sorts_by_score_desc() {
let plugin = Arc::new(MockPlugin::returns(vec![
("lower", 5),
("higher", 10),
("middle", 7),
]));
let k = Kernel::new(vec![plugin]);
let results = k.search("q").await;
assert_eq!(results[0].score.value(), 10);
assert_eq!(results[1].score.value(), 7);
assert_eq!(results[2].score.value(), 5);
} }
} }

View File

@@ -1,14 +1,20 @@
pub fn add(left: u64, right: u64) -> u64 { /// Configuration for the launcher window.
left + right pub struct WindowConfig {
pub width: f32,
pub height: f32,
pub decorations: bool,
pub transparent: bool,
pub resizable: bool,
} }
#[cfg(test)] impl WindowConfig {
mod tests { pub fn launcher() -> Self {
use super::*; Self {
width: 600.0,
#[test] height: 400.0,
fn it_works() { decorations: false,
let result = add(2, 2); transparent: true,
assert_eq!(result, 4); resizable: false,
}
} }
} }

View File

@@ -3,4 +3,11 @@ name = "k-launcher-ui"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[lib]
name = "k_launcher_ui"
path = "src/lib.rs"
[dependencies] [dependencies]
iced = { workspace = true }
k-launcher-kernel = { path = "../k-launcher-kernel" }
tokio = { workspace = true }

View File

@@ -0,0 +1,179 @@
use std::sync::Arc;
use iced::{
Color, Element, Length, Size, Subscription, Task,
keyboard::{self, Event as KeyEvent, Key, key::Named},
widget::{column, container, image, row, scrollable, svg, text, text_input, Space},
window,
};
use k_launcher_kernel::{Kernel, SearchResult};
use crate::theme::AeroColors;
static INPUT_ID: std::sync::LazyLock<iced::widget::Id> =
std::sync::LazyLock::new(|| iced::widget::Id::new("search"));
pub struct KLauncherApp {
kernel: Arc<Kernel>,
query: String,
results: Arc<Vec<SearchResult>>,
selected: usize,
}
impl KLauncherApp {
fn new(kernel: Arc<Kernel>) -> Self {
Self {
kernel,
query: String::new(),
results: Arc::new(vec![]),
selected: 0,
}
}
}
#[derive(Debug, Clone)]
pub enum Message {
QueryChanged(String),
ResultsReady(Arc<Vec<SearchResult>>),
KeyPressed(KeyEvent),
}
fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
match message {
Message::QueryChanged(q) => {
state.query = q.clone();
state.selected = 0;
let kernel = state.kernel.clone();
Task::perform(
async move { kernel.search(&q).await },
|results| Message::ResultsReady(Arc::new(results)),
)
}
Message::ResultsReady(results) => {
state.results = results;
Task::none()
}
Message::KeyPressed(event) => {
let key = match event {
KeyEvent::KeyPressed { key, .. } => key,
_ => return Task::none(),
};
let Key::Named(named) = key else {
return Task::none();
};
let len = state.results.len();
match named {
Named::Escape => std::process::exit(0),
Named::ArrowDown => {
if len > 0 {
state.selected = (state.selected + 1).min(len - 1);
}
}
Named::ArrowUp => {
if state.selected > 0 {
state.selected -= 1;
}
}
Named::Enter => {
if let Some(result) = state.results.get(state.selected) {
(result.on_execute)();
}
std::process::exit(0);
}
_ => {}
}
Task::none()
}
}
}
fn view(state: &KLauncherApp) -> Element<'_, Message> {
let colors = AeroColors::standard();
let search_bar = text_input("Search...", &state.query)
.id(INPUT_ID.clone())
.on_input(Message::QueryChanged)
.padding(12)
.size(18);
let result_rows: Vec<Element<'_, Message>> = state
.results
.iter()
.enumerate()
.map(|(i, result)| {
let is_selected = i == state.selected;
let bg_color = if is_selected {
colors.border_cyan
} else {
Color::from_rgba8(255, 255, 255, 0.07)
};
let icon_el: Element<'_, Message> = match &result.icon {
Some(p) if p.ends_with(".svg") =>
svg(svg::Handle::from_path(p)).width(24).height(24).into(),
Some(p) =>
image(image::Handle::from_path(p)).width(24).height(24).into(),
None => Space::new().width(24).height(24).into(),
};
container(
row![icon_el, text(result.title.as_str()).size(15)]
.spacing(8)
.align_y(iced::Center),
)
.width(Length::Fill)
.padding([6, 12])
.style(move |_theme| container::Style {
background: Some(iced::Background::Color(bg_color)),
..Default::default()
})
.into()
})
.collect();
let results_list =
scrollable(column(result_rows).spacing(2).width(Length::Fill)).height(Length::Fill);
let content = column![search_bar, results_list]
.spacing(8)
.padding(12)
.width(Length::Fill)
.height(Length::Fill);
container(content)
.width(Length::Fill)
.height(Length::Fill)
.style(|_theme| container::Style {
background: Some(iced::Background::Color(Color::from_rgba8(
20, 20, 30, 0.9,
))),
..Default::default()
})
.into()
}
fn subscription(_state: &KLauncherApp) -> Subscription<Message> {
keyboard::listen().map(Message::KeyPressed)
}
pub fn run(kernel: Arc<Kernel>) -> iced::Result {
iced::application(
move || {
let app = KLauncherApp::new(kernel.clone());
let focus = iced::widget::operation::focus(INPUT_ID.clone());
(app, focus)
},
update,
view,
)
.title("K-Launcher")
.subscription(subscription)
.window(window::Settings {
size: Size::new(600.0, 400.0),
position: window::Position::Centered,
decorations: false,
transparent: true,
resizable: false,
..Default::default()
})
.run()
}

View File

@@ -0,0 +1,11 @@
mod app;
pub mod theme;
use std::sync::Arc;
use k_launcher_kernel::Kernel;
pub fn run(kernel: Arc<Kernel>) -> iced::Result {
app::run(kernel)
}

View File

@@ -1,3 +0,0 @@
fn main() {
println!("Hello, world!");
}

View File

@@ -0,0 +1,32 @@
use iced::{
Color, Gradient,
gradient::{ColorStop, Linear},
};
pub struct AeroColors {
pub glass_bg: Color,
pub gloss_highlight: Gradient,
pub border_cyan: Color,
}
impl AeroColors {
pub fn standard() -> Self {
Self {
// Semi-transparent "Aero Glass" base
glass_bg: Color::from_rgba8(255, 255, 255, 0.2),
// Cyan/Blue glow typical of the 2008 era
border_cyan: Color::from_rgb8(0, 183, 235),
// We'll use this for the "shine" effect on buttons
gloss_highlight: Gradient::Linear(Linear::new(0.0).add_stops([
ColorStop {
color: Color::from_rgba8(255, 255, 255, 0.5),
offset: 0.0,
},
ColorStop {
color: Color::from_rgba8(255, 255, 255, 0.0),
offset: 1.0,
},
])),
}
}
}

View File

@@ -0,0 +1,17 @@
[package]
name = "k-launcher"
version = "0.1.0"
edition = "2024"
default-run = "k-launcher"
[[bin]]
name = "k-launcher"
path = "src/main.rs"
[dependencies]
iced = { workspace = true }
k-launcher-kernel = { path = "../k-launcher-kernel" }
k-launcher-ui = { path = "../k-launcher-ui" }
plugin-apps = { path = "../plugins/plugin-apps" }
plugin-calc = { path = "../plugins/plugin-calc" }
tokio = { workspace = true }

View File

@@ -0,0 +1,13 @@
use std::sync::Arc;
use k_launcher_kernel::Kernel;
use plugin_apps::{AppsPlugin, FsDesktopEntrySource};
use plugin_calc::CalcPlugin;
fn main() -> iced::Result {
let kernel = Arc::new(Kernel::new(vec![
Arc::new(CalcPlugin::new()),
Arc::new(AppsPlugin::new(FsDesktopEntrySource::new())),
]));
k_launcher_ui::run(kernel)
}

View File

@@ -3,4 +3,17 @@ name = "plugin-apps"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[lib]
name = "plugin_apps"
path = "src/lib.rs"
[[bin]]
name = "plugin-apps"
path = "src/main.rs"
[dependencies] [dependencies]
async-trait = { workspace = true }
k-launcher-kernel = { path = "../../k-launcher-kernel" }
libc = "0.2"
tokio = { workspace = true }
xdg = "2"

View File

@@ -0,0 +1,291 @@
use std::{path::Path, process::{Command, Stdio}, sync::Arc};
use std::os::unix::process::CommandExt;
use async_trait::async_trait;
use k_launcher_kernel::{Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult};
// --- Domain newtypes ---
#[derive(Debug, Clone)]
pub struct AppName(String);
impl AppName {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct ExecCommand(String);
impl ExecCommand {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct IconPath(String);
impl IconPath {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
// --- Desktop entry ---
pub struct DesktopEntry {
pub name: AppName,
pub exec: ExecCommand,
pub icon: Option<IconPath>,
}
// --- Swappable source trait (Application layer principle) ---
pub trait DesktopEntrySource: Send + Sync {
fn entries(&self) -> Vec<DesktopEntry>;
}
// --- Plugin ---
pub struct AppsPlugin<S: DesktopEntrySource> {
source: S,
}
impl<S: DesktopEntrySource> AppsPlugin<S> {
pub fn new(source: S) -> Self {
Self { source }
}
}
fn resolve_icon_path(name: &str) -> Option<String> {
if name.starts_with('/') && Path::new(name).exists() {
return Some(name.to_string());
}
let candidates = [
format!("/usr/share/pixmaps/{name}.png"),
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())
}
fn score_match(name: &str, query: &str) -> Option<u32> {
let name_lc = name.to_lowercase();
let query_lc = query.to_lowercase();
if name_lc == query_lc {
Some(100)
} else if name_lc.starts_with(&query_lc) {
Some(80)
} else if name_lc.contains(&query_lc) {
Some(60)
} else {
None
}
}
#[async_trait]
impl<S: DesktopEntrySource> Plugin for AppsPlugin<S> {
fn name(&self) -> PluginName {
"apps"
}
async fn search(&self, query: &str) -> Vec<SearchResult> {
if query.is_empty() {
return vec![];
}
self.source
.entries()
.into_iter()
.filter_map(|entry| {
score_match(entry.name.as_str(), query).map(|score| {
let exec = entry.exec.clone();
let icon = entry.icon.as_ref().and_then(|p| resolve_icon_path(p.as_str()));
SearchResult {
id: ResultId::new(format!("app-{}", entry.name.as_str())),
title: ResultTitle::new(entry.name.as_str()),
description: None,
icon,
score: Score::new(score),
on_execute: Arc::new(move || {
let parts: Vec<&str> = exec.as_str().split_whitespace().collect();
if let Some((cmd, args)) = parts.split_first() {
let _ = unsafe {
Command::new(cmd)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.pre_exec(|| { libc::setsid(); Ok(()) })
.spawn()
};
}
}),
}
})
})
.collect()
}
}
// --- Filesystem source ---
pub struct FsDesktopEntrySource;
impl FsDesktopEntrySource {
pub fn new() -> Self {
Self
}
}
impl Default for FsDesktopEntrySource {
fn default() -> Self {
Self::new()
}
}
impl DesktopEntrySource for FsDesktopEntrySource {
fn entries(&self) -> Vec<DesktopEntry> {
let mut dirs = Vec::new();
if let Ok(xdg) = xdg::BaseDirectories::new() {
dirs.push(xdg.get_data_home().join("applications"));
for d in xdg.get_data_dirs() {
dirs.push(d.join("applications"));
}
}
let mut entries = Vec::new();
for dir in &dirs {
if let Ok(read_dir) = std::fs::read_dir(dir) {
for entry in read_dir.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
continue;
}
if let Some(de) = parse_desktop_file(&path) {
entries.push(de);
}
}
}
}
entries
}
}
fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
let content = std::fs::read_to_string(path).ok()?;
let mut in_section = false;
let mut name: Option<String> = None;
let mut exec: Option<String> = None;
let mut icon: Option<String> = None;
let mut entry_type: Option<String> = None;
let mut no_display = false;
for line in content.lines() {
let line = line.trim();
if line == "[Desktop Entry]" {
in_section = true;
continue;
}
if line.starts_with('[') {
in_section = false;
continue;
}
if !in_section || line.starts_with('#') || line.is_empty() {
continue;
}
if let Some((key, value)) = line.split_once('=') {
match key.trim() {
"Name" if name.is_none() => name = Some(value.trim().to_string()),
"Exec" if exec.is_none() => exec = Some(value.trim().to_string()),
"Icon" if icon.is_none() => icon = Some(value.trim().to_string()),
"Type" if entry_type.is_none() => entry_type = Some(value.trim().to_string()),
"NoDisplay" => no_display = value.trim().eq_ignore_ascii_case("true"),
_ => {}
}
}
}
if entry_type.as_deref() != Some("Application") || no_display {
return None;
}
let exec_clean: String = exec?
.split_whitespace()
.filter(|s| !s.starts_with('%'))
.collect::<Vec<_>>()
.join(" ");
Some(DesktopEntry {
name: AppName::new(name?),
exec: ExecCommand::new(exec_clean),
icon: icon.map(IconPath::new),
})
}
// --- Tests ---
#[cfg(test)]
mod tests {
use super::*;
struct MockSource {
entries: Vec<(String, String)>, // (name, exec)
}
impl MockSource {
fn with(entries: Vec<(&str, &str)>) -> Self {
Self {
entries: entries
.into_iter()
.map(|(n, e)| (n.to_string(), e.to_string()))
.collect(),
}
}
}
impl DesktopEntrySource for MockSource {
fn entries(&self) -> Vec<DesktopEntry> {
self.entries
.iter()
.map(|(name, exec)| DesktopEntry {
name: AppName::new(name.clone()),
exec: ExecCommand::new(exec.clone()),
icon: None,
})
.collect()
}
}
#[tokio::test]
async fn apps_prefix_match() {
let source = MockSource::with(vec![("Firefox", "firefox")]);
let p = AppsPlugin::new(source);
let results = p.search("fire").await;
assert_eq!(results[0].title.as_str(), "Firefox");
}
#[tokio::test]
async fn apps_no_match_returns_empty() {
let source = MockSource::with(vec![("Firefox", "firefox")]);
let p = AppsPlugin::new(source);
assert!(p.search("zz").await.is_empty());
}
#[tokio::test]
async fn apps_empty_query_returns_empty() {
let source = MockSource::with(vec![("Firefox", "firefox")]);
let p = AppsPlugin::new(source);
assert!(p.search("").await.is_empty());
}
}

View File

@@ -1,3 +1 @@
fn main() { fn main() {}
println!("Hello, world!");
}

View File

@@ -3,4 +3,16 @@ name = "plugin-calc"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[lib]
name = "plugin_calc"
path = "src/lib.rs"
[[bin]]
name = "plugin-calc"
path = "src/main.rs"
[dependencies] [dependencies]
async-trait = { workspace = true }
evalexpr = "11"
k-launcher-kernel = { path = "../../k-launcher-kernel" }
tokio = { workspace = true }

View File

@@ -0,0 +1,83 @@
use std::sync::Arc;
use async_trait::async_trait;
use k_launcher_kernel::{Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult};
pub struct CalcPlugin;
impl CalcPlugin {
pub fn new() -> Self {
Self
}
}
impl Default for CalcPlugin {
fn default() -> Self {
Self::new()
}
}
fn should_eval(query: &str) -> bool {
query
.chars()
.next()
.map(|c| c.is_ascii_digit() || c == '(' || c == '-')
.unwrap_or(false)
|| query.starts_with('=')
}
#[async_trait]
impl Plugin for CalcPlugin {
fn name(&self) -> PluginName {
"calc"
}
async fn search(&self, query: &str) -> Vec<SearchResult> {
if !should_eval(query) {
return vec![];
}
let expr = query.strip_prefix('=').unwrap_or(query);
match evalexpr::eval_number(expr) {
Ok(n) if n.is_finite() => {
let display = if n.fract() == 0.0 {
format!("= {}", n as i64)
} else {
format!("= {n}")
};
vec![SearchResult {
id: ResultId::new("calc-result"),
title: ResultTitle::new(display),
description: None,
icon: None,
score: Score::new(90),
on_execute: Arc::new(|| {}),
}]
}
_ => vec![],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn calc_valid_expr() {
let p = CalcPlugin::new();
let results = p.search("2+2").await;
assert_eq!(results[0].title.as_str(), "= 4");
}
#[tokio::test]
async fn calc_non_numeric_returns_empty() {
let p = CalcPlugin::new();
assert!(p.search("firefox").await.is_empty());
}
#[tokio::test]
async fn calc_bad_expr_returns_empty() {
let p = CalcPlugin::new();
assert!(p.search("1/0").await.is_empty());
}
}

View File

@@ -1,3 +1 @@
fn main() { fn main() {}
println!("Hello, world!");
}