diff --git a/Cargo.lock b/Cargo.lock index 3502650..0988d54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1917,6 +1917,7 @@ name = "worf-warden" version = "0.1.0" dependencies = [ "env_logger", + "log", "worf", ] diff --git a/examples/worf-warden/Cargo.toml b/examples/worf-warden/Cargo.toml index 99b56d4..f7244ae 100644 --- a/examples/worf-warden/Cargo.toml +++ b/examples/worf-warden/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] worf = {path = "../../worf"} env_logger = "0.11.8" +log = "0.4.27" # todo re-add this #[features] diff --git a/examples/worf-warden/src/main.rs b/examples/worf-warden/src/main.rs index 4bd9c45..396bb94 100644 --- a/examples/worf-warden/src/main.rs +++ b/examples/worf-warden/src/main.rs @@ -125,11 +125,7 @@ fn keyboard_type(text: &str) { fn keyboard_tab() { Command::new("ydotool") - .arg("key") - .arg("-d") - .arg("50") - .arg("15:1") - .arg("15:0") + .arg("TAB") .output() .expect("Failed to execute ydotool"); } @@ -334,7 +330,7 @@ fn main() -> Result<(), String> { let config = config::load_config(Some(&args)).unwrap_or(args); if !groups().contains("input") { - eprintln!("User must be in input group. 'sudo usermod -aG input $USER', then login again"); + log::error!("User must be in input group. 'sudo usermod -aG input $USER', then login again"); std::process::exit(1) } diff --git a/worf/src/lib/gui.rs b/worf/src/lib/gui.rs index 9149c4a..ce00db9 100644 --- a/worf/src/lib/gui.rs +++ b/worf/src/lib/gui.rs @@ -2,13 +2,13 @@ use std::collections::HashMap; use std::rc::Rc; use std::sync::{Arc, Mutex}; use std::thread; -use std::time::{Duration, Instant}; +use std::time::Instant; use crossbeam::channel; use crossbeam::channel::Sender; use gdk4::Display; use gdk4::gio::File; -use gdk4::glib::{MainContext, Propagation, timeout_add_local}; +use gdk4::glib::{MainContext, Propagation}; use gdk4::prelude::{Cast, DisplayExt, MonitorExt, SurfaceExt}; use gtk4::glib::ControlFlow; use gtk4::prelude::{ @@ -1011,7 +1011,7 @@ fn send_selected_item( T: Clone + Send + 'static, { let ui_clone = Rc::clone(&ui); - ui.window.connect_hide(move |w| { + ui.window.connect_hide(move |_| { if let Err(e) = meta.selected_sender.send(Ok(Selection { menu: selected_item.clone(), custom_key: custom_key.clone(), diff --git a/worf/src/lib/mod.rs b/worf/src/lib/mod.rs index 5455ba3..5997d9a 100644 --- a/worf/src/lib/mod.rs +++ b/worf/src/lib/mod.rs @@ -1,5 +1,15 @@ use std::fmt; +/// Configuration and command line parsing +pub mod config; +/// Desktop action like parsing desktop files and launching programs +pub mod desktop; +/// All things related to the user interface +pub mod gui; +/// Out of the box supported modes, like drun, dmenu, etc... +pub mod modes; + + /// Defines error the lib can encounter #[derive(Debug, PartialEq)] pub enum Error { @@ -64,12 +74,3 @@ impl fmt::Display for Error { } } } - -/// Configuration and command line parsing -pub mod config; -/// Desktop action like parsing desktop files and launching programs -pub mod desktop; -/// All things related to the user interface -pub mod gui; -/// Out of the box supported modes, like drun, dmenu, etc... -pub mod mode; diff --git a/worf/src/lib/mode.rs b/worf/src/lib/mode.rs deleted file mode 100644 index 3284210..0000000 --- a/worf/src/lib/mode.rs +++ /dev/null @@ -1,991 +0,0 @@ -use crate::config::{Config, SortOrder, expand_path}; -use crate::desktop::{ - copy_to_clipboard, create_file_if_not_exists, find_desktop_files, get_locale_variants, - is_executable, load_cache_file, lookup_name_with_locale, save_cache_file, spawn_fork, -}; -use crate::gui::{ItemProvider, MenuItem}; -use crate::{Error, gui}; -use freedesktop_file_parser::EntryType; -use rayon::prelude::*; -use regex::Regex; -use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; -use std::ffi::CString; -use std::io::Read; -use std::os::unix::fs::FileTypeExt; -use std::path::{Path, PathBuf}; -use std::time::Instant; -use std::{env, fs, io}; - -#[derive(Debug, Deserialize, Serialize, Clone)] -struct DRunCache { - desktop_entry: String, - run_count: usize, -} - -#[derive(Clone)] -struct DRunProvider { - items: Option>>, - cache_path: Option, - cache: HashMap, - data: T, - no_actions: bool, - sort_order: SortOrder, -} - -impl DRunProvider { - fn new(menu_item_data: T, no_actions: bool, sort_order: SortOrder) -> Self { - let (cache_path, d_run_cache) = load_d_run_cache(); - DRunProvider { - items: None, - cache_path, - cache: d_run_cache, - data: menu_item_data, - no_actions, - sort_order, - } - } - - #[allow(clippy::cast_possible_truncation)] - #[allow(clippy::cast_precision_loss)] - fn load(&self) -> Vec> { - let locale_variants = get_locale_variants(); - let default_icon = "application-x-executable".to_string(); - let start = Instant::now(); - - let entries: Vec> = find_desktop_files() - .into_par_iter() - .filter(|file| { - !file.entry.no_display.unwrap_or(false) - && !file.entry.hidden.unwrap_or(false) - }) - .filter_map(|file| { - let name = lookup_name_with_locale( - &locale_variants, - &file.entry.name.variants, - &file.entry.name.default, - )?; - - let (action, working_dir) = match &file.entry.entry_type { - EntryType::Application(app) => (app.exec.clone(), app.path.clone()), - _ => return 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"); - return None; - } - - let icon = file - .entry - .icon - .as_ref() - .map(|s| s.content.clone()) - .or(Some(default_icon.clone())); - - let sort_score = *self.cache.get(&name).unwrap_or(&0) as f64; - - let mut entry = MenuItem::new( - name.clone(), - icon.clone(), - action.clone(), - Vec::new(), - working_dir.clone(), - sort_score, - Some(self.data.clone()), - ); - if !self.no_actions { - for action in file.actions.values() { - 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()) - .unwrap_or("application-x-executable".to_string()); - - - entry.sub_elements.push(MenuItem::new( - action_name, - Some(action_icon), - action.exec.clone(), - Vec::new(), - working_dir.clone(), - 0.0, - Some(self.data.clone()), - )); - } - } - } - Some(entry) - }) - .collect(); - - let mut seen_actions = HashSet::new(); - let mut entries: Vec> = entries - .into_iter() - .filter(|entry| seen_actions.insert(entry.action.clone())) - .collect(); - - log::info!( - "parsing desktop files took {}ms", - start.elapsed().as_millis() - ); - - gui::apply_sort(&mut entries, &self.sort_order); - entries - } -} - -impl ItemProvider for RunProvider { - fn get_elements(&mut self, _: Option<&str>) -> (bool, Vec>) { - if self.items.is_none() { - self.items = Some(self.load().clone()); - } - (false, self.items.clone().unwrap()) - } - - fn get_sub_elements(&mut self, _: &MenuItem) -> (bool, Option>>) { - (false, None) - } -} - -#[derive(Clone)] -struct RunProvider { - items: Option>>, - cache_path: Option, - cache: HashMap, - sort_order: SortOrder, -} - -impl RunProvider { - fn new(sort_order: SortOrder) -> Self { - let (cache_path, d_run_cache) = load_run_cache(); - RunProvider { - items: None, - cache_path, - cache: d_run_cache, - sort_order, - } - } - - #[allow(clippy::cast_possible_truncation)] - #[allow(clippy::cast_precision_loss)] - fn load(&self) -> Vec> { - let path_var = env::var("PATH").unwrap_or_default(); - let paths = env::split_paths(&path_var); - - let entries: Vec<_> = paths - .filter(|dir| dir.is_dir()) - .flat_map(|dir| { - fs::read_dir(dir) - .into_iter() - .flatten() - .filter_map(Result::ok) - .filter_map(|entry| { - let path = entry.path(); - if !is_executable(&path) { - return None; - } - - let label = path.file_name()?.to_str()?.to_string(); - let sort_score = *self.cache.get(&label).unwrap_or(&0) as f64; - - Some(MenuItem::new( - label, - None, - path.to_str().map(ToString::to_string), - vec![], - None, - sort_score, - None, - )) - }) - }) - .collect(); - - let mut seen_actions = HashSet::new(); - let mut entries: Vec> = entries - .into_iter() - .filter(|entry| { - entry - .action - .as_ref() - .and_then(|action| action.split('/').next_back()) - .is_some_and(|cmd| seen_actions.insert(cmd.to_string())) - }) - .collect(); - - gui::apply_sort(&mut entries, &self.sort_order); - entries - } -} - -impl ItemProvider for DRunProvider { - fn get_elements(&mut self, _: Option<&str>) -> (bool, Vec>) { - if self.items.is_none() { - self.items = Some(self.load().clone()); - } - (false, self.items.clone().unwrap()) - } - - fn get_sub_elements(&mut self, _: &MenuItem) -> (bool, Option>>) { - (false, None) - } -} - -#[derive(Clone)] -struct FileItemProvider { - last_result: Option>>, - menu_item_data: T, - sort_order: SortOrder, -} - -impl FileItemProvider { - fn new(menu_item_data: T, sort_order: SortOrder) -> Self { - FileItemProvider { - last_result: None, - menu_item_data, - sort_order, - } - } - - fn resolve_icon_for_name(path: &Path) -> String { - let type_result = fs::symlink_metadata(path) - .map(|meta| meta.file_type()) - .map(|file_type| { - if file_type.is_symlink() { - Some("edit-redo") - } else if file_type.is_char_device() { - Some("input-keyboard") - } else if file_type.is_block_device() { - Some("drive-harddisk") - } else if file_type.is_socket() { - Some("network-transmit-receive") - } else if file_type.is_fifo() { - Some("rotation-allowed") - } else { - None - } - }) - .unwrap_or(Some("system-lock-screen")); - - if let Some(tr) = type_result { - return tr.to_owned(); - } - - let Some(mime) = tree_magic_mini::from_filepath(path) else { - return "image-not-found".to_string(); - }; - - if mime.starts_with("image") { - return "image-x-generic".to_string(); - } - - if mime.starts_with("inode") { - return mime.replace('/', "-"); - } - - if mime.starts_with("text") { - return if mime.contains("plain") { - "text-x-generic".to_string() - } else if mime.contains("python") { - "text-x-script".to_string() - } else if mime.contains("html") { - "text-html".to_string() - } else { - "text-x-generic".to_string() - }; - } - - if mime.starts_with("application") { - return if mime.contains("octet") { - "application-x-executable".to_string() - } else if mime.contains("tar") - || mime.contains("lz") - || mime.contains("zip") - || mime.contains("7z") - || mime.contains("xz") - { - "package-x-generic".to_string() - } else { - "text-html".to_string() - }; - } - - log::debug!("unsupported mime type {mime}"); - "application-x-generic".to_string() - } -} - -impl ItemProvider for FileItemProvider { - fn get_elements(&mut self, search: Option<&str>) -> (bool, 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.starts_with('$') - { - trimmed_search = format!("{default_path}/{trimmed_search}"); - } - - let path = expand_path(&trimmed_search); - let mut items: Vec> = Vec::new(); - - if !path.exists() { - if let Some(last) = &self.last_result { - return (false, last.clone()); - } - - return (true, vec![]); - } - - if path.is_dir() { - items.push(MenuItem::new( - trimmed_search.clone(), - Some(FileItemProvider::::resolve_icon_for_name(&path)), - Some(format!("xdg-open {}", path.display())), - vec![], - None, - 100.0, - Some(self.menu_item_data.clone()), - )); - - 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.push('/'); - } - - items.push(MenuItem::new( - path_str.clone(), - Some(FileItemProvider::::resolve_icon_for_name(&entry.path())), - Some(format!("xdg-open {path_str}")), - vec![], - None, - 0.0, - Some(self.menu_item_data.clone()), - )); - } - } - } - } else { - items.push({ - MenuItem::new( - trimmed_search.clone(), - Some(FileItemProvider::::resolve_icon_for_name( - &PathBuf::from(&trimmed_search), - )), - Some(format!("xdg-open {trimmed_search}")), - vec![], - None, - 0.0, - Some(self.menu_item_data.clone()), - ) - }); - } - - gui::apply_sort(&mut items, &self.sort_order); - - self.last_result = Some(items.clone()); - (true, items) - } - - fn get_sub_elements(&mut self, _: &MenuItem) -> (bool, Option>>) { - (false, self.last_result.clone()) - } -} - -#[derive(Clone)] -struct SshProvider { - elements: Vec>, -} - -impl SshProvider { - fn new(menu_item_data: T, order: &SortOrder) -> Self { - let re = Regex::new(r"(?m)^\s*Host\s+(.+)$").unwrap(); - let mut items: Vec<_> = dirs::home_dir() - .map(|home| home.join(".ssh").join("config")) - .filter(|path| path.exists()) - .map(|path| fs::read_to_string(&path).unwrap_or_default()) - .into_iter() - .flat_map(|content| { - re.captures_iter(&content) - .flat_map(|cap| { - cap[1] - .split_whitespace() - .map(|host| { - log::debug!("found ssh host {host}"); - MenuItem::new( - host.to_owned(), - Some("computer".to_owned()), - Some(format!("ssh {host}")), - vec![], - None, - 0.0, - Some(menu_item_data.clone()), - ) - }) - .collect::>() - }) - .collect::>() - }) - .collect(); - - gui::apply_sort(&mut items, order); - Self { elements: items } - } -} - -impl ItemProvider for SshProvider { - fn get_elements(&mut self, _: Option<&str>) -> (bool, Vec>) { - (false, self.elements.clone()) - } - - fn get_sub_elements(&mut self, _: &MenuItem) -> (bool, Option>>) { - (false, None) - } -} - -#[derive(Clone)] -struct MathProvider { - menu_item_data: T, - elements: Vec>, -} - -impl MathProvider { - fn new(menu_item_data: T) -> Self { - Self { - menu_item_data, - elements: vec![], - } - } - - 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) - } - - fn add_elements(&mut self, elements: &mut Vec>) { - self.elements.append(elements); - } -} - -impl ItemProvider for MathProvider { - fn get_elements(&mut self, search: Option<&str>) -> (bool, 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::new( - result, - None, - search.map(String::from), - vec![], - None, - 0.0, - Some(self.menu_item_data.clone()), - ); - let mut result = vec![item]; - result.append(&mut self.elements.clone()); - (true, result) - } else { - (false, self.elements.clone()) - } - } - - fn get_sub_elements(&mut self, _: &MenuItem) -> (bool, Option>>) { - (false, None) - } -} - -#[derive(Clone)] -struct EmojiProvider { - elements: Vec>, - #[allow(dead_code)] // needed for the detection of mode in 'auto' - menu_item_data: T, -} - -impl EmojiProvider { - fn new(data: T, sort_order: &SortOrder) -> Self { - let emoji = emoji::search::search_annotation_all(""); - let mut menus = emoji - .into_iter() - .map(|e| { - MenuItem::new( - format!("{} — Category: {} — Name: {}", e.glyph, e.group, e.name), - None, - Some(format!("emoji {}", e.glyph)), - vec![], - None, - 0.0, - Some(data.clone()), - ) - }) - .collect::>(); - gui::apply_sort(&mut menus, sort_order); - - Self { - elements: menus, - menu_item_data: data.clone(), - } - } -} - -impl ItemProvider for EmojiProvider { - fn get_elements(&mut self, _: Option<&str>) -> (bool, Vec>) { - (false, self.elements.clone()) - } - - fn get_sub_elements(&mut self, _: &MenuItem) -> (bool, Option>>) { - (false, None) - } -} - -#[derive(Clone)] -struct DMenuProvider { - items: Vec>, -} - -impl DMenuProvider { - fn new(sort_order: &SortOrder) -> Result { - log::debug!("parsing stdin"); - let mut input = String::new(); - io::stdin() - .read_to_string(&mut input) - .expect("Failed to read from stdin"); - - let mut items: Vec> = input - .lines() - .rev() - .map(|s| MenuItem::new(s.to_string(), None, None, vec![], None, 0.0, None)) - .collect(); - log::debug!("parsed stdin"); - gui::apply_sort(&mut items, sort_order); - Ok(Self { items }) - } -} - -impl ItemProvider for DMenuProvider { - fn get_elements(&mut self, _: Option<&str>) -> (bool, Vec>) { - (false, self.items.clone()) - } - - fn get_sub_elements(&mut self, _: &MenuItem) -> (bool, Option>>) { - (false, None) - } -} - -#[derive(Debug, Clone, PartialEq)] -enum AutoRunType { - Math, - DRun, - File, - Ssh, - Emoji, - // WebSearch, -} - -#[derive(Clone)] -struct AutoItemProvider { - drun: DRunProvider, - file: FileItemProvider, - math: MathProvider, - ssh: SshProvider, - emoji: EmojiProvider, - last_mode: Option, -} - -impl AutoItemProvider { - fn new(config: &Config) -> Self { - AutoItemProvider { - drun: DRunProvider::new(AutoRunType::DRun, config.no_actions(), config.sort_order()), - file: FileItemProvider::new(AutoRunType::File, config.sort_order()), - math: MathProvider::new(AutoRunType::Math), - ssh: SshProvider::new(AutoRunType::Ssh, &config.sort_order()), - emoji: EmojiProvider::new(AutoRunType::Emoji, &config.sort_order()), - last_mode: None, - } - } - - fn default_auto_elements( - &mut self, - search_opt: Option<&str>, - ) -> (bool, Vec>) { - // return ssh and drun items - let (changed, mut items) = self.drun.get_elements(search_opt); - items.append(&mut self.ssh.get_elements(search_opt).1); - if self.last_mode == Some(AutoRunType::DRun) { - (changed, items) - } else { - self.last_mode = Some(AutoRunType::DRun); - (true, items) - } - } -} - -impl ItemProvider for AutoItemProvider { - fn get_elements(&mut self, search_opt: Option<&str>) -> (bool, Vec>) { - let search = match search_opt { - Some(s) if !s.trim().is_empty() => s.trim(), - _ => return self.default_auto_elements(search_opt), - }; - - let (mode, (changed, items)) = - if MathProvider::::contains_math_functions_or_starts_with_number(search) { - (AutoRunType::Math, self.math.get_elements(search_opt)) - } else if search.starts_with('$') || search.starts_with('/') || search.starts_with('~') - { - (AutoRunType::File, self.file.get_elements(search_opt)) - } else if search.starts_with("ssh") { - (AutoRunType::Ssh, self.ssh.get_elements(search_opt)) - } else if search.starts_with("emoji") { - (AutoRunType::Emoji, self.emoji.get_elements(search_opt)) - } else { - return self.default_auto_elements(search_opt); - }; - - if self.last_mode.as_ref().is_some_and(|m| m == &mode) { - (changed, items) - } else { - self.last_mode = Some(mode); - (true, items) - } - } - - fn get_sub_elements( - &mut self, - item: &MenuItem, - ) -> (bool, Option>>) { - let (changed, items) = self.get_elements(Some(item.label.as_ref())); - (changed, Some(items)) - } -} - -/// Shows the drun mode -/// # Errors -/// -/// Will return `Err` if it was not able to spawn the process -pub fn d_run(config: &Config) -> Result<(), Error> { - let provider = DRunProvider::new(0, config.no_actions(), config.sort_order()); - let cache_path = provider.cache_path.clone(); - let mut cache = provider.cache.clone(); - - // todo ues a arc instead of cloning the config - let selection_result = gui::show(config.clone(), provider, false, None, None); - match selection_result { - Ok(s) => update_drun_cache_and_run(cache_path, &mut cache, s.menu)?, - Err(_) => { - log::error!("No item selected"); - } - } - - Ok(()) -} - -/// Shows the run mode -/// # Errors -/// -/// Will return `Err` if it was not able to spawn the process -pub fn run(config: &Config) -> Result<(), Error> { - let provider = RunProvider::new(config.sort_order()); - let cache_path = provider.cache_path.clone(); - let mut cache = provider.cache.clone(); - - let selection_result = gui::show(config.clone(), provider, false, None, None); - match selection_result { - Ok(s) => update_run_cache_and_run(cache_path, &mut cache, s.menu)?, - Err(_) => { - log::error!("No item selected"); - } - } - - Ok(()) -} - -/// Shows the auto mode -/// # Errors -/// -/// Will return `Err` -/// * if it was not able to spawn the process -/// -/// # Panics -/// Panics if an internal static regex cannot be passed anymore, should never happen -pub fn auto(config: &Config) -> Result<(), Error> { - let mut provider = AutoItemProvider::new(config); - let cache_path = provider.drun.cache_path.clone(); - let mut cache = provider.drun.cache.clone(); - - loop { - // todo ues a arc instead of cloning the config - let selection_result = gui::show( - config.clone(), - provider.clone(), - true, - Some( - vec!["ssh", "emoji", "^\\$\\w+"] - .into_iter() - .map(|s| Regex::new(s).unwrap()) - .collect(), - ), - None, - ); - - if let Ok(selection_result) = selection_result { - let mut selection_result = selection_result.menu; - if let Some(data) = &selection_result.data { - match data { - AutoRunType::Math => { - provider.math.elements.push(selection_result); - } - AutoRunType::DRun => { - update_drun_cache_and_run(cache_path, &mut cache, selection_result)?; - break; - } - AutoRunType::File => { - if let Some(action) = selection_result.action { - spawn_fork(&action, selection_result.working_dir.as_ref())?; - } - break; - } - AutoRunType::Ssh => { - ssh_launch(&selection_result, config)?; - break; - } - AutoRunType::Emoji => { - if let Some(action) = selection_result.action { - copy_to_clipboard(action)?; - } else { - return Err(Error::MissingAction); - } - break; - } - } - } else if selection_result.label.starts_with("ssh") { - selection_result.label = selection_result.label.chars().skip(4).collect(); - ssh_launch(&selection_result, config)?; - } - } else { - log::error!("No item selected"); - break; - } - } - - Ok(()) -} - -/// Shows the file browser mode -/// # Errors -/// -/// Will return `Err` -/// * if it was not able to spawn the process -/// -/// # Panics -/// In case an internal regex does not parse anymore, this should never happen -pub fn file(config: &Config) -> Result<(), Error> { - let provider = FileItemProvider::new(0, config.sort_order()); - - // todo ues a arc instead of cloning the config - let selection_result = gui::show( - config.clone(), - provider, - false, - Some(vec![Regex::new("^\\$\\w+").unwrap()]), - None, - )?; - if let Some(action) = selection_result.menu.action { - spawn_fork(&action, selection_result.menu.working_dir.as_ref()) - } else { - Err(Error::MissingAction) - } -} - -fn ssh_launch(menu_item: &MenuItem, config: &Config) -> Result<(), Error> { - let ssh_cmd = if let Some(action) = &menu_item.action { - action.clone() - } else { - let cmd = config - .term() - .map(|s| format!("{s} ssh {}", menu_item.label)); - if let Some(cmd) = cmd { - cmd - } else { - return Err(Error::MissingAction); - } - }; - - let cmd = format!( - "{} bash -c \"source ~/.bashrc; {ssh_cmd}\"", - config.term().unwrap_or_default() - ); - spawn_fork(&cmd, menu_item.working_dir.as_ref()) -} - -/// Shows the ssh mode -/// # Errors -/// -/// Will return `Err` -/// * if it was not able to spawn the process -/// * if it didn't find a terminal -pub fn ssh(config: &Config) -> Result<(), Error> { - let provider = SshProvider::new(0, &config.sort_order()); - let selection_result = gui::show(config.clone(), provider, true, None, None); - if let Ok(mi) = selection_result { - ssh_launch(&mi.menu, config)?; - } else { - log::error!("No item selected"); - } - Ok(()) -} - -/// Shows the math mode -pub fn math(config: &Config) { - let mut calc: Vec> = vec![]; - loop { - let mut provider = MathProvider::new(String::new()); - provider.add_elements(&mut calc.clone()); - let selection_result = gui::show(config.clone(), provider, true, None, None); - if let Ok(mi) = selection_result { - calc.push(mi.menu); - } else { - log::error!("No item selected"); - break; - } - } -} - -/// Shows the emoji mode -/// # Errors -/// -/// Forwards errors from the gui. See `gui::show` for details. -pub fn emoji(config: &Config) -> Result<(), Error> { - let provider = EmojiProvider::new(0, &config.sort_order()); - let selection_result = gui::show(config.clone(), provider, true, None, None)?; - match selection_result.menu.action { - None => Err(Error::MissingAction), - Some(action) => copy_to_clipboard(action), - } -} - -/// Shows the dmenu mode -/// # Errors -/// -/// Forwards errors from the gui. See `gui::show` for details. -pub fn dmenu(config: &Config) -> Result<(), Error> { - let provider = DMenuProvider::new(&config.sort_order())?; - - let selection_result = gui::show(config.clone(), provider, true, None, None); - match selection_result { - Ok(s) => { - println!("{}", s.menu.label); - Ok(()) - } - Err(_) => Err(Error::InvalidSelection), - } -} - -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(Error::MissingAction) - } -} - -fn update_run_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 run cache {e:?}"); - } - } - - if let Some(action) = selection_result.action { - let program = CString::new(action).unwrap(); - let args = [program.clone()]; - - // This replaces the current process image - nix::unistd::execvp(&program, &args).map_err(|e| Error::RunFailed(e.to_string()))?; - Ok(()) - } else { - Err(Error::MissingAction) - } -} - -fn load_d_run_cache() -> (Option, HashMap) { - let cache_path = dirs::cache_dir().map(|x| x.join("worf-drun")); - load_cache(cache_path) -} - -fn load_run_cache() -> (Option, HashMap) { - let cache_path = dirs::cache_dir().map(|x| x.join("worf-run")); - load_cache(cache_path) -} - -fn load_cache(cache_path: Option) -> (Option, HashMap) { - let 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, cache) -} diff --git a/worf/src/lib/modes/auto.rs b/worf/src/lib/modes/auto.rs new file mode 100644 index 0000000..7867a54 --- /dev/null +++ b/worf/src/lib/modes/auto.rs @@ -0,0 +1,182 @@ +use regex::Regex; +use crate::config::Config; +use crate::desktop::{copy_to_clipboard, spawn_fork}; +use crate::{gui, Error}; +use crate::gui::{ItemProvider, MenuItem}; +use crate::modes::math::MathProvider; +use crate::modes::drun::{update_drun_cache_and_run, DRunProvider}; +use crate::modes::emoji::EmojiProvider; +use crate::modes::file::FileItemProvider; +use crate::modes::ssh; +use crate::modes::ssh::SshProvider; + +#[derive(Debug, Clone, PartialEq)] +enum AutoRunType { + Math, + DRun, + File, + Ssh, + Emoji, + // WebSearch, +} + +#[derive(Clone)] +struct AutoItemProvider { + drun: DRunProvider, + file: FileItemProvider, + math: MathProvider, + ssh: SshProvider, + emoji: EmojiProvider, + last_mode: Option, +} + +impl AutoItemProvider { + fn new(config: &Config) -> Self { + AutoItemProvider { + drun: DRunProvider::new(AutoRunType::DRun, config.no_actions(), config.sort_order()), + file: FileItemProvider::new(AutoRunType::File, config.sort_order()), + math: MathProvider::new(AutoRunType::Math), + ssh: SshProvider::new(AutoRunType::Ssh, &config.sort_order()), + emoji: EmojiProvider::new(AutoRunType::Emoji, &config.sort_order()), + last_mode: None, + } + } + + fn default_auto_elements( + &mut self, + search_opt: Option<&str>, + ) -> (bool, Vec>) { + // return ssh and drun items + let (changed, mut items) = self.drun.get_elements(search_opt); + items.append(&mut self.ssh.get_elements(search_opt).1); + if self.last_mode == Some(AutoRunType::DRun) { + (changed, items) + } else { + self.last_mode = Some(AutoRunType::DRun); + (true, items) + } + } +} + +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>) -> (bool, Vec>) { + let search = match search_opt { + Some(s) if !s.trim().is_empty() => s.trim(), + _ => return self.default_auto_elements(search_opt), + }; + + let (mode, (changed, items)) = + if contains_math_functions_or_starts_with_number(search) { + (AutoRunType::Math, self.math.get_elements(search_opt)) + } else if search.starts_with('$') || search.starts_with('/') || search.starts_with('~') + { + (AutoRunType::File, self.file.get_elements(search_opt)) + } else if search.starts_with("ssh") { + (AutoRunType::Ssh, self.ssh.get_elements(search_opt)) + } else if search.starts_with("emoji") { + (AutoRunType::Emoji, self.emoji.get_elements(search_opt)) + } else { + return self.default_auto_elements(search_opt); + }; + + if self.last_mode.as_ref().is_some_and(|m| m == &mode) { + (changed, items) + } else { + self.last_mode = Some(mode); + (true, items) + } + } + + fn get_sub_elements( + &mut self, + item: &MenuItem, + ) -> (bool, Option>>) { + let (changed, items) = self.get_elements(Some(item.label.as_ref())); + (changed, Some(items)) + } +} + + +/// Shows the auto mode +/// # Errors +/// +/// Will return `Err` +/// * if it was not able to spawn the process +/// +/// # Panics +/// Panics if an internal static regex cannot be passed anymore, should never happen +pub fn show(config: &Config) -> Result<(), Error> { + let mut provider = AutoItemProvider::new(config); + let cache_path = provider.drun.cache_path.clone(); + let mut cache = provider.drun.cache.clone(); + + loop { + // todo ues a arc instead of cloning the config + let selection_result = gui::show( + config.clone(), + provider.clone(), + true, + Some( + vec!["ssh", "emoji", "^\\$\\w+"] + .into_iter() + .map(|s| Regex::new(s).unwrap()) + .collect(), + ), + None, + ); + + if let Ok(selection_result) = selection_result { + let mut selection_result = selection_result.menu; + if let Some(data) = &selection_result.data { + match data { + AutoRunType::Math => { + provider.math.elements.push(selection_result); + } + AutoRunType::DRun => { + update_drun_cache_and_run(cache_path, &mut cache, selection_result)?; + break; + } + AutoRunType::File => { + if let Some(action) = selection_result.action { + spawn_fork(&action, selection_result.working_dir.as_ref())?; + } + break; + } + AutoRunType::Ssh => { + ssh::launch(&selection_result, config)?; + break; + } + AutoRunType::Emoji => { + if let Some(action) = selection_result.action { + copy_to_clipboard(action)?; + } else { + return Err(Error::MissingAction); + } + break; + } + } + } else if selection_result.label.starts_with("ssh") { + selection_result.label = selection_result.label.chars().skip(4).collect(); + ssh::launch(&selection_result, config)?; + } + } else { + log::error!("No item selected"); + break; + } + } + + Ok(()) +} \ No newline at end of file diff --git a/worf/src/lib/modes/dmenu.rs b/worf/src/lib/modes/dmenu.rs new file mode 100644 index 0000000..d8c5759 --- /dev/null +++ b/worf/src/lib/modes/dmenu.rs @@ -0,0 +1,56 @@ +use std::io; +use std::io::Read; +use crate::config::{Config, SortOrder}; +use crate::{gui, Error}; +use crate::gui::{ItemProvider, MenuItem}; + +#[derive(Clone)] +struct DMenuProvider { + items: Vec>, +} + +impl DMenuProvider { + fn new(sort_order: &SortOrder) -> DMenuProvider { + log::debug!("parsing stdin"); + let mut input = String::new(); + io::stdin() + .read_to_string(&mut input) + .expect("Failed to read from stdin"); + + let mut items: Vec> = input + .lines() + .rev() + .map(|s| MenuItem::new(s.to_string(), None, None, vec![], None, 0.0, None)) + .collect(); + log::debug!("parsed stdin"); + gui::apply_sort(&mut items, sort_order); + Self { items } + } +} +impl ItemProvider for DMenuProvider { + fn get_elements(&mut self, _: Option<&str>) -> (bool, Vec>) { + (false, self.items.clone()) + } + + fn get_sub_elements(&mut self, _: &MenuItem) -> (bool, Option>>) { + (false, None) + } +} + + +/// Shows the dmenu mode +/// # Errors +/// +/// Forwards errors from the gui. See `gui::show` for details. +pub fn show(config: &Config) -> Result<(), Error> { + let provider = DMenuProvider::new(&config.sort_order()); + + let selection_result = gui::show(config.clone(), provider, true, None, None); + match selection_result { + Ok(s) => { + println!("{}", s.menu.label); + Ok(()) + } + Err(_) => Err(Error::InvalidSelection), + } +} \ No newline at end of file diff --git a/worf/src/lib/modes/drun.rs b/worf/src/lib/modes/drun.rs new file mode 100644 index 0000000..8e6c064 --- /dev/null +++ b/worf/src/lib/modes/drun.rs @@ -0,0 +1,203 @@ +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::time::Instant; +use freedesktop_file_parser::EntryType; +use rayon::prelude::*; +use serde::{Deserialize, Serialize}; +use crate::config::{Config, SortOrder}; +use crate::desktop::{find_desktop_files, get_locale_variants, lookup_name_with_locale, save_cache_file, spawn_fork}; +use crate::{gui, Error}; +use crate::gui::{ItemProvider, MenuItem}; +use crate::modes::load_cache; + +#[derive(Debug, Deserialize, Serialize, Clone)] +struct DRunCache { + desktop_entry: String, + run_count: usize, +} + +#[derive(Clone)] +pub(crate) struct DRunProvider { + items: Option>>, + pub(crate) cache_path: Option, + pub(crate) cache: HashMap, + data: T, + no_actions: bool, + sort_order: SortOrder, +} + +impl ItemProvider for DRunProvider { + fn get_elements(&mut self, _: Option<&str>) -> (bool, Vec>) { + if self.items.is_none() { + self.items = Some(self.load().clone()); + } + (false, self.items.clone().unwrap()) + } + + fn get_sub_elements(&mut self, _: &MenuItem) -> (bool, Option>>) { + (false, None) + } +} + +impl DRunProvider { + pub(crate) fn new(menu_item_data: T, no_actions: bool, sort_order: SortOrder) -> Self { + let (cache_path, d_run_cache) = load_d_run_cache(); + DRunProvider { + items: None, + cache_path, + cache: d_run_cache, + data: menu_item_data, + no_actions, + sort_order, + } + } + + #[allow(clippy::cast_possible_truncation)] + #[allow(clippy::cast_precision_loss)] + fn load(&self) -> Vec> { + let locale_variants = get_locale_variants(); + let default_icon = "application-x-executable".to_string(); + let start = Instant::now(); + + let entries: Vec> = find_desktop_files() + .into_par_iter() + .filter(|file| { + !file.entry.no_display.unwrap_or(false) + && !file.entry.hidden.unwrap_or(false) + }) + .filter_map(|file| { + let name = lookup_name_with_locale( + &locale_variants, + &file.entry.name.variants, + &file.entry.name.default, + )?; + + let (action, working_dir) = match &file.entry.entry_type { + EntryType::Application(app) => (app.exec.clone(), app.path.clone()), + _ => return 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"); + return None; + } + + let icon = file + .entry + .icon + .as_ref() + .map(|s| s.content.clone()) + .or(Some(default_icon.clone())); + + let sort_score = *self.cache.get(&name).unwrap_or(&0) as f64; + + let mut entry = MenuItem::new( + name.clone(), + icon.clone(), + action.clone(), + Vec::new(), + working_dir.clone(), + sort_score, + Some(self.data.clone()), + ); + if !self.no_actions { + for action in file.actions.values() { + 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()) + .unwrap_or("application-x-executable".to_string()); + + + entry.sub_elements.push(MenuItem::new( + action_name, + Some(action_icon), + action.exec.clone(), + Vec::new(), + working_dir.clone(), + 0.0, + Some(self.data.clone()), + )); + } + } + } + Some(entry) + }) + .collect(); + + let mut seen_actions = HashSet::new(); + let mut entries: Vec> = entries + .into_iter() + .filter(|entry| seen_actions.insert(entry.action.clone())) + .collect(); + + log::info!( + "parsing desktop files took {}ms", + start.elapsed().as_millis() + ); + + gui::apply_sort(&mut entries, &self.sort_order); + entries + } +} + +fn load_d_run_cache() -> (Option, HashMap) { + let cache_path = dirs::cache_dir().map(|x| x.join("worf-drun")); + load_cache(cache_path) +} + +pub(crate) 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(Error::MissingAction) + } +} + +/// Shows the drun mode +/// # Errors +/// +/// Will return `Err` if it was not able to spawn the process +pub fn show(config: &Config) -> Result<(), Error> { + let provider = DRunProvider::new(0, config.no_actions(), config.sort_order()); + let cache_path = provider.cache_path.clone(); + let mut cache = provider.cache.clone(); + + // todo ues a arc instead of cloning the config + let selection_result = gui::show(config.clone(), provider, false, None, None); + match selection_result { + Ok(s) => update_drun_cache_and_run(cache_path, &mut cache, s.menu)?, + Err(_) => { + log::error!("No item selected"); + } + } + + Ok(()) +} diff --git a/worf/src/lib/modes/emoji.rs b/worf/src/lib/modes/emoji.rs new file mode 100644 index 0000000..e782761 --- /dev/null +++ b/worf/src/lib/modes/emoji.rs @@ -0,0 +1,60 @@ +use crate::config::{Config, SortOrder}; +use crate::{gui, Error}; +use crate::desktop::copy_to_clipboard; +use crate::gui::{ItemProvider, MenuItem}; + +#[derive(Clone)] +pub(crate) struct EmojiProvider { + elements: Vec>, + #[allow(dead_code)] // needed for the detection of mode in 'auto' + menu_item_data: T, +} + +impl EmojiProvider { + pub(crate) fn new(data: T, sort_order: &SortOrder) -> Self { + let emoji = emoji::search::search_annotation_all(""); + let mut menus = emoji + .into_iter() + .map(|e| { + MenuItem::new( + format!("{} — Category: {} — Name: {}", e.glyph, e.group, e.name), + None, + Some(format!("emoji {}", e.glyph)), + vec![], + None, + 0.0, + Some(data.clone()), + ) + }) + .collect::>(); + gui::apply_sort(&mut menus, sort_order); + + Self { + elements: menus, + menu_item_data: data.clone(), + } + } +} + +impl ItemProvider for EmojiProvider { + fn get_elements(&mut self, _: Option<&str>) -> (bool, Vec>) { + (false, self.elements.clone()) + } + + fn get_sub_elements(&mut self, _: &MenuItem) -> (bool, Option>>) { + (false, None) + } +} + +/// Shows the emoji mode +/// # Errors +/// +/// Forwards errors from the gui. See `gui::show` for details. +pub fn show(config: &Config) -> Result<(), Error> { + let provider = EmojiProvider::new(0, &config.sort_order()); + let selection_result = gui::show(config.clone(), provider, true, None, None)?; + match selection_result.menu.action { + None => Err(Error::MissingAction), + Some(action) => copy_to_clipboard(action), + } +} diff --git a/worf/src/lib/modes/file.rs b/worf/src/lib/modes/file.rs new file mode 100644 index 0000000..24bc795 --- /dev/null +++ b/worf/src/lib/modes/file.rs @@ -0,0 +1,214 @@ +use std::fs; +use std::os::unix::fs::FileTypeExt; +use std::path::{Path, PathBuf}; +use regex::Regex; +use crate::config::{expand_path, Config, SortOrder}; +use crate::{gui, Error}; +use crate::desktop::spawn_fork; +use crate::gui::{ItemProvider, MenuItem}; + +#[derive(Clone)] +pub(crate) struct FileItemProvider { + last_result: Option>>, + menu_item_data: T, + sort_order: SortOrder, +} + +impl FileItemProvider { + pub(crate) fn new(menu_item_data: T, sort_order: SortOrder) -> Self { + FileItemProvider { + last_result: None, + menu_item_data, + sort_order, + } + } + + fn resolve_icon_for_name(path: &Path) -> String { + let type_result = fs::symlink_metadata(path) + .map(|meta| meta.file_type()) + .map(|file_type| { + if file_type.is_symlink() { + Some("edit-redo") + } else if file_type.is_char_device() { + Some("input-keyboard") + } else if file_type.is_block_device() { + Some("drive-harddisk") + } else if file_type.is_socket() { + Some("network-transmit-receive") + } else if file_type.is_fifo() { + Some("rotation-allowed") + } else { + None + } + }) + .unwrap_or(Some("system-lock-screen")); + + if let Some(tr) = type_result { + return tr.to_owned(); + } + + let Some(mime) = tree_magic_mini::from_filepath(path) else { + return "image-not-found".to_string(); + }; + + if mime.starts_with("image") { + return "image-x-generic".to_string(); + } + + if mime.starts_with("inode") { + return mime.replace('/', "-"); + } + + if mime.starts_with("text") { + return if mime.contains("plain") { + "text-x-generic".to_string() + } else if mime.contains("python") { + "text-x-script".to_string() + } else if mime.contains("html") { + "text-html".to_string() + } else { + "text-x-generic".to_string() + }; + } + + if mime.starts_with("application") { + return if mime.contains("octet") { + "application-x-executable".to_string() + } else if mime.contains("tar") + || mime.contains("lz") + || mime.contains("zip") + || mime.contains("7z") + || mime.contains("xz") + { + "package-x-generic".to_string() + } else { + "text-html".to_string() + }; + } + + log::debug!("unsupported mime type {mime}"); + "application-x-generic".to_string() + } +} + +impl ItemProvider for FileItemProvider { + fn get_elements(&mut self, search: Option<&str>) -> (bool, 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.starts_with('$') + { + trimmed_search = format!("{default_path}/{trimmed_search}"); + } + + let path = expand_path(&trimmed_search); + let mut items: Vec> = Vec::new(); + + if !path.exists() { + if let Some(last) = &self.last_result { + return (false, last.clone()); + } + + return (true, vec![]); + } + + if path.is_dir() { + items.push(MenuItem::new( + trimmed_search.clone(), + Some(FileItemProvider::::resolve_icon_for_name(&path)), + Some(format!("xdg-open {}", path.display())), + vec![], + None, + 100.0, + Some(self.menu_item_data.clone()), + )); + + 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.push('/'); + } + + items.push(MenuItem::new( + path_str.clone(), + Some(FileItemProvider::::resolve_icon_for_name(&entry.path())), + Some(format!("xdg-open {path_str}")), + vec![], + None, + 0.0, + Some(self.menu_item_data.clone()), + )); + } + } + } + } else { + items.push({ + MenuItem::new( + trimmed_search.clone(), + Some(FileItemProvider::::resolve_icon_for_name( + &PathBuf::from(&trimmed_search), + )), + Some(format!("xdg-open {trimmed_search}")), + vec![], + None, + 0.0, + Some(self.menu_item_data.clone()), + ) + }); + } + + gui::apply_sort(&mut items, &self.sort_order); + + self.last_result = Some(items.clone()); + (true, items) + } + + fn get_sub_elements(&mut self, _: &MenuItem) -> (bool, Option>>) { + (false, self.last_result.clone()) + } +} + + + +/// Shows the file browser mode +/// # Errors +/// +/// Will return `Err` +/// * if it was not able to spawn the process +/// +/// # Panics +/// In case an internal regex does not parse anymore, this should never happen +pub fn show(config: &Config) -> Result<(), Error> { + let provider = FileItemProvider::new(0, config.sort_order()); + + // todo ues a arc instead of cloning the config + let selection_result = gui::show( + config.clone(), + provider, + false, + Some(vec![Regex::new("^\\$\\w+").unwrap()]), + None, + )?; + if let Some(action) = selection_result.menu.action { + spawn_fork(&action, selection_result.menu.working_dir.as_ref()) + } else { + Err(Error::MissingAction) + } +} diff --git a/worf/src/lib/modes/math.rs b/worf/src/lib/modes/math.rs new file mode 100644 index 0000000..aa75ce6 --- /dev/null +++ b/worf/src/lib/modes/math.rs @@ -0,0 +1,67 @@ +use crate::config::Config; +use crate::gui; +use crate::gui::{ItemProvider, MenuItem}; + +#[derive(Clone)] +pub(crate) struct MathProvider { + menu_item_data: T, + pub(crate) elements: Vec>, +} + +impl MathProvider { + pub(crate) fn new(menu_item_data: T) -> Self { + Self { + menu_item_data, + elements: vec![], + } + } + fn add_elements(&mut self, elements: &mut Vec>) { + self.elements.append(elements); + } +} + +impl ItemProvider for MathProvider { + fn get_elements(&mut self, search: Option<&str>) -> (bool, 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::new( + result, + None, + search.map(String::from), + vec![], + None, + 0.0, + Some(self.menu_item_data.clone()), + ); + let mut result = vec![item]; + result.append(&mut self.elements.clone()); + (true, result) + } else { + (false, self.elements.clone()) + } + } + + fn get_sub_elements(&mut self, _: &MenuItem) -> (bool, Option>>) { + (false, None) + } +} + +/// Shows the math mode +pub fn show(config: &Config) { + let mut calc: Vec> = vec![]; + loop { + let mut provider = MathProvider::new(String::new()); + provider.add_elements(&mut calc.clone()); + let selection_result = gui::show(config.clone(), provider, true, None, None); + if let Ok(mi) = selection_result { + calc.push(mi.menu); + } else { + log::error!("No item selected"); + break; + } + } +} diff --git a/worf/src/lib/modes/mod.rs b/worf/src/lib/modes/mod.rs new file mode 100644 index 0000000..e5d4706 --- /dev/null +++ b/worf/src/lib/modes/mod.rs @@ -0,0 +1,26 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use crate::desktop::{create_file_if_not_exists, load_cache_file}; + +pub mod dmenu; +pub mod file; +pub mod math; +pub mod auto; +pub mod drun; +pub mod run; +pub mod ssh; +pub mod emoji; + + +pub(crate) fn load_cache(cache_path: Option) -> (Option, HashMap) { + let 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, cache) +} diff --git a/worf/src/lib/modes/run.rs b/worf/src/lib/modes/run.rs new file mode 100644 index 0000000..d11476e --- /dev/null +++ b/worf/src/lib/modes/run.rs @@ -0,0 +1,144 @@ +use std::collections::{HashMap, HashSet}; +use std::{env, fs}; +use std::ffi::CString; +use std::path::PathBuf; +use crate::config::{Config, SortOrder}; +use crate::desktop::{is_executable, save_cache_file}; +use crate::{gui, Error}; +use crate::gui::{ItemProvider, MenuItem}; +use crate::modes::load_cache; + +impl ItemProvider for RunProvider { + fn get_elements(&mut self, _: Option<&str>) -> (bool, Vec>) { + if self.items.is_none() { + self.items = Some(self.load().clone()); + } + (false, self.items.clone().unwrap()) + } + + fn get_sub_elements(&mut self, _: &MenuItem) -> (bool, Option>>) { + (false, None) + } +} + +#[derive(Clone)] +struct RunProvider { + items: Option>>, + cache_path: Option, + cache: HashMap, + sort_order: SortOrder, +} + +impl RunProvider { + fn new(sort_order: SortOrder) -> Self { + let (cache_path, d_run_cache) = load_run_cache(); + RunProvider { + items: None, + cache_path, + cache: d_run_cache, + sort_order, + } + } + + #[allow(clippy::cast_possible_truncation)] + #[allow(clippy::cast_precision_loss)] + fn load(&self) -> Vec> { + let path_var = env::var("PATH").unwrap_or_default(); + let paths = env::split_paths(&path_var); + + let entries: Vec<_> = paths + .filter(|dir| dir.is_dir()) + .flat_map(|dir| { + fs::read_dir(dir) + .into_iter() + .flatten() + .filter_map(Result::ok) + .filter_map(|entry| { + let path = entry.path(); + if !is_executable(&path) { + return None; + } + + let label = path.file_name()?.to_str()?.to_string(); + let sort_score = *self.cache.get(&label).unwrap_or(&0) as f64; + + Some(MenuItem::new( + label, + None, + path.to_str().map(ToString::to_string), + vec![], + None, + sort_score, + None, + )) + }) + }) + .collect(); + + let mut seen_actions = HashSet::new(); + let mut entries: Vec> = entries + .into_iter() + .filter(|entry| { + entry + .action + .as_ref() + .and_then(|action| action.split('/').next_back()) + .is_some_and(|cmd| seen_actions.insert(cmd.to_string())) + }) + .collect(); + + gui::apply_sort(&mut entries, &self.sort_order); + entries + } +} + + +fn load_run_cache() -> (Option, HashMap) { + let cache_path = dirs::cache_dir().map(|x| x.join("worf-run")); + load_cache(cache_path) +} + +fn update_run_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 run cache {e:?}"); + } + } + + if let Some(action) = selection_result.action { + let program = CString::new(action).unwrap(); + let args = [program.clone()]; + + // This replaces the current process image + nix::unistd::execvp(&program, &args).map_err(|e| Error::RunFailed(e.to_string()))?; + Ok(()) + } else { + Err(Error::MissingAction) + } +} + + +/// Shows the run mode +/// # Errors +/// +/// Will return `Err` if it was not able to spawn the process +pub fn show(config: &Config) -> Result<(), Error> { + let provider = RunProvider::new(config.sort_order()); + let cache_path = provider.cache_path.clone(); + let mut cache = provider.cache.clone(); + + let selection_result = gui::show(config.clone(), provider, false, None, None); + match selection_result { + Ok(s) => update_run_cache_and_run(cache_path, &mut cache, s.menu)?, + Err(_) => { + log::error!("No item selected"); + } + } + + Ok(()) +} diff --git a/worf/src/lib/modes/ssh.rs b/worf/src/lib/modes/ssh.rs new file mode 100644 index 0000000..4f84d17 --- /dev/null +++ b/worf/src/lib/modes/ssh.rs @@ -0,0 +1,95 @@ +use std::fs; +use regex::Regex; +use crate::config::{Config, SortOrder}; +use crate::{gui, Error}; +use crate::desktop::spawn_fork; +use crate::gui::{ItemProvider, MenuItem}; + +#[derive(Clone)] +pub(crate) struct SshProvider { + elements: Vec>, +} + +impl SshProvider { + pub(crate) fn new(menu_item_data: T, order: &SortOrder) -> Self { + let re = Regex::new(r"(?m)^\s*Host\s+(.+)$").unwrap(); + let mut items: Vec<_> = dirs::home_dir() + .map(|home| home.join(".ssh").join("config")) + .filter(|path| path.exists()) + .map(|path| fs::read_to_string(&path).unwrap_or_default()) + .into_iter() + .flat_map(|content| { + re.captures_iter(&content) + .flat_map(|cap| { + cap[1] + .split_whitespace() + .map(|host| { + log::debug!("found ssh host {host}"); + MenuItem::new( + host.to_owned(), + Some("computer".to_owned()), + Some(format!("ssh {host}")), + vec![], + None, + 0.0, + Some(menu_item_data.clone()), + ) + }) + .collect::>() + }) + .collect::>() + }) + .collect(); + + gui::apply_sort(&mut items, order); + Self { elements: items } + } +} + +impl ItemProvider for SshProvider { + fn get_elements(&mut self, _: Option<&str>) -> (bool, Vec>) { + (false, self.elements.clone()) + } + + fn get_sub_elements(&mut self, _: &MenuItem) -> (bool, Option>>) { + (false, None) + } +} + +pub(crate) fn launch(menu_item: &MenuItem, config: &Config) -> Result<(), Error> { + let ssh_cmd = if let Some(action) = &menu_item.action { + action.clone() + } else { + let cmd = config + .term() + .map(|s| format!("{s} ssh {}", menu_item.label)); + if let Some(cmd) = cmd { + cmd + } else { + return Err(Error::MissingAction); + } + }; + + let cmd = format!( + "{} bash -c \"source ~/.bashrc; {ssh_cmd}\"", + config.term().unwrap_or_default() + ); + spawn_fork(&cmd, menu_item.working_dir.as_ref()) +} + +/// Shows the ssh mode +/// # Errors +/// +/// Will return `Err` +/// * if it was not able to spawn the process +/// * if it didn't find a terminal +pub fn show(config: &Config) -> Result<(), Error> { + let provider = SshProvider::new(0, &config.sort_order()); + let selection_result = gui::show(config.clone(), provider, true, None, None); + if let Ok(mi) = selection_result { + launch(&mi.menu, config)?; + } else { + log::error!("No item selected"); + } + Ok(()) +} \ No newline at end of file diff --git a/worf/src/main.rs b/worf/src/main.rs index fbf9d25..988e1be 100644 --- a/worf/src/main.rs +++ b/worf/src/main.rs @@ -2,7 +2,7 @@ use std::env; use anyhow::anyhow; use worf_lib::config::Mode; -use worf_lib::{Error, config, mode}; +use worf_lib::{Error, config, mode, modes}; fn main() -> anyhow::Result<()> { env_logger::Builder::new() .parse_filters(&env::var("RUST_LOG").unwrap_or_else(|_| "error".to_owned())) @@ -14,17 +14,17 @@ fn main() -> anyhow::Result<()> { if let Some(show) = &config.show() { let result = match show { - Mode::Run => mode::run(&config), - Mode::Drun => mode::d_run(&config), - Mode::Dmenu => mode::dmenu(&config), - Mode::File => mode::file(&config), + Mode::Run => modes::run::show(&config), + Mode::Drun => modes::drun::show(&config), + Mode::Dmenu => modes::dmenu::show(&config), + Mode::File => modes::file::show(&config), Mode::Math => { - mode::math(&config); + modes::math::show(&config); Ok(()) } - Mode::Ssh => mode::ssh(&config), - Mode::Emoji => mode::emoji(&config), - Mode::Auto => mode::auto(&config), + Mode::Ssh => modes::ssh::show(&config), + Mode::Emoji => modes::emoji::show(&config), + Mode::Auto => modes::auto::show(&config), }; if let Err(err) = result {