diff --git a/Cargo.lock b/Cargo.lock index 61ae164..591a1f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,24 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "application-launcher" version = "0.1.0" dependencies = [ "gtk4", "gtk4-layer-shell", + "nucleo-matcher", + "regex", + "shlex", ] [[package]] @@ -486,6 +498,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + [[package]] name = "pango" version = "0.20.12" @@ -555,6 +577,35 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + [[package]] name = "rustc_version" version = "0.4.1" @@ -599,6 +650,12 @@ dependencies = [ "serde", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "slab" version = "0.4.10" @@ -681,6 +738,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "version-compare" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 919d988..9c0beee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,3 +6,6 @@ edition = "2024" [dependencies] gtk = { package = "gtk4", version = "0.9.6" } gtk4-layer-shell = "0.5.0" +nucleo-matcher = "0.3.1" +regex = "1.11.1" +shlex = "1.3.0" diff --git a/src/main.rs b/src/main.rs index 6984adf..20266db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,11 @@ +pub mod modules; +pub mod widget; + +use std::sync::{ + Arc, Mutex, + atomic::{AtomicU32, Ordering}, +}; + use gtk::{ CssProvider, EventControllerKey, Orientation, gdk::{Display, Key}, @@ -5,8 +13,9 @@ use gtk::{ prelude::*, }; use gtk4_layer_shell::{Edge, Layer, LayerShell}; +use nucleo_matcher::{Config, Matcher}; -const ITEMS: [&'static str; 3] = ["Test", "Example", "Placeholder"]; +use crate::modules::{Module, SearchResult, desktop::DesktopModule}; fn main() -> glib::ExitCode { let app = gtk::Application::builder() @@ -31,57 +40,161 @@ fn build_ui(application: >k::Application) { window.set_anchor(Edge::Left, false); let container = gtk::Box::builder() + .css_classes(vec!["container"]) .orientation(Orientation::Vertical) - .spacing(6) + .height_request(600) .build(); window.set_child(Some(&container)); let input = gtk::Entry::builder() .css_classes(vec!["main-input"]) .placeholder_text("Start typing...") - .width_request(800) .build(); container.append(&input); + let below_input_container = gtk::Box::builder() + .orientation(Orientation::Horizontal) + .spacing(0) + .hexpand(true) + .vexpand(true) + .build(); + container.append(&below_input_container); + let result_container = gtk::Box::builder() + .css_classes(vec!["result-container"]) + .orientation(Orientation::Vertical) + .spacing(0) + .width_request(400) + .overflow(gtk::Overflow::Hidden) + .build(); + below_input_container.append(&result_container); + + let detail_container = gtk::Box::builder() + .css_classes(vec!["detail-container"]) .orientation(Orientation::Vertical) .spacing(2) - .height_request(600) + .width_request(800) .build(); - container.append(&result_container); + below_input_container.append(&detail_container); + + let current_index = Arc::new(AtomicU32::new(0)); + let modules: Arc>> = Arc::new(vec![Box::new(DesktopModule::new())]); + let matcher = Arc::new(Mutex::new(Matcher::new(Config::DEFAULT.match_paths()))); + let last_results = Arc::new(Mutex::new({ + let mut matcher = matcher.lock().unwrap(); + update_results("", &modules, &mut *matcher) + })); input.connect_changed(glib::clone! { #[weak] result_container, + #[weak] + current_index, + #[weak] + last_results, move |input| { - remove_all_children(&result_container); let search: &str = &input.text().to_lowercase(); - for item in ITEMS { - if item.to_lowercase().starts_with(&search) { - let label = gtk::Label::new(Some(item)); - result_container.append(&label); - } + let mut matcher = matcher.lock().unwrap(); + current_index.store(0, Ordering::Relaxed); + let mut last_results = last_results.lock().unwrap(); + *last_results = update_results(search, &modules, &mut *matcher); + render_result_list(&result_container, last_results.iter().take(10), 0); + }}); + + input.connect_activate(glib::clone! { + #[weak] + current_index, + #[weak] + last_results, + move|_| { + let selected_index = current_index.load(Ordering::Relaxed); + let last_results = last_results.lock().unwrap(); + match last_results.get(selected_index as usize) { + Some(res) => res.execute(), + None => {} } - println!("{}", input.text()); + std::process::exit(0); }}); let event_controller = EventControllerKey::new(); - event_controller.connect_key_pressed(|_, key, _, _| { - match key { - Key::Escape => { - std::process::exit(0); + { + let last_results = last_results.clone(); + event_controller.connect_key_pressed(move |_, key, _, _| { + match key { + Key::Escape => { + std::process::exit(0); + } + Key::Down | Key::Tab => { + let last_results = last_results.lock().unwrap(); + let _ = current_index.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |i| { + if last_results.len() == 0 { + return Some(0); + } + if i as usize == (last_results.len() - 1).min(9) { + Some(i) + } else { + Some(i + 1) + } + }); + let selected_index = current_index.load(Ordering::Relaxed); + render_result_list( + &result_container, + last_results.iter().take(10), + selected_index as usize, + ); + return glib::Propagation::Stop; + } + Key::Up | Key::ISO_Left_Tab => { + let _ = current_index.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |i| { + if i == 0 { Some(0) } else { Some(i - 1) } + }); + let selected_index = current_index.load(Ordering::Relaxed); + let last_results = last_results.lock().unwrap(); + render_result_list( + &result_container, + last_results.iter().take(10), + selected_index as usize, + ); + return glib::Propagation::Stop; + } + _ => (), } - _ => (), - } - glib::Propagation::Proceed - }); + glib::Propagation::Proceed + }); + } window.add_controller(event_controller); window.show(); } +fn update_results( + search: &str, + modules: &[Box], + matcher: &mut Matcher, +) -> Vec> { + let mut results = vec![]; + { + for module in modules.iter() { + results.extend(module.results_for_search(search, matcher)); + } + } + results.sort_by(|a, b| b.confidence().cmp(&a.confidence())); + results +} + +fn render_result_list<'a>( + container: >k::Box, + results: impl Iterator>, + current_index: usize, +) { + remove_all_children(&container); + for (i, result) in results.enumerate() { + widget::search_result::create(container, result, i == current_index); + } +} + fn remove_all_children(b: >k::Box) { let mut child = b.first_child(); while let Some(c) = child { @@ -99,6 +212,9 @@ fn load_css() { gtk::style_context_add_provider_for_display( &Display::default().expect("Could not connect to a display."), &provider, - gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + // We use a really high priority to be able to overwrite user style sheets, as these are + // often used for custom themes. We use the theme colors where possible, but sometimes we + // need to color something in a way that the theme might not expect. + 1000, // gtk::STYLE_PROVIDER_PRIORITY_APPLICATION ); } diff --git a/src/modules/desktop.rs b/src/modules/desktop.rs new file mode 100644 index 0000000..e6049de --- /dev/null +++ b/src/modules/desktop.rs @@ -0,0 +1,178 @@ +use std::{ + path::{Path, PathBuf}, + str::FromStr, +}; + +use gtk::{ + gio::{ + AppInfo, DesktopAppInfo, Icon, + prelude::{AppInfoExt, DesktopAppInfoExtManual}, + }, + prelude::BoxExt, +}; +use nucleo_matcher::{ + Matcher, Utf32Str, + pattern::{AtomKind, CaseMatching, Normalization, Pattern}, +}; +use regex::Regex; + +use crate::modules::{Module, SearchResult}; + +const SEARCH_THRESHOLD: u32 = 0; + +pub struct DesktopResult { + pub confidence: u32, + pub app_info: DesktopAppInfo, +} + +pub struct DesktopModule { + pub desktop_entries: Vec, +} + +impl DesktopModule { + pub fn new() -> Self { + let desktop_entries = AppInfo::all() + .into_iter() + .filter_map(|entry| entry.id()) + .filter_map(|id| DesktopAppInfo::new(&id)) + .filter(|entry| { + !entry.executable().as_os_str().is_empty() + && !entry.display_name().is_empty() + && entry.should_show() + }) + .collect(); + + Self { desktop_entries } + } +} + +impl Module for DesktopModule { + fn results_for_search( + &self, + search: &str, + matcher: &mut Matcher, + ) -> Vec> { + let mut results: Vec> = vec![]; + let pattern = Pattern::new( + search, + CaseMatching::Smart, + Normalization::Smart, + AtomKind::Fuzzy, + ); + for app in self.desktop_entries.iter() { + let name: String = app.name().into(); + let mut name_buf = Vec::new(); + let name = Utf32Str::new(&name, &mut name_buf); + let score = pattern.score(name, matcher).unwrap_or(0); + if score > SEARCH_THRESHOLD { + results.push(Box::new(DesktopResult { + confidence: score, + app_info: app.clone(), + })); + } + } + + results + } +} + +impl SearchResult for DesktopResult { + fn confidence(&self) -> u32 { + self.confidence + } + fn label(&self) -> String { + self.app_info.name().into() + } + fn description(&self) -> Option { + self.app_info.description().map(Into::into) + } + + fn icon(&self) -> Option { + self.app_info.icon() + } + + fn render_detail(&self, container: >k::Box) { + let text = format!("[{}] {}", self.confidence, self.app_info.name(),); + let label = gtk::Label::builder() + .label(text) + .halign(gtk::Align::Start) + .build(); + container.append(&label); + } + + fn execute(&self) { + let app_id = self.app_info.id(); + let mut app_exec = match self + .app_info + .commandline() + .map(|p| p.to_str().map(ToOwned::to_owned)) + .flatten() + { + Some(app_exec) => app_exec, + None => return, + }; + + let mut app_dir = None; + if let Some(mut desktop_entry_path) = self.app_info.filename() { + if let Some(desktop_entry_path) = desktop_entry_path.to_str() { + app_exec = app_exec.replace("%k", desktop_entry_path); + } + desktop_entry_path.pop(); + app_dir = Some(desktop_entry_path); + } + + app_exec = Regex::new(r"\%[uUfFdDnNickvm]") + .unwrap() + .replace(&app_exec, "") + .to_string(); + + let working_dir = match self + .app_info + .string("Path") + .map(|path| PathBuf::from_str(&path).ok()) + .flatten() + { + Some(path) => Some(path), + None => app_dir, + }; + + if self.app_info.boolean("DBusActivatable") { + if let Some(app_id) = app_id { + launch_cmd(vec!["gapplication", "launch", &app_id], working_dir); + } else { + return; + } + } else { + if self.app_info.boolean("Terminal") { + // TODO: Make terminal emulator configurable + launch_cmd(vec!["kitty", &app_exec], working_dir); + } else { + if let Some(cmd) = shlex::split(&app_exec) { + launch_cmd(cmd, working_dir); + } + } + }; + } +} + +fn launch_cmd(cmd: Vec>, working_dir: Option) { + let cmd_prefix = vec!["systemd-run", "--user", "--scope"]; + let mut cmd_prefix = cmd_prefix.iter().map(|s| Path::new(s)).collect::>(); + let cmd_suffix = cmd + .iter() + .map(|s| Path::new(s.as_ref())) + .collect::>(); + cmd_prefix.extend(cmd_suffix); + let cmd = cmd_prefix; + let env = gtk::glib::environ(); + let env = env.iter().map(|f| Path::new(f)).collect::>(); + + gtk::glib::spawn_async( + working_dir, + &cmd, + &env, + gtk::glib::SpawnFlags::SEARCH_PATH_FROM_ENVP | gtk::glib::SpawnFlags::SEARCH_PATH, + None, + ) + .unwrap(); +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs new file mode 100644 index 0000000..7d24d6a --- /dev/null +++ b/src/modules/mod.rs @@ -0,0 +1,19 @@ +use gtk::gio::Icon; +use nucleo_matcher::Matcher; + +pub mod desktop; + +pub trait Module { + fn results_for_search(&self, search: &str, matcher: &mut Matcher) + -> Vec>; +} + +pub trait SearchResult { + fn confidence(&self) -> u32; + fn label(&self) -> String; + fn description(&self) -> Option; + fn icon(&self) -> Option; + + fn render_detail(&self, container: >k::Box); + fn execute(&self); +} diff --git a/src/style.css b/src/style.css index 866caab..7a40114 100644 --- a/src/style.css +++ b/src/style.css @@ -1,7 +1,44 @@ .main-input { + background: @theme_bg_color; font-size: 32px; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border: 2px solid @borders; + outline: none; } .main-input text { margin: 8px; } + +.result { + padding: 8px 16px; +} + +.result.selected { + color: @theme_selected_fg_color; + background: @theme_selected_bg_color; +} + +.result-container { + border: 2px solid @borders; + /* We already get a border from the search input, so we don't need to repeat + * it here. */ + border-top: none; + background: @theme_bg_color; + border-bottom-left-radius: 6px; +} + +.result-name { + font-size: 16px; +} + +.detail-container { + background: gray; + border-bottom-right-radius: 6px; +} + +window { + background-color: transparent; +} + diff --git a/src/widget/mod.rs b/src/widget/mod.rs new file mode 100644 index 0000000..bcf06f0 --- /dev/null +++ b/src/widget/mod.rs @@ -0,0 +1 @@ +pub mod search_result; diff --git a/src/widget/search_result.rs b/src/widget/search_result.rs new file mode 100644 index 0000000..fb80446 --- /dev/null +++ b/src/widget/search_result.rs @@ -0,0 +1,36 @@ +use gtk::{Orientation, pango::EllipsizeMode, prelude::BoxExt}; + +use crate::modules::SearchResult; + +pub fn create(container: >k::Box, result: &Box, is_selected: bool) { + let res_box = gtk::Box::builder() + .orientation(Orientation::Horizontal) + .css_classes(if is_selected { + vec!["result", "selected"] + } else { + vec!["result"] + }) + .spacing(8) + .build(); + + // If there is an icon display it, otherwise display an empty box. + match result.icon() { + Some(icon) => res_box.append(>k::Image::builder().gicon(&icon).pixel_size(16).build()), + None => res_box.append(>k::Box::new(Orientation::Horizontal, 0)), + } + + let detail_box = gtk::Box::new(Orientation::Vertical, 2); + detail_box.append( + >k::Label::builder() + .label(result.label()) + .css_classes(vec!["result-name"]) + .halign(gtk::Align::Start) + .max_width_chars(40) + .ellipsize(EllipsizeMode::End) + .hexpand(true) + .build(), + ); + res_box.append(&detail_box); + + container.append(&res_box); +}