diff --git a/Cargo.lock b/Cargo.lock index 58411ab..496717f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" +dependencies = [ + "lazy_static", + "regex", +] + [[package]] name = "addr2line" version = "0.24.2" @@ -361,9 +371,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clap" -version = "4.5.38" +version = "4.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" +checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" dependencies = [ "clap_builder", "clap_derive", @@ -371,9 +381,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.38" +version = "4.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" +checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" dependencies = [ "anstream", "anstyle", @@ -383,9 +393,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.32" +version = "4.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" dependencies = [ "heck", "proc-macro2", @@ -1462,6 +1472,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c1c64d6120e51cd86033f67176b1cb66780c2efe34dec55176f77befd93c0a" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.36.7" @@ -1941,6 +1961,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustversion" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" + [[package]] name = "ryu" version = "1.0.20" @@ -2069,6 +2095,25 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" + +[[package]] +name = "strum_macros" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.101", +] + [[package]] name = "syn" version = "1.0.109" @@ -2093,15 +2138,16 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.34.2" +version = "0.35.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4b93974b3d3aeaa036504b8eefd4c039dced109171c1ae973f1dc63b2c7e4b2" +checksum = "3c3ffa3e4ff2b324a57f7aeb3c349656c7b127c3c189520251a648102a92496e" dependencies = [ "libc", "memchr", "ntapi", "objc2-core-foundation", - "windows 0.57.0", + "objc2-io-kit", + "windows", ] [[package]] @@ -2131,7 +2177,7 @@ checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" dependencies = [ "quick-xml", "thiserror 2.0.12", - "windows 0.61.1", + "windows", "windows-version", ] @@ -2501,16 +2547,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" -dependencies = [ - "windows-core 0.57.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.61.1" @@ -2518,7 +2554,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419" dependencies = [ "windows-collections", - "windows-core 0.61.2", + "windows-core", "windows-future", "windows-link", "windows-numerics", @@ -2530,19 +2566,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" -dependencies = [ - "windows-implement 0.57.0", - "windows-interface 0.57.0", - "windows-result 0.1.2", - "windows-targets 0.52.6", + "windows-core", ] [[package]] @@ -2551,10 +2575,10 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement 0.60.0", - "windows-interface 0.59.1", + "windows-implement", + "windows-interface", "windows-link", - "windows-result 0.3.4", + "windows-result", "windows-strings", ] @@ -2564,22 +2588,11 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "windows-core 0.61.2", + "windows-core", "windows-link", "windows-threading", ] -[[package]] -name = "windows-implement" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", -] - [[package]] name = "windows-implement" version = "0.60.0" @@ -2591,17 +2604,6 @@ dependencies = [ "syn 2.0.101", ] -[[package]] -name = "windows-interface" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", -] - [[package]] name = "windows-interface" version = "0.59.1" @@ -2625,19 +2627,10 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core 0.61.2", + "windows-core", "windows-link", ] -[[package]] -name = "windows-result" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-result" version = "0.3.4" @@ -2867,7 +2860,7 @@ dependencies = [ [[package]] name = "worf" -version = "0.2.0" +version = "0.4.0" dependencies = [ "clap", "crossbeam", @@ -2896,15 +2889,30 @@ dependencies = [ "wl-clipboard-rs", ] +[[package]] +name = "worf-hyprspace" +version = "0.1.0" +dependencies = [ + "Inflector", + "clap", + "env_logger", + "hyprland", + "log", + "nix", + "regex", + "serde", + "strum", + "strum_macros", + "worf", +] + [[package]] name = "worf-hyprswitch" version = "0.1.0" dependencies = [ - "dirs 6.0.0", "env_logger", "freedesktop-icons", "hyprland", - "log", "rayon", "sysinfo", "toml", diff --git a/Cargo.toml b/Cargo.toml index fbd68fa..fc39350 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "worf", "examples/worf-warden", "examples/worf-hyprswitch", + "examples/worf-hyprspace", ] resolver = "3" diff --git a/examples/images/hyprspace.png b/examples/images/hyprspace.png new file mode 100644 index 0000000..f89112b Binary files /dev/null and b/examples/images/hyprspace.png differ diff --git a/examples/worf-hyprspace/Cargo.toml b/examples/worf-hyprspace/Cargo.toml new file mode 100644 index 0000000..cce0224 --- /dev/null +++ b/examples/worf-hyprspace/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "worf-hyprspace" +version = "0.1.0" +edition = "2024" + +[dependencies] +worf = {path = "../../worf"} +env_logger = "0.11.8" +hyprland = "0.4.0-beta.2" +clap = "4.5.40" +serde = "1.0.219" +strum = "0.27.1" +strum_macros = "0.27.1" +Inflector = "0.11" +regex = "1.11.1" +log = "0.4.27" +nix = "0.30.1" diff --git a/examples/worf-hyprspace/Readme.md b/examples/worf-hyprspace/Readme.md new file mode 100644 index 0000000..ac877ef --- /dev/null +++ b/examples/worf-hyprspace/Readme.md @@ -0,0 +1,15 @@ +# Worf Hyprspace + +This allows to manage workspaces in hyprland using the Worf API. +Inspired by https://github.com/sslater11/hyprland-dynamic-workspaces-manager + + + +## Features +-Auto: Automatic detection of mode +-Rename: Change the name of the chosen workspace +-SwitchToWorkspace: Change the active workspace +-MoveCurrentWindowToOtherWorkspace: Move the focused window to a new workspace and follow it +-MoveCurrentWindowToOtherWorkspaceSilent: Move the focused window to a new workspace and don't follow it +-MoveAllWindowsToOtherWorkSpace: Move all windows to a new workspace +-DeleteWorkspace: Close all windows and go to another workspace diff --git a/examples/worf-hyprspace/src/main.rs b/examples/worf-hyprspace/src/main.rs new file mode 100644 index 0000000..ded3088 --- /dev/null +++ b/examples/worf-hyprspace/src/main.rs @@ -0,0 +1,615 @@ +use std::{ + env, + fmt::{Display, Formatter}, + str::FromStr, + sync::{Arc, Mutex, RwLock}, + thread::sleep, + time::{Duration, Instant}, +}; + +use clap::Parser; +use hyprland::data::Client; +use hyprland::dispatch::WindowIdentifier; +use hyprland::{ + data::{Workspace, Workspaces}, + dispatch::{Dispatch, DispatchType, WorkspaceIdentifierWithSpecial}, + prelude::HyprData, + shared::HyprDataActive, +}; +use nix::libc::{SIGTERM, kill}; +use regex::Regex; +use serde::Deserialize; +use strum::IntoEnumIterator; +use strum_macros::EnumIter; +use worf::gui::{ + self, ArcFactory, ArcProvider, ExpandMode, ItemFactory, ItemProvider, MenuItem, ProviderData, + Selection, +}; + +#[derive(Clone)] +struct Action { + workspace: Option, + mode: Mode, +} + +#[derive(Clone)] +struct HyprspaceProvider { + cfg: HyprSpaceConfig, + search_ignored_words: Vec, + detected_mode: Option, +} + +#[derive(Debug, Clone, Deserialize, EnumIter, PartialEq, Eq)] +enum Mode { + Auto, + Rename, + SwitchToWorkspace, + MoveCurrentWindowToOtherWorkspace, + MoveCurrentWindowToOtherWorkspaceSilent, + MoveAllWindowsToOtherWorkSpace, + DeleteWorkspace, +} + +impl FromStr for Mode { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "auto" => Ok(Mode::Auto), + "rename" => Ok(Mode::Rename), + "switchtoworkspace" => Ok(Mode::SwitchToWorkspace), + "movecurrentwindowtootherworkspace" => Ok(Mode::MoveCurrentWindowToOtherWorkspace), + "movecurrentwindowtootherworkspacesilent" => { + Ok(Mode::MoveCurrentWindowToOtherWorkspaceSilent) + } + "moveallwindowstootherworkspace" => Ok(Mode::MoveCurrentWindowToOtherWorkspace), + "deleteworkspace" => Ok(Mode::DeleteWorkspace), + _ => Err(format!("Invalid mode: {s}")), + } + } +} + +impl Display for Mode { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let variant = format!("{self:?}"); + // Convert PascalCase to Title Case with spaces + let spaced = inflector::cases::titlecase::to_title_case(&variant); + write!(f, "{spaced}") + } +} + +#[derive(Debug, Clone, Parser, Deserialize)] +#[clap(about = "Worf-Hyprspace is a Hyprland workspace manager built on top of Worf")] +struct HyprSpaceConfig { + #[command(flatten)] + worf: worf::config::Config, + + #[arg(long)] + hypr_space_mode: Option, + + #[arg(long)] + add_id_prefix: Option, + + #[arg(long)] + max_workspace_id: Option, +} + +impl HyprSpaceConfig { + fn hypr_space_mode(&self) -> Mode { + self.hypr_space_mode.clone().unwrap_or(Mode::Auto) + } + + fn add_id_prefix(&self) -> bool { + self.add_id_prefix.unwrap_or(true) + } + + fn max_workspace_id(&self) -> i32 { + self.max_workspace_id.unwrap_or(10) + } +} + +impl HyprspaceProvider { + fn new(cfg: &HyprSpaceConfig, search_ignored_words: Vec) -> Result { + Ok(Self { + cfg: cfg.clone(), + search_ignored_words, + detected_mode: None, + }) + } +} + +impl ItemProvider for HyprspaceProvider { + fn get_elements(&mut self, query: Option<&str>) -> ProviderData { + let auto = if self.cfg.hypr_space_mode() == Mode::Auto { + query.and_then(|q| { + Mode::iter() + .find(|m| m.to_string().to_lowercase().trim() == q.to_lowercase()) + .map(|m| { + self.detected_mode = Some(m.clone()); + ProviderData { + items: Some(get_modes_actions( + &m, + query, + self.search_ignored_words.as_ref(), + )), + } + }) + }) + } else { + self.detected_mode = None; + None + }; + auto.unwrap_or(ProviderData { + items: Some(get_modes_actions( + &self.cfg.hypr_space_mode(), + query, + self.search_ignored_words.as_ref(), + )), + }) + } + + fn get_sub_elements(&mut self, item: &MenuItem) -> ProviderData { + if let Some(mode) = Mode::iter() + .find(|m| { + m.to_string() + .to_lowercase() + .trim() + .contains(&item.label.to_lowercase()) + }) + .map(|m| { + self.detected_mode = Some(m.clone()); + ProviderData { + items: Some(get_modes_actions( + &m, + Some(&item.label), + self.search_ignored_words.as_ref(), + )), + } + }) + { + mode + } else { + ProviderData { items: None } + } + } +} + +impl ItemFactory for HyprspaceProvider { + fn new_menu_item(&self, label: String) -> Option> { + Some(MenuItem::new( + label, + None, + None, + Vec::new(), + None, + 0.0, + Some(Action { + workspace: None, + mode: if self.cfg.hypr_space_mode() == Mode::Auto { + self.detected_mode.clone().unwrap_or(Mode::Auto) + } else { + self.cfg.hypr_space_mode() + }, + }), + )) + } +} + +fn build_menu_items<'a, F>( + mode: &Mode, + aws: &'a Workspace, + workspaces: &'a Workspaces, + query: Option<&'a str>, + search_ignored_words: &Vec, + filter_fn: F, +) -> Vec> +where + F: for<'b> Fn(&'b Workspace) -> bool + Copy, +{ + workspaces + .iter() + .filter(|ws| filter_fn(ws)) + .map(|ws| workspace_to_menu_item(mode, aws, ws)) + .chain(query.map(|q| { + MenuItem::new( + gui::filtered_query(Some(search_ignored_words), q), + None, + None, + Vec::new(), + None, + 0.0, + Some(Action { + workspace: None, + mode: mode.clone(), + }), + ) + })) + .collect() +} + +fn get_modes_actions( + mode: &Mode, + query: Option<&str>, + search_ignored_words: &Vec, +) -> Vec> { + let workspaces = match hyprland::data::Workspaces::get() { + Ok(ws) => ws, + Err(e) => { + log::error!("Failed to get workspaces {e}"); + return Vec::new(); + } + }; + + let aws = if let Ok(ws) = hyprland::data::Workspace::get_active() { + ws + } else { + log::error!("No active workspace found"); + return Vec::>::new(); + }; + + match mode { + Mode::Auto => Mode::iter() + .filter(|m| m != &Mode::Auto) + .map(|mode| { + MenuItem::new( + mode.to_string(), + None, + None, + Vec::new(), + None, + 0.0, + Some(Action { + workspace: None, + mode, + }), + ) + }) + .collect(), + + Mode::Rename | Mode::DeleteWorkspace => { + build_menu_items(mode, &aws, &workspaces, query, search_ignored_words, |_| { + true + }) + } + + Mode::SwitchToWorkspace + | Mode::MoveAllWindowsToOtherWorkSpace + | Mode::MoveCurrentWindowToOtherWorkspace + | Mode::MoveCurrentWindowToOtherWorkspaceSilent => { + build_menu_items(mode, &aws, &workspaces, query, search_ignored_words, |ws| { + ws.id != aws.id + }) + } + } +} + +fn workspace_to_menu_item(mode: &Mode, aws: &Workspace, ws: &Workspace) -> MenuItem { + MenuItem::new( + ws.name.clone(), + None, + None, + Vec::new(), + None, + if aws.id == ws.id { 1.0 } else { 0.0 }, + Some(Action { + workspace: Some(ws.clone()), + mode: mode.clone(), + }), + ) +} + +fn handle_sub_selection( + item: &MenuItem, + query: Option<&str>, + search_ignored_words: &Vec, +) -> ProviderData { + if let Some(mode) = Mode::iter() + .find(|m| { + m.to_string() + .to_lowercase() + .contains(&item.label.to_lowercase()) + }) + .map(|m| ProviderData { + items: Some(get_modes_actions(&m, query, search_ignored_words)), + }) + { + mode + } else { + ProviderData { items: None } + } +} + +#[derive(Clone)] +struct EmptyProvider {} + +impl ItemProvider for EmptyProvider { + fn get_elements(&mut self, search: Option<&str>) -> ProviderData { + ProviderData { + items: Some(vec![MenuItem::new( + search.unwrap_or_default().to_owned(), + None, + None, + Vec::new(), + None, + 0.0, + Some(Action { + workspace: None, + mode: Mode::Auto, + }), + )]), + } + } + + fn get_sub_elements(&mut self, _: &MenuItem) -> ProviderData { + ProviderData { items: None } + } +} + +impl ItemFactory for EmptyProvider { + fn new_menu_item(&self, label: String) -> Option> { + Some(MenuItem::new( + label, + None, + None, + Vec::new(), + None, + 0.0, + Some(Action { + workspace: None, + mode: Mode::Auto, + }), + )) + } +} + +fn find_first_free_workspace_id(max_id: i32) -> Option { + let ws = Workspaces::get().ok()?; + (1..=max_id).find(|&i| !ws.iter().any(|w| w.id == i)) +} + +fn show_gui + ItemFactory + Send + Clone + 'static>( + cfg: &HyprSpaceConfig, + pattern: &Regex, + provider: Arc>, +) -> Result, String> { + gui::show( + &Arc::new(RwLock::new(cfg.worf.clone())), + Arc::clone(&provider) as ArcProvider, + Some(provider as ArcFactory), + Some(vec![pattern.clone()]), + ExpandMode::WithSpace, + None, + ) + .map_err(|e| e.to_string()) +} + +fn workspace_from_selection<'a>( + action: Option, + max_id: i32, +) -> Result<(WorkspaceIdentifierWithSpecial<'a>, i32, bool), String> { + if let Some(action) = action { + if let Some(ws) = action.workspace { + return Ok((WorkspaceIdentifierWithSpecial::Id(ws.id), ws.id, false)); + } + } + find_first_free_workspace_id(max_id) + .map(|id| (WorkspaceIdentifierWithSpecial::Id(id), id, true)) + .ok_or_else(|| "Failed to get workspace id".to_string()) +} + +fn set_workspace_name(label: &str, id: i32, add_id_prefix: bool) -> Result<(), String> { + // todo maybe there is a better way to poll if a workspace has been created + let start = Instant::now(); + let ws = loop { + // same as above might break at some point but waiting at the tail + // end of the loop sometimes leads to timing issues + // where the workspace exists in some weird state + sleep(Duration::from_millis(10)); + if start.elapsed().as_millis() >= 1500 { + break None; + } + + if let Some(workspace) = get_workspace(id)? { + break Some(workspace); + } + }; + + ws.map(|ws| { + let ws_id = ws.id.to_string(); + let id_prefix = format!("{ws_id}: "); + let new_name = if add_id_prefix && !ws.name.starts_with(&id_prefix) { + &format!("{id_prefix}{label}") + } else { + label + }; + + Dispatch::call(DispatchType::RenameWorkspace(ws.id, Some(new_name))) + }) + .transpose() + .map_err(|e| e.to_string())?; + + Ok(()) +} + +fn get_workspace(id: i32) -> Result, String> { + let ws = Workspaces::get() + .map_err(|e| e.to_string())? + .into_iter() + .find(|ws| ws.id == id); + Ok(ws) +} + +fn process_clients_on_workspace(ws_id: i32, proc: F) -> Result<(), String> +where + F: for<'a> Fn(&'a Client), +{ + hyprland::data::Clients::get() + .map_err(|e| format!("failed to get clients for ws {ws_id}, err {e}"))? + .iter() + .filter(|client| client.workspace.id == ws_id) + .for_each(proc); + Ok(()) +} + +fn handle_workspace_action( + cfg: &HyprSpaceConfig, + label: &str, + action: Option, + dispatch_builder: F, +) -> Result<(), String> +where + F: FnOnce(WorkspaceIdentifierWithSpecial) -> DispatchType, +{ + let (workspace, id, _new) = workspace_from_selection(action, cfg.max_workspace_id())?; + Dispatch::call(dispatch_builder(workspace)).map_err(|e| e.to_string())?; + set_workspace_name(label, id, cfg.add_id_prefix())?; + Ok(()) +} + +fn main() -> Result<(), String> { + env_logger::Builder::new() + .parse_filters(&env::var("RUST_LOG").unwrap_or_else(|_| "error".to_owned())) + .format_timestamp_micros() + .init(); + + let mut cfg = HyprSpaceConfig::parse(); + cfg.worf = worf::config::load_config(Some(&cfg.worf)).unwrap_or(cfg.worf); + if cfg.worf.prompt().is_none() { + cfg.worf.set_prompt(cfg.hypr_space_mode().to_string()); + } + + let pattern = Mode::iter() + .map(|m| regex::escape(&m.to_string().to_lowercase())) + .collect::>() + .join("|"); + + let pattern = Regex::new(&format!("(?i){pattern}")).map_err(|e| e.to_string())?; + + let provider = Arc::new(Mutex::new(HyprspaceProvider::new( + &cfg, + vec![pattern.clone()], + )?)); + + process_inputs(&mut cfg, &pattern, provider)?; + + Ok(()) +} + +fn process_inputs( + cfg: &mut HyprSpaceConfig, + pattern: &Regex, + provider: Arc>, +) -> Result<(), String> { + let result = show_gui(cfg, pattern, Arc::clone(&provider))?; + + let result_items = handle_sub_selection(&result.menu, None, vec![pattern.clone()].as_ref()); + let result = if result_items.items.is_some() { + if let Some(menu) = result.menu.data { + cfg.hypr_space_mode = Some(menu.mode.clone()); + cfg.worf.set_prompt(cfg.hypr_space_mode().to_string()); + + let provider = Arc::new(Mutex::new(HyprspaceProvider::new( + &cfg.clone(), + vec![pattern.clone()], + )?)); + show_gui(cfg, pattern, provider)? + } else { + result + } + } else { + result + }; + + let action = result.menu.data; + let mode = action + .as_ref() + .map(|m| m.mode.clone()) + .unwrap_or(cfg.hypr_space_mode()); + match mode { + Mode::Auto => { + return process_inputs(cfg, pattern, provider); + } + Mode::Rename => { + if let Some(action) = action { + cfg.worf + .set_prompt(format!("Rename {} to ", result.menu.label)); + let provider = Arc::new(Mutex::new(EmptyProvider {})); + let rename_result = show_gui(cfg, pattern, provider)?; + + let new_name = if cfg.add_id_prefix() { + let ws_id = action + .workspace + .as_ref() + .map(|ws| ws.id.to_string()) + .unwrap_or_default(); + format!("{}: {}", ws_id, rename_result.menu.label) + } else { + rename_result.menu.label.to_string() + }; + + Dispatch::call(DispatchType::RenameWorkspace( + action.workspace.as_ref().unwrap().id, + Some(&new_name), + )) + .map_err(|e| e.to_string())?; + } else { + Err("Action is not set, cannot rename workspace".to_owned())?; + } + } + Mode::SwitchToWorkspace => { + // Clippy suggests removing this closure as redundant, + // but doing so causes lifetime inference issues with `DispatchType::Workspace`. + // Keeping the closure avoids `'static` lifetime assumptions. + #[allow(clippy::redundant_closure)] + handle_workspace_action(cfg, &result.menu.label, action, |ws| { + DispatchType::Workspace(ws) + })?; + } + Mode::MoveCurrentWindowToOtherWorkspace => { + handle_workspace_action(cfg, &result.menu.label, action, |ws| { + DispatchType::MoveToWorkspace(ws, None) + })?; + } + Mode::MoveCurrentWindowToOtherWorkspaceSilent => { + handle_workspace_action(cfg, &result.menu.label, action, |ws| { + DispatchType::MoveToWorkspaceSilent(ws, None) + })?; + } + Mode::DeleteWorkspace => { + let (_ws, selected_id, _new) = + workspace_from_selection(action, cfg.max_workspace_id())?; + + process_clients_on_workspace(selected_id, |client| unsafe { + kill(client.pid, SIGTERM); + })?; + + let active_ws = Workspace::get_active() + .map_err(|e| format!("failed to get active workspace {e}"))?; + if active_ws.id == selected_id { + Dispatch::call(DispatchType::Workspace( + WorkspaceIdentifierWithSpecial::Previous, + )) + .map_err(|e| e.to_string())?; + } + } + Mode::MoveAllWindowsToOtherWorkSpace => { + let active_ws = Workspace::get_active() + .map_err(|e| format!("failed to get active workspace {e}"))?; + + let (ws, target_id, new) = workspace_from_selection(action, cfg.max_workspace_id())?; + process_clients_on_workspace(active_ws.id, |client| { + if let Err(e) = Dispatch::call(DispatchType::MoveToWorkspace( + ws, + Some(WindowIdentifier::Address(client.address.clone())), + )) { + log::warn!("cannot move client to new workspace, ignoring it, err={e}") + } + })?; + + if new { + set_workspace_name(&result.menu.label, target_id, cfg.add_id_prefix())?; + } + } + } + Ok(()) +} diff --git a/examples/worf-hyprswitch/Cargo.toml b/examples/worf-hyprswitch/Cargo.toml index c065309..8961cfd 100644 --- a/examples/worf-hyprswitch/Cargo.toml +++ b/examples/worf-hyprswitch/Cargo.toml @@ -7,9 +7,7 @@ edition = "2024" worf = {path = "../../worf"} env_logger = "0.11.8" hyprland = "0.4.0-beta.2" -sysinfo = "0.34.2" +sysinfo = "0.35.2" freedesktop-icons = "0.4.0" rayon = "1.10.0" toml = "0.8.22" -log = "0.4.27" -dirs = "6.0.0" diff --git a/examples/worf-hyprswitch/src/main.rs b/examples/worf-hyprswitch/src/main.rs index a886623..0efbee3 100644 --- a/examples/worf-hyprswitch/src/main.rs +++ b/examples/worf-hyprswitch/src/main.rs @@ -1,4 +1,10 @@ -use std::{collections::HashMap, env, fs, path::PathBuf, sync::Arc, thread}; +use std::{ + collections::HashMap, + env, fs, + path::PathBuf, + sync::{Arc, Mutex, RwLock}, + thread, +}; use hyprland::{ dispatch::{DispatchType, WindowIdentifier}, @@ -12,7 +18,7 @@ use worf::{ config::{self, Config}, desktop, desktop::EntryType, - gui::{self, ItemProvider, MenuItem}, + gui::{self, ExpandMode, ItemProvider, MenuItem, ProviderData}, }; #[derive(Clone)] @@ -108,12 +114,18 @@ impl WindowProvider { } impl ItemProvider for WindowProvider { - fn get_elements(&mut self, _: Option<&str>) -> (bool, Vec>) { - (false, self.windows.clone()) + fn get_elements(&mut self, query: Option<&str>) -> ProviderData { + if query.is_some() { + ProviderData { items: None } + } else { + ProviderData { + items: Some(self.windows.clone()), + } + } } - fn get_sub_elements(&mut self, _: &MenuItem) -> (bool, Option>>) { - (false, None) + fn get_sub_elements(&mut self, _: &MenuItem) -> ProviderData { + ProviderData { items: None } } } @@ -132,15 +144,21 @@ fn main() -> Result<(), String> { .init(); let args = config::parse_args(); - let config = config::load_config(Some(&args)).unwrap_or(args); + let config = Arc::new(RwLock::new( + config::load_config(Some(&args)).unwrap_or(args), + )); - let cache_path = - desktop::cache_file_path(&config, "worf-hyprswitch").map_err(|err| err.to_string())?; + let cache_path = desktop::cache_file_path(&config.read().unwrap(), "worf-hyprswitch") + .map_err(|err| err.to_string())?; let mut cache = load_icon_cache(&cache_path).map_err(|e| e.to_string())?; - let provider = WindowProvider::new(&config, &cache)?; - let windows = provider.windows.clone(); - let result = gui::show(config, provider, false, None, None).map_err(|e| e.to_string())?; + let provider = Arc::new(Mutex::new(WindowProvider::new( + &config.read().unwrap(), + &cache, + )?)); + let windows = provider.lock().unwrap().windows.clone(); + let result = gui::show(&config, provider, None, None, ExpandMode::Verbatim, None) + .map_err(|e| e.to_string())?; let update_cache = thread::spawn(move || { windows.iter().for_each(|item| { if let Some(window) = &item.data { @@ -158,13 +176,15 @@ fn main() -> Result<(), String> { } }); - if let Some(window) = result.menu.data { + let return_value = if let Some(window) = result.menu.data { hyprland::dispatch::Dispatch::call(DispatchType::FocusWindow(WindowIdentifier::Address( window.address, ))) - .map_err(|e| e.to_string())?; - Ok(update_cache.join().unwrap().map_err(|e| e.to_string())?) + .map_err(|e| e.to_string()) } else { Err("No window data found".to_owned()) - } + }; + + update_cache.join().unwrap().map_err(|e| e.to_string())?; + return_value } diff --git a/examples/worf-warden/src/main.rs b/examples/worf-warden/src/main.rs index 001f8c1..d2e8fd0 100644 --- a/examples/worf-warden/src/main.rs +++ b/examples/worf-warden/src/main.rs @@ -1,9 +1,19 @@ -use std::{collections::HashMap, env, process::Command, thread::sleep, time::Duration}; +use std::{ + collections::HashMap, + env, + process::Command, + sync::{Arc, Mutex, RwLock}, + thread::sleep, + time::Duration, +}; use worf::{ config::{self, Config, CustomKeyHintLocation, Key}, desktop::{copy_to_clipboard, spawn_fork}, - gui::{self, CustomKeyHint, CustomKeys, ItemProvider, KeyBinding, MenuItem, Modifier}, + gui::{ + self, CustomKeyHint, CustomKeys, ExpandMode, ItemProvider, KeyBinding, MenuItem, Modifier, + ProviderData, + }, }; #[derive(Clone)] @@ -75,15 +85,21 @@ impl PasswordProvider { } impl ItemProvider for PasswordProvider { - fn get_elements(&mut self, _: Option<&str>) -> (bool, Vec>) { - (false, self.items.clone()) + fn get_elements(&mut self, query: Option<&str>) -> ProviderData { + if query.is_some() { + ProviderData { items: None } + } else { + ProviderData { + items: Some(self.items.clone()), + } + } } fn get_sub_elements( &mut self, _: &MenuItem, - ) -> (bool, Option>>) { - (false, None) + ) -> ProviderData { + ProviderData { items: None } } } @@ -132,7 +148,7 @@ fn rbw(cmd: &str, args: Option>) -> Result { let output = command .output() - .map_err(|e| format!("Failed to execute command: {}", e))?; + .map_err(|e| format!("Failed to execute command: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -140,7 +156,7 @@ fn rbw(cmd: &str, args: Option>) -> Result { } let stdout = - String::from_utf8(output.stdout).map_err(|e| format!("Invalid UTF-8 output: {}", e))?; + String::from_utf8(output.stdout).map_err(|e| format!("Invalid UTF-8 output: {e}"))?; Ok(stdout.trim().to_string()) } @@ -265,12 +281,13 @@ fn key_lock() -> KeyBinding { } } -fn show(config: Config, provider: PasswordProvider) -> Result<(), String> { +fn show(config: Arc>, provider: Arc>) -> Result<(), String> { match gui::show( - config.clone(), + &config, provider, - false, None, + None, + ExpandMode::Verbatim, Some(CustomKeys { bindings: vec![ key_type_all(), @@ -294,7 +311,10 @@ fn show(config: Config, provider: PasswordProvider) -> Result<(), String> { Ok(selection) => { if let Some(meta) = selection.menu.data { if meta.ids.len() > 1 { - return show(config, PasswordProvider::sub_provider(meta.ids)?); + return show( + config, + Arc::new(Mutex::new(PasswordProvider::sub_provider(meta.ids)?)), + ); } let id = meta.ids.first().unwrap_or(&selection.menu.label); @@ -344,7 +364,9 @@ fn main() -> Result<(), String> { .init(); let args = config::parse_args(); - let config = config::load_config(Some(&args)).unwrap_or(args); + let config = Arc::new(RwLock::new( + config::load_config(Some(&args)).unwrap_or(args), + )); if !groups().contains("input") { log::error!( @@ -359,6 +381,6 @@ fn main() -> Result<(), String> { } // todo eventually use a propper rust client for this, for now rbw is good enough - let provider = PasswordProvider::new(&config)?; + let provider = Arc::new(Mutex::new(PasswordProvider::new(&config.read().unwrap())?)); show(config, provider) } diff --git a/styles/launcher/style.css b/styles/launcher/style.css index 4d07857..68a57a1 100644 --- a/styles/launcher/style.css +++ b/styles/launcher/style.css @@ -1,24 +1,12 @@ -:root { - --bg-blur: rgba(33, 33, 33, 0.6); - --entry-hover: rgba(255, 255, 255, 0.08); - --entry-selected: rgba(214, 174, 0, 0.2); - --search-bg: rgba(32, 32, 32, 0.6); - --search-border: rgba(214, 174, 0, 1); - --text-color: #f2f2f2; - --font: 'DejaVu Sans', 'Segoe UI', sans-serif; - --transition: 0.2s ease; -} - /* General Reset */ * { - font-family: var(--font); + font-family: 'DejaVu Sans', 'Segoe UI', sans-serif; box-sizing: border-box; outline: none; + background-color: transparent; } #background { - background-color: rgba(33, 33, 33, 0.1); - backdrop-filter: blur(12px); width: 100vw; height: 100vh; overflow-y: auto; @@ -26,6 +14,7 @@ display: flex; flex-direction: column; align-items: center; + background-color: rgba(32, 32, 32, 0.5); } #window { @@ -35,7 +24,6 @@ flex-direction: column; align-items: center; width: 100%; - max-width: 1200px; margin: 2em 15em 5em; } @@ -53,9 +41,9 @@ /* Search input styling */ #input { - background-color: var(--search-bg); - color: var(--text-color); - border-bottom: 2px solid var(--search-border); + background-color: rgba(32, 32, 32, 0.6); + color: #f2f2f2; + border-bottom: 2px solid rgba(39, 37, 164, 1); padding: 0.8rem 1rem; font-size: 1rem; border-radius: 6px; @@ -64,38 +52,31 @@ margin: 2em 25em 5em; } -#input:focus, -#input:focus-visible, -#input:active { - all: unset; - background-color: var(--search-bg); - color: var(--text-color); - border-bottom: 2px solid rgba(214, 174, 2, 1); - font-size: 1rem; -} - /* Entry styling */ #scroll #inner-box #entry { color: #fff; - background-color: rgba(32, 32, 32, 0); + background-color: transparent; padding: 1rem; - margin: 1rem; + margin: 2em; + margin-bottom: 6em; display: flex; flex-direction: column; align-items: center; text-align: center; - transition: background-color var(--transition), transform var(--transition); + transition: background-color 0.2s ease, transform 0.2s ease; +} + +#window #outer-box #scroll #inner-box { + background-color: transparent; } #entry:hover { - background-color: rgba(255, 255, 255, 1); + background-color: transparent; transform: scale(1.3); } #entry:selected { - color: #fff; - background-color: var(--entry-selected); - border-bottom: 3px solid var(--search-border); + border-bottom: 3px solid rgba(39, 37, 164, 1); border-bottom-left-radius: 0; border-bottom-right-radius: 0; } @@ -120,4 +101,4 @@ background-color: transparent; outline: inherit; border-width: 0; -} +} \ No newline at end of file diff --git a/worf/Cargo.toml b/worf/Cargo.toml index 37d895b..78a2c07 100644 --- a/worf/Cargo.toml +++ b/worf/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "worf" -version = "0.3.0" +version = "0.4.0" edition = "2024" [lints.clippy] diff --git a/worf/src/lib/config.rs b/worf/src/lib/config.rs index ab4a0fa..0a045e1 100644 --- a/worf/src/lib/config.rs +++ b/worf/src/lib/config.rs @@ -62,36 +62,6 @@ pub enum KeyDetectionType { Value, } -#[derive(Clone, Debug, Serialize, Deserialize)] -pub enum Mode { - /// searches `$PATH` for executables and allows them to be run by selecting them. - Run, - /// searches `$XDG_DATA_HOME/applications` and `$XDG_DATA_DIRS/applications` - /// for desktop files and allows them to be run by selecting them. - Drun, - - /// reads from stdin and displays options which when selected will be output to stdout. - Dmenu, - - /// tries to determine automatically what to do - Auto, - - /// use worf as file browser - File, - - /// Use is as calculator - Math, - - /// Connect via ssh to a given host - Ssh, - - /// Emoji browser - Emoji, - - /// Open search engine. - WebSearch, -} - #[derive(Clone, Debug, Serialize, Deserialize)] pub enum Layer { Background, @@ -128,27 +98,6 @@ impl FromStr for Anchor { } } -impl FromStr for Mode { - type Err = Error; - - fn from_str(s: &str) -> Result { - match s { - "run" => Ok(Mode::Run), - "drun" => Ok(Mode::Drun), - "dmenu" => Ok(Mode::Dmenu), - "file" => Ok(Mode::File), - "math" => Ok(Mode::Math), - "ssh" => Ok(Mode::Ssh), - "emoji" => Ok(Mode::Emoji), - "websearch" => Ok(Mode::WebSearch), - "auto" => Ok(Mode::Auto), - _ => Err(Error::InvalidArgument( - format!("{s} is not a valid argument, see help for details").to_owned(), - )), - } - } -} - impl FromStr for WrapMode { type Err = Error; @@ -420,7 +369,9 @@ impl FromStr for Key { } #[derive(Debug, Deserialize, Serialize, Clone, Parser)] -#[clap(about = "Worf is a wofi clone written in rust, it aims to be a drop-in replacement")] +#[clap( + about = "Worf is a wofi like launcher, written in rust, it aims to be a drop-in replacement" +)] #[derive(Default)] pub struct Config { /// Forks the menu so you can close the terminal @@ -442,10 +393,6 @@ pub struct Config { #[clap(long = "style")] style: Option, - /// Defines the mode worf is running in - #[clap(long = "show")] - show: Option, - /// Default width of the window, defaults to 50% of the screen #[clap(long = "width")] width: Option, @@ -681,6 +628,14 @@ pub struct Config { /// Defaults to false. #[clap(long = "blurred-background-fullscreen")] blurred_background_fullscreen: Option, + + /// Allow submitting selected entry with expand key if there is only 1 item left. + #[clap(long = "submit-with-expand")] + submit_with_expand: Option, + + /// Auto select when only 1 possible choice is left + #[clap(long = "auto-select-on-search")] + auto_select_on_search: Option, } impl Config { @@ -765,25 +720,12 @@ impl Config { } #[must_use] - pub fn prompt(&self) -> String { - match &self.prompt { - None => match &self.show { - None => String::new(), - Some(mode) => match mode { - Mode::Run => "run".to_owned(), - Mode::Drun => "drun".to_owned(), - Mode::Dmenu => "dmenu".to_owned(), - Mode::Math => "math".to_owned(), - Mode::File => "file".to_owned(), - Mode::Auto => "auto".to_owned(), - Mode::Ssh => "ssh".to_owned(), - Mode::Emoji => "emoji".to_owned(), - Mode::WebSearch => "websearch".to_owned(), - }, - }, - - Some(prompt) => prompt.clone(), - } + pub fn prompt(&self) -> Option { + self.prompt.clone() + } + + pub fn set_prompt(&mut self, val: String) { + self.prompt = Some(val); } #[must_use] @@ -845,11 +787,6 @@ impl Config { }) } - #[must_use] - pub fn show(&self) -> Option { - self.show.clone() - } - #[must_use] pub fn insensitive(&self) -> bool { self.insensitive.unwrap_or(true) @@ -978,16 +915,22 @@ impl Config { pub fn blurred_background_fullscreen(&self) -> bool { self.blurred_background_fullscreen.unwrap_or(false) } + + #[must_use] + pub fn submit_with_expand(&self) -> bool { + self.submit_with_expand.unwrap_or(true) + } + + #[must_use] + pub fn auto_select_on_search(&self) -> bool { + self.auto_select_on_search.unwrap_or(false) + } } fn default_false() -> bool { false } -// fn default_true() -> bool { -// true -// } - #[must_use] pub fn parse_args() -> Config { Config::parse() diff --git a/worf/src/lib/gui.rs b/worf/src/lib/gui.rs index 98c01c4..89f9f85 100644 --- a/worf/src/lib/gui.rs +++ b/worf/src/lib/gui.rs @@ -1,5 +1,6 @@ use std::{ collections::{HashMap, HashSet}, + marker::PhantomData, rc::Rc, sync::{Arc, Mutex, RwLock}, thread, @@ -39,8 +40,9 @@ use crate::{ desktop::known_image_extension_regex_pattern, }; -type ArcMenuMap = Arc>>>; -type ArcProvider = Arc + Send>>; +pub type ArcMenuMap = Arc>>>; +pub type ArcProvider = Arc + Send>>; +pub type ArcFactory = Arc + Send>>; pub struct Selection { pub menu: MenuItem, @@ -48,9 +50,49 @@ pub struct Selection { } type SelectionSender = Sender, Error>>; +pub struct ProviderData { + pub items: Option>>, +} + pub trait ItemProvider { - fn get_elements(&mut self, search: Option<&str>) -> (bool, Vec>); - fn get_sub_elements(&mut self, item: &MenuItem) -> (bool, Option>>); + fn get_elements(&mut self, search: Option<&str>) -> ProviderData; + + /// Get elements below the given menu entry. + /// Will be called for completion + /// If (true, None) is returned and submit-accept is set in the config, this + /// will be handled the name way as pressing enter (or the configured submit key). + fn get_sub_elements(&mut self, item: &MenuItem) -> ProviderData; +} + +pub trait ItemFactory { + fn new_menu_item(&self, label: String) -> Option>; +} + +/// Default generic item factory that creates an almost empty menu item +/// Without data, no icon, and sort score of 0. +pub struct DefaultItemFactory { + _marker: PhantomData, +} + +impl DefaultItemFactory { + #[must_use] + pub fn new() -> DefaultItemFactory { + DefaultItemFactory:: { + _marker: PhantomData, + } + } +} + +impl Default for DefaultItemFactory { + fn default() -> Self { + Self::new() + } +} + +impl ItemFactory for DefaultItemFactory { + fn new_menu_item(&self, label: String) -> Option> { + Some(MenuItem::new(label, None, None, vec![], None, 0.0, None)) + } } impl From<&Anchor> for Edge { @@ -121,6 +163,10 @@ pub struct MenuItem { /// Allows to store arbitrary additional information pub data: Option, + // /// If set to true, the item is _not_ an intermediate thing + // /// and is acceptable, i.e. will close the UI + // pub allow_submit: bool, + // todo /// Score the item got in the current search search_sort_score: f64, /// True if the item is visible @@ -351,6 +397,12 @@ pub enum Modifier { None, } +#[derive(PartialEq)] +pub enum ExpandMode { + Verbatim, + WithSpace, +} + fn modifiers_from_mask(mask: gdk4::ModifierType) -> HashSet { let mut modifiers = HashSet::new(); @@ -421,6 +473,7 @@ impl MenuItem { working_dir: Option, initial_sort_score: f64, data: Option, + //allow_submit: bool, ) -> Self { MenuItem { label, @@ -430,6 +483,7 @@ impl MenuItem { working_dir, initial_sort_score, data, + //allow_submit, search_sort_score: 0.0, visible: true, } @@ -444,10 +498,11 @@ impl AsRef> for MenuItem { struct MetaData { item_provider: ArcProvider, + item_factory: Option>, selected_sender: SelectionSender, - config: Rc, - new_on_empty: bool, + config: Arc>, search_ignored_words: Option>, + expand_mode: ExpandMode, } struct UiElements { @@ -468,20 +523,22 @@ struct UiElements { /// # 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, +/// # Panics +/// When failing to unwrap the arc lock +pub fn show( + config: &Arc>, + item_provider: ArcProvider, + item_factory: Option>, search_ignored_words: Option>, + expand_mode: ExpandMode, custom_keys: Option, ) -> Result, Error> where T: Clone + 'static + Send, - P: ItemProvider + 'static + Clone + Send, { gtk4::init().map_err(|e| Error::Graphics(e.to_string()))?; log::debug!("Starting GUI"); - if let Some(ref css) = config.style() { + if let Some(ref css) = config.read().unwrap().style() { log::debug!("loading css from {css}"); let provider = CssProvider::new(); let css_file_path = File::for_path(css); @@ -498,16 +555,18 @@ where let app = Application::builder().application_id("worf").build(); let (sender, receiver) = channel::bounded(1); + let meta = Rc::new(MetaData { + item_provider, + item_factory, + selected_sender: sender, + config: Arc::clone(config), + search_ignored_words, + expand_mode, + }); + + let connect_cfg = Arc::clone(config); app.connect_activate(move |app| { - build_ui( - &config, - item_provider.clone(), - sender.clone(), - app.clone(), - new_on_empty, - search_ignored_words.clone(), - custom_keys.as_ref(), - ); + build_ui::(&connect_cfg, &meta, app.clone(), custom_keys.as_ref()); }); let gtk_args: [&str; 0] = []; @@ -524,28 +583,16 @@ where receiver_result? } -fn build_ui( - config: &Config, - item_provider: P, - sender: Sender, Error>>, +fn build_ui( + config: &Arc>, + meta: &Rc>, app: Application, - new_on_empty: bool, - search_ignored_words: Option>, custom_keys: Option<&CustomKeys>, ) 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, - search_ignored_words, - }); - let provider_clone = Arc::clone(&meta.item_provider); let get_provider_elements = thread::spawn(move || { log::debug!("getting items"); @@ -560,7 +607,7 @@ fn build_ui( .default_height(1) .build(); - let background = create_background(config); + let background = create_background(&config.read().unwrap()); let ui_elements = Rc::new(UiElements { app, @@ -571,20 +618,22 @@ fn build_ui( menu_rows: Arc::new(RwLock::new(HashMap::new())), search_text: Arc::new(Mutex::new(String::new())), search_delete_event: Arc::new(Mutex::new(None)), - outer_box: gtk4::Box::new(config.orientation().into(), 0), + outer_box: gtk4::Box::new(config.read().unwrap().orientation().into(), 0), scroll: ScrolledWindow::new(), custom_key_box: gtk4::Box::new(Orientation::Vertical, 0), }); // handle keys as soon as possible - setup_key_event_handler(&ui_elements, &meta, custom_keys); + setup_key_event_handler(&ui_elements, meta, custom_keys); log::debug!("keyboard ready after {:?}", start.elapsed()); - if !config.normal_window() { + if !config.read().unwrap().normal_window() { // Initialize the window as a layer ui_elements.window.init_layer_shell(); - ui_elements.window.set_layer(config.layer().into()); + ui_elements + .window + .set_layer(config.read().unwrap().layer().into()); ui_elements .window .set_keyboard_mode(KeyboardMode::Exclusive); @@ -593,7 +642,7 @@ fn build_ui( ui_elements.window.set_widget_name("window"); ui_elements.window.set_namespace(Some("worf")); - if let Some(location) = config.location() { + if let Some(location) = config.read().unwrap().location() { for anchor in location { ui_elements.window.set_anchor(anchor.into(), true); } @@ -615,31 +664,33 @@ fn build_ui( ui_elements.scroll.set_hexpand(true); ui_elements.scroll.set_vexpand(true); - if config.hide_scroll() { + if config.read().unwrap().hide_scroll() { ui_elements .scroll .set_policy(PolicyType::External, PolicyType::External); } ui_elements.outer_box.append(&ui_elements.scroll); - build_main_box(config, &ui_elements); - build_search_entry(config, &ui_elements, &meta); + build_main_box(&config.read().unwrap(), &ui_elements); + build_search_entry(&config.read().unwrap(), &ui_elements, meta); let wrapper_box = gtk4::Box::new(Orientation::Vertical, 0); wrapper_box.append(&ui_elements.main_box); ui_elements.scroll.set_child(Some(&wrapper_box)); let wait_for_items = Instant::now(); - let (_changed, provider_elements) = get_provider_elements.join().unwrap(); + let provider_elements = get_provider_elements.join().unwrap(); log::debug!("got items after {:?}", wait_for_items.elapsed()); - let cfg = config.clone(); + let cfg = Arc::clone(config); let ui = Rc::clone(&ui_elements); ui_elements.window.connect_is_active_notify(move |_| { - window_show_resize(&cfg.clone(), &ui); + window_show_resize(&cfg.read().unwrap(), &ui); }); - build_ui_from_menu_items(&ui_elements, &meta, provider_elements); + if let Some(elements) = provider_elements.items { + build_ui_from_menu_items(&ui_elements, meta, elements); + } let window_start = Instant::now(); ui_elements.window.present(); @@ -720,7 +771,7 @@ fn build_search_entry( ui_elements.search.set_css_classes(&["input"]); ui_elements .search - .set_placeholder_text(Some(config.prompt().as_ref())); + .set_placeholder_text(Some(&config.prompt().unwrap_or("Search...".to_owned()))); ui_elements.search.set_can_focus(false); search_start_listen_delete_event(ui_elements, meta); @@ -833,7 +884,7 @@ fn set_search_text( search_stop_listen_delete_event(ui); let mut lock = ui.search_text.lock().unwrap(); query.clone_into(&mut lock); - if let Some(pw) = meta.config.password() { + if let Some(pw) = meta.config.read().unwrap().password() { let mut ui_text = String::new(); for _ in 0..query.len() { ui_text += &pw; @@ -850,7 +901,7 @@ fn build_ui_from_menu_items( meta: &Rc>, mut items: Vec>, ) { - if meta.config.sort_order() != SortOrder::Default { + if meta.config.read().unwrap().sort_order() != SortOrder::Default { items.reverse(); } let start = Instant::now(); @@ -968,7 +1019,7 @@ fn handle_key_press( // hide search let propagate = if is_key_match( - meta.config.key_hide_search(), + meta.config.read().unwrap().key_hide_search(), &detection_type, key_code, keyboard_key, @@ -976,7 +1027,7 @@ fn handle_key_press( handle_key_hide_search(ui) // submit } else if is_key_match( - Some(meta.config.key_submit()), + Some(meta.config.read().unwrap().key_submit()), &detection_type, key_code, keyboard_key, @@ -985,7 +1036,7 @@ fn handle_key_press( } // exit else if is_key_match( - Some(meta.config.key_exit()), + Some(meta.config.read().unwrap().key_exit()), &detection_type, key_code, keyboard_key, @@ -993,7 +1044,7 @@ fn handle_key_press( handle_key_exit(ui, meta) // copy } else if is_key_match( - meta.config.key_copy(), + meta.config.read().unwrap().key_copy(), &detection_type, key_code, keyboard_key, @@ -1001,7 +1052,7 @@ fn handle_key_press( handle_key_copy(ui, meta) // expand } else if is_key_match( - Some(meta.config.key_expand()), + Some(meta.config.read().unwrap().key_expand()), &detection_type, key_code, keyboard_key, @@ -1029,7 +1080,7 @@ fn handle_key_press( } else { pos }; - if let Some((start, ch)) = query.char_indices().nth((del_pos) as usize) { + if let Some((start, ch)) = query.char_indices().nth(del_pos as usize) { let end = start + ch.len_utf8(); query.replace_range(start..end, ""); } @@ -1084,7 +1135,7 @@ fn handle_custom_keys( modifier_type: gdk4::ModifierType, custom_keys: Option<&CustomKeys>, ) -> KeyDetectionType { - let detection_type = meta.config.key_detection_type(); + let detection_type = meta.config.read().unwrap().key_detection_type(); if let Some(custom_keys) = custom_keys { let mods = modifiers_from_mask(modifier_type); for custom_key in &custom_keys.bindings { @@ -1098,14 +1149,9 @@ fn handle_custom_keys( if custom_key_match { let search_lock = ui.search_text.lock().unwrap(); - if let Err(e) = handle_selected_item( - ui, - Rc::>::clone(meta), - Some(&search_lock), - None, - meta.new_on_empty, - Some(custom_key), - ) { + if let Err(e) = + handle_selected_item(ui, meta, Some(&search_lock), None, Some(custom_key)) + { log::error!("{e}"); } } @@ -1118,8 +1164,8 @@ fn update_view_from_provider(ui: &Rc>, meta: &Rc>, where T: Clone + Send + 'static, { - let (changed, filtered_list) = meta.item_provider.lock().unwrap().get_elements(Some(query)); - if changed { + let data = meta.item_provider.lock().unwrap().get_elements(Some(query)); + if let Some(filtered_list) = data.items { build_ui_from_menu_items(ui, meta, filtered_list); } update_view(ui, meta, query); @@ -1129,19 +1175,35 @@ fn update_view(ui: &Rc>, meta: &Rc>, query: &str) where T: Clone + Send + 'static, { - let mut lock = ui.menu_rows.write().unwrap(); + let mut menu_rows = ui.menu_rows.write().unwrap(); set_menu_visibility_for_search( query, - &mut lock, + &mut menu_rows, &meta.config, meta.search_ignored_words.as_ref(), ); - select_first_visible_child(&*lock, &ui.main_box); - drop(lock); - if meta.config.dynamic_lines() { + select_first_visible_child(&*menu_rows, &ui.main_box); + + if meta.config.read().unwrap().auto_select_on_search() { + let visible_items = menu_rows + .iter() + .filter(|(_, menu)| menu.visible) + .collect::>(); + if visible_items.len() == 1 { + if let Err(e) = + handle_selected_item(ui, meta, None, Some(visible_items[0].1.clone()), None) + { + log::error!("failed to handle selected item {e}"); + } + } + } + + drop(menu_rows); + if meta.config.read().unwrap().dynamic_lines() { if let Some(geometry) = get_monitor_geometry(ui.window.surface().as_ref()) { - let height = calculate_dynamic_lines_window_height(&meta.config, ui, geometry); + let height = + calculate_dynamic_lines_window_height(&meta.config.read().unwrap(), ui, geometry); ui.window.set_height_request(height); } } @@ -1168,7 +1230,7 @@ where if let Some(expander) = expander { expander.set_expanded(true); } else { - let opt_changed = { + let data = { let lock = ui.menu_rows.read().unwrap(); let menu_item = lock.get(fb); menu_item.map(|menu_item| { @@ -1177,20 +1239,30 @@ where .lock() .unwrap() .get_sub_elements(menu_item), - menu_item.label.clone(), + menu_item.clone(), ) }) }; - if let Some(changed) = opt_changed { - let items = changed.0.1.unwrap_or_default(); - if changed.0.0 { + if let Some((provider_data, menu_item)) = data { + if let Some(items) = provider_data.items { build_ui_from_menu_items(ui, meta, items); - } + let query = match meta.expand_mode { + ExpandMode::Verbatim => menu_item.label.clone(), + ExpandMode::WithSpace => format!("{} ", menu_item.label.clone()), + }; + + set_search_text(ui, meta, &query); + if let Ok(new_pos) = i32::try_from(query.len() + 1) { + ui.search.set_position(new_pos); + } - let query = changed.1; - set_search_text(ui, meta, &query); - update_view(ui, meta, &query); + update_view(ui, meta, &query); + } else if let Err(e) = + handle_selected_item(ui, meta, None, Some(menu_item), None) + { + log::error!("{e}"); + } } } } @@ -1221,14 +1293,7 @@ where T: Clone + Send + 'static, { let search_lock = ui.search_text.lock().unwrap(); - if let Err(e) = handle_selected_item( - ui, - Rc::>::clone(meta), - Some(&search_lock), - None, - meta.new_on_empty, - None, - ) { + if let Err(e) = handle_selected_item(ui, meta, Some(&search_lock), None, None) { log::error!("{e}"); } Propagation::Stop @@ -1294,6 +1359,13 @@ fn window_show_resize(config: &Config, ui: &Rc return; }; + if !config.blurred_background_fullscreen() { + if let Some(background) = &ui.background { + background.set_height_request(geometry.height()); + background.set_width_request(geometry.width()); + } + } + // Calculate target width from config, return early if not set let Some(target_width) = percent_or_absolute(&config.width(), geometry.width()) else { log::error!("width is not set"); @@ -1424,10 +1496,9 @@ where } fn handle_selected_item( ui: &Rc>, - meta: Rc>, + meta: &Rc>, query: Option<&str>, item: Option>, - new_on_empty: bool, custom_key: Option<&KeyBinding>, ) -> Result<(), String> where @@ -1441,37 +1512,31 @@ where 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, - }; - - send_selected_item(ui, meta, custom_key.cloned(), item); - Ok(()) - } else { - Err("selected item cannot be resolved".to_owned()) + if let Some(factory) = meta.item_factory.as_ref() { + let factory = factory.lock().unwrap(); + let label = filtered_query(meta.search_ignored_words.as_ref(), query.unwrap_or("")); + let item = factory.new_menu_item(label); + if let Some(item) = item { + send_selected_item(ui, meta, custom_key.cloned(), item); + return Ok(()); + } } + + Err("selected item cannot be resolved".to_owned()) } fn send_selected_item( ui: &Rc>, - meta: Rc>, + meta: &Rc>, custom_key: Option, selected_item: MenuItem, ) where T: Clone + Send + 'static, { let ui_clone = Rc::clone(ui); + let meta_clone = Rc::clone(meta); ui.window.connect_hide(move |_| { - if let Err(e) = meta.selected_sender.send(Ok(Selection { + if let Err(e) = meta_clone.selected_sender.send(Ok(Selection { menu: selected_item.clone(), custom_key: custom_key.clone(), })) { @@ -1541,7 +1606,7 @@ fn create_menu_row( row.set_halign(Align::Fill); row.set_widget_name("row"); - let row_box = gtk4::Box::new(meta.config.row_box_orientation().into(), 0); + let row_box = gtk4::Box::new(meta.config.read().unwrap().row_box_orientation().into(), 0); row_box.set_hexpand(true); row_box.set_vexpand(false); row_box.set_halign(Align::Fill); @@ -1550,15 +1615,13 @@ fn create_menu_row( let (label_img, label_text) = parse_label(&element_to_add.label); - if meta.config.allow_images() { + let config = meta.config.read().unwrap(); + if meta.config.read().unwrap().allow_images() { let img = lookup_icon( element_to_add.icon_path.as_ref().map(AsRef::as_ref), - &meta.config, + &config, ) - .or(lookup_icon( - label_img.as_ref().map(AsRef::as_ref), - &meta.config, - )); + .or(lookup_icon(label_img.as_ref().map(AsRef::as_ref), &config)); if let Some(image) = img { image.set_widget_name("img"); @@ -1567,16 +1630,16 @@ fn create_menu_row( } let label = Label::new(label_text.as_ref().map(AsRef::as_ref)); - label.set_use_markup(meta.config.allow_markup()); - label.set_natural_wrap_mode(meta.config.line_wrap().into()); + label.set_use_markup(meta.config.read().unwrap().allow_markup()); + label.set_natural_wrap_mode(meta.config.read().unwrap().line_wrap().into()); label.set_hexpand(true); label.set_widget_name("text"); label.set_wrap(true); - if let Some(max_width_chars) = meta.config.line_max_width_chars() { + if let Some(max_width_chars) = meta.config.read().unwrap().line_max_width_chars() { label.set_max_width_chars(max_width_chars); } - if let Some(max_len) = meta.config.line_max_chars() { + if let Some(max_len) = meta.config.read().unwrap().line_max_chars() { if let Some(text) = label_text.as_ref() { if text.chars().count() > max_len { let end = text @@ -1590,8 +1653,18 @@ fn create_menu_row( row_box.append(&label); - if meta.config.content_halign().eq(&config::Align::Start) - || meta.config.content_halign().eq(&config::Align::Fill) + if meta + .config + .read() + .unwrap() + .content_halign() + .eq(&config::Align::Start) + || meta + .config + .read() + .unwrap() + .content_halign() + .eq(&config::Align::Fill) { label.set_xalign(0.0); } @@ -1603,16 +1676,19 @@ fn create_menu_row( let click = GestureClick::new(); click.set_button(gtk4::gdk::BUTTON_PRIMARY); - let presses = if meta.config.single_click() { 1 } else { 2 }; + let presses = if meta.config.read().unwrap().single_click() { + 1 + } else { + 2 + }; click.connect_pressed(move |_gesture, n_press, _x, _y| { if n_press == presses { if let Err(e) = handle_selected_item( &click_ui, - Rc::>::clone(&click_meta), + &click_meta, None, Some(element_clone.clone()), - false, None, ) { log::error!("{e}"); @@ -1696,82 +1772,89 @@ fn lookup_icon(icon_path: Option<&str>, config: &Config) -> Option { fn set_menu_visibility_for_search( query: &str, items: &mut HashMap>, - config: &Config, + config: &Arc>, search_ignored_words: Option<&Vec>, ) { - { - if query.is_empty() { - for (fb, menu_item) in items.iter_mut() { - menu_item.search_sort_score = 0.0; - menu_item.visible = true; - fb.set_visible(menu_item.visible); - } - return; - } - - let mut query = if config.insensitive() { - query.to_owned().to_lowercase() - } else { - query.to_owned() - }; - - if let Some(s) = search_ignored_words.as_ref() { - s.iter().for_each(|rgx| { - query = rgx.replace_all(&query, "").to_string(); - }); - } - + if query.is_empty() { for (fb, menu_item) in items.iter_mut() { - let menu_item_search = format!( - "{} {}", - menu_item - .action - .as_ref() - .map(|a| { - if config.insensitive() { - a.to_lowercase() - } else { - a.clone() - } - }) - .unwrap_or_default(), - if config.insensitive() { - menu_item.label.to_lowercase() - } else { - menu_item.label.clone() - } - ); + menu_item.search_sort_score = 0.0; + menu_item.visible = true; + fb.set_visible(menu_item.visible); + } + } - 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; - } + let mut query = if config.read().unwrap().insensitive() { + query.to_owned().to_lowercase() + } else { + query.to_owned() + }; - (score, score > config.fuzzy_min_score() && score > 0.0) - } - MatchMethod::Contains => { - if menu_item_search.contains(&query) { - (1.0, true) + query = filtered_query(search_ignored_words, &query); + + for (fb, menu_item) in items.iter_mut() { + let menu_item_search = format!( + "{} {}", + menu_item + .action + .as_ref() + .map(|a| { + if config.read().unwrap().insensitive() { + a.to_lowercase() } else { - (0.0, false) + a.clone() } + }) + .unwrap_or_default(), + if config.read().unwrap().insensitive() { + menu_item.label.to_lowercase() + } else { + menu_item.label.clone() + } + ); + + let (search_sort_score, visible) = match config.read().unwrap().match_method() { + MatchMethod::Fuzzy => { + let mut score = strsim::jaro_winkler(&query, &menu_item_search); + if score == 0.0 { + score = -1.0; } - MatchMethod::MultiContains => { - let contains = query.split(' ').all(|x| menu_item_search.contains(x)); - (if contains { 1.0 } else { 0.0 }, contains) - } - MatchMethod::None => { - (1.0, true) // items are always shown + + ( + score, + score > config.read().unwrap().fuzzy_min_score() && score > 0.0, + ) + } + MatchMethod::Contains => { + if menu_item_search.contains(&query) { + (1.0, true) + } else { + (0.0, false) } - }; + } + MatchMethod::MultiContains => { + let contains = query.split(' ').all(|x| menu_item_search.contains(x)); + (if contains { 1.0 } else { 0.0 }, contains) + } + MatchMethod::None => { + (1.0, true) // items are always shown + } + }; - menu_item.search_sort_score = search_sort_score + menu_item.initial_sort_score; - menu_item.visible = visible; - fb.set_visible(menu_item.visible); - } + menu_item.search_sort_score = search_sort_score + menu_item.initial_sort_score; + menu_item.visible = visible; + fb.set_visible(menu_item.visible); + } +} + +#[must_use] +pub fn filtered_query(search_ignored_words: Option<&Vec>, query: &str) -> String { + let mut query = query.to_owned(); + if let Some(s) = search_ignored_words.as_ref() { + s.iter().for_each(|rgx| { + query = rgx.replace_all(&query, "").to_string(); + }); } + query } fn select_first_visible_child( diff --git a/worf/src/lib/modes/auto.rs b/worf/src/lib/modes/auto.rs index eac176d..a9329b0 100644 --- a/worf/src/lib/modes/auto.rs +++ b/worf/src/lib/modes/auto.rs @@ -1,10 +1,12 @@ use regex::Regex; +use std::sync::{Arc, Mutex, RwLock}; +use crate::gui::ArcProvider; use crate::{ Error, config::Config, desktop::spawn_fork, - gui::{self, ItemProvider, MenuItem}, + gui::{self, DefaultItemFactory, ExpandMode, ItemProvider, MenuItem, ProviderData}, modes::{ drun::{DRunProvider, update_drun_cache_and_run}, file::FileItemProvider, @@ -22,6 +24,7 @@ enum AutoRunType { File, Ssh, WebSearch, + Auto, } #[derive(Clone)] @@ -46,18 +49,25 @@ impl AutoItemProvider { } } - fn default_auto_elements( - &mut self, - search_opt: Option<&str>, - ) -> (bool, Vec>) { + fn default_auto_elements(&mut self) -> ProviderData { // 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) + if self.last_mode.is_none() + || self + .last_mode + .as_ref() + .is_some_and(|t| t != &AutoRunType::Auto) + { + let mut data = self.drun.get_elements(None); + if let Some(items) = data.items.as_mut() { + if let Some(mut ssh) = self.ssh.get_elements(None).items { + items.append(&mut ssh); + } + } + + self.last_mode = Some(AutoRunType::Auto); + data } else { - self.last_mode = Some(AutoRunType::DRun); - (true, items) + ProviderData { items: None } } } } @@ -76,13 +86,13 @@ fn contains_math_functions_or_starts_with_number(input: &str) -> bool { } impl ItemProvider for AutoItemProvider { - fn get_elements(&mut self, search_opt: Option<&str>) -> (bool, Vec>) { + fn get_elements(&mut self, search_opt: Option<&str>) -> ProviderData { 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) { + let (mode, provider_data) = 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)) @@ -96,23 +106,26 @@ impl ItemProvider for AutoItemProvider { self.search.get_elements(Some(&query)), ) } else { - return self.default_auto_elements(search_opt); + (AutoRunType::Auto, self.default_auto_elements()) }; - if self.last_mode.as_ref().is_some_and(|m| m == &mode) { - (changed, items) - } else { - self.last_mode = Some(mode); - (true, items) - } + self.last_mode = Some(mode); + provider_data } - fn get_sub_elements( - &mut self, - item: &MenuItem, - ) -> (bool, Option>>) { - let (changed, items) = self.get_elements(Some(item.label.as_ref())); - (changed, Some(items)) + fn get_sub_elements(&mut self, item: &MenuItem) -> ProviderData { + if let Some(auto_run_type) = item.data.as_ref() { + match auto_run_type { + AutoRunType::Math => self.math.get_sub_elements(item), + AutoRunType::DRun => self.drun.get_sub_elements(item), + AutoRunType::File => self.file.get_sub_elements(item), + AutoRunType::Ssh => self.ssh.get_sub_elements(item), + AutoRunType::WebSearch => self.search.get_sub_elements(item), + AutoRunType::Auto => ProviderData { items: None }, + } + } else { + ProviderData { items: None } + } } } @@ -124,23 +137,24 @@ impl ItemProvider for AutoItemProvider { /// /// # 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(); +pub fn show(config: &Arc>) -> Result<(), Error> { + let provider = Arc::new(Mutex::new(AutoItemProvider::new(&config.read().unwrap()))); + let arc_provider = Arc::clone(&provider) as ArcProvider; + let cache_path = provider.lock().unwrap().drun.cache_path.clone(); + let mut cache = provider.lock().unwrap().drun.cache.clone(); loop { - // todo ues a arc instead of cloning the config let selection_result = gui::show( - config.clone(), - provider.clone(), - true, + config, + Arc::clone(&arc_provider), + Some(Arc::new(Mutex::new(DefaultItemFactory::new()))), Some( vec!["ssh", "emoji", "^\\$\\w+", "^\\?\\s*"] .into_iter() .map(|s| Regex::new(s).unwrap()) .collect(), ), + ExpandMode::Verbatim, None, ); @@ -149,7 +163,12 @@ pub fn show(config: &Config) -> Result<(), Error> { if let Some(data) = &selection_result.data { match data { AutoRunType::Math => { - provider.math.elements.push(selection_result); + provider + .lock() + .unwrap() + .math + .elements + .push(selection_result); } AutoRunType::DRun => { update_drun_cache_and_run(&cache_path, &mut cache, selection_result)?; @@ -162,7 +181,7 @@ pub fn show(config: &Config) -> Result<(), Error> { break; } AutoRunType::Ssh => { - ssh::launch(&selection_result, config)?; + ssh::launch(&selection_result, &config.read().unwrap())?; break; } AutoRunType::WebSearch => { @@ -171,10 +190,13 @@ pub fn show(config: &Config) -> Result<(), Error> { } break; } + AutoRunType::Auto => { + unreachable!("Auto mode should never be set for show.") + } } } else if selection_result.label.starts_with("ssh") { selection_result.label = selection_result.label.chars().skip(4).collect(); - ssh::launch(&selection_result, config)?; + ssh::launch(&selection_result, &config.read().unwrap())?; } } else { log::error!("No item selected"); diff --git a/worf/src/lib/modes/dmenu.rs b/worf/src/lib/modes/dmenu.rs index a7929df..0e50914 100644 --- a/worf/src/lib/modes/dmenu.rs +++ b/worf/src/lib/modes/dmenu.rs @@ -1,9 +1,12 @@ -use std::io::{self, Read}; +use std::{ + io::{self, Read}, + sync::{Arc, Mutex, RwLock}, +}; use crate::{ Error, config::{Config, SortOrder}, - gui::{self, ItemProvider, MenuItem}, + gui::{self, DefaultItemFactory, ExpandMode, ItemProvider, MenuItem, ProviderData}, }; #[derive(Clone)] @@ -30,12 +33,18 @@ impl DMenuProvider { } } impl ItemProvider for DMenuProvider { - fn get_elements(&mut self, _: Option<&str>) -> (bool, Vec>) { - (false, self.items.clone()) + fn get_elements(&mut self, query: Option<&str>) -> ProviderData { + if query.is_some() { + ProviderData { items: None } + } else { + ProviderData { + items: Some(self.items.clone()), + } + } } - fn get_sub_elements(&mut self, _: &MenuItem) -> (bool, Option>>) { - (false, None) + fn get_sub_elements(&mut self, _: &MenuItem) -> ProviderData { + ProviderData { items: None } } } @@ -43,10 +52,21 @@ impl ItemProvider for DMenuProvider { /// # 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()); +/// # Panics +/// When failing to unwrap the arc lock +pub fn show(config: &Arc>) -> Result<(), Error> { + let provider = Arc::new(Mutex::new(DMenuProvider::new( + &config.read().unwrap().sort_order(), + ))); - let selection_result = gui::show(config.clone(), provider, true, None, None); + let selection_result = gui::show( + config, + provider, + Some(Arc::new(Mutex::new(DefaultItemFactory::new()))), + None, + ExpandMode::Verbatim, + None, + ); match selection_result { Ok(s) => { println!("{}", s.menu.label); diff --git a/worf/src/lib/modes/drun.rs b/worf/src/lib/modes/drun.rs index 1cf6995..66f152a 100644 --- a/worf/src/lib/modes/drun.rs +++ b/worf/src/lib/modes/drun.rs @@ -1,6 +1,7 @@ use std::{ collections::{HashMap, HashSet}, path::PathBuf, + sync::{Arc, Mutex, RwLock}, time::Instant, }; @@ -8,6 +9,7 @@ use freedesktop_file_parser::EntryType; use rayon::prelude::*; use serde::{Deserialize, Serialize}; +use crate::gui::ArcProvider; use crate::{ Error, config::{Config, SortOrder}, @@ -15,7 +17,7 @@ use crate::{ find_desktop_files, get_locale_variants, lookup_name_with_locale, save_cache_file, spawn_fork, }, - gui::{self, ItemProvider, MenuItem}, + gui::{self, ExpandMode, ItemProvider, MenuItem, ProviderData}, modes::load_cache, }; @@ -37,15 +39,21 @@ pub(crate) struct DRunProvider { } impl ItemProvider for DRunProvider { - fn get_elements(&mut self, _: Option<&str>) -> (bool, Vec>) { + fn get_elements(&mut self, query: Option<&str>) -> ProviderData { if self.items.is_none() { self.items = Some(self.load().clone()); } - (false, self.items.clone().unwrap()) + if query.is_some() { + ProviderData { items: None } + } else { + ProviderData { + items: self.items.clone(), + } + } } - fn get_sub_elements(&mut self, _: &MenuItem) -> (bool, Option>>) { - (false, None) + fn get_sub_elements(&mut self, _: &MenuItem) -> ProviderData { + ProviderData { items: None } } } @@ -211,15 +219,17 @@ pub(crate) fn update_drun_cache_and_run( /// # 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); - 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); +/// # Panics +/// When failing to unwrap the arc lock +pub fn show(config: &Arc>) -> Result<(), Error> { + let provider = Arc::new(Mutex::new(DRunProvider::new((), &config.read().unwrap()))); + let arc_provider = Arc::clone(&provider) as ArcProvider<()>; + let selection_result = gui::show(config, arc_provider, None, None, ExpandMode::Verbatim, None); match selection_result { - Ok(s) => update_drun_cache_and_run(&cache_path, &mut cache, s.menu)?, + Ok(s) => { + let p = provider.lock().unwrap(); + update_drun_cache_and_run(&p.cache_path, &mut p.cache.clone(), s.menu)?; + } Err(_) => { log::error!("No item selected"); } diff --git a/worf/src/lib/modes/emoji.rs b/worf/src/lib/modes/emoji.rs index 6d217fd..69ab940 100644 --- a/worf/src/lib/modes/emoji.rs +++ b/worf/src/lib/modes/emoji.rs @@ -1,8 +1,10 @@ +use std::sync::{Arc, Mutex, RwLock}; + use crate::{ Error, config::{Config, SortOrder}, desktop::copy_to_clipboard, - gui::{self, ItemProvider, MenuItem}, + gui::{self, ExpandMode, ItemProvider, MenuItem, ProviderData}, }; #[derive(Clone)] @@ -41,12 +43,18 @@ impl EmojiProvider { } impl ItemProvider for EmojiProvider { - fn get_elements(&mut self, _: Option<&str>) -> (bool, Vec>) { - (false, self.elements.clone()) + fn get_elements(&mut self, query: Option<&str>) -> ProviderData { + if query.is_some() { + ProviderData { items: None } + } else { + ProviderData { + items: Some(self.elements.clone()), + } + } } - fn get_sub_elements(&mut self, _: &MenuItem) -> (bool, Option>>) { - (false, None) + fn get_sub_elements(&mut self, _: &MenuItem) -> ProviderData { + ProviderData { items: None } } } @@ -54,9 +62,17 @@ impl ItemProvider for EmojiProvider { /// # Errors /// /// Forwards errors from the gui. See `gui::show` for details. -pub fn show(config: &Config) -> Result<(), Error> { - let provider = EmojiProvider::new(&config.sort_order(), config.emoji_hide_label()); - let selection_result = gui::show(config.clone(), provider, true, None, None)?; +/// # Panics +/// When failing to unwrap the arc lock +pub fn show(config: &Arc>) -> Result<(), Error> { + let cfg = config.read().unwrap(); + let provider = Arc::new(Mutex::new(EmojiProvider::new( + &cfg.sort_order(), + cfg.emoji_hide_label(), + ))); + drop(cfg); + + let selection_result = gui::show(config, provider, None, None, ExpandMode::Verbatim, None)?; match selection_result.menu.data { None => Err(Error::MissingAction), Some(action) => copy_to_clipboard(action, None), diff --git a/worf/src/lib/modes/file.rs b/worf/src/lib/modes/file.rs index 2a04066..73f847d 100644 --- a/worf/src/lib/modes/file.rs +++ b/worf/src/lib/modes/file.rs @@ -1,16 +1,17 @@ +use regex::Regex; +use std::sync::Mutex; use std::{ fs, os::unix::fs::FileTypeExt, path::{Path, PathBuf}, + sync::{Arc, RwLock}, }; -use regex::Regex; - use crate::{ Error, config::{Config, SortOrder, expand_path}, desktop::spawn_fork, - gui::{self, ItemProvider, MenuItem}, + gui::{self, ExpandMode, ItemProvider, MenuItem, ProviderData}, }; #[derive(Clone)] @@ -98,7 +99,7 @@ impl FileItemProvider { } impl ItemProvider for FileItemProvider { - fn get_elements(&mut self, search: Option<&str>) -> (bool, Vec>) { + fn get_elements(&mut self, search: Option<&str>) -> ProviderData { let default_path = if let Some(home) = dirs::home_dir() { home.display().to_string() } else { @@ -117,11 +118,7 @@ impl ItemProvider for FileItemProvider { let mut items: Vec> = Vec::new(); if !path.exists() { - if let Some(last) = &self.last_result { - return (false, last.clone()); - } - - return (true, vec![]); + return ProviderData { items: None }; } if path.is_dir() { @@ -183,11 +180,15 @@ impl ItemProvider for FileItemProvider { gui::apply_sort(&mut items, &self.sort_order); self.last_result = Some(items.clone()); - (true, items) + ProviderData { items: Some(items) } } - fn get_sub_elements(&mut self, _: &MenuItem) -> (bool, Option>>) { - (false, self.last_result.clone()) + fn get_sub_elements(&mut self, item: &MenuItem) -> ProviderData { + if self.last_result.as_ref().is_some_and(|lr| lr.len() == 1) { + ProviderData { items: None } + } else { + self.get_elements(Some(&item.label)) + } } } @@ -199,15 +200,19 @@ impl ItemProvider for FileItemProvider { /// /// # 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()); +pub fn show(config: &Arc>) -> Result<(), Error> { + let provider = Arc::new(Mutex::new(FileItemProvider::new( + 0, + config.read().unwrap().sort_order(), + ))); // todo ues a arc instead of cloning the config let selection_result = gui::show( - config.clone(), + config, provider, - false, + None, Some(vec![Regex::new("^\\$\\w+").unwrap()]), + ExpandMode::Verbatim, None, )?; if let Some(action) = selection_result.menu.action { diff --git a/worf/src/lib/modes/math.rs b/worf/src/lib/modes/math.rs index 92931c9..6cf69c9 100644 --- a/worf/src/lib/modes/math.rs +++ b/worf/src/lib/modes/math.rs @@ -1,9 +1,16 @@ +use std::{ + collections::VecDeque, + sync::{Arc, Mutex, RwLock}, +}; + use regex::Regex; -use std::collections::VecDeque; use crate::{ config::Config, - gui::{self, ItemProvider, MenuItem}, + gui::{ + self, ArcFactory, ArcProvider, DefaultItemFactory, ExpandMode, ItemProvider, MenuItem, + ProviderData, + }, }; #[derive(Clone)] @@ -26,7 +33,7 @@ impl MathProvider { impl ItemProvider for MathProvider { #[allow(clippy::cast_possible_truncation)] - fn get_elements(&mut self, search: Option<&str>) -> (bool, Vec>) { + fn get_elements(&mut self, search: Option<&str>) -> ProviderData { if let Some(search_text) = search { let result = calc(search_text); @@ -41,26 +48,30 @@ impl ItemProvider for MathProvider { ); let mut result = vec![item]; result.append(&mut self.elements.clone()); - (true, result) + ProviderData { + items: Some(result), + } } else { - (false, self.elements.clone()) + ProviderData { items: None } } } - fn get_sub_elements(&mut self, _: &MenuItem) -> (bool, Option>>) { - (false, None) + fn get_sub_elements(&mut self, _: &MenuItem) -> ProviderData { + ProviderData { items: None } } } #[derive(Debug, Clone, Copy)] enum Token { - Num(i64), + Int(i64), + Float(f64), Op(char), ShiftLeft, ShiftRight, Power, } +#[derive(Debug)] enum Value { Int(i64), Float(f64), @@ -81,11 +92,22 @@ fn normalize_bases(expr: &str) -> String { .to_string() } +fn insert_implicit_multiplication(tokens: &mut VecDeque, last_token: Option<&Token>) { + if matches!( + last_token, + Some(Token::Int(_) | Token::Float(_) | Token::Op(')')) + ) { + tokens.push_back(Token::Op('*')); + } +} + /// Tokenize a normalized expression string into tokens +#[allow(clippy::too_many_lines)] fn tokenize(expr: &str) -> Result, String> { let mut tokens = VecDeque::new(); let chars: Vec = expr.chars().collect(); let mut i = 0; + let mut last_token: Option = None; while i < chars.len() { let c = chars[i]; @@ -100,16 +122,19 @@ fn tokenize(expr: &str) -> Result, String> { match &expr[i..=i + 1] { "<<" => { tokens.push_back(Token::ShiftLeft); + last_token = Some(Token::ShiftLeft); i += 2; continue; } ">>" => { tokens.push_back(Token::ShiftRight); + last_token = Some(Token::ShiftRight); i += 2; continue; } "**" => { tokens.push_back(Token::Power); + last_token = Some(Token::Power); i += 2; continue; } @@ -117,20 +142,68 @@ fn tokenize(expr: &str) -> Result, String> { } } - // Single-character operators or digits + // Single-character operators, parentheses, or digits/float match c { '+' | '-' | '*' | '/' | '&' | '|' | '^' => { - tokens.push_back(Token::Op(c)); + let token = Token::Op(c); + tokens.push_back(token); + last_token = Some(token); i += 1; } - '0'..='9' => { + '(' => { + insert_implicit_multiplication(&mut tokens, last_token.as_ref()); + let token = Token::Op('('); + tokens.push_back(token); + last_token = Some(token); + i += 1; + } + ')' => { + let token = Token::Op(')'); + tokens.push_back(token); + last_token = Some(token); + i += 1; + } + '0'..='9' | '.' => { + // Only insert implicit multiplication if the last token is ')' and the last token in tokens is not already an operator (except ')') + if let Some(Token::Op(')')) = last_token { + if let Some(Token::Op(op)) = tokens.back() { + if *op == ')' { + tokens.push_back(Token::Op('*')); + } + } else { + tokens.push_back(Token::Op('*')); + } + } let start = i; - while i < chars.len() && chars[i].is_ascii_digit() { + let mut has_dot = c == '.'; + if c == '.' && (i + 1 >= chars.len() || !chars[i + 1].is_ascii_digit()) { + return Err("Invalid float literal".to_owned()); + } + i += 1; + while i < chars.len() + && (chars[i].is_ascii_digit() || (!has_dot && chars[i] == '.')) + { + if chars[i] == '.' { + has_dot = true; + } i += 1; } let num_str: String = chars[start..i].iter().collect(); - let n = num_str.parse::().unwrap(); - tokens.push_back(Token::Num(n)); + if has_dot { + let n = num_str + .parse::() + .map_err(|_| "Invalid float literal".to_owned())?; + let token = Token::Float(n); + tokens.push_back(token); + last_token = Some(token); + } else { + let n = num_str + .parse::() + .map_err(|_| "Invalid integer literal".to_owned())?; + let token = Token::Int(n); + tokens.push_back(token); + last_token = Some(token); + } } _ => return Err("Invalid character in expression".to_owned()), } @@ -193,9 +266,33 @@ fn eval_expr(tokens: &mut VecDeque) -> Result { while let Some(token) = tokens.pop_front() { match token { - Token::Num(n) => values.push(Value::Int(n)), + Token::Int(n) => values.push(Value::Int(n)), + Token::Float(f) => values.push(Value::Float(f)), + Token::Op('(') => { + ops.push(Token::Op('(')); + } + Token::Op(')') => { + while let Some(top_op) = ops.last() { + if let Token::Op('(') = top_op { + break; + } + let b = values.pop().ok_or("Missing left operand")?; + let a = values.pop().ok_or("Missing right operand")?; + let op = ops.pop().ok_or("Missing operator")?; + values.push(apply_op(&a, &b, &op)); + } + if let Some(Token::Op('(')) = ops.last() { + ops.pop(); // Remove '(' + } else { + return Err("Mismatched parentheses".to_owned()); + } + } op @ (Token::Op(_) | Token::ShiftLeft | Token::ShiftRight | Token::Power) => { while let Some(top_op) = ops.last() { + // Only pop ops with higher or equal precedence, and not '(' + if let Token::Op('(') = top_op { + break; + } if precedence(&op) >= precedence(top_op) { let b = values.pop().ok_or("Missing left operand")?; let a = values.pop().ok_or("Missing right operand")?; @@ -210,13 +307,22 @@ fn eval_expr(tokens: &mut VecDeque) -> Result { } } + // Final reduction: check if there are enough values for the remaining operators + if !ops.is_empty() && values.len() < 2 { + return Err(format!( + "Not enough values for the remaining operators (values: {values:?}, ops: {ops:?})", + )); + } while let Some(op) = ops.pop() { - let b = values - .pop() - .ok_or("Missing right operand in final evaluation")?; - let a = values - .pop() - .ok_or("Missing left operand in final evaluation")?; + if let Token::Op('(') = op { + return Err("Mismatched parentheses".to_owned()); + } + let b = values.pop().ok_or_else(|| { + format!("Missing right operand in final evaluation (values: {values:?}, ops: {ops:?})",) + })?; + let a = values.pop().ok_or_else(|| { + format!("Missing left operand in final evaluation (values: {values:?}, ops: {ops:?})",) + })?; values.push(apply_op(&a, &b, &op)); } @@ -239,12 +345,23 @@ fn calc(input: &str) -> String { } /// Shows the math mode -pub fn show(config: &Config) { - let mut calc: Vec> = vec![]; +/// # Panics +/// When failing to unwrap the arc lock +pub fn show(config: &Arc>) { + let mut calc: Vec> = vec![]; + let provider = Arc::new(Mutex::new(MathProvider::new(()))); + let factory: ArcFactory<()> = Arc::new(Mutex::new(DefaultItemFactory::new())); + let arc_provider = Arc::clone(&provider) as ArcProvider<()>; 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); + provider.lock().unwrap().add_elements(&mut calc.clone()); + let selection_result = gui::show( + config, + Arc::clone(&arc_provider), + Some(Arc::clone(&factory)), + None, + ExpandMode::Verbatim, + None, + ); if let Ok(mi) = selection_result { calc.push(mi.menu); } else { diff --git a/worf/src/lib/modes/run.rs b/worf/src/lib/modes/run.rs index 4bfdfda..70ad720 100644 --- a/worf/src/lib/modes/run.rs +++ b/worf/src/lib/modes/run.rs @@ -4,32 +4,42 @@ use std::{ ffi::CString, fs, path::PathBuf, + sync::{Arc, Mutex, RwLock}, }; +use crate::gui::ArcProvider; use crate::{ Error, config::{Config, SortOrder}, desktop::{is_executable, save_cache_file}, - gui::{self, ItemProvider, MenuItem}, + gui::{self, ExpandMode, ItemProvider, MenuItem, ProviderData}, modes::load_cache, }; -impl ItemProvider for RunProvider { - fn get_elements(&mut self, _: Option<&str>) -> (bool, Vec>) { +impl ItemProvider<()> for RunProvider { + fn get_elements(&mut self, query: Option<&str>) -> ProviderData<()> { if self.items.is_none() { self.items = Some(self.load().clone()); } - (false, self.items.clone().unwrap()) + if query.is_some() { + ProviderData { items: None } + } else { + ProviderData { + items: self.items.clone(), + } + } } - fn get_sub_elements(&mut self, _: &MenuItem) -> (bool, Option>>) { - (false, None) + fn get_sub_elements(&mut self, _: &MenuItem<()>) -> ProviderData<()> { + ProviderData { + items: self.items.clone(), + } } } #[derive(Clone)] struct RunProvider { - items: Option>>, + items: Option>>, cache_path: PathBuf, cache: HashMap, sort_order: SortOrder, @@ -48,7 +58,7 @@ impl RunProvider { #[allow(clippy::cast_possible_truncation)] #[allow(clippy::cast_precision_loss)] - fn load(&self) -> Vec> { + fn load(&self) -> Vec> { let path_var = env::var("PATH").unwrap_or_default(); let paths = env::split_paths(&path_var); @@ -82,7 +92,7 @@ impl RunProvider { .collect(); let mut seen_actions = HashSet::new(); - let mut entries: Vec> = entries + let mut entries: Vec> = entries .into_iter() .filter(|entry| { entry @@ -124,13 +134,18 @@ fn update_run_cache_and_run( /// # 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)?; - let cache_path = provider.cache_path.clone(); - let mut cache = provider.cache.clone(); - let selection_result = gui::show(config.clone(), provider, false, None, None); +/// # Panics +/// When failing to unwrap the arc lock +pub fn show(config: &Arc>) -> Result<(), Error> { + let provider = Arc::new(Mutex::new(RunProvider::new(&config.read().unwrap())?)); + let arc_provider = Arc::clone(&provider) as ArcProvider<()>; + + let selection_result = gui::show(config, arc_provider, None, None, ExpandMode::Verbatim, None); match selection_result { - Ok(s) => update_run_cache_and_run(&cache_path, &mut cache, s.menu)?, + Ok(s) => { + let prov = provider.lock().unwrap(); + update_run_cache_and_run(&prov.cache_path, &mut prov.cache.clone(), s.menu)?; + } Err(_) => { log::error!("No item selected"); } diff --git a/worf/src/lib/modes/search.rs b/worf/src/lib/modes/search.rs index ba68f1b..9241139 100644 --- a/worf/src/lib/modes/search.rs +++ b/worf/src/lib/modes/search.rs @@ -1,10 +1,11 @@ +use std::sync::{Arc, Mutex, RwLock}; use urlencoding::encode; -use crate::desktop::spawn_fork; use crate::{ Error, config::Config, - gui::{self, ItemProvider, MenuItem}, + desktop::spawn_fork, + gui::{self, ArcFactory, DefaultItemFactory, ExpandMode, ItemProvider, MenuItem, ProviderData}, }; #[derive(Clone)] @@ -23,7 +24,7 @@ impl SearchProvider { } impl ItemProvider for SearchProvider { - fn get_elements(&mut self, query: Option<&str>) -> (bool, Vec>) { + fn get_elements(&mut self, query: Option<&str>) -> ProviderData { if let Some(query) = query { let url = format!("{}{}", self.search_query, encode(query)); let run_search = MenuItem::new( @@ -35,14 +36,17 @@ impl ItemProvider for SearchProvider { 0.0, Some(self.data.clone()), ); - (true, vec![run_search]) + + ProviderData { + items: Some(vec![run_search]), + } } else { - (false, vec![]) + ProviderData { items: None } } } - fn get_sub_elements(&mut self, _: &MenuItem) -> (bool, Option>>) { - (false, None) + fn get_sub_elements(&mut self, _: &MenuItem) -> ProviderData { + ProviderData { items: None } } } @@ -50,9 +54,22 @@ impl ItemProvider for SearchProvider { /// # Errors /// /// Forwards errors from the gui. See `gui::show` for details. -pub fn show(config: &Config) -> Result<(), Error> { - let provider = SearchProvider::new(String::new(), config.search_query()); - let selection_result = gui::show(config.clone(), provider, true, None, None)?; +/// # Panics +/// When failing to unwrap the arc lock +pub fn show(config: &Arc>) -> Result<(), Error> { + let provider = Arc::new(Mutex::new(SearchProvider::new( + (), + config.read().unwrap().search_query(), + ))); + let factory: ArcFactory<()> = Arc::new(Mutex::new(DefaultItemFactory::new())); + let selection_result = gui::show( + config, + provider, + Some(factory), + None, + ExpandMode::Verbatim, + None, + )?; match selection_result.menu.action { None => Err(Error::MissingAction), Some(action) => spawn_fork(&action, None), diff --git a/worf/src/lib/modes/ssh.rs b/worf/src/lib/modes/ssh.rs index 3e82557..4ffe2a7 100644 --- a/worf/src/lib/modes/ssh.rs +++ b/worf/src/lib/modes/ssh.rs @@ -1,7 +1,8 @@ -use std::fs; - use regex::Regex; +use std::fs; +use std::sync::{Arc, Mutex, RwLock}; +use crate::gui::{ExpandMode, ProviderData}; use crate::{ Error, config::{Config, SortOrder}, @@ -11,7 +12,7 @@ use crate::{ #[derive(Clone)] pub(crate) struct SshProvider { - elements: Vec>, + items: Vec>, } impl SshProvider { @@ -46,17 +47,23 @@ impl SshProvider { .collect(); gui::apply_sort(&mut items, order); - Self { elements: items } + Self { items } } } impl ItemProvider for SshProvider { - fn get_elements(&mut self, _: Option<&str>) -> (bool, Vec>) { - (false, self.elements.clone()) + fn get_elements(&mut self, query: Option<&str>) -> ProviderData { + if query.is_some() { + ProviderData { items: None } + } else { + ProviderData { + items: Some(self.items.clone()), + } + } } - fn get_sub_elements(&mut self, _: &MenuItem) -> (bool, Option>>) { - (false, None) + fn get_sub_elements(&mut self, _: &MenuItem) -> ProviderData { + ProviderData { items: None } } } @@ -87,11 +94,16 @@ pub(crate) fn launch(menu_item: &MenuItem, config: &Config) -> Resu /// 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); +/// # Panics +/// When failing to unwrap the arc lock +pub fn show(config: &Arc>) -> Result<(), Error> { + let provider = Arc::new(Mutex::new(SshProvider::new( + 0, + &config.read().unwrap().sort_order(), + ))); + let selection_result = gui::show(config, provider, None, None, ExpandMode::Verbatim, None); if let Ok(mi) = selection_result { - launch(&mi.menu, config)?; + launch(&mi.menu, &config.read().unwrap())?; } else { log::error!("No item selected"); } diff --git a/worf/src/main.rs b/worf/src/main.rs index c38f2e5..2a686a5 100644 --- a/worf/src/main.rs +++ b/worf/src/main.rs @@ -1,6 +1,91 @@ -use std::env; +use clap::Parser; +use std::fmt::Display; +use std::str::FromStr; +use std::{ + env, + sync::{Arc, RwLock}, +}; +use worf::{Error, config, desktop::fork_if_configured, modes}; -use worf::{Error, config, config::Mode, desktop::fork_if_configured, modes}; +#[derive(Clone, Debug)] +pub enum Mode { + /// searches `$PATH` for executables and allows them to be run by selecting them. + Run, + /// searches `$XDG_DATA_HOME/applications` and `$XDG_DATA_DIRS/applications` + /// for desktop files and allows them to be run by selecting them. + Drun, + + /// reads from stdin and displays options which when selected will be output to stdout. + Dmenu, + + /// tries to determine automatically what to do + Auto, + + /// use worf as file browser + File, + + /// Use is as calculator + Math, + + /// Connect via ssh to a given host + Ssh, + + /// Emoji browser + Emoji, + + /// Open search engine. + WebSearch, +} + +#[derive(Debug, Parser)] +#[clap( + about = "Worf is a wofi like launcher, written in rust, it aims to be a drop-in replacement" +)] +struct MainConfig { + /// Defines the mode worf is running in + #[clap(long = "show")] + show: Mode, + + #[command(flatten)] + worf: config::Config, +} + +impl Display for Mode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Mode::Run => write!(f, "run"), + Mode::Drun => write!(f, "drun"), + Mode::Dmenu => write!(f, "dmenu"), + Mode::Math => write!(f, "math"), + Mode::File => write!(f, "file"), + Mode::Auto => write!(f, "auto"), + Mode::Ssh => write!(f, "ssh"), + Mode::Emoji => write!(f, "emoji"), + Mode::WebSearch => write!(f, "websearch"), + } + } +} + +impl FromStr for Mode { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "run" => Ok(Mode::Run), + "drun" => Ok(Mode::Drun), + "dmenu" => Ok(Mode::Dmenu), + "file" => Ok(Mode::File), + "math" => Ok(Mode::Math), + "ssh" => Ok(Mode::Ssh), + "emoji" => Ok(Mode::Emoji), + "websearch" => Ok(Mode::WebSearch), + "auto" => Ok(Mode::Auto), + _ => Err(Error::InvalidArgument( + format!("{s} is not a valid argument, see help for details").to_owned(), + )), + } + } +} fn main() { env_logger::Builder::new() @@ -8,50 +93,41 @@ fn main() { .format_timestamp_micros() .init(); - let args = config::parse_args(); - - let config = config::load_config(Some(&args)); - let config = match config { - Ok(c) => c, - Err(e) => { - log::error!("error during config load, skipping it, {e}"); - args - } - }; + let mut config = MainConfig::parse(); + config.worf = config::load_config(Some(&config.worf)).unwrap_or(config.worf); + if config.worf.prompt().is_none() { + config.worf.set_prompt(config.show.to_string()); + } - if config.version() { + if config.worf.version() { println!("worf version {}", env!("CARGO_PKG_VERSION")); return; } - fork_if_configured(&config); // may exit the program - - if let Some(show) = &config.show() { - let result = match show { - 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 => { - modes::math::show(&config); - Ok(()) - } - Mode::Ssh => modes::ssh::show(&config), - Mode::Emoji => modes::emoji::show(&config), - Mode::Auto => modes::auto::show(&config), - Mode::WebSearch => modes::search::show(&config), - }; - - if let Err(err) = result { - if err == Error::NoSelection { - log::info!("no selection made"); - } else { - log::error!("Error occurred {err:?}"); - std::process::exit(1); - } + fork_if_configured(&config.worf); // may exit the program + + let cfg_arc = Arc::new(RwLock::new(config.worf)); + let result = match config.show { + Mode::Run => modes::run::show(&cfg_arc), + Mode::Drun => modes::drun::show(&cfg_arc), + Mode::Dmenu => modes::dmenu::show(&cfg_arc), + Mode::File => modes::file::show(&cfg_arc), + Mode::Math => { + modes::math::show(&cfg_arc); + Ok(()) + } + Mode::Ssh => modes::ssh::show(&cfg_arc), + Mode::Emoji => modes::emoji::show(&cfg_arc), + Mode::Auto => modes::auto::show(&cfg_arc), + Mode::WebSearch => modes::search::show(&cfg_arc), + }; + + if let Err(err) = result { + if err == Error::NoSelection { + log::info!("no selection made"); + } else { + log::error!("Error occurred {err:?}"); + std::process::exit(1); } - } else { - log::error!("No mode provided"); - std::process::exit(1); } }