From 93736ae19dc1ced85fb36af9573153ed31b2d80f Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 15 Mar 2026 17:15:47 +0100 Subject: [PATCH] feat: add FilesPlugin for file searching and integrate into KLauncher --- Cargo.lock | 7 ++ crates/k-launcher-kernel/src/lib.rs | 1 + crates/k-launcher-ui/Cargo.toml | 1 + crates/k-launcher-ui/src/app.rs | 41 +++++++-- crates/k-launcher/Cargo.toml | 1 + crates/k-launcher/src/main.rs | 2 + crates/plugins/plugin-calc/src/lib.rs | 26 ++++-- crates/plugins/plugin-files/Cargo.toml | 3 + crates/plugins/plugin-files/src/lib.rs | 115 +++++++++++++++++++++++++ 9 files changed, 184 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a370410..94b81e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1768,6 +1768,7 @@ dependencies = [ "plugin-apps", "plugin-calc", "plugin-cmd", + "plugin-files", "tokio", ] @@ -1791,6 +1792,7 @@ version = "0.1.0" dependencies = [ "iced", "k-launcher-kernel", + "k-launcher-os-bridge", "tokio", ] @@ -2766,6 +2768,11 @@ dependencies = [ [[package]] name = "plugin-files" version = "0.1.0" +dependencies = [ + "async-trait", + "k-launcher-kernel", + "tokio", +] [[package]] name = "png" diff --git a/crates/k-launcher-kernel/src/lib.rs b/crates/k-launcher-kernel/src/lib.rs index f1a0998..5cbb568 100644 --- a/crates/k-launcher-kernel/src/lib.rs +++ b/crates/k-launcher-kernel/src/lib.rs @@ -89,6 +89,7 @@ impl Kernel { let nested: Vec> = join_all(futures).await; let mut flat: Vec = nested.into_iter().flatten().collect(); flat.sort_by(|a, b| b.score.cmp(&a.score)); + flat.truncate(8); flat } } diff --git a/crates/k-launcher-ui/Cargo.toml b/crates/k-launcher-ui/Cargo.toml index a179f18..4486257 100644 --- a/crates/k-launcher-ui/Cargo.toml +++ b/crates/k-launcher-ui/Cargo.toml @@ -10,4 +10,5 @@ path = "src/lib.rs" [dependencies] iced = { workspace = true } k-launcher-kernel = { path = "../k-launcher-kernel" } +k-launcher-os-bridge = { path = "../k-launcher-os-bridge" } tokio = { workspace = true } diff --git a/crates/k-launcher-ui/src/app.rs b/crates/k-launcher-ui/src/app.rs index c494f6c..3076a6d 100644 --- a/crates/k-launcher-ui/src/app.rs +++ b/crates/k-launcher-ui/src/app.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use iced::{ - Color, Element, Length, Size, Subscription, Task, + Border, Color, Element, Length, Size, Subscription, Task, event, keyboard::{Event as KeyEvent, Key, key::Named}, widget::{column, container, image, row, scrollable, svg, text, text_input, Space}, @@ -9,6 +9,7 @@ use iced::{ }; use k_launcher_kernel::{Kernel, SearchResult}; +use k_launcher_os_bridge::WindowConfig; use crate::theme; @@ -119,7 +120,7 @@ fn view(state: &KLauncherApp) -> Element<'_, Message> { let title_col: Element<'_, Message> = if let Some(desc) = &result.description { column![ text(result.title.as_str()).size(15), - text(desc).size(11).color(Color::from_rgba8(180, 180, 200, 0.8)), + text(desc).size(12).color(Color::from_rgba8(210, 215, 230, 1.0)), ] .into() } else { @@ -134,14 +135,32 @@ fn view(state: &KLauncherApp) -> Element<'_, Message> { .padding([6, 12]) .style(move |_theme| container::Style { background: Some(iced::Background::Color(bg_color)), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 4.0.into(), + }, ..Default::default() }) .into() }) .collect(); - let results_list = - scrollable(column(result_rows).spacing(2).width(Length::Fill)).height(Length::Fill); + let results_list = if state.results.is_empty() && !state.query.is_empty() { + scrollable( + container( + text("No results") + .size(15) + .color(Color::from_rgba8(180, 180, 200, 0.5)), + ) + .width(Length::Fill) + .align_x(iced::Center) + .padding([20, 0]), + ) + .height(Length::Fill) + } else { + scrollable(column(result_rows).spacing(2).width(Length::Fill)).height(Length::Fill) + }; let content = column![search_bar, results_list] .spacing(8) @@ -156,6 +175,11 @@ fn view(state: &KLauncherApp) -> Element<'_, Message> { background: Some(iced::Background::Color(Color::from_rgba8( 20, 20, 30, 0.9, ))), + border: Border { + color: theme::AERO.border_cyan, + width: 1.0, + radius: 8.0.into(), + }, ..Default::default() }) .into() @@ -169,6 +193,7 @@ fn subscription(_state: &KLauncherApp) -> Subscription { } pub fn run(kernel: Arc) -> iced::Result { + let wc = WindowConfig::launcher(); iced::application( move || { let app = KLauncherApp::new(kernel.clone()); @@ -181,11 +206,11 @@ pub fn run(kernel: Arc) -> iced::Result { .title("K-Launcher") .subscription(subscription) .window(window::Settings { - size: Size::new(600.0, 400.0), + size: Size::new(wc.width, wc.height), position: window::Position::Centered, - decorations: false, - transparent: true, - resizable: false, + decorations: wc.decorations, + transparent: wc.transparent, + resizable: wc.resizable, ..Default::default() }) .run() diff --git a/crates/k-launcher/Cargo.toml b/crates/k-launcher/Cargo.toml index 10e0cc9..4c9c163 100644 --- a/crates/k-launcher/Cargo.toml +++ b/crates/k-launcher/Cargo.toml @@ -15,4 +15,5 @@ k-launcher-ui = { path = "../k-launcher-ui" } plugin-apps = { path = "../plugins/plugin-apps" } plugin-calc = { path = "../plugins/plugin-calc" } plugin-cmd = { path = "../plugins/plugin-cmd" } +plugin-files = { path = "../plugins/plugin-files" } tokio = { workspace = true } diff --git a/crates/k-launcher/src/main.rs b/crates/k-launcher/src/main.rs index 77b76d2..b0d3663 100644 --- a/crates/k-launcher/src/main.rs +++ b/crates/k-launcher/src/main.rs @@ -4,12 +4,14 @@ use k_launcher_kernel::Kernel; use plugin_apps::{AppsPlugin, FsDesktopEntrySource, frecency::FrecencyStore}; use plugin_calc::CalcPlugin; use plugin_cmd::CmdPlugin; +use plugin_files::FilesPlugin; fn main() -> iced::Result { let frecency = FrecencyStore::load(); let kernel = Arc::new(Kernel::new(vec![ Arc::new(CmdPlugin::new()), Arc::new(CalcPlugin::new()), + Arc::new(FilesPlugin::new()), Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)), ])); k_launcher_ui::run(kernel) diff --git a/crates/plugins/plugin-calc/src/lib.rs b/crates/plugins/plugin-calc/src/lib.rs index 2070c09..efd8b87 100644 --- a/crates/plugins/plugin-calc/src/lib.rs +++ b/crates/plugins/plugin-calc/src/lib.rs @@ -39,18 +39,34 @@ impl Plugin for CalcPlugin { 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) + let value_str = if n.fract() == 0.0 { + format!("{}", n as i64) } else { - format!("= {n}") + format!("{n}") }; + let display = format!("= {value_str}"); + let expr_owned = expr.to_string(); + let clipboard_val = value_str; vec![SearchResult { id: ResultId::new("calc-result"), title: ResultTitle::new(display), - description: None, + description: Some(format!("{expr_owned} ยท Enter to copy")), icon: None, score: Score::new(90), - on_execute: Arc::new(|| {}), + on_execute: Arc::new(move || { + if std::process::Command::new("wl-copy").arg(&clipboard_val).spawn().is_err() { + use std::io::Write; + if let Ok(mut child) = std::process::Command::new("xclip") + .args(["-selection", "clipboard"]) + .stdin(std::process::Stdio::piped()) + .spawn() + { + if let Some(stdin) = child.stdin.as_mut() { + let _ = stdin.write_all(clipboard_val.as_bytes()); + } + } + } + }), }] } _ => vec![], diff --git a/crates/plugins/plugin-files/Cargo.toml b/crates/plugins/plugin-files/Cargo.toml index cbd9a44..b410cbb 100644 --- a/crates/plugins/plugin-files/Cargo.toml +++ b/crates/plugins/plugin-files/Cargo.toml @@ -8,3 +8,6 @@ name = "plugin_files" path = "src/lib.rs" [dependencies] +async-trait = { workspace = true } +k-launcher-kernel = { path = "../../k-launcher-kernel" } +tokio = { workspace = true } diff --git a/crates/plugins/plugin-files/src/lib.rs b/crates/plugins/plugin-files/src/lib.rs index e69de29..eca77f6 100644 --- a/crates/plugins/plugin-files/src/lib.rs +++ b/crates/plugins/plugin-files/src/lib.rs @@ -0,0 +1,115 @@ +use std::path::Path; +use std::sync::Arc; + +use async_trait::async_trait; +use k_launcher_kernel::{Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult}; + +pub struct FilesPlugin; + +impl FilesPlugin { + pub fn new() -> Self { + Self + } +} + +impl Default for FilesPlugin { + fn default() -> Self { + Self::new() + } +} + +fn expand_query(query: &str) -> Option { + if query.starts_with("~/") { + let home = std::env::var("HOME").ok()?; + Some(format!("{}{}", home, &query[1..])) + } else if query.starts_with('/') { + Some(query.to_string()) + } else { + None + } +} + +#[async_trait] +impl Plugin for FilesPlugin { + fn name(&self) -> PluginName { + "files" + } + + async fn search(&self, query: &str) -> Vec { + let expanded = match expand_query(query) { + Some(p) => p, + None => return vec![], + }; + let path = Path::new(&expanded); + let (parent, prefix) = if path.is_dir() { + (path.to_path_buf(), String::new()) + } else { + let parent = path.parent().unwrap_or(Path::new("/")).to_path_buf(); + let prefix = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_lowercase(); + (parent, prefix) + }; + + let entries = match std::fs::read_dir(&parent) { + Ok(e) => e, + Err(_) => return vec![], + }; + + entries + .filter_map(|e| e.ok()) + .filter(|e| { + if prefix.is_empty() { + return true; + } + e.file_name() + .to_str() + .map(|n| n.to_lowercase().starts_with(&prefix)) + .unwrap_or(false) + }) + .take(20) + .enumerate() + .map(|(i, entry)| { + let full_path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + let is_dir = full_path.is_dir(); + let title = if is_dir { + format!("{name}/") + } else { + name + }; + let path_str = full_path.to_string_lossy().to_string(); + SearchResult { + id: ResultId::new(format!("file-{i}")), + title: ResultTitle::new(title), + description: Some(path_str.clone()), + icon: None, + score: Score::new(50), + on_execute: Arc::new(move || { + let _ = std::process::Command::new("xdg-open").arg(&path_str).spawn(); + }), + } + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn files_ignores_non_path_query() { + let p = FilesPlugin::new(); + assert!(p.search("firefox").await.is_empty()); + } + + #[tokio::test] + async fn files_handles_root() { + let p = FilesPlugin::new(); + let results = p.search("/").await; + assert!(!results.is_empty()); + } +}