diff --git a/Cargo.lock b/Cargo.lock index 1967632..8c4684d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2105,6 +2105,7 @@ dependencies = [ "serde", "serde_json", "smithay-client-toolkit", + "strsim 0.11.1", "sysinfo", "thiserror 2.0.12", "toml", diff --git a/Cargo.toml b/Cargo.toml index eec2443..8af58ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,3 +28,4 @@ calloop = "0.14.2" crossbeam = "0.8.4" libc = "0.2.171" freedesktop-file-parser = "0.1.0" +strsim = "0.11.1" diff --git a/README.md b/README.md index af23136..fd79220 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ # Worf -Worf is a clone of [wofi](https://github.com/SimplyCEO/wofi) written in rust. -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. - -* Wofis css files are supported -* Wofis command line flags are supported +Worf is yet another dmenu style launcher, heavily inspired by wofi but written in Rust on top of GTK4. +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. ## Setup @@ -20,10 +18,19 @@ layerrule = blur, worf * Window switcher for hyprland ## 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 -* 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 -* 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. diff --git a/src/args.rs b/src/args.rs index 1c19175..94002a4 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,3 +1,4 @@ +use crate::lib::config::{Align, MatchMethod, Orientation}; use clap::Parser; use serde::{Deserialize, Serialize}; use std::str::FromStr; @@ -51,10 +52,6 @@ pub struct Args { #[clap(short = 's', long = "style")] style: Option, - /// Selects a colors file to use - #[clap(short = 'C', long = "color")] - color: Option, - /// Runs in dmenu mode #[clap(short = 'd', long = "dmenu")] dmenu: bool, @@ -117,7 +114,7 @@ pub struct Args { /// Sets the matching method, default is contains #[clap(short = 'M', long = "matching")] - matching: Option, + matching: Option, /// Allows case insensitive searching #[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 #[clap(short = 'r', long = "pre-display-cmd")] pre_display_cmd: Option, + + /// Defines how good a fuzzy match must be, to be shown. + #[clap(long = "fuzzy-min-score")] + fuzzy_min_score: Option, + + /// Size of displayed images + #[clap(long = "image-size")] + image_size: Option, + + /// Orientation of main window + #[clap(long = "orientation")] + orientation: Option, + + /// Orientation of the row box, defining if label is below or at the side. + #[clap(long = "row-box-orientation")] + row_bow_orientation: Option, + + /// 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, + //// 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, + + /// 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, } diff --git a/src/gui.rs b/src/gui.rs deleted file mode 100644 index b65e12a..0000000 --- a/src/gui.rs +++ /dev/null @@ -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, - pub action: Option, - pub sub_elements: Vec, -} - -pub fn show(config: Config, elements: Vec) -> 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, -) { - 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 { - if value.contains("%") { - let value = value.replace("%", ""); - let value = value.trim(); - match value.parse::() { - Ok(n) => { - let result = ((n as f32 / 100.0) * base_value as f32) as i32; - Some(result) - } - Err(_) => None, - } - } else { - value.parse::().ok() - } -} diff --git a/src/config.rs b/src/lib/config.rs similarity index 70% rename from src/config.rs rename to src/lib/config.rs index b5c20f7..c98db63 100644 --- a/src/config.rs +++ b/src/lib/config.rs @@ -1,16 +1,41 @@ use crate::args::Args; +use crate::lib::system; use anyhow::anyhow; +use clap::ValueEnum; use gtk4::prelude::ToValue; use merge::Merge; use serde::{Deserialize, Serialize}; 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)] 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, - pub stylesheet: Option, - pub color: Option, - pub colors: Option, pub show: Option, pub mode: Option, #[serde(default = "default_width")] @@ -32,24 +57,46 @@ pub struct Config { pub password: Option, pub exec_search: Option, pub hide_scroll: Option, - pub matching: Option, + + /// Defines how matching is done + #[serde(default = "default_match_method")] + pub matching: Option, pub insensitive: Option, pub parse_search: Option, pub location: Option, pub no_actions: Option, pub lines: Option, + /// Defines how many columns are shown per row + #[serde(default = "default_columns")] pub columns: Option, pub sort_order: Option, pub gtk_dark: Option, pub search: Option, pub monitor: Option, pub pre_display_cmd: Option, - pub orientation: Option, - pub halign: Option, - pub content_halign: Option, - pub valign: Option, + /// Defines how the entries root container are ordered + /// Default is vertical + #[serde(default = "default_orientation")] + pub orientation: Option, + /// 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, + //// 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, + + /// 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, + pub filter_rate: Option, - pub image_size: Option, + /// Specifies the image size when enabled. + /// Defaults to 32. + #[serde(default = "default_image_size")] + pub image_size: Option, pub key_up: Option, pub key_down: Option, pub key_left: Option, @@ -73,19 +120,28 @@ pub struct Config { pub copy_exec: Option, pub single_click: Option, pub pre_display_exec: Option, + + // 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, + + /// Defines how the content in the row box is aligned + /// Defaults to vertical + #[serde(default = "default_row_box_orientation")] + pub row_bow_orientation: Option, } impl Default for Config { fn default() -> Self { Config { - style: None, - stylesheet: None, - color: None, - colors: None, + style: default_style(), show: None, mode: None, - width: None, - height: None, + width: default_width(), + height: default_height(), prompt: None, xoffset: None, x: None, @@ -105,18 +161,18 @@ impl Default for Config { location: None, no_actions: None, lines: None, - columns: None, + columns: default_columns(), sort_order: None, gtk_dark: None, search: None, monitor: None, pre_display_cmd: None, - orientation: None, - halign: None, - content_halign: None, + orientation: default_row_box_orientation(), + halign: default_halign(), + content_halign: default_content_halign(), valign: None, filter_rate: None, - image_size: None, + image_size: default_image_size(), key_up: None, key_down: None, key_left: None, @@ -139,10 +195,32 @@ impl Default for Config { copy_exec: None, single_click: 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 { + Some(Orientation::Horizontal) +} + +fn default_orientation() -> Option { + Some(Orientation::Vertical) +} + +fn default_halign() -> Option { + Some(Align::Fill) +} + +fn default_content_halign() -> Option { + Some(Align::Fill) +} + +fn default_columns() -> Option { + Some(1) +} + fn default_normal_window() -> Option { Some(false) } @@ -224,18 +302,44 @@ fn default_normal_window() -> Option { // key_default = "Ctrl-c"; // char* key_copy = (i == 0) ? key_default : config_get(config, "key_copy", key_default); -fn default_height() -> Option { +fn default_style() -> Option { + 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 { Some("40%".to_owned()) } -fn default_width() -> Option { +pub fn default_width() -> Option { Some("50%".to_owned()) } -fn default_password_char() -> Option { +pub fn default_password_char() -> Option { Some("*".to_owned()) } +pub fn default_fuzzy_min_length() -> Option { + Some(10) +} + +pub fn default_fuzzy_min_score() -> Option { + Some(0.1) +} + +pub fn default_match_method() -> Option { + Some(MatchMethod::Contains) +} + +pub fn default_image_size() -> Option { + Some(32) +} + pub fn merge_config_with_args(config: &mut Config, args: &Args) -> anyhow::Result { let args_json = serde_json::to_value(args)?; let mut config_json = serde_json::to_value(config)?; diff --git a/src/desktop/mod.rs b/src/lib/desktop.rs similarity index 95% rename from src/desktop/mod.rs rename to src/lib/desktop.rs index b712799..dae2a79 100644 --- a/src/desktop/mod.rs +++ b/src/lib/desktop.rs @@ -121,19 +121,14 @@ fn find_file_case_insensitive(folder: &Path, file_name: &Regex) -> Option Vec { let mut paths = vec![ PathBuf::from("/usr/share/applications"), PathBuf::from("/usr/local/share/applications"), + PathBuf::from("/var/lib/flatpak/exports/share/applications"), ]; if let Some(home) = home_dir() { @@ -166,7 +162,6 @@ pub(crate) fn find_desktop_files() -> Vec { p } - pub fn get_locale_variants() -> Vec { let locale = env::var("LC_ALL") .or_else(|_| env::var("LC_MESSAGES")) diff --git a/src/lib/gui.rs b/src/lib/gui.rs new file mode 100644 index 0000000..13789e0 --- /dev/null +++ b/src/lib/gui.rs @@ -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>>; +type MenuItemSender = Sender>; + +impl Into for config::Orientation { + fn into(self) -> Orientation { + match self { + config::Orientation::Vertical => Orientation::Vertical, + config::Orientation::Horizontal => Orientation::Horizontal, + } + } +} + +impl Into 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, + pub action: Option, + pub sub_elements: Vec, + pub working_dir: Option, + pub initial_sort_score: i64, + pub search_sort_score: f64, +} + +pub fn show(config: Config, elements: Vec) -> Result { + 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, + sender: Sender>, + 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>>, + 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>, +) -> 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, + 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::(); + 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 { + if value.contains("%") { + let value = value.replace("%", "").trim().to_string(); + match value.parse::() { + Ok(n) => Some(((n as f32 / 100.0) * base_value as f32) as i32), + Err(_) => None, + } + } else { + value.parse::().ok() + } +} + +pub fn initialize_sort_scores(items: &mut Vec) { + 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; + } + } +} diff --git a/src/lib/mod.rs b/src/lib/mod.rs new file mode 100644 index 0000000..cc32f75 --- /dev/null +++ b/src/lib/mod.rs @@ -0,0 +1,4 @@ +pub mod config; +pub mod desktop; +pub mod gui; +pub mod system; diff --git a/src/lib/system.rs b/src/lib/system.rs new file mode 100644 index 0000000..a1a4fcf --- /dev/null +++ b/src/lib/system.rs @@ -0,0 +1,31 @@ +use anyhow::anyhow; +use std::env; +use std::path::PathBuf; + +pub fn home_dir() -> Result { + env::var("HOME").map_err(|e| anyhow::anyhow!("$HOME not set: {e}")) +} + +pub fn conf_home() -> Result { + env::var("XDG_CONF_HOME").map_err(|e| anyhow::anyhow!("XDG_CONF_HOME not set: {e}")) +} + +pub fn config_path(config_path: Option) -> Result { + 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.")) +} diff --git a/src/main.rs b/src/main.rs index 745fbd6..fee407c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,16 @@ #![warn(clippy::pedantic)] #![allow(clippy::implicit_return)] +// todo resolve paths like ~/ + use crate::args::{Args, Mode}; -use crate::config::{Config, merge_config_with_args}; -use crate::desktop::{default_icon, find_desktop_files, get_locale_variants}; -use crate::gui::MenuItem; +use crate::lib::config::{Config, merge_config_with_args}; +use crate::lib::desktop::{default_icon, find_desktop_files, get_locale_variants}; +use crate::lib::gui; +use crate::lib::gui::MenuItem; +use anyhow::{Error, anyhow}; use clap::Parser; +use freedesktop_file_parser::{DesktopAction, EntryType}; use gdk4::prelude::Cast; use gtk4::prelude::{ ApplicationExt, ApplicationExtManual, BoxExt, ButtonExt, EditableExt, EntryExt, @@ -22,51 +27,40 @@ use std::process::{Command, Stdio}; use std::sync::Arc; use std::thread::sleep; use std::{env, fs, time}; -use freedesktop_file_parser::{DesktopAction, EntryType}; mod args; -mod config; -mod desktop; -mod gui; +mod lib; fn main() -> anyhow::Result<()> { gtk4::init()?; env_logger::Builder::new() - // todo change to info as default - .parse_filters(&std::env::var("RUST_LOG").unwrap_or_else(|_| "debug".to_owned())) + // todo change to error as default + .parse_filters(&env::var("RUST_LOG").unwrap_or_else(|_| "debug".to_owned())) .init(); let args = Args::parse(); - let home_dir = std::env::var("HOME")?; + let home_dir = env::var("HOME")?; let config_path = args .config .as_ref() .map(|c| PathBuf::from(c)) .unwrap_or_else(|| { - std::env::var("XDG_CONF_HOME") + env::var("XDG_CONF_HOME") .map_or( PathBuf::from(home_dir.clone()).join(".config"), |xdg_conf_home| PathBuf::from(&xdg_conf_home), ) - .join("wofi") // todo change to worf + .join("worf") .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( PathBuf::from(home_dir.clone()).join(".cache"), |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 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(); 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) - // 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( &locale_variants, &file.entry.name.variants, @@ -115,14 +113,26 @@ fn drun(mut config: Config) -> anyhow::Result<()> { debug!("Skipping desktop entry without name {file:?}") } - let icon = file.entry.icon.as_ref().map(|s| s.content.clone()); - debug!("file, name={name:?}, icon={icon:?}"); + let icon = file + .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 { label: name.unwrap(), icon_path: icon.clone(), - action: None, + action, sub_elements: Vec::default(), + working_dir: working_dir.clone(), + initial_sort_score: 0, + search_sort_score: sort_score, }; file.actions.iter().for_each(|(_, action)| { @@ -131,95 +141,83 @@ fn drun(mut config: Config) -> anyhow::Result<()> { &action.name.variants, &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:?}"); let sub_entry = MenuItem { label: action_name.unwrap().trim().to_owned(), icon_path: action_icon, - action: None, + action: action.exec.clone(), sub_elements: Vec::default(), + working_dir: working_dir.clone(), + initial_sort_score: 0, + search_sort_score: 0.0, }; entry.sub_elements.push(sub_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)); - if config.prompt.is_none() { - config.prompt = Some("drun".to_owned()); - } + gui::initialize_sort_scores(&mut entries); // todo ues a arc instead of cloning the config - let selected_index = gui::show(config.clone(), entries.clone())?; - entries.get(selected_index as usize).map(|e| { - e.action.as_ref().map(|a| { - spawn_fork(&a); - }) - }); + let selection_result = gui::show(config.clone(), entries.clone()); + match selection_result { + Ok(selected_item) => { + if let Some(action) = selected_item.action { + spawn_fork(&action, &selected_item.working_dir)? + } + } + Err(e) => { + log::error!("{e}"); + } + } Ok(()) } -fn spawn_fork(cmd: &str) { - // todo fork this for real +fn spawn_fork(cmd: &str, working_dir: &Option) -> anyhow::Result<()> { // todo probably remove arguments? + // todo support working dir + // todo fix actions + // todo graphical disk map icon not working // Unix-like systems (Linux, macOS) - let _ = Command::new(cmd) - .stdin(Stdio::null()) // Disconnect stdin - .stdout(Stdio::null()) // Disconnect stdout - .stderr(Stdio::null()) // Disconnect stderr - .spawn(); - sleep(time::Duration::from_secs(30)); + + let parts = cmd.split(' ').collect::>(); + if parts.is_empty() { + return Err(anyhow!("empty command passed")); + } + + 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<()> { diff --git a/styles/compact.css b/styles/compact.css new file mode 100644 index 0000000..a87c642 --- /dev/null +++ b/styles/compact.css @@ -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; +} diff --git a/styles/launcher.css b/styles/launcher.css new file mode 100644 index 0000000..d4c075e --- /dev/null +++ b/styles/launcher.css @@ -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; +}