add window open/close animations

This commit is contained in:
Alexander Mohr 2025-04-20 01:37:07 +02:00
parent 437763f51f
commit 24fa66a2de
4 changed files with 356 additions and 48 deletions

View file

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

View file

@ -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<usize>,
/// Defines the animation when the window is show.
/// Defaults to Expand
#[serde(default = "default_show_animation")]
#[clap(long = "show-animation")]
pub show_animation: Option<Animation>,
/// 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<u64>,
/// Defines the animation when the window is hidden.
/// Defaults to Expand
#[serde(default = "default_hide_animation")]
#[clap(long = "hide-animation")]
pub hide_animation: Option<Animation>,
/// 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<u64>,
}
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<u64> {
Some(70)
}
// allowed because option is needed for serde macro
#[allow(clippy::unnecessary_wraps)]
#[must_use]
pub fn default_show_animation() -> Option<Animation> {
Some(Animation::Expand)
}
// allowed because option is needed for serde macro
#[allow(clippy::unnecessary_wraps)]
#[must_use]
pub fn default_hide_animation_time() -> Option<u64> {
Some(100)
}
// allowed because option is needed for serde macro
#[allow(clippy::unnecessary_wraps)]
#[must_use]
pub fn default_hide_animation() -> Option<Animation> {
Some(Animation::Expand)
}
// allowed because option is needed for serde macro
#[allow(clippy::unnecessary_wraps)]
#[must_use]

View file

@ -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<T> = Arc<Mutex<HashMap<FlowBoxChild, MenuItem<T>>>>;
type MenuItemSender<T> = Sender<Result<MenuItem<T>, anyhow::Error>>;
@ -103,8 +105,8 @@ fn build_ui<T>(
.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<T>(
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<T>(
.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<T>(
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<T: Clone + 'static>(
@ -226,16 +211,24 @@ fn setup_key_event_handler<T: Clone + 'static>(
) {
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<T>(
}
}
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<Func>(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<Func>(
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<T>(
sender: &MenuItemSender<T>,
app: &Application,
app: Application,
window: ApplicationWindow,
config: &Config,
inner_box: &FlowBox,
lock_arc: &ArcMenuMap<T>,
) -> 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<T: Clone + 'static>(
sender: &MenuItemSender<T>,
lock_arc: &ArcMenuMap<T>,
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<T: Clone + 'static>(
Arc::<Mutex<HashMap<FlowBoxChild, MenuItem<T>>>>::clone(lock_arc),
sender.clone(),
app.clone(),
window.clone(),
inner_box.clone(),
)
.upcast()
@ -345,6 +566,7 @@ fn add_menu_item<T: Clone + 'static>(
Arc::<Mutex<HashMap<FlowBoxChild, MenuItem<T>>>>::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<T: Clone + 'static>(
Arc::<Mutex<HashMap<FlowBoxChild, MenuItem<T>>>>::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<T: Clone + 'static>(
lock_arc: ArcMenuMap<T>,
sender: MenuItemSender<T>,
app: Application,
window: ApplicationWindow,
inner_box: FlowBox,
) -> Widget {
let row = ListBoxRow::new();
@ -401,9 +625,17 @@ fn create_menu_row<T: Clone + 'static>(
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}");
}
}

View file

@ -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<MenuItem<String>> = 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<PathBuf>, HashMap<String, i64>) {
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<String, i64>) -> 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)