use std::collections::HashMap; use std::rc::Rc; use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; use crate::config::{Anchor, Config, MatchMethod, WrapMode}; use crate::desktop::known_image_extension_regex_pattern; use crate::{config, desktop}; use anyhow::anyhow; use crossbeam::channel; use crossbeam::channel::Sender; use gdk4::gio::File; 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, OrientableExt, WidgetExt, }; 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 regex::Regex; type ArcMenuMap = Arc>>>; type ArcProvider = Arc + Send>>; 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<&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 { config::Orientation::Vertical => Orientation::Vertical, config::Orientation::Horizontal => Orientation::Horizontal, } } } impl From 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 { config::Align::Fill => Align::Fill, config::Align::Start => Align::Start, config::Align::Center => Align::Center, } } } /// An entry in the list of selectable items in the UI. /// Supports nested items but these cannot nested again (only nesting with depth == 1 is supported) #[derive(Clone, PartialEq)] pub struct MenuItem { /// text to show in the UI pub label: String, /// optional icon, will use fallback icon if None is given pub icon_path: Option, /// the action to run when this is selected. pub action: Option, /// Sub elements of this entry. If this already has a parent entry, nesting is not supported pub sub_elements: Vec>, /// Working directory to run the action in. pub working_dir: Option, /// Initial sort score to display favourites at the top pub initial_sort_score: f64, /// Allows to store arbitrary additional information pub data: Option, /// Score the item got in the current search search_sort_score: f64, /// True if the item is visible visible: bool, } impl MenuItem { #[must_use] pub fn new( label: String, icon_path: Option, action: Option, sub_elements: Vec>, working_dir: Option, initial_sort_score: f64, data: Option, ) -> Self { MenuItem { label, icon_path, action, sub_elements, working_dir, initial_sort_score, data, search_sort_score: 0.0, visible: true, } } } impl AsRef> for MenuItem { fn as_ref(&self) -> &MenuItem { self } } struct MetaData { item_provider: ArcProvider, selected_sender: MenuItemSender, config: Rc, new_on_empty: bool, } struct UiElements { app: Application, window: ApplicationWindow, search: SearchEntry, main_box: FlowBox, menu_rows: ArcMenuMap, } /// Shows the user interface and **blocks** until the user selected an entry /// # Errors /// /// Will return Err when the channel between the UI and this is broken pub fn show( config: Config, item_provider: P, new_on_empty: bool, ) -> Result, anyhow::Error> where T: Clone + 'static + Send, P: ItemProvider + 'static + Clone + Send, { log::debug!("Starting GUI"); if let Some(ref css) = config.style() { let provider = CssProvider::new(); let css_file_path = File::for_path(css); provider.load_from_file(&css_file_path); if let Some(display) = Display::default() { gtk4::style_context_add_provider_for_display( &display, &provider, gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, ); } } let app = Application::builder().application_id("worf").build(); let (sender, receiver) = channel::bounded(1); app.connect_activate(move |app| { build_ui( &config, item_provider.clone(), sender.clone(), app.clone(), new_on_empty, ); }); let gtk_args: [&str; 0] = []; app.run_with_args(>k_args); receiver.recv()? } fn build_ui( config: &Config, item_provider: P, sender: Sender, anyhow::Error>>, app: Application, new_on_empty: bool, ) where T: Clone + 'static + Send, P: ItemProvider + 'static + Send, { let start = Instant::now(); let meta = Rc::new(MetaData { item_provider: Arc::new(Mutex::new(item_provider)), selected_sender: sender, config: Rc::new(config.clone()), new_on_empty, }); let provider_clone = Arc::clone(&meta.item_provider); let get_provider_elements = thread::spawn(move || { log::debug!("getting items"); provider_clone.lock().unwrap().get_elements(None) }); let window = ApplicationWindow::builder() .application(&app) .decorated(false) .resizable(false) .default_width(100) .default_height(100) .build(); let ui_elements = Rc::new(UiElements { app, window, search: SearchEntry::new(), main_box: FlowBox::new(), menu_rows: Arc::new(Mutex::new(HashMap::new())), }); let window_show = Instant::now(); // handle keys as soon as possible setup_key_event_handler(&ui_elements, &meta); log::debug!("keyboard ready after {:?}", start.elapsed()); ui_elements.window.set_widget_name("window"); if !config.normal_window() { // Initialize the window as a layer ui_elements.window.init_layer_shell(); ui_elements .window .set_layer(gtk4_layer_shell::Layer::Overlay); ui_elements .window .set_keyboard_mode(KeyboardMode::Exclusive); ui_elements.window.set_namespace(Some("worf")); } let window_done = Instant::now(); if let Some(location) = config.location() { for anchor in location { ui_elements.window.set_anchor(anchor.into(), true); } } let outer_box = gtk4::Box::new(config.orientation().into(), 0); outer_box.set_widget_name("outer-box"); outer_box.append(&ui_elements.search); ui_elements.window.set_child(Some(&outer_box)); let scroll = ScrolledWindow::new(); scroll.set_widget_name("scroll"); scroll.set_hexpand(true); scroll.set_vexpand(true); if config.hide_scroll() { scroll.set_policy(PolicyType::External, PolicyType::External); } outer_box.append(&scroll); build_main_box(config, &ui_elements); build_search_entry(config, &ui_elements); let wrapper_box = gtk4::Box::new(Orientation::Vertical, 0); wrapper_box.append(&ui_elements.main_box); scroll.set_child(Some(&wrapper_box)); let wait_for_items = Instant::now(); let provider_elements = get_provider_elements.join().unwrap(); log::debug!("got items after {:?}", wait_for_items.elapsed()); build_ui_from_menu_items(&ui_elements, &meta, &provider_elements); let items_sort = ArcMenuMap::clone(&ui_elements.menu_rows); ui_elements .main_box .set_sort_func(move |child1, child2| sort_menu_items_by_score(child1, child2, &items_sort)); ui_elements.window.show(); animate_window_show(config, ui_elements.window.clone()); let animation_done = Instant::now(); log::debug!( "Building UI took {:?}, window show {:?}, animation {:?}", start.elapsed(), window_done - window_show, animation_done - window_done ); } fn build_main_box(config: &Config, ui_elements: &Rc>) { ui_elements.main_box.set_widget_name("inner-box"); ui_elements.main_box.set_css_classes(&["inner-box"]); ui_elements.main_box.set_hexpand(true); ui_elements.main_box.set_vexpand(false); ui_elements .main_box .set_selection_mode(gtk4::SelectionMode::Browse); ui_elements .main_box .set_max_children_per_line(config.columns()); ui_elements.main_box.set_activate_on_single_click(true); ui_elements.main_box.set_halign(config.halign().into()); ui_elements.main_box.set_valign(config.valign().into()); if config.orientation() == config::Orientation::Horizontal { ui_elements.main_box.set_valign(Align::Center); ui_elements.main_box.set_orientation(Orientation::Vertical); } else { ui_elements.main_box.set_valign(Align::Start); } let ui_clone = Rc::clone(ui_elements); ui_elements.main_box.connect_map(move |fb| { fb.grab_focus(); fb.invalidate_sort(); select_first_visible_child(&ui_clone); }); } fn build_search_entry(config: &Config, ui_elements: &UiElements) { ui_elements.search.set_widget_name("input"); ui_elements.search.set_css_classes(&["input"]); ui_elements .search .set_placeholder_text(Some(config.prompt().as_ref())); ui_elements.search.set_can_focus(false); } fn build_ui_from_menu_items( ui: &Rc>, meta: &Rc>, items: &Vec>, ) { let start = Instant::now(); { let mut arc_lock = ui.menu_rows.lock().unwrap(); let got_lock = Instant::now(); ui.main_box.unset_sort_func(); while let Some(b) = ui.main_box.child_at_index(0) { ui.main_box.remove(&b); drop(b); } arc_lock.clear(); let cleared_box = Instant::now(); for entry in items { if entry.visible { arc_lock.insert(add_menu_item(ui, meta, entry), (*entry).clone()); } } let created_ui = Instant::now(); log::debug!( "Creating UI took {:?}, got lock after {:?}, cleared box after {:?}, created UI after {:?}", start.elapsed(), got_lock - start, cleared_box - start, created_ui - start ); } let lic = ArcMenuMap::clone(&ui.menu_rows); ui.main_box .set_sort_func(move |child2, child1| sort_menu_items_by_score(child1, child2, &lic)); } fn setup_key_event_handler( ui: &Rc>, meta: &Rc>, ) { let key_controller = EventControllerKey::new(); let ui_clone = Rc::clone(ui); let meta_clone = Rc::clone(meta); key_controller.connect_key_pressed(move |_, key_value, _, _| { handle_key_press(&ui_clone, &meta_clone, key_value) }); ui.window.add_controller(key_controller); } #[allow(clippy::too_many_arguments)] // todo refactor this? fn handle_key_press( ui: &Rc>, meta: &Rc>, keyboard_key: Key, ) -> Propagation { let update_view = |query: &String, items: &mut Vec>| { set_menu_visibility_for_search(query, items, &meta.config); build_ui_from_menu_items(ui, meta, items); select_first_visible_child(ui); }; let update_view_from_provider = |query: &String| { let mut filtered_list = meta.item_provider.lock().unwrap().get_elements(Some(query)); update_view(query, &mut filtered_list); }; match keyboard_key { Key::Down => { return Propagation::Stop; } Key::Escape => { if let Err(e) = meta.selected_sender.send(Err(anyhow!("No item selected"))) { log::error!("failed to send message {e}"); } close_gui(ui.app.clone(), ui.window.clone(), &meta.config); } Key::Return => { let query = ui.search.text().to_string(); if let Err(e) = handle_selected_item(ui, meta, Some(&query), meta.new_on_empty) { log::error!("{e}"); } } Key::BackSpace => { let mut query = ui.search.text().to_string(); if !query.is_empty() { query.pop(); } ui.search.set_text(&query); update_view_from_provider(&query); } Key::Tab => { if let Some(fb) = ui.main_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 = ui.menu_rows.lock().unwrap(); let menu_item = lock.get(fb); if let Some(menu_item) = menu_item { if let Some(mut new_items) = meta .item_provider .lock() .unwrap() .get_sub_elements(menu_item) { let query = menu_item.label.clone(); drop(lock); ui.search.set_text(&query); update_view(&query, &mut new_items); } } } } } return Propagation::Stop; } _ => { if let Some(c) = keyboard_key.to_unicode() { let current = ui.search.text().to_string(); let query = format!("{current}{c}"); ui.search.set_text(&query); update_view_from_provider(&query); } } } Propagation::Proceed } fn sort_menu_items_by_score( child1: &FlowBoxChild, child2: &FlowBoxChild, items_lock: &ArcMenuMap, ) -> Ordering { let lock = items_lock.lock().unwrap(); 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 { if menu1.search_sort_score < menu2.search_sort_score { Ordering::Smaller } else { Ordering::Larger } } else if menu1.initial_sort_score > menu2.initial_sort_score { Ordering::Smaller } else { Ordering::Larger } } (Some(_), None) => Ordering::Larger, (None, Some(_)) => Ordering::Smaller, (None, None) => Ordering::Equal, } } fn animate_window_show(config: &Config, window: ApplicationWindow) { if let Some(surface) = window.surface() { let display = window.display(); // 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(), geometry.width()) else { return; }; let Some(target_height) = percent_or_absolute(&config.height(), geometry.height()) else { return; }; log::debug!( "monitor geometry: {geometry:?}, target_height {target_height}, target_width {target_width}" ); let animation_start = Instant::now(); animate_window( window, config.show_animation_time(), target_height, target_width, move || { log::debug!("animation done after {:?}", animation_start.elapsed()); }, ); } } } 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); animate_window(window, config.hide_animation_time(), 10, 10, on_done_func); } fn ease_in_out_cubic(t: f32) -> f32 { if t < 0.7 { 10.0 * t * t * t } else { 1.0 - (-2.0 * t + 2.0).powi(3) } } #[allow(clippy::cast_possible_truncation)] #[allow(clippy::cast_precision_loss)] fn animate_window( window: ApplicationWindow, animation_time: u64, target_height: i32, target_width: i32, on_done_func: Func, ) where Func: Fn() + 'static, { if animation_time == 0 { window.set_width_request(target_width); window.set_height_request(target_height); on_done_func(); return; } let allocation = window.allocation(); // Define animation parameters let animation_step_length = Duration::from_millis(20); // Start positions (initial window dimensions) let start_width = allocation.width() as f32; let start_height = allocation.height() as f32; // Calculate the change in width and height let delta_width = target_width as f32 - start_width; let delta_height = target_height as f32 - start_height; // Animation time starts when the timeout is ran for the first time let mut start_time: Option = None; let mut last_t = 0.0; let before_animation = Instant::now(); timeout_add_local(animation_step_length, move || { if !window.is_visible() { return ControlFlow::Continue; } let start_time = start_time.unwrap_or_else(|| { let now = Instant::now(); start_time = Some(now); log::debug!("animation started after {:?}", before_animation.elapsed()); now }); let elapsed_us = start_time.elapsed().as_micros() as f32; let t = (elapsed_us / (animation_time * 1000) as f32).min(1.0); // Skip if there's no meaningful change in progress if (t - last_t).abs() < 0.001 && t < 1.0 { return ControlFlow::Continue; } last_t = t; let eased_t = ease_in_out_cubic(t); let current_width = start_width + delta_width * eased_t; let current_height = start_height + delta_height * eased_t; let rounded_width = current_width.round() as i32; let rounded_height = current_height.round() as i32; if t >= 1.0 || rounded_height > target_height || rounded_width > target_width { window.set_width_request(target_width); window.set_height_request(target_height); on_done_func(); ControlFlow::Break } else { window.set_width_request(rounded_width); window.set_height_request(rounded_height); ControlFlow::Continue } }); } fn close_gui(app: Application, window: ApplicationWindow, config: &Config) { animate_window_close(config, window, move || app.quit()); } fn handle_selected_item( ui: &UiElements, meta: &MetaData, query: Option<&str>, new_on_empty: bool, ) -> Result<(), String> where T: Clone, { if let Some(s) = ui.main_box.selected_children().into_iter().next() { let list_items = ui.menu_rows.lock().unwrap(); let item = list_items.get(&s); if let Some(item) = item { if let Err(e) = meta.selected_sender.send(Ok(item.clone())) { log::error!("failed to send message {e}"); } } close_gui(ui.app.clone(), ui.window.clone(), &meta.config); return Ok(()); } if new_on_empty { let item = MenuItem { label: query.unwrap_or("").to_owned(), icon_path: None, action: None, sub_elements: Vec::new(), working_dir: None, initial_sort_score: 0.0, search_sort_score: 0.0, data: None, visible: true, }; if let Err(e) = meta.selected_sender.send(Ok(item.clone())) { log::error!("failed to send message {e}"); } close_gui(ui.app.clone(), ui.window.clone(), &meta.config); Ok(()) } else { Err("selected item cannot be resolved".to_owned()) } } fn add_menu_item( ui: &Rc>, meta: &Rc>, element_to_add: &MenuItem, ) -> FlowBoxChild { let parent: Widget = if element_to_add.sub_elements.is_empty() { create_menu_row(ui, meta, element_to_add).upcast() } else { let expander = Expander::new(None); expander.set_widget_name("expander-box"); expander.set_hexpand(true); let menu_row = create_menu_row(ui, meta, element_to_add); expander.set_label_widget(Some(&menu_row)); let list_box = ListBox::new(); list_box.set_hexpand(true); list_box.set_halign(Align::Fill); for sub_item in &element_to_add.sub_elements { let sub_row = create_menu_row(ui, meta, sub_item); sub_row.set_hexpand(true); sub_row.set_halign(Align::Fill); sub_row.set_widget_name("entry"); list_box.append(&sub_row); } expander.set_child(Some(&list_box)); expander.upcast() }; parent.set_halign(Align::Fill); parent.set_valign(Align::Start); parent.set_hexpand(true); let child = FlowBoxChild::new(); child.set_widget_name("entry"); child.set_child(Some(&parent)); child.set_hexpand(true); child.set_vexpand(false); ui.main_box.append(&child); child } fn create_menu_row( ui: &Rc>, meta: &Rc>, element_to_add: &MenuItem, ) -> Widget { let row = ListBoxRow::new(); row.set_hexpand(true); row.set_halign(Align::Fill); row.set_widget_name("row"); let click_ui = Rc::clone(ui); let click_meta = Rc::clone(meta); let click = GestureClick::new(); click.set_button(gdk::BUTTON_PRIMARY); click.connect_pressed(move |_gesture, n_press, _x, _y| { if n_press == 2 { if let Err(e) = handle_selected_item(click_ui.as_ref(), click_meta.as_ref(), None, false) { log::error!("{e}"); } } }); row.add_controller(click); let row_box = gtk4::Box::new(meta.config.row_bow_orientation().into(), 0); row_box.set_hexpand(true); row_box.set_vexpand(false); row_box.set_halign(Align::Fill); row.set_child(Some(&row_box)); if meta.config.allow_images() { if let Some(image) = lookup_icon(element_to_add, &meta.config) { image.set_widget_name("img"); row_box.append(&image); } } let label = Label::new(Some(element_to_add.label.as_str())); label.set_natural_wrap_mode(meta.config.line_wrap().into()); label.set_hexpand(true); label.set_widget_name("text"); label.set_wrap(true); row_box.append(&label); if meta.config.content_halign().eq(&config::Align::Start) || meta.config.content_halign().eq(&config::Align::Fill) { label.set_xalign(0.0); } row.upcast() } fn lookup_icon(menu_item: &MenuItem, config: &Config) -> Option { if let Some(image_path) = &menu_item.icon_path { let img_regex = Regex::new(&format!( r"((?i).*{})", known_image_extension_regex_pattern() )); let image = if image_path.starts_with('/') { Image::from_file(image_path) } else if img_regex.unwrap().is_match(image_path) { if let Ok(img) = desktop::fetch_icon_from_common_dirs(image_path) { Image::from_file(img) } else { Image::from_icon_name(image_path) } } else { Image::from_icon_name(image_path) }; image.set_pixel_size(config.image_size()); Some(image) } else { None } } fn set_menu_visibility_for_search( query: &str, items: &mut [MenuItem], config: &Config, ) { { if query.is_empty() { for menu_item in items.iter_mut() { menu_item.search_sort_score = menu_item.initial_sort_score; menu_item.visible = true; } } else { let query = query.to_owned().to_lowercase(); // todo match case sensitive according to conf for 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 (search_sort_score, visible) = match config.match_method() { MatchMethod::Fuzzy => { let mut score = strsim::jaro_winkler(&query, &menu_item_search); if score == 0.0 { score = -1.0; } (score, score > config.fuzzy_min_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 + menu_item.initial_sort_score; menu_item.visible = visible; } } } } fn select_first_visible_child(ui: &UiElements) { let items = ui.menu_rows.lock().unwrap(); for i in 0..items.len() { let i_32 = i.try_into().unwrap_or(i32::MAX); if let Some(child) = ui.main_box.child_at_index(i_32) { if child.is_visible() { ui.main_box.select_child(&child); break; } } } } // 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, } } else { value.parse::().ok() } } /// Sorts menu items in alphabetical order, while maintaining the initial score // highly unlikely that we are dealing with > i64 items #[allow(clippy::cast_possible_wrap)] #[allow(clippy::cast_possible_truncation)] #[allow(clippy::cast_precision_loss)] pub fn sort_menu_items_alphabetically_honor_initial_score(items: &mut [MenuItem]) { let special_score = items.len() as f64; let mut regular_score = 0.0; items.sort_by(|l, r| l.label.cmp(&r.label)); for item in items.iter_mut() { if item.initial_sort_score == 0.0 { item.initial_sort_score += regular_score; regular_score += 1.0; } else { item.initial_sort_score += special_score; } } }