Initial messy version

This commit is contained in:
kalle 2025-06-22 22:47:48 +02:00
parent 2715f58991
commit 9080e512c3
8 changed files with 474 additions and 21 deletions

63
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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: &gtk::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<Vec<Box<dyn Module>>> = 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<dyn Module>],
matcher: &mut Matcher,
) -> Vec<Box<dyn SearchResult>> {
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: &gtk::Box,
results: impl Iterator<Item = &'a Box<dyn SearchResult>>,
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: &gtk::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
);
}

178
src/modules/desktop.rs Normal file
View file

@ -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<DesktopAppInfo>,
}
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<Box<dyn SearchResult>> {
let mut results: Vec<Box<dyn SearchResult>> = 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<String> {
self.app_info.description().map(Into::into)
}
fn icon(&self) -> Option<Icon> {
self.app_info.icon()
}
fn render_detail(&self, container: &gtk::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<impl AsRef<str>>, working_dir: Option<PathBuf>) {
let cmd_prefix = vec!["systemd-run", "--user", "--scope"];
let mut cmd_prefix = cmd_prefix.iter().map(|s| Path::new(s)).collect::<Vec<_>>();
let cmd_suffix = cmd
.iter()
.map(|s| Path::new(s.as_ref()))
.collect::<Vec<_>>();
cmd_prefix.extend(cmd_suffix);
let cmd = cmd_prefix;
let env = gtk::glib::environ();
let env = env.iter().map(|f| Path::new(f)).collect::<Vec<_>>();
gtk::glib::spawn_async(
working_dir,
&cmd,
&env,
gtk::glib::SpawnFlags::SEARCH_PATH_FROM_ENVP | gtk::glib::SpawnFlags::SEARCH_PATH,
None,
)
.unwrap();
}

19
src/modules/mod.rs Normal file
View file

@ -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<Box<dyn SearchResult>>;
}
pub trait SearchResult {
fn confidence(&self) -> u32;
fn label(&self) -> String;
fn description(&self) -> Option<String>;
fn icon(&self) -> Option<Icon>;
fn render_detail(&self, container: &gtk::Box);
fn execute(&self);
}

View file

@ -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;
}

1
src/widget/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod search_result;

View file

@ -0,0 +1,36 @@
use gtk::{Orientation, pango::EllipsizeMode, prelude::BoxExt};
use crate::modules::SearchResult;
pub fn create(container: &gtk::Box, result: &Box<dyn SearchResult>, 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(&gtk::Image::builder().gicon(&icon).pixel_size(16).build()),
None => res_box.append(&gtk::Box::new(Orientation::Horizontal, 0)),
}
let detail_box = gtk::Box::new(Orientation::Vertical, 2);
detail_box.append(
&gtk::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);
}