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:
5168
Cargo.lock
generated
5168
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
18
Cargo.toml
18
Cargo.toml
@@ -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
56
README.md
Normal 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 Iced’s 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.
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
179
crates/k-launcher-ui/src/app.rs
Normal file
179
crates/k-launcher-ui/src/app.rs
Normal 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()
|
||||||
|
}
|
||||||
11
crates/k-launcher-ui/src/lib.rs
Normal file
11
crates/k-launcher-ui/src/lib.rs
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
fn main() {
|
|
||||||
println!("Hello, world!");
|
|
||||||
}
|
|
||||||
32
crates/k-launcher-ui/src/theme.rs
Normal file
32
crates/k-launcher-ui/src/theme.rs
Normal 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,
|
||||||
|
},
|
||||||
|
])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
crates/k-launcher/Cargo.toml
Normal file
17
crates/k-launcher/Cargo.toml
Normal 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 }
|
||||||
13
crates/k-launcher/src/main.rs
Normal file
13
crates/k-launcher/src/main.rs
Normal 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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
291
crates/plugins/plugin-apps/src/lib.rs
Normal file
291
crates/plugins/plugin-apps/src/lib.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1 @@
|
|||||||
fn main() {
|
fn main() {}
|
||||||
println!("Hello, world!");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
83
crates/plugins/plugin-calc/src/lib.rs
Normal file
83
crates/plugins/plugin-calc/src/lib.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1 @@
|
|||||||
fn main() {
|
fn main() {}
|
||||||
println!("Hello, world!");
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user