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)