From dc7c47b0a2a9fe666f251fa4fd22c1655e159c75 Mon Sep 17 00:00:00 2001 From: Alexander Mohr Date: Sat, 19 Apr 2025 19:52:01 +0200 Subject: [PATCH 01/12] filter drun menu better and make some commands actually run --- Cargo.lock | 54 +++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + README.md | 8 +++++--- src/lib/mode.rs | 32 +++++++++++++++++++---------- src/main.rs | 2 +- 5 files changed, 82 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8cef9e4..ba645a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -391,6 +391,12 @@ dependencies = [ "regex", ] +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "env_logger" version = "0.11.8" @@ -410,6 +416,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "field-offset" version = "0.3.6" @@ -970,6 +986,12 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + [[package]] name = "log" version = "0.4.27" @@ -1259,6 +1281,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" +dependencies = [ + "bitflags 2.9.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + [[package]] name = "ryu" version = "1.0.20" @@ -1534,6 +1569,18 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1656,6 +1703,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "worf" version = "0.1.0" @@ -1679,6 +1732,7 @@ dependencies = [ "strsim 0.11.1", "thiserror", "toml", + "which", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 87f2c10..15ebdf3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,3 +41,4 @@ libc = "0.2.171" freedesktop-file-parser = "0.1.3" strsim = "0.11.1" dirs = "6.0.0" +which = "7.0.3" diff --git a/README.md b/README.md index df84cbf..e465362 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,12 @@ layerrule = blur, worf 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 * Color files are not supported -* `mode` dropped, use show -* `D` argument dropped. Arguments are the same as config in worf, no need to have have this flag. -## Dropped configuration options +## Dropped arguments +* `mode`, use show +* `D`, arguments are the same as config in worf, no need to have have this flag. + +### Dropped configuration options * stylesheet -> use style instead * color / colors -> GTK4 does not support color files diff --git a/src/lib/mode.rs b/src/lib/mode.rs index 798f50e..fadab7a 100644 --- a/src/lib/mode.rs +++ b/src/lib/mode.rs @@ -42,17 +42,30 @@ pub fn d_run(config: &Config) -> anyhow::Result<()> { f.entry.hidden.is_none_or(|hidden| !hidden) && f.entry.no_display.is_none_or(|no_display| !no_display) }) { - let (action, working_dir) = match &file.entry.entry_type { - EntryType::Application(app) => (app.exec.clone(), app.path.clone()), - _ => (None, None), - }; - let Some(name) = lookup_name_with_locale( &locale_variants, &file.entry.name.variants, &file.entry.name.default, ) else { - log::debug!("Skipping desktop entry without name {file:?}"); + log::warn!("Skipping desktop entry without name {file:?}"); + continue; + }; + + let (action, working_dir) = match &file.entry.entry_type { + EntryType::Application(app) => (app.exec.clone(), app.path.clone()), + _ => (None, None), + }; + + let cmd_exists = action.as_ref().map(|a| { + a.split(' ') + .next() + .map(|cmd| cmd.replace("\"", "")) + .map(|cmd| { + PathBuf::from(&cmd).exists() || which::which(&cmd).is_ok() + })}).flatten().unwrap_or(false); + + if !cmd_exists { + log::warn!("Skipping desktop entry for {name:?} because action {action:?} does not exist"); continue; }; @@ -174,11 +187,8 @@ fn create_file_if_not_exists(path: &PathBuf) -> anyhow::Result<()> { } fn spawn_fork(cmd: &str, working_dir: Option<&String>) -> anyhow::Result<()> { - // todo probably remove arguments? - // todo support working dir - // todo fix actions + // todo fix actions ?? // todo graphical disk map icon not working - // Unix-like systems (Linux, macOS) let parts = cmd.split(' ').collect::>(); if parts.is_empty() { @@ -189,7 +199,7 @@ fn spawn_fork(cmd: &str, working_dir: Option<&String>) -> anyhow::Result<()> { env::set_current_dir(dir)?; } - let exec = parts[0]; + let exec = parts[0].replace("\"", ""); let args: Vec<_> = parts .iter() .skip(1) diff --git a/src/main.rs b/src/main.rs index 418f3f0..8be9da2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ fn main() -> anyhow::Result<()> { env_logger::Builder::new() // todo change to error as default - .parse_filters(&env::var("RUST_LOG").unwrap_or_else(|_| "debug".to_owned())) + .parse_filters(&env::var("RUST_LOG").unwrap_or_else(|_| "error".to_owned())) .init(); let args = config::parse_args(); From 8e6634c73bd9431bdc10e3bb3abfe6a024f2860e Mon Sep 17 00:00:00 2001 From: Alexander Mohr Date: Sun, 20 Apr 2025 01:37:07 +0200 Subject: [PATCH 02/12] add window open/close animations --- README.md | 2 + src/lib/config.rs | 65 +++++++++++ src/lib/gui.rs | 290 +++++++++++++++++++++++++++++++++++++++++----- src/lib/mode.rs | 47 +++++--- 4 files changed, 356 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index e465362..750f593 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ layerrule = blur, worf ### New config / command line options * fuzzy-length: Defines how long a string must be to be considered for fuzzy match * row-box-orientation: Allows aligning values vertically to place the label below the icon +* text wrapping +* configurable animations ### New Styling options * `label`: Allows styling the label diff --git a/src/lib/config.rs b/src/lib/config.rs index 7c903a2..1a40367 100644 --- a/src/lib/config.rs +++ b/src/lib/config.rs @@ -28,6 +28,14 @@ pub enum Align { Center, } +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug, Serialize, Deserialize)] +pub enum Animation { + None, + Expand, + ExpandVertical, + ExpandHorizontal, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub enum Mode { /// searches `$PATH` for executables and allows them to be run by selecting them. @@ -248,6 +256,30 @@ pub struct Config { #[serde(default = "default_text_wrap_length")] #[clap(long = "text-wrap-length")] pub text_wrap_length: Option, + + /// Defines the animation when the window is show. + /// Defaults to Expand + #[serde(default = "default_show_animation")] + #[clap(long = "show-animation")] + pub show_animation: Option, + + /// Defines how long it takes for the show animation to finish + /// Defaults to 70ms + #[serde(default = "default_show_animation_time")] + #[clap(long = "show-animation-time")] + pub show_animation_time: Option, + + /// Defines the animation when the window is hidden. + /// Defaults to Expand + #[serde(default = "default_hide_animation")] + #[clap(long = "hide-animation")] + pub hide_animation: Option, + + /// Defines how long it takes for the hide animation to finish + /// Defaults to 100ms + #[serde(default = "default_hide_animation_time")] + #[clap(long = "hide-animation-time")] + pub hide_animation_time: Option, } impl Default for Config { @@ -318,9 +350,42 @@ impl Default for Config { row_bow_orientation: default_row_box_orientation(), text_wrap: default_text_wrap(), text_wrap_length: default_text_wrap_length(), + show_animation: default_show_animation(), + show_animation_time: default_show_animation_time(), + hide_animation: default_hide_animation(), + hide_animation_time: default_hide_animation_time(), } } } + +// allowed because option is needed for serde macro +#[allow(clippy::unnecessary_wraps)] +#[must_use] +pub fn default_show_animation_time() -> Option { + Some(70) +} + +// allowed because option is needed for serde macro +#[allow(clippy::unnecessary_wraps)] +#[must_use] +pub fn default_show_animation() -> Option { + Some(Animation::Expand) +} + +// allowed because option is needed for serde macro +#[allow(clippy::unnecessary_wraps)] +#[must_use] +pub fn default_hide_animation_time() -> Option { + Some(100) +} + +// allowed because option is needed for serde macro +#[allow(clippy::unnecessary_wraps)] +#[must_use] +pub fn default_hide_animation() -> Option { + Some(Animation::Expand) +} + // allowed because option is needed for serde macro #[allow(clippy::unnecessary_wraps)] #[must_use] diff --git a/src/lib/gui.rs b/src/lib/gui.rs index e48b119..629a5cd 100644 --- a/src/lib/gui.rs +++ b/src/lib/gui.rs @@ -1,13 +1,15 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; +use std::time::Duration; use anyhow::anyhow; use crossbeam::channel; use crossbeam::channel::Sender; use gdk4::gio::File; -use gdk4::glib::Propagation; +use gdk4::glib::{Propagation, timeout_add_local}; use gdk4::prelude::{Cast, DisplayExt, MonitorExt}; use gdk4::{Display, Key}; +use gtk4::glib::ControlFlow; use gtk4::prelude::{ ApplicationExt, ApplicationExtManual, BoxExt, EditableExt, FlowBoxChildExt, GestureSingleExt, GtkWindowExt, ListBoxRowExt, NativeExt, WidgetExt, @@ -21,7 +23,7 @@ use gtk4_layer_shell::{KeyboardMode, LayerShell}; use log; use crate::config; -use crate::config::{Config, MatchMethod}; +use crate::config::{Animation, Config, MatchMethod}; type ArcMenuMap = Arc>>>; type MenuItemSender = Sender, anyhow::Error>>; @@ -103,8 +105,8 @@ fn build_ui( .application(app) .decorated(false) .resizable(false) - .default_width(20) - .default_height(20) + .default_width(0) + .default_height(0) .build(); window.set_widget_name("window"); @@ -120,8 +122,6 @@ fn build_ui( 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"]); @@ -167,7 +167,7 @@ fn build_ui( .lock() .unwrap() // panic here ok? deadlock? .insert( - add_menu_item(&inner_box, entry, config, sender, &list_items, app), + add_menu_item(&inner_box, entry, config, sender, &list_items, app, &window), entry.clone(), ); } @@ -195,24 +195,9 @@ fn build_ui( config.clone(), ); + window.set_child(Widget::NONE); window.show(); - - let display = window.display(); - if let Some(surface) = window.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"); - } - } + animate_window_show(config.clone(), window.clone(), outer_box); } fn setup_key_event_handler( @@ -226,16 +211,24 @@ fn setup_key_event_handler( ) { let key_controller = EventControllerKey::new(); + let window_clone = window.clone(); 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(); + close_gui(app.clone(), window_clone.clone(), &config); } Key::Return => { - if let Err(e) = handle_selected_item(&sender, &app, &inner_box, &list_items) { + if let Err(e) = handle_selected_item( + &sender, + app.clone(), + window_clone.clone(), + &config, + &inner_box, + &list_items, + ) { log::error!("{e}"); } } @@ -292,9 +285,235 @@ fn sort_menu_items( } } +fn animate_window_show(config: Config, window: ApplicationWindow, outer_box: gtk4::Box) { + let display = window.display(); + if let Some(surface) = window.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(); + let Some(target_width) = percent_or_absolute(&config.width.unwrap(), geometry.width()) + else { + return; + }; + + let Some(target_height) = + percent_or_absolute(&config.height.unwrap(), geometry.height()) + else { + return; + }; + + animate_window( + window.clone(), + config.show_animation.unwrap(), + config.show_animation_time.unwrap(), + target_height, + target_width, + move || { + window.set_child(Some(&outer_box)); + }, + ); + } + } +} +fn animate_window_close(config: &Config, window: ApplicationWindow, on_done_func: Func) +where + Func: Fn() + 'static, +{ + // todo the target size might not work for higher dpi displays or bigger resolutions + window.set_child(Widget::NONE); + + let (target_h, target_w) = { + if let Some(animation) = config.hide_animation { + let allocation = window.allocation(); + match animation { + Animation::None | Animation::Expand => (10, 10), + Animation::ExpandVertical => (allocation.height(), 0), + Animation::ExpandHorizontal => (0, allocation.width()), + } + } else { + (0, 0) + } + }; + + animate_window( + window, + config.hide_animation.unwrap(), + config.hide_animation_time.unwrap(), + target_h, + target_w, + on_done_func, + ); +} + +// both warnings are disabled because +// we can deal with truncation and precission loss +#[allow(clippy::cast_possible_truncation)] +#[allow(clippy::cast_precision_loss)] +fn animate_window( + window: ApplicationWindow, + animation_type: Animation, + animation_time: u64, + target_height: i32, + target_width: i32, + on_done_func: Func, +) where + Func: Fn() + 'static, +{ + let allocation = window.allocation(); + + let animation_step_length = Duration::from_millis(10); // ~60 FPS + let animation_speed = Duration::from_millis(animation_time); + + let animation_steps = + ((animation_speed.as_millis() / animation_step_length.as_millis()) as f32).max(1.0); + + let width = allocation.width(); + let height = allocation.height(); + + // Calculate signed steps (can be negative) + let mut width_step = ((target_width as f32 - width as f32) / animation_steps).round() as i32; + let mut height_step = ((target_height as f32 - height as f32) / animation_steps).round() as i32; + + // Ensure we move at least 1 pixel per step in the correct direction + if width_step == 0 && target_width != width { + width_step = if target_width < width { -1 } else { 1 }; + } + if height_step == 0 && target_height != height { + height_step = if target_height < height { -1 } else { 1 }; + } + + timeout_add_local(animation_step_length, move || { + let result = match animation_type { + Animation::None => animation_none(&window, target_width, target_height), + Animation::Expand => animation_expand( + &window, + target_width, + target_height, + width_step, + height_step, + ), + Animation::ExpandVertical => { + animation_expand_vertical(&window, target_width, target_height, width_step) + } + Animation::ExpandHorizontal => { + animation_expand_horizontal(&window, target_width, target_height, height_step) + } + }; + + window.queue_draw(); + + if result == ControlFlow::Break { + on_done_func(); + } + result + }); +} + +fn animation_none( + window: &ApplicationWindow, + target_width: i32, + target_height: i32, +) -> ControlFlow { + window.set_height_request(target_height); + window.set_width_request(target_width); + ControlFlow::Break +} + +fn animation_expand( + window: &ApplicationWindow, + target_width: i32, + target_height: i32, + width_step: i32, + height_step: i32, +) -> ControlFlow { + let allocation = window.allocation(); + let mut done = true; + let height = allocation.height(); + let width = allocation.width(); + + if resize_height_needed(window, target_height, height_step, height) { + window.set_height_request(height + height_step); + done = false; + } + + if resize_width_needed(window, target_width, width_step, width) { + window.set_width_request(width + width_step); + done = false; + } + + if done { + ControlFlow::Break + } else { + ControlFlow::Continue + } +} + +fn animation_expand_horizontal( + window: &ApplicationWindow, + target_width: i32, + target_height: i32, + height_step: i32, +) -> ControlFlow { + let allocation = window.allocation(); + let height = allocation.height(); + window.set_width_request(target_width); + + if resize_height_needed(window, target_height, height_step, height) { + window.set_height_request(height + height_step); + ControlFlow::Continue + } else { + ControlFlow::Break + } +} + +fn animation_expand_vertical( + window: &ApplicationWindow, + target_width: i32, + target_height: i32, + width_step: i32, +) -> ControlFlow { + let allocation = window.allocation(); + let width = allocation.width(); + window.set_height_request(target_height); + + if resize_width_needed(window, target_width, width_step, width) { + window.set_width_request(allocation.width() + width_step); + ControlFlow::Continue + } else { + ControlFlow::Break + } +} + +fn resize_height_needed( + window: &ApplicationWindow, + target_height: i32, + height_step: i32, + current_height: i32, +) -> bool { + (height_step > 0 && window.height() < target_height) + || (height_step < 0 && window.height() > target_height && current_height + height_step > 0) +} + +fn resize_width_needed( + window: &ApplicationWindow, + target_width: i32, + width_step: i32, + current_width: i32, +) -> bool { + (width_step > 0 && window.width() < target_width) + || (width_step < 0 && window.width() > target_width && current_width + width_step > 0) +} + +fn close_gui(app: Application, window: ApplicationWindow, config: &Config) { + animate_window_close(config, window, move || app.quit()); +} + fn handle_selected_item( sender: &MenuItemSender, - app: &Application, + app: Application, + window: ApplicationWindow, + config: &Config, inner_box: &FlowBox, lock_arc: &ArcMenuMap, ) -> Result<(), String> @@ -309,7 +528,7 @@ where log::error!("failed to send message {e}"); } } - app.quit(); + close_gui(app, window, config); return Ok(()); } Err("selected item cannot be resolved".to_owned()) @@ -322,6 +541,7 @@ fn add_menu_item( sender: &MenuItemSender, lock_arc: &ArcMenuMap, app: &Application, + window: &ApplicationWindow, ) -> FlowBoxChild { let parent: Widget = if entry_element.sub_elements.is_empty() { create_menu_row( @@ -330,6 +550,7 @@ fn add_menu_item( Arc::>>>::clone(lock_arc), sender.clone(), app.clone(), + window.clone(), inner_box.clone(), ) .upcast() @@ -345,6 +566,7 @@ fn add_menu_item( Arc::>>>::clone(lock_arc), sender.clone(), app.clone(), + window.clone(), inner_box.clone(), ); expander.set_label_widget(Some(&menu_row)); @@ -360,6 +582,7 @@ fn add_menu_item( Arc::>>>::clone(lock_arc), sender.clone(), app.clone(), + window.clone(), inner_box.clone(), ); sub_row.set_hexpand(true); @@ -392,6 +615,7 @@ fn create_menu_row( lock_arc: ArcMenuMap, sender: MenuItemSender, app: Application, + window: ApplicationWindow, inner_box: FlowBox, ) -> Widget { let row = ListBoxRow::new(); @@ -401,9 +625,17 @@ fn create_menu_row( let click = GestureClick::new(); click.set_button(gdk::BUTTON_PRIMARY); + let config_clone = config.clone(); 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) { + if let Err(e) = handle_selected_item( + &sender, + app.clone(), + window.clone(), + &config_clone, + &inner_box, + &lock_arc, + ) { log::error!("{e}"); } } diff --git a/src/lib/mode.rs b/src/lib/mode.rs index fadab7a..27ec831 100644 --- a/src/lib/mode.rs +++ b/src/lib/mode.rs @@ -26,16 +26,7 @@ pub fn d_run(config: &Config) -> anyhow::Result<()> { let locale_variants = get_locale_variants(); let default_icon = default_icon().unwrap_or_default(); - let cache_path = dirs::cache_dir().map(|x| x.join("worf-drun")); - let mut d_run_cache = { - if let Some(ref cache_path) = cache_path { - if let Err(e) = create_file_if_not_exists(cache_path) { - log::warn!("No drun cache file and cannot create: {e:?}"); - } - } - - load_cache_file(cache_path.as_ref()).unwrap_or_default() - }; + let (cache_path, mut d_run_cache) = load_d_run_cache(); let mut entries: Vec> = Vec::new(); for file in find_desktop_files().ok().iter().flatten().filter(|f| { @@ -56,16 +47,20 @@ pub fn d_run(config: &Config) -> anyhow::Result<()> { _ => (None, None), }; - let cmd_exists = action.as_ref().map(|a| { - a.split(' ') - .next() - .map(|cmd| cmd.replace("\"", "")) - .map(|cmd| { - PathBuf::from(&cmd).exists() || which::which(&cmd).is_ok() - })}).flatten().unwrap_or(false); + let cmd_exists = action + .as_ref() + .and_then(|a| { + a.split(' ') + .next() + .map(|cmd| cmd.replace('"', "")) + .map(|cmd| PathBuf::from(&cmd).exists() || which::which(&cmd).is_ok()) + }) + .unwrap_or(false); if !cmd_exists { - log::warn!("Skipping desktop entry for {name:?} because action {action:?} does not exist"); + log::warn!( + "Skipping desktop entry for {name:?} because action {action:?} does not exist" + ); continue; }; @@ -145,6 +140,20 @@ pub fn d_run(config: &Config) -> anyhow::Result<()> { Ok(()) } +fn load_d_run_cache() -> (Option, HashMap) { + let cache_path = dirs::cache_dir().map(|x| x.join("worf-drun")); + let d_run_cache = { + if let Some(ref cache_path) = cache_path { + if let Err(e) = create_file_if_not_exists(cache_path) { + log::warn!("No drun cache file and cannot create: {e:?}"); + } + } + + load_cache_file(cache_path.as_ref()).unwrap_or_default() + }; + (cache_path, d_run_cache) +} + fn save_cache_file(path: &PathBuf, data: &HashMap) -> anyhow::Result<()> { // Convert the HashMap to TOML string let toml_string = toml::ser::to_string(&data).map_err(|e| anyhow::anyhow!(e))?; @@ -199,7 +208,7 @@ fn spawn_fork(cmd: &str, working_dir: Option<&String>) -> anyhow::Result<()> { env::set_current_dir(dir)?; } - let exec = parts[0].replace("\"", ""); + let exec = parts[0].replace('"', ""); let args: Vec<_> = parts .iter() .skip(1) From c46e064195f38218bd768f05f4c3890e2005a9c7 Mon Sep 17 00:00:00 2001 From: Alexander Mohr Date: Sun, 20 Apr 2025 13:11:59 +0200 Subject: [PATCH 03/12] fix search and select --- src/lib/config.rs | 5 +++-- src/lib/gui.rs | 41 +++++++++++++++++++++++++++++------------ 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/lib/config.rs b/src/lib/config.rs index 1a40367..c15d618 100644 --- a/src/lib/config.rs +++ b/src/lib/config.rs @@ -270,7 +270,8 @@ pub struct Config { pub show_animation_time: Option, /// Defines the animation when the window is hidden. - /// Defaults to Expand + /// Defaults to None, because it is a bit buggy with + /// gtk layer shell. works fine with normal window though #[serde(default = "default_hide_animation")] #[clap(long = "hide-animation")] pub hide_animation: Option, @@ -383,7 +384,7 @@ pub fn default_hide_animation_time() -> Option { #[allow(clippy::unnecessary_wraps)] #[must_use] pub fn default_hide_animation() -> Option { - Some(Animation::Expand) + Some(Animation::None) } // allowed because option is needed for serde macro diff --git a/src/lib/gui.rs b/src/lib/gui.rs index 629a5cd..984c635 100644 --- a/src/lib/gui.rs +++ b/src/lib/gui.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::ops::DerefMut; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -172,13 +173,16 @@ fn build_ui( ); } - let items_clone = Arc::>>>::clone(&list_items); - inner_box.set_sort_func(move |child1, child2| sort_menu_items(child1, child2, &items_clone)); + let items_sort = Arc::>>>::clone(&list_items); + inner_box.set_sort_func(move |child1, child2| sort_menu_items(child1, child2, &items_sort)); - // Set focus after everything is realized - inner_box.connect_map(|fb| { + let items_focus = Arc::>>>::clone(&list_items); + inner_box.connect_map(move |fb| { fb.grab_focus(); fb.invalidate_sort(); + + let mut item_lock = items_focus.lock().unwrap(); + select_first_visible_child(item_lock.deref_mut(), fb) }); let wrapper_box = gtk4::Box::new(Orientation::Vertical, 0); @@ -265,6 +269,13 @@ fn sort_menu_items( let m1 = lock.get(child1); let m2 = lock.get(child2); + if !child1.is_visible() { + return Ordering::Smaller; + } + if !child2.is_visible() { + return Ordering::Larger; + } + match (m1, m2) { (Some(menu1), Some(menu2)) => { if menu1.search_sort_score != 0.0 || menu2.search_sort_score != 0.0 { @@ -704,7 +715,6 @@ fn filter_widgets( } let query = query.to_owned().to_lowercase(); - let mut fb: Option<&FlowBoxChild> = None; for (flowbox_child, menu_item) in items.iter_mut() { let menu_item_search = format!( "{} {}", @@ -745,16 +755,23 @@ fn filter_widgets( }; menu_item.search_sort_score = search_sort_score; - if visible { - 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(); + select_first_visible_child(items, inner_box); +} + +fn select_first_visible_child( + items: &mut HashMap>, + inner_box: &FlowBox, +) { + for i in 0..items.len() { + if let Some(child) = inner_box.child_at_index(i as i32) { + if child.is_visible() { + inner_box.select_child(&child); + break; + } + } } } From 8b974a4e4b9b4ec1a9d3bd8c0916a7436579021e Mon Sep 17 00:00:00 2001 From: Alexander Mohr Date: Sun, 20 Apr 2025 14:03:05 +0200 Subject: [PATCH 04/12] re-add support for default prompt --- src/lib/config.rs | 16 +++++++++++++++- src/lib/gui.rs | 6 +++--- styles/launcher/style.css | 16 ++-------------- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/lib/config.rs b/src/lib/config.rs index c15d618..b7d5e6c 100644 --- a/src/lib/config.rs +++ b/src/lib/config.rs @@ -658,7 +658,21 @@ pub fn load_config(args_opt: Option) -> Result { let mut config: Config = toml::from_str(&toml_content)?; if let Some(args) = args_opt { - let merge_result = merge_config_with_args(&mut config, &args)?; + let mut merge_result = merge_config_with_args(&mut config, &args)?; + + if merge_result.prompt.is_none() { + match &merge_result.show { + None => {} + Some(mode) => { + match mode { + Mode::Run => merge_result.prompt = Some("run".to_owned()), + Mode::Drun => merge_result.prompt = Some("drun".to_owned()), + Mode::Dmenu => merge_result.prompt = Some("dmenu".to_owned()), + } + } + } + } + Ok(merge_result) } else { Ok(config) diff --git a/src/lib/gui.rs b/src/lib/gui.rs index 984c635..245ca07 100644 --- a/src/lib/gui.rs +++ b/src/lib/gui.rs @@ -1,5 +1,4 @@ use std::collections::HashMap; -use std::ops::DerefMut; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -182,7 +181,7 @@ fn build_ui( fb.invalidate_sort(); let mut item_lock = items_focus.lock().unwrap(); - select_first_visible_child(item_lock.deref_mut(), fb) + select_first_visible_child(&mut *item_lock, fb); }); let wrapper_box = gtk4::Box::new(Orientation::Vertical, 0); @@ -766,7 +765,8 @@ fn select_first_visible_child( inner_box: &FlowBox, ) { for i in 0..items.len() { - if let Some(child) = inner_box.child_at_index(i as i32) { + let i_32 = i.try_into().unwrap_or(i32::MAX); + if let Some(child) = inner_box.child_at_index(i_32) { if child.is_visible() { inner_box.select_child(&child); break; diff --git a/styles/launcher/style.css b/styles/launcher/style.css index 141b141..435aa23 100644 --- a/styles/launcher/style.css +++ b/styles/launcher/style.css @@ -5,7 +5,7 @@ #window { all: unset; background-color: rgba(33, 33, 33, 0.8); /* Matches #212121BB */ - border-radius: 0px; + border-radius: 0; } #window #outer-box { @@ -31,14 +31,6 @@ 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); @@ -49,7 +41,6 @@ } #window #outer-box #scroll #inner-box #entry #img { - width: 1rem; margin-right: 0.5rem; } @@ -63,15 +54,12 @@ } #row:hover { - background-color: rgba(255, 255, 255, 0);; + 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; } #label { From 943ace5a1db19052f1f32a3f1e2d63454243d699 Mon Sep 17 00:00:00 2001 From: Alexander Mohr Date: Sun, 20 Apr 2025 14:32:41 +0200 Subject: [PATCH 05/12] add fullscreen config --- src/lib/gui.rs | 6 +++ styles/fullscreen/config.toml | 8 ++++ styles/fullscreen/style.css | 70 +++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 styles/fullscreen/config.toml create mode 100644 styles/fullscreen/style.css diff --git a/src/lib/gui.rs b/src/lib/gui.rs index 245ca07..f746d4e 100644 --- a/src/lib/gui.rs +++ b/src/lib/gui.rs @@ -453,6 +453,8 @@ fn animation_expand( } if done { + window.set_height_request(target_height); + window.set_width_request(target_width); ControlFlow::Break } else { ControlFlow::Continue @@ -473,6 +475,8 @@ fn animation_expand_horizontal( window.set_height_request(height + height_step); ControlFlow::Continue } else { + window.set_height_request(target_height); + window.set_width_request(target_width); ControlFlow::Break } } @@ -491,6 +495,8 @@ fn animation_expand_vertical( window.set_width_request(allocation.width() + width_step); ControlFlow::Continue } else { + window.set_height_request(target_height); + window.set_width_request(target_width); ControlFlow::Break } } diff --git a/styles/fullscreen/config.toml b/styles/fullscreen/config.toml new file mode 100644 index 0000000..77c56d8 --- /dev/null +++ b/styles/fullscreen/config.toml @@ -0,0 +1,8 @@ +image_size=64 +columns=6 +orientation="Vertical" +row_bow_orientation="Vertical" +content_halign="Center" +height="110%" +width="100%" +valign="Start" diff --git a/styles/fullscreen/style.css b/styles/fullscreen/style.css new file mode 100644 index 0000000..d6288ac --- /dev/null +++ b/styles/fullscreen/style.css @@ -0,0 +1,70 @@ +* { + font-family: DejaVu; +} + +#window { + all: unset; + background-color: rgba(33, 33, 33, 0.76); /* Matches #212121BB */ + border-radius: 0; + padding-top: 5rem; +} + +#window #outer-box { +} + +#window #outer-box #input { + background-color: rgba(32, 32, 32, 0.6); + color: #f2f2f2; + border-bottom: 2px solid rgba(214, 174, 0, 1); + font-size: 1rem; + margin: 1rem 40rem; +} + +#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 #inner-box { + padding: 0 25rem; + +} + +#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; + border-bottom: 5px solid rgba(32, 32, 32, 0.1); + +} +#window #outer-box #scroll #inner-box #entry #img { + margin-right: 0.5rem; +} + +#window #outer-box #scroll #inner-box #entry:selected { + color: #fff; + background-color: rgba(255, 255, 255, 0.1); + outline: none; + border-bottom: 5px solid rgba(214, 174, 0, 1); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +#row:hover { + background-color: rgba(255, 255, 255, 0); + outline: inherit; +} +#window #outer-box #scroll #inner-box #entry:hover { + background-color: rgba(255, 255, 255, 0.1); + outline: inherit; +} + +#label { + margin-top: 1rem; + margin-bottom: 0; +} From 97aed9e8efbdb56fd45aeab5d9eee7ea1ede798f Mon Sep 17 00:00:00 2001 From: Alexander Mohr Date: Mon, 21 Apr 2025 02:08:38 +0200 Subject: [PATCH 06/12] add auto mode --- Cargo.lock | 23 ++ Cargo.toml | 1 + src/lib/config.rs | 102 ++++--- src/lib/desktop.rs | 142 +++++----- src/lib/gui.rs | 418 +++++++++++++++++++--------- src/lib/mode.rs | 506 +++++++++++++++++++++++++++------- src/main.rs | 14 +- styles/dmenu/config.toml | 8 + styles/dmenu/style.css | 66 +++++ styles/fullscreen/config.toml | 2 +- styles/fullscreen/style.css | 4 +- styles/relaxed/config.toml | 5 + styles/relaxed/style.css | 68 +++++ 13 files changed, 1004 insertions(+), 355 deletions(-) create mode 100644 styles/dmenu/config.toml create mode 100644 styles/dmenu/style.css create mode 100644 styles/relaxed/config.toml create mode 100644 styles/relaxed/style.css diff --git a/Cargo.lock b/Cargo.lock index ba645a0..7cabb2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -436,6 +436,12 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "freedesktop-file-parser" version = "0.1.3" @@ -1013,6 +1019,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "meval" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79496a5651c8d57cd033c5add8ca7ee4e3d5f7587a4777484640d9cb60392d9" +dependencies = [ + "fnv", + "nom", +] + [[package]] name = "miniz_oxide" version = "0.8.7" @@ -1033,6 +1049,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "nom" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5b8c256fd9471521bcb84c3cdba98921497f1a331cbc15b8030fc63b82050ce" + [[package]] name = "num-traits" version = "0.2.19" @@ -1726,6 +1748,7 @@ dependencies = [ "hyprland", "libc", "log", + "meval", "regex", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 15ebdf3..42dfd2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,3 +42,4 @@ freedesktop-file-parser = "0.1.3" strsim = "0.11.1" dirs = "6.0.0" which = "7.0.3" +meval = "0.2.0" diff --git a/src/lib/config.rs b/src/lib/config.rs index b7d5e6c..c77310c 100644 --- a/src/lib/config.rs +++ b/src/lib/config.rs @@ -1,13 +1,28 @@ use std::path::PathBuf; use std::str::FromStr; -use std::{env, fs}; +use std::{env, fmt, fs}; -use anyhow::anyhow; +use anyhow::{Error, anyhow}; use clap::{Parser, ValueEnum}; use serde::{Deserialize, Serialize}; use serde_json::Value; use thiserror::Error; +#[derive(Debug)] +pub enum ConfigurationError { + Open(String), + Parse(String), +} + +impl fmt::Display for ConfigurationError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ConfigurationError::Open(e) => write!(f, "{e}"), + ConfigurationError::Parse(e) => write!(f, "{e}"), + } + } +} + #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug, Serialize, Deserialize)] pub enum MatchMethod { Fuzzy, @@ -46,6 +61,9 @@ pub enum Mode { /// reads from stdin and displays options which when selected will be output to stdout. Dmenu, + + /// tries to determine automatically what to do + Auto, } #[derive(Debug, Error)] @@ -62,6 +80,7 @@ impl FromStr for Mode { "run" => Ok(Mode::Run), "drun" => Ok(Mode::Drun), "dmenu" => Ok(Mode::Dmenu), + "auto" => Ok(Mode::Auto), _ => Err(ArgsError::InvalidParameter( format!("{s} is not a valid argument show this, see help for details").to_owned(), )), @@ -598,6 +617,17 @@ pub fn style_path(full_path: Option) -> Result { resolve_path(full_path, alternative_paths.into_iter().collect()) } +/// # Errors +/// +/// Will return Err when it cannot resolve any path or no style is found +pub fn conf_path(full_path: Option) -> Result { + let alternative_paths = path_alternatives( + vec![dirs::config_dir()], + &PathBuf::from("worf").join("config"), + ); + resolve_path(full_path, alternative_paths.into_iter().collect()) +} + #[must_use] pub fn path_alternatives(base_paths: Vec>, sub_path: &PathBuf) -> Vec { base_paths @@ -635,41 +665,28 @@ pub fn resolve_path( /// * cannot parse the config file /// * no config file exists /// * config file and args cannot be merged -pub fn load_config(args_opt: Option) -> Result { - let home_dir = env::var("HOME")?; - let config_path = args_opt.as_ref().map(|c| { - c.config.as_ref().map_or_else( - || { - env::var("XDG_CONF_HOME") - .map_or( - PathBuf::from(home_dir.clone()).join(".config"), - |xdg_conf_home| PathBuf::from(&xdg_conf_home), - ) - .join("worf") - .join("config") - }, - PathBuf::from, - ) - }); - +pub fn load_config(args_opt: Option) -> Result { + let config_path = conf_path(args_opt.as_ref().map(|c| c.config.clone()).flatten()); match config_path { - Some(path) => { - let toml_content = fs::read_to_string(path)?; - let mut config: Config = toml::from_str(&toml_content)?; + Ok(path) => { + let toml_content = + fs::read_to_string(path).map_err(|e| ConfigurationError::Open(format!("{e}")))?; + let mut config: Config = toml::from_str(&toml_content) + .map_err(|e| ConfigurationError::Parse(format!("{e}")))?; if let Some(args) = args_opt { - let mut merge_result = merge_config_with_args(&mut config, &args)?; + let mut merge_result = merge_config_with_args(&mut config, &args) + .map_err(|e| ConfigurationError::Parse(format!("{e}")))?; if merge_result.prompt.is_none() { match &merge_result.show { None => {} - Some(mode) => { - match mode { - Mode::Run => merge_result.prompt = Some("run".to_owned()), - Mode::Drun => merge_result.prompt = Some("drun".to_owned()), - Mode::Dmenu => merge_result.prompt = Some("dmenu".to_owned()), - } - } + Some(mode) => match mode { + Mode::Run => merge_result.prompt = Some("run".to_owned()), + Mode::Drun => merge_result.prompt = Some("drun".to_owned()), + Mode::Dmenu => merge_result.prompt = Some("dmenu".to_owned()), + _ => {} + }, } } @@ -678,9 +695,32 @@ pub fn load_config(args_opt: Option) -> Result { Ok(config) } } - None => Err(anyhow!("No config file found")), + + Err(e) => Err(ConfigurationError::Open(format!("{e}"))), } } +pub fn expand_path(input: &str) -> PathBuf { + let mut path = input.to_string(); + + // Expand ~ to home directory + if path.starts_with("~") { + if let Some(home_dir) = dirs::home_dir() { + path = path.replacen("~", home_dir.to_str().unwrap_or(""), 1); + } + } + + // Expand $VAR style environment variables + if path.contains('$') { + for (key, value) in env::vars() { + let var_pattern = format!("${}", key); + if path.contains(&var_pattern) { + path = path.replace(&var_pattern, &value); + } + } + } + + PathBuf::from(path) +} /// # Errors /// diff --git a/src/lib/desktop.rs b/src/lib/desktop.rs index 62b947b..0681c72 100644 --- a/src/lib/desktop.rs +++ b/src/lib/desktop.rs @@ -1,5 +1,6 @@ use anyhow::anyhow; use freedesktop_file_parser::DesktopFile; +use gdk4::Display; use gtk4::prelude::*; use gtk4::{IconLookupFlags, IconTheme, TextDirection}; use home::home_dir; @@ -10,88 +11,71 @@ use std::path::Path; use std::path::PathBuf; use std::{env, fs, string}; -pub struct IconResolver { - cache: HashMap, +#[derive(Debug)] +pub enum DesktopError { + MissingIcon, } -impl Default for IconResolver { - #[must_use] - fn default() -> IconResolver { - Self::new() - } -} - -impl IconResolver { - #[must_use] - pub fn new() -> IconResolver { - IconResolver { - cache: HashMap::new(), - } - } - - pub fn icon_path(&mut self, icon_name: &str) -> String { - if let Some(icon_path) = self.cache.get(icon_name) { - info!("Fetching {icon_name} from cache"); - return icon_path.to_owned(); - } - - info!("Loading icon for {icon_name}"); - let icon = fetch_icon_from_theme(icon_name) - .or_else(|_| { - fetch_icon_from_common_dirs(icon_name).map_or_else( - || Err(anyhow::anyhow!("Missing file")), // Return an error here - Ok, - ) - }) - .or_else(|_| { - warn!("Missing icon for {icon_name}, using fallback"); - default_icon() - }); - - self.cache - .entry(icon_name.to_owned()) - .or_insert_with(|| icon.unwrap_or_default()) - .to_owned() - } -} +// +// #[derive(Clone)] +// pub struct IconResolver { +// cache: HashMap, +// } +// +// impl Default for IconResolver { +// #[must_use] +// fn default() -> IconResolver { +// Self::new() +// } +// } +// +// impl IconResolver { +// #[must_use] +// pub fn new() -> IconResolver { +// IconResolver { +// cache: HashMap::new(), +// } +// } +// +// pub fn icon_path_no_cache(&self, icon_name: &str) -> Result { +// let icon = fetch_icon_from_theme(icon_name) +// .or_else(|_| +// fetch_icon_from_common_dirs(icon_name) +// .or_else(|_| default_icon())); +// +// icon +// } +// +// pub fn icon_path(&mut self, icon_name: &str) -> String { +// if let Some(icon_path) = self.cache.get(icon_name) { +// return icon_path.to_owned(); +// } +// +// let icon = self.icon_path_no_cache(icon_name); +// +// self.cache +// .entry(icon_name.to_owned()) +// .or_insert_with(|| icon.unwrap_or_default()) +// .to_owned() +// } +// } /// # Errors /// /// Will return `Err` if no icon can be found -pub fn default_icon() -> anyhow::Result { - fetch_icon_from_theme("image-missing") +pub fn default_icon() -> Result { + fetch_icon_from_theme("image-missing").map_err(|e| DesktopError::MissingIcon) } -// fn fetch_icon_from_desktop_file(icon_name: &str) -> Option { -// // find_desktop_files().into_iter().find_map(|desktop_file| { -// // desktop_file -// // .get("Desktop Entry") -// // .filter(|desktop_entry| { -// // desktop_entry -// // .get("Exec") -// // .and_then(|opt| opt.as_ref()) -// // .is_some_and(|exec| exec.to_lowercase().contains(icon_name)) -// // }) -// // .map(|desktop_entry| { -// // desktop_entry -// // .get("Icon") -// // .and_then(|opt| opt.as_ref()) -// // .map(ToOwned::to_owned) -// // .unwrap_or_default() -// // }) -// // }) -// //todo -// None -// } - -fn fetch_icon_from_theme(icon_name: &str) -> anyhow::Result { +fn fetch_icon_from_theme(icon_name: &str) -> Result { let display = gtk4::gdk::Display::default(); if display.is_none() { log::error!("Failed to get display"); } - let display = display.unwrap(); + let display = Display::default().expect("Failed to get default display"); let theme = IconTheme::for_display(&display); + let icon = theme.lookup_icon( icon_name, &[], @@ -106,17 +90,25 @@ fn fetch_icon_from_theme(icon_name: &str) -> anyhow::Result { .and_then(|file| file.path()) .and_then(|path| path.to_str().map(string::ToString::to_string)) { - None => Err(anyhow!("Cannot find file")), + None => { + let path = PathBuf::from("/usr/share/icons") + .join(theme.theme_name()) + .join(format!("{icon_name}.svg")); + if path.exists() { + Ok(path.display().to_string()) + } else { + Err(DesktopError::MissingIcon) + } + } Some(i) => Ok(i), } } -fn fetch_icon_from_common_dirs(icon_name: &str) -> Option { +pub fn fetch_icon_from_common_dirs(icon_name: &str) -> Result { let mut paths = vec![ PathBuf::from("/usr/local/share/icons"), PathBuf::from("/usr/share/icons"), PathBuf::from("/usr/share/pixmaps"), - // /usr/share/icons contains the theme icons, handled via separate function ]; if let Some(home) = home_dir() { @@ -133,6 +125,7 @@ fn fetch_icon_from_common_dirs(icon_name: &str) -> Option { find_file_case_insensitive(dir.as_path(), &formatted_name) .and_then(|files| files.first().map(|f| f.to_string_lossy().into_owned())) }) + .ok_or_else(|| DesktopError::MissingIcon) } fn find_file_case_insensitive(folder: &Path, file_name: &Regex) -> Option> { @@ -156,7 +149,7 @@ fn find_file_case_insensitive(folder: &Path, file_name: &Regex) -> Option anyhow::Result> { +pub fn find_desktop_files() -> Vec { let mut paths = vec![ PathBuf::from("/usr/share/applications"), PathBuf::from("/usr/local/share/applications"), @@ -168,6 +161,7 @@ pub fn find_desktop_files() -> anyhow::Result> { } if let Ok(xdg_data_home) = env::var("XDG_DATA_HOME") { + // todo use dirs:: instead paths.push(PathBuf::from(xdg_data_home).join(".applications")); } @@ -175,7 +169,7 @@ pub fn find_desktop_files() -> anyhow::Result> { paths.push(PathBuf::from(xdg_data_dir).join(".applications")); } - let regex = &Regex::new("(?i).*\\.desktop$")?; + let regex = &Regex::new("(?i).*\\.desktop$").unwrap(); let p: Vec<_> = paths .into_iter() @@ -190,7 +184,7 @@ pub fn find_desktop_files() -> anyhow::Result> { }) }) .collect(); - Ok(p) + p } #[must_use] diff --git a/src/lib/gui.rs b/src/lib/gui.rs index f746d4e..9f36531 100644 --- a/src/lib/gui.rs +++ b/src/lib/gui.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::ops::DerefMut; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -19,15 +20,21 @@ use gtk4::{ ListBox, ListBoxRow, Ordering, PolicyType, ScrolledWindow, SearchEntry, Widget, gdk, }; use gtk4::{Application, ApplicationWindow, CssProvider, Orientation}; -use gtk4_layer_shell::{KeyboardMode, LayerShell}; +use gtk4_layer_shell::{Edge, KeyboardMode, LayerShell}; use log; use crate::config; use crate::config::{Animation, Config, MatchMethod}; type ArcMenuMap = Arc>>>; +type ArcProvider = Arc>>; type MenuItemSender = Sender, anyhow::Error>>; +pub trait ItemProvider { + fn get_elements(&mut self, search: Option<&str>) -> Vec>; + fn get_sub_elements(&mut self, item: &MenuItem) -> Option>>; +} + impl From for Orientation { fn from(orientation: config::Orientation) -> Self { match orientation { @@ -47,8 +54,8 @@ impl From for Align { } } -#[derive(Clone)] -pub struct MenuItem { +#[derive(Clone, PartialEq)] +pub struct MenuItem { pub label: String, // todo support empty label? pub icon_path: Option, pub action: Option, @@ -61,12 +68,19 @@ pub struct MenuItem { pub data: Option, } +impl AsRef> for MenuItem { + fn as_ref(&self) -> &MenuItem { + self + } +} + /// # Errors /// /// Will return Err when the channel between the UI and this is broken -pub fn show(config: Config, elements: Vec>) -> Result, anyhow::Error> +pub fn show(config: Config, item_provider: P) -> Result, anyhow::Error> where T: Clone + 'static, + P: ItemProvider + 'static + Clone, { if let Some(ref css) = config.style { let provider = CssProvider::new(); @@ -85,7 +99,7 @@ where let (sender, receiver) = channel::bounded(1); app.connect_activate(move |app| { - build_ui(&config, &elements, &sender, app); + build_ui(&config, item_provider.clone(), &sender, app); }); let gtk_args: [&str; 0] = []; @@ -93,13 +107,14 @@ where receiver.recv()? } -fn build_ui( +fn build_ui( config: &Config, - elements: &Vec>, + item_provider: P, sender: &Sender, anyhow::Error>>, app: &Application, ) where T: Clone + 'static, + P: ItemProvider + 'static, { let window = ApplicationWindow::builder() .application(app) @@ -119,6 +134,9 @@ fn build_ui( window.set_namespace(Some("worf")); } + /// todo make this configurable + window.set_anchor(Edge::Top, true); + let outer_box = gtk4::Box::new(config.orientation.unwrap().into(), 0); outer_box.set_widget_name("outer-box"); @@ -126,7 +144,7 @@ fn build_ui( entry.set_widget_name("input"); entry.set_css_classes(&["input"]); entry.set_placeholder_text(config.prompt.as_deref()); - entry.set_sensitive(false); + entry.set_can_focus(false); outer_box.append(&entry); let scroll = ScrolledWindow::new(); @@ -145,6 +163,7 @@ fn build_ui( inner_box.set_css_classes(&["inner-box"]); inner_box.set_hexpand(true); inner_box.set_vexpand(false); + if let Some(halign) = config.halign { inner_box.set_halign(halign.into()); } @@ -161,27 +180,29 @@ fn build_ui( inner_box.set_max_children_per_line(config.columns.unwrap()); inner_box.set_activate_on_single_click(true); + let item_provider = Arc::new(Mutex::new(item_provider)); let 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, &list_items, app, &window), - entry.clone(), - ); - } + build_ui_from_menu_items( + &item_provider.lock().unwrap().get_elements(None), + &list_items, + &inner_box, + &config, + &sender, + &app, + &window, + ); - let items_sort = Arc::>>>::clone(&list_items); - inner_box.set_sort_func(move |child1, child2| sort_menu_items(child1, child2, &items_sort)); + let items_sort = ArcMenuMap::clone(&list_items); + inner_box.set_sort_func(move |child1, child2| { + sort_menu_items_by_score(child1, child2, items_sort.clone()) + }); - let items_focus = Arc::>>>::clone(&list_items); + let items_focus = ArcMenuMap::clone(&list_items); inner_box.connect_map(move |fb| { fb.grab_focus(); fb.invalidate_sort(); - let mut item_lock = items_focus.lock().unwrap(); - select_first_visible_child(&mut *item_lock, fb); + select_first_visible_child(&items_focus, fb); }); let wrapper_box = gtk4::Box::new(Orientation::Vertical, 0); @@ -194,8 +215,9 @@ fn build_ui( inner_box, app.clone(), sender.clone(), - Arc::>>>::clone(&list_items), + ArcMenuMap::clone(&list_items), config.clone(), + item_provider, ); window.set_child(Widget::NONE); @@ -203,66 +225,169 @@ fn build_ui( animate_window_show(config.clone(), window.clone(), outer_box); } +fn build_ui_from_menu_items( + items: &Vec>, + list_items: &ArcMenuMap, + inner_box: &FlowBox, + config: &Config, + sender: &MenuItemSender, + app: &Application, + window: &ApplicationWindow, +) { + { + let mut arc_lock = list_items.lock().unwrap(); + inner_box.unset_sort_func(); + + loop { + if let Some(b) = inner_box.child_at_index(0) { + inner_box.remove(&b); + } else { + break; + } + } + + for entry in items { + arc_lock.insert( + add_menu_item(&inner_box, entry, config, sender, &list_items, app, window), + (*entry).clone(), + ); + } + } + let lic = list_items.clone(); + inner_box + .set_sort_func(move |child2, child1| sort_menu_items_by_score(child1, child2, lic.clone())); + inner_box.invalidate_sort(); +} + fn setup_key_event_handler( window: &ApplicationWindow, - entry_clone: SearchEntry, + entry: SearchEntry, inner_box: FlowBox, app: Application, sender: MenuItemSender, list_items: Arc>>>, config: Config, + item_provider: ArcProvider, ) { let key_controller = EventControllerKey::new(); let window_clone = window.clone(); + let entry_clone = entry.clone(); 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}"); - } - close_gui(app.clone(), window_clone.clone(), &config); + handle_key_press( + &entry_clone, + &inner_box, + &app, + &sender, + &list_items, + &config, + &item_provider, + &window_clone, + &key_value, + ) + }); + + window.add_controller(key_controller); +} + +fn handle_key_press( + search_entry: &SearchEntry, + inner_box: &FlowBox, + app: &Application, + sender: &MenuItemSender, + list_items: &ArcMenuMap, + config: &Config, + item_provider: &ArcProvider, + window_clone: &ApplicationWindow, + keyboard_key: &Key, +) -> Propagation { + let update_view = |query: &String, items: Vec>| { + build_ui_from_menu_items( + &items, + &list_items, + &inner_box, + &config, + &sender, + &app, + &window_clone, + ); + filter_widgets(query, list_items, &config, &inner_box); + select_first_visible_child(&list_items, &inner_box); + }; + + let update_view_from_provider = |query: &String| { + let filtered_list = item_provider.lock().unwrap().get_elements(Some(&query)); + update_view(query, filtered_list) + }; + + match keyboard_key { + &Key::Escape => { + if let Err(e) = sender.send(Err(anyhow!("No item selected"))) { + log::error!("failed to send message {e}"); } - Key::Return => { - if let Err(e) = handle_selected_item( - &sender, - app.clone(), - window_clone.clone(), - &config, - &inner_box, - &list_items, - ) { - log::error!("{e}"); - } + close_gui(app.clone(), window_clone.clone(), &config); + } + &Key::Return => { + if let Err(e) = handle_selected_item( + &sender, + app.clone(), + window_clone.clone(), + &config, + &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(); + } + &Key::BackSpace => { + let mut query = search_entry.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); + search_entry.set_text(&query); + update_view_from_provider(&query); + } + &Key::Tab => { + if let Some(fb) = inner_box.selected_children().first() { + if let Some(child) = fb.child() { + let expander = child.downcast::().ok(); + if let Some(expander) = expander { + expander.set_expanded(true); + } else { + let lock = list_items.lock().unwrap(); + let menu_item = lock.get(fb); + if let Some(menu_item) = menu_item { + if let Some(new_items) = + item_provider.lock().unwrap().get_sub_elements(&menu_item) + { + let query = menu_item.label.clone(); + drop(lock); + + search_entry.set_text(&query); + update_view(&query, new_items); + } + } + } } } + return Propagation::Stop; } + _ => { + if let Some(c) = keyboard_key.to_unicode() { + let current = search_entry.text().to_string(); + let query = format!("{current}{c}"); + search_entry.set_text(&query); + update_view_from_provider(&query); + } + } + } - Propagation::Proceed - }); - window.add_controller(key_controller); + Propagation::Proceed } -fn sort_menu_items( +fn sort_menu_items_by_score( child1: &FlowBoxChild, child2: &FlowBoxChild, - items_lock: &Mutex>>, + items_lock: ArcMenuMap, ) -> Ordering { let lock = items_lock.lock().unwrap(); let m1 = lock.get(child1); @@ -302,21 +427,21 @@ fn animate_window_show(config: Config, window: ApplicationWindow, outer_box: gtk let monitor = display.monitor_at_surface(&surface); if let Some(monitor) = monitor { let geometry = monitor.geometry(); - let Some(target_width) = percent_or_absolute(&config.width.unwrap(), geometry.width()) + let Some(target_width) = percent_or_absolute(config.width.as_ref(), geometry.width()) else { return; }; let Some(target_height) = - percent_or_absolute(&config.height.unwrap(), geometry.height()) + percent_or_absolute(config.height.as_ref(), geometry.height()) else { return; }; animate_window( window.clone(), - config.show_animation.unwrap(), - config.show_animation_time.unwrap(), + config.show_animation.unwrap_or(Animation::None), + config.show_animation_time.unwrap_or(0), target_height, target_width, move || { @@ -348,8 +473,8 @@ where animate_window( window, - config.hide_animation.unwrap(), - config.hide_animation_time.unwrap(), + config.hide_animation.unwrap_or(Animation::None), + config.hide_animation_time.unwrap_or(0), target_h, target_w, on_done_func, @@ -563,7 +688,7 @@ fn add_menu_item( create_menu_row( entry_element, config, - Arc::>>>::clone(lock_arc), + ArcMenuMap::clone(lock_arc), sender.clone(), app.clone(), window.clone(), @@ -579,7 +704,7 @@ fn add_menu_item( let menu_row = create_menu_row( entry_element, config, - Arc::>>>::clone(lock_arc), + ArcMenuMap::clone(lock_arc), sender.clone(), app.clone(), window.clone(), @@ -595,7 +720,7 @@ fn add_menu_item( let sub_row = create_menu_row( sub_item, config, - Arc::>>>::clone(lock_arc), + ArcMenuMap::clone(lock_arc), sender.clone(), app.clone(), window.clone(), @@ -659,7 +784,13 @@ fn create_menu_row( row.add_controller(click); - let row_box = gtk4::Box::new(config.row_bow_orientation.unwrap().into(), 0); + let row_box = gtk4::Box::new( + config + .row_bow_orientation + .unwrap_or(config::Orientation::Horizontal) + .into(), + 0, + ); row_box.set_hexpand(true); row_box.set_vexpand(false); row_box.set_halign(Align::Fill); @@ -690,86 +821,97 @@ fn create_menu_row( label.set_wrap(true); row_box.append(&label); - if config.content_halign.unwrap() == config::Align::Start - || config.content_halign.unwrap() == config::Align::Fill + if config + .content_halign + .is_some_and(|c| c == config::Align::Start) + || config + .content_halign + .is_some_and(|c| c == config::Align::Fill) { label.set_xalign(0.0); } row.upcast() } -fn filter_widgets( +fn filter_widgets( query: &str, - items: &mut HashMap>, + item_arc: &ArcMenuMap, config: &Config, inner_box: &FlowBox, ) { - if items.is_empty() { - for (child, _) in items.iter() { - child.set_visible(true); - } + { + let mut items = item_arc.lock().unwrap(); + if items.is_empty() { + for (child, _) in items.iter() { + 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); + 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; } - return; - } - let query = query.to_owned().to_lowercase(); - for (flowbox_child, menu_item) in items.iter_mut() { - let menu_item_search = format!( - "{} {}", - menu_item - .action - .as_ref() - .map(|a| a.to_lowercase()) - .unwrap_or_default(), - &menu_item.label.to_lowercase() - ); + let query = query.to_owned().to_lowercase(); + for (flowbox_child, menu_item) in items.iter_mut() { + 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 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) + 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_or(config::default_fuzzy_min_score().unwrap_or(0.0)), + ) } - } - MatchMethod::MultiContains => { - let score = query - .split(' ') - .filter(|i| menu_item_search.contains(i)) - .map(|_| 1.0) - .sum(); - (score, score > 0.0) - } - }; + 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; - flowbox_child.set_visible(visible); + menu_item.search_sort_score = search_sort_score; + flowbox_child.set_visible(visible); + } } - select_first_visible_child(items, inner_box); + inner_box.invalidate_sort(); } -fn select_first_visible_child( - items: &mut HashMap>, - inner_box: &FlowBox, -) { +fn select_first_visible_child(lock: &ArcMenuMap, inner_box: &FlowBox) { + let items = lock.lock().unwrap(); for i in 0..items.len() { let i_32 = i.try_into().unwrap_or(i32::MAX); if let Some(child) = inner_box.child_at_index(i_32) { @@ -784,21 +926,25 @@ fn select_first_visible_child( // allowed because truncating is fine, we do no need the precision #[allow(clippy::cast_possible_truncation)] #[allow(clippy::cast_precision_loss)] -fn percent_or_absolute(value: &str, 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, +fn percent_or_absolute(value: Option<&String>, base_value: i32) -> Option { + if let Some(value) = value { + 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() } } else { - value.parse::().ok() + None } } // highly unlikely that we are dealing with > i64 items #[allow(clippy::cast_possible_wrap)] -pub fn initialize_sort_scores(items: &mut [MenuItem]) { +pub fn initialize_sort_scores(items: &mut [MenuItem]) { let mut regular_score = items.len() as i64; items.sort_by(|l, r| l.label.cmp(&r.label)); diff --git a/src/lib/mode.rs b/src/lib/mode.rs index 27ec831..9973c7c 100644 --- a/src/lib/mode.rs +++ b/src/lib/mode.rs @@ -1,13 +1,17 @@ -use crate::config::Config; +use crate::config::{Config, expand_path}; use crate::desktop::{ default_icon, find_desktop_files, get_locale_variants, lookup_name_with_locale, }; -use crate::gui; -use crate::gui::MenuItem; -use anyhow::{Context, anyhow}; +use crate::gui::{ItemProvider, MenuItem}; +use crate::{config, desktop, gui}; +use anyhow::{Context, Error, anyhow}; use freedesktop_file_parser::EntryType; +use gtk4::Image; +use libc::option; +use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::os::unix::fs::PermissionsExt; use std::os::unix::prelude::CommandExt; use std::path::PathBuf; use std::process::{Command, Stdio}; @@ -19,127 +23,416 @@ struct DRunCache { run_count: usize, } -/// # Errors -/// -/// Will return `Err` if it was not able to spawn the process -pub fn d_run(config: &Config) -> anyhow::Result<()> { - let locale_variants = get_locale_variants(); - let default_icon = default_icon().unwrap_or_default(); - - let (cache_path, mut d_run_cache) = load_d_run_cache(); - - let mut entries: Vec> = Vec::new(); - for file in find_desktop_files().ok().iter().flatten().filter(|f| { - f.entry.hidden.is_none_or(|hidden| !hidden) - && f.entry.no_display.is_none_or(|no_display| !no_display) - }) { - let Some(name) = lookup_name_with_locale( - &locale_variants, - &file.entry.name.variants, - &file.entry.name.default, - ) else { - log::warn!("Skipping desktop entry without name {file:?}"); - continue; - }; - - let (action, working_dir) = match &file.entry.entry_type { - EntryType::Application(app) => (app.exec.clone(), app.path.clone()), - _ => (None, None), - }; - - let cmd_exists = action - .as_ref() - .and_then(|a| { - a.split(' ') - .next() - .map(|cmd| cmd.replace('"', "")) - .map(|cmd| PathBuf::from(&cmd).exists() || which::which(&cmd).is_ok()) - }) - .unwrap_or(false); - - if !cmd_exists { - log::warn!( - "Skipping desktop entry for {name:?} because action {action:?} does not exist" - ); - continue; - }; - - let icon = file - .entry - .icon - .as_ref() - .map(|s| s.content.clone()) - .or(Some(default_icon.clone())); - log::debug!("file, name={name:?}, icon={icon:?}, action={action:?}"); - let sort_score = d_run_cache.get(&name).unwrap_or(&0); - - let mut entry: MenuItem = MenuItem { - label: name, - icon_path: icon.clone(), - action, - sub_elements: Vec::default(), - working_dir: working_dir.clone(), - initial_sort_score: -(*sort_score), - search_sort_score: 0.0, - data: None, - }; - - file.actions.iter().for_each(|(_, action)| { - if let Some(action_name) = lookup_name_with_locale( +#[derive(Clone)] +struct DRunProvider { + items: Vec>, + cache_path: Option, + cache: HashMap, +} + +impl DRunProvider { + fn new(menu_item_data: T) -> Self { + let locale_variants = get_locale_variants(); + let default_icon = default_icon().unwrap_or_default(); + + let (cache_path, d_run_cache) = load_d_run_cache(); + + let mut entries: Vec> = Vec::new(); + for file in find_desktop_files().iter().filter(|f| { + f.entry.hidden.is_none_or(|hidden| !hidden) + && f.entry.no_display.is_none_or(|no_display| !no_display) + }) { + let Some(name) = lookup_name_with_locale( &locale_variants, - &action.name.variants, - &action.name.default, - ) { - let action_icon = action - .icon - .as_ref() - .map(|s| s.content.clone()) - .or(icon.clone()); - - log::debug!("sub, action_name={action_name:?}, action_icon={action_icon:?}"); - - let sub_entry = MenuItem { - label: action_name, - icon_path: action_icon, - action: action.exec.clone(), - sub_elements: Vec::default(), - working_dir: working_dir.clone(), - initial_sort_score: 0, // subitems are never sorted right now. + &file.entry.name.variants, + &file.entry.name.default, + ) else { + log::warn!("Skipping desktop entry without name {file:?}"); + continue; + }; + + let (action, working_dir) = match &file.entry.entry_type { + EntryType::Application(app) => (app.exec.clone(), app.path.clone()), + _ => (None, None), + }; + + let cmd_exists = action + .as_ref() + .and_then(|a| { + a.split(' ') + .next() + .map(|cmd| cmd.replace('"', "")) + .map(|cmd| PathBuf::from(&cmd).exists() || which::which(&cmd).is_ok()) + }) + .unwrap_or(false); + + if !cmd_exists { + log::warn!( + "Skipping desktop entry for {name:?} because action {action:?} does not exist" + ); + continue; + }; + + let icon = file + .entry + .icon + .as_ref() + .map(|s| s.content.clone()) + .or(Some(default_icon.clone())); + log::debug!("file, name={name:?}, icon={icon:?}, action={action:?}"); + let sort_score = d_run_cache.get(&name).unwrap_or(&0); + + let mut entry: MenuItem = MenuItem { + label: name, + icon_path: icon.clone(), + action, + sub_elements: Vec::default(), + working_dir: working_dir.clone(), + initial_sort_score: -(*sort_score), + search_sort_score: 0.0, + data: Some(menu_item_data.clone()), + }; + + file.actions.iter().for_each(|(_, action)| { + if let Some(action_name) = lookup_name_with_locale( + &locale_variants, + &action.name.variants, + &action.name.default, + ) { + let action_icon = action + .icon + .as_ref() + .map(|s| s.content.clone()) + .or(icon.clone()); + + log::debug!("sub, action_name={action_name:?}, action_icon={action_icon:?}"); + + let sub_entry = MenuItem { + label: action_name, + icon_path: action_icon, + action: action.exec.clone(), + sub_elements: Vec::default(), + working_dir: working_dir.clone(), + initial_sort_score: 0, // subitems are never sorted right now. + search_sort_score: 0.0, + data: None, + }; + entry.sub_elements.push(sub_entry); + } + }); + + entries.push(entry); + } + + gui::initialize_sort_scores(&mut entries); + + DRunProvider { + items: entries, + cache_path, + cache: d_run_cache, + } + } +} + +impl ItemProvider for DRunProvider { + fn get_elements(&mut self, _: Option<&str>) -> Vec> { + self.items.clone() + } + + fn get_sub_elements(&mut self, item: &MenuItem) -> Option>> { + None + } +} + +#[derive(Debug, Clone, PartialEq)] +enum AutoRunType { + Math, + DRun, + File, + Ssh, + WebSearch, + Emoji, + Run, +} + +#[derive(Clone)] +struct AutoItemProvider { + drun_provider: DRunProvider, + last_result: Option>>, +} + +impl AutoItemProvider { + fn new() -> Self { + AutoItemProvider { + drun_provider: DRunProvider::new(AutoRunType::DRun), + + last_result: None, + } + } + + fn auto_run_handle_files(&mut self, trimmed_search: &str) -> Vec> { + let folder_icon = "inode-directory"; + + let path = config::expand_path(trimmed_search); + let mut items: Vec> = Vec::new(); + + if !path.exists() { + if let Some(last) = &self.last_result { + if !last.is_empty() + && last.first().is_some_and(|l| { + l.as_ref() + .data + .as_ref() + .is_some_and(|t| t == &AutoRunType::File) + }) + { + return last.clone(); + } + } + + return vec![]; + } + + if path.is_dir() { + for entry in path.read_dir().unwrap() { + if let Ok(entry) = entry { + let mut path_str = entry.path().to_str().unwrap_or("").to_string(); + if trimmed_search.starts_with("~") { + if let Some(home_dir) = dirs::home_dir() { + path_str = path_str.replace(home_dir.to_str().unwrap_or(""), "~"); + } + } + + if entry.path().is_dir() { + path_str += "/"; + } + + items.push({ + MenuItem { + label: path_str.clone(), + icon_path: if entry.path().is_dir() { + Some(folder_icon.to_owned()) + } else { + Some(resolve_icon_for_name(entry.path())) + }, + action: Some(format!("xdg-open {path_str}")), + sub_elements: vec![], + working_dir: None, + initial_sort_score: 0, + search_sort_score: 0.0, + data: Some(AutoRunType::File), + } + }); + } + } + } else { + items.push({ + MenuItem { + label: trimmed_search.to_owned(), + icon_path: Some(resolve_icon_for_name(PathBuf::from(trimmed_search))), + action: Some(format!("xdg-open {trimmed_search}")), + sub_elements: vec![], + working_dir: None, + initial_sort_score: 0, search_sort_score: 0.0, - data: None, + data: Some(AutoRunType::File), + } + }); + } + + self.last_result = Some(items.clone()); + items + } +} + +fn resolve_icon_for_name(path: PathBuf) -> String { + // todo use https://docs.rs/tree_magic_mini/latest/tree_magic_mini/ instead + if let Ok(metadata) = fs::symlink_metadata(&path) { + if metadata.file_type().is_symlink() { + return "inode-symlink".to_owned(); + } else if metadata.is_dir() { + return "inode-directory".to_owned(); + } else if metadata.permissions().mode() & 0o111 != 0 { + return "application-x-executable".to_owned(); + } + } + + let file_name = path + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or("") + .to_lowercase(); + + let extension = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + + match extension.as_str() { + "sh" | "py" | "rb" | "pl" | "bash" => "text-x-script".to_owned(), + "c" | "cpp" | "rs" | "java" | "js" | "h" | "hpp" => "text-x-generic".to_owned(), + "txt" | "md" | "log" => "text-x-generic".to_owned(), + "html" | "htm" => "text-html".to_owned(), + "jpg" | "jpeg" | "png" | "gif" | "svg" | "webp" => "image-x-generic".to_owned(), + "mp3" | "wav" | "ogg" => "audio-x-generic".to_owned(), + "mp4" | "mkv" | "avi" => "video-x-generic".to_owned(), + "ttf" | "otf" | "woff" => "font-x-generic".to_owned(), + "zip" | "tar" | "gz" | "xz" | "7z" | "lz4" => "package-x-generic".to_owned(), + "deb" | "rpm" | "apk" => "x-package-repository".to_owned(), + "odt" => "x-office-document".to_owned(), + "ott" => "x-office-document-template".to_owned(), + "ods" => "x-office-spreadsheet".to_owned(), + "ots" => "x-office-spreadsheet-template".to_owned(), + "odp" => "x-office-presentation".to_owned(), + "otp" => "x-office-presentation-template".to_owned(), + "odg" => "x-office-drawing".to_owned(), + "vcf" => "x-office-addressbook".to_owned(), + _ => "application-x-generic".to_owned(), + } +} + +fn contains_math_functions_or_starts_with_number(input: &str) -> bool { + // Regex for function names (word boundaries to match whole words) + let math_functions = r"\b(sqrt|abs|exp|ln|sin|cos|tan|asin|acos|atan|atan2|sinh|cosh|tanh|asinh|acosh|atanh|floor|ceil|round|signum|min|max|pi|e)\b"; + + // Regex for strings that start with a number (including decimals) + let starts_with_number = r"^\s*[+-]?(\d+(\.\d*)?|\.\d+)"; + + let math_regex = Regex::new(math_functions).unwrap(); + let number_regex = Regex::new(starts_with_number).unwrap(); + + math_regex.is_match(input) || number_regex.is_match(input) +} + +impl ItemProvider for AutoItemProvider { + fn get_elements(&mut self, search_opt: Option<&str>) -> Vec> { + if let Some(search) = search_opt { + let trimmed_search = search.trim(); + if trimmed_search.is_empty() { + self.drun_provider.get_elements(search_opt) + } else if contains_math_functions_or_starts_with_number(trimmed_search) { + let result = match meval::eval_str(trimmed_search) { + Ok(result) => result.to_string(), + Err(e) => format!("failed to calculate {e:?}"), + }; + + let item = MenuItem { + label: result, + icon_path: None, + action: None, + sub_elements: vec![], + working_dir: None, + initial_sort_score: 0, + search_sort_score: 0.0, + data: Some(AutoRunType::Math), }; - entry.sub_elements.push(sub_entry); + + return vec![item]; + } else if trimmed_search.starts_with("$") + || trimmed_search.starts_with("/") + || trimmed_search.starts_with("~") + { + self.auto_run_handle_files(trimmed_search) + } else { + return self.drun_provider.get_elements(search_opt); } - }); + } else { + self.drun_provider.get_elements(search_opt) + } + } + + fn get_sub_elements( + &mut self, + item: &MenuItem, + ) -> Option>> { + Some(self.get_elements(Some(item.label.as_ref()))) + } +} - entries.push(entry); +/// # Errors +/// +/// Will return `Err` if it was not able to spawn the process +pub fn d_run(config: &mut Config) -> anyhow::Result<()> { + let provider = DRunProvider::new("".to_owned()); + let cache_path = provider.cache_path.clone(); + let mut cache = provider.cache.clone(); + 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 selection_result = gui::show(config.clone(), provider); + match selection_result { + Ok(s) => { + update_drun_cache_and_run(cache_path, &mut cache, s)?; + } + Err(_) => { + log::error!("No item selected"); + } + } + + Ok(()) +} + +pub fn auto(config: &mut Config) -> anyhow::Result<()> { + let provider = AutoItemProvider::new(); + let cache_path = provider.drun_provider.cache_path.clone(); + let mut cache = provider.drun_provider.cache.clone(); + + if config.prompt.is_none() { + config.prompt = Some("auto".to_owned()); + } // todo ues a arc instead of cloning the config - let selection_result = gui::show(config.clone(), entries.clone()); + let selection_result = gui::show(config.clone(), provider); + match selection_result { - Ok(selected_item) => { - if let Some(cache) = cache_path { - *d_run_cache.entry(selected_item.label).or_insert(0) += 1; - if let Err(e) = save_cache_file(&cache, &d_run_cache) { - log::warn!("cannot save drun cache {e:?}"); + Ok(selection_result) => { + if let Some(data) = &selection_result.data { + match data { + AutoRunType::Math => {} + AutoRunType::DRun => { + update_drun_cache_and_run(cache_path, &mut cache, selection_result)?; + } + AutoRunType::File => { + if let Some(action) = selection_result.action { + spawn_fork(&action, selection_result.working_dir.as_ref())? + } + } + _ => { + todo!("not supported yet"); + } } } - - if let Some(action) = selected_item.action { - spawn_fork(&action, selected_item.working_dir.as_ref())?; - } } - Err(e) => { - log::error!("{e}"); + Err(_) => { + log::error!("No item selected"); } } Ok(()) } +fn update_drun_cache_and_run( + cache_path: Option, + cache: &mut HashMap, + selection_result: MenuItem, +) -> Result<(), Error> { + if let Some(cache_path) = cache_path { + *cache.entry(selection_result.label).or_insert(0) += 1; + if let Err(e) = save_cache_file(&cache_path, &cache) { + log::warn!("cannot save drun cache {e:?}"); + } + } + + if let Some(action) = selection_result.action { + spawn_fork(&action, selection_result.working_dir.as_ref()) + } else { + Err(anyhow::anyhow!("cannot find drun action")) + } +} + fn load_d_run_cache() -> (Option, HashMap) { let cache_path = dirs::cache_dir().map(|x| x.join("worf-drun")); let d_run_cache = { @@ -213,6 +506,7 @@ fn spawn_fork(cmd: &str, working_dir: Option<&String>) -> anyhow::Result<()> { .iter() .skip(1) .filter(|arg| !arg.starts_with('%')) + .map(|arg| expand_path(arg)) .collect(); unsafe { diff --git a/src/main.rs b/src/main.rs index 8be9da2..4b7bd37 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ use std::env; use anyhow::anyhow; +use worf_lib::config::Mode; use worf_lib::{config, mode}; fn main() -> anyhow::Result<()> { @@ -12,19 +13,22 @@ fn main() -> anyhow::Result<()> { .init(); let args = config::parse_args(); - let config = config::load_config(Some(args))?; + let mut config = config::load_config(Some(args)).map_err(|e| anyhow!(e))?; if let Some(show) = &config.show { match show { - config::Mode::Run => { + Mode::Run => { todo!("run not implemented") } - config::Mode::Drun => { - mode::d_run(&config)?; + Mode::Drun => { + mode::d_run(&mut config)?; } - config::Mode::Dmenu => { + Mode::Dmenu => { todo!("dmenu not implemented") } + Mode::Auto => { + mode::auto(&mut config)?; + } } Ok(()) diff --git a/styles/dmenu/config.toml b/styles/dmenu/config.toml new file mode 100644 index 0000000..2a986cb --- /dev/null +++ b/styles/dmenu/config.toml @@ -0,0 +1,8 @@ +image_size=0 +columns=999 +orientation="Horizontal" +row_bow_orientation="Horizontal" +content_halign="Center" +height="25" +width="100%" +valign="Start" diff --git a/styles/dmenu/style.css b/styles/dmenu/style.css new file mode 100644 index 0000000..4a68708 --- /dev/null +++ b/styles/dmenu/style.css @@ -0,0 +1,66 @@ +* { + font-family: DejaVu; + margin: 0; + padding: 0; +} + +#window { + all: unset; + background-color: rgba(33, 33, 33, 1); + margin-left: 10px; + margin-right: 10px; + border-radius: 6px; +} + +#window #outer-box { + /* The name of the search bar */ + /* The name of the scrolled window containing all of the entries */ + border: none; +} + +#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); + font-size: 1rem; +} + +#window #outer-box #scroll #inner-box #entry { + color: #fff; + background-color: rgba(32, 32, 32, 0.1); + border-radius: 0.5rem; + border-bottom: 5px solid rgba(32, 32, 32, 0.1); + +} +#window #outer-box #scroll #inner-box #entry #img { + +} + +#window #outer-box #scroll #inner-box #entry:selected { + color: #fff; + background-color: rgba(255, 255, 255, 0.1); + outline: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +#row:hover { + background-color: rgba(255, 255, 255, 0); + outline: inherit; +} +#window #outer-box #scroll #inner-box #entry:hover { + background-color: rgba(255, 255, 255, 0.1); + outline: inherit; +} + +#label { + margin-top: 1rem; + margin-bottom: 0; +} diff --git a/styles/fullscreen/config.toml b/styles/fullscreen/config.toml index 77c56d8..519d540 100644 --- a/styles/fullscreen/config.toml +++ b/styles/fullscreen/config.toml @@ -3,6 +3,6 @@ columns=6 orientation="Vertical" row_bow_orientation="Vertical" content_halign="Center" -height="110%" +height="105%" width="100%" valign="Start" diff --git a/styles/fullscreen/style.css b/styles/fullscreen/style.css index d6288ac..434cf10 100644 --- a/styles/fullscreen/style.css +++ b/styles/fullscreen/style.css @@ -6,7 +6,7 @@ all: unset; background-color: rgba(33, 33, 33, 0.76); /* Matches #212121BB */ border-radius: 0; - padding-top: 5rem; + padding-top: 3rem; } #window #outer-box { @@ -17,7 +17,7 @@ color: #f2f2f2; border-bottom: 2px solid rgba(214, 174, 0, 1); font-size: 1rem; - margin: 1rem 40rem; + margin: 1rem 50rem 2rem; } #window #outer-box #input:focus, #window #outer-box #input:focus-visible, #window #outer-box #input:active { diff --git a/styles/relaxed/config.toml b/styles/relaxed/config.toml new file mode 100644 index 0000000..dc12027 --- /dev/null +++ b/styles/relaxed/config.toml @@ -0,0 +1,5 @@ +image_size=48 +columns=1 +height="60%" +width="70%" +valign="Start" diff --git a/styles/relaxed/style.css b/styles/relaxed/style.css new file mode 100644 index 0000000..435aa23 --- /dev/null +++ b/styles/relaxed/style.css @@ -0,0 +1,68 @@ +* { + font-family: DejaVu; +} + +#window { + all: unset; + background-color: rgba(33, 33, 33, 0.8); /* Matches #212121BB */ + border-radius: 0; +} + +#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 #inner-box #entry { + color: #fff; + background-color: rgba(32, 32, 32, 0.1); + padding: 1rem; + margin: 1rem; + border-radius: 0.5rem; + border-bottom: 5px solid rgba(32, 32, 32, 0.1); + +} +#window #outer-box #scroll #inner-box #entry #img { + margin-right: 0.5rem; +} + +#window #outer-box #scroll #inner-box #entry:selected { + color: #fff; + background-color: rgba(255, 255, 255, 0.1); + outline: none; + border-bottom: 5px solid rgba(214, 174, 0, 1); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +#row:hover { + background-color: rgba(255, 255, 255, 0); + outline: inherit; +} +#window #outer-box #scroll #inner-box #entry:hover { + background-color: rgba(255, 255, 255, 0.1); + outline: inherit; +} + +#label { + margin-top: 1rem; + margin-bottom: 0; +} From 71c22714537af427763ea76d96e79822d3507ef7 Mon Sep 17 00:00:00 2001 From: Alexander Mohr Date: Mon, 21 Apr 2025 16:23:22 +0200 Subject: [PATCH 07/12] file can now be used as standalone mode --- Cargo.lock | 48 ++++++++++- Cargo.toml | 1 + README.md | 14 ++-- src/lib/config.rs | 13 ++- src/lib/gui.rs | 4 +- src/lib/mode.rs | 210 +++++++++++++++++++++++++++------------------- src/main.rs | 3 + 7 files changed, 189 insertions(+), 104 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7cabb2d..699f2ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -436,6 +436,12 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "fnv" version = "1.0.7" @@ -1026,9 +1032,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f79496a5651c8d57cd033c5add8ca7ee4e3d5f7587a4777484640d9cb60392d9" dependencies = [ "fnv", - "nom", + "nom 1.2.4", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.7" @@ -1055,6 +1067,16 @@ version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5b8c256fd9471521bcb84c3cdba98921497f1a331cbc15b8030fc63b82050ce" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1121,6 +1143,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.9.0", +] + [[package]] name = "phf" version = "0.11.3" @@ -1555,6 +1587,19 @@ dependencies = [ "winnow", ] +[[package]] +name = "tree_magic_mini" +version = "3.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac5e8971f245c3389a5a76e648bfc80803ae066a1243a75db0064d7c1129d63" +dependencies = [ + "fnv", + "memchr", + "nom 7.1.3", + "once_cell", + "petgraph", +] + [[package]] name = "unicode-ident" version = "1.0.18" @@ -1755,6 +1800,7 @@ dependencies = [ "strsim 0.11.1", "thiserror", "toml", + "tree_magic_mini", "which", ] diff --git a/Cargo.toml b/Cargo.toml index 42dfd2b..9fa4efd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,3 +43,4 @@ strsim = "0.11.1" dirs = "6.0.0" which = "7.0.3" meval = "0.2.0" +tree_magic_mini = "3.1.6" diff --git a/README.md b/README.md index 750f593..689ceab 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,12 @@ -# Worf +# Worf - Wayland Optimized Run Facilitator + +Worf is yet another style launcher, heavily inspired by wofi, rofi and walker. +Worf is written in Rust on top of GTK4. + +It aims to be a drop in replacement for wofi in most part, so it is (almost) compatible with its +configuration and css files. See below for differences + -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 @@ -37,7 +41,7 @@ layerrule = blur, worf ## Dropped arguments * `mode`, use show -* `D`, arguments are the same as config in worf, no need to have have this flag. +* `D`, arguments are the same as config in worf, no need to have this flag. ### Dropped configuration options * stylesheet -> use style instead diff --git a/src/lib/config.rs b/src/lib/config.rs index c77310c..8fc6068 100644 --- a/src/lib/config.rs +++ b/src/lib/config.rs @@ -64,6 +64,9 @@ pub enum Mode { /// tries to determine automatically what to do Auto, + + /// use worf as file browser + File, } #[derive(Debug, Error)] @@ -80,6 +83,7 @@ impl FromStr for Mode { "run" => Ok(Mode::Run), "drun" => Ok(Mode::Drun), "dmenu" => Ok(Mode::Dmenu), + "file" => Ok(Mode::File), "auto" => Ok(Mode::Auto), _ => Err(ArgsError::InvalidParameter( format!("{s} is not a valid argument show this, see help for details").to_owned(), @@ -128,21 +132,16 @@ pub struct Config { #[clap(long = "height")] pub height: Option, + /// Defines which prompt is used. Default is selected 'show' #[clap(short = 'p', long = "prompt")] pub prompt: Option, #[clap(short = 'x', long = "xoffset")] pub xoffset: Option, - #[clap(long = "x")] - pub x: Option, - #[clap(short = 'y', long = "yoffset")] pub yoffset: Option, - #[clap(long = "y")] - pub y: Option, - /// If true a normal window instead of a layer shell will be used #[serde(default = "default_normal_window")] #[clap(short = 'n', long = "normal-window")] @@ -315,9 +314,7 @@ impl Default for Config { height: default_height(), prompt: None, xoffset: None, - x: None, yoffset: None, - y: None, normal_window: default_normal_window(), allow_images: None, allow_markup: None, diff --git a/src/lib/gui.rs b/src/lib/gui.rs index 9f36531..15cdae1 100644 --- a/src/lib/gui.rs +++ b/src/lib/gui.rs @@ -135,7 +135,7 @@ fn build_ui( } /// todo make this configurable - window.set_anchor(Edge::Top, true); + //window.set_anchor(Edge::Top, true); let outer_box = gtk4::Box::new(config.orientation.unwrap().into(), 0); outer_box.set_widget_name("outer-box"); @@ -944,7 +944,7 @@ fn percent_or_absolute(value: Option<&String>, base_value: i32) -> Option { // highly unlikely that we are dealing with > i64 items #[allow(clippy::cast_possible_wrap)] -pub fn initialize_sort_scores(items: &mut [MenuItem]) { +pub fn sort_menu_items_alphabetically_honor_initial_score(items: &mut [MenuItem]) { let mut regular_score = items.len() as i64; items.sort_by(|l, r| l.label.cmp(&r.label)); diff --git a/src/lib/mode.rs b/src/lib/mode.rs index 9973c7c..d8b993b 100644 --- a/src/lib/mode.rs +++ b/src/lib/mode.rs @@ -124,7 +124,7 @@ impl DRunProvider { entries.push(entry); } - gui::initialize_sort_scores(&mut entries); + gui::sort_menu_items_alphabetically_honor_initial_score(&mut entries); DRunProvider { items: entries, @@ -134,7 +134,7 @@ impl DRunProvider { } } -impl ItemProvider for DRunProvider { +impl ItemProvider for DRunProvider { fn get_elements(&mut self, _: Option<&str>) -> Vec> { self.items.clone() } @@ -144,50 +144,79 @@ impl ItemProvider for DRunProvider { } } -#[derive(Debug, Clone, PartialEq)] -enum AutoRunType { - Math, - DRun, - File, - Ssh, - WebSearch, - Emoji, - Run, -} - #[derive(Clone)] -struct AutoItemProvider { - drun_provider: DRunProvider, - last_result: Option>>, +struct FileItemProvider { + last_result: Option>>, + menu_item_data: T, } -impl AutoItemProvider { - fn new() -> Self { - AutoItemProvider { - drun_provider: DRunProvider::new(AutoRunType::DRun), - +impl FileItemProvider { + fn new(menu_item_data: T) -> Self { + FileItemProvider { last_result: None, + menu_item_data, } } - fn auto_run_handle_files(&mut self, trimmed_search: &str) -> Vec> { - let folder_icon = "inode-directory"; + fn resolve_icon_for_name(&self, path: PathBuf) -> String { + let result = tree_magic_mini::from_filepath(&path); + if let Some(result) = result { + if result.starts_with("image") { + "image-x-generic".to_owned() + } else if result.starts_with("inode") { + return result.replace("/", "-"); + } else if result.starts_with("text") { + if result.contains("plain") { + "text-x-generic".to_owned() + } else if result.contains("python") { + "text-x-script".to_owned() + } else if result.contains("html") { + return "text-html".to_owned(); + } else { + "text-x-generic".to_owned() + } + } else if result.starts_with("application") { + if result.contains("octet") { + "application-x-executable".to_owned() + } else if result.contains("tar") + || result.contains("lz") + || result.contains("zip") + || result.contains("7z") + || result.contains("xz") + { + "package-x-generic".to_owned() + } else { + return "text-html".to_owned(); + } + } else { + log::debug!("unsupported mime type {result}"); + return "application-x-generic".to_owned(); + } + } else { + "image-not-found".to_string() + } + } +} + +impl ItemProvider for FileItemProvider { + fn get_elements(&mut self, search: Option<&str>) -> Vec> { + let default_path = if let Some(home) = dirs::home_dir() { + home.display().to_string() + } else { + "/".to_string() + }; + + let mut trimmed_search = search.unwrap_or(&default_path).to_owned(); + if !trimmed_search.starts_with("/") && !trimmed_search.starts_with("~") { + trimmed_search = format!("{default_path}/{trimmed_search}"); + } - let path = config::expand_path(trimmed_search); - let mut items: Vec> = Vec::new(); + let path = expand_path(&trimmed_search); + let mut items: Vec> = Vec::new(); if !path.exists() { if let Some(last) = &self.last_result { - if !last.is_empty() - && last.first().is_some_and(|l| { - l.as_ref() - .data - .as_ref() - .is_some_and(|t| t == &AutoRunType::File) - }) - { - return last.clone(); - } + return last.clone(); } return vec![]; @@ -210,17 +239,13 @@ impl AutoItemProvider { items.push({ MenuItem { label: path_str.clone(), - icon_path: if entry.path().is_dir() { - Some(folder_icon.to_owned()) - } else { - Some(resolve_icon_for_name(entry.path())) - }, + icon_path: Some(self.resolve_icon_for_name(entry.path())), action: Some(format!("xdg-open {path_str}")), sub_elements: vec![], working_dir: None, initial_sort_score: 0, search_sort_score: 0.0, - data: Some(AutoRunType::File), + data: Some(self.menu_item_data.clone()), } }); } @@ -229,66 +254,53 @@ impl AutoItemProvider { items.push({ MenuItem { label: trimmed_search.to_owned(), - icon_path: Some(resolve_icon_for_name(PathBuf::from(trimmed_search))), + icon_path: Some(self.resolve_icon_for_name(PathBuf::from(&trimmed_search))), action: Some(format!("xdg-open {trimmed_search}")), sub_elements: vec![], working_dir: None, initial_sort_score: 0, search_sort_score: 0.0, - data: Some(AutoRunType::File), + data: Some(self.menu_item_data.clone()), } }); } + gui::sort_menu_items_alphabetically_honor_initial_score(&mut items); + self.last_result = Some(items.clone()); items } -} -fn resolve_icon_for_name(path: PathBuf) -> String { - // todo use https://docs.rs/tree_magic_mini/latest/tree_magic_mini/ instead - if let Ok(metadata) = fs::symlink_metadata(&path) { - if metadata.file_type().is_symlink() { - return "inode-symlink".to_owned(); - } else if metadata.is_dir() { - return "inode-directory".to_owned(); - } else if metadata.permissions().mode() & 0o111 != 0 { - return "application-x-executable".to_owned(); - } + fn get_sub_elements(&mut self, _: &MenuItem) -> Option>> { + self.last_result.clone() } +} - let file_name = path - .file_name() - .and_then(|f| f.to_str()) - .unwrap_or("") - .to_lowercase(); - - let extension = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - - match extension.as_str() { - "sh" | "py" | "rb" | "pl" | "bash" => "text-x-script".to_owned(), - "c" | "cpp" | "rs" | "java" | "js" | "h" | "hpp" => "text-x-generic".to_owned(), - "txt" | "md" | "log" => "text-x-generic".to_owned(), - "html" | "htm" => "text-html".to_owned(), - "jpg" | "jpeg" | "png" | "gif" | "svg" | "webp" => "image-x-generic".to_owned(), - "mp3" | "wav" | "ogg" => "audio-x-generic".to_owned(), - "mp4" | "mkv" | "avi" => "video-x-generic".to_owned(), - "ttf" | "otf" | "woff" => "font-x-generic".to_owned(), - "zip" | "tar" | "gz" | "xz" | "7z" | "lz4" => "package-x-generic".to_owned(), - "deb" | "rpm" | "apk" => "x-package-repository".to_owned(), - "odt" => "x-office-document".to_owned(), - "ott" => "x-office-document-template".to_owned(), - "ods" => "x-office-spreadsheet".to_owned(), - "ots" => "x-office-spreadsheet-template".to_owned(), - "odp" => "x-office-presentation".to_owned(), - "otp" => "x-office-presentation-template".to_owned(), - "odg" => "x-office-drawing".to_owned(), - "vcf" => "x-office-addressbook".to_owned(), - _ => "application-x-generic".to_owned(), +#[derive(Debug, Clone, PartialEq)] +enum AutoRunType { + Math, + DRun, + File, + Ssh, + WebSearch, + Emoji, + Run, +} + +#[derive(Clone)] +struct AutoItemProvider { + drun_provider: DRunProvider, + file_provider: FileItemProvider, + last_result: Option>>, +} + +impl AutoItemProvider { + fn new() -> Self { + AutoItemProvider { + drun_provider: DRunProvider::new(AutoRunType::DRun), + file_provider: FileItemProvider::new(AutoRunType::File), + last_result: None, + } } } @@ -320,7 +332,7 @@ impl ItemProvider for AutoItemProvider { let item = MenuItem { label: result, icon_path: None, - action: None, + action: Some(trimmed_search.to_owned()), sub_elements: vec![], working_dir: None, initial_sort_score: 0, @@ -333,7 +345,7 @@ impl ItemProvider for AutoItemProvider { || trimmed_search.starts_with("/") || trimmed_search.starts_with("~") { - self.auto_run_handle_files(trimmed_search) + self.file_provider.get_elements(search_opt) } else { return self.drun_provider.get_elements(search_opt); } @@ -414,6 +426,28 @@ pub fn auto(config: &mut Config) -> anyhow::Result<()> { Ok(()) } +pub fn file(config: &mut Config) -> Result<(), String> { + let provider = FileItemProvider::new("".to_owned()); + if config.prompt.is_none() { + config.prompt = Some("file".to_owned()); + } + + // todo ues a arc instead of cloning the config + let selection_result = gui::show(config.clone(), provider); + match selection_result { + Ok(s) => { + if let Some(action) = s.action { + spawn_fork(&action, s.working_dir.as_ref()).map_err(|e| e.to_string())?; + } + } + Err(_) => { + log::error!("No item selected"); + } + } + + Ok(()) +} + fn update_drun_cache_and_run( cache_path: Option, cache: &mut HashMap, diff --git a/src/main.rs b/src/main.rs index 4b7bd37..196264b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,9 @@ fn main() -> anyhow::Result<()> { Mode::Dmenu => { todo!("dmenu not implemented") } + Mode::File => { + mode::file(&mut config).map_err(|e| anyhow!(e))?; + } Mode::Auto => { mode::auto(&mut config)?; } From 35b8882577b8b982e7e776d5e53b8f1e7b08bb1c Mon Sep 17 00:00:00 2001 From: Alexander Mohr Date: Mon, 21 Apr 2025 16:41:02 +0200 Subject: [PATCH 08/12] add math as own mode --- src/lib/config.rs | 4 ++ src/lib/mode.rs | 126 +++++++++++++++++++++++++++++++--------------- src/main.rs | 84 ++----------------------------- 3 files changed, 93 insertions(+), 121 deletions(-) diff --git a/src/lib/config.rs b/src/lib/config.rs index 8fc6068..c837734 100644 --- a/src/lib/config.rs +++ b/src/lib/config.rs @@ -67,6 +67,9 @@ pub enum Mode { /// use worf as file browser File, + + /// Use is as calculator + Math, } #[derive(Debug, Error)] @@ -84,6 +87,7 @@ impl FromStr for Mode { "drun" => Ok(Mode::Drun), "dmenu" => Ok(Mode::Dmenu), "file" => Ok(Mode::File), + "math" => Ok(Mode::Math), "auto" => Ok(Mode::Auto), _ => Err(ArgsError::InvalidParameter( format!("{s} is not a valid argument show this, see help for details").to_owned(), diff --git a/src/lib/mode.rs b/src/lib/mode.rs index d8b993b..4bc327e 100644 --- a/src/lib/mode.rs +++ b/src/lib/mode.rs @@ -24,7 +24,7 @@ struct DRunCache { } #[derive(Clone)] -struct DRunProvider { +struct DRunProvider { items: Vec>, cache_path: Option, cache: HashMap, @@ -145,7 +145,7 @@ impl ItemProvider for DRunProvider { } #[derive(Clone)] -struct FileItemProvider { +struct FileItemProvider { last_result: Option>>, menu_item_data: T, } @@ -276,22 +276,78 @@ impl ItemProvider for FileItemProvider { } } +#[derive(Clone)] +struct MathProvider { + menu_item_data: T, +} + +impl MathProvider { + fn new(menu_item_data: T) -> Self { + Self { + menu_item_data, + } + } + + fn contains_math_functions_or_starts_with_number(input: &str) -> bool { + // Regex for function names (word boundaries to match whole words) + let math_functions = r"\b(sqrt|abs|exp|ln|sin|cos|tan|asin|acos|atan|atan2|sinh|cosh|tanh|asinh|acosh|atanh|floor|ceil|round|signum|min|max|pi|e)\b"; + + // Regex for strings that start with a number (including decimals) + let starts_with_number = r"^\s*[+-]?(\d+(\.\d*)?|\.\d+)"; + + let math_regex = Regex::new(math_functions).unwrap(); + let number_regex = Regex::new(starts_with_number).unwrap(); + + math_regex.is_match(input) || number_regex.is_match(input) + } +} + +impl ItemProvider for MathProvider { + fn get_elements(&mut self, search: Option<&str>) -> Vec> { + if let Some(search_text) = search { + let result = match meval::eval_str(search_text) { + Ok(result) => result.to_string(), + Err(e) => format!("failed to calculate {e:?}"), + }; + + let item = MenuItem { + label: result, + icon_path: None, + action: search.map(|s| s.to_string()), + sub_elements: vec![], + working_dir: None, + initial_sort_score: 0, + search_sort_score: 0.0, + data: Some(self.menu_item_data.clone()), + }; + + vec![item] + } else { + vec![] + } + } + + fn get_sub_elements(&mut self, item: &MenuItem) -> Option>> { + None + } +} + #[derive(Debug, Clone, PartialEq)] enum AutoRunType { Math, DRun, File, - Ssh, - WebSearch, - Emoji, - Run, + // Ssh, + // WebSearch, + // Emoji, + // Run, } #[derive(Clone)] struct AutoItemProvider { drun_provider: DRunProvider, file_provider: FileItemProvider, - last_result: Option>>, + math_provider: MathProvider, } impl AutoItemProvider { @@ -299,55 +355,26 @@ impl AutoItemProvider { AutoItemProvider { drun_provider: DRunProvider::new(AutoRunType::DRun), file_provider: FileItemProvider::new(AutoRunType::File), - last_result: None, + math_provider: MathProvider::new(AutoRunType::Math), } } } -fn contains_math_functions_or_starts_with_number(input: &str) -> bool { - // Regex for function names (word boundaries to match whole words) - let math_functions = r"\b(sqrt|abs|exp|ln|sin|cos|tan|asin|acos|atan|atan2|sinh|cosh|tanh|asinh|acosh|atanh|floor|ceil|round|signum|min|max|pi|e)\b"; - - // Regex for strings that start with a number (including decimals) - let starts_with_number = r"^\s*[+-]?(\d+(\.\d*)?|\.\d+)"; - - let math_regex = Regex::new(math_functions).unwrap(); - let number_regex = Regex::new(starts_with_number).unwrap(); - - math_regex.is_match(input) || number_regex.is_match(input) -} - impl ItemProvider for AutoItemProvider { fn get_elements(&mut self, search_opt: Option<&str>) -> Vec> { if let Some(search) = search_opt { let trimmed_search = search.trim(); if trimmed_search.is_empty() { self.drun_provider.get_elements(search_opt) - } else if contains_math_functions_or_starts_with_number(trimmed_search) { - let result = match meval::eval_str(trimmed_search) { - Ok(result) => result.to_string(), - Err(e) => format!("failed to calculate {e:?}"), - }; - - let item = MenuItem { - label: result, - icon_path: None, - action: Some(trimmed_search.to_owned()), - sub_elements: vec![], - working_dir: None, - initial_sort_score: 0, - search_sort_score: 0.0, - data: Some(AutoRunType::Math), - }; - - return vec![item]; + } else if MathProvider::::contains_math_functions_or_starts_with_number(trimmed_search) { + self.math_provider.get_elements(search_opt) } else if trimmed_search.starts_with("$") || trimmed_search.starts_with("/") || trimmed_search.starts_with("~") { self.file_provider.get_elements(search_opt) } else { - return self.drun_provider.get_elements(search_opt); + self.drun_provider.get_elements(search_opt) } } else { self.drun_provider.get_elements(search_opt) @@ -448,6 +475,25 @@ pub fn file(config: &mut Config) -> Result<(), String> { Ok(()) } +pub fn math(config: &mut Config) -> Result<(), String> { + let provider = MathProvider::new("".to_owned()); + if config.prompt.is_none() { + config.prompt = Some("math".to_owned()); + } + + // todo ues a arc instead of cloning the config + let selection_result = gui::show(config.clone(), provider); + match selection_result { + Ok(_) => { + } + Err(_) => { + log::error!("No item selected"); + } + } + + Ok(()) +} + fn update_drun_cache_and_run( cache_path: Option, cache: &mut HashMap, diff --git a/src/main.rs b/src/main.rs index 196264b..85687c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,9 @@ fn main() -> anyhow::Result<()> { Mode::File => { mode::file(&mut config).map_err(|e| anyhow!(e))?; } + Mode::Math => { + mode::math(&mut config).map_err(|e| anyhow!(e))?; + } Mode::Auto => { mode::auto(&mut config)?; } @@ -39,84 +42,3 @@ fn main() -> anyhow::Result<()> { Err(anyhow!("No mode provided")) } } - -// -// fn main() -> anyhow::Result<()> { -// env_logger::Builder::new() -// // todo change to info as default -// .parse_filters(&std::env::var("RUST_LOG").unwrap_or_else(|_| "debug".to_owned())) -// .init(); -// let args = Args::parse(); -// -// let home_dir = std::env::var("HOME")?; -// let config_path = args.config.as_ref().map(|c| PathBuf::from(c)).unwrap_or_else(||{ -// std::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 ravi -// .join("config") -// }); -// -// 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 toml_content = fs::read_to_string(config_path)?; -// let config: Config = toml::from_str(&toml_content).unwrap_or_default(); -// -// -// -// gtk4::init()?; -// -// let application = Application::builder() -// .application_id("com.example.FirstGtkApp") -// .build(); -// -// application.connect_activate(|app| { -// let window = ApplicationWindow::builder() -// .application(app) -// .title("First GTK Program") -// .name("window") -// .default_width(config.x.clone().unwrap()) -// .default_height(config.y.clone().unwrap()) -// .resizable(false) -// .decorated(false) -// .build(); -// -// -// -// // Create a dialog window -// let dialog = Dialog::new(); -// dialog.set_title(Some("Custom Dialog")); -// dialog.set_default_size(300, 150); -// -// // Create a vertical box container for the dialog content -// let mut vbox =gtk4:: Box::new(Orientation::Horizontal, 10); -// -// // Add a label to the dialog -// let label = Label::new(Some("This is a custom dialog!")); -// vbox.append(&label); -// -// // Set the dialog content -// dialog.set_child(Some(&vbox)); -// -// // Show the dialog -// dialog.present(); -// }); -// -// let empty_array: [&str; 0] = [];; -// -// -// application.run_with_args(&empty_array); -// -// debug!("merged config result {:#?}", config); -// -// -// Ok(()) -// } From e70103b762ab381b1f99f1bca3c37b6e9d644aa2 Mon Sep 17 00:00:00 2001 From: Alexander Mohr Date: Mon, 21 Apr 2025 17:27:28 +0200 Subject: [PATCH 09/12] rework wrap mode --- README.md | 1 + src/lib/config.rs | 79 +++++++++++++++++++++++++++++------------------ src/lib/gui.rs | 49 ++++++++++------------------- 3 files changed, 67 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 689ceab..5dd52df 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ layerrule = blur, worf 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 * Color files are not supported +* `line_wrap` is now called `line-wrap` ## Dropped arguments * `mode`, use show diff --git a/src/lib/config.rs b/src/lib/config.rs index c837734..876318e 100644 --- a/src/lib/config.rs +++ b/src/lib/config.rs @@ -51,6 +51,13 @@ pub enum Animation { ExpandHorizontal, } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum WrapMode { + None, + Word, + Inherit, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub enum Mode { /// searches `$PATH` for executables and allows them to be run by selecting them. @@ -90,7 +97,22 @@ impl FromStr for Mode { "math" => Ok(Mode::Math), "auto" => Ok(Mode::Auto), _ => Err(ArgsError::InvalidParameter( - format!("{s} is not a valid argument show this, see help for details").to_owned(), + format!("{s} is not a valid argument, see help for details").to_owned(), + )), + } + } +} + +impl FromStr for WrapMode { + type Err = ArgsError; + + fn from_str(s: &str) -> Result { + match s { + "none" => Ok(WrapMode::None), + "word" => Ok(WrapMode::Word), + "inherit" => Ok(WrapMode::Inherit), + _ => Err(ArgsError::InvalidParameter( + format!("{s} is not a valid argument, see help for details").to_owned(), )), } } @@ -215,19 +237,23 @@ pub struct Config { #[clap(long = "orientation")] pub orientation: Option, + /// Horizontal alignment #[serde(default = "default_halign")] #[clap(long = "halign")] pub halign: Option, + /// Alignment of content #[serde(default = "default_content_halign")] #[clap(long = "content-halign")] pub content_halign: Option, + /// Vertical alignment #[clap(long = "valign")] pub valign: Option, pub filter_rate: Option, + /// Defines the image size in pixels #[serde(default = "default_image_size")] #[clap(long = "image-size")] pub image_size: Option, @@ -249,7 +275,6 @@ pub struct Config { // todo re-add this // #[serde(flatten)] // pub key_custom: Option>, - pub line_wrap: Option, pub global_coords: Option, pub hide_search: Option, pub dynamic_lines: Option, @@ -268,17 +293,16 @@ pub struct Config { #[clap(long = "row-box-orientation")] pub row_bow_orientation: Option, - /// Set to to true to wrap text after a given amount of chars - #[serde(default = "default_text_wrap")] - #[clap(long = "text-wrap")] - pub text_wrap: Option, - - /// Defines after how many chars a line is broken over. - /// Only cuts at spaces. - #[serde(default = "default_text_wrap_length")] - #[clap(long = "text-wrap-length")] - pub text_wrap_length: Option, - + // /// Set to to true to wrap text after a given amount of chars + // #[serde(default = "default_text_wrap")] + // #[clap(long = "text-wrap")] + // pub text_wrap: Option, + // + // /// Defines after how many chars a line is broken over. + // /// Only cuts at spaces. + // #[serde(default = "default_text_wrap_length")] + // #[clap(long = "text-wrap-length")] + // pub text_wrap_length: Option, /// Defines the animation when the window is show. /// Defaults to Expand #[serde(default = "default_show_animation")] @@ -303,6 +327,10 @@ pub struct Config { #[serde(default = "default_hide_animation_time")] #[clap(long = "hide-animation-time")] pub hide_animation_time: Option, + + #[serde(default = "default_line_wrap")] + #[clap(long = "line-wrap")] + pub line_wrap: Option, } impl Default for Config { @@ -359,7 +387,7 @@ impl Default for Config { key_hide_search: None, key_copy: None, //key_custom: None, - line_wrap: None, + line_wrap: default_line_wrap(), global_coords: None, hide_search: None, dynamic_lines: None, @@ -369,8 +397,6 @@ impl Default for Config { pre_display_exec: None, fuzzy_min_score: default_fuzzy_min_score(), row_bow_orientation: default_row_box_orientation(), - text_wrap: default_text_wrap(), - text_wrap_length: default_text_wrap_length(), show_animation: default_show_animation(), show_animation_time: default_show_animation_time(), hide_animation: default_hide_animation(), @@ -449,6 +475,13 @@ pub fn default_normal_window() -> bool { false } +// allowed because option is needed for serde macro +#[allow(clippy::unnecessary_wraps)] +#[must_use] +pub fn default_line_wrap() -> Option { + Some(WrapMode::Word) +} + // TODO // GtkOrientation orientation = config_get_mnemonic(config, "orientation", "vertical", 2, "vertical", "horizontal"); // outer_orientation = config_get_mnemonic(cstoonfig, "orientation", "vertical", 2, "horizontal", "vertical"); @@ -588,20 +621,6 @@ pub fn default_image_size() -> Option { Some(32) } -// allowed because option is needed for serde macro -#[allow(clippy::unnecessary_wraps)] -#[must_use] -pub fn default_text_wrap_length() -> Option { - Some(15) -} - -// allowed because option is needed for serde macro -#[allow(clippy::unnecessary_wraps)] -#[must_use] -pub fn default_text_wrap() -> Option { - Some(false) -} - #[must_use] pub fn parse_args() -> Config { Config::parse() diff --git a/src/lib/gui.rs b/src/lib/gui.rs index 15cdae1..f52ab74 100644 --- a/src/lib/gui.rs +++ b/src/lib/gui.rs @@ -15,16 +15,13 @@ use gtk4::prelude::{ ApplicationExt, ApplicationExtManual, BoxExt, EditableExt, FlowBoxChildExt, GestureSingleExt, GtkWindowExt, ListBoxRowExt, NativeExt, WidgetExt, }; -use gtk4::{ - Align, EventControllerKey, Expander, FlowBox, FlowBoxChild, GestureClick, Image, Label, - ListBox, ListBoxRow, Ordering, PolicyType, ScrolledWindow, SearchEntry, Widget, gdk, -}; +use gtk4::{Align, EventControllerKey, Expander, FlowBox, FlowBoxChild, GestureClick, Image, Label, ListBox, ListBoxRow, Ordering, PolicyType, ScrolledWindow, SearchEntry, Widget, gdk, NaturalWrapMode}; use gtk4::{Application, ApplicationWindow, CssProvider, Orientation}; use gtk4_layer_shell::{Edge, KeyboardMode, LayerShell}; use log; use crate::config; -use crate::config::{Animation, Config, MatchMethod}; +use crate::config::{Animation, Config, MatchMethod, WrapMode}; type ArcMenuMap = Arc>>>; type ArcProvider = Arc>>; @@ -44,6 +41,16 @@ impl From for Orientation { } } +impl From<&WrapMode> for NaturalWrapMode { + fn from(value: &WrapMode) -> Self { + match value { + WrapMode::None => {NaturalWrapMode::None}, + WrapMode::Word => {NaturalWrapMode::Word}, + WrapMode::Inherit => {NaturalWrapMode::Inherit}, + } + } +} + impl From for Align { fn from(align: config::Align) -> Self { match align { @@ -808,14 +815,14 @@ fn create_menu_row( row_box.append(&image); } - // todo make max length configurable - let text = if config.text_wrap.is_some_and(|x| x) { - &wrap_text(&menu_item.label, config.text_wrap_length) + let label = Label::new(Some(menu_item.label.as_str())); + let wrap_mode : NaturalWrapMode = if let Some(config_wrap) = &config.line_wrap { + config_wrap.into() } else { - menu_item.label.as_str() + NaturalWrapMode::Word }; - let label = Label::new(Some(text)); + label.set_natural_wrap_mode(wrap_mode); label.set_hexpand(true); label.set_widget_name("label"); label.set_wrap(true); @@ -955,25 +962,3 @@ pub fn sort_menu_items_alphabetically_honor_initial_score( } } } - -fn wrap_text(text: &str, line_length: Option) -> String { - let mut result = String::new(); - let mut line = String::new(); - let len = line_length.unwrap_or(text.len()); - - for word in text.split_whitespace() { - if line.len() + word.len() + 1 > len && !line.is_empty() { - result.push_str(line.trim_end()); - result.push('\n'); - line.clear(); - } - line.push_str(word); - line.push(' '); - } - - if !line.is_empty() { - result.push_str(line.trim_end()); - } - - result -} From 0bcf15d08cecbc61fa34e95592bddde449fd004c Mon Sep 17 00:00:00 2001 From: Alexander Mohr Date: Mon, 21 Apr 2025 17:48:47 +0200 Subject: [PATCH 10/12] fix auto prompt --- README.md | 1 + src/lib/config.rs | 9 +++------ src/lib/mode.rs | 21 ++++----------------- src/main.rs | 8 ++++---- 4 files changed, 12 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 5dd52df..f3a58f7 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ layerrule = blur, worf ## Dropped arguments * `mode`, use show +* `dmenu`, use show * `D`, arguments are the same as config in worf, no need to have this flag. ### Dropped configuration options diff --git a/src/lib/config.rs b/src/lib/config.rs index 876318e..d9b287a 100644 --- a/src/lib/config.rs +++ b/src/lib/config.rs @@ -129,10 +129,6 @@ pub struct Config { #[clap(short = 'c', long = "conf")] pub config: Option, - /// Runs in dmenu mode - #[clap(short = 'd', long = "dmenu")] - pub dmenu: Option, - /// Prints the version and then exits #[clap(short = 'v', long = "version")] pub version: Option, @@ -338,7 +334,6 @@ impl Default for Config { Config { fork: None, config: None, - dmenu: None, version: None, style: default_style(), show: None, @@ -705,7 +700,9 @@ pub fn load_config(args_opt: Option) -> Result merge_result.prompt = Some("run".to_owned()), Mode::Drun => merge_result.prompt = Some("drun".to_owned()), Mode::Dmenu => merge_result.prompt = Some("dmenu".to_owned()), - _ => {} + Mode::Math => merge_result.prompt = Some("math".to_owned()), + Mode::File => merge_result.prompt = Some("file".to_owned()), + Mode::Auto => merge_result.prompt = Some("auto".to_owned()), }, } } diff --git a/src/lib/mode.rs b/src/lib/mode.rs index 4bc327e..065e53a 100644 --- a/src/lib/mode.rs +++ b/src/lib/mode.rs @@ -392,13 +392,10 @@ impl ItemProvider for AutoItemProvider { /// # Errors /// /// Will return `Err` if it was not able to spawn the process -pub fn d_run(config: &mut Config) -> anyhow::Result<()> { +pub fn d_run(config: &Config) -> anyhow::Result<()> { let provider = DRunProvider::new("".to_owned()); let cache_path = provider.cache_path.clone(); let mut cache = provider.cache.clone(); - if config.prompt.is_none() { - config.prompt = Some("drun".to_owned()); - } // todo ues a arc instead of cloning the config let selection_result = gui::show(config.clone(), provider); @@ -414,15 +411,11 @@ pub fn d_run(config: &mut Config) -> anyhow::Result<()> { Ok(()) } -pub fn auto(config: &mut Config) -> anyhow::Result<()> { +pub fn auto(config: &Config) -> anyhow::Result<()> { let provider = AutoItemProvider::new(); let cache_path = provider.drun_provider.cache_path.clone(); let mut cache = provider.drun_provider.cache.clone(); - if config.prompt.is_none() { - config.prompt = Some("auto".to_owned()); - } - // todo ues a arc instead of cloning the config let selection_result = gui::show(config.clone(), provider); @@ -453,11 +446,8 @@ pub fn auto(config: &mut Config) -> anyhow::Result<()> { Ok(()) } -pub fn file(config: &mut Config) -> Result<(), String> { +pub fn file(config: &Config) -> Result<(), String> { let provider = FileItemProvider::new("".to_owned()); - if config.prompt.is_none() { - config.prompt = Some("file".to_owned()); - } // todo ues a arc instead of cloning the config let selection_result = gui::show(config.clone(), provider); @@ -475,11 +465,8 @@ pub fn file(config: &mut Config) -> Result<(), String> { Ok(()) } -pub fn math(config: &mut Config) -> Result<(), String> { +pub fn math(config: &Config) -> Result<(), String> { let provider = MathProvider::new("".to_owned()); - if config.prompt.is_none() { - config.prompt = Some("math".to_owned()); - } // todo ues a arc instead of cloning the config let selection_result = gui::show(config.clone(), provider); diff --git a/src/main.rs b/src/main.rs index 85687c9..e024387 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,19 +21,19 @@ fn main() -> anyhow::Result<()> { todo!("run not implemented") } Mode::Drun => { - mode::d_run(&mut config)?; + mode::d_run(&config)?; } Mode::Dmenu => { todo!("dmenu not implemented") } Mode::File => { - mode::file(&mut config).map_err(|e| anyhow!(e))?; + mode::file(&config).map_err(|e| anyhow!(e))?; } Mode::Math => { - mode::math(&mut config).map_err(|e| anyhow!(e))?; + mode::math(&config).map_err(|e| anyhow!(e))?; } Mode::Auto => { - mode::auto(&mut config)?; + mode::auto(&config)?; } } From 66326fc841bc8f7e6b58cd373280e52caf8ce127 Mon Sep 17 00:00:00 2001 From: Alexander Mohr Date: Mon, 21 Apr 2025 19:30:07 +0200 Subject: [PATCH 11/12] add support for location --- README.md | 14 ++++--- examples/dmenu.sh | 12 ++++++ src/lib/config.rs | 38 +++++++++++++---- src/lib/desktop.rs | 60 ++++----------------------- src/lib/gui.rs | 39 +++++++++++++----- src/lib/mode.rs | 100 ++++++++++++++++++++++++++------------------- src/main.rs | 8 ++-- 7 files changed, 151 insertions(+), 120 deletions(-) create mode 100755 examples/dmenu.sh diff --git a/README.md b/README.md index f3a58f7..d19c523 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Allow blur for Worf layerrule = blur, worf ``` -## Additional functionality compared to Wofi (planed) +## Additional functionality compared to Wofi * Support passing 'hidden' parameters that are not visible in the launcher but will be returned to the application * Window switcher for hyprland * All arguments expect show are supported by config and args @@ -32,6 +32,12 @@ layerrule = blur, worf * `label`: Allows styling the label * `row`: Allows styling to row, mainly used to disable hover effects +## Library + +The launcher and UI can be used to build any launcher, as the ui, config and run logic is available as a separate crate. +This library is not available publicly yet as the interface is not stable enough. + + ## Breaking changes to Wofi * 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, @@ -39,6 +45,7 @@ layerrule = blur, worf * Configuration files are not 100% compatible, Worf is using toml files instead, for most part this only means strings have to be quoted * Color files are not supported * `line_wrap` is now called `line-wrap` +* Wofi has a C-API, that is not and won't be supported. ## Dropped arguments * `mode`, use show @@ -48,8 +55,3 @@ layerrule = blur, worf ### Dropped configuration options * stylesheet -> use style instead * color / colors -> GTK4 does not support color files - - - -## Not supported -* Wofi has a C-API, that is not and won't be supported. diff --git a/examples/dmenu.sh b/examples/dmenu.sh new file mode 100755 index 0000000..9157161 --- /dev/null +++ b/examples/dmenu.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# A list of options, one per line +options="Option 1 +Option 2 +Option 3" + +# Pipe options to wofi and capture the selection +selection=$(echo "$options" | cargo run -- --show dmenu) + +# Do something with the selection +echo "You selected: $selection" diff --git a/src/lib/config.rs b/src/lib/config.rs index d9b287a..66896ec 100644 --- a/src/lib/config.rs +++ b/src/lib/config.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use std::str::FromStr; use std::{env, fmt, fs}; -use anyhow::{Error, anyhow}; +use anyhow::anyhow; use clap::{Parser, ValueEnum}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -23,6 +23,14 @@ impl fmt::Display for ConfigurationError { } } +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Serialize, Deserialize)] +pub enum Anchor { + Top, + Left, + Bottom, + Right, +} + #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug, Serialize, Deserialize)] pub enum MatchMethod { Fuzzy, @@ -85,6 +93,20 @@ pub enum ArgsError { InvalidParameter(String), } +impl FromStr for Anchor { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.trim() { + "top" => Ok(Anchor::Top), + "left" => Ok(Anchor::Left), + "bottom" => Ok(Anchor::Bottom), + "right" => Ok(Anchor::Right), + other => Err(format!("Invalid anchor: {}", other)), + } + } +} + impl FromStr for Mode { type Err = ArgsError; @@ -201,8 +223,11 @@ pub struct Config { #[clap(short = 'q', long = "parse-search")] pub parse_search: Option, - #[clap(short = 'l', long = "location")] - pub location: Option, + /// set where the window is displayed. + /// can be used to anchor a window to an edge by + /// setting top,left for example + #[clap(short = 'l', long = "location", value_delimiter = ',', value_parser = clap::builder::ValueParser::new(Anchor::from_str))] + pub location: Option>, #[clap(short = 'a', long = "no-actions")] pub no_actions: Option, @@ -508,7 +533,6 @@ pub fn default_line_wrap() -> Option { // max_lines = lines; // columns = strtol(config_get(config, "columns", "1"), NULL, 10); // sort_order = config_get_mnemonic(config, "sort_order", "default", 2, "default", "alphabetical"); -// line_wrap = config_get_mnemonic(config, "line_wrap", "off", 4, "off", "word", "char", "word_char") - 1; // bool global_coords = strcmp(config_get(config, "global_coords", "false"), "true") == 0; // hide_search = strcmp(config_get(config, "hide_search", "false"), "true") == 0; // char* search = map_get(config, "search"); @@ -700,9 +724,9 @@ pub fn load_config(args_opt: Option) -> Result merge_result.prompt = Some("run".to_owned()), Mode::Drun => merge_result.prompt = Some("drun".to_owned()), Mode::Dmenu => merge_result.prompt = Some("dmenu".to_owned()), - Mode::Math => merge_result.prompt = Some("math".to_owned()), - Mode::File => merge_result.prompt = Some("file".to_owned()), - Mode::Auto => merge_result.prompt = Some("auto".to_owned()), + Mode::Math => merge_result.prompt = Some("math".to_owned()), + Mode::File => merge_result.prompt = Some("file".to_owned()), + Mode::Auto => merge_result.prompt = Some("auto".to_owned()), }, } } diff --git a/src/lib/desktop.rs b/src/lib/desktop.rs index 0681c72..72e88a3 100644 --- a/src/lib/desktop.rs +++ b/src/lib/desktop.rs @@ -1,70 +1,26 @@ -use anyhow::anyhow; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; +use std::{env, fs, string}; + use freedesktop_file_parser::DesktopFile; use gdk4::Display; use gtk4::prelude::*; use gtk4::{IconLookupFlags, IconTheme, TextDirection}; use home::home_dir; -use log::{debug, info, warn}; +use log; use regex::Regex; -use std::collections::HashMap; -use std::path::Path; -use std::path::PathBuf; -use std::{env, fs, string}; #[derive(Debug)] pub enum DesktopError { MissingIcon, } -// -// #[derive(Clone)] -// pub struct IconResolver { -// cache: HashMap, -// } -// -// impl Default for IconResolver { -// #[must_use] -// fn default() -> IconResolver { -// Self::new() -// } -// } -// -// impl IconResolver { -// #[must_use] -// pub fn new() -> IconResolver { -// IconResolver { -// cache: HashMap::new(), -// } -// } -// -// pub fn icon_path_no_cache(&self, icon_name: &str) -> Result { -// let icon = fetch_icon_from_theme(icon_name) -// .or_else(|_| -// fetch_icon_from_common_dirs(icon_name) -// .or_else(|_| default_icon())); -// -// icon -// } -// -// pub fn icon_path(&mut self, icon_name: &str) -> String { -// if let Some(icon_path) = self.cache.get(icon_name) { -// return icon_path.to_owned(); -// } -// -// let icon = self.icon_path_no_cache(icon_name); -// -// self.cache -// .entry(icon_name.to_owned()) -// .or_insert_with(|| icon.unwrap_or_default()) -// .to_owned() -// } -// } - /// # Errors /// /// Will return `Err` if no icon can be found pub fn default_icon() -> Result { - fetch_icon_from_theme("image-missing").map_err(|e| DesktopError::MissingIcon) + fetch_icon_from_theme("image-missing").map_err(|_| DesktopError::MissingIcon) } fn fetch_icon_from_theme(icon_name: &str) -> Result { @@ -177,7 +133,7 @@ pub fn find_desktop_files() -> Vec { .filter_map(|icon_dir| find_file_case_insensitive(&icon_dir, regex)) .flat_map(|desktop_files| { desktop_files.into_iter().filter_map(|desktop_file| { - debug!("loading desktop file {desktop_file:?}"); + log::debug!("loading desktop file {desktop_file:?}"); fs::read_to_string(desktop_file) .ok() .and_then(|content| freedesktop_file_parser::parse(&content).ok()) diff --git a/src/lib/gui.rs b/src/lib/gui.rs index f52ab74..d0d19a6 100644 --- a/src/lib/gui.rs +++ b/src/lib/gui.rs @@ -1,5 +1,4 @@ use std::collections::HashMap; -use std::ops::DerefMut; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -15,13 +14,17 @@ use gtk4::prelude::{ ApplicationExt, ApplicationExtManual, BoxExt, EditableExt, FlowBoxChildExt, GestureSingleExt, GtkWindowExt, ListBoxRowExt, NativeExt, WidgetExt, }; -use gtk4::{Align, EventControllerKey, Expander, FlowBox, FlowBoxChild, GestureClick, Image, Label, ListBox, ListBoxRow, Ordering, PolicyType, ScrolledWindow, SearchEntry, Widget, gdk, NaturalWrapMode}; +use gtk4::{ + Align, EventControllerKey, Expander, FlowBox, FlowBoxChild, GestureClick, Image, Label, + ListBox, ListBoxRow, NaturalWrapMode, Ordering, PolicyType, ScrolledWindow, SearchEntry, + Widget, gdk, +}; use gtk4::{Application, ApplicationWindow, CssProvider, Orientation}; use gtk4_layer_shell::{Edge, KeyboardMode, LayerShell}; use log; use crate::config; -use crate::config::{Animation, Config, MatchMethod, WrapMode}; +use crate::config::{Anchor, Animation, Config, MatchMethod, WrapMode}; type ArcMenuMap = Arc>>>; type ArcProvider = Arc>>; @@ -32,6 +35,17 @@ pub trait ItemProvider { fn get_sub_elements(&mut self, item: &MenuItem) -> Option>>; } +impl From<&Anchor> for Edge { + fn from(value: &Anchor) -> Self { + match value { + Anchor::Top => Edge::Top, + Anchor::Left => Edge::Left, + Anchor::Bottom => Edge::Bottom, + Anchor::Right => Edge::Right, + } + } +} + impl From for Orientation { fn from(orientation: config::Orientation) -> Self { match orientation { @@ -44,9 +58,9 @@ impl From for Orientation { impl From<&WrapMode> for NaturalWrapMode { fn from(value: &WrapMode) -> Self { match value { - WrapMode::None => {NaturalWrapMode::None}, - WrapMode::Word => {NaturalWrapMode::Word}, - WrapMode::Inherit => {NaturalWrapMode::Inherit}, + WrapMode::None => NaturalWrapMode::None, + WrapMode::Word => NaturalWrapMode::Word, + WrapMode::Inherit => NaturalWrapMode::Inherit, } } } @@ -141,8 +155,11 @@ fn build_ui( window.set_namespace(Some("worf")); } - /// todo make this configurable - //window.set_anchor(Edge::Top, true); + config.location.as_ref().map(|location| { + for anchor in location { + window.set_anchor(anchor.into(), true); + } + }); let outer_box = gtk4::Box::new(config.orientation.unwrap().into(), 0); outer_box.set_widget_name("outer-box"); @@ -816,7 +833,7 @@ fn create_menu_row( } let label = Label::new(Some(menu_item.label.as_str())); - let wrap_mode : NaturalWrapMode = if let Some(config_wrap) = &config.line_wrap { + let wrap_mode: NaturalWrapMode = if let Some(config_wrap) = &config.line_wrap { config_wrap.into() } else { NaturalWrapMode::Word @@ -951,7 +968,9 @@ fn percent_or_absolute(value: Option<&String>, base_value: i32) -> Option { // highly unlikely that we are dealing with > i64 items #[allow(clippy::cast_possible_wrap)] -pub fn sort_menu_items_alphabetically_honor_initial_score(items: &mut [MenuItem]) { +pub fn sort_menu_items_alphabetically_honor_initial_score( + items: &mut [MenuItem], +) { let mut regular_score = items.len() as i64; items.sort_by(|l, r| l.label.cmp(&r.label)); diff --git a/src/lib/mode.rs b/src/lib/mode.rs index 065e53a..9fc85f5 100644 --- a/src/lib/mode.rs +++ b/src/lib/mode.rs @@ -1,21 +1,39 @@ +use std::os::unix::prelude::CommandExt; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::{env, fmt, fs, io}; + +use anyhow::Context; +use freedesktop_file_parser::EntryType; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + use crate::config::{Config, expand_path}; use crate::desktop::{ default_icon, find_desktop_files, get_locale_variants, lookup_name_with_locale, }; +use crate::gui; use crate::gui::{ItemProvider, MenuItem}; -use crate::{config, desktop, gui}; -use anyhow::{Context, Error, anyhow}; -use freedesktop_file_parser::EntryType; -use gtk4::Image; -use libc::option; -use regex::Regex; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::os::unix::fs::PermissionsExt; -use std::os::unix::prelude::CommandExt; -use std::path::PathBuf; -use std::process::{Command, Stdio}; -use std::{env, fs, io}; + +#[derive(Debug)] +pub enum ModeError { + UpdateCacheError(String), + MissingAction, + RunError(String), + MissingCache, +} + +impl fmt::Display for ModeError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ModeError::UpdateCacheError(s) => write!(f, "UpdateCacheError {s}"), + ModeError::MissingAction => write!(f, "MissingAction"), + ModeError::RunError(s) => write!(f, "RunError, {s}"), + ModeError::MissingCache => write!(f, "MissingCache"), + } + } +} #[derive(Debug, Deserialize, Serialize, Clone)] struct DRunCache { @@ -139,7 +157,7 @@ impl ItemProvider for DRunProvider { self.items.clone() } - fn get_sub_elements(&mut self, item: &MenuItem) -> Option>> { + fn get_sub_elements(&mut self, _: &MenuItem) -> Option>> { None } } @@ -283,9 +301,7 @@ struct MathProvider { impl MathProvider { fn new(menu_item_data: T) -> Self { - Self { - menu_item_data, - } + Self { menu_item_data } } fn contains_math_functions_or_starts_with_number(input: &str) -> bool { @@ -327,7 +343,7 @@ impl ItemProvider for MathProvider { } } - fn get_sub_elements(&mut self, item: &MenuItem) -> Option>> { + fn get_sub_elements(&mut self, _: &MenuItem) -> Option>> { None } } @@ -366,7 +382,9 @@ impl ItemProvider for AutoItemProvider { let trimmed_search = search.trim(); if trimmed_search.is_empty() { self.drun_provider.get_elements(search_opt) - } else if MathProvider::::contains_math_functions_or_starts_with_number(trimmed_search) { + } else if MathProvider::::contains_math_functions_or_starts_with_number( + trimmed_search, + ) { self.math_provider.get_elements(search_opt) } else if trimmed_search.starts_with("$") || trimmed_search.starts_with("/") @@ -392,7 +410,7 @@ impl ItemProvider for AutoItemProvider { /// # Errors /// /// Will return `Err` if it was not able to spawn the process -pub fn d_run(config: &Config) -> anyhow::Result<()> { +pub fn d_run(config: &Config) -> Result<(), ModeError> { let provider = DRunProvider::new("".to_owned()); let cache_path = provider.cache_path.clone(); let mut cache = provider.cache.clone(); @@ -400,9 +418,7 @@ pub fn d_run(config: &Config) -> anyhow::Result<()> { // todo ues a arc instead of cloning the config let selection_result = gui::show(config.clone(), provider); match selection_result { - Ok(s) => { - update_drun_cache_and_run(cache_path, &mut cache, s)?; - } + Ok(s) => update_drun_cache_and_run(cache_path, &mut cache, s)?, Err(_) => { log::error!("No item selected"); } @@ -411,7 +427,7 @@ pub fn d_run(config: &Config) -> anyhow::Result<()> { Ok(()) } -pub fn auto(config: &Config) -> anyhow::Result<()> { +pub fn auto(config: &Config) -> Result<(), ModeError> { let provider = AutoItemProvider::new(); let cache_path = provider.drun_provider.cache_path.clone(); let mut cache = provider.drun_provider.cache.clone(); @@ -429,12 +445,9 @@ pub fn auto(config: &Config) -> anyhow::Result<()> { } AutoRunType::File => { if let Some(action) = selection_result.action { - spawn_fork(&action, selection_result.working_dir.as_ref())? + spawn_fork(&action, selection_result.working_dir.as_ref())?; } } - _ => { - todo!("not supported yet"); - } } } } @@ -446,7 +459,7 @@ pub fn auto(config: &Config) -> anyhow::Result<()> { Ok(()) } -pub fn file(config: &Config) -> Result<(), String> { +pub fn file(config: &Config) -> Result<(), ModeError> { let provider = FileItemProvider::new("".to_owned()); // todo ues a arc instead of cloning the config @@ -454,7 +467,7 @@ pub fn file(config: &Config) -> Result<(), String> { match selection_result { Ok(s) => { if let Some(action) = s.action { - spawn_fork(&action, s.working_dir.as_ref()).map_err(|e| e.to_string())?; + spawn_fork(&action, s.working_dir.as_ref())?; } } Err(_) => { @@ -465,14 +478,13 @@ pub fn file(config: &Config) -> Result<(), String> { Ok(()) } -pub fn math(config: &Config) -> Result<(), String> { +pub fn math(config: &Config) -> Result<(), ModeError> { let provider = MathProvider::new("".to_owned()); // todo ues a arc instead of cloning the config let selection_result = gui::show(config.clone(), provider); match selection_result { - Ok(_) => { - } + Ok(_) => {} Err(_) => { log::error!("No item selected"); } @@ -481,11 +493,15 @@ pub fn math(config: &Config) -> Result<(), String> { Ok(()) } +pub fn dmenu(_: &Config) -> Result<(), ModeError> { + Ok(()) +} + fn update_drun_cache_and_run( cache_path: Option, cache: &mut HashMap, selection_result: MenuItem, -) -> Result<(), Error> { +) -> Result<(), ModeError> { if let Some(cache_path) = cache_path { *cache.entry(selection_result.label).or_insert(0) += 1; if let Err(e) = save_cache_file(&cache_path, &cache) { @@ -496,7 +512,7 @@ fn update_drun_cache_and_run( if let Some(action) = selection_result.action { spawn_fork(&action, selection_result.working_dir.as_ref()) } else { - Err(anyhow::anyhow!("cannot find drun action")) + Err(ModeError::MissingAction) } } @@ -520,12 +536,13 @@ fn save_cache_file(path: &PathBuf, data: &HashMap) -> anyhow::Resul fs::write(path, toml_string).map_err(|e| anyhow::anyhow!(e)) } -fn load_cache_file(cache_path: Option<&PathBuf>) -> anyhow::Result> { +fn load_cache_file(cache_path: Option<&PathBuf>) -> Result, ModeError> { let Some(path) = cache_path else { - return Err(anyhow!("Cache is missing")); + return Err(ModeError::MissingCache); }; - let toml_content = fs::read_to_string(path)?; + let toml_content = + fs::read_to_string(path).map_err(|e| ModeError::UpdateCacheError(format!("{e}")))?; let parsed: toml::Value = toml_content.parse().expect("Failed to parse TOML"); let mut result: HashMap = HashMap::new(); @@ -555,17 +572,18 @@ fn create_file_if_not_exists(path: &PathBuf) -> anyhow::Result<()> { } } -fn spawn_fork(cmd: &str, working_dir: Option<&String>) -> anyhow::Result<()> { +fn spawn_fork(cmd: &str, working_dir: Option<&String>) -> Result<(), ModeError> { // todo fix actions ?? // todo graphical disk map icon not working let parts = cmd.split(' ').collect::>(); if parts.is_empty() { - return Err(anyhow!("empty command passed")); + return Err(ModeError::MissingAction); } if let Some(dir) = working_dir { - env::set_current_dir(dir)?; + env::set_current_dir(dir) + .map_err(|e| ModeError::RunError(format!("cannot set workdir {e}")))? } let exec = parts[0].replace('"', ""); diff --git a/src/main.rs b/src/main.rs index e024387..65f3df5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ fn main() -> anyhow::Result<()> { .init(); let args = config::parse_args(); - let mut config = config::load_config(Some(args)).map_err(|e| anyhow!(e))?; + let config = config::load_config(Some(args)).map_err(|e| anyhow!(e))?; if let Some(show) = &config.show { match show { @@ -21,10 +21,10 @@ fn main() -> anyhow::Result<()> { todo!("run not implemented") } Mode::Drun => { - mode::d_run(&config)?; + mode::d_run(&config).map_err(|e| anyhow!(e))?; } Mode::Dmenu => { - todo!("dmenu not implemented") + mode::dmenu(&config).map_err(|e| anyhow!(e))?; } Mode::File => { mode::file(&config).map_err(|e| anyhow!(e))?; @@ -33,7 +33,7 @@ fn main() -> anyhow::Result<()> { mode::math(&config).map_err(|e| anyhow!(e))?; } Mode::Auto => { - mode::auto(&config)?; + mode::auto(&config).map_err(|e| anyhow!(e))?; } } From 025e79ccf73695fe8bcc8d011ebb56eca16d912c Mon Sep 17 00:00:00 2001 From: Alexander Mohr Date: Mon, 21 Apr 2025 20:07:47 +0200 Subject: [PATCH 12/12] fix clippy --- src/lib/config.rs | 15 +++--- src/lib/desktop.rs | 36 ++++++++++----- src/lib/gui.rs | 86 +++++++++++++++++----------------- src/lib/mode.rs | 112 ++++++++++++++++++++++++++------------------- src/main.rs | 2 +- 5 files changed, 138 insertions(+), 113 deletions(-) diff --git a/src/lib/config.rs b/src/lib/config.rs index 66896ec..b1ca6e2 100644 --- a/src/lib/config.rs +++ b/src/lib/config.rs @@ -17,8 +17,7 @@ pub enum ConfigurationError { impl fmt::Display for ConfigurationError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { - ConfigurationError::Open(e) => write!(f, "{e}"), - ConfigurationError::Parse(e) => write!(f, "{e}"), + ConfigurationError::Open(e) | ConfigurationError::Parse(e) => write!(f, "{e}"), } } } @@ -102,7 +101,7 @@ impl FromStr for Anchor { "left" => Ok(Anchor::Left), "bottom" => Ok(Anchor::Bottom), "right" => Ok(Anchor::Right), - other => Err(format!("Invalid anchor: {}", other)), + other => Err(format!("Invalid anchor: {other}")), } } } @@ -705,7 +704,7 @@ pub fn resolve_path( /// * no config file exists /// * config file and args cannot be merged pub fn load_config(args_opt: Option) -> Result { - let config_path = conf_path(args_opt.as_ref().map(|c| c.config.clone()).flatten()); + let config_path = conf_path(args_opt.as_ref().and_then(|c| c.config.clone())); match config_path { Ok(path) => { let toml_content = @@ -740,20 +739,22 @@ pub fn load_config(args_opt: Option) -> Result Err(ConfigurationError::Open(format!("{e}"))), } } + +#[must_use] pub fn expand_path(input: &str) -> PathBuf { let mut path = input.to_string(); // Expand ~ to home directory - if path.starts_with("~") { + if path.starts_with('~') { if let Some(home_dir) = dirs::home_dir() { - path = path.replacen("~", home_dir.to_str().unwrap_or(""), 1); + path = path.replacen('~', home_dir.to_str().unwrap_or(""), 1); } } // Expand $VAR style environment variables if path.contains('$') { for (key, value) in env::vars() { - let var_pattern = format!("${}", key); + let var_pattern = format!("${key}"); if path.contains(&var_pattern) { path = path.replace(&var_pattern, &value); } diff --git a/src/lib/desktop.rs b/src/lib/desktop.rs index 72e88a3..f3239a4 100644 --- a/src/lib/desktop.rs +++ b/src/lib/desktop.rs @@ -14,6 +14,7 @@ use regex::Regex; #[derive(Debug)] pub enum DesktopError { MissingIcon, + ParsingError(String), } /// # Errors @@ -60,6 +61,10 @@ fn fetch_icon_from_theme(icon_name: &str) -> Result { } } +/// # Errors +/// +/// Will return `Err` +/// * if it was not able to find any icon pub fn fetch_icon_from_common_dirs(icon_name: &str) -> Result { let mut paths = vec![ PathBuf::from("/usr/local/share/icons"), @@ -72,16 +77,22 @@ pub fn fetch_icon_from_common_dirs(icon_name: &str) -> Result Option> { @@ -102,9 +113,10 @@ fn find_file_case_insensitive(folder: &Path, file_name: &Regex) -> Option Vec { let mut paths = vec![ PathBuf::from("/usr/share/applications"), diff --git a/src/lib/gui.rs b/src/lib/gui.rs index d0d19a6..7204a8f 100644 --- a/src/lib/gui.rs +++ b/src/lib/gui.rs @@ -155,11 +155,11 @@ fn build_ui( window.set_namespace(Some("worf")); } - config.location.as_ref().map(|location| { + if let Some(location) = config.location.as_ref() { for anchor in location { window.set_anchor(anchor.into(), true); } - }); + } let outer_box = gtk4::Box::new(config.orientation.unwrap().into(), 0); outer_box.set_widget_name("outer-box"); @@ -210,16 +210,15 @@ fn build_ui( &item_provider.lock().unwrap().get_elements(None), &list_items, &inner_box, - &config, - &sender, - &app, + config, + sender, + app, &window, ); let items_sort = ArcMenuMap::clone(&list_items); - inner_box.set_sort_func(move |child1, child2| { - sort_menu_items_by_score(child1, child2, items_sort.clone()) - }); + inner_box + .set_sort_func(move |child1, child2| sort_menu_items_by_score(child1, child2, &items_sort)); let items_focus = ArcMenuMap::clone(&list_items); inner_box.connect_map(move |fb| { @@ -235,7 +234,7 @@ fn build_ui( setup_key_event_handler( &window, - entry.clone(), + &entry, inner_box, app.clone(), sender.clone(), @@ -246,7 +245,7 @@ fn build_ui( window.set_child(Widget::NONE); window.show(); - animate_window_show(config.clone(), window.clone(), outer_box); + animate_window_show(config, window.clone(), outer_box); } fn build_ui_from_menu_items( @@ -262,30 +261,26 @@ fn build_ui_from_menu_items( let mut arc_lock = list_items.lock().unwrap(); inner_box.unset_sort_func(); - loop { - if let Some(b) = inner_box.child_at_index(0) { - inner_box.remove(&b); - } else { - break; - } + while let Some(b) = inner_box.child_at_index(0) { + inner_box.remove(&b); } for entry in items { arc_lock.insert( - add_menu_item(&inner_box, entry, config, sender, &list_items, app, window), + add_menu_item(inner_box, entry, config, sender, list_items, app, window), (*entry).clone(), ); } } - let lic = list_items.clone(); - inner_box - .set_sort_func(move |child2, child1| sort_menu_items_by_score(child1, child2, lic.clone())); + let lic = ArcMenuMap::clone(list_items); + inner_box.set_sort_func(move |child2, child1| sort_menu_items_by_score(child1, child2, &lic)); inner_box.invalidate_sort(); } +#[allow(clippy::too_many_arguments)] // todo fix this fn setup_key_event_handler( window: &ApplicationWindow, - entry: SearchEntry, + entry: &SearchEntry, inner_box: FlowBox, app: Application, sender: MenuItemSender, @@ -307,13 +302,14 @@ fn setup_key_event_handler( &config, &item_provider, &window_clone, - &key_value, + key_value, ) }); window.add_controller(key_controller); } +#[allow(clippy::too_many_arguments)] // todo refactor this? fn handle_key_press( search_entry: &SearchEntry, inner_box: &FlowBox, @@ -323,54 +319,54 @@ fn handle_key_press( config: &Config, item_provider: &ArcProvider, window_clone: &ApplicationWindow, - keyboard_key: &Key, + keyboard_key: Key, ) -> Propagation { let update_view = |query: &String, items: Vec>| { build_ui_from_menu_items( &items, - &list_items, - &inner_box, - &config, - &sender, - &app, - &window_clone, + list_items, + inner_box, + config, + sender, + app, + window_clone, ); - filter_widgets(query, list_items, &config, &inner_box); - select_first_visible_child(&list_items, &inner_box); + filter_widgets(query, list_items, config, inner_box); + select_first_visible_child(list_items, inner_box); }; let update_view_from_provider = |query: &String| { - let filtered_list = item_provider.lock().unwrap().get_elements(Some(&query)); - update_view(query, filtered_list) + let filtered_list = item_provider.lock().unwrap().get_elements(Some(query)); + update_view(query, filtered_list); }; match keyboard_key { - &Key::Escape => { + Key::Escape => { if let Err(e) = sender.send(Err(anyhow!("No item selected"))) { log::error!("failed to send message {e}"); } - close_gui(app.clone(), window_clone.clone(), &config); + close_gui(app.clone(), window_clone.clone(), config); } - &Key::Return => { + Key::Return => { if let Err(e) = handle_selected_item( - &sender, + sender, app.clone(), window_clone.clone(), - &config, - &inner_box, - &list_items, + config, + inner_box, + list_items, ) { log::error!("{e}"); } } - &Key::BackSpace => { + Key::BackSpace => { let mut query = search_entry.text().to_string(); query.pop(); search_entry.set_text(&query); update_view_from_provider(&query); } - &Key::Tab => { + Key::Tab => { if let Some(fb) = inner_box.selected_children().first() { if let Some(child) = fb.child() { let expander = child.downcast::().ok(); @@ -381,7 +377,7 @@ fn handle_key_press( let menu_item = lock.get(fb); if let Some(menu_item) = menu_item { if let Some(new_items) = - item_provider.lock().unwrap().get_sub_elements(&menu_item) + item_provider.lock().unwrap().get_sub_elements(menu_item) { let query = menu_item.label.clone(); drop(lock); @@ -411,7 +407,7 @@ fn handle_key_press( fn sort_menu_items_by_score( child1: &FlowBoxChild, child2: &FlowBoxChild, - items_lock: ArcMenuMap, + items_lock: &ArcMenuMap, ) -> Ordering { let lock = items_lock.lock().unwrap(); let m1 = lock.get(child1); @@ -444,7 +440,7 @@ fn sort_menu_items_by_score( } } -fn animate_window_show(config: Config, window: ApplicationWindow, outer_box: gtk4::Box) { +fn animate_window_show(config: &Config, window: ApplicationWindow, outer_box: gtk4::Box) { let display = window.display(); if let Some(surface) = window.surface() { // todo this does not work for multi monitor systems diff --git a/src/lib/mode.rs b/src/lib/mode.rs index 9fc85f5..38c119b 100644 --- a/src/lib/mode.rs +++ b/src/lib/mode.rs @@ -1,5 +1,5 @@ use std::os::unix::prelude::CommandExt; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::{env, fmt, fs, io}; @@ -89,7 +89,7 @@ impl DRunProvider { "Skipping desktop entry for {name:?} because action {action:?} does not exist" ); continue; - }; + } let icon = file .entry @@ -176,13 +176,13 @@ impl FileItemProvider { } } - fn resolve_icon_for_name(&self, path: PathBuf) -> String { - let result = tree_magic_mini::from_filepath(&path); + fn resolve_icon_for_name(path: &Path) -> String { + let result = tree_magic_mini::from_filepath(path); if let Some(result) = result { if result.starts_with("image") { "image-x-generic".to_owned() } else if result.starts_with("inode") { - return result.replace("/", "-"); + return result.replace('/', "-"); } else if result.starts_with("text") { if result.contains("plain") { "text-x-generic".to_owned() @@ -225,7 +225,7 @@ impl ItemProvider for FileItemProvider { }; let mut trimmed_search = search.unwrap_or(&default_path).to_owned(); - if !trimmed_search.starts_with("/") && !trimmed_search.starts_with("~") { + if !trimmed_search.starts_with('/') && !trimmed_search.starts_with('~') { trimmed_search = format!("{default_path}/{trimmed_search}"); } @@ -241,38 +241,45 @@ impl ItemProvider for FileItemProvider { } if path.is_dir() { - for entry in path.read_dir().unwrap() { - if let Ok(entry) = entry { - let mut path_str = entry.path().to_str().unwrap_or("").to_string(); - if trimmed_search.starts_with("~") { - if let Some(home_dir) = dirs::home_dir() { - path_str = path_str.replace(home_dir.to_str().unwrap_or(""), "~"); + if let Ok(entries) = path.read_dir() { + for entry in entries.flatten() { + if let Some(mut path_str) = + entry.path().to_str().map(std::string::ToString::to_string) + { + if trimmed_search.starts_with('~') { + if let Some(home_dir) = dirs::home_dir() { + if let Some(home_str) = home_dir.to_str() { + path_str = path_str.replace(home_str, "~"); + } + } } - } - if entry.path().is_dir() { - path_str += "/"; - } + if entry.path().is_dir() { + path_str.push('/'); + } - items.push({ - MenuItem { + items.push(MenuItem { label: path_str.clone(), - icon_path: Some(self.resolve_icon_for_name(entry.path())), + icon_path: Some(FileItemProvider::::resolve_icon_for_name( + &entry.path(), + )), action: Some(format!("xdg-open {path_str}")), sub_elements: vec![], working_dir: None, initial_sort_score: 0, search_sort_score: 0.0, data: Some(self.menu_item_data.clone()), - } - }); + }); + } } } } else { items.push({ MenuItem { - label: trimmed_search.to_owned(), - icon_path: Some(self.resolve_icon_for_name(PathBuf::from(&trimmed_search))), + label: trimmed_search.clone(), + icon_path: Some(FileItemProvider::::resolve_icon_for_name( + &PathBuf::from(&trimmed_search), + )), action: Some(format!("xdg-open {trimmed_search}")), sub_elements: vec![], working_dir: None, @@ -329,7 +336,7 @@ impl ItemProvider for MathProvider { let item = MenuItem { label: result, icon_path: None, - action: search.map(|s| s.to_string()), + action: search.map(String::from), sub_elements: vec![], working_dir: None, initial_sort_score: 0, @@ -361,17 +368,17 @@ enum AutoRunType { #[derive(Clone)] struct AutoItemProvider { - drun_provider: DRunProvider, - file_provider: FileItemProvider, - math_provider: MathProvider, + drun: DRunProvider, + file: FileItemProvider, + math: MathProvider, } impl AutoItemProvider { fn new() -> Self { AutoItemProvider { - drun_provider: DRunProvider::new(AutoRunType::DRun), - file_provider: FileItemProvider::new(AutoRunType::File), - math_provider: MathProvider::new(AutoRunType::Math), + drun: DRunProvider::new(AutoRunType::DRun), + file: FileItemProvider::new(AutoRunType::File), + math: MathProvider::new(AutoRunType::Math), } } } @@ -381,21 +388,21 @@ impl ItemProvider for AutoItemProvider { if let Some(search) = search_opt { let trimmed_search = search.trim(); if trimmed_search.is_empty() { - self.drun_provider.get_elements(search_opt) + self.drun.get_elements(search_opt) } else if MathProvider::::contains_math_functions_or_starts_with_number( trimmed_search, ) { - self.math_provider.get_elements(search_opt) - } else if trimmed_search.starts_with("$") - || trimmed_search.starts_with("/") - || trimmed_search.starts_with("~") + self.math.get_elements(search_opt) + } else if trimmed_search.starts_with('$') + || trimmed_search.starts_with('/') + || trimmed_search.starts_with('~') { - self.file_provider.get_elements(search_opt) + self.file.get_elements(search_opt) } else { - self.drun_provider.get_elements(search_opt) + self.drun.get_elements(search_opt) } } else { - self.drun_provider.get_elements(search_opt) + self.drun.get_elements(search_opt) } } @@ -411,7 +418,7 @@ impl ItemProvider for AutoItemProvider { /// /// Will return `Err` if it was not able to spawn the process pub fn d_run(config: &Config) -> Result<(), ModeError> { - let provider = DRunProvider::new("".to_owned()); + let provider = DRunProvider::new(String::new()); let cache_path = provider.cache_path.clone(); let mut cache = provider.cache.clone(); @@ -427,10 +434,14 @@ pub fn d_run(config: &Config) -> Result<(), ModeError> { Ok(()) } +/// # Errors +/// +/// Will return `Err` +/// * if it was not able to spawn the process pub fn auto(config: &Config) -> Result<(), ModeError> { let provider = AutoItemProvider::new(); - let cache_path = provider.drun_provider.cache_path.clone(); - let mut cache = provider.drun_provider.cache.clone(); + let cache_path = provider.drun.cache_path.clone(); + let mut cache = provider.drun.cache.clone(); // todo ues a arc instead of cloning the config let selection_result = gui::show(config.clone(), provider); @@ -459,8 +470,12 @@ pub fn auto(config: &Config) -> Result<(), ModeError> { Ok(()) } +/// # Errors +/// +/// Will return `Err` +/// * if it was not able to spawn the process pub fn file(config: &Config) -> Result<(), ModeError> { - let provider = FileItemProvider::new("".to_owned()); + let provider = FileItemProvider::new(String::new()); // todo ues a arc instead of cloning the config let selection_result = gui::show(config.clone(), provider); @@ -478,8 +493,8 @@ pub fn file(config: &Config) -> Result<(), ModeError> { Ok(()) } -pub fn math(config: &Config) -> Result<(), ModeError> { - let provider = MathProvider::new("".to_owned()); +pub fn math(config: &Config) { + let provider = MathProvider::new(String::new); // todo ues a arc instead of cloning the config let selection_result = gui::show(config.clone(), provider); @@ -489,10 +504,11 @@ pub fn math(config: &Config) -> Result<(), ModeError> { log::error!("No item selected"); } } - - Ok(()) } +/// # Errors +/// +/// todo pub fn dmenu(_: &Config) -> Result<(), ModeError> { Ok(()) } @@ -504,7 +520,7 @@ fn update_drun_cache_and_run( ) -> Result<(), ModeError> { if let Some(cache_path) = cache_path { *cache.entry(selection_result.label).or_insert(0) += 1; - if let Err(e) = save_cache_file(&cache_path, &cache) { + if let Err(e) = save_cache_file(&cache_path, cache) { log::warn!("cannot save drun cache {e:?}"); } } @@ -583,7 +599,7 @@ fn spawn_fork(cmd: &str, working_dir: Option<&String>) -> Result<(), ModeError> if let Some(dir) = working_dir { env::set_current_dir(dir) - .map_err(|e| ModeError::RunError(format!("cannot set workdir {e}")))? + .map_err(|e| ModeError::RunError(format!("cannot set workdir {e}")))?; } let exec = parts[0].replace('"', ""); diff --git a/src/main.rs b/src/main.rs index 65f3df5..cae7ce3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,7 +30,7 @@ fn main() -> anyhow::Result<()> { mode::file(&config).map_err(|e| anyhow!(e))?; } Mode::Math => { - mode::math(&config).map_err(|e| anyhow!(e))?; + mode::math(&config); } Mode::Auto => { mode::auto(&config).map_err(|e| anyhow!(e))?;