improve theme and add examples, support clicks

This commit is contained in:
Alexander Mohr 2025-04-13 11:38:48 +02:00
parent 962f6a8688
commit 141e9ac124
13 changed files with 991 additions and 430 deletions

1
Cargo.lock generated
View file

@ -2105,6 +2105,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"smithay-client-toolkit", "smithay-client-toolkit",
"strsim 0.11.1",
"sysinfo", "sysinfo",
"thiserror 2.0.12", "thiserror 2.0.12",
"toml", "toml",

View file

@ -28,3 +28,4 @@ calloop = "0.14.2"
crossbeam = "0.8.4" crossbeam = "0.8.4"
libc = "0.2.171" libc = "0.2.171"
freedesktop-file-parser = "0.1.0" freedesktop-file-parser = "0.1.0"
strsim = "0.11.1"

View file

@ -1,10 +1,8 @@
# Worf # Worf
Worf is a clone of [wofi](https://github.com/SimplyCEO/wofi) written in rust. Worf is yet another dmenu style launcher, heavily inspired by wofi but written in Rust on top of GTK4.
Although no code was taken over, the original project is great and to honor their license this tool is licensed under the same GPLV3 terms. It supports a lot of things the same way wofi does, so migrating to worf is easy, but things I did not
deemed necessary where dropped from worf. See breaking changes section for details.
* Wofis css files are supported
* Wofis command line flags are supported
## Setup ## Setup
@ -20,10 +18,19 @@ layerrule = blur, worf
* Window switcher for hyprland * Window switcher for hyprland
## Breaking changes to Wofi ## Breaking changes to Wofi
* Error messages differ * Runtime behaviour is not guaranteed to be the same and won't ever be, this includes error messages and themes.
* Themes in general are mostly compatible. Worf is using the same entity ids,
because worf is build on GTK4 instead of GTK3 there will be differences in the look and feel.
* Configuration files are not 100% compatible, Worf is using toml files instead, for most part this only means strings have to be quoted * Configuration files are not 100% compatible, Worf is using toml files instead, for most part this only means strings have to be quoted
* Themes are not 100% compatible * Color files are not supported
## Dropped configuration options
* stylesheet -> use style instead
* color / colors -> GTK4 does not support color files
## New options
* --fuzzy-length: Defines how long a string must be be
## Not supported ## Not supported
* Wofi has a C-API, that is not and won't be supported. As of now there are no plans to provide a Rust API either. * Wofi has a C-API, that is not and won't be supported.

View file

@ -1,3 +1,4 @@
use crate::lib::config::{Align, MatchMethod, Orientation};
use clap::Parser; use clap::Parser;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::str::FromStr; use std::str::FromStr;
@ -51,10 +52,6 @@ pub struct Args {
#[clap(short = 's', long = "style")] #[clap(short = 's', long = "style")]
style: Option<String>, style: Option<String>,
/// Selects a colors file to use
#[clap(short = 'C', long = "color")]
color: Option<String>,
/// Runs in dmenu mode /// Runs in dmenu mode
#[clap(short = 'd', long = "dmenu")] #[clap(short = 'd', long = "dmenu")]
dmenu: bool, dmenu: bool,
@ -117,7 +114,7 @@ pub struct Args {
/// Sets the matching method, default is contains /// Sets the matching method, default is contains
#[clap(short = 'M', long = "matching")] #[clap(short = 'M', long = "matching")]
matching: Option<String>, matching: Option<MatchMethod>,
/// Allows case insensitive searching /// Allows case insensitive searching
#[clap(short = 'i', long = "insensitive")] #[clap(short = 'i', long = "insensitive")]
@ -170,4 +167,35 @@ pub struct Args {
/// Runs command for the displayed entries, without changing the output. %s for the real string /// Runs command for the displayed entries, without changing the output. %s for the real string
#[clap(short = 'r', long = "pre-display-cmd")] #[clap(short = 'r', long = "pre-display-cmd")]
pre_display_cmd: Option<String>, pre_display_cmd: Option<String>,
/// Defines how good a fuzzy match must be, to be shown.
#[clap(long = "fuzzy-min-score")]
fuzzy_min_score: Option<f64>,
/// Size of displayed images
#[clap(long = "image-size")]
image_size: Option<i32>,
/// Orientation of main window
#[clap(long = "orientation")]
orientation: Option<Orientation>,
/// Orientation of the row box, defining if label is below or at the side.
#[clap(long = "row-box-orientation")]
row_bow_orientation: Option<Orientation>,
/// Specifies the horizontal align for the entire scrolled area,
/// it can be any of fill, start, end, or center, default is fill.
#[clap(long = "halign")]
pub halign: Option<Align>,
//// Specifies the horizontal align for the individual entries,
// it can be any of fill, start, end, or center, default is fill.
#[clap(long = "content-halign")]
pub content_halign: Option<Align>,
/// Specifies the vertical align for the entire scrolled area, it can be any of fill, start, e
/// nd, or center, the default is orientation dependent. If vertical then it defaults to
/// start, if horizontal it defaults to center.
#[clap(long = "valign")]
pub valign: Option<Align>,
} }

View file

@ -1,290 +0,0 @@
use crate::config::Config;
use anyhow::{Context, anyhow};
use crossbeam::channel;
use crossbeam::channel::Sender;
use gdk4::gio::File;
use gdk4::glib::Propagation;
use gdk4::prelude::{Cast, DisplayExt, MonitorExt};
use gdk4::{Display, Key};
use gtk4::prelude::{
ApplicationExt, ApplicationExtManual, BoxExt, ButtonExt, EditableExt, EntryExt, FileChooserExt,
FlowBoxChildExt, GtkWindowExt, ListBoxRowExt, NativeExt, WidgetExt,
};
use gtk4::{Align, EventControllerKey, Expander, FlowBox, FlowBoxChild, Image, Label, ListBox, ListBoxRow, PolicyType, ScrolledWindow, SearchEntry, Widget};
use gtk4::{Application, ApplicationWindow, CssProvider, Orientation};
use gtk4_layer_shell::{KeyboardMode, LayerShell};
use log::{debug, error, info};
use std::process::exit;
use hyprland::ctl::output::create;
use hyprland::ctl::plugin::list;
#[derive(Clone)]
pub struct MenuItem {
pub label: String, // todo support empty label?
pub icon_path: Option<String>,
pub action: Option<String>,
pub sub_elements: Vec<MenuItem>,
}
pub fn show(config: Config, elements: Vec<MenuItem>) -> anyhow::Result<(i32)> {
// Load CSS
let provider = CssProvider::new();
let css_file_path = File::for_path("/home/me/.config/wofi/style.css");
provider.load_from_file(&css_file_path);
// Apply CSS to the display
let display = Display::default().expect("Could not connect to a display");
gtk4::style_context_add_provider_for_display(
&display,
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
let display = Display::default().expect("Could not connect to a display");
// Apply CSS to the display
gtk4::style_context_add_provider_for_display(
&display,
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
// No need for application_id unless you want portal support
let app = Application::builder().application_id("worf").build();
let (sender, receiver) = channel::bounded(1);
app.connect_activate(move |app| {
// Create a toplevel undecorated window
let window = ApplicationWindow::builder()
.application(app)
.decorated(false)
.resizable(false)
.default_width(20)
.default_height(20)
.build();
window.set_widget_name("window");
config.normal_window.map(|normal| {
if !normal {
window.set_layer(gtk4_layer_shell::Layer::Overlay);
window.init_layer_shell();
window.set_keyboard_mode(KeyboardMode::Exclusive);
window.set_namespace(Some("worf"));
}
});
let outer_box = gtk4::Box::new(Orientation::Vertical, 0);
outer_box.set_widget_name("outer-box");
window.set_child(Some(&outer_box));
let entry = SearchEntry::new();
entry.set_widget_name("input");
entry.set_css_classes(&["input"]);
entry.set_placeholder_text(config.prompt.as_deref());
// Example `search` and `password_char` usage
// let password_char = Some('*');
// todo\
// if let Some(c) = password_char {
// let entry_casted: Entry = entry.clone().upcast();
// entry_casted.set_visibility(false);
// entry_casted.set_invisible_char(c);
// }
outer_box.append(&entry);
let scroll = ScrolledWindow::new();
scroll.set_widget_name("scroll");
scroll.set_hexpand(true);
scroll.set_vexpand(true);
let hide_scroll = false; // todo
if hide_scroll {
scroll.set_policy(PolicyType::External, PolicyType::External);
}
outer_box.append(&scroll);
let inner_box = FlowBox::new();
inner_box.set_widget_name("inner-box");
inner_box.set_css_classes(&["inner-box"]);
inner_box.set_selection_mode(gtk4::SelectionMode::Browse);
inner_box.set_max_children_per_line(1); // todo change to `columns` variable
//inner_box.set_orientation(Orientation::Horizontal); // or Vertical
inner_box.set_halign(Align::Fill);
inner_box.set_valign(Align::Start);
inner_box.set_activate_on_single_click(true);
for entry in &elements {
add_menu_item(&inner_box, &entry);
}
// Set focus after everything is realized
inner_box.connect_map(|fb| {
fb.grab_focus();
});
let wrapper_box = gtk4::Box::new(Orientation::Vertical, 0);
wrapper_box.set_homogeneous(true);
wrapper_box.append(&inner_box);
scroll.set_child(Some(&wrapper_box));
// todo implement search function
// // Dummy filter and sort funcs replace with actual logic
// inner_box.set_filter_func(Some(Box::new(|_child| {
// true // filter logic here
// })));
// inner_box.set_sort_func(Some(Box::new(|child1, child2| {
// child1.widget_name().cmp(&child2.widget_name())
// })));
// Create key event controller
let entry_clone = entry.clone();
setup_key_event_handler(&window, entry_clone, inner_box, app.clone(), sender.clone());
window.show();
// Get the display where the window resides
let display = window.display();
// Get the monitor that the window is on (use window's coordinates to find this)
window.surface().map(|surface| {
let monitor = display.monitor_at_surface(&surface);
if let Some(monitor) = monitor {
let geometry = monitor.geometry();
config.width.as_ref().map(|width| {
percent_or_absolute(&width, geometry.width())
.map(|w| window.set_width_request(w))
});
config.height.as_ref().map(|height| {
percent_or_absolute(&height, geometry.height())
.map(|h| window.set_height_request(h))
});
} else {
error!("failed to get monitor to init window size");
}
});
});
let empty_array: [&str; 0] = [];
app.run_with_args(&empty_array);
let selected_index = receiver.recv()?;
Ok(selected_index)
}
fn setup_key_event_handler(
window: &ApplicationWindow,
entry_clone: SearchEntry,
inner_box: FlowBox,
app: Application,
sender: Sender<i32>,
) {
let key_controller = EventControllerKey::new();
key_controller.connect_key_pressed(move |_, key_value, _, _| {
match key_value {
Key::Escape => exit(1), // todo better way to do this?
Key::Return => {
for s in &inner_box.selected_children() {
// let element : &Option<&EntryElement> = &elements.get(s.index() as usize);
// if let Some(element) = *element {
// debug!("Running action on element with name {}", element.label);
// (element.action)();
// }
if let Err(e) = sender.send(s.index()) {
error!("failed to send selected child {e:?}")
}
app.quit();
}
}
_ => {
if let Some(c) = key_value.name() {
// Only proceed if it's a single alphanumeric character
if c.len() == 1 && c.chars().all(|ch| ch.is_alphanumeric()) {
let current = entry_clone.text().to_string();
entry_clone.set_text(&format!("{current}{c}"));
}
}
}
}
Propagation::Proceed
});
// Add the controller to the window
window.add_controller(key_controller);
}
fn add_menu_item(inner_box: &FlowBox, entry_element: &MenuItem) {
let parent: Widget = if !entry_element.sub_elements.is_empty() {
let expander = Expander::new(None);
expander.set_widget_name("expander-box");
expander.set_halign(Align::Fill);
let menu_row = create_menu_row(entry_element);
expander.set_label_widget(Some(&menu_row));
let list_box = ListBox::new();
list_box.set_widget_name("entry");
// todo multi nesting is not supported yet.
for sub_item in entry_element.sub_elements.iter(){
list_box.append(&create_menu_row(sub_item));
}
expander.set_child(Some(&list_box));
expander.upcast()
} else {
create_menu_row(entry_element).upcast()
};
parent.set_halign(Align::Start);
let child = FlowBoxChild::new();
child.set_widget_name("entry");
child.set_child(Some(&parent));
inner_box.append(&child);
}
fn create_menu_row(menu_item: &MenuItem) -> Widget {
let row = ListBoxRow::new();
row.set_widget_name("entry");
row.set_hexpand(true);
row.set_halign(Align::Start);
let row_box = gtk4::Box::new(Orientation::Horizontal, 0);
row.set_child(Some(&row_box));
if let Some(image_path) = &menu_item.icon_path {
// todo check config too
let image = Image::from_icon_name(image_path);
image.set_pixel_size(24);
image.set_widget_name("img");
row_box.append(&image);
}
let label = Label::new(Some(&menu_item.label));
label.set_widget_name("unselected");
row_box.append(&label);
row.upcast()
}
fn percent_or_absolute(value: &String, base_value: i32) -> Option<i32> {
if value.contains("%") {
let value = value.replace("%", "");
let value = value.trim();
match value.parse::<i32>() {
Ok(n) => {
let result = ((n as f32 / 100.0) * base_value as f32) as i32;
Some(result)
}
Err(_) => None,
}
} else {
value.parse::<i32>().ok()
}
}

View file

@ -1,16 +1,41 @@
use crate::args::Args; use crate::args::Args;
use crate::lib::system;
use anyhow::anyhow; use anyhow::anyhow;
use clap::ValueEnum;
use gtk4::prelude::ToValue; use gtk4::prelude::ToValue;
use merge::Merge; use merge::Merge;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::env;
use std::path::PathBuf;
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug, Serialize, Deserialize)]
pub enum MatchMethod {
Fuzzy,
Contains,
MultiContains,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug, Serialize, Deserialize)]
pub enum Orientation {
Vertical,
Horizontal,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug, Serialize, Deserialize)]
pub enum Align {
Fill,
Start,
Center,
}
#[derive(Debug, Deserialize, Serialize, Merge, Clone)] #[derive(Debug, Deserialize, Serialize, Merge, Clone)]
pub struct Config { pub struct Config {
/// Defines the path to the stylesheet being used.
/// Defaults to XDG_CONFIG_DIR/worf/style.css
/// If XDG_CONFIG_DIR is not defined $HOME/.config will be used instead
#[serde(default = "default_style")]
pub style: Option<String>, pub style: Option<String>,
pub stylesheet: Option<String>,
pub color: Option<String>,
pub colors: Option<String>,
pub show: Option<String>, pub show: Option<String>,
pub mode: Option<String>, pub mode: Option<String>,
#[serde(default = "default_width")] #[serde(default = "default_width")]
@ -32,24 +57,46 @@ pub struct Config {
pub password: Option<String>, pub password: Option<String>,
pub exec_search: Option<bool>, pub exec_search: Option<bool>,
pub hide_scroll: Option<bool>, pub hide_scroll: Option<bool>,
pub matching: Option<String>,
/// Defines how matching is done
#[serde(default = "default_match_method")]
pub matching: Option<MatchMethod>,
pub insensitive: Option<bool>, pub insensitive: Option<bool>,
pub parse_search: Option<bool>, pub parse_search: Option<bool>,
pub location: Option<String>, pub location: Option<String>,
pub no_actions: Option<bool>, pub no_actions: Option<bool>,
pub lines: Option<u32>, pub lines: Option<u32>,
/// Defines how many columns are shown per row
#[serde(default = "default_columns")]
pub columns: Option<u32>, pub columns: Option<u32>,
pub sort_order: Option<String>, pub sort_order: Option<String>,
pub gtk_dark: Option<bool>, pub gtk_dark: Option<bool>,
pub search: Option<String>, pub search: Option<String>,
pub monitor: Option<String>, pub monitor: Option<String>,
pub pre_display_cmd: Option<String>, pub pre_display_cmd: Option<String>,
pub orientation: Option<String>, /// Defines how the entries root container are ordered
pub halign: Option<String>, /// Default is vertical
pub content_halign: Option<String>, #[serde(default = "default_orientation")]
pub valign: Option<String>, pub orientation: Option<Orientation>,
/// Specifies the horizontal align for the entire scrolled area,
/// it can be any of fill, start, end, or center, default is fill.
#[serde(default = "default_halign")]
pub halign: Option<Align>,
//// Specifies the horizontal align for the individual entries,
// it can be any of fill, start, end, or center, default is fill.
#[serde(default = "default_content_halign")]
pub content_halign: Option<Align>,
/// Specifies the vertical align for the entire scrolled area, it can be any of fill, start, e
/// nd, or center, the default is orientation dependent. If vertical then it defaults to
/// start, if horizontal it defaults to center.
pub valign: Option<Align>,
pub filter_rate: Option<u32>, pub filter_rate: Option<u32>,
pub image_size: Option<u32>, /// Specifies the image size when enabled.
/// Defaults to 32.
#[serde(default = "default_image_size")]
pub image_size: Option<i32>,
pub key_up: Option<String>, pub key_up: Option<String>,
pub key_down: Option<String>, pub key_down: Option<String>,
pub key_left: Option<String>, pub key_left: Option<String>,
@ -73,19 +120,28 @@ pub struct Config {
pub copy_exec: Option<String>, pub copy_exec: Option<String>,
pub single_click: Option<bool>, pub single_click: Option<bool>,
pub pre_display_exec: Option<bool>, pub pre_display_exec: Option<bool>,
// Exclusive options
/// Minimum score for the fuzzy finder to accept a match.
/// Must be a value between 0 and 1
/// Defaults to 0.1.
#[serde(default = "default_fuzzy_min_score")]
pub fuzzy_min_score: Option<f64>,
/// Defines how the content in the row box is aligned
/// Defaults to vertical
#[serde(default = "default_row_box_orientation")]
pub row_bow_orientation: Option<Orientation>,
} }
impl Default for Config { impl Default for Config {
fn default() -> Self { fn default() -> Self {
Config { Config {
style: None, style: default_style(),
stylesheet: None,
color: None,
colors: None,
show: None, show: None,
mode: None, mode: None,
width: None, width: default_width(),
height: None, height: default_height(),
prompt: None, prompt: None,
xoffset: None, xoffset: None,
x: None, x: None,
@ -105,18 +161,18 @@ impl Default for Config {
location: None, location: None,
no_actions: None, no_actions: None,
lines: None, lines: None,
columns: None, columns: default_columns(),
sort_order: None, sort_order: None,
gtk_dark: None, gtk_dark: None,
search: None, search: None,
monitor: None, monitor: None,
pre_display_cmd: None, pre_display_cmd: None,
orientation: None, orientation: default_row_box_orientation(),
halign: None, halign: default_halign(),
content_halign: None, content_halign: default_content_halign(),
valign: None, valign: None,
filter_rate: None, filter_rate: None,
image_size: None, image_size: default_image_size(),
key_up: None, key_up: None,
key_down: None, key_down: None,
key_left: None, key_left: None,
@ -139,10 +195,32 @@ impl Default for Config {
copy_exec: None, copy_exec: None,
single_click: None, single_click: None,
pre_display_exec: None, pre_display_exec: None,
fuzzy_min_score: default_fuzzy_min_score(),
row_bow_orientation: default_row_box_orientation(),
} }
} }
} }
fn default_row_box_orientation() -> Option<Orientation> {
Some(Orientation::Horizontal)
}
fn default_orientation() -> Option<Orientation> {
Some(Orientation::Vertical)
}
fn default_halign() -> Option<Align> {
Some(Align::Fill)
}
fn default_content_halign() -> Option<Align> {
Some(Align::Fill)
}
fn default_columns() -> Option<u32> {
Some(1)
}
fn default_normal_window() -> Option<bool> { fn default_normal_window() -> Option<bool> {
Some(false) Some(false)
} }
@ -224,18 +302,44 @@ fn default_normal_window() -> Option<bool> {
// key_default = "Ctrl-c"; // key_default = "Ctrl-c";
// char* key_copy = (i == 0) ? key_default : config_get(config, "key_copy", key_default); // char* key_copy = (i == 0) ? key_default : config_get(config, "key_copy", key_default);
fn default_height() -> Option<String> { fn default_style() -> Option<String> {
system::config_path(None)
.ok()
.and_then(|pb| Some(pb.display().to_string()))
.or_else(|| {
log::error!("no stylesheet found, using system styles");
None
})
}
pub fn default_height() -> Option<String> {
Some("40%".to_owned()) Some("40%".to_owned())
} }
fn default_width() -> Option<String> { pub fn default_width() -> Option<String> {
Some("50%".to_owned()) Some("50%".to_owned())
} }
fn default_password_char() -> Option<String> { pub fn default_password_char() -> Option<String> {
Some("*".to_owned()) Some("*".to_owned())
} }
pub fn default_fuzzy_min_length() -> Option<i32> {
Some(10)
}
pub fn default_fuzzy_min_score() -> Option<f64> {
Some(0.1)
}
pub fn default_match_method() -> Option<MatchMethod> {
Some(MatchMethod::Contains)
}
pub fn default_image_size() -> Option<i32> {
Some(32)
}
pub fn merge_config_with_args(config: &mut Config, args: &Args) -> anyhow::Result<Config> { pub fn merge_config_with_args(config: &mut Config, args: &Args) -> anyhow::Result<Config> {
let args_json = serde_json::to_value(args)?; let args_json = serde_json::to_value(args)?;
let mut config_json = serde_json::to_value(config)?; let mut config_json = serde_json::to_value(config)?;

View file

@ -121,19 +121,14 @@ fn find_file_case_insensitive(folder: &Path, file_name: &Regex) -> Option<Vec<Pa
fs::read_dir(folder).ok().map(|entries| { fs::read_dir(folder).ok().map(|entries| {
entries entries
.filter_map(Result::ok) .filter_map(Result::ok)
.filter(|entry| { .filter_map(|entry| entry.path().canonicalize().ok())
entry
.file_type()
.ok()
.is_some_and(|file_type| file_type.is_file())
})
.filter(|entry| { .filter(|entry| {
entry entry
.file_name() .file_name()
.to_str() .and_then(|e| e.to_str()) // Handle the Option here.
.is_some_and(|name| file_name.is_match(name)) .map(|name| file_name.is_match(name))
.unwrap_or(false) // Handle the case where the file name is not a valid string.
}) })
.map(|entry| entry.path())
.collect() .collect()
}) })
} }
@ -142,6 +137,7 @@ pub(crate) fn find_desktop_files() -> Vec<DesktopFile> {
let mut paths = vec![ let mut paths = vec![
PathBuf::from("/usr/share/applications"), PathBuf::from("/usr/share/applications"),
PathBuf::from("/usr/local/share/applications"), PathBuf::from("/usr/local/share/applications"),
PathBuf::from("/var/lib/flatpak/exports/share/applications"),
]; ];
if let Some(home) = home_dir() { if let Some(home) = home_dir() {
@ -166,7 +162,6 @@ pub(crate) fn find_desktop_files() -> Vec<DesktopFile> {
p p
} }
pub fn get_locale_variants() -> Vec<String> { pub fn get_locale_variants() -> Vec<String> {
let locale = env::var("LC_ALL") let locale = env::var("LC_ALL")
.or_else(|_| env::var("LC_MESSAGES")) .or_else(|_| env::var("LC_MESSAGES"))

544
src/lib/gui.rs Normal file
View file

@ -0,0 +1,544 @@
use crate::lib::config;
use crate::lib::config::{Config, MatchMethod};
use anyhow::{Context, anyhow};
use crossbeam::channel;
use crossbeam::channel::Sender;
use gdk4::gio::{File, Menu};
use gdk4::glib::{GString, Propagation, Unichar};
use gdk4::prelude::{Cast, DisplayExt, ListModelExtManual, MonitorExt};
use gdk4::{Display, Key};
use gtk4::prelude::{
ApplicationExt, ApplicationExtManual, BoxExt, ButtonExt, EditableExt, EntryExt, FileChooserExt,
FlowBoxChildExt, GestureSingleExt, GtkWindowExt, ListBoxRowExt, NativeExt, OrientableExt,
WidgetExt,
};
use gtk4::{
Align, Entry, EventControllerKey, Expander, FlowBox, FlowBoxChild, GestureClick, Image, Label,
ListBox, ListBoxRow, Ordering, PolicyType, Revealer, ScrolledWindow, SearchEntry, Widget, gdk,
};
use gtk4::{Application, ApplicationWindow, CssProvider, Orientation};
use gtk4_layer_shell::{KeyboardMode, LayerShell};
use hyprland::ctl::output::create;
use hyprland::ctl::plugin::list;
use std::collections::HashMap;
use log::{debug, error, info};
use std::process::exit;
use std::sync::{Arc, Mutex, MutexGuard};
type ArcMenuMap = Arc<Mutex<HashMap<FlowBoxChild, MenuItem>>>;
type MenuItemSender = Sender<Result<MenuItem, anyhow::Error>>;
impl Into<Orientation> for config::Orientation {
fn into(self) -> Orientation {
match self {
config::Orientation::Vertical => Orientation::Vertical,
config::Orientation::Horizontal => Orientation::Horizontal,
}
}
}
impl Into<Align> for config::Align {
fn into(self) -> Align {
match self {
config::Align::Fill => Align::Fill,
config::Align::Start => Align::Start,
config::Align::Center => Align::Center,
}
}
}
#[derive(Clone)]
pub struct MenuItem {
pub label: String, // todo support empty label?
pub icon_path: Option<String>,
pub action: Option<String>,
pub sub_elements: Vec<MenuItem>,
pub working_dir: Option<String>,
pub initial_sort_score: i64,
pub search_sort_score: f64,
}
pub fn show(config: Config, elements: Vec<MenuItem>) -> Result<MenuItem, anyhow::Error> {
if let Some(ref css) = config.style {
let provider = CssProvider::new();
let css_file_path = File::for_path(css);
provider.load_from_file(&css_file_path);
let display = Display::default().expect("Could not connect to a display");
gtk4::style_context_add_provider_for_display(
&display,
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
// No need for application_id unless you want portal support
let app = Application::builder().application_id("worf").build();
let (sender, receiver) = channel::bounded(1);
app.connect_activate(move |app| {
build_ui(&config, &elements, sender.clone(), app);
});
let empty_array: [&str; 0] = [];
app.run_with_args(&empty_array);
let selection = receiver.recv()?;
selection
}
fn build_ui(
config: &Config,
elements: &Vec<MenuItem>,
sender: Sender<Result<MenuItem, anyhow::Error>>,
app: &Application,
) {
// Create a toplevel undecorated window
let window = ApplicationWindow::builder()
.application(app)
.decorated(false)
.resizable(false)
.default_width(20)
.default_height(20)
.build();
window.set_widget_name("window");
config.normal_window.map(|normal| {
if !normal {
// Initialize the window as a layer
window.init_layer_shell();
window.set_layer(gtk4_layer_shell::Layer::Overlay);
window.set_keyboard_mode(KeyboardMode::Exclusive);
window.set_namespace(Some("worf"));
}
});
let outer_box = gtk4::Box::new(config.orientation.unwrap().into(), 0);
outer_box.set_widget_name("outer-box");
window.set_child(Some(&outer_box));
let entry = SearchEntry::new();
entry.set_widget_name("input");
entry.set_css_classes(&["input"]);
entry.set_placeholder_text(config.prompt.as_deref());
entry.set_sensitive(false);
outer_box.append(&entry);
let scroll = ScrolledWindow::new();
scroll.set_widget_name("scroll");
scroll.set_hexpand(true);
scroll.set_vexpand(true);
// if let Some(valign) = config.valign {
// scroll.set_valign(valign.into());
// } else {
// if config.orientation.unwrap() == config::Orientation::Horizontal {
// scroll.set_valign(Align::Center);
// } else {
// scroll.set_valign(Align::Start);
// }
// }
let hide_scroll = false; // todo
if hide_scroll {
scroll.set_policy(PolicyType::External, PolicyType::External);
}
outer_box.append(&scroll);
let inner_box = FlowBox::new();
inner_box.set_widget_name("inner-box");
inner_box.set_css_classes(&["inner-box"]);
inner_box.set_hexpand(true);
inner_box.set_vexpand(false);
inner_box.set_selection_mode(gtk4::SelectionMode::Browse);
inner_box.set_max_children_per_line(config.columns.unwrap());
inner_box.set_activate_on_single_click(true);
let mut list_items: ArcMenuMap = Arc::new(Mutex::new(HashMap::new()));
for entry in elements {
list_items
.lock()
.unwrap() // panic here ok? deadlock?
.insert(
add_menu_item(
&inner_box,
&entry,
&config,
sender.clone(),
list_items.clone(),
app.clone(),
),
entry.clone(),
);
}
let items_clone = list_items.clone();
inner_box.set_sort_func(move |child1, child2| sort_menu_items(child1, child2, &items_clone));
// Set focus after everything is realized
inner_box.connect_map(|fb| {
fb.grab_focus();
fb.invalidate_sort();
});
let wrapper_box = gtk4::Box::new(Orientation::Vertical, 0);
wrapper_box.append(&inner_box);
scroll.set_child(Some(&wrapper_box));
setup_key_event_handler(
&window,
entry.clone(),
inner_box,
app.clone(),
sender.clone(),
list_items.clone(),
config.clone(),
);
window.show();
let display = window.display();
window.surface().map(|surface| {
// todo this does not work for multi monitor systems
let monitor = display.monitor_at_surface(&surface);
if let Some(monitor) = monitor {
let geometry = monitor.geometry();
config.width.as_ref().map(|width| {
percent_or_absolute(&width, geometry.width()).map(|w| window.set_width_request(w))
});
config.height.as_ref().map(|height| {
percent_or_absolute(&height, geometry.height())
.map(|h| window.set_height_request(h))
});
} else {
log::error!("failed to get monitor to init window size");
}
});
}
fn setup_key_event_handler(
window: &ApplicationWindow,
entry_clone: SearchEntry,
inner_box: FlowBox,
app: Application,
sender: MenuItemSender,
list_items: Arc<Mutex<HashMap<FlowBoxChild, MenuItem>>>,
config: Config,
) {
let key_controller = EventControllerKey::new();
key_controller.connect_key_pressed(move |_, key_value, _, _| {
match key_value {
Key::Escape => {
if let Err(e) = sender.send(Err(anyhow!("No item selected"))) {
log::error!("failed to send message {e}");
}
app.quit();
}
Key::Return => {
if let Err(e) = handle_selected_item(&sender, &app, &inner_box, &list_items) {
log::error!("{e}");
}
}
Key::BackSpace => {
let mut items = list_items.lock().unwrap();
let mut query = entry_clone.text().to_string();
query.pop();
entry_clone.set_text(&query);
filter_widgets(&query, &mut items, &config, &inner_box);
}
_ => {
let mut items = list_items.lock().unwrap();
if let Some(c) = key_value.to_unicode() {
let current = entry_clone.text().to_string();
let query = format!("{current}{c}");
entry_clone.set_text(&query);
filter_widgets(&query, &mut items, &config, &inner_box);
}
}
}
Propagation::Proceed
});
window.add_controller(key_controller);
}
fn sort_menu_items(
child1: &FlowBoxChild,
child2: &FlowBoxChild,
items_lock: &Mutex<HashMap<FlowBoxChild, MenuItem>>,
) -> Ordering {
let lock = items_lock.lock().unwrap();
let m1 = lock.get(child1);
let m2 = lock.get(child2);
match (m1, m2) {
(Some(menu1), Some(menu2)) => {
if menu1.search_sort_score != 0.0 || menu2.search_sort_score != 0.0 {
if menu1.search_sort_score > menu2.search_sort_score {
Ordering::Smaller
} else {
Ordering::Larger
}
} else {
if menu1.initial_sort_score > menu2.initial_sort_score {
Ordering::Smaller
} else {
Ordering::Larger
}
}
}
(Some(_), None) => Ordering::Larger,
(None, Some(_)) => Ordering::Smaller,
(None, None) => Ordering::Equal,
}
}
fn handle_selected_item(
sender: &MenuItemSender,
app: &Application,
inner_box: &FlowBox,
lock_arc: &ArcMenuMap,
) -> Result<(), String> {
for s in inner_box.selected_children() {
let list_items = lock_arc.lock().unwrap();
let item = list_items.get(&s);
if let Some(item) = item {
if let Err(e) = sender.send(Ok(item.clone())) {
log::error!("failed to send message {e}");
}
}
app.quit();
return Ok(());
}
Err("selected item cannot be resolved".to_owned())
}
fn add_menu_item(
inner_box: &FlowBox,
entry_element: &MenuItem,
config: &Config,
sender: MenuItemSender,
lock_arc: ArcMenuMap,
app: Application,
) -> FlowBoxChild {
let parent: Widget = if !entry_element.sub_elements.is_empty() {
let expander = Expander::new(None);
expander.set_widget_name("expander-box");
expander.set_hexpand(true);
let menu_row = create_menu_row(
entry_element,
config,
lock_arc.clone(),
sender.clone(),
app.clone(),
inner_box.clone(),
);
expander.set_label_widget(Some(&menu_row));
let list_box = ListBox::new();
list_box.set_hexpand(true);
list_box.set_halign(Align::Fill);
for sub_item in &entry_element.sub_elements {
let sub_row = create_menu_row(
sub_item,
config,
lock_arc.clone(),
sender.clone(),
app.clone(),
inner_box.clone(),
);
sub_row.set_hexpand(true);
sub_row.set_halign(Align::Fill);
sub_row.set_widget_name("entry");
list_box.append(&sub_row);
}
expander.set_child(Some(&list_box));
expander.upcast()
} else {
create_menu_row(
entry_element,
config,
lock_arc.clone(),
sender.clone(),
app.clone(),
inner_box.clone(),
)
.upcast()
};
parent.set_halign(Align::Fill);
parent.set_valign(Align::Start);
parent.set_hexpand(true);
let child = FlowBoxChild::new();
child.set_widget_name("entry");
child.set_child(Some(&parent));
child.set_hexpand(true);
child.set_vexpand(false);
inner_box.append(&child);
child
}
fn create_menu_row(
menu_item: &MenuItem,
config: &Config,
lock_arc: ArcMenuMap,
sender: MenuItemSender,
app: Application,
inner_box: FlowBox,
) -> Widget {
let row = ListBoxRow::new();
row.set_hexpand(true);
row.set_halign(Align::Fill);
row.set_widget_name("row");
let click = GestureClick::new();
click.set_button(gdk::BUTTON_PRIMARY);
click.connect_pressed(move |_gesture, n_press, _x, _y| {
if n_press == 2 {
if let Err(e) = handle_selected_item(&sender, &app, &inner_box, &lock_arc) {
log::error!("{e}");
}
}
});
row.add_controller(click);
let row_box = gtk4::Box::new(config.row_bow_orientation.unwrap().into(), 0);
row_box.set_hexpand(true);
row_box.set_vexpand(false);
row_box.set_halign(Align::Fill);
row.set_child(Some(&row_box));
if let Some(image_path) = &menu_item.icon_path {
let image = Image::from_icon_name(image_path);
image.set_pixel_size(
config
.image_size
.unwrap_or(config::default_image_size().unwrap()),
);
image.set_widget_name("img");
row_box.append(&image);
}
let label = Label::new(Some(&menu_item.label));
label.set_hexpand(true);
row_box.append(&label);
if config.content_halign.unwrap() == config::Align::Start
|| config.content_halign.unwrap() == config::Align::Fill
{
label.set_xalign(0.0);
}
row.upcast()
}
fn filter_widgets(
query: &str,
items: &mut HashMap<FlowBoxChild, MenuItem>,
config: &Config,
inner_box: &FlowBox,
) {
if items.is_empty() {
items.iter().for_each(|(child, _)| {
child.set_visible(true);
});
if let Some(child) = inner_box.first_child() {
child.grab_focus();
let fb = child.downcast::<FlowBoxChild>();
if let Ok(fb) = fb {
inner_box.select_child(&fb);
}
}
return;
}
let query = query.to_owned().to_lowercase();
let mut highest_score = -1.0;
let mut fb: Option<&FlowBoxChild> = None;
items.iter_mut().for_each(|(flowbox_child, mut menu_item)| {
let menu_item_search = format!(
"{} {}",
menu_item
.action
.as_ref()
.map(|a| a.to_lowercase())
.unwrap_or_default(),
&menu_item.label.to_lowercase()
);
let matching = if let Some(matching) = &config.matching {
matching
} else {
&config::default_match_method().unwrap()
};
let (search_sort_score, visible) = match matching {
MatchMethod::Fuzzy => {
let score = strsim::normalized_levenshtein(&query, &menu_item_search);
(score, score > config.fuzzy_min_score.unwrap())
}
MatchMethod::Contains => {
if menu_item_search.contains(&query) {
(1.0, true)
} else {
(0.0, false)
}
}
MatchMethod::MultiContains => {
let score = query
.split(' ')
.filter(|i| menu_item_search.contains(i))
.map(|_| 1.0)
.sum();
(score, score > 0.0)
}
};
menu_item.search_sort_score = search_sort_score;
if visible {
highest_score = search_sort_score;
fb = Some(flowbox_child);
}
flowbox_child.set_visible(visible);
});
if let Some(top_item) = fb {
inner_box.select_child(top_item);
top_item.grab_focus();
}
}
fn percent_or_absolute(value: &String, base_value: i32) -> Option<i32> {
if value.contains("%") {
let value = value.replace("%", "").trim().to_string();
match value.parse::<i32>() {
Ok(n) => Some(((n as f32 / 100.0) * base_value as f32) as i32),
Err(_) => None,
}
} else {
value.parse::<i32>().ok()
}
}
pub fn initialize_sort_scores(items: &mut Vec<MenuItem>) {
let mut regular_score = items.len() as i64;
items.sort_by(|l, r| r.label.cmp(&l.label));
for item in items.iter_mut() {
if item.initial_sort_score == 0 {
item.initial_sort_score = regular_score;
regular_score += 1;
}
}
}

4
src/lib/mod.rs Normal file
View file

@ -0,0 +1,4 @@
pub mod config;
pub mod desktop;
pub mod gui;
pub mod system;

31
src/lib/system.rs Normal file
View file

@ -0,0 +1,31 @@
use anyhow::anyhow;
use std::env;
use std::path::PathBuf;
pub fn home_dir() -> Result<String, anyhow::Error> {
env::var("HOME").map_err(|e| anyhow::anyhow!("$HOME not set: {e}"))
}
pub fn conf_home() -> Result<String, anyhow::Error> {
env::var("XDG_CONF_HOME").map_err(|e| anyhow::anyhow!("XDG_CONF_HOME not set: {e}"))
}
pub fn config_path(config_path: Option<String>) -> Result<PathBuf, anyhow::Error> {
config_path
.map(PathBuf::from)
.and_then(|p| p.canonicalize().ok().filter(|c| c.exists()))
.or_else(|| {
[
conf_home().ok().map(PathBuf::from),
home_dir()
.ok()
.map(PathBuf::from)
.map(|c| c.join(".config")),
]
.into_iter()
.flatten()
.map(|base| base.join("worf").join("style.css"))
.find_map(|p| p.canonicalize().ok())
})
.ok_or_else(|| anyhow!("Could not find a valid config file."))
}

View file

@ -1,11 +1,16 @@
#![warn(clippy::pedantic)] #![warn(clippy::pedantic)]
#![allow(clippy::implicit_return)] #![allow(clippy::implicit_return)]
// todo resolve paths like ~/
use crate::args::{Args, Mode}; use crate::args::{Args, Mode};
use crate::config::{Config, merge_config_with_args}; use crate::lib::config::{Config, merge_config_with_args};
use crate::desktop::{default_icon, find_desktop_files, get_locale_variants}; use crate::lib::desktop::{default_icon, find_desktop_files, get_locale_variants};
use crate::gui::MenuItem; use crate::lib::gui;
use crate::lib::gui::MenuItem;
use anyhow::{Error, anyhow};
use clap::Parser; use clap::Parser;
use freedesktop_file_parser::{DesktopAction, EntryType};
use gdk4::prelude::Cast; use gdk4::prelude::Cast;
use gtk4::prelude::{ use gtk4::prelude::{
ApplicationExt, ApplicationExtManual, BoxExt, ButtonExt, EditableExt, EntryExt, ApplicationExt, ApplicationExtManual, BoxExt, ButtonExt, EditableExt, EntryExt,
@ -22,51 +27,40 @@ use std::process::{Command, Stdio};
use std::sync::Arc; use std::sync::Arc;
use std::thread::sleep; use std::thread::sleep;
use std::{env, fs, time}; use std::{env, fs, time};
use freedesktop_file_parser::{DesktopAction, EntryType};
mod args; mod args;
mod config; mod lib;
mod desktop;
mod gui;
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
gtk4::init()?; gtk4::init()?;
env_logger::Builder::new() env_logger::Builder::new()
// todo change to info as default // todo change to error as default
.parse_filters(&std::env::var("RUST_LOG").unwrap_or_else(|_| "debug".to_owned())) .parse_filters(&env::var("RUST_LOG").unwrap_or_else(|_| "debug".to_owned()))
.init(); .init();
let args = Args::parse(); let args = Args::parse();
let home_dir = std::env::var("HOME")?; let home_dir = env::var("HOME")?;
let config_path = args let config_path = args
.config .config
.as_ref() .as_ref()
.map(|c| PathBuf::from(c)) .map(|c| PathBuf::from(c))
.unwrap_or_else(|| { .unwrap_or_else(|| {
std::env::var("XDG_CONF_HOME") env::var("XDG_CONF_HOME")
.map_or( .map_or(
PathBuf::from(home_dir.clone()).join(".config"), PathBuf::from(home_dir.clone()).join(".config"),
|xdg_conf_home| PathBuf::from(&xdg_conf_home), |xdg_conf_home| PathBuf::from(&xdg_conf_home),
) )
.join("wofi") // todo change to worf .join("worf")
.join("config") .join("config")
}); });
// todo use this?
let colors_dir = std::env::var("XDG_CACHE_HOME")
.map_or(
PathBuf::from(home_dir.clone()).join(".cache"),
|xdg_conf_home| PathBuf::from(&xdg_conf_home),
)
.join("wal")
.join("colors");
let drun_cache = std::env::var("XDG_CACHE_HOME") let drun_cache = env::var("XDG_CACHE_HOME")
.map_or( .map_or(
PathBuf::from(home_dir.clone()).join(".cache"), PathBuf::from(home_dir.clone()).join(".cache"),
|xdg_conf_home| PathBuf::from(&xdg_conf_home), |xdg_conf_home| PathBuf::from(&xdg_conf_home),
) )
.join("worf-drun"); // todo change to worf .join("worf-drun");
let toml_content = fs::read_to_string(config_path)?; let toml_content = fs::read_to_string(config_path)?;
let mut config: Config = toml::from_str(&toml_content)?; // todo bail out properly let mut config: Config = toml::from_str(&toml_content)?; // todo bail out properly
@ -102,10 +96,14 @@ fn drun(mut config: Config) -> anyhow::Result<()> {
let default_icon = default_icon(); let default_icon = default_icon();
for file in find_desktop_files().iter().filter(|f| { for file in find_desktop_files().iter().filter(|f| {
f.entry.hidden.map_or(true, |hidden| !hidden) f.entry.hidden.map_or(true, |hidden| !hidden)
&& f.entry.no_display.map_or(true, |no_display| !no_display) && f.entry.no_display.map_or(true, |no_display| !no_display)
// todo handle not shown in?
}) { }) {
let (action, working_dir) = match &file.entry.entry_type {
EntryType::Application(app) => (app.exec.clone(), app.path.clone()),
_ => (None, None),
};
let name = lookup_name_with_locale( let name = lookup_name_with_locale(
&locale_variants, &locale_variants,
&file.entry.name.variants, &file.entry.name.variants,
@ -115,14 +113,26 @@ fn drun(mut config: Config) -> anyhow::Result<()> {
debug!("Skipping desktop entry without name {file:?}") debug!("Skipping desktop entry without name {file:?}")
} }
let icon = file.entry.icon.as_ref().map(|s| s.content.clone()); let icon = file
debug!("file, name={name:?}, icon={icon:?}"); .entry
.icon
.as_ref()
.map(|s| s.content.clone())
.or(Some(default_icon.clone()));
debug!("file, name={name:?}, icon={icon:?}, action={action:?}");
let mut sort_score = 0.0;
if name.as_ref().unwrap().contains("ox") {
sort_score = 999.0;
}
let mut entry = MenuItem { let mut entry = MenuItem {
label: name.unwrap(), label: name.unwrap(),
icon_path: icon.clone(), icon_path: icon.clone(),
action: None, action,
sub_elements: Vec::default(), sub_elements: Vec::default(),
working_dir: working_dir.clone(),
initial_sort_score: 0,
search_sort_score: sort_score,
}; };
file.actions.iter().for_each(|(_, action)| { file.actions.iter().for_each(|(_, action)| {
@ -131,95 +141,83 @@ fn drun(mut config: Config) -> anyhow::Result<()> {
&action.name.variants, &action.name.variants,
&action.name.default, &action.name.default,
); );
let action_icon = action.icon.as_ref().map(|s| s.content.clone()).or(icon.as_ref().map(|s| s.clone())); let action_icon = action
.icon
.as_ref()
.map(|s| s.content.clone())
.or(icon.as_ref().map(|s| s.clone()));
debug!("sub, action_name={action_name:?}, action_icon={action_icon:?}"); debug!("sub, action_name={action_name:?}, action_icon={action_icon:?}");
let sub_entry = MenuItem { let sub_entry = MenuItem {
label: action_name.unwrap().trim().to_owned(), label: action_name.unwrap().trim().to_owned(),
icon_path: action_icon, icon_path: action_icon,
action: None, action: action.exec.clone(),
sub_elements: Vec::default(), sub_elements: Vec::default(),
working_dir: working_dir.clone(),
initial_sort_score: 0,
search_sort_score: 0.0,
}; };
entry.sub_elements.push(sub_entry); entry.sub_elements.push(sub_entry);
}); });
entries.push(entry); entries.push(entry);
// let desktop = Some("desktop entry");
// let locale =
// env::var("LC_ALL")
// .or_else(|_| env::var("LC_MESSAGES"))
// .or_else(|_| env::var("LANG"))
// .unwrap_or_else(|_| "en_US.UTF-8".to_string()).split_once(".").map(|(k,_)| k.to_owned().to_lowercase());
//
//
//
//
// if let Some(desktop_entry) = file.get("desktop entry") {
// let icon = desktop_entry
// .get("icon")
// .and_then(|x| x.as_ref().map(|x| x.to_owned()));
//
//
// let Some(exec) = desktop_entry.get("exec")
//
//
//
// .and_then(|x| x.as_ref()) else {
// warn!("Skipping desktop file {file:#?}");
// continue;
// };
//
// if let Some((cmd, _)) = exec.split_once(' ') {
// if !PathBuf::from(cmd).exists() {
// continue;
// }
// }
//
// let name = desktop_entry
// .get("name")
// .and_then(|x| x.as_ref().map(|x| x.to_owned()));
//
// if let Some(name) = name {
// entries.push({
// EntryElement {
// label: name,
// icon_path: icon,
// action: Some(exec.clone()),
// sub_elements: None,
// }
// })
// }
// }
} }
entries.sort_by(|l, r| l.label.cmp(&r.label)); gui::initialize_sort_scores(&mut entries);
if config.prompt.is_none() {
config.prompt = Some("drun".to_owned());
}
// todo ues a arc instead of cloning the config // todo ues a arc instead of cloning the config
let selected_index = gui::show(config.clone(), entries.clone())?; let selection_result = gui::show(config.clone(), entries.clone());
entries.get(selected_index as usize).map(|e| { match selection_result {
e.action.as_ref().map(|a| { Ok(selected_item) => {
spawn_fork(&a); if let Some(action) = selected_item.action {
}) spawn_fork(&action, &selected_item.working_dir)?
}); }
}
Err(e) => {
log::error!("{e}");
}
}
Ok(()) Ok(())
} }
fn spawn_fork(cmd: &str) { fn spawn_fork(cmd: &str, working_dir: &Option<String>) -> anyhow::Result<()> {
// todo fork this for real
// todo probably remove arguments? // todo probably remove arguments?
// todo support working dir
// todo fix actions
// todo graphical disk map icon not working
// Unix-like systems (Linux, macOS) // Unix-like systems (Linux, macOS)
let _ = Command::new(cmd)
.stdin(Stdio::null()) // Disconnect stdin let parts = cmd.split(' ').collect::<Vec<_>>();
.stdout(Stdio::null()) // Disconnect stdout if parts.is_empty() {
.stderr(Stdio::null()) // Disconnect stderr return Err(anyhow!("empty command passed"));
.spawn(); }
sleep(time::Duration::from_secs(30));
if let Some(dir) = working_dir {
env::set_current_dir(dir)?;
}
let exec = parts[0];
let args: Vec<_> = parts
.iter()
.skip(1)
.filter(|arg| !arg.starts_with("%"))
.collect();
unsafe {
let _ = Command::new(exec)
.args(args)
.stdin(Stdio::null()) // Disconnect stdin
.stdout(Stdio::null()) // Disconnect stdout
.stderr(Stdio::null()) // Disconnect stderr
.pre_exec(|| {
libc::setsid();
Ok(())
})
.spawn();
}
Ok(())
} }
// //
// fn main() -> anyhow::Result<()> { // fn main() -> anyhow::Result<()> {

69
styles/compact.css Normal file
View file

@ -0,0 +1,69 @@
* {
font-family: DejaVu;
}
#window {
all: unset;
background-color: rgba(33, 33, 33, 0.8); /* Matches #212121BB */
border-radius: 0px;
}
#window #outer-box {
/* The name of the search bar */
/* The name of the scrolled window containing all of the entries */
border: 2px solid rgba(63, 81, 181, 1);
border-radius: 6px;
}
#window #outer-box #input {
background-color: rgba(32, 32, 32, 0.6);
color: #f2f2f2;
border-bottom: 2px solid rgba(214, 174, 0, 1);
padding: 0.8rem 1rem;
font-size: 1rem;
}
#window #outer-box #input:focus, #window #outer-box #input:focus-visible, #window #outer-box #input:active {
all: unset;
background-color: rgba(32, 32, 32, 0.6);
color: #f2f2f2;
border-bottom: 2px solid rgba(214, 174, 2, 1);
padding: 1.2rem 1.2rem 1.2rem 1rem;
font-size: 1rem;
}
#window #outer-box #scroll {
/* The name of the box containing all of the entries */
}
#window #outer-box #scroll #inner-box {
/* The name of all entries */
/* The name of all boxes shown when expanding */
/* entries with multiple actions */
}
#window #outer-box #scroll #inner-box #entry {
color: #fff;
background-color: rgba(32, 32, 32, 0.1);
padding: 0.6rem 1rem;
/* The name of all images in entries displayed in image mode */
/* The name of all the text in entries */
}
#window #outer-box #scroll #inner-box #entry #img {
width: 1rem;
margin-right: 0.5rem;
}
#window #outer-box #scroll #inner-box #entry:selected {
color: #fff;
background-color: rgba(255, 255, 255, 0.1);
outline: none;
}
#row:hover {
background-color: rgba(255, 255, 255, 0);;
outline: inherit;
outline-color: inherit;
}
#window #outer-box #scroll #inner-box #entry:hover {
background-color: rgba(255, 255, 255, 0.1);
outline: inherit;
outline-color: inherit;
}

69
styles/launcher.css Normal file
View file

@ -0,0 +1,69 @@
* {
font-family: DejaVu;
}
#window {
all: unset;
background-color: rgba(33, 33, 33, 0.8); /* Matches #212121BB */
border-radius: 0px;
}
#window #outer-box {
/* The name of the search bar */
/* The name of the scrolled window containing all of the entries */
border: 2px solid rgba(63, 81, 181, 1);
border-radius: 6px;
}
#window #outer-box #input {
background-color: rgba(32, 32, 32, 0.6);
color: #f2f2f2;
border-bottom: 2px solid rgba(214, 174, 0, 1);
padding: 0.8rem 1rem;
font-size: 1rem;
}
#window #outer-box #input:focus, #window #outer-box #input:focus-visible, #window #outer-box #input:active {
all: unset;
background-color: rgba(32, 32, 32, 0.6);
color: #f2f2f2;
border-bottom: 2px solid rgba(214, 174, 2, 1);
font-size: 1rem;
}
#window #outer-box #scroll {
/* The name of the box containing all of the entries */
}
#window #outer-box #scroll #inner-box {
/* The name of all entries */
/* The name of all boxes shown when expanding */
/* entries with multiple actions */
}
#window #outer-box #scroll #inner-box #entry {
color: #fff;
background-color: rgba(32, 32, 32, 0.1);
padding: 1rem;
margin: 1rem;
border-radius: 0.5rem;
}
#window #outer-box #scroll #inner-box #entry #img {
width: 1rem;
margin-right: 0.5rem;
}
#window #outer-box #scroll #inner-box #entry:selected {
color: #fff;
background-color: rgba(255, 255, 255, 0.1);
outline: none;
}
#row:hover {
background-color: rgba(255, 255, 255, 0);;
outline: inherit;
outline-color: inherit;
}
#window #outer-box #scroll #inner-box #entry:hover {
background-color: rgba(255, 255, 255, 0.1);
outline: inherit;
outline-color: inherit;
}