From b806f2b26db8292510f06dd92b91ccc18d545720 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Thu, 22 Jun 2023 18:00:30 -0700 Subject: [PATCH 01/37] feat: start on dioxus ui --- crates/rmenu/Cargo.toml | 11 +- crates/rmenu/src/gui/mod.rs | 199 ++++----------------- crates/rmenu/src/{gui => gui_old}/icons.rs | 0 crates/rmenu/src/gui_old/mod.rs | 188 +++++++++++++++++++ crates/rmenu/src/{gui => gui_old}/page.rs | 0 5 files changed, 224 insertions(+), 174 deletions(-) rename crates/rmenu/src/{gui => gui_old}/icons.rs (100%) create mode 100644 crates/rmenu/src/gui_old/mod.rs rename crates/rmenu/src/{gui => gui_old}/page.rs (100%) diff --git a/crates/rmenu/Cargo.toml b/crates/rmenu/Cargo.toml index e34a75e..e37e107 100644 --- a/crates/rmenu/Cargo.toml +++ b/crates/rmenu/Cargo.toml @@ -8,17 +8,10 @@ edition = "2021" [dependencies] abi_stable = "0.11.1" clap = { version = "4.0.32", features = ["derive"] } -dashmap = "5.4.0" -eframe = "0.20.1" -egui = "0.20.1" -egui_extras = { version = "0.20.0", features = ["svg", "image"] } -image = { version = "0.24.5", default-features = false, features = ["png"] } +dioxus = "0.3.2" +dioxus-desktop = "0.3.0" log = "0.4.17" rmenu-plugin = { version = "0.1.0", path = "../rmenu-plugin", features = ["rmenu_internals"] } serde = { version = "1.0.152", features = ["derive"] } shellexpand = "3.0.0" toml = "0.5.10" - -[patch.crates-io] -eframe = { git = "https://github.com/imgurbot12/egui", branch="feat/grid-color" } -egui = { git = "https://github.com/imgurbot12/egui", branch="feat/grid-color" } diff --git a/crates/rmenu/src/gui/mod.rs b/crates/rmenu/src/gui/mod.rs index 408e353..ecc49a4 100644 --- a/crates/rmenu/src/gui/mod.rs +++ b/crates/rmenu/src/gui/mod.rs @@ -1,188 +1,57 @@ -/*! - * Rmenu - Egui implementation - */ -use std::process::exit; -use eframe::egui; +use dioxus::prelude::*; +use dioxus_desktop::{Config, WindowBuilder}; -mod icons; -mod page; -use icons::{load_images, IconCache}; -use page::Paginator; +use rmenu_plugin::Entry; -use crate::{config::Config, plugins::Plugins}; +use crate::{config, plugins::Plugins}; -// v1: -//TODO: fix grid so items expand entire length of window -//TODO: remove prefix and name specification from module definition -//TODO: allow specifying prefix in search to limit enabled plugins -//TODO: build in the actual execute and close part -//TODO: build compilation and install script for easy setup -//TODO: allow for close-on-defocus option in config? - -// v2: -//TODO: look into dynamic rendering w/ a custom style config - maybe even css? -//TODO: add additonal plugins: file-browser, browser-url, etc... - -/* Function */ - -// spawn gui application and run it -pub fn launch_gui(cfg: Config, plugins: Plugins) -> Result<(), eframe::Error> { - let pos = match cfg.rmenu.window_pos { - Some(pos) => Some(egui::pos2(pos[0], pos[1])), - None => None, - }; +/// Spawn GUI instance with the specified config and plugins +pub fn launch_gui(cfg: config::Config, plugins: Plugins) -> Result<(), String> { + // simple_logger::init_with_level(log::Level::Debug).unwrap(); let size = cfg.rmenu.window_size.unwrap_or([550.0, 350.0]); - let options = eframe::NativeOptions { - transparent: true, - always_on_top: true, - decorated: cfg.rmenu.decorate_window, - centered: cfg.rmenu.centered.unwrap_or(false), - initial_window_pos: pos, - initial_window_size: Some(egui::vec2(size[0], size[1])), - ..Default::default() - }; - let gui = GUI::new(cfg, plugins); - eframe::run_native("rmenu", options, Box::new(|_cc| Box::new(gui))) + let gui = GUI::new(cfg, plugins); + dioxus_desktop::launch_cfg( + App, + Config::default().with_window(WindowBuilder::new().with_resizable(true).with_inner_size( + dioxus_desktop::wry::application::dpi::LogicalSize::new(size[0], size[1]), + )), + ); + Ok(()) } -/* Implementation */ - struct GUI { plugins: Plugins, search: String, - images: IconCache, - config: Config, - page: Paginator, + config: config::Config, } impl GUI { - pub fn new(config: Config, plugins: Plugins) -> Self { - let mut gui = Self { + pub fn new(config: config::Config, plugins: Plugins) -> Self { + Self { + config, plugins, search: "".to_owned(), - images: IconCache::new(), - page: Paginator::new(config.rmenu.result_size.clone().unwrap_or(15)), - config, - }; - // pre-run empty search to generate cache - gui.search(); - gui - } - - // complete search based on current internal search variable - fn search(&mut self) { - let results = self.plugins.search(&self.search); - if results.len() > 0 { - load_images(&mut self.images, 20, &results); - } - self.page.reset(results); - } - - #[inline] - fn keyboard(&mut self, ctx: &egui::Context) { - // tab / ctrl+tab controls - if ctx.input().key_pressed(egui::Key::Tab) { - match ctx.input().modifiers.ctrl { - true => self.page.focus_up(1), - false => self.page.focus_down(1), - }; - } - // arrow-key controls - if ctx.input().key_pressed(egui::Key::ArrowUp) { - self.page.focus_up(1); - } - if ctx.input().key_pressed(egui::Key::ArrowDown) { - self.page.focus_down(1) - } - // pageup / pagedown controls - if ctx.input().key_pressed(egui::Key::PageUp) { - self.page.focus_up(5); - } - if ctx.input().key_pressed(egui::Key::PageDown) { - self.page.focus_down(5); - } - // exit controls - if ctx.input().key_pressed(egui::Key::Escape) { - exit(1); } } -} + + fn search(&mut self, search: &str) -> Vec { + self.plugins.search(search) + } -impl GUI { - // implement simple topbar searchbar - #[inline] - fn simple_search(&mut self, ui: &mut egui::Ui) { - let size = ui.available_size(); - ui.horizontal(|ui| { - ui.spacing_mut().text_edit_width = size.x; - let search = egui::TextEdit::singleline(&mut self.search).frame(false); - let object = ui.add(search); - if object.changed() { - self.search(); + pub fn app(&mut self, cx: Scope) -> Element { + let results = self.search(""); + cx.render(rsx! { + div { + h1 { "Hello World!" } + result.iter().map(|entry| { + div { + div { entry.name } + div { entry.comment } + } + }) } - }); - } - - // check if results contain any icons at all - #[inline] - fn has_icons(&self) -> bool { - self.page - .iter() - .filter(|r| r.icon.is_some()) - .peekable() - .peek() - .is_some() - } - - #[inline] - fn grid_highlight(&self) -> Box Option> { - let focus = self.page.row_focus(); - Box::new(move |row, style| { - if row == focus { - return Some(egui::Rgba::from(style.visuals.faint_bg_color)); - } - None }) } - // implement simple scrolling grid-based results pannel - #[inline] - fn simple_results(&mut self, ui: &mut egui::Ui) { - let results = self.page.iter(); - let has_icons = self.has_icons(); - egui::Grid::new("results") - .with_row_color(self.grid_highlight()) - .show(ui, |ui| { - for record in results { - // render icons (if any were present in set) - if has_icons { - ui.horizontal(|ui| { - if let Some(icon) = record.icon.as_ref().into_option() { - if let Ok(image) = self.images.load(&icon) { - let xy = self.config.rmenu.icon_size; - image.show_size(ui, egui::vec2(xy, xy)); - } - } - }); - } - // render content - ui.label(record.name.as_str()); - if let Some(comment) = record.comment.as_ref().into_option() { - ui.label(comment.as_str()); - } - ui.end_row(); - } - }); - } -} - -impl eframe::App for GUI { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show(ctx, |ui| { - self.keyboard(ctx); - self.simple_search(ui); - self.simple_results(ui); - }); - } } diff --git a/crates/rmenu/src/gui/icons.rs b/crates/rmenu/src/gui_old/icons.rs similarity index 100% rename from crates/rmenu/src/gui/icons.rs rename to crates/rmenu/src/gui_old/icons.rs diff --git a/crates/rmenu/src/gui_old/mod.rs b/crates/rmenu/src/gui_old/mod.rs new file mode 100644 index 0000000..408e353 --- /dev/null +++ b/crates/rmenu/src/gui_old/mod.rs @@ -0,0 +1,188 @@ +/*! + * Rmenu - Egui implementation + */ +use std::process::exit; + +use eframe::egui; + +mod icons; +mod page; +use icons::{load_images, IconCache}; +use page::Paginator; + +use crate::{config::Config, plugins::Plugins}; + +// v1: +//TODO: fix grid so items expand entire length of window +//TODO: remove prefix and name specification from module definition +//TODO: allow specifying prefix in search to limit enabled plugins +//TODO: build in the actual execute and close part +//TODO: build compilation and install script for easy setup +//TODO: allow for close-on-defocus option in config? + +// v2: +//TODO: look into dynamic rendering w/ a custom style config - maybe even css? +//TODO: add additonal plugins: file-browser, browser-url, etc... + +/* Function */ + +// spawn gui application and run it +pub fn launch_gui(cfg: Config, plugins: Plugins) -> Result<(), eframe::Error> { + let pos = match cfg.rmenu.window_pos { + Some(pos) => Some(egui::pos2(pos[0], pos[1])), + None => None, + }; + let size = cfg.rmenu.window_size.unwrap_or([550.0, 350.0]); + let options = eframe::NativeOptions { + transparent: true, + always_on_top: true, + decorated: cfg.rmenu.decorate_window, + centered: cfg.rmenu.centered.unwrap_or(false), + initial_window_pos: pos, + initial_window_size: Some(egui::vec2(size[0], size[1])), + ..Default::default() + }; + let gui = GUI::new(cfg, plugins); + eframe::run_native("rmenu", options, Box::new(|_cc| Box::new(gui))) +} + +/* Implementation */ + +struct GUI { + plugins: Plugins, + search: String, + images: IconCache, + config: Config, + page: Paginator, +} + +impl GUI { + pub fn new(config: Config, plugins: Plugins) -> Self { + let mut gui = Self { + plugins, + search: "".to_owned(), + images: IconCache::new(), + page: Paginator::new(config.rmenu.result_size.clone().unwrap_or(15)), + config, + }; + // pre-run empty search to generate cache + gui.search(); + gui + } + + // complete search based on current internal search variable + fn search(&mut self) { + let results = self.plugins.search(&self.search); + if results.len() > 0 { + load_images(&mut self.images, 20, &results); + } + self.page.reset(results); + } + + #[inline] + fn keyboard(&mut self, ctx: &egui::Context) { + // tab / ctrl+tab controls + if ctx.input().key_pressed(egui::Key::Tab) { + match ctx.input().modifiers.ctrl { + true => self.page.focus_up(1), + false => self.page.focus_down(1), + }; + } + // arrow-key controls + if ctx.input().key_pressed(egui::Key::ArrowUp) { + self.page.focus_up(1); + } + if ctx.input().key_pressed(egui::Key::ArrowDown) { + self.page.focus_down(1) + } + // pageup / pagedown controls + if ctx.input().key_pressed(egui::Key::PageUp) { + self.page.focus_up(5); + } + if ctx.input().key_pressed(egui::Key::PageDown) { + self.page.focus_down(5); + } + // exit controls + if ctx.input().key_pressed(egui::Key::Escape) { + exit(1); + } + } +} + +impl GUI { + // implement simple topbar searchbar + #[inline] + fn simple_search(&mut self, ui: &mut egui::Ui) { + let size = ui.available_size(); + ui.horizontal(|ui| { + ui.spacing_mut().text_edit_width = size.x; + let search = egui::TextEdit::singleline(&mut self.search).frame(false); + let object = ui.add(search); + if object.changed() { + self.search(); + } + }); + } + + // check if results contain any icons at all + #[inline] + fn has_icons(&self) -> bool { + self.page + .iter() + .filter(|r| r.icon.is_some()) + .peekable() + .peek() + .is_some() + } + + #[inline] + fn grid_highlight(&self) -> Box Option> { + let focus = self.page.row_focus(); + Box::new(move |row, style| { + if row == focus { + return Some(egui::Rgba::from(style.visuals.faint_bg_color)); + } + None + }) + } + + // implement simple scrolling grid-based results pannel + #[inline] + fn simple_results(&mut self, ui: &mut egui::Ui) { + let results = self.page.iter(); + let has_icons = self.has_icons(); + egui::Grid::new("results") + .with_row_color(self.grid_highlight()) + .show(ui, |ui| { + for record in results { + // render icons (if any were present in set) + if has_icons { + ui.horizontal(|ui| { + if let Some(icon) = record.icon.as_ref().into_option() { + if let Ok(image) = self.images.load(&icon) { + let xy = self.config.rmenu.icon_size; + image.show_size(ui, egui::vec2(xy, xy)); + } + } + }); + } + // render content + ui.label(record.name.as_str()); + if let Some(comment) = record.comment.as_ref().into_option() { + ui.label(comment.as_str()); + } + ui.end_row(); + } + }); + } +} + +impl eframe::App for GUI { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + egui::CentralPanel::default().show(ctx, |ui| { + self.keyboard(ctx); + self.simple_search(ui); + self.simple_results(ui); + }); + } +} diff --git a/crates/rmenu/src/gui/page.rs b/crates/rmenu/src/gui_old/page.rs similarity index 100% rename from crates/rmenu/src/gui/page.rs rename to crates/rmenu/src/gui_old/page.rs From 6fe171c3988dc6f82c0478aad546faa3a134d7a1 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Mon, 17 Jul 2023 22:49:07 -0700 Subject: [PATCH 02/37] feat: complete rewrite for v2 --- Cargo.toml | 5 + crates/rmenu-plugin/Cargo.toml | 17 --- crates/rmenu-plugin/src/cache.rs | 196 ---------------------------- crates/rmenu-plugin/src/internal.rs | 32 ----- crates/rmenu-plugin/src/lib.rs | 67 ---------- crates/rmenu/Cargo.toml | 17 --- crates/rmenu/src/config.rs | 101 -------------- crates/rmenu/src/exec.rs | 0 crates/rmenu/src/gui/mod.rs | 57 -------- crates/rmenu/src/gui_old/icons.rs | 94 ------------- crates/rmenu/src/gui_old/mod.rs | 188 -------------------------- crates/rmenu/src/gui_old/page.rs | 107 --------------- crates/rmenu/src/main.rs | 55 -------- crates/rmenu/src/plugins.rs | 33 ----- plugins/drun/Cargo.toml | 16 --- plugins/drun/src/desktop.rs | 136 ------------------- plugins/drun/src/lib.rs | 156 ---------------------- plugins/run/Cargo.toml | 15 --- plugins/run/src/lib.rs | 105 --------------- plugins/run/src/run.rs | 79 ----------- rmenu-plugin/Cargo.toml | 9 ++ rmenu-plugin/src/lib.rs | 33 +++++ rmenu/Cargo.toml | 18 +++ rmenu/public/default.css | 40 ++++++ rmenu/src/gui.rs | 69 ++++++++++ rmenu/src/main.rs | 69 ++++++++++ 26 files changed, 243 insertions(+), 1471 deletions(-) create mode 100644 Cargo.toml delete mode 100644 crates/rmenu-plugin/Cargo.toml delete mode 100644 crates/rmenu-plugin/src/cache.rs delete mode 100644 crates/rmenu-plugin/src/internal.rs delete mode 100644 crates/rmenu-plugin/src/lib.rs delete mode 100644 crates/rmenu/Cargo.toml delete mode 100644 crates/rmenu/src/config.rs delete mode 100644 crates/rmenu/src/exec.rs delete mode 100644 crates/rmenu/src/gui/mod.rs delete mode 100644 crates/rmenu/src/gui_old/icons.rs delete mode 100644 crates/rmenu/src/gui_old/mod.rs delete mode 100644 crates/rmenu/src/gui_old/page.rs delete mode 100644 crates/rmenu/src/main.rs delete mode 100644 crates/rmenu/src/plugins.rs delete mode 100644 plugins/drun/Cargo.toml delete mode 100644 plugins/drun/src/desktop.rs delete mode 100644 plugins/drun/src/lib.rs delete mode 100644 plugins/run/Cargo.toml delete mode 100644 plugins/run/src/lib.rs delete mode 100644 plugins/run/src/run.rs create mode 100644 rmenu-plugin/Cargo.toml create mode 100644 rmenu-plugin/src/lib.rs create mode 100644 rmenu/Cargo.toml create mode 100644 rmenu/public/default.css create mode 100644 rmenu/src/gui.rs create mode 100644 rmenu/src/main.rs diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..57aa7db --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,5 @@ +[workspace] +members = [ + "rmenu", + "rmenu-plugin" +] diff --git a/crates/rmenu-plugin/Cargo.toml b/crates/rmenu-plugin/Cargo.toml deleted file mode 100644 index d1982cd..0000000 --- a/crates/rmenu-plugin/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "rmenu-plugin" -version = "0.1.0" -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[features] -cache = ["dep:bincode", "dep:serde"] -rmenu_internals = ["dep:libloading"] - -[dependencies] -abi_stable = "0.11.1" -bincode = { version = "1.3.3", optional = true } -lastlog = { version = "0.2.3", features = ["libc"] } -libloading = { version = "0.7.4", optional = true } -serde = { version = "1.0.152", features = ["derive"], optional = true } diff --git a/crates/rmenu-plugin/src/cache.rs b/crates/rmenu-plugin/src/cache.rs deleted file mode 100644 index 67a175d..0000000 --- a/crates/rmenu-plugin/src/cache.rs +++ /dev/null @@ -1,196 +0,0 @@ -/* - * Plugin Optional Cache Implementation for Convenient Storage - */ -use std::collections::HashMap; -use std::io::{Error, ErrorKind, Result, Write}; -use std::path::Path; -use std::path::PathBuf; -use std::time::{Duration, SystemTime}; -use std::{env, fs}; - -use abi_stable::{std_types::RString, StableAbi}; -use lastlog::search_self; - -use super::{Entries, ModuleConfig}; - -/* Variables */ - -static HOME: &str = "HOME"; -static XDG_CACHE_HOME: &str = "XDG_CACHE_HOME"; - -/* Functions */ - -/// Retrieve xdg-cache directory or get default -pub fn get_cache_dir() -> std::result::Result { - let path = if let Ok(xdg) = env::var(XDG_CACHE_HOME) { - Path::new(&xdg).to_path_buf() - } else if let Ok(home) = env::var(HOME) { - Path::new(&home).join(".cache") - } else { - Path::new("/tmp/").to_path_buf() - }; - match path.join("rmenu").to_str() { - Some(s) => Ok(s.to_owned()), - None => Err(format!("Failed to read $XDG_CACHE_DIR or $HOME")), - } -} - -/// Retrieve standard cache-path variable from config or set to default -#[inline] -pub fn get_cache_dir_setting(cfg: &ModuleConfig) -> PathBuf { - Path::new( - match cfg.get("cache_path") { - Some(path) => RString::from(path.as_str()), - None => RString::from(get_cache_dir().unwrap()), - } - .as_str(), - ) - .to_path_buf() -} - -/// Retrieve standard cache-mode variable from config or set to default -#[inline] -pub fn get_cache_setting(cfg: &ModuleConfig, default: CacheSetting) -> CacheSetting { - cfg.get("cache_mode") - .unwrap_or(&RString::from("")) - .parse::() - .unwrap_or(default) -} - -/* Module */ - -/// Configured Module Cache settings -#[repr(C)] -#[derive(Debug, Clone, PartialEq, StableAbi)] -pub enum CacheSetting { - Never, - Forever, - OnLogin, - After(u64), -} - -impl std::str::FromStr for CacheSetting { - type Err = String; - - fn from_str(s: &str) -> std::result::Result { - if s.chars().all(char::is_numeric) { - let i = s.parse::().map_err(|e| format!("{e}"))?; - return Ok(CacheSetting::After(i)); - }; - match s { - "never" => Ok(CacheSetting::Never), - "forever" => Ok(CacheSetting::Forever), - "login" => Ok(CacheSetting::OnLogin), - _ => Err(format!("invalid value: {:?}", s)), - } - } -} - -/// Simple Entry cache implementation -pub struct Cache { - path: PathBuf, - cache: HashMap, -} - -impl Cache { - pub fn new(path: PathBuf) -> Self { - Self { - path, - cache: HashMap::new(), - } - } - - // attempt to read given cache entry from disk - fn load(&self, name: &str, valid: &CacheSetting) -> Result { - // skip all checks if caching is disabled - let expr_err = Error::new(ErrorKind::InvalidData, "cache expired"); - if valid == &CacheSetting::Never { - return Err(expr_err); - } - // check if the given path exists - let path = self.path.join(name); - if !path.is_file() { - return Err(Error::new(ErrorKind::NotFound, "no such file")); - } - // get last-modified date of file - let meta = path.metadata()?; - let modified = meta.modified()?; - // handle expiration based on cache-setting - match valid { - CacheSetting::Never => return Err(expr_err), - CacheSetting::Forever => {} - CacheSetting::OnLogin => { - // expire content if it was last modified before last login - if let Ok(record) = search_self() { - if let Some(lastlog) = record.last_login.into() { - if modified <= lastlog { - return Err(expr_err); - } - } - } - } - CacheSetting::After(secs) => { - // expire content if it was last modified longer than duration - let now = SystemTime::now(); - let duration = Duration::from_secs(*secs); - if now - duration >= modified { - return Err(expr_err); - } - } - }; - // load entries from bincode - let data = fs::read(path)?; - let entries: Entries = bincode::deserialize(&data).map_err(|_| ErrorKind::InvalidData)?; - Ok(entries) - } - - /// Returns true if the given key was found in cache memory - pub fn contains_key(&self, name: &str) -> bool { - self.cache.contains_key(name) - } - - /// Read all cached entries associated w/ the specified key - pub fn read(&mut self, name: &str, valid: &CacheSetting) -> Result<&Entries> { - // return cache entry if already cached - if self.cache.contains_key(name) { - return Ok(self.cache.get(name).expect("read failed cache grab")); - } - // load entries from cache on disk - let entries = self.load(name, valid)?; - self.cache.insert(name.to_owned(), entries); - Ok(self.cache.get(name).expect("cached entries are missing?")) - } - - /// Write all entries associated w/ the specified key to cache - pub fn write(&mut self, name: &str, valid: &CacheSetting, entries: Entries) -> Result<()> { - // write to runtime cache reguardless of cache settings - self.cache.insert(name.to_owned(), entries); - // skip caching if disabled - if valid == &CacheSetting::Never { - return Ok(()); - } - // retrieve entries passed to cache - let entries = self.cache.get(name).expect("write failed cache grab"); - // serialize entries and write to cache - let path = self.path.join(name); - let mut f = fs::File::create(path)?; - let data = bincode::serialize(entries).map_err(|_| ErrorKind::InvalidInput)?; - f.write_all(&data)?; - Ok(()) - } - - /// easy functional wrapper that ensures data is always loaded once and then cached - pub fn wrap(&mut self, name: &str, valid: &CacheSetting, func: F) -> Result - where - F: FnOnce() -> Entries, - { - let entries = match self.read(name, valid) { - Ok(entries) => entries, - Err(_) => { - self.write(name, valid, func())?; - self.read(name, valid)? - } - }; - Ok(entries.clone()) - } -} diff --git a/crates/rmenu-plugin/src/internal.rs b/crates/rmenu-plugin/src/internal.rs deleted file mode 100644 index f4388ec..0000000 --- a/crates/rmenu-plugin/src/internal.rs +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Internal Library Loading Implementation - */ -use libloading::{Error, Library, Symbol}; - -use super::{Module, ModuleConfig}; - -/* Types */ - -pub struct Plugin { - pub lib: Library, - pub module: Box, -} - -type LoadFunc = unsafe extern "C" fn(&ModuleConfig) -> Box; - -/* Functions */ - -pub unsafe fn load_plugin(path: &str, cfg: &ModuleConfig) -> Result { - // Load and initialize library - #[cfg(target_os = "linux")] - let lib: Library = { - // Load library with `RTLD_NOW | RTLD_NODELETE` to fix a SIGSEGV - // https://github.com/nagisa/rust_libloading/issues/41#issuecomment-448303856 - libloading::os::unix::Library::open(Some(path), 0x2 | 0x1000)?.into() - }; - #[cfg(not(target_os = "linux"))] - let lib = Library::new(path.as_ref())?; - // load module object and generate plugin - let module = lib.get::>(b"load_module")?(cfg); - Ok(Plugin { lib, module }) -} diff --git a/crates/rmenu-plugin/src/lib.rs b/crates/rmenu-plugin/src/lib.rs deleted file mode 100644 index be1c685..0000000 --- a/crates/rmenu-plugin/src/lib.rs +++ /dev/null @@ -1,67 +0,0 @@ -#[cfg(feature = "rmenu_internals")] -pub mod internal; - -#[cfg(feature = "cache")] -pub mod cache; - -use abi_stable::std_types::*; -use abi_stable::{sabi_trait, StableAbi}; - -#[cfg(feature = "cache")] -use serde::{Deserialize, Serialize}; - -/// Configured Entry Execution settings -#[repr(C)] -#[derive(Debug, Clone, StableAbi)] -#[cfg_attr(feature = "cache", derive(Serialize, Deserialize))] -pub enum Exec { - Command(RString), - Terminal(RString), -} - -/// Module search-entry Icon configuration -#[repr(C)] -#[derive(Debug, Clone, StableAbi)] -#[cfg_attr(feature = "cache", derive(Serialize, Deserialize))] -pub struct Icon { - pub name: RString, - pub path: RString, - pub data: RVec, -} - -/// Module single search-entry -#[repr(C)] -#[derive(Debug, Clone, StableAbi)] -#[cfg_attr(feature = "cache", derive(Serialize, Deserialize))] -pub struct Entry { - pub name: RString, - pub exec: Exec, - pub comment: ROption, - pub icon: ROption, -} - -/// Alias for FFI-safe vector of `Entry` object -pub type Entries = RVec; - -/// Alias for FFI-Safe hashmap for config entries -pub type ModuleConfig = RHashMap; - -/// Module trait abstraction for all search plugins -#[sabi_trait] -pub trait Module { - extern "C" fn name(&self) -> RString; - extern "C" fn prefix(&self) -> RString; - extern "C" fn search(&mut self, search: RString) -> Entries; -} - -/// Generates the required rmenu plugin resources -#[macro_export] -macro_rules! export_plugin { - ($module:ident) => { - #[no_mangle] - extern "C" fn load_module(config: &ModuleConfig) -> Box { - let inst = $module::new(config); - Box::new(inst) - } - }; -} diff --git a/crates/rmenu/Cargo.toml b/crates/rmenu/Cargo.toml deleted file mode 100644 index e37e107..0000000 --- a/crates/rmenu/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "rmenu" -version = "0.1.0" -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -abi_stable = "0.11.1" -clap = { version = "4.0.32", features = ["derive"] } -dioxus = "0.3.2" -dioxus-desktop = "0.3.0" -log = "0.4.17" -rmenu-plugin = { version = "0.1.0", path = "../rmenu-plugin", features = ["rmenu_internals"] } -serde = { version = "1.0.152", features = ["derive"] } -shellexpand = "3.0.0" -toml = "0.5.10" diff --git a/crates/rmenu/src/config.rs b/crates/rmenu/src/config.rs deleted file mode 100644 index 5347557..0000000 --- a/crates/rmenu/src/config.rs +++ /dev/null @@ -1,101 +0,0 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::{env, fs}; - -use rmenu_plugin::ModuleConfig; -use serde::{Deserialize, Serialize}; -use shellexpand::tilde; - -/* Variables */ - -static HOME: &str = "HOME"; -static XDG_CONIFG_HOME: &str = "XDG_CONIFG_HOME"; - -/* Types */ - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct PluginConfig { - pub prefix: String, - pub path: String, - pub config: ModuleConfig, -} - -#[derive(Serialize, Deserialize)] -pub struct RMenuConfig { - pub terminal: String, - pub icon_size: f32, - pub centered: Option, - pub window_pos: Option<[f32; 2]>, - pub window_size: Option<[f32; 2]>, - pub result_size: Option, - pub decorate_window: bool, -} - -#[derive(Serialize, Deserialize)] -pub struct Config { - pub rmenu: RMenuConfig, - pub plugins: HashMap, -} - -impl Default for Config { - fn default() -> Self { - Self { - rmenu: RMenuConfig { - terminal: "foot".to_owned(), - icon_size: 20.0, - centered: Some(true), - window_pos: None, - window_size: Some([500.0, 300.0]), - result_size: Some(15), - decorate_window: false, - }, - plugins: HashMap::new(), - } - } -} - -/* Functions */ - -#[inline] -fn get_config_dir() -> PathBuf { - if let Ok(config) = env::var(XDG_CONIFG_HOME) { - return Path::new(&config).join("rmenu").to_path_buf(); - } - if let Ok(home) = env::var(HOME) { - return Path::new(&home).join(".config").join("rmenu").to_path_buf(); - } - panic!("cannot find config directory!") -} - -pub fn load_config(path: Option) -> Config { - // determine path based on arguments - let fpath = match path.clone() { - Some(path) => Path::new(&tilde(&path).to_string()).to_path_buf(), - None => get_config_dir().join("config.toml"), - }; - // read existing file or write default and read it back - let mut config = match fpath.exists() { - false => { - // write default config to standard location - let config = Config::default(); - if path.is_none() { - let dir = get_config_dir(); - if !dir.exists() { - fs::create_dir(dir).expect("failed to make config dir"); - } - let default = toml::to_string(&config).unwrap(); - fs::write(fpath, default).expect("failed to write default config"); - } - config - } - true => { - let config = fs::read_to_string(fpath).expect("unable to read config"); - toml::from_str(&config).expect("broken config") - } - }; - // expand plugin paths - for plugin in config.plugins.values_mut() { - plugin.path = tilde(&plugin.path).to_string(); - } - config -} diff --git a/crates/rmenu/src/exec.rs b/crates/rmenu/src/exec.rs deleted file mode 100644 index e69de29..0000000 diff --git a/crates/rmenu/src/gui/mod.rs b/crates/rmenu/src/gui/mod.rs deleted file mode 100644 index ecc49a4..0000000 --- a/crates/rmenu/src/gui/mod.rs +++ /dev/null @@ -1,57 +0,0 @@ - -use dioxus::prelude::*; -use dioxus_desktop::{Config, WindowBuilder}; - -use rmenu_plugin::Entry; - -use crate::{config, plugins::Plugins}; - -/// Spawn GUI instance with the specified config and plugins -pub fn launch_gui(cfg: config::Config, plugins: Plugins) -> Result<(), String> { - // simple_logger::init_with_level(log::Level::Debug).unwrap(); - let size = cfg.rmenu.window_size.unwrap_or([550.0, 350.0]); - let gui = GUI::new(cfg, plugins); - dioxus_desktop::launch_cfg( - App, - Config::default().with_window(WindowBuilder::new().with_resizable(true).with_inner_size( - dioxus_desktop::wry::application::dpi::LogicalSize::new(size[0], size[1]), - )), - ); - Ok(()) -} - -struct GUI { - plugins: Plugins, - search: String, - config: config::Config, -} - -impl GUI { - pub fn new(config: config::Config, plugins: Plugins) -> Self { - Self { - config, - plugins, - search: "".to_owned(), - } - } - - fn search(&mut self, search: &str) -> Vec { - self.plugins.search(search) - } - - pub fn app(&mut self, cx: Scope) -> Element { - let results = self.search(""); - cx.render(rsx! { - div { - h1 { "Hello World!" } - result.iter().map(|entry| { - div { - div { entry.name } - div { entry.comment } - } - }) - } - }) - } - -} diff --git a/crates/rmenu/src/gui_old/icons.rs b/crates/rmenu/src/gui_old/icons.rs deleted file mode 100644 index a692e45..0000000 --- a/crates/rmenu/src/gui_old/icons.rs +++ /dev/null @@ -1,94 +0,0 @@ -/* - * GUI Icon Cache/Loading utilities - */ -use std::sync::Arc; -use std::thread; - -use dashmap::{mapref::one::Ref, DashMap}; -use egui_extras::RetainedImage; -use log::debug; - -use rmenu_plugin::{Entry, Icon}; - -/* Types */ - -type Cache = DashMap; -type IconRef<'a> = Ref<'a, String, RetainedImage>; - -/* Functions */ - -// load result entry icons into cache in background w/ given chunk-size per thread -pub fn load_images(cache: &mut IconCache, chunk_size: usize, results: &Vec) { - // retrieve icons from results - let icons: Vec = results - .iter() - .filter_map(|r| r.icon.clone().into()) - .collect(); - for chunk in icons.chunks(chunk_size).into_iter() { - cache.save_background(Vec::from(chunk)); - } -} - -/* Cache */ - -// spawn multiple threads to load image objects into cache from search results - -pub struct IconCache { - c: Arc, -} - -impl IconCache { - pub fn new() -> Self { - Self { - c: Arc::new(Cache::new()), - } - } - - // save icon to cache if not already saved - pub fn save(&mut self, icon: &Icon) -> Result<(), String> { - let name = icon.name.as_str(); - let path = icon.path.as_str(); - if !self.c.contains_key(name) { - self.c.insert( - name.to_owned(), - if path.ends_with(".svg") { - RetainedImage::from_svg_bytes(path, &icon.data)? - } else { - RetainedImage::from_image_bytes(path, &icon.data)? - }, - ); - } - Ok(()) - } - - // load an image from the given icon-cache - #[inline] - pub fn load(&mut self, icon: &Icon) -> Result, String> { - self.save(icon)?; - Ok(self - .c - .get(icon.name.as_str()) - .expect("failed to load saved image")) - } - - // save list of icon-entries in the background - pub fn save_background(&mut self, icons: Vec) { - let mut cache = self.clone(); - thread::spawn(move || { - for icon in icons.iter() { - if let Err(err) = cache.save(&icon) { - debug!("icon {} failed to load: {}", icon.name.as_str(), err); - }; - } - debug!("background task loaded {} icons", icons.len()); - }); - } -} - -impl Clone for IconCache { - fn clone(&self) -> Self { - Self { - c: Arc::clone(&self.c), - } - } -} diff --git a/crates/rmenu/src/gui_old/mod.rs b/crates/rmenu/src/gui_old/mod.rs deleted file mode 100644 index 408e353..0000000 --- a/crates/rmenu/src/gui_old/mod.rs +++ /dev/null @@ -1,188 +0,0 @@ -/*! - * Rmenu - Egui implementation - */ -use std::process::exit; - -use eframe::egui; - -mod icons; -mod page; -use icons::{load_images, IconCache}; -use page::Paginator; - -use crate::{config::Config, plugins::Plugins}; - -// v1: -//TODO: fix grid so items expand entire length of window -//TODO: remove prefix and name specification from module definition -//TODO: allow specifying prefix in search to limit enabled plugins -//TODO: build in the actual execute and close part -//TODO: build compilation and install script for easy setup -//TODO: allow for close-on-defocus option in config? - -// v2: -//TODO: look into dynamic rendering w/ a custom style config - maybe even css? -//TODO: add additonal plugins: file-browser, browser-url, etc... - -/* Function */ - -// spawn gui application and run it -pub fn launch_gui(cfg: Config, plugins: Plugins) -> Result<(), eframe::Error> { - let pos = match cfg.rmenu.window_pos { - Some(pos) => Some(egui::pos2(pos[0], pos[1])), - None => None, - }; - let size = cfg.rmenu.window_size.unwrap_or([550.0, 350.0]); - let options = eframe::NativeOptions { - transparent: true, - always_on_top: true, - decorated: cfg.rmenu.decorate_window, - centered: cfg.rmenu.centered.unwrap_or(false), - initial_window_pos: pos, - initial_window_size: Some(egui::vec2(size[0], size[1])), - ..Default::default() - }; - let gui = GUI::new(cfg, plugins); - eframe::run_native("rmenu", options, Box::new(|_cc| Box::new(gui))) -} - -/* Implementation */ - -struct GUI { - plugins: Plugins, - search: String, - images: IconCache, - config: Config, - page: Paginator, -} - -impl GUI { - pub fn new(config: Config, plugins: Plugins) -> Self { - let mut gui = Self { - plugins, - search: "".to_owned(), - images: IconCache::new(), - page: Paginator::new(config.rmenu.result_size.clone().unwrap_or(15)), - config, - }; - // pre-run empty search to generate cache - gui.search(); - gui - } - - // complete search based on current internal search variable - fn search(&mut self) { - let results = self.plugins.search(&self.search); - if results.len() > 0 { - load_images(&mut self.images, 20, &results); - } - self.page.reset(results); - } - - #[inline] - fn keyboard(&mut self, ctx: &egui::Context) { - // tab / ctrl+tab controls - if ctx.input().key_pressed(egui::Key::Tab) { - match ctx.input().modifiers.ctrl { - true => self.page.focus_up(1), - false => self.page.focus_down(1), - }; - } - // arrow-key controls - if ctx.input().key_pressed(egui::Key::ArrowUp) { - self.page.focus_up(1); - } - if ctx.input().key_pressed(egui::Key::ArrowDown) { - self.page.focus_down(1) - } - // pageup / pagedown controls - if ctx.input().key_pressed(egui::Key::PageUp) { - self.page.focus_up(5); - } - if ctx.input().key_pressed(egui::Key::PageDown) { - self.page.focus_down(5); - } - // exit controls - if ctx.input().key_pressed(egui::Key::Escape) { - exit(1); - } - } -} - -impl GUI { - // implement simple topbar searchbar - #[inline] - fn simple_search(&mut self, ui: &mut egui::Ui) { - let size = ui.available_size(); - ui.horizontal(|ui| { - ui.spacing_mut().text_edit_width = size.x; - let search = egui::TextEdit::singleline(&mut self.search).frame(false); - let object = ui.add(search); - if object.changed() { - self.search(); - } - }); - } - - // check if results contain any icons at all - #[inline] - fn has_icons(&self) -> bool { - self.page - .iter() - .filter(|r| r.icon.is_some()) - .peekable() - .peek() - .is_some() - } - - #[inline] - fn grid_highlight(&self) -> Box Option> { - let focus = self.page.row_focus(); - Box::new(move |row, style| { - if row == focus { - return Some(egui::Rgba::from(style.visuals.faint_bg_color)); - } - None - }) - } - - // implement simple scrolling grid-based results pannel - #[inline] - fn simple_results(&mut self, ui: &mut egui::Ui) { - let results = self.page.iter(); - let has_icons = self.has_icons(); - egui::Grid::new("results") - .with_row_color(self.grid_highlight()) - .show(ui, |ui| { - for record in results { - // render icons (if any were present in set) - if has_icons { - ui.horizontal(|ui| { - if let Some(icon) = record.icon.as_ref().into_option() { - if let Ok(image) = self.images.load(&icon) { - let xy = self.config.rmenu.icon_size; - image.show_size(ui, egui::vec2(xy, xy)); - } - } - }); - } - // render content - ui.label(record.name.as_str()); - if let Some(comment) = record.comment.as_ref().into_option() { - ui.label(comment.as_str()); - } - ui.end_row(); - } - }); - } -} - -impl eframe::App for GUI { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - egui::CentralPanel::default().show(ctx, |ui| { - self.keyboard(ctx); - self.simple_search(ui); - self.simple_results(ui); - }); - } -} diff --git a/crates/rmenu/src/gui_old/page.rs b/crates/rmenu/src/gui_old/page.rs deleted file mode 100644 index fe6049f..0000000 --- a/crates/rmenu/src/gui_old/page.rs +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Result Paginator Implementation - */ -use std::cmp::min; - -use rmenu_plugin::Entry; - -/// Plugin results paginator implementation -pub struct Paginator { - page: usize, - page_size: usize, - results: Vec, - focus: usize, -} - -impl Paginator { - pub fn new(page_size: usize) -> Self { - Self { - page: 0, - page_size, - results: vec![], - focus: 0, - } - } - - #[inline(always)] - fn lower_bound(&self) -> usize { - self.page * self.page_size - } - - #[inline(always)] - fn upper_bound(&self) -> usize { - (self.page + 1) * self.page_size - } - - fn set_focus(&mut self, focus: usize) { - self.focus = focus; - if self.focus < self.lower_bound() { - self.page -= 1; - } - if self.focus >= self.upper_bound() { - self.page += 1; - } - } - - /// reset paginator location and replace internal results - pub fn reset(&mut self, results: Vec) { - self.page = 0; - self.focus = 0; - self.results = results; - } - - /// calculate zeroed focus based on index in iterator - #[inline] - pub fn row_focus(&self) -> usize { - self.focus - self.lower_bound() - } - - /// shift focus up a certain number of rows - #[inline] - pub fn focus_up(&mut self, shift: usize) { - self.set_focus(self.focus - min(shift, self.focus)); - } - - /// shift focus down a certain number of rows - pub fn focus_down(&mut self, shift: usize) { - let results = self.results.len(); - let max_pos = if results > 0 { results - 1 } else { 0 }; - self.set_focus(min(self.focus + shift, max_pos)); - } - - /// Generate page-size iterator - #[inline] - pub fn iter(&self) -> PageIter { - PageIter::new(self.lower_bound(), self.upper_bound(), &self.results) - } -} - -/// Paginator bounds iterator implementation -pub struct PageIter<'a> { - stop: usize, - cursor: usize, - results: &'a Vec, -} - -impl<'a> PageIter<'a> { - pub fn new(start: usize, stop: usize, results: &'a Vec) -> Self { - Self { - stop, - results, - cursor: start, - } - } -} - -impl<'a> Iterator for PageIter<'a> { - type Item = &'a Entry; - - fn next(&mut self) -> Option { - if self.cursor >= self.stop { - return None; - } - let result = self.results.get(self.cursor); - self.cursor += 1; - result - } -} diff --git a/crates/rmenu/src/main.rs b/crates/rmenu/src/main.rs deleted file mode 100644 index 63f2a90..0000000 --- a/crates/rmenu/src/main.rs +++ /dev/null @@ -1,55 +0,0 @@ -use clap::Parser; - -mod config; -mod gui; -mod plugins; - -use config::{load_config, PluginConfig}; -use gui::launch_gui; -use plugins::Plugins; - -/* Types */ - -#[derive(Parser)] -#[command(author, version, about, long_about = None)] -struct Args { - /// configuration file to read from - #[arg(short, long)] - pub config: Option, - /// terminal command override - #[arg(short, long)] - pub term: Option, - /// declared and enabled plugin modes - #[arg(short, long)] - pub show: Option>, -} - -fn main() { - // parse cli-args and use it to load the config - let args = Args::parse(); - let mut config = load_config(args.config); - // update config based on other cli-args - if let Some(term) = args.term.as_ref() { - config.rmenu.terminal = term.to_owned() - } - // load relevant plugins based on configured options - let enabled = args.show.unwrap_or_else(|| vec!["drun".to_owned()]); - let plugin_configs: Vec = config - .plugins - .clone() - .into_iter() - .filter(|(k, _)| enabled.contains(k)) - .map(|(_, v)| v) - .collect(); - // error if plugins-list is empty - if plugin_configs.len() != enabled.len() { - let missing: Vec<&String> = enabled - .iter() - .filter(|p| !config.plugins.contains_key(p.as_str())) - .collect(); - panic!("no plugin configurations for: {:?}", missing); - } - // spawn gui instance w/ config and enabled plugins - let plugins = Plugins::new(enabled, plugin_configs); - launch_gui(config, plugins).expect("gui crashed") -} diff --git a/crates/rmenu/src/plugins.rs b/crates/rmenu/src/plugins.rs deleted file mode 100644 index 1b876ea..0000000 --- a/crates/rmenu/src/plugins.rs +++ /dev/null @@ -1,33 +0,0 @@ -use abi_stable::std_types::RString; -use rmenu_plugin::internal::{load_plugin, Plugin}; -use rmenu_plugin::Entry; - -use super::config::PluginConfig; - -/// Convenient wrapper used to execute configured plugins -pub struct Plugins { - plugins: Vec, -} - -impl Plugins { - pub fn new(enable: Vec, plugins: Vec) -> Self { - Self { - plugins: plugins - .into_iter() - .map(|p| unsafe { load_plugin(&p.path, &p.config) }.expect("failed to load plugin")) - .filter(|plugin| enable.contains(&plugin.module.name().as_str().to_owned())) - .collect(), - } - } - - /// complete search w/ the configured plugins - pub fn search(&mut self, search: &str) -> Vec { - let mut entries = vec![]; - for plugin in self.plugins.iter_mut() { - let found = plugin.module.search(RString::from(search)); - entries.append(&mut found.into()); - continue; - } - entries - } -} diff --git a/plugins/drun/Cargo.toml b/plugins/drun/Cargo.toml deleted file mode 100644 index 1303f60..0000000 --- a/plugins/drun/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "drun" -version = "0.1.0" -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[lib] -crate-type = ["cdylib", "rlib"] - -[dependencies] -abi_stable = "0.11.1" -freedesktop_entry_parser = "1.3.0" -regex = "1.7.0" -rmenu-plugin = { version = "0.1.0", path = "../../crates/rmenu-plugin", features = ["cache"] } -walkdir = "2.3.2" diff --git a/plugins/drun/src/desktop.rs b/plugins/drun/src/desktop.rs deleted file mode 100644 index d2975ec..0000000 --- a/plugins/drun/src/desktop.rs +++ /dev/null @@ -1,136 +0,0 @@ -use std::collections::HashMap; -use std::fs; - -use abi_stable::std_types::{RNone, ROption, RSome, RString, RVec}; -use freedesktop_entry_parser::parse_entry; -use walkdir::{DirEntry, WalkDir}; - -use rmenu_plugin::*; - -/* Types */ - -#[derive(Debug)] -struct IconFile { - name: String, - path: String, - size: u64, -} - -/* Functions */ - -// filter out invalid icon entries -fn is_icon(entry: &DirEntry) -> bool { - entry.file_type().is_dir() - || entry - .file_name() - .to_str() - .map(|s| s.ends_with(".svg") || s.ends_with(".png")) - .unwrap_or(false) -} - -// filter out invalid desktop entries -fn is_desktop(entry: &DirEntry) -> bool { - entry.file_type().is_dir() - || entry - .file_name() - .to_str() - .map(|s| s.ends_with(".desktop")) - .unwrap_or(false) -} - -// correlate name w/ best matched icon -#[inline] -fn match_icon(name: &str, icons: &Vec) -> Option { - for icon in icons.iter() { - if icon.name == name { - return Some(icon.path.to_owned()); - } - let Some((fname, _)) = icon.name.rsplit_once('.') else { continue }; - if fname == name { - return Some(icon.path.to_owned()); - } - } - None -} - -// correlate name w/ best matched icon and read into valid entry -#[inline] -fn read_icon(name: &str, icons: &Vec) -> Option { - let path = match_icon(name, icons)?; - let Ok(data) = fs::read(&path) else { return None }; - Some(Icon { - name: RString::from(name), - path: RString::from(path), - data: RVec::from(data), - }) -} - -// retrieve master-list of all possible xdg-application entries from filesystem -pub fn load_entries(app_paths: &Vec, icon_paths: &Vec) -> Vec { - // iterate and collect all existing icon paths - let mut imap: HashMap = HashMap::new(); - for path in icon_paths.into_iter() { - let walker = WalkDir::new(path).into_iter(); - for entry in walker.filter_entry(is_icon) { - let Ok(dir) = entry else { continue }; - let Ok(meta) = dir.metadata() else { continue }; - let Some(name) = dir.file_name().to_str() else { continue }; - let Some(path) = dir.path().to_str() else { continue; }; - let size = meta.len(); - // find the biggest icon file w/ the same name - let pathstr = path.to_owned(); - if let Some(icon) = imap.get_mut(name) { - if icon.size < size { - icon.path = pathstr; - icon.size = size; - } - continue; - } - imap.insert( - name.to_owned(), - IconFile { - name: name.to_owned(), - path: pathstr, - size, - }, - ); - } - } - // parse application entries - let icons = imap.into_values().collect(); - let mut entries = vec![]; - for path in app_paths.into_iter() { - let walker = WalkDir::new(path).into_iter(); - for entry in walker.filter_entry(is_desktop) { - let Ok(dir) = entry else { continue }; - let Ok(file) = parse_entry(dir.path()) else { continue }; - let desktop = file.section("Desktop Entry"); - let Some(name) = desktop.attr("Name") else { continue }; - let Some(exec) = desktop.attr("Exec") else { continue }; - let terminal = desktop.attr("Terminal").unwrap_or("") == "true"; - // parse icon - let icon = match desktop.attr("Icon") { - Some(name) => ROption::from(read_icon(name, &icons)), - None => RNone, - }; - // parse comment - let comment = match desktop.attr("Comment") { - Some(attr) => RSome(RString::from(attr)), - None => RNone, - }; - // convert exec/terminal into command - let command = match terminal { - true => Exec::Terminal(RString::from(exec)), - false => Exec::Command(RString::from(exec)), - }; - // generate entry - entries.push(Entry { - name: RString::from(name), - exec: command, - comment, - icon, - }); - } - } - entries -} diff --git a/plugins/drun/src/lib.rs b/plugins/drun/src/lib.rs deleted file mode 100644 index cc5092e..0000000 --- a/plugins/drun/src/lib.rs +++ /dev/null @@ -1,156 +0,0 @@ -use std::env; -use std::io::Result; -use std::path::{Path, PathBuf}; - -use abi_stable::std_types::*; -use regex::{Regex, RegexBuilder}; -use rmenu_plugin::{cache::*, *}; - -mod desktop; -use desktop::load_entries; - -/* Variables */ - -static NAME: &str = "drun"; -static PREFIX: &str = "app"; - -static XDG_DATA_DIRS: &str = "XDG_DATA_DIRS"; - -static DEFAULT_XDG_PATHS: &str = "/usr/share/:/usr/local/share"; -static DEFAULT_APP_PATHS: &str = ""; -static DEFAULT_ICON_PATHS: &str = "/usr/share/pixmaps/"; - -/* Functions */ - -// parse path string into separate path entries -#[inline] -fn parse_config_paths(paths: String) -> Vec { - env::split_paths(&paths) - .map(|s| s.to_str().expect("invalid path").to_owned()) - .collect() -} - -// retrieve default xdg-paths using xdg environment variable when possible -#[inline] -fn default_xdg_paths() -> String { - if let Ok(paths) = env::var(XDG_DATA_DIRS) { - return paths.to_owned(); - } - DEFAULT_XDG_PATHS.to_owned() -} - -// append joined xdg-paths to app/icon path results -#[inline] -fn apply_paths(join: &str, paths: &Vec) -> Vec { - paths - .iter() - .map(|s| { - Path::new(s) - .join(join) - .to_str() - .expect("Unable to join PATH") - .to_owned() - }) - .collect() -} - -// regex validate if the following entry matches the given regex expression -#[inline] -pub fn is_match(entry: &Entry, search: &Regex) -> bool { - if search.is_match(&entry.name) { - return true; - }; - if let RSome(comment) = entry.comment.as_ref() { - return search.is_match(&comment); - } - false -} - -/* Macros */ - -macro_rules! pathify { - ($cfg:expr, $key:expr, $other:expr) => { - parse_config_paths(match $cfg.get($key) { - Some(path) => path.as_str().to_owned(), - None => $other, - }) - }; -} - -/* Plugin */ - -struct Settings { - xdg_paths: Vec, - app_paths: Vec, - icon_paths: Vec, - cache_path: PathBuf, - cache_mode: CacheSetting, - ignore_case: bool, -} - -struct DesktopRun { - cache: Cache, - settings: Settings, -} - -impl DesktopRun { - pub fn new(cfg: &ModuleConfig) -> Self { - let settings = Settings { - xdg_paths: pathify!(cfg, "xdg_paths", default_xdg_paths()), - app_paths: pathify!(cfg, "app_paths", DEFAULT_APP_PATHS.to_owned()), - icon_paths: pathify!(cfg, "icon_paths", DEFAULT_ICON_PATHS.to_owned()), - ignore_case: cfg - .get("ignore_case") - .unwrap_or(&RString::from("true")) - .parse() - .unwrap_or(true), - cache_path: get_cache_dir_setting(cfg), - cache_mode: get_cache_setting(cfg, CacheSetting::OnLogin), - }; - Self { - cache: Cache::new(settings.cache_path.clone()), - settings, - } - } - - fn load(&mut self) -> Result { - self.cache.wrap(NAME, &self.settings.cache_mode, || { - // configure paths w/ xdg-paths - let mut app_paths = apply_paths("applications", &self.settings.xdg_paths); - let mut icon_paths = apply_paths("icons", &self.settings.xdg_paths); - app_paths.append(&mut self.settings.app_paths.clone()); - icon_paths.append(&mut self.settings.icon_paths.clone()); - // generate search results - let mut entries = load_entries(&app_paths, &icon_paths); - entries.sort_by_cached_key(|s| s.name.to_owned()); - RVec::from(entries) - }) - } -} - -impl Module for DesktopRun { - extern "C" fn name(&self) -> RString { - RString::from(NAME) - } - extern "C" fn prefix(&self) -> RString { - RString::from(PREFIX) - } - extern "C" fn search(&mut self, search: RString) -> Entries { - // compile regex expression for the given search - let mut matches = RVec::new(); - let Ok(rgx) = RegexBuilder::new(search.as_str()) - .case_insensitive(self.settings.ignore_case) - .build() else { return matches }; - // retrieve entries based on declared modes - let Ok(entries) = self.load() else { return matches }; - // search existing entries for matching regex expr - for entry in entries.into_iter() { - if is_match(&entry, &rgx) { - matches.push(entry); - } - } - matches - } -} - -export_plugin!(DesktopRun); diff --git a/plugins/run/Cargo.toml b/plugins/run/Cargo.toml deleted file mode 100644 index b6beef0..0000000 --- a/plugins/run/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "run" -version = "0.1.0" -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[lib] -crate-type = ["cdylib", "rlib"] - -[dependencies] -abi_stable = "0.11.1" -regex = "1.7.0" -rmenu-plugin = { version = "0.1.0", path = "../../crates/rmenu-plugin", features = ["cache"] } -walkdir = "2.3.2" diff --git a/plugins/run/src/lib.rs b/plugins/run/src/lib.rs deleted file mode 100644 index 48dcd2f..0000000 --- a/plugins/run/src/lib.rs +++ /dev/null @@ -1,105 +0,0 @@ -/*! - * Binary/Executable App Search Module - */ - -use std::env; -use std::io::Result; -use std::path::PathBuf; - -use abi_stable::std_types::{RString, RVec}; -use regex::RegexBuilder; -use rmenu_plugin::{cache::*, *}; - -mod run; -use run::find_executables; - -/* Variables */ - -static NAME: &str = "run"; -static PREFIX: &str = "exec"; - -static PATH_VAR: &str = "PATH"; - -/* Functions */ - -// parse path string into separate path entries -#[inline] -fn parse_config_paths(paths: &str) -> Vec { - env::split_paths(paths) - .map(|s| s.to_str().expect("invalid path").to_owned()) - .collect() -} - -// get all paths listed in $PATH env variable -#[inline] -fn get_paths() -> Vec { - parse_config_paths(&env::var(PATH_VAR).expect("Unable to read $PATH")) -} - -/* Module */ - -struct Settings { - paths: Vec, - cache_path: PathBuf, - cache_mode: CacheSetting, - ignore_case: bool, -} - -struct Run { - cache: Cache, - settings: Settings, -} - -impl Run { - pub fn new(cfg: &ModuleConfig) -> Self { - let settings = Settings { - ignore_case: cfg - .get("ignore_case") - .unwrap_or(&RString::from("true")) - .parse() - .unwrap_or(true), - paths: match cfg.get("paths") { - Some(paths) => parse_config_paths(paths.as_str()), - None => get_paths(), - }, - cache_path: get_cache_dir_setting(cfg), - cache_mode: get_cache_setting(cfg, CacheSetting::After(30)), - }; - Run { - cache: Cache::new(settings.cache_path.clone()), - settings, - } - } - - fn load(&mut self) -> Result { - self.cache.wrap(NAME, &self.settings.cache_mode, || { - RVec::from(find_executables(&self.settings.paths)) - }) - } -} - -impl Module for Run { - extern "C" fn name(&self) -> RString { - RString::from(NAME) - } - extern "C" fn prefix(&self) -> RString { - RString::from(PREFIX) - } - extern "C" fn search(&mut self, search: RString) -> Entries { - // compile regex expression for the given search - let mut matches = RVec::new(); - let Ok(rgx) = RegexBuilder::new(search.as_str()) - .case_insensitive(self.settings.ignore_case) - .build() else { return matches }; - // load entries and evaluate matches - let entries = self.load().expect("failed to parse through $PATH"); - for entry in entries.into_iter() { - if rgx.is_match(entry.name.as_str()) { - matches.push(entry); - } - } - matches - } -} - -export_plugin!(Run); diff --git a/plugins/run/src/run.rs b/plugins/run/src/run.rs deleted file mode 100644 index 790a658..0000000 --- a/plugins/run/src/run.rs +++ /dev/null @@ -1,79 +0,0 @@ -use std::collections::HashMap; -use std::os::unix::fs::PermissionsExt; - -use abi_stable::std_types::{RNone, RString}; -use rmenu_plugin::{Entry, Exec}; -use walkdir::{DirEntry, WalkDir}; - -/* Functions */ - -// check if file is executable -fn is_exec(entry: &DirEntry) -> bool { - if entry.file_type().is_dir() { - return true; - } - let Ok(meta) = entry.metadata() else { return false }; - meta.permissions().mode() & 0o111 != 0 -} - -// find all executables within the given paths -#[inline] -pub fn find_executables(paths: &Vec) -> Vec { - let mut execs: HashMap = HashMap::new(); - for path in paths.iter() { - let walker = WalkDir::new(path).into_iter(); - for entry in walker.filter_entry(is_exec) { - let Ok(dir) = entry else { continue }; - let Some(name) = dir.file_name().to_str() else { continue }; - let Some(path) = dir.path().to_str() else { continue; }; - // check if entry already exists but replace on longer path - if let Some(entry) = execs.get(name) { - if let Exec::Terminal(ref exec) = entry.exec { - if exec.len() >= path.len() { - continue; - } - } - } - execs.insert( - name.to_owned(), - Entry { - name: RString::from(name), - exec: Exec::Terminal(RString::from(path)), - comment: RNone, - icon: RNone, - }, - ); - } - } - execs.into_values().collect() -} - -/* Module */ - -// pub struct RunModule {} -// -// impl Module for RunModule { -// fn name(&self) -> &str { -// "run" -// } -// -// fn mode(&self) -> Mode { -// Mode::Run -// } -// -// fn cache_setting(&self, settings: &Settings) -> Cache { -// match settings.run.cache.as_ref() { -// Some(cache) => cache.clone(), -// None => Cache::After(Duration::new(30, 0)), -// } -// } -// -// fn load(&self, settings: &Settings) -> Vec { -// let cfg = &settings.run; -// let paths = match cfg.paths.as_ref() { -// Some(paths) => paths.clone(), -// None => get_paths(), -// }; -// find_executables(&paths) -// } -// } diff --git a/rmenu-plugin/Cargo.toml b/rmenu-plugin/Cargo.toml new file mode 100644 index 0000000..c20b9ba --- /dev/null +++ b/rmenu-plugin/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "rmenu-plugin" +version = "0.0.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +serde = { version = "1.0.171", features = ["derive"] } diff --git a/rmenu-plugin/src/lib.rs b/rmenu-plugin/src/lib.rs new file mode 100644 index 0000000..b4e20a5 --- /dev/null +++ b/rmenu-plugin/src/lib.rs @@ -0,0 +1,33 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub enum Method { + Terminal, + Desktop, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub struct Action { + pub exec: String, + pub comment: Option, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub struct Entry { + pub name: String, + pub actions: BTreeMap, + pub comment: Option, + pub icon: Option, +} + +impl Entry { + pub fn new(name: &str) -> Self { + Self { + name: name.to_owned(), + actions: Default::default(), + comment: Default::default(), + icon: Default::default(), + } + } +} diff --git a/rmenu/Cargo.toml b/rmenu/Cargo.toml new file mode 100644 index 0000000..8c3d0fc --- /dev/null +++ b/rmenu/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "rmenu" +version = "0.0.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +clap = { version = "4.3.15", features = ["derive"] } +dioxus = "0.3.2" +rmenu-plugin = { version = "0.0.0", path = "../rmenu-plugin" } +serde_json = "1.0.103" + +[target.'cfg(any(unix, windows))'.dependencies] +dioxus-desktop = { version = "0.3.0" } + +[target.'cfg(target_family = "wasm")'.dependencies] +dioxus-web = { version = "0.3.1" } diff --git a/rmenu/public/default.css b/rmenu/public/default.css new file mode 100644 index 0000000..48f3270 --- /dev/null +++ b/rmenu/public/default.css @@ -0,0 +1,40 @@ +main { + display: flex; + flex-direction: column; + justify-content: center; +} + +input { + min-width: 99%; +} + +div.result { + display: flex; + align-items: center; + justify-content: left; +} + +div.result > div { + margin: 2px 5px; +} + +div.result > div.icon { + width: 4%; + overflow: hidden; + display: flex; + justify-content: center; +} + +div.result > div.icon > img { + width: 100%; + height: 100%; + object-fit: cover; +} + +div.result > div.name { + width: 30%; +} + +div.result > div.comment { + flex: 1; +} diff --git a/rmenu/src/gui.rs b/rmenu/src/gui.rs new file mode 100644 index 0000000..9844481 --- /dev/null +++ b/rmenu/src/gui.rs @@ -0,0 +1,69 @@ +#![allow(non_snake_case)] +use dioxus::prelude::*; +use rmenu_plugin::Entry; + +use crate::App; + +pub fn run(app: App) { + #[cfg(target_family = "wasm")] + dioxus_web::launch(App, app, dioxus_web::Config::default()); + + #[cfg(any(windows, unix))] + dioxus_desktop::launch_with_props(App, app, dioxus_desktop::Config::default()); +} + +#[derive(PartialEq, Props)] +struct GEntry<'a> { + o: &'a Entry, +} + +fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { + cx.render(rsx! { + div { + class: "result", + div { + class: "icon", + if let Some(icon) = cx.props.o.icon.as_ref() { + cx.render(rsx! { img { src: "{icon}" } }) + } + } + div { + class: "name", + "{cx.props.o.name}" + } + div { + class: "comment", + if let Some(comment) = cx.props.o.comment.as_ref() { + format!("- {comment}") + } + } + } + }) +} + +fn App(cx: Scope) -> Element { + let search = use_state(cx, || "".to_string()); + let results = &cx.props.entries; + let searchstr = search.as_str(); + let results_rendered = results + .iter() + .filter(|entry| { + if entry.name.contains(searchstr) { + return true; + } + if let Some(comment) = entry.comment.as_ref() { + return comment.contains(searchstr); + } + false + }) + .map(|entry| cx.render(rsx! { TableEntry{ o: entry } })); + + cx.render(rsx! { + style { "{cx.props.css}" } + input { + value: "{search}", + oninput: move |evt| search.set(evt.value.clone()), + } + results_rendered + }) +} diff --git a/rmenu/src/main.rs b/rmenu/src/main.rs new file mode 100644 index 0000000..1708deb --- /dev/null +++ b/rmenu/src/main.rs @@ -0,0 +1,69 @@ +use std::fs::{read_to_string, File}; +use std::io::{prelude::*, BufReader, Error}; + +mod gui; + +use clap::*; +use rmenu_plugin::Entry; + +/// Rofi Clone (Built with Rust) +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +#[command(propagate_version = true)] +pub struct Args { + #[arg(short, long, default_value_t=String::from("-"))] + input: String, + #[arg(short, long)] + json: bool, + #[arg(short, long)] + msgpack: bool, + #[arg(short, long)] + run: Vec, + #[arg(long)] + css: Option, +} + +//TODO: improve search w/ options for regex/case-insensivity/modes? +//TODO: improve looks and css + +/// Application State for GUI +#[derive(Debug, PartialEq)] +pub struct App { + css: String, + name: String, + entries: Vec, +} + +fn default(args: &Args) -> Result { + // read entries from specified input + let fpath = if args.input == "-" { + "/dev/stdin" + } else { + &args.input + }; + let file = File::open(fpath)?; + let reader = BufReader::new(file); + let mut entries = vec![]; + for line in reader.lines() { + let entry = serde_json::from_str::(&line?)?; + entries.push(entry); + } + // generate app object based on configured args + let css = args + .css + .clone() + .unwrap_or("rmenu/public/default.css".to_owned()); + let args = App { + name: "default".to_string(), + css: read_to_string(css)?, + entries, + }; + Ok(args) +} + +fn main() { + let cli = Args::parse(); + let app = default(&cli).unwrap(); + println!("{:?}", app); + gui::run(app); +} From 7b5633b82cc4a63427ca3f0eed6ceafbea72429b Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Tue, 18 Jul 2023 15:55:08 -0700 Subject: [PATCH 03/37] feat: better internal tracking, added keyboard support --- rmenu/Cargo.toml | 14 ++++---- rmenu/public/default.css | 4 +++ rmenu/src/config.rs | 16 +++++++++ rmenu/src/gui.rs | 70 ++++++++++++++++++++++++++++------------ rmenu/src/main.rs | 18 ++++++++--- rmenu/src/search.rs | 49 ++++++++++++++++++++++++++++ rmenu/src/state.rs | 68 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 207 insertions(+), 32 deletions(-) create mode 100644 rmenu/src/config.rs create mode 100644 rmenu/src/search.rs create mode 100644 rmenu/src/state.rs diff --git a/rmenu/Cargo.toml b/rmenu/Cargo.toml index 8c3d0fc..ff167d0 100644 --- a/rmenu/Cargo.toml +++ b/rmenu/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "rmenu" +name = "rmenu" version = "0.0.0" edition = "2021" @@ -7,12 +7,10 @@ edition = "2021" [dependencies] clap = { version = "4.3.15", features = ["derive"] } -dioxus = "0.3.2" +dioxus = { git = "https://github.com/DioxusLabs/dioxus", version = "0.3.2" } +dioxus-desktop = { git = "https://github.com/DioxusLabs/dioxus", version = "0.3.0" } +keyboard-types = "0.6.2" +regex = { version = "1.9.1", features = ["pattern"] } rmenu-plugin = { version = "0.0.0", path = "../rmenu-plugin" } +serde = { version = "1.0.171", features = ["derive"] } serde_json = "1.0.103" - -[target.'cfg(any(unix, windows))'.dependencies] -dioxus-desktop = { version = "0.3.0" } - -[target.'cfg(target_family = "wasm")'.dependencies] -dioxus-web = { version = "0.3.1" } diff --git a/rmenu/public/default.css b/rmenu/public/default.css index 48f3270..6d90509 100644 --- a/rmenu/public/default.css +++ b/rmenu/public/default.css @@ -14,6 +14,10 @@ div.result { justify-content: left; } +div.selected { + background-color: lightblue; +} + div.result > div { margin: 2px 5px; } diff --git a/rmenu/src/config.rs b/rmenu/src/config.rs new file mode 100644 index 0000000..b0a69ed --- /dev/null +++ b/rmenu/src/config.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub struct Config { + pub regex: bool, + pub ignore_case: bool, +} + +impl Default for Config { + fn default() -> Self { + Self { + regex: true, + ignore_case: true, + } + } +} diff --git a/rmenu/src/gui.rs b/rmenu/src/gui.rs index 9844481..40e297d 100644 --- a/rmenu/src/gui.rs +++ b/rmenu/src/gui.rs @@ -1,26 +1,29 @@ #![allow(non_snake_case)] use dioxus::prelude::*; +use keyboard_types::{Code, Modifiers}; use rmenu_plugin::Entry; +use crate::search::new_searchfn; +use crate::state::PosTracker; use crate::App; pub fn run(app: App) { - #[cfg(target_family = "wasm")] - dioxus_web::launch(App, app, dioxus_web::Config::default()); - - #[cfg(any(windows, unix))] dioxus_desktop::launch_with_props(App, app, dioxus_desktop::Config::default()); } #[derive(PartialEq, Props)] struct GEntry<'a> { + i: usize, o: &'a Entry, + selected: bool, } fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { + let classes = if cx.props.selected { "selected" } else { "" }; cx.render(rsx! { div { - class: "result", + id: "result-{cx.props.i}", + class: "result {classes}", div { class: "icon", if let Some(icon) = cx.props.o.icon.as_ref() { @@ -43,27 +46,54 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { fn App(cx: Scope) -> Element { let search = use_state(cx, || "".to_string()); + let position = use_state(cx, || 0); + + // retrieve build results tracker let results = &cx.props.entries; - let searchstr = search.as_str(); - let results_rendered = results + let mut tracker = PosTracker::new(position, results); + + // keyboard events + let eval = dioxus_desktop::use_eval(cx); + let change_evt = move |evt: KeyboardEvent| { + match evt.code() { + // modify position + Code::ArrowUp => tracker.shift_up(), + Code::ArrowDown => tracker.shift_down(), + Code::Tab => match evt.modifiers().contains(Modifiers::SHIFT) { + true => tracker.close_menu(), + false => tracker.open_menu(), + }, + _ => println!("key: {:?}", evt.key()), + } + // always set focus back on input + let js = "document.getElementById(`search`).focus()"; + eval(js.to_owned()); + }; + + // pre-render results into elements + let searchfn = new_searchfn(&cx.props.config, &search); + let results_rendered: Vec = results .iter() - .filter(|entry| { - if entry.name.contains(searchstr) { - return true; - } - if let Some(comment) = entry.comment.as_ref() { - return comment.contains(searchstr); - } - false + .filter(|entry| searchfn(entry)) + .enumerate() + .map(|(i, entry)| { + cx.render(rsx! { + TableEntry{ i: i, o: entry, selected: (i + 1) == active } + }) }) - .map(|entry| cx.render(rsx! { TableEntry{ o: entry } })); + .collect(); cx.render(rsx! { style { "{cx.props.css}" } - input { - value: "{search}", - oninput: move |evt| search.set(evt.value.clone()), + div { + onkeydown: change_evt, + input { + id: "search", + value: "{search}", + oninput: move |evt| search.set(evt.value.clone()), + + } + results_rendered.into_iter() } - results_rendered }) } diff --git a/rmenu/src/main.rs b/rmenu/src/main.rs index 1708deb..f85733c 100644 --- a/rmenu/src/main.rs +++ b/rmenu/src/main.rs @@ -1,7 +1,10 @@ use std::fs::{read_to_string, File}; use std::io::{prelude::*, BufReader, Error}; +mod config; mod gui; +mod search; +mod state; use clap::*; use rmenu_plugin::Entry; @@ -24,22 +27,28 @@ pub struct Args { } //TODO: improve search w/ options for regex/case-insensivity/modes? +//TODO: add secondary menu for sub-actions aside from the main action //TODO: improve looks and css +//TODO: config +// - default and cli accessable modules (instead of piped in) +// - allow/disable icons (also available via CLI) +// - custom keybindings (some available via CLI?) + /// Application State for GUI #[derive(Debug, PartialEq)] pub struct App { css: String, name: String, entries: Vec, + config: config::Config, } fn default(args: &Args) -> Result { // read entries from specified input - let fpath = if args.input == "-" { - "/dev/stdin" - } else { - &args.input + let fpath = match args.input.as_str() { + "-" => "/dev/stdin", + _ => &args.input, }; let file = File::open(fpath)?; let reader = BufReader::new(file); @@ -57,6 +66,7 @@ fn default(args: &Args) -> Result { name: "default".to_string(), css: read_to_string(css)?, entries, + config: Default::default(), }; Ok(args) } diff --git a/rmenu/src/search.rs b/rmenu/src/search.rs new file mode 100644 index 0000000..ae1f1ba --- /dev/null +++ b/rmenu/src/search.rs @@ -0,0 +1,49 @@ +use regex::RegexBuilder; +use rmenu_plugin::Entry; + +use crate::config::Config; + +macro_rules! search { + ($search:expr) => { + Box::new(move |entry: &Entry| { + if entry.name.contains($search) { + return true; + } + if let Some(comment) = entry.comment.as_ref() { + return comment.contains($search); + } + false + }) + }; + ($search:expr,$mod:ident) => { + Box::new(move |entry: &Entry| { + if entry.name.$mod().contains($search) { + return true; + } + if let Some(comment) = entry.comment.as_ref() { + return comment.$mod().contains($search); + } + false + }) + }; +} + +/// Generate a new dynamic Search Function based on +/// Configurtaion Settigns and Search-String +pub fn new_searchfn(cfg: &Config, search: &str) -> Box bool> { + if cfg.regex { + let regex = RegexBuilder::new(search) + .case_insensitive(cfg.ignore_case) + .build(); + return match regex { + Ok(rgx) => search!(&rgx), + Err(_) => Box::new(|_| false), + }; + } + if cfg.ignore_case { + let matchstr = search.to_lowercase(); + return search!(&matchstr, to_lowercase); + } + let matchstr = search.to_owned(); + return search!(&matchstr); +} diff --git a/rmenu/src/state.rs b/rmenu/src/state.rs new file mode 100644 index 0000000..86df84c --- /dev/null +++ b/rmenu/src/state.rs @@ -0,0 +1,68 @@ +/// Application State Trackers and Utilities +use dioxus::prelude::UseState; +use rmenu_plugin::Entry; + +pub struct PosTracker<'a> { + pos: &'a UseState, + subpos: usize, + results: &'a Vec, +} + +impl<'a> PosTracker<'a> { + pub fn new(pos: &UseState, results: &Vec) -> Self { + Self { + pos, + results, + subpos: 0, + } + } + /// Move X Primary Results Upwards + pub fn move_up(&mut self, x: usize) { + self.subpos = 0; + self.pos.modify(|v| if v >= &x { v - x } else { 0 }) + } + /// Move X Primary Results Downwards + pub fn move_down(&mut self, x: usize) { + self.subpos = 0; + self.pos + .modify(|v| std::cmp::min(v + x, self.results.len())) + } + /// Get Current Position/SubPosition + pub fn position(&self) -> (usize, usize) { + (*self.pos.get(), self.subpos) + } + /// Move Position To SubMenu if it Exists + pub fn open_menu(&mut self) { + self.subpos = 1; + } + // Reset and Close SubMenu Position + pub fn close_menu(&mut self) { + self.subpos = 0; + } + /// Move Up Once With Context of SubMenu + pub fn shift_up(&mut self) { + let index = *self.pos.get(); + if index == 0 { + return; + } + let result = self.results[index]; + if self.subpos > 0 { + self.subpos -= 1; + return; + } + self.move_up(1) + } + /// Move Down Once With Context of SubMenu + pub fn shift_down(&mut self) { + let index = *self.pos.get(); + if index == 0 { + return self.move_down(1); + } + let result = self.results[index]; + if self.subpos > 0 && self.subpos < result.actions.len() { + self.subpos += 1; + return; + } + self.move_down(1) + } +} From 9c03c4bf1ee5ec957d7b7b555bc402b4e34739dc Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Tue, 18 Jul 2023 15:55:35 -0700 Subject: [PATCH 04/37] feat: update plugin --- rmenu-plugin/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rmenu-plugin/src/lib.rs b/rmenu-plugin/src/lib.rs index b4e20a5..2a10945 100644 --- a/rmenu-plugin/src/lib.rs +++ b/rmenu-plugin/src/lib.rs @@ -1,5 +1,4 @@ use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; #[derive(Debug, PartialEq, Serialize, Deserialize)] pub enum Method { @@ -9,6 +8,7 @@ pub enum Method { #[derive(Debug, PartialEq, Serialize, Deserialize)] pub struct Action { + pub name: String, pub exec: String, pub comment: Option, } @@ -16,7 +16,7 @@ pub struct Action { #[derive(Debug, PartialEq, Serialize, Deserialize)] pub struct Entry { pub name: String, - pub actions: BTreeMap, + pub actions: Vec, pub comment: Option, pub icon: Option, } From d56680fc0d7eea8194dac373190e26d8becd7154 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Tue, 18 Jul 2023 22:29:31 -0700 Subject: [PATCH 05/37] feat: start on formalizing configuration & app --- rmenu/Cargo.toml | 7 +- rmenu/public/default.css | 27 ++++++-- rmenu/src/config.rs | 9 ++- rmenu/src/gui.rs | 87 ++++++++++++++++++++----- rmenu/src/main.rs | 135 +++++++++++++++++++++++++++------------ rmenu/src/search.rs | 2 +- rmenu/src/state.rs | 60 ++++++++--------- 7 files changed, 230 insertions(+), 97 deletions(-) diff --git a/rmenu/Cargo.toml b/rmenu/Cargo.toml index ff167d0..12266fc 100644 --- a/rmenu/Cargo.toml +++ b/rmenu/Cargo.toml @@ -7,10 +7,13 @@ edition = "2021" [dependencies] clap = { version = "4.3.15", features = ["derive"] } -dioxus = { git = "https://github.com/DioxusLabs/dioxus", version = "0.3.2" } -dioxus-desktop = { git = "https://github.com/DioxusLabs/dioxus", version = "0.3.0" } +dioxus = "0.3.2" +dioxus-desktop = "0.3.0" +dirs = "5.0.1" keyboard-types = "0.6.2" +log = "0.4.19" regex = { version = "1.9.1", features = ["pattern"] } rmenu-plugin = { version = "0.0.0", path = "../rmenu-plugin" } serde = { version = "1.0.171", features = ["derive"] } serde_json = "1.0.103" +toml = "0.7.6" diff --git a/rmenu/public/default.css b/rmenu/public/default.css index 6d90509..9ca8818 100644 --- a/rmenu/public/default.css +++ b/rmenu/public/default.css @@ -8,17 +8,19 @@ input { min-width: 99%; } -div.result { +div.selected { + background-color: lightblue; +} + +/* Result CSS */ + +div.result, div.action { display: flex; align-items: center; justify-content: left; } -div.selected { - background-color: lightblue; -} - -div.result > div { +div.result > div, div.action > div { margin: 2px 5px; } @@ -42,3 +44,16 @@ div.result > div.name { div.result > div.comment { flex: 1; } + +/* Action CSS */ + +div.actions { + display: none; + padding-left: 5%; +} + +div.actions.active { + display: flex; + flex-direction: column; + justify-content: center; +} diff --git a/rmenu/src/config.rs b/rmenu/src/config.rs index b0a69ed..29420cc 100644 --- a/rmenu/src/config.rs +++ b/rmenu/src/config.rs @@ -1,15 +1,20 @@ use serde::{Deserialize, Serialize}; +use std::ffi::OsString; #[derive(Debug, PartialEq, Serialize, Deserialize)] pub struct Config { - pub regex: bool, + pub css: Vec, + pub use_icons: bool, + pub search_regex: bool, pub ignore_case: bool, } impl Default for Config { fn default() -> Self { Self { - regex: true, + css: vec![], + use_icons: true, + search_regex: false, ignore_case: true, } } diff --git a/rmenu/src/gui.rs b/rmenu/src/gui.rs index 40e297d..6e67231 100644 --- a/rmenu/src/gui.rs +++ b/rmenu/src/gui.rs @@ -13,44 +13,90 @@ pub fn run(app: App) { #[derive(PartialEq, Props)] struct GEntry<'a> { - i: usize, - o: &'a Entry, - selected: bool, + index: usize, + entry: &'a Entry, + pos: usize, + subpos: usize, } fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { - let classes = if cx.props.selected { "selected" } else { "" }; + // build css classes for result and actions (if nessesary) + let main_select = cx.props.index == cx.props.pos; + let action_select = main_select && cx.props.subpos > 0; + let action_classes = match action_select { + true => "active", + false => "", + }; + let result_classes = match main_select && !action_select { + true => "selected", + false => "", + }; + // build sub-actions if present + let actions = cx + .props + .entry + .actions + .iter() + .skip(1) + .enumerate() + .map(|(idx, action)| { + let act_class = match action_select && idx + 1 == cx.props.subpos { + true => "selected", + false => "", + }; + cx.render(rsx! { + div { + class: "action {act_class}", + div { + class: "action-name", + "{action.name}" + } + div { + class: "action-comment", + if let Some(comment) = action.comment.as_ref() { + format!("- {comment}") + } + } + } + }) + }); cx.render(rsx! { div { - id: "result-{cx.props.i}", - class: "result {classes}", + id: "result-{cx.props.index}", + class: "result {result_classes}", div { class: "icon", - if let Some(icon) = cx.props.o.icon.as_ref() { + if let Some(icon) = cx.props.entry.icon.as_ref() { cx.render(rsx! { img { src: "{icon}" } }) } } div { class: "name", - "{cx.props.o.name}" + "{cx.props.entry.name}" } div { class: "comment", - if let Some(comment) = cx.props.o.comment.as_ref() { + if let Some(comment) = cx.props.entry.comment.as_ref() { format!("- {comment}") } } } + div { + id: "result-{cx.props.index}-actions", + class: "actions {action_classes}", + actions.into_iter() + } }) } fn App(cx: Scope) -> Element { let search = use_state(cx, || "".to_string()); - let position = use_state(cx, || 0); // retrieve build results tracker let results = &cx.props.entries; - let mut tracker = PosTracker::new(position, results); + let tracker = PosTracker::new(cx, results); + let (pos, subpos) = tracker.position(); + println!("pos: {pos}, {subpos}"); // keyboard events let eval = dioxus_desktop::use_eval(cx); @@ -60,8 +106,14 @@ fn App(cx: Scope) -> Element { Code::ArrowUp => tracker.shift_up(), Code::ArrowDown => tracker.shift_down(), Code::Tab => match evt.modifiers().contains(Modifiers::SHIFT) { - true => tracker.close_menu(), - false => tracker.open_menu(), + true => { + println!("close menu"); + tracker.close_menu() + } + false => { + println!("open menu!"); + tracker.open_menu() + } }, _ => println!("key: {:?}", evt.key()), } @@ -76,9 +128,14 @@ fn App(cx: Scope) -> Element { .iter() .filter(|entry| searchfn(entry)) .enumerate() - .map(|(i, entry)| { + .map(|(index, entry)| { cx.render(rsx! { - TableEntry{ i: i, o: entry, selected: (i + 1) == active } + TableEntry{ + index: index, + entry: entry, + pos: pos, + subpos: subpos, + } }) }) .collect(); diff --git a/rmenu/src/main.rs b/rmenu/src/main.rs index f85733c..c9c8bc5 100644 --- a/rmenu/src/main.rs +++ b/rmenu/src/main.rs @@ -1,5 +1,6 @@ +use std::ffi::OsString; use std::fs::{read_to_string, File}; -use std::io::{prelude::*, BufReader, Error}; +use std::io::{prelude::*, BufReader, Error, ErrorKind}; mod config; mod gui; @@ -9,6 +10,15 @@ mod state; use clap::*; use rmenu_plugin::Entry; +/// Application State for GUI +#[derive(Debug, PartialEq)] +pub struct App { + css: String, + name: String, + entries: Vec, + config: config::Config, +} + /// Rofi Clone (Built with Rust) #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] @@ -22,10 +32,87 @@ pub struct Args { msgpack: bool, #[arg(short, long)] run: Vec, + #[arg(short, long)] + config: Option, #[arg(long)] - css: Option, + css: Vec, } +impl Args { + /// Load Config based on CLI Settings + fn config(&self) -> Result { + let path = match &self.config { + Some(path) => path.to_owned(), + None => match dirs::config_dir() { + Some(mut dir) => { + dir.push("rmenu"); + dir.push("config.toml"); + dir.into() + } + None => { + return Err(Error::new(ErrorKind::NotFound, "$HOME not found")); + } + }, + }; + log::debug!("loading config from {path:?}"); + let cfg = match read_to_string(path) { + Ok(cfg) => cfg, + Err(err) => { + log::error!("failed to load config: {err:?}"); + return Ok(config::Config::default()); + } + }; + toml::from_str(&cfg).map_err(|e| Error::new(ErrorKind::InvalidInput, format!("{e}"))) + } + + /// Load Entries From Input (Stdin by Default) + fn load_default(&self) -> Result, Error> { + let fpath = match self.input.as_str() { + "-" => "/dev/stdin", + _ => &self.input, + }; + let file = File::open(fpath)?; + let reader = BufReader::new(file); + let mut entries = vec![]; + for line in reader.lines() { + let entry = serde_json::from_str::(&line?)?; + entries.push(entry); + } + Ok(entries) + } + + /// Load Entries From Specified Sources + fn load_sources(&self, cfg: &config::Config) -> Result, Error> { + todo!() + } + + /// Load Application + pub fn parse_app() -> Result { + let args = Self::parse(); + let mut config = args.config()?; + // load css files from settings + config.css.extend(args.css.clone()); + let mut css = vec![]; + for path in config.css.iter() { + let src = read_to_string(path)?; + css.push(src); + } + // load entries from configured sources + let entries = match args.run.len() > 0 { + true => args.load_sources(&config)?, + false => args.load_default()?, + }; + // generate app object + return Ok(App { + css: css.join("\n"), + name: "rmenu".to_owned(), + entries, + config, + }); + } +} + +//TODO: add better errors with `thiserror` to add context //TODO: improve search w/ options for regex/case-insensivity/modes? //TODO: add secondary menu for sub-actions aside from the main action //TODO: improve looks and css @@ -35,45 +122,9 @@ pub struct Args { // - allow/disable icons (also available via CLI) // - custom keybindings (some available via CLI?) -/// Application State for GUI -#[derive(Debug, PartialEq)] -pub struct App { - css: String, - name: String, - entries: Vec, - config: config::Config, -} - -fn default(args: &Args) -> Result { - // read entries from specified input - let fpath = match args.input.as_str() { - "-" => "/dev/stdin", - _ => &args.input, - }; - let file = File::open(fpath)?; - let reader = BufReader::new(file); - let mut entries = vec![]; - for line in reader.lines() { - let entry = serde_json::from_str::(&line?)?; - entries.push(entry); - } - // generate app object based on configured args - let css = args - .css - .clone() - .unwrap_or("rmenu/public/default.css".to_owned()); - let args = App { - name: "default".to_string(), - css: read_to_string(css)?, - entries, - config: Default::default(), - }; - Ok(args) -} - -fn main() { - let cli = Args::parse(); - let app = default(&cli).unwrap(); - println!("{:?}", app); +fn main() -> Result<(), Error> { + // parse cli / config / application-settings + let app = Args::parse_app()?; gui::run(app); + Ok(()) } diff --git a/rmenu/src/search.rs b/rmenu/src/search.rs index ae1f1ba..ba84f27 100644 --- a/rmenu/src/search.rs +++ b/rmenu/src/search.rs @@ -31,7 +31,7 @@ macro_rules! search { /// Generate a new dynamic Search Function based on /// Configurtaion Settigns and Search-String pub fn new_searchfn(cfg: &Config, search: &str) -> Box bool> { - if cfg.regex { + if cfg.search_regex { let regex = RegexBuilder::new(search) .case_insensitive(cfg.ignore_case) .build(); diff --git a/rmenu/src/state.rs b/rmenu/src/state.rs index 86df84c..6477eaa 100644 --- a/rmenu/src/state.rs +++ b/rmenu/src/state.rs @@ -1,66 +1,68 @@ /// Application State Trackers and Utilities -use dioxus::prelude::UseState; +use dioxus::prelude::{use_state, Scope, UseState}; use rmenu_plugin::Entry; +use crate::App; + pub struct PosTracker<'a> { pos: &'a UseState, - subpos: usize, + subpos: &'a UseState, results: &'a Vec, } impl<'a> PosTracker<'a> { - pub fn new(pos: &UseState, results: &Vec) -> Self { + pub fn new(cx: Scope<'a, App>, results: &'a Vec) -> Self { + let pos = use_state(cx, || 0); + let subpos = use_state(cx, || 0); Self { pos, + subpos, results, - subpos: 0, } } /// Move X Primary Results Upwards - pub fn move_up(&mut self, x: usize) { - self.subpos = 0; + pub fn move_up(&self, x: usize) { + self.subpos.set(0); self.pos.modify(|v| if v >= &x { v - x } else { 0 }) } /// Move X Primary Results Downwards - pub fn move_down(&mut self, x: usize) { - self.subpos = 0; + pub fn move_down(&self, x: usize) { + self.subpos.set(0); self.pos - .modify(|v| std::cmp::min(v + x, self.results.len())) + .modify(|v| std::cmp::min(v + x, self.results.len() - 1)) } /// Get Current Position/SubPosition pub fn position(&self) -> (usize, usize) { - (*self.pos.get(), self.subpos) + (self.pos.get().clone(), self.subpos.get().clone()) } /// Move Position To SubMenu if it Exists - pub fn open_menu(&mut self) { - self.subpos = 1; + pub fn open_menu(&self) { + let index = *self.pos.get(); + let result = &self.results[index]; + if result.actions.len() > 0 { + self.subpos.set(1); + } } // Reset and Close SubMenu Position - pub fn close_menu(&mut self) { - self.subpos = 0; + pub fn close_menu(&self) { + self.subpos.set(0); } /// Move Up Once With Context of SubMenu - pub fn shift_up(&mut self) { - let index = *self.pos.get(); - if index == 0 { - return; - } - let result = self.results[index]; - if self.subpos > 0 { - self.subpos -= 1; + pub fn shift_up(&self) { + if self.subpos.get() > &0 { + self.subpos.modify(|v| v - 1); return; } self.move_up(1) } /// Move Down Once With Context of SubMenu - pub fn shift_down(&mut self) { + pub fn shift_down(&self) { let index = *self.pos.get(); - if index == 0 { - return self.move_down(1); - } - let result = self.results[index]; - if self.subpos > 0 && self.subpos < result.actions.len() { - self.subpos += 1; + let result = &self.results[index]; + let subpos = *self.subpos.get(); + println!("modify subpos? {} {}", subpos, result.actions.len()); + if subpos > 0 && subpos < result.actions.len() - 1 { + self.subpos.modify(|v| v + 1); return; } self.move_down(1) From 9b8d626c4dce5060068ef8a5a0136aa117091dd1 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Thu, 20 Jul 2023 16:59:27 -0700 Subject: [PATCH 06/37] feat: impl thiserr, use yaml for config, remove osstring --- rmenu/Cargo.toml | 3 +- rmenu/src/config.rs | 6 +- rmenu/src/gui.rs | 17 ++++-- rmenu/src/main.rs | 144 ++++++++++++++++++++++++++++++++++++-------- 4 files changed, 137 insertions(+), 33 deletions(-) diff --git a/rmenu/Cargo.toml b/rmenu/Cargo.toml index 12266fc..50bb7ce 100644 --- a/rmenu/Cargo.toml +++ b/rmenu/Cargo.toml @@ -16,4 +16,5 @@ regex = { version = "1.9.1", features = ["pattern"] } rmenu-plugin = { version = "0.0.0", path = "../rmenu-plugin" } serde = { version = "1.0.171", features = ["derive"] } serde_json = "1.0.103" -toml = "0.7.6" +serde_yaml = "0.9.24" +thiserror = "1.0.43" diff --git a/rmenu/src/config.rs b/rmenu/src/config.rs index 29420cc..e616d79 100644 --- a/rmenu/src/config.rs +++ b/rmenu/src/config.rs @@ -1,12 +1,13 @@ use serde::{Deserialize, Serialize}; -use std::ffi::OsString; +use std::collections::{BTreeMap, VecDeque}; #[derive(Debug, PartialEq, Serialize, Deserialize)] pub struct Config { - pub css: Vec, + pub css: Vec, pub use_icons: bool, pub search_regex: bool, pub ignore_case: bool, + pub plugins: BTreeMap>, } impl Default for Config { @@ -16,6 +17,7 @@ impl Default for Config { use_icons: true, search_regex: false, ignore_case: true, + plugins: Default::default(), } } } diff --git a/rmenu/src/gui.rs b/rmenu/src/gui.rs index 6e67231..2106be6 100644 --- a/rmenu/src/gui.rs +++ b/rmenu/src/gui.rs @@ -3,6 +3,7 @@ use dioxus::prelude::*; use keyboard_types::{Code, Modifiers}; use rmenu_plugin::Entry; +use crate::config::Config; use crate::search::new_searchfn; use crate::state::PosTracker; use crate::App; @@ -15,6 +16,7 @@ pub fn run(app: App) { struct GEntry<'a> { index: usize, entry: &'a Entry, + config: &'a Config, pos: usize, subpos: usize, } @@ -64,11 +66,15 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { div { id: "result-{cx.props.index}", class: "result {result_classes}", - div { - class: "icon", - if let Some(icon) = cx.props.entry.icon.as_ref() { - cx.render(rsx! { img { src: "{icon}" } }) - } + if cx.props.config.use_icons { + cx.render(rsx! { + div { + class: "icon", + if let Some(icon) = cx.props.entry.icon.as_ref() { + cx.render(rsx! { img { src: "{icon}" } }) + } + } + }) } div { class: "name", @@ -133,6 +139,7 @@ fn App(cx: Scope) -> Element { TableEntry{ index: index, entry: entry, + config: &cx.props.config, pos: pos, subpos: subpos, } diff --git a/rmenu/src/main.rs b/rmenu/src/main.rs index c9c8bc5..eb5b811 100644 --- a/rmenu/src/main.rs +++ b/rmenu/src/main.rs @@ -1,14 +1,59 @@ -use std::ffi::OsString; +use std::fmt::Display; use std::fs::{read_to_string, File}; -use std::io::{prelude::*, BufReader, Error, ErrorKind}; +use std::io::{self, prelude::*, BufReader}; +use std::process::{Command, ExitStatus, Stdio}; +use std::str::FromStr; mod config; mod gui; mod search; mod state; -use clap::*; +use clap::Parser; use rmenu_plugin::Entry; +use thiserror::Error; + +#[derive(Debug, Clone)] +pub enum Format { + Json, + MsgPack, +} + +impl Display for Format { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&format!("{self:?}").to_lowercase()) + } +} + +impl FromStr for Format { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "json" => Ok(Format::Json), + "msgpack" => Ok(Format::MsgPack), + _ => Err("No Such Format".to_owned()), + } + } +} + +#[derive(Error, Debug)] +pub enum RMenuError { + #[error("$HOME not found")] + HomeNotFound, + #[error("Invalid Config")] + InvalidConfig(#[from] serde_yaml::Error), + #[error("File Error")] + FileError(#[from] io::Error), + #[error("No Such Plugin")] + NoSuchPlugin(String), + #[error("Invalid Plugin Specified")] + InvalidPlugin(String), + #[error("Command Runtime Exception")] + CommandError(Vec, Option), + #[error("Invalid JSON Entry Object")] + InvalidJson(#[from] serde_json::Error), +} /// Application State for GUI #[derive(Debug, PartialEq)] @@ -26,31 +71,29 @@ pub struct App { pub struct Args { #[arg(short, long, default_value_t=String::from("-"))] input: String, - #[arg(short, long)] - json: bool, - #[arg(short, long)] - msgpack: bool, + #[arg(short, long, default_value_t=Format::Json)] + format: Format, #[arg(short, long)] run: Vec, #[arg(short, long)] - config: Option, + config: Option, #[arg(long)] - css: Vec, + css: Vec, } impl Args { /// Load Config based on CLI Settings - fn config(&self) -> Result { + fn config(&self) -> Result { let path = match &self.config { Some(path) => path.to_owned(), None => match dirs::config_dir() { Some(mut dir) => { dir.push("rmenu"); - dir.push("config.toml"); - dir.into() + dir.push("config.yaml"); + dir.to_string_lossy().to_string() } None => { - return Err(Error::new(ErrorKind::NotFound, "$HOME not found")); + return Err(RMenuError::HomeNotFound); } }, }; @@ -62,32 +105,83 @@ impl Args { return Ok(config::Config::default()); } }; - toml::from_str(&cfg).map_err(|e| Error::new(ErrorKind::InvalidInput, format!("{e}"))) + serde_yaml::from_str(&cfg).map_err(|e| RMenuError::InvalidConfig(e)) + } + + /// Read single entry from incoming line object + fn readentry(&self, cfg: &config::Config, line: &str) -> Result { + let mut entry = match self.format { + Format::Json => serde_json::from_str::(line)?, + Format::MsgPack => todo!(), + }; + if !cfg.use_icons { + entry.icon = None; + } + Ok(entry) } /// Load Entries From Input (Stdin by Default) - fn load_default(&self) -> Result, Error> { + fn load_default(&self, cfg: &config::Config) -> Result, RMenuError> { let fpath = match self.input.as_str() { "-" => "/dev/stdin", _ => &self.input, }; - let file = File::open(fpath)?; + let file = File::open(fpath).map_err(|e| RMenuError::FileError(e))?; let reader = BufReader::new(file); let mut entries = vec![]; for line in reader.lines() { - let entry = serde_json::from_str::(&line?)?; + let entry = self.readentry(cfg, &line?)?; entries.push(entry); } Ok(entries) } /// Load Entries From Specified Sources - fn load_sources(&self, cfg: &config::Config) -> Result, Error> { - todo!() + fn load_sources(&self, cfg: &config::Config) -> Result, RMenuError> { + println!("{cfg:?}"); + // execute commands to get a list of entries + let mut entries = vec![]; + for plugin in self.run.iter() { + log::debug!("running plugin: {plugin}"); + // retrieve plugin command arguments + let Some(args) = cfg.plugins.get(plugin) else { + return Err(RMenuError::NoSuchPlugin(plugin.to_owned())); + }; + // build command + let mut cmdargs = args.clone(); + let Some(main) = cmdargs.pop_front() else { + return Err(RMenuError::InvalidPlugin(plugin.to_owned())); + }; + let mut cmd = Command::new(main); + for arg in cmdargs.iter() { + cmd.arg(arg); + } + // spawn command + let mut proc = cmd.stdout(Stdio::piped()).spawn()?; + let stdout = proc + .stdout + .as_mut() + .ok_or_else(|| RMenuError::CommandError(args.clone().into(), None))?; + let reader = BufReader::new(stdout); + // read output line by line and parse content + for line in reader.lines() { + let entry = self.readentry(cfg, &line?)?; + entries.push(entry); + } + // check status of command on exit + let status = proc.wait()?; + if !status.success() { + return Err(RMenuError::CommandError( + args.clone().into(), + Some(status.clone()), + )); + } + } + Ok(entries) } /// Load Application - pub fn parse_app() -> Result { + pub fn parse_app() -> Result { let args = Self::parse(); let mut config = args.config()?; // load css files from settings @@ -100,7 +194,7 @@ impl Args { // load entries from configured sources let entries = match args.run.len() > 0 { true => args.load_sources(&config)?, - false => args.load_default()?, + false => args.load_default(&config)?, }; // generate app object return Ok(App { @@ -112,9 +206,7 @@ impl Args { } } -//TODO: add better errors with `thiserror` to add context -//TODO: improve search w/ options for regex/case-insensivity/modes? -//TODO: add secondary menu for sub-actions aside from the main action +//TODO: improve search w/ modes? //TODO: improve looks and css //TODO: config @@ -122,7 +214,9 @@ impl Args { // - allow/disable icons (also available via CLI) // - custom keybindings (some available via CLI?) -fn main() -> Result<(), Error> { +//TODO: add exit key (Esc by default?) - part of keybindings + +fn main() -> Result<(), RMenuError> { // parse cli / config / application-settings let app = Args::parse_app()?; gui::run(app); From 31989d4ee8cdcb0835a0147048ddf8024a1b6e0a Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Thu, 20 Jul 2023 23:52:04 -0700 Subject: [PATCH 07/37] feat: impl keybindings, window-settings, action-execution --- rmenu/Cargo.toml | 3 + rmenu/src/config.rs | 140 ++++++++++++++++++++++++++++++++++++++++++-- rmenu/src/exec.rs | 13 ++++ rmenu/src/gui.rs | 108 ++++++++++++++++++++++++++-------- rmenu/src/main.rs | 11 +++- rmenu/src/search.rs | 3 +- rmenu/src/state.rs | 14 ++++- 7 files changed, 256 insertions(+), 36 deletions(-) create mode 100644 rmenu/src/exec.rs diff --git a/rmenu/Cargo.toml b/rmenu/Cargo.toml index 50bb7ce..6dbda0a 100644 --- a/rmenu/Cargo.toml +++ b/rmenu/Cargo.toml @@ -10,6 +10,7 @@ clap = { version = "4.3.15", features = ["derive"] } dioxus = "0.3.2" dioxus-desktop = "0.3.0" dirs = "5.0.1" +heck = "0.4.1" keyboard-types = "0.6.2" log = "0.4.19" regex = { version = "1.9.1", features = ["pattern"] } @@ -17,4 +18,6 @@ rmenu-plugin = { version = "0.0.0", path = "../rmenu-plugin" } serde = { version = "1.0.171", features = ["derive"] } serde_json = "1.0.103" serde_yaml = "0.9.24" +shell-words = "1.1.0" +shellexpand = "3.1.0" thiserror = "1.0.43" diff --git a/rmenu/src/config.rs b/rmenu/src/config.rs index e616d79..32fe84b 100644 --- a/rmenu/src/config.rs +++ b/rmenu/src/config.rs @@ -1,13 +1,143 @@ -use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, VecDeque}; +//! RMENU Configuration Implementations +use heck::AsPascalCase; +use keyboard_types::{Code, Modifiers}; +use serde::{de::Error, Deserialize}; +use std::collections::BTreeMap; +use std::str::FromStr; -#[derive(Debug, PartialEq, Serialize, Deserialize)] +use dioxus_desktop::tao::dpi::{LogicalPosition, LogicalSize}; + +// parse supported modifiers from string +fn mod_from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "alt" => Some(Modifiers::ALT), + "ctrl" => Some(Modifiers::CONTROL), + "shift" => Some(Modifiers::SHIFT), + "super" => Some(Modifiers::SUPER), + _ => None, + } +} + +#[derive(Debug, PartialEq)] +pub struct Keybind { + pub mods: Modifiers, + pub key: Code, +} + +impl Keybind { + fn new(key: Code) -> Self { + Self { + mods: Modifiers::empty(), + key, + } + } +} + +impl FromStr for Keybind { + type Err = String; + + fn from_str(s: &str) -> Result { + // parse modifiers/keys from string + let mut mods = vec![]; + let mut keys = vec![]; + for item in s.split("+") { + let camel = format!("{}", AsPascalCase(item)); + match Code::from_str(&camel) { + Ok(key) => keys.push(key), + Err(_) => match mod_from_str(item) { + Some(keymod) => mods.push(keymod), + None => return Err(format!("invalid key/modifier: {item}")), + }, + } + } + // generate final keybind + let kmod = mods.into_iter().fold(Modifiers::empty(), |m1, m2| m1 | m2); + match keys.len() { + 0 => Err(format!("no keys specified")), + 1 => Ok(Keybind { + mods: kmod, + key: keys.pop().unwrap(), + }), + _ => Err(format!("too many keys: {keys:?}")), + } + } +} + +impl<'de> Deserialize<'de> for Keybind { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s: &str = Deserialize::deserialize(deserializer)?; + Keybind::from_str(s).map_err(D::Error::custom) + } +} + +#[derive(Debug, PartialEq, Deserialize)] +pub struct KeyConfig { + pub exit: Vec, + pub move_up: Vec, + pub move_down: Vec, + #[serde(default)] + pub open_menu: Vec, + #[serde(default)] + pub close_menu: Vec, +} + +impl Default for KeyConfig { + fn default() -> Self { + return Self { + exit: vec![Keybind::new(Code::Escape)], + move_up: vec![Keybind::new(Code::ArrowUp)], + move_down: vec![Keybind::new(Code::ArrowDown)], + open_menu: vec![], + close_menu: vec![], + }; + } +} + +#[derive(Debug, PartialEq, Deserialize)] +pub struct WindowConfig { + pub title: String, + pub size: LogicalSize, + pub position: LogicalPosition, + pub focus: bool, + pub decorate: bool, + pub transparent: bool, + pub always_top: bool, + pub dark_mode: Option, +} + +impl Default for WindowConfig { + fn default() -> Self { + Self { + title: "RMenu - App Launcher".to_owned(), + size: LogicalSize { + width: 700.0, + height: 400.0, + }, + position: LogicalPosition { x: 100.0, y: 100.0 }, + focus: true, + decorate: false, + transparent: false, + always_top: true, + dark_mode: None, + } + } +} + +#[derive(Debug, PartialEq, Deserialize)] pub struct Config { pub css: Vec, pub use_icons: bool, pub search_regex: bool, pub ignore_case: bool, - pub plugins: BTreeMap>, + #[serde(default)] + pub plugins: BTreeMap>, + #[serde(default)] + pub keybinds: KeyConfig, + #[serde(default)] + pub window: WindowConfig, } impl Default for Config { @@ -18,6 +148,8 @@ impl Default for Config { search_regex: false, ignore_case: true, plugins: Default::default(), + keybinds: Default::default(), + window: Default::default(), } } } diff --git a/rmenu/src/exec.rs b/rmenu/src/exec.rs new file mode 100644 index 0000000..9defe1d --- /dev/null +++ b/rmenu/src/exec.rs @@ -0,0 +1,13 @@ +//! Execution Implementation for Entry Actions +use std::os::unix::process::CommandExt; +use std::process::Command; + +use rmenu_plugin::Action; + +pub fn execute(action: &Action) { + let args = match shell_words::split(&action.exec) { + Ok(args) => args, + Err(err) => panic!("{:?} invalid command {err}", action.exec), + }; + Command::new(&args[0]).args(&args[1..]).exec(); +} diff --git a/rmenu/src/gui.rs b/rmenu/src/gui.rs index 2106be6..7c8b5e0 100644 --- a/rmenu/src/gui.rs +++ b/rmenu/src/gui.rs @@ -1,15 +1,36 @@ +//! RMENU GUI Implementation using Dioxus #![allow(non_snake_case)] use dioxus::prelude::*; use keyboard_types::{Code, Modifiers}; use rmenu_plugin::Entry; -use crate::config::Config; +use crate::config::{Config, Keybind}; +use crate::exec::execute; use crate::search::new_searchfn; use crate::state::PosTracker; use crate::App; +/// spawn and run the app on the configured platform pub fn run(app: App) { - dioxus_desktop::launch_with_props(App, app, dioxus_desktop::Config::default()); + // customize window + let theme = match app.config.window.dark_mode { + Some(dark) => match dark { + true => Some(dioxus_desktop::tao::window::Theme::Dark), + false => Some(dioxus_desktop::tao::window::Theme::Light), + }, + None => None, + }; + let builder = dioxus_desktop::WindowBuilder::new() + .with_title(app.config.window.title.clone()) + .with_inner_size(app.config.window.size) + .with_position(app.config.window.position) + .with_focused(app.config.window.focus) + .with_decorations(app.config.window.decorate) + .with_transparent(app.config.window.transparent) + .with_always_on_top(app.config.window.always_top) + .with_theme(theme); + let config = dioxus_desktop::Config::new().with_window(builder); + dioxus_desktop::launch_with_props(App, app, config); } #[derive(PartialEq, Props)] @@ -21,6 +42,7 @@ struct GEntry<'a> { subpos: usize, } +/// render a single result entry w/ the given information fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { // build css classes for result and actions (if nessesary) let main_select = cx.props.index == cx.props.pos; @@ -29,6 +51,10 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { true => "active", false => "", }; + let multi_classes = match cx.props.entry.actions.len() > 1 { + true => "submenu", + false => "", + }; let result_classes = match main_select && !action_select { true => "selected", false => "", @@ -65,7 +91,19 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { cx.render(rsx! { div { id: "result-{cx.props.index}", - class: "result {result_classes}", + class: "result {result_classes} {multi_classes}", + ondblclick: |_| { + let action = match cx.props.entry.actions.get(0) { + Some(action) => action, + None => { + let name = &cx.props.entry.name; + log::warn!("no action to execute on {:?}", name); + return; + } + }; + log::info!("executing: {:?}", action.exec); + execute(action); + }, if cx.props.config.use_icons { cx.render(rsx! { div { @@ -83,7 +121,7 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { div { class: "comment", if let Some(comment) = cx.props.entry.comment.as_ref() { - format!("- {comment}") + comment.to_string() } } } @@ -95,37 +133,54 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { }) } +#[inline] +fn focus(cx: Scope) { + let eval = dioxus_desktop::use_eval(cx); + let js = "document.getElementById(`search`).focus()"; + eval(js.to_owned()); +} + +/// check if the current inputs match any of the given keybindings +#[inline] +fn matches(bind: &Vec, mods: &Modifiers, key: &Code) -> bool { + bind.iter().any(|b| mods.contains(b.mods) && &b.key == key) +} + +/// main application function/loop fn App(cx: Scope) -> Element { + let quit = use_state(cx, || false); let search = use_state(cx, || "".to_string()); - // retrieve build results tracker + // handle exit check + if *quit.get() { + std::process::exit(0); + } + + // retrieve results build and build position-tracker let results = &cx.props.entries; let tracker = PosTracker::new(cx, results); let (pos, subpos) = tracker.position(); - println!("pos: {pos}, {subpos}"); + log::debug!("pos: {pos}, {subpos}"); // keyboard events - let eval = dioxus_desktop::use_eval(cx); - let change_evt = move |evt: KeyboardEvent| { - match evt.code() { - // modify position - Code::ArrowUp => tracker.shift_up(), - Code::ArrowDown => tracker.shift_down(), - Code::Tab => match evt.modifiers().contains(Modifiers::SHIFT) { - true => { - println!("close menu"); - tracker.close_menu() - } - false => { - println!("open menu!"); - tracker.open_menu() - } - }, - _ => println!("key: {:?}", evt.key()), + let keybinds = &cx.props.config.keybinds; + let keyboard_evt = move |evt: KeyboardEvent| { + let key = &evt.code(); + let mods = &evt.modifiers(); + log::debug!("key: {key:?} mods: {mods:?}"); + if matches(&keybinds.exit, mods, key) { + quit.set(true); + } else if matches(&keybinds.move_up, mods, key) { + tracker.shift_up(); + } else if matches(&keybinds.move_down, mods, key) { + tracker.shift_down(); + } else if matches(&keybinds.open_menu, mods, key) { + tracker.open_menu(); + } else if matches(&keybinds.close_menu, mods, key) { + tracker.close_menu(); } // always set focus back on input - let js = "document.getElementById(`search`).focus()"; - eval(js.to_owned()); + focus(cx); }; // pre-render results into elements @@ -150,7 +205,8 @@ fn App(cx: Scope) -> Element { cx.render(rsx! { style { "{cx.props.css}" } div { - onkeydown: change_evt, + onkeydown: keyboard_evt, + onclick: |_| focus(cx), input { id: "search", value: "{search}", diff --git a/rmenu/src/main.rs b/rmenu/src/main.rs index eb5b811..26b069e 100644 --- a/rmenu/src/main.rs +++ b/rmenu/src/main.rs @@ -1,3 +1,4 @@ +use std::collections::VecDeque; use std::fmt::Display; use std::fs::{read_to_string, File}; use std::io::{self, prelude::*, BufReader}; @@ -5,6 +6,7 @@ use std::process::{Command, ExitStatus, Stdio}; use std::str::FromStr; mod config; +mod exec; mod gui; mod search; mod state; @@ -148,7 +150,10 @@ impl Args { return Err(RMenuError::NoSuchPlugin(plugin.to_owned())); }; // build command - let mut cmdargs = args.clone(); + let mut cmdargs: VecDeque = args + .iter() + .map(|arg| shellexpand::tilde(arg).to_string()) + .collect(); let Some(main) = cmdargs.pop_front() else { return Err(RMenuError::InvalidPlugin(plugin.to_owned())); }; @@ -188,6 +193,7 @@ impl Args { config.css.extend(args.css.clone()); let mut css = vec![]; for path in config.css.iter() { + let path = shellexpand::tilde(path).to_string(); let src = read_to_string(path)?; css.push(src); } @@ -211,8 +217,7 @@ impl Args { //TODO: config // - default and cli accessable modules (instead of piped in) -// - allow/disable icons (also available via CLI) -// - custom keybindings (some available via CLI?) +// - should resolve arguments/paths with home expansion //TODO: add exit key (Esc by default?) - part of keybindings diff --git a/rmenu/src/search.rs b/rmenu/src/search.rs index ba84f27..d9e2504 100644 --- a/rmenu/src/search.rs +++ b/rmenu/src/search.rs @@ -1,3 +1,4 @@ +//! RMENU Entry Search Function Implementaton use regex::RegexBuilder; use rmenu_plugin::Entry; @@ -29,7 +30,7 @@ macro_rules! search { } /// Generate a new dynamic Search Function based on -/// Configurtaion Settigns and Search-String +/// Configurtaion Settings and Search-String pub fn new_searchfn(cfg: &Config, search: &str) -> Box bool> { if cfg.search_regex { let regex = RegexBuilder::new(search) diff --git a/rmenu/src/state.rs b/rmenu/src/state.rs index 6477eaa..c610683 100644 --- a/rmenu/src/state.rs +++ b/rmenu/src/state.rs @@ -1,15 +1,26 @@ -/// Application State Trackers and Utilities +//! GUI Application State Trackers and Utilities use dioxus::prelude::{use_state, Scope, UseState}; use rmenu_plugin::Entry; use crate::App; +#[derive(PartialEq)] pub struct PosTracker<'a> { pos: &'a UseState, subpos: &'a UseState, results: &'a Vec, } +impl<'a> Clone for PosTracker<'a> { + fn clone(&self) -> Self { + Self { + pos: self.pos, + subpos: self.subpos, + results: self.results, + } + } +} + impl<'a> PosTracker<'a> { pub fn new(cx: Scope<'a, App>, results: &'a Vec) -> Self { let pos = use_state(cx, || 0); @@ -60,7 +71,6 @@ impl<'a> PosTracker<'a> { let index = *self.pos.get(); let result = &self.results[index]; let subpos = *self.subpos.get(); - println!("modify subpos? {} {}", subpos, result.actions.len()); if subpos > 0 && subpos < result.actions.len() - 1 { self.subpos.modify(|v| v + 1); return; From c83febd10b1012b5ab667329c5377d2328b4ea64 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Thu, 20 Jul 2023 23:52:19 -0700 Subject: [PATCH 08/37] fix: always use resolver-2 --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index 57aa7db..6b863fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,5 @@ [workspace] +resolver = "2" members = [ "rmenu", "rmenu-plugin" From 25ee2f32c421a94b78e483e5b41b1ff6008d323d Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Fri, 21 Jul 2023 16:10:27 -0700 Subject: [PATCH 09/37] fix: pos-tracker is search-context aware, panic on no-action --- rmenu/public/default.css | 4 ++++ rmenu/src/config.rs | 2 ++ rmenu/src/exec.rs | 3 ++- rmenu/src/gui.rs | 21 ++++++++++++++++----- rmenu/src/state.rs | 21 ++++++++------------- 5 files changed, 32 insertions(+), 19 deletions(-) diff --git a/rmenu/public/default.css b/rmenu/public/default.css index 9ca8818..cf14845 100644 --- a/rmenu/public/default.css +++ b/rmenu/public/default.css @@ -52,6 +52,10 @@ div.actions { padding-left: 5%; } +div.action-name { + width: 10%; +} + div.actions.active { display: flex; flex-direction: column; diff --git a/rmenu/src/config.rs b/rmenu/src/config.rs index 32fe84b..73be9a1 100644 --- a/rmenu/src/config.rs +++ b/rmenu/src/config.rs @@ -75,6 +75,7 @@ impl<'de> Deserialize<'de> for Keybind { #[derive(Debug, PartialEq, Deserialize)] pub struct KeyConfig { + pub exec: Vec, pub exit: Vec, pub move_up: Vec, pub move_down: Vec, @@ -87,6 +88,7 @@ pub struct KeyConfig { impl Default for KeyConfig { fn default() -> Self { return Self { + exec: vec![Keybind::new(Code::Enter)], exit: vec![Keybind::new(Code::Escape)], move_up: vec![Keybind::new(Code::ArrowUp)], move_down: vec![Keybind::new(Code::ArrowDown)], diff --git a/rmenu/src/exec.rs b/rmenu/src/exec.rs index 9defe1d..c481c51 100644 --- a/rmenu/src/exec.rs +++ b/rmenu/src/exec.rs @@ -9,5 +9,6 @@ pub fn execute(action: &Action) { Ok(args) => args, Err(err) => panic!("{:?} invalid command {err}", action.exec), }; - Command::new(&args[0]).args(&args[1..]).exec(); + let err = Command::new(&args[0]).args(&args[1..]).exec(); + panic!("Command Error: {err:?}"); } diff --git a/rmenu/src/gui.rs b/rmenu/src/gui.rs index 7c8b5e0..38d83bf 100644 --- a/rmenu/src/gui.rs +++ b/rmenu/src/gui.rs @@ -156,9 +156,17 @@ fn App(cx: Scope) -> Element { std::process::exit(0); } + // retrieve results and filter based on search + let searchfn = new_searchfn(&cx.props.config, &search); + let results: Vec<&Entry> = cx + .props + .entries + .iter() + .filter(|entry| searchfn(entry)) + .collect(); + // retrieve results build and build position-tracker - let results = &cx.props.entries; - let tracker = PosTracker::new(cx, results); + let tracker = PosTracker::new(cx, results.clone()); let (pos, subpos) = tracker.position(); log::debug!("pos: {pos}, {subpos}"); @@ -168,7 +176,12 @@ fn App(cx: Scope) -> Element { let key = &evt.code(); let mods = &evt.modifiers(); log::debug!("key: {key:?} mods: {mods:?}"); - if matches(&keybinds.exit, mods, key) { + if matches(&keybinds.exec, mods, key) { + match tracker.action() { + Some(action) => execute(action), + None => panic!("No Action Configured"), + } + } else if matches(&keybinds.exit, mods, key) { quit.set(true); } else if matches(&keybinds.move_up, mods, key) { tracker.shift_up(); @@ -184,10 +197,8 @@ fn App(cx: Scope) -> Element { }; // pre-render results into elements - let searchfn = new_searchfn(&cx.props.config, &search); let results_rendered: Vec = results .iter() - .filter(|entry| searchfn(entry)) .enumerate() .map(|(index, entry)| { cx.render(rsx! { diff --git a/rmenu/src/state.rs b/rmenu/src/state.rs index c610683..c9cac12 100644 --- a/rmenu/src/state.rs +++ b/rmenu/src/state.rs @@ -1,6 +1,6 @@ //! GUI Application State Trackers and Utilities use dioxus::prelude::{use_state, Scope, UseState}; -use rmenu_plugin::Entry; +use rmenu_plugin::{Action, Entry}; use crate::App; @@ -8,21 +8,11 @@ use crate::App; pub struct PosTracker<'a> { pos: &'a UseState, subpos: &'a UseState, - results: &'a Vec, -} - -impl<'a> Clone for PosTracker<'a> { - fn clone(&self) -> Self { - Self { - pos: self.pos, - subpos: self.subpos, - results: self.results, - } - } + results: Vec<&'a Entry>, } impl<'a> PosTracker<'a> { - pub fn new(cx: Scope<'a, App>, results: &'a Vec) -> Self { + pub fn new(cx: Scope<'a, App>, results: Vec<&'a Entry>) -> Self { let pos = use_state(cx, || 0); let subpos = use_state(cx, || 0); Self { @@ -46,6 +36,11 @@ impl<'a> PosTracker<'a> { pub fn position(&self) -> (usize, usize) { (self.pos.get().clone(), self.subpos.get().clone()) } + /// Get Action Linked To The Current Position + pub fn action(&self) -> Option<&Action> { + let (pos, subpos) = self.position(); + self.results[pos].actions.get(subpos) + } /// Move Position To SubMenu if it Exists pub fn open_menu(&self) { let index = *self.pos.get(); From 82897da0e23a808a065d2e0e1e4e883c0f6168a4 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Fri, 21 Jul 2023 23:28:33 -0700 Subject: [PATCH 10/37] feat: better cfg defaults, navigation fixes, css uses one file w/ builtin backup. --- rmenu/Cargo.toml | 2 +- rmenu/src/config.rs | 9 ++------ rmenu/src/exec.rs | 1 + rmenu/src/gui.rs | 9 ++------ rmenu/src/main.rs | 55 +++++++++++++++++++++++---------------------- rmenu/src/state.rs | 15 +++++++------ 6 files changed, 42 insertions(+), 49 deletions(-) diff --git a/rmenu/Cargo.toml b/rmenu/Cargo.toml index 6dbda0a..c9e14bc 100644 --- a/rmenu/Cargo.toml +++ b/rmenu/Cargo.toml @@ -9,7 +9,7 @@ edition = "2021" clap = { version = "4.3.15", features = ["derive"] } dioxus = "0.3.2" dioxus-desktop = "0.3.0" -dirs = "5.0.1" +env_logger = "0.10.0" heck = "0.4.1" keyboard-types = "0.6.2" log = "0.4.19" diff --git a/rmenu/src/config.rs b/rmenu/src/config.rs index 73be9a1..80f6722 100644 --- a/rmenu/src/config.rs +++ b/rmenu/src/config.rs @@ -74,14 +74,13 @@ impl<'de> Deserialize<'de> for Keybind { } #[derive(Debug, PartialEq, Deserialize)] +#[serde(default)] pub struct KeyConfig { pub exec: Vec, pub exit: Vec, pub move_up: Vec, pub move_down: Vec, - #[serde(default)] pub open_menu: Vec, - #[serde(default)] pub close_menu: Vec, } @@ -129,23 +128,19 @@ impl Default for WindowConfig { } #[derive(Debug, PartialEq, Deserialize)] +#[serde(default)] pub struct Config { - pub css: Vec, pub use_icons: bool, pub search_regex: bool, pub ignore_case: bool, - #[serde(default)] pub plugins: BTreeMap>, - #[serde(default)] pub keybinds: KeyConfig, - #[serde(default)] pub window: WindowConfig, } impl Default for Config { fn default() -> Self { Self { - css: vec![], use_icons: true, search_regex: false, ignore_case: true, diff --git a/rmenu/src/exec.rs b/rmenu/src/exec.rs index c481c51..703936f 100644 --- a/rmenu/src/exec.rs +++ b/rmenu/src/exec.rs @@ -5,6 +5,7 @@ use std::process::Command; use rmenu_plugin::Action; pub fn execute(action: &Action) { + log::info!("executing: {} {:?}", action.name, action.exec); let args = match shell_words::split(&action.exec) { Ok(args) => args, Err(err) => panic!("{:?} invalid command {err}", action.exec), diff --git a/rmenu/src/gui.rs b/rmenu/src/gui.rs index 38d83bf..bc37152 100644 --- a/rmenu/src/gui.rs +++ b/rmenu/src/gui.rs @@ -95,13 +95,8 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { ondblclick: |_| { let action = match cx.props.entry.actions.get(0) { Some(action) => action, - None => { - let name = &cx.props.entry.name; - log::warn!("no action to execute on {:?}", name); - return; - } + None => panic!("No Action Configured"), }; - log::info!("executing: {:?}", action.exec); execute(action); }, if cx.props.config.use_icons { @@ -168,7 +163,7 @@ fn App(cx: Scope) -> Element { // retrieve results build and build position-tracker let tracker = PosTracker::new(cx, results.clone()); let (pos, subpos) = tracker.position(); - log::debug!("pos: {pos}, {subpos}"); + log::debug!("search: {search:?}, pos: {pos}, {subpos}"); // keyboard events let keybinds = &cx.props.config.keybinds; diff --git a/rmenu/src/main.rs b/rmenu/src/main.rs index 26b069e..4bcdc3c 100644 --- a/rmenu/src/main.rs +++ b/rmenu/src/main.rs @@ -15,6 +15,11 @@ use clap::Parser; use rmenu_plugin::Entry; use thiserror::Error; +static CONFIG_DIR: &'static str = "~/.config/rmenu/"; +static DEFAULT_CSS: &'static str = "~/.config/rmenu/style.css"; +static DEFAULT_CONFIG: &'static str = "~/.config/rmenu/config.yaml"; +static DEFAULT_CSS_CONTENT: &'static str = include_str!("../public/default.css"); + #[derive(Debug, Clone)] pub enum Format { Json, @@ -80,7 +85,7 @@ pub struct Args { #[arg(short, long)] config: Option, #[arg(long)] - css: Vec, + css: Option, } impl Args { @@ -88,16 +93,7 @@ impl Args { fn config(&self) -> Result { let path = match &self.config { Some(path) => path.to_owned(), - None => match dirs::config_dir() { - Some(mut dir) => { - dir.push("rmenu"); - dir.push("config.yaml"); - dir.to_string_lossy().to_string() - } - None => { - return Err(RMenuError::HomeNotFound); - } - }, + None => shellexpand::tilde(DEFAULT_CONFIG).to_string(), }; log::debug!("loading config from {path:?}"); let cfg = match read_to_string(path) { @@ -140,7 +136,7 @@ impl Args { /// Load Entries From Specified Sources fn load_sources(&self, cfg: &config::Config) -> Result, RMenuError> { - println!("{cfg:?}"); + log::debug!("config: {cfg:?}"); // execute commands to get a list of entries let mut entries = vec![]; for plugin in self.run.iter() { @@ -188,15 +184,17 @@ impl Args { /// Load Application pub fn parse_app() -> Result { let args = Self::parse(); - let mut config = args.config()?; + let config = args.config()?; // load css files from settings - config.css.extend(args.css.clone()); - let mut css = vec![]; - for path in config.css.iter() { - let path = shellexpand::tilde(path).to_string(); - let src = read_to_string(path)?; - css.push(src); - } + let csspath = args.css.clone().unwrap_or_else(|| DEFAULT_CSS.to_owned()); + let csspath = shellexpand::tilde(&csspath).to_string(); + let css = match read_to_string(csspath) { + Ok(css) => css, + Err(err) => { + log::error!("failed to load css: {err:?}"); + DEFAULT_CSS_CONTENT.to_owned() + } + }; // load entries from configured sources let entries = match args.run.len() > 0 { true => args.load_sources(&config)?, @@ -204,7 +202,7 @@ impl Args { }; // generate app object return Ok(App { - css: css.join("\n"), + css, name: "rmenu".to_owned(), entries, config, @@ -215,13 +213,16 @@ impl Args { //TODO: improve search w/ modes? //TODO: improve looks and css -//TODO: config -// - default and cli accessable modules (instead of piped in) -// - should resolve arguments/paths with home expansion - -//TODO: add exit key (Esc by default?) - part of keybindings - fn main() -> Result<(), RMenuError> { + // enable log if env-var is present + if std::env::var("RUST_LOG").is_ok() { + env_logger::init(); + } + // change directory to configuration dir + let cfgdir = shellexpand::tilde(CONFIG_DIR).to_string(); + if let Err(err) = std::env::set_current_dir(&cfgdir) { + log::error!("failed to change directory: {err:?}"); + } // parse cli / config / application-settings let app = Args::parse_app()?; gui::run(app); diff --git a/rmenu/src/state.rs b/rmenu/src/state.rs index c9cac12..5cd7e9c 100644 --- a/rmenu/src/state.rs +++ b/rmenu/src/state.rs @@ -28,9 +28,9 @@ impl<'a> PosTracker<'a> { } /// Move X Primary Results Downwards pub fn move_down(&self, x: usize) { + let max = std::cmp::max(self.results.len(), 1); self.subpos.set(0); - self.pos - .modify(|v| std::cmp::min(v + x, self.results.len() - 1)) + self.pos.modify(|v| std::cmp::min(v + x, max - 1)) } /// Get Current Position/SubPosition pub fn position(&self) -> (usize, usize) { @@ -64,11 +64,12 @@ impl<'a> PosTracker<'a> { /// Move Down Once With Context of SubMenu pub fn shift_down(&self) { let index = *self.pos.get(); - let result = &self.results[index]; - let subpos = *self.subpos.get(); - if subpos > 0 && subpos < result.actions.len() - 1 { - self.subpos.modify(|v| v + 1); - return; + if let Some(result) = &self.results.get(index) { + let subpos = *self.subpos.get(); + if subpos > 0 && subpos < result.actions.len() - 1 { + self.subpos.modify(|v| v + 1); + return; + } } self.move_down(1) } From 20ced561378feb2182c2ab90aabd78b8bb87b63d Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Mon, 24 Jul 2023 21:24:18 -0700 Subject: [PATCH 11/37] feat: much improved internal-state mgmt system --- rmenu/src/config.rs | 10 +- rmenu/src/gui.rs | 146 +++++++++++------------ rmenu/src/main.rs | 5 +- rmenu/src/state.rs | 275 +++++++++++++++++++++++++++++++++++--------- 4 files changed, 297 insertions(+), 139 deletions(-) diff --git a/rmenu/src/config.rs b/rmenu/src/config.rs index 80f6722..aa1fef5 100644 --- a/rmenu/src/config.rs +++ b/rmenu/src/config.rs @@ -113,8 +113,12 @@ impl Default for WindowConfig { fn default() -> Self { Self { title: "RMenu - App Launcher".to_owned(), + // size: LogicalSize { + // width: 700.0, + // height: 400.0, + // }, size: LogicalSize { - width: 700.0, + width: 1000.0, height: 400.0, }, position: LogicalPosition { x: 100.0, y: 100.0 }, @@ -130,6 +134,8 @@ impl Default for WindowConfig { #[derive(Debug, PartialEq, Deserialize)] #[serde(default)] pub struct Config { + pub page_size: usize, + pub page_load: f64, pub use_icons: bool, pub search_regex: bool, pub ignore_case: bool, @@ -141,6 +147,8 @@ pub struct Config { impl Default for Config { fn default() -> Self { Self { + page_size: 50, + page_load: 0.8, use_icons: true, search_regex: false, ignore_case: true, diff --git a/rmenu/src/gui.rs b/rmenu/src/gui.rs index bc37152..50cfe0b 100644 --- a/rmenu/src/gui.rs +++ b/rmenu/src/gui.rs @@ -4,10 +4,8 @@ use dioxus::prelude::*; use keyboard_types::{Code, Modifiers}; use rmenu_plugin::Entry; -use crate::config::{Config, Keybind}; -use crate::exec::execute; -use crate::search::new_searchfn; -use crate::state::PosTracker; +use crate::config::Keybind; +use crate::state::{AppState, KeyEvent}; use crate::App; /// spawn and run the app on the configured platform @@ -35,11 +33,11 @@ pub fn run(app: App) { #[derive(PartialEq, Props)] struct GEntry<'a> { - index: usize, - entry: &'a Entry, - config: &'a Config, pos: usize, subpos: usize, + index: usize, + entry: &'a Entry, + state: AppState<'a>, } /// render a single result entry w/ the given information @@ -75,6 +73,8 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { cx.render(rsx! { div { class: "action {act_class}", + onclick: move |_| cx.props.state.set_position(cx.props.index, idx + 1), + ondblclick: |_| cx.props.state.set_event(KeyEvent::Exec), div { class: "action-name", "{action.name}" @@ -92,14 +92,10 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { div { id: "result-{cx.props.index}", class: "result {result_classes} {multi_classes}", - ondblclick: |_| { - let action = match cx.props.entry.actions.get(0) { - Some(action) => action, - None => panic!("No Action Configured"), - }; - execute(action); - }, - if cx.props.config.use_icons { + // onmouseenter: |_| cx.props.state.set_position(cx.props.index, 0), + onclick: |_| cx.props.state.set_position(cx.props.index, 0), + ondblclick: |_| cx.props.state.set_event(KeyEvent::Exec), + if cx.props.state.config().use_icons { cx.render(rsx! { div { class: "icon", @@ -142,84 +138,76 @@ fn matches(bind: &Vec, mods: &Modifiers, key: &Code) -> bool { } /// main application function/loop -fn App(cx: Scope) -> Element { - let quit = use_state(cx, || false); - let search = use_state(cx, || "".to_string()); +fn App<'a>(cx: Scope) -> Element { + let mut state = AppState::new(cx, cx.props); - // handle exit check - if *quit.get() { - std::process::exit(0); - } - - // retrieve results and filter based on search - let searchfn = new_searchfn(&cx.props.config, &search); - let results: Vec<&Entry> = cx - .props - .entries - .iter() - .filter(|entry| searchfn(entry)) - .collect(); - - // retrieve results build and build position-tracker - let tracker = PosTracker::new(cx, results.clone()); - let (pos, subpos) = tracker.position(); + // log current position + let search = state.search(); + let (pos, subpos) = state.position(); log::debug!("search: {search:?}, pos: {pos}, {subpos}"); - // keyboard events + // generate state tracker instances + let results = state.results(&cx.props.entries); + let s_updater = state.partial_copy(); + let k_updater = state.partial_copy(); + + //TODO: consider implementing some sort of + // action channel reference to pass to keboard events + + // build keyboard actions event handler let keybinds = &cx.props.config.keybinds; - let keyboard_evt = move |evt: KeyboardEvent| { - let key = &evt.code(); - let mods = &evt.modifiers(); - log::debug!("key: {key:?} mods: {mods:?}"); - if matches(&keybinds.exec, mods, key) { - match tracker.action() { - Some(action) => execute(action), - None => panic!("No Action Configured"), - } - } else if matches(&keybinds.exit, mods, key) { - quit.set(true); - } else if matches(&keybinds.move_up, mods, key) { - tracker.shift_up(); - } else if matches(&keybinds.move_down, mods, key) { - tracker.shift_down(); - } else if matches(&keybinds.open_menu, mods, key) { - tracker.open_menu(); - } else if matches(&keybinds.close_menu, mods, key) { - tracker.close_menu(); + let keyboard_controls = move |e: KeyboardEvent| { + let code = e.code(); + let mods = e.modifiers(); + if matches(&keybinds.exec, &mods, &code) { + k_updater.set_event(KeyEvent::Exec); + } else if matches(&keybinds.exit, &mods, &code) { + k_updater.set_event(KeyEvent::Exit); + } else if matches(&keybinds.move_up, &mods, &code) { + k_updater.set_event(KeyEvent::ShiftUp); + } else if matches(&keybinds.move_down, &mods, &code) { + k_updater.set_event(KeyEvent::ShiftDown); + } else if matches(&keybinds.open_menu, &mods, &code) { + k_updater.set_event(KeyEvent::OpenMenu); + } else if matches(&keybinds.close_menu, &mods, &code) { + k_updater.set_event(KeyEvent::CloseMenu); } - // always set focus back on input - focus(cx); }; - // pre-render results into elements - let results_rendered: Vec = results - .iter() - .enumerate() - .map(|(index, entry)| { - cx.render(rsx! { - TableEntry{ - index: index, - entry: entry, - config: &cx.props.config, - pos: pos, - subpos: subpos, - } - }) + // handle keyboard events + state.handle_events(cx); + + // render results objects + let rendered_results = results.iter().enumerate().map(|(i, e)| { + let state = state.partial_copy(); + cx.render(rsx! { + TableEntry{ + pos: pos, + subpos: subpos, + index: i, + entry: e, + state: state, + } }) - .collect(); + }); cx.render(rsx! { style { "{cx.props.css}" } div { - onkeydown: keyboard_evt, onclick: |_| focus(cx), - input { - id: "search", - value: "{search}", - oninput: move |evt| search.set(evt.value.clone()), - + onkeydown: keyboard_controls, + div { + class: "navbar", + input { + id: "search", + value: "{search}", + oninput: move |evt| s_updater.set_search(evt.value.clone()), + } + } + div { + class: "results", + rendered_results.into_iter() } - results_rendered.into_iter() } }) } diff --git a/rmenu/src/main.rs b/rmenu/src/main.rs index 4bcdc3c..2e41343 100644 --- a/rmenu/src/main.rs +++ b/rmenu/src/main.rs @@ -218,13 +218,14 @@ fn main() -> Result<(), RMenuError> { if std::env::var("RUST_LOG").is_ok() { env_logger::init(); } + // parse cli / config / application-settings + let app = Args::parse_app()?; // change directory to configuration dir let cfgdir = shellexpand::tilde(CONFIG_DIR).to_string(); if let Err(err) = std::env::set_current_dir(&cfgdir) { log::error!("failed to change directory: {err:?}"); } - // parse cli / config / application-settings - let app = Args::parse_app()?; + // run gui gui::run(app); Ok(()) } diff --git a/rmenu/src/state.rs b/rmenu/src/state.rs index 5cd7e9c..81cc092 100644 --- a/rmenu/src/state.rs +++ b/rmenu/src/state.rs @@ -1,76 +1,237 @@ -//! GUI Application State Trackers and Utilities -use dioxus::prelude::{use_state, Scope, UseState}; -use rmenu_plugin::{Action, Entry}; +use dioxus::prelude::{use_ref, Scope, UseRef}; +use rmenu_plugin::Entry; +use crate::config::Config; +use crate::exec::execute; +use crate::search::new_searchfn; use crate::App; -#[derive(PartialEq)] -pub struct PosTracker<'a> { - pos: &'a UseState, - subpos: &'a UseState, - results: Vec<&'a Entry>, +#[inline] +fn scroll(cx: Scope, pos: usize) { + let eval = dioxus_desktop::use_eval(cx); + let js = format!("document.getElementById(`result-{pos}`).scrollIntoView(false)"); + eval(js); } -impl<'a> PosTracker<'a> { - pub fn new(cx: Scope<'a, App>, results: Vec<&'a Entry>) -> Self { - let pos = use_state(cx, || 0); - let subpos = use_state(cx, || 0); - Self { - pos, - subpos, - results, - } - } +#[derive(Debug, PartialEq, Clone)] +pub enum KeyEvent { + Exec, + Exit, + ShiftUp, + ShiftDown, + OpenMenu, + CloseMenu, +} + +pub struct InnerState { + pos: usize, + subpos: usize, + page: usize, + search: String, + event: Option, +} + +impl InnerState { /// Move X Primary Results Upwards - pub fn move_up(&self, x: usize) { - self.subpos.set(0); - self.pos.modify(|v| if v >= &x { v - x } else { 0 }) + pub fn move_up(&mut self, x: usize) { + self.subpos = 0; + self.pos = std::cmp::max(self.pos, x) - x; } + /// Move X Primary Results Downwards - pub fn move_down(&self, x: usize) { - let max = std::cmp::max(self.results.len(), 1); - self.subpos.set(0); - self.pos.modify(|v| std::cmp::min(v + x, max - 1)) - } - /// Get Current Position/SubPosition - pub fn position(&self) -> (usize, usize) { - (self.pos.get().clone(), self.subpos.get().clone()) - } - /// Get Action Linked To The Current Position - pub fn action(&self) -> Option<&Action> { - let (pos, subpos) = self.position(); - self.results[pos].actions.get(subpos) - } - /// Move Position To SubMenu if it Exists - pub fn open_menu(&self) { - let index = *self.pos.get(); - let result = &self.results[index]; - if result.actions.len() > 0 { - self.subpos.set(1); - } - } - // Reset and Close SubMenu Position - pub fn close_menu(&self) { - self.subpos.set(0); + pub fn move_down(&mut self, x: usize, max: usize) { + self.subpos = 0; + self.pos = std::cmp::min(self.pos + x, max - 1) } + /// Move Up Once With Context of SubMenu - pub fn shift_up(&self) { - if self.subpos.get() > &0 { - self.subpos.modify(|v| v - 1); + pub fn shift_up(&mut self) { + if self.subpos > 0 { + self.subpos -= 1; return; } - self.move_up(1) + self.move_up(1); } + /// Move Down Once With Context of SubMenu - pub fn shift_down(&self) { - let index = *self.pos.get(); - if let Some(result) = &self.results.get(index) { - let subpos = *self.subpos.get(); - if subpos > 0 && subpos < result.actions.len() - 1 { - self.subpos.modify(|v| v + 1); + pub fn shift_down(&mut self, results: &Vec<&Entry>) { + if let Some(result) = results.get(self.pos) { + if self.subpos > 0 && self.subpos < result.actions.len() - 1 { + self.subpos += 1; return; } } - self.move_down(1) + let max = std::cmp::max(results.len(), 1); + self.move_down(1, max); + } +} + +#[derive(PartialEq)] +pub struct AppState<'a> { + state: &'a UseRef, + app: &'a App, + results: Vec<&'a Entry>, +} + +impl<'a> AppState<'a> { + /// Spawn new Application State Tracker + pub fn new(cx: Scope<'a, T>, app: &'a App) -> Self { + Self { + state: use_ref(cx, || InnerState { + pos: 0, + subpos: 0, + page: 0, + search: "".to_string(), + event: None, + }), + app, + results: vec![], + } + } + + /// Create Partial Copy of Self (Not Including Results) + pub fn partial_copy(&self) -> Self { + Self { + state: self.state, + app: self.app, + results: vec![], + } + } + + /// Retrieve Configuration + #[inline] + pub fn config(&self) -> &Config { + &self.app.config + } + + /// Retrieve Current Position State + #[inline] + pub fn position(&self) -> (usize, usize) { + self.state.with(|s| (s.pos, s.subpos)) + } + + /// Retrieve Current Search String + #[inline] + pub fn search(&self) -> String { + self.state.with(|s| s.search.clone()) + } + + /// Execute the Current Action + pub fn execute(&self) { + let (pos, subpos) = self.position(); + println!("double click {pos} {subpos}"); + let Some(result) = self.results.get(pos) else { + return; + }; + println!("result: {result:?}"); + let Some(action) = result.actions.get(subpos) else { + return; + }; + println!("action: {action:?}"); + execute(action); + } + + /// Set Current Key/Action for Later Evaluation + #[inline] + pub fn set_event(&self, event: KeyEvent) { + self.state.with_mut(|s| s.event = Some(event)); + } + + /// React to Previously Activated KeyEvents + pub fn handle_events(&self, cx: Scope<'a, App>) { + match self.state.with(|s| s.event.clone()) { + None => {} + Some(event) => { + match event { + KeyEvent::Exit => std::process::exit(0), + KeyEvent::Exec => self.execute(), + KeyEvent::OpenMenu => self.open_menu(), + KeyEvent::CloseMenu => self.close_menu(), + KeyEvent::ShiftUp => { + self.shift_up(); + scroll(cx, self.position().0) + } + KeyEvent::ShiftDown => { + self.shift_down(); + scroll(cx, self.position().0) + } + }; + self.state.with_mut(|s| s.event = None); + } + } + } + + /// Generate and return Results PTR + pub fn results(&mut self, entries: &'a Vec) -> Vec<&'a Entry> { + let ratio = self.app.config.page_load; + let page_size = self.app.config.page_size; + let (pos, page, search) = self.state.with(|s| (s.pos, s.page, s.search.clone())); + // determine current page based on position and configuration + let next = (pos % page_size) as f64 / page_size as f64 > ratio; + let pos_page = (pos + 1) / page_size + 1 + next as usize; + let new_page = std::cmp::max(pos_page, page); + let index = page_size * new_page; + // update page counter if higher than before + if new_page > page { + self.state.with_mut(|s| s.page = new_page); + } + // render results and stop at page-limit + let sfn = new_searchfn(&self.app.config, &search); + self.results = entries.iter().filter(|e| sfn(e)).take(index).collect(); + self.results.clone() + } + + /// Update Search and Reset Position + pub fn set_search(&self, search: String) { + self.state.with_mut(|s| { + s.pos = 0; + s.subpos = 0; + s.search = search; + }); + } + + /// Manually Set Position/SubPosition (with Click) + pub fn set_position(&self, pos: usize, subpos: usize) { + self.state.with_mut(|s| { + s.pos = pos; + s.subpos = subpos; + }) + } + + /// Automatically Increase PageCount When Nearing Bottom + pub fn scroll_down(&self) { + self.state.with_mut(|s| { + if self.app.config.page_size * s.page < self.app.entries.len() { + s.page += 1; + } + }); + } + + /// Move Position To SubMenu if it Exists + pub fn open_menu(&self) { + let pos = self.state.with(|s| s.pos); + if let Some(result) = self.results.get(pos) { + if result.actions.len() > 1 { + self.state.with_mut(|s| s.subpos += 1); + } + } + } + + // Reset and Close SubMenu Position + #[inline] + pub fn close_menu(&self) { + self.state.with_mut(|s| s.subpos = 0); + } + + /// Move Up Once With Context of SubMenu + #[inline] + pub fn shift_up(&self) { + self.state.with_mut(|s| s.shift_up()); + } + + /// Move Down Once With Context of SubMenu + #[inline] + pub fn shift_down(&self) { + self.state.with_mut(|s| s.shift_down(&self.results)) } } From 41c7c65528071891467532b23b2777413482e5d5 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Mon, 24 Jul 2023 21:53:23 -0700 Subject: [PATCH 12/37] feat: impl simple run plugin --- Cargo.toml | 5 ++- plugin-desktop/Cargo.toml | 8 +++++ plugin-desktop/src/main.rs | 3 ++ plugin-run/Cargo.toml | 11 +++++++ plugin-run/src/main.rs | 64 ++++++++++++++++++++++++++++++++++++++ rmenu-plugin/src/lib.rs | 16 ++++++++-- 6 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 plugin-desktop/Cargo.toml create mode 100644 plugin-desktop/src/main.rs create mode 100644 plugin-run/Cargo.toml create mode 100644 plugin-run/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index 6b863fa..9b94371 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,5 +2,8 @@ resolver = "2" members = [ "rmenu", - "rmenu-plugin" + "rmenu-plugin", + "plugin-run", + "plugin-desktop", + "rtest", ] diff --git a/plugin-desktop/Cargo.toml b/plugin-desktop/Cargo.toml new file mode 100644 index 0000000..e2ddac0 --- /dev/null +++ b/plugin-desktop/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "desktop" +version = "0.0.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] \ No newline at end of file diff --git a/plugin-desktop/src/main.rs b/plugin-desktop/src/main.rs new file mode 100644 index 0000000..e7a11a9 --- /dev/null +++ b/plugin-desktop/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} diff --git a/plugin-run/Cargo.toml b/plugin-run/Cargo.toml new file mode 100644 index 0000000..59999c5 --- /dev/null +++ b/plugin-run/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "run" +version = "0.0.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +rmenu-plugin = { version = "0.0.0", path = "../rmenu-plugin" } +serde_json = "1.0.103" +walkdir = "2.3.3" diff --git a/plugin-run/src/main.rs b/plugin-run/src/main.rs new file mode 100644 index 0000000..def09ca --- /dev/null +++ b/plugin-run/src/main.rs @@ -0,0 +1,64 @@ +use std::env; +use std::os::unix::fs::PermissionsExt; + +use rmenu_plugin::Entry; +use walkdir::{DirEntry, WalkDir}; + +static PATH: &'static str = "PATH"; +static DEFAULT_PATH: &'static str = "/bin:/usr/bin:/usr/sbin"; +static EXEC_FLAG: u32 = 0o111; + +/// Retrieve Search Paths from OS-VAR or Default +fn bin_paths() -> Vec { + env::var(PATH) + .unwrap_or_else(|_| DEFAULT_PATH.to_string()) + .split(":") + .map(|s| s.to_string()) + .collect() +} + +/// Ignore Entry if Hidden or Filename contains a `.` +fn should_ignore(entry: &DirEntry) -> bool { + entry + .file_name() + .to_str() + .map(|s| s.contains(".")) + .unwrap_or(false) +} + +/// Retrieve Binaries for the Specified Paths +fn find_binaries(path: String) -> Vec { + WalkDir::new(path) + .follow_links(true) + .into_iter() + .filter_entry(|e| !should_ignore(e)) + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + .filter(|e| { + e.metadata() + .map(|m| m.permissions().mode() & EXEC_FLAG != 0) + .unwrap_or(false) + }) + .map(|e| { + let path = e.path().to_string_lossy(); + Entry::new(&e.file_name().to_string_lossy(), &path, Some(&path)) + }) + .collect() +} + +fn main() { + // collect entries for sorting + let mut entries: Vec = bin_paths() + .into_iter() + .map(find_binaries) + .flatten() + .collect(); + // sort entries and render to json + entries.sort_by_cached_key(|e| e.name.clone()); + entries + .into_iter() + .map(|e| serde_json::to_string(&e)) + .filter_map(|r| r.ok()) + .map(|s| println!("{}", s)) + .last(); +} diff --git a/rmenu-plugin/src/lib.rs b/rmenu-plugin/src/lib.rs index 2a10945..47eab2f 100644 --- a/rmenu-plugin/src/lib.rs +++ b/rmenu-plugin/src/lib.rs @@ -13,6 +13,16 @@ pub struct Action { pub comment: Option, } +impl Action { + pub fn new(exec: &str) -> Self { + Self { + name: "main".to_string(), + exec: exec.to_string(), + comment: None, + } + } +} + #[derive(Debug, PartialEq, Serialize, Deserialize)] pub struct Entry { pub name: String, @@ -22,11 +32,11 @@ pub struct Entry { } impl Entry { - pub fn new(name: &str) -> Self { + pub fn new(name: &str, action: &str, comment: Option<&str>) -> Self { Self { name: name.to_owned(), - actions: Default::default(), - comment: Default::default(), + actions: vec![Action::new(action)], + comment: comment.map(|c| c.to_owned()), icon: Default::default(), } } From f4af594cd9f68cd100c633d41e0113a920f0bbf9 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Mon, 24 Jul 2023 23:47:04 -0700 Subject: [PATCH 13/37] feat: simplify search generation, avoid using regex-pattern. update cli opts --- rmenu/Cargo.toml | 2 +- rmenu/src/main.rs | 7 ++++- rmenu/src/search.rs | 64 +++++++++++++++++++++++---------------------- 3 files changed, 40 insertions(+), 33 deletions(-) diff --git a/rmenu/Cargo.toml b/rmenu/Cargo.toml index c9e14bc..049c08c 100644 --- a/rmenu/Cargo.toml +++ b/rmenu/Cargo.toml @@ -13,7 +13,7 @@ env_logger = "0.10.0" heck = "0.4.1" keyboard-types = "0.6.2" log = "0.4.19" -regex = { version = "1.9.1", features = ["pattern"] } +regex = { version = "1.9.1" } rmenu-plugin = { version = "0.0.0", path = "../rmenu-plugin" } serde = { version = "1.0.171", features = ["derive"] } serde_json = "1.0.103" diff --git a/rmenu/src/main.rs b/rmenu/src/main.rs index 2e41343..1b04c9d 100644 --- a/rmenu/src/main.rs +++ b/rmenu/src/main.rs @@ -82,6 +82,8 @@ pub struct Args { format: Format, #[arg(short, long)] run: Vec, + #[arg(long)] + regex: Option, #[arg(short, long)] config: Option, #[arg(long)] @@ -184,7 +186,7 @@ impl Args { /// Load Application pub fn parse_app() -> Result { let args = Self::parse(); - let config = args.config()?; + let mut config = args.config()?; // load css files from settings let csspath = args.css.clone().unwrap_or_else(|| DEFAULT_CSS.to_owned()); let csspath = shellexpand::tilde(&csspath).to_string(); @@ -200,6 +202,9 @@ impl Args { true => args.load_sources(&config)?, false => args.load_default(&config)?, }; + // update configuration based on cli + config.use_icons = config.use_icons && entries.iter().any(|e| e.icon.is_some()); + config.search_regex = args.regex.unwrap_or(config.search_regex); // generate app object return Ok(App { css, diff --git a/rmenu/src/search.rs b/rmenu/src/search.rs index d9e2504..1ec0cef 100644 --- a/rmenu/src/search.rs +++ b/rmenu/src/search.rs @@ -4,47 +4,49 @@ use rmenu_plugin::Entry; use crate::config::Config; -macro_rules! search { - ($search:expr) => { - Box::new(move |entry: &Entry| { - if entry.name.contains($search) { - return true; - } - if let Some(comment) = entry.comment.as_ref() { - return comment.contains($search); - } - false - }) - }; - ($search:expr,$mod:ident) => { - Box::new(move |entry: &Entry| { - if entry.name.$mod().contains($search) { - return true; - } - if let Some(comment) = entry.comment.as_ref() { - return comment.$mod().contains($search); - } - false - }) - }; -} - /// Generate a new dynamic Search Function based on /// Configurtaion Settings and Search-String pub fn new_searchfn(cfg: &Config, search: &str) -> Box bool> { + // build regex search expression if cfg.search_regex { - let regex = RegexBuilder::new(search) + let rgx = RegexBuilder::new(search) .case_insensitive(cfg.ignore_case) .build(); - return match regex { - Ok(rgx) => search!(&rgx), - Err(_) => Box::new(|_| false), + let Ok(regex) = rgx else { + return Box::new(|_| false); }; + return Box::new(move |entry: &Entry| { + if regex.is_match(&entry.name) { + return true; + } + if let Some(comment) = entry.comment.as_ref() { + return regex.is_match(&comment); + } + false + }); } + // build case-insensitive search expression if cfg.ignore_case { let matchstr = search.to_lowercase(); - return search!(&matchstr, to_lowercase); + return Box::new(move |entry: &Entry| { + if entry.name.to_lowercase().contains(&matchstr) { + return true; + } + if let Some(comment) = entry.comment.as_ref() { + return comment.to_lowercase().contains(&matchstr); + } + false + }); } + // build standard normal string comparison function let matchstr = search.to_owned(); - return search!(&matchstr); + Box::new(move |entry: &Entry| { + if entry.name.contains(&matchstr) { + return true; + } + if let Some(comment) = entry.comment.as_ref() { + return comment.contains(&matchstr); + } + false + }) } From f34f5870b60491f410a551f46d683c10835208c5 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Mon, 24 Jul 2023 23:48:03 -0700 Subject: [PATCH 14/37] feat: more default css --- rmenu/public/default.css | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/rmenu/public/default.css b/rmenu/public/default.css index cf14845..d12e4aa 100644 --- a/rmenu/public/default.css +++ b/rmenu/public/default.css @@ -1,17 +1,37 @@ + +body { + overflow: hidden; +} + main { display: flex; flex-direction: column; justify-content: center; } -input { +div.navbar { + top: 0; + position: fixed; + overflow: hidden; min-width: 99%; } +div.results { + height: 100vh; + margin-top: 50px; + overflow-y: auto; +} + div.selected { background-color: lightblue; } +/* Navigation */ + +input { + width: 99%; +} + /* Result CSS */ div.result, div.action { From 1aa6dd1f531d77313c98bf82f8709d15451603b6 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Tue, 25 Jul 2023 22:24:30 -0700 Subject: [PATCH 15/37] feat: most of desktop retrieval impl done --- plugin-desktop/Cargo.toml | 8 +- plugin-desktop/src/main.rs | 156 ++++++++++++++++++++++++++++++++++++- 2 files changed, 161 insertions(+), 3 deletions(-) diff --git a/plugin-desktop/Cargo.toml b/plugin-desktop/Cargo.toml index e2ddac0..baa4064 100644 --- a/plugin-desktop/Cargo.toml +++ b/plugin-desktop/Cargo.toml @@ -5,4 +5,10 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html -[dependencies] \ No newline at end of file +[dependencies] +freedesktop-desktop-entry = "0.5.0" +rmenu-plugin = { version = "0.0.0", path = "../rmenu-plugin" } +rust-ini = { version = "0.19.0", features = ["unicase"] } +serde_json = "1.0.103" +shellexpand = "3.1.0" +walkdir = "2.3.3" diff --git a/plugin-desktop/src/main.rs b/plugin-desktop/src/main.rs index e7a11a9..42b23ab 100644 --- a/plugin-desktop/src/main.rs +++ b/plugin-desktop/src/main.rs @@ -1,3 +1,155 @@ -fn main() { - println!("Hello, world!"); +use std::collections::HashMap; +use std::path::PathBuf; +use std::{fs::read_to_string, path::Path}; + +use freedesktop_desktop_entry::DesktopEntry; +use ini::Ini; +use rmenu_plugin::{Action, Entry}; +use walkdir::WalkDir; + +static XDG_DATA_ENV: &'static str = "XDG_DATA_DIRS"; +static XDG_CONFIG_ENV: &'static str = "XDG_CONFIG_HOME"; +static XDG_DATA_DEFAULT: &'static str = "/usr/share:/usr/local/share"; +static XDG_CONFIG_DEFAULT: &'static str = "~/.config"; +static DEFAULT_THEME: &'static str = "hicolor"; + +/// Retrieve XDG-CONFIG-HOME Directory +#[inline] +fn config_dir(dir: &str) -> PathBuf { + let path = std::env::var(XDG_CONFIG_ENV).unwrap_or_else(|_| XDG_CONFIG_DEFAULT.to_string()); + PathBuf::from(shellexpand::tilde(&path).to_string()) +} + +/// Determine XDG Icon Theme based on Preexisting Configuration Files +fn find_theme(cfgdir: &PathBuf) -> String { + vec![ + ("kdeglobals", "Icons", "Theme"), + ("gtk-3.0/settings.ini", "Settings", "gtk-icon-theme-name"), + ("gtk-4.0/settings.ini", "Settings", "gtk-icon-theme-name"), + ] + .into_iter() + .find_map(|(path, sec, key)| { + let path = cfgdir.join(path); + let ini = Ini::load_from_file(path).ok()?; + ini.get_from(Some(sec), key).map(|s| s.to_string()) + }) + .unwrap_or_else(|| DEFAULT_THEME.to_string()) +} + +type IconGroup = HashMap; +type Icons = HashMap; + +/// Parse and Categorize Icons Within the Specified Path +fn find_icons(path: &PathBuf) -> Icons { + WalkDir::new(path) + // collect list of directories of icon subdirs + .max_depth(1) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_dir()) + .filter_map(|e| { + let name = e.file_name().to_str()?.to_string(); + Some((name, e.path().to_owned())) + }) + // iterate content within subdirs + .map(|(name, path)| { + let group = WalkDir::new(path) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + .filter_map(|e| { + let name = e.file_name().to_str()?.to_string(); + if name.ends_with(".png") || name.ends_with(".svg") { + let icon = name.rsplit_once(".").map(|(i, _)| i).unwrap_or(&name); + return Some((icon.to_owned(), e.path().to_owned())); + } + None + }) + .collect(); + (name, group) + }) + .collect() +} + +/// Find Best Icon Match for the Given Name +fn match_icon<'a>(icons: &'a Icons, name: &str, size: usize) -> Option<&'a PathBuf> { + todo!("implement icon matching to specified name") +} + +/// Retrieve XDG-DATA Directories +fn data_dirs(dir: &str) -> Vec { + std::env::var(XDG_DATA_ENV) + .unwrap_or_else(|_| XDG_DATA_DEFAULT.to_string()) + .split(":") + .map(|p| shellexpand::tilde(p).to_string()) + .map(PathBuf::from) + .map(|p| p.join(dir.to_owned())) + .collect() +} + +/// Parse XDG Desktop Entry into RMenu Entry +fn parse_desktop(path: &Path, locale: Option<&str>) -> Option { + let bytes = read_to_string(path).ok()?; + let entry = DesktopEntry::decode(&path, &bytes).ok()?; + let name = entry.name(locale)?.to_string(); + let icon = entry.icon().map(|s| s.to_string()); + let comment = entry.comment(locale).map(|s| s.to_string()); + let actions: Vec = entry + .actions()? + .split(";") + .into_iter() + .filter(|a| a.len() > 0) + .filter_map(|a| { + let name = entry.action_name(a, locale)?; + let exec = entry.action_exec(a)?; + Some(Action { + name: name.to_string(), + exec: exec.to_string(), + comment: None, + }) + }) + .collect(); + Some(Entry { + name, + actions, + comment, + icon, + }) +} + +/// Iterate Path and Parse All `.desktop` files into Entries +fn find_desktops(path: PathBuf, locale: Option<&str>) -> Vec { + WalkDir::new(path) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().ends_with(".desktop")) + .filter(|e| e.file_type().is_file()) + .filter_map(|e| parse_desktop(e.path(), locale)) + .collect() +} + +fn main() { + let path = PathBuf::from("/usr/share/icons/hicolor"); + let icons = find_icons(&path); + icons + .into_iter() + .map(|(k, v)| { + println!("category: {k:?}"); + v.into_iter() + .map(|(name, path)| { + println!(" - {name:?}"); + }) + .last() + }) + .last(); + + // data_dirs("applications") + // .into_iter() + // .map(|p| find_desktops(p, locale)) + // .flatten() + // .filter_map(|e| serde_json::to_string(&e).ok()) + // .map(|s| println!("{}", s)) + // .last(); } From f8d0ce85eaf03502aa557ea0c6822a8ad82ee00f Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Fri, 28 Jul 2023 23:07:06 -0700 Subject: [PATCH 16/37] feat: icons implemented --- plugin-desktop/Cargo.toml | 1 + plugin-desktop/src/main.rs | 157 ++++++++++++++++++++++++++----------- 2 files changed, 114 insertions(+), 44 deletions(-) diff --git a/plugin-desktop/Cargo.toml b/plugin-desktop/Cargo.toml index baa4064..1936159 100644 --- a/plugin-desktop/Cargo.toml +++ b/plugin-desktop/Cargo.toml @@ -11,4 +11,5 @@ rmenu-plugin = { version = "0.0.0", path = "../rmenu-plugin" } rust-ini = { version = "0.19.0", features = ["unicase"] } serde_json = "1.0.103" shellexpand = "3.1.0" +thiserror = "1.0.44" walkdir = "2.3.3" diff --git a/plugin-desktop/src/main.rs b/plugin-desktop/src/main.rs index 42b23ab..4f4209f 100644 --- a/plugin-desktop/src/main.rs +++ b/plugin-desktop/src/main.rs @@ -1,10 +1,11 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::{fs::read_to_string, path::Path}; use freedesktop_desktop_entry::DesktopEntry; use ini::Ini; use rmenu_plugin::{Action, Entry}; +use thiserror::Error; use walkdir::WalkDir; static XDG_DATA_ENV: &'static str = "XDG_DATA_DIRS"; @@ -13,35 +14,71 @@ static XDG_DATA_DEFAULT: &'static str = "/usr/share:/usr/local/share"; static XDG_CONFIG_DEFAULT: &'static str = "~/.config"; static DEFAULT_THEME: &'static str = "hicolor"; +#[derive(Error, Debug)] +enum ProcessError { + #[error("Failed to Read Desktop File")] + FileError(#[from] std::io::Error), + #[error("Invalid Desktop File")] + InvalidFile(#[from] freedesktop_desktop_entry::DecodeError), + #[error("No Such Attribute")] + InvalidAttr(&'static str), +} + /// Retrieve XDG-CONFIG-HOME Directory #[inline] -fn config_dir(dir: &str) -> PathBuf { +fn config_dir() -> PathBuf { let path = std::env::var(XDG_CONFIG_ENV).unwrap_or_else(|_| XDG_CONFIG_DEFAULT.to_string()); PathBuf::from(shellexpand::tilde(&path).to_string()) } /// Determine XDG Icon Theme based on Preexisting Configuration Files -fn find_theme(cfgdir: &PathBuf) -> String { - vec![ +fn find_theme(cfgdir: &PathBuf) -> Vec { + let mut themes: Vec = vec![ ("kdeglobals", "Icons", "Theme"), - ("gtk-3.0/settings.ini", "Settings", "gtk-icon-theme-name"), ("gtk-4.0/settings.ini", "Settings", "gtk-icon-theme-name"), + ("gtk-3.0/settings.ini", "Settings", "gtk-icon-theme-name"), ] .into_iter() - .find_map(|(path, sec, key)| { + .filter_map(|(path, sec, key)| { let path = cfgdir.join(path); let ini = Ini::load_from_file(path).ok()?; ini.get_from(Some(sec), key).map(|s| s.to_string()) }) - .unwrap_or_else(|| DEFAULT_THEME.to_string()) + .collect(); + let default = DEFAULT_THEME.to_string(); + if !themes.contains(&default) { + themes.push(default); + } + themes } type IconGroup = HashMap; type Icons = HashMap; +/// Precalculate prefferred sizes folders +fn calculate_sizes(range: (usize, usize, usize)) -> HashSet { + let (min, preffered, max) = range; + let mut size = preffered.clone(); + let mut sizes = HashSet::new(); + while size < max { + sizes.insert(format!("{size}x{size}")); + sizes.insert(format!("{size}x{size}@2")); + size *= 2; + } + // attempt to match sizes down to lowest minimum + let mut size = preffered.clone(); + while size > min { + sizes.insert(format!("{size}x{size}")); + sizes.insert(format!("{size}x{size}@2")); + size /= 2; + } + sizes +} + /// Parse and Categorize Icons Within the Specified Path -fn find_icons(path: &PathBuf) -> Icons { - WalkDir::new(path) +fn find_icons(path: &PathBuf, sizes: (usize, usize, usize)) -> Vec { + let sizes = calculate_sizes(sizes); + let icons: Icons = WalkDir::new(path) // collect list of directories of icon subdirs .max_depth(1) .into_iter() @@ -69,12 +106,22 @@ fn find_icons(path: &PathBuf) -> Icons { .collect(); (name, group) }) - .collect() -} - -/// Find Best Icon Match for the Given Name -fn match_icon<'a>(icons: &'a Icons, name: &str, size: usize) -> Option<&'a PathBuf> { - todo!("implement icon matching to specified name") + .collect(); + // organize icon groups according to prefference + let mut priority = vec![]; + let mut others = vec![]; + icons + .into_iter() + .map(|(folder, group)| match sizes.contains(&folder) { + true => priority.push(group), + false => match folder.contains("x") { + false => others.push(group), + _ => {} + }, + }) + .last(); + priority.append(&mut others); + priority } /// Retrieve XDG-DATA Directories @@ -85,18 +132,23 @@ fn data_dirs(dir: &str) -> Vec { .map(|p| shellexpand::tilde(p).to_string()) .map(PathBuf::from) .map(|p| p.join(dir.to_owned())) + .filter(|p| p.exists()) .collect() } /// Parse XDG Desktop Entry into RMenu Entry -fn parse_desktop(path: &Path, locale: Option<&str>) -> Option { - let bytes = read_to_string(path).ok()?; - let entry = DesktopEntry::decode(&path, &bytes).ok()?; - let name = entry.name(locale)?.to_string(); +fn parse_desktop(path: &Path, locale: Option<&str>) -> Result { + let bytes = read_to_string(path)?; + let entry = DesktopEntry::decode(&path, &bytes)?; + let name = entry + .name(locale) + .ok_or(ProcessError::InvalidAttr("Name"))? + .to_string(); let icon = entry.icon().map(|s| s.to_string()); let comment = entry.comment(locale).map(|s| s.to_string()); let actions: Vec = entry - .actions()? + .actions() + .unwrap_or("") .split(";") .into_iter() .filter(|a| a.len() > 0) @@ -110,7 +162,7 @@ fn parse_desktop(path: &Path, locale: Option<&str>) -> Option { }) }) .collect(); - Some(Entry { + Ok(Entry { name, actions, comment, @@ -126,30 +178,47 @@ fn find_desktops(path: PathBuf, locale: Option<&str>) -> Vec { .filter_map(|e| e.ok()) .filter(|e| e.file_name().to_string_lossy().ends_with(".desktop")) .filter(|e| e.file_type().is_file()) - .filter_map(|e| parse_desktop(e.path(), locale)) + .filter_map(|e| parse_desktop(e.path(), locale).ok()) .collect() } -fn main() { - let path = PathBuf::from("/usr/share/icons/hicolor"); - let icons = find_icons(&path); - icons - .into_iter() - .map(|(k, v)| { - println!("category: {k:?}"); - v.into_iter() - .map(|(name, path)| { - println!(" - {name:?}"); - }) - .last() - }) - .last(); - - // data_dirs("applications") - // .into_iter() - // .map(|p| find_desktops(p, locale)) - // .flatten() - // .filter_map(|e| serde_json::to_string(&e).ok()) - // .map(|s| println!("{}", s)) - // .last(); +/// Find and Assign Icons from Icon-Cache when Possible +fn assign_icons(icons: &Vec, mut e: Entry) -> Entry { + if let Some(name) = e.icon.as_ref() { + if !name.contains("/") { + if let Some(path) = icons.iter().find_map(|i| i.get(name)) { + if let Some(fpath) = path.to_str() { + e.icon = Some(fpath.to_owned()); + } + } + } + } + e +} + +fn main() { + let locale = Some("en"); + let sizes = (32, 64, 128); + // build a collection of icons for configured themes + let cfgdir = config_dir(); + let themes = find_theme(&cfgdir); + let icons: Vec = data_dirs("icons") + // generate list of icon-paths that exist + .iter() + .map(|d| themes.iter().map(|t| d.join(t))) + .flatten() + .filter(|t| t.exists()) + .map(|t| find_icons(&t, sizes)) + .flatten() + .collect(); + + // retrieve desktop applications and assign icons before printing results + data_dirs("applications") + .into_iter() + .map(|p| find_desktops(p, locale)) + .flatten() + .map(|e| assign_icons(&icons, e)) + .filter_map(|e| serde_json::to_string(&e).ok()) + .map(|s| println!("{}", s)) + .last(); } From fd147364c8b2ea54dc59d98eedd845163b346925 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Sat, 29 Jul 2023 20:30:20 -0700 Subject: [PATCH 17/37] feat: remove unessesary error assignment --- plugin-desktop/Cargo.toml | 1 - plugin-desktop/src/main.rs | 67 ++++++++++++++++++-------------------- 2 files changed, 31 insertions(+), 37 deletions(-) diff --git a/plugin-desktop/Cargo.toml b/plugin-desktop/Cargo.toml index 1936159..baa4064 100644 --- a/plugin-desktop/Cargo.toml +++ b/plugin-desktop/Cargo.toml @@ -11,5 +11,4 @@ rmenu-plugin = { version = "0.0.0", path = "../rmenu-plugin" } rust-ini = { version = "0.19.0", features = ["unicase"] } serde_json = "1.0.103" shellexpand = "3.1.0" -thiserror = "1.0.44" walkdir = "2.3.3" diff --git a/plugin-desktop/src/main.rs b/plugin-desktop/src/main.rs index 4f4209f..50f6848 100644 --- a/plugin-desktop/src/main.rs +++ b/plugin-desktop/src/main.rs @@ -5,7 +5,6 @@ use std::{fs::read_to_string, path::Path}; use freedesktop_desktop_entry::DesktopEntry; use ini::Ini; use rmenu_plugin::{Action, Entry}; -use thiserror::Error; use walkdir::WalkDir; static XDG_DATA_ENV: &'static str = "XDG_DATA_DIRS"; @@ -14,16 +13,6 @@ static XDG_DATA_DEFAULT: &'static str = "/usr/share:/usr/local/share"; static XDG_CONFIG_DEFAULT: &'static str = "~/.config"; static DEFAULT_THEME: &'static str = "hicolor"; -#[derive(Error, Debug)] -enum ProcessError { - #[error("Failed to Read Desktop File")] - FileError(#[from] std::io::Error), - #[error("Invalid Desktop File")] - InvalidFile(#[from] freedesktop_desktop_entry::DecodeError), - #[error("No Such Attribute")] - InvalidAttr(&'static str), -} - /// Retrieve XDG-CONFIG-HOME Directory #[inline] fn config_dir() -> PathBuf { @@ -137,32 +126,38 @@ fn data_dirs(dir: &str) -> Vec { } /// Parse XDG Desktop Entry into RMenu Entry -fn parse_desktop(path: &Path, locale: Option<&str>) -> Result { - let bytes = read_to_string(path)?; - let entry = DesktopEntry::decode(&path, &bytes)?; - let name = entry - .name(locale) - .ok_or(ProcessError::InvalidAttr("Name"))? - .to_string(); +fn parse_desktop(path: &Path, locale: Option<&str>) -> Option { + let bytes = read_to_string(path).ok()?; + let entry = DesktopEntry::decode(&path, &bytes).ok()?; + let name = entry.name(locale)?.to_string(); let icon = entry.icon().map(|s| s.to_string()); let comment = entry.comment(locale).map(|s| s.to_string()); - let actions: Vec = entry - .actions() - .unwrap_or("") - .split(";") - .into_iter() - .filter(|a| a.len() > 0) - .filter_map(|a| { - let name = entry.action_name(a, locale)?; - let exec = entry.action_exec(a)?; - Some(Action { - name: name.to_string(), - exec: exec.to_string(), - comment: None, - }) - }) - .collect(); - Ok(Entry { + let mut actions = match entry.exec() { + Some(exec) => vec![Action { + name: "main".to_string(), + exec: exec.to_string(), + comment: None, + }], + None => vec![], + }; + actions.extend( + entry + .actions() + .unwrap_or("") + .split(";") + .into_iter() + .filter(|a| a.len() > 0) + .filter_map(|a| { + let name = entry.action_name(a, locale)?; + let exec = entry.action_exec(a)?; + Some(Action { + name: name.to_string(), + exec: exec.to_string(), + comment: None, + }) + }), + ); + Some(Entry { name, actions, comment, @@ -178,7 +173,7 @@ fn find_desktops(path: PathBuf, locale: Option<&str>) -> Vec { .filter_map(|e| e.ok()) .filter(|e| e.file_name().to_string_lossy().ends_with(".desktop")) .filter(|e| e.file_type().is_file()) - .filter_map(|e| parse_desktop(e.path(), locale).ok()) + .filter_map(|e| parse_desktop(e.path(), locale)) .collect() } From a776eededd82079566e36aeedfffe7f60f8c56a8 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Tue, 1 Aug 2023 23:25:20 -0700 Subject: [PATCH 18/37] feat: auto-translate svgs --- rmenu/Cargo.toml | 5 +++++ rmenu/src/gui.rs | 34 ++++++++++++++++++++++++---------- rmenu/src/image.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ rmenu/src/main.rs | 1 + rmenu/src/state.rs | 8 +++++--- 5 files changed, 77 insertions(+), 13 deletions(-) create mode 100644 rmenu/src/image.rs diff --git a/rmenu/Cargo.toml b/rmenu/Cargo.toml index 049c08c..7c0b8cd 100644 --- a/rmenu/Cargo.toml +++ b/rmenu/Cargo.toml @@ -6,6 +6,8 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +base64 = "0.21.2" +cached = "0.44.0" clap = { version = "4.3.15", features = ["derive"] } dioxus = "0.3.2" dioxus-desktop = "0.3.0" @@ -13,7 +15,10 @@ env_logger = "0.10.0" heck = "0.4.1" keyboard-types = "0.6.2" log = "0.4.19" +png = "0.17.9" +quick-xml = "0.30.0" regex = { version = "1.9.1" } +resvg = "0.35.0" rmenu-plugin = { version = "0.0.0", path = "../rmenu-plugin" } serde = { version = "1.0.171", features = ["derive"] } serde_json = "1.0.103" diff --git a/rmenu/src/gui.rs b/rmenu/src/gui.rs index 50cfe0b..2a4d821 100644 --- a/rmenu/src/gui.rs +++ b/rmenu/src/gui.rs @@ -1,5 +1,7 @@ //! RMENU GUI Implementation using Dioxus #![allow(non_snake_case)] +use std::fs::read_to_string; + use dioxus::prelude::*; use keyboard_types::{Code, Modifiers}; use rmenu_plugin::Entry; @@ -40,6 +42,24 @@ struct GEntry<'a> { state: AppState<'a>, } +#[inline] +fn render_comment(comment: Option<&String>) -> String { + return comment.map(|s| s.as_str()).unwrap_or("").to_string(); +} + +#[inline] +fn render_image<'a, T>(cx: Scope<'a, T>, image: Option<&String>) -> Element<'a> { + if let Some(img) = image { + if img.ends_with(".svg") { + if let Some(content) = crate::image::convert_svg(img.to_owned()) { + return cx.render(rsx! { img { class: "image", src: "{content}" } }); + } + } + return cx.render(rsx! { img { class: "image", src: "{img}" } }); + } + None +} + /// render a single result entry w/ the given information fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { // build css classes for result and actions (if nessesary) @@ -81,9 +101,7 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { } div { class: "action-comment", - if let Some(comment) = action.comment.as_ref() { - format!("- {comment}") - } + render_comment(action.comment.as_ref()) } } }) @@ -99,9 +117,7 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { cx.render(rsx! { div { class: "icon", - if let Some(icon) = cx.props.entry.icon.as_ref() { - cx.render(rsx! { img { src: "{icon}" } }) - } + render_image(cx, cx.props.entry.icon.as_ref()) } }) } @@ -111,9 +127,7 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { } div { class: "comment", - if let Some(comment) = cx.props.entry.comment.as_ref() { - comment.to_string() - } + render_comment(cx.props.entry.comment.as_ref()) } } div { @@ -201,7 +215,7 @@ fn App<'a>(cx: Scope) -> Element { input { id: "search", value: "{search}", - oninput: move |evt| s_updater.set_search(evt.value.clone()), + oninput: move |evt| s_updater.set_search(cx, evt.value.clone()), } } div { diff --git a/rmenu/src/image.rs b/rmenu/src/image.rs new file mode 100644 index 0000000..2d617ac --- /dev/null +++ b/rmenu/src/image.rs @@ -0,0 +1,42 @@ +//! GUI Image Processing +use std::fs::read_to_string; + +use base64::{engine::general_purpose, Engine as _}; +use cached::proc_macro::cached; +use resvg::usvg::TreeParsing; +use thiserror::Error; + +#[derive(Debug, Error)] +enum SvgError { + #[error("Invalid SVG Filepath")] + InvalidFile(#[from] std::io::Error), + #[error("Invalid Document")] + InvalidTree(#[from] resvg::usvg::Error), + #[error("Failed to Alloc PixBuf")] + NoPixBuf, + #[error("Failed to Convert SVG to PNG")] + PngError(#[from] png::EncodingError), +} + +fn svg_to_png(path: &str) -> Result { + // read and convert to resvg document tree + let xml = read_to_string(path)?; + let opt = resvg::usvg::Options::default(); + let tree = resvg::usvg::Tree::from_str(&xml, &opt)?; + let rtree = resvg::Tree::from_usvg(&tree); + // generate pixel-buffer + let size = rtree.size.to_int_size(); + let mut pixmap = resvg::tiny_skia::Pixmap::new(size.width(), size.height()) + .ok_or_else(|| SvgError::NoPixBuf)?; + // render as png to memory + rtree.render(resvg::tiny_skia::Transform::default(), &mut pixmap.as_mut()); + let mut png = pixmap.encode_png()?; + // base64 encode png + let encoded = general_purpose::STANDARD.encode(&mut png); + Ok(format!("data:image/png;base64, {encoded}")) +} + +#[cached] +pub fn convert_svg(path: String) -> Option { + svg_to_png(&path).ok() +} diff --git a/rmenu/src/main.rs b/rmenu/src/main.rs index 1b04c9d..0043aaf 100644 --- a/rmenu/src/main.rs +++ b/rmenu/src/main.rs @@ -8,6 +8,7 @@ use std::str::FromStr; mod config; mod exec; mod gui; +mod image; mod search; mod state; diff --git a/rmenu/src/state.rs b/rmenu/src/state.rs index 81cc092..77c0afa 100644 --- a/rmenu/src/state.rs +++ b/rmenu/src/state.rs @@ -149,11 +149,12 @@ impl<'a> AppState<'a> { KeyEvent::CloseMenu => self.close_menu(), KeyEvent::ShiftUp => { self.shift_up(); - scroll(cx, self.position().0) + let pos = self.position().0; + scroll(cx, if pos <= 3 { pos } else { pos + 3 }) } KeyEvent::ShiftDown => { self.shift_down(); - scroll(cx, self.position().0) + scroll(cx, self.position().0 + 3) } }; self.state.with_mut(|s| s.event = None); @@ -182,12 +183,13 @@ impl<'a> AppState<'a> { } /// Update Search and Reset Position - pub fn set_search(&self, search: String) { + pub fn set_search(&self, cx: Scope<'_, App>, search: String) { self.state.with_mut(|s| { s.pos = 0; s.subpos = 0; s.search = search; }); + scroll(cx, 0); } /// Manually Set Position/SubPosition (with Click) From a14ce281677ab1fc4cd0f1f7dbc11a586b56c675 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Thu, 3 Aug 2023 13:54:47 -0700 Subject: [PATCH 19/37] feat: better icon collect coverage --- plugin-desktop/src/main.rs | 63 ++++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/plugin-desktop/src/main.rs b/plugin-desktop/src/main.rs index 50f6848..13a35a2 100644 --- a/plugin-desktop/src/main.rs +++ b/plugin-desktop/src/main.rs @@ -1,4 +1,5 @@ use std::collections::{HashMap, HashSet}; +use std::fs::FileType; use std::path::PathBuf; use std::{fs::read_to_string, path::Path}; @@ -64,18 +65,42 @@ fn calculate_sizes(range: (usize, usize, usize)) -> HashSet { sizes } +#[inline(always)] +fn is_valid_icon(name: &str) -> bool { + name.ends_with(".png") || name.ends_with(".svg") +} + +/// Parse Icon-Name from Filename +#[inline] +fn icon_name(name: &str) -> String { + name.rsplit_once(".") + .map(|(i, _)| i) + .unwrap_or(&name) + .to_owned() +} + /// Parse and Categorize Icons Within the Specified Path fn find_icons(path: &PathBuf, sizes: (usize, usize, usize)) -> Vec { let sizes = calculate_sizes(sizes); + let mut extras = IconGroup::new(); let icons: Icons = WalkDir::new(path) // collect list of directories of icon subdirs .max_depth(1) + .follow_links(true) .into_iter() .filter_map(|e| e.ok()) - .filter(|e| e.file_type().is_dir()) .filter_map(|e| { - let name = e.file_name().to_str()?.to_string(); - Some((name, e.path().to_owned())) + let name = e.file_name().to_str()?; + let path = e.path().to_owned(); + match e.file_type().is_dir() { + true => Some((name.to_owned(), path)), + false => { + if is_valid_icon(name) { + extras.insert(icon_name(name), path); + } + None + } + } }) // iterate content within subdirs .map(|(name, path)| { @@ -85,10 +110,9 @@ fn find_icons(path: &PathBuf, sizes: (usize, usize, usize)) -> Vec { .filter_map(|e| e.ok()) .filter(|e| e.file_type().is_file()) .filter_map(|e| { - let name = e.file_name().to_str()?.to_string(); - if name.ends_with(".png") || name.ends_with(".svg") { - let icon = name.rsplit_once(".").map(|(i, _)| i).unwrap_or(&name); - return Some((icon.to_owned(), e.path().to_owned())); + let name = e.file_name().to_str()?; + if is_valid_icon(name) { + return Some((icon_name(name), e.path().to_owned())); } None }) @@ -110,9 +134,27 @@ fn find_icons(path: &PathBuf, sizes: (usize, usize, usize)) -> Vec { }) .last(); priority.append(&mut others); + priority.push(extras); priority } +/// Retrieve Extras in Base Icon Directories +fn find_icon_extras(path: &PathBuf) -> IconGroup { + WalkDir::new(path) + .max_depth(1) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + .filter_map(|e| { + let name = e.file_name().to_str()?; + if is_valid_icon(name) { + return Some((icon_name(&name), e.path().to_owned())); + } + None + }) + .collect() +} + /// Retrieve XDG-DATA Directories fn data_dirs(dir: &str) -> Vec { std::env::var(XDG_DATA_ENV) @@ -197,16 +239,19 @@ fn main() { // build a collection of icons for configured themes let cfgdir = config_dir(); let themes = find_theme(&cfgdir); - let icons: Vec = data_dirs("icons") + let icon_paths = data_dirs("icons"); + let mut icons: Vec = icon_paths // generate list of icon-paths that exist .iter() .map(|d| themes.iter().map(|t| d.join(t))) .flatten() .filter(|t| t.exists()) + // append icon-paths within supported themes .map(|t| find_icons(&t, sizes)) .flatten() .collect(); - + // add extra icons found in base folders + icons.extend(icon_paths.iter().map(|p| find_icon_extras(p))); // retrieve desktop applications and assign icons before printing results data_dirs("applications") .into_iter() From 8a219e6044084ad0e3494d665c6ff30b4783fb31 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Thu, 3 Aug 2023 13:55:15 -0700 Subject: [PATCH 20/37] feat: implement image scaling on svg --- rmenu/src/image.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/rmenu/src/image.rs b/rmenu/src/image.rs index 2d617ac..d6b9c0c 100644 --- a/rmenu/src/image.rs +++ b/rmenu/src/image.rs @@ -18,18 +18,23 @@ enum SvgError { PngError(#[from] png::EncodingError), } -fn svg_to_png(path: &str) -> Result { +fn svg_to_png(path: &str, pixels: u32) -> Result { // read and convert to resvg document tree let xml = read_to_string(path)?; let opt = resvg::usvg::Options::default(); let tree = resvg::usvg::Tree::from_str(&xml, &opt)?; let rtree = resvg::Tree::from_usvg(&tree); - // generate pixel-buffer + // generate pixel-buffer and scale according to size preference let size = rtree.size.to_int_size(); - let mut pixmap = resvg::tiny_skia::Pixmap::new(size.width(), size.height()) - .ok_or_else(|| SvgError::NoPixBuf)?; + let scale = pixels / size.width(); + let width = size.width() * scale; + let height = size.height() * scale; + let fscale = scale as f32; + let mut pixmap = + resvg::tiny_skia::Pixmap::new(width, height).ok_or_else(|| SvgError::NoPixBuf)?; + let form = resvg::tiny_skia::Transform::from_scale(fscale, fscale); // render as png to memory - rtree.render(resvg::tiny_skia::Transform::default(), &mut pixmap.as_mut()); + rtree.render(form, &mut pixmap.as_mut()); let mut png = pixmap.encode_png()?; // base64 encode png let encoded = general_purpose::STANDARD.encode(&mut png); @@ -38,5 +43,5 @@ fn svg_to_png(path: &str) -> Result { #[cached] pub fn convert_svg(path: String) -> Option { - svg_to_png(&path).ok() + svg_to_png(&path, 64).ok() } From 2894bb257dc231e3b223d8fe36c1a8ce8d404415 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Thu, 3 Aug 2023 17:22:04 -0700 Subject: [PATCH 21/37] feat: impl terminal/normal run launch and better icon lookups --- plugin-desktop/Cargo.toml | 1 + plugin-desktop/src/main.rs | 33 ++++++++++++++++------- rmenu-plugin/src/lib.rs | 18 ++++++++++--- rmenu/Cargo.toml | 2 ++ rmenu/src/config.rs | 2 ++ rmenu/src/exec.rs | 54 +++++++++++++++++++++++++++++++++----- rmenu/src/state.rs | 8 +++--- 7 files changed, 94 insertions(+), 24 deletions(-) diff --git a/plugin-desktop/Cargo.toml b/plugin-desktop/Cargo.toml index baa4064..956c433 100644 --- a/plugin-desktop/Cargo.toml +++ b/plugin-desktop/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" [dependencies] freedesktop-desktop-entry = "0.5.0" +regex = "1.9.1" rmenu-plugin = { version = "0.0.0", path = "../rmenu-plugin" } rust-ini = { version = "0.19.0", features = ["unicase"] } serde_json = "1.0.103" diff --git a/plugin-desktop/src/main.rs b/plugin-desktop/src/main.rs index 13a35a2..0cbb306 100644 --- a/plugin-desktop/src/main.rs +++ b/plugin-desktop/src/main.rs @@ -1,11 +1,11 @@ use std::collections::{HashMap, HashSet}; -use std::fs::FileType; use std::path::PathBuf; use std::{fs::read_to_string, path::Path}; use freedesktop_desktop_entry::DesktopEntry; use ini::Ini; -use rmenu_plugin::{Action, Entry}; +use regex::Regex; +use rmenu_plugin::{Action, Entry, Method}; use walkdir::WalkDir; static XDG_DATA_ENV: &'static str = "XDG_DATA_DIRS"; @@ -167,17 +167,23 @@ fn data_dirs(dir: &str) -> Vec { .collect() } +#[inline(always)] +fn fix_exec(exec: &str, ematch: &Regex) -> String { + ematch.replace_all(exec, "").trim().to_string() +} + /// Parse XDG Desktop Entry into RMenu Entry -fn parse_desktop(path: &Path, locale: Option<&str>) -> Option { +fn parse_desktop(path: &Path, locale: Option<&str>, ematch: &Regex) -> Option { let bytes = read_to_string(path).ok()?; let entry = DesktopEntry::decode(&path, &bytes).ok()?; let name = entry.name(locale)?.to_string(); let icon = entry.icon().map(|s| s.to_string()); let comment = entry.comment(locale).map(|s| s.to_string()); + let terminal = entry.terminal(); let mut actions = match entry.exec() { Some(exec) => vec![Action { name: "main".to_string(), - exec: exec.to_string(), + exec: Method::new(fix_exec(exec, ematch), terminal), comment: None, }], None => vec![], @@ -194,7 +200,7 @@ fn parse_desktop(path: &Path, locale: Option<&str>) -> Option { let exec = entry.action_exec(a)?; Some(Action { name: name.to_string(), - exec: exec.to_string(), + exec: Method::new(fix_exec(exec, ematch), terminal), comment: None, }) }), @@ -208,14 +214,14 @@ fn parse_desktop(path: &Path, locale: Option<&str>) -> Option { } /// Iterate Path and Parse All `.desktop` files into Entries -fn find_desktops(path: PathBuf, locale: Option<&str>) -> Vec { +fn find_desktops(path: PathBuf, locale: Option<&str>, ematch: &Regex) -> Vec { WalkDir::new(path) .follow_links(true) .into_iter() .filter_map(|e| e.ok()) .filter(|e| e.file_name().to_string_lossy().ends_with(".desktop")) .filter(|e| e.file_type().is_file()) - .filter_map(|e| parse_desktop(e.path(), locale)) + .filter_map(|e| parse_desktop(e.path(), locale, ematch)) .collect() } @@ -236,6 +242,8 @@ fn assign_icons(icons: &Vec, mut e: Entry) -> Entry { fn main() { let locale = Some("en"); let sizes = (32, 64, 128); + // build regex desktop formatter args + let ematch = regex::Regex::new(r"%\w").expect("Failed Regex Compile"); // build a collection of icons for configured themes let cfgdir = config_dir(); let themes = find_theme(&cfgdir); @@ -252,11 +260,16 @@ fn main() { .collect(); // add extra icons found in base folders icons.extend(icon_paths.iter().map(|p| find_icon_extras(p))); - // retrieve desktop applications and assign icons before printing results - data_dirs("applications") + // retrieve desktop applications and sort alphabetically + let mut applications: Vec = data_dirs("applications") .into_iter() - .map(|p| find_desktops(p, locale)) + .map(|p| find_desktops(p, locale, &ematch)) .flatten() + .collect(); + applications.sort_by_cached_key(|e| e.name.to_owned()); + // assign icons and print results + applications + .into_iter() .map(|e| assign_icons(&icons, e)) .filter_map(|e| serde_json::to_string(&e).ok()) .map(|s| println!("{}", s)) diff --git a/rmenu-plugin/src/lib.rs b/rmenu-plugin/src/lib.rs index 47eab2f..e766c2c 100644 --- a/rmenu-plugin/src/lib.rs +++ b/rmenu-plugin/src/lib.rs @@ -1,15 +1,25 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] pub enum Method { - Terminal, - Desktop, + Terminal(String), + Run(String), +} + +impl Method { + pub fn new(exec: String, terminal: bool) -> Self { + match terminal { + true => Self::Terminal(exec), + false => Self::Run(exec), + } + } } #[derive(Debug, PartialEq, Serialize, Deserialize)] pub struct Action { pub name: String, - pub exec: String, + pub exec: Method, pub comment: Option, } @@ -17,7 +27,7 @@ impl Action { pub fn new(exec: &str) -> Self { Self { name: "main".to_string(), - exec: exec.to_string(), + exec: Method::Run(exec.to_string()), comment: None, } } diff --git a/rmenu/Cargo.toml b/rmenu/Cargo.toml index 7c0b8cd..9d12acc 100644 --- a/rmenu/Cargo.toml +++ b/rmenu/Cargo.toml @@ -25,4 +25,6 @@ serde_json = "1.0.103" serde_yaml = "0.9.24" shell-words = "1.1.0" shellexpand = "3.1.0" +strfmt = "0.2.4" thiserror = "1.0.43" +which = "4.4.0" diff --git a/rmenu/src/config.rs b/rmenu/src/config.rs index aa1fef5..a0567be 100644 --- a/rmenu/src/config.rs +++ b/rmenu/src/config.rs @@ -142,6 +142,7 @@ pub struct Config { pub plugins: BTreeMap>, pub keybinds: KeyConfig, pub window: WindowConfig, + pub terminal: Option, } impl Default for Config { @@ -155,6 +156,7 @@ impl Default for Config { plugins: Default::default(), keybinds: Default::default(), window: Default::default(), + terminal: Default::default(), } } } diff --git a/rmenu/src/exec.rs b/rmenu/src/exec.rs index 703936f..f5858a4 100644 --- a/rmenu/src/exec.rs +++ b/rmenu/src/exec.rs @@ -1,14 +1,56 @@ //! Execution Implementation for Entry Actions -use std::os::unix::process::CommandExt; use std::process::Command; +use std::{collections::HashMap, os::unix::process::CommandExt}; -use rmenu_plugin::Action; +use rmenu_plugin::{Action, Method}; +use shell_words::split; +use strfmt::strfmt; +use which::which; -pub fn execute(action: &Action) { - log::info!("executing: {} {:?}", action.name, action.exec); - let args = match shell_words::split(&action.exec) { +/// Find Best Terminal To Execute +fn find_terminal() -> String { + vec![ + ("alacritty", "-e {cmd}"), + ("kitty", "{cmd}"), + ("gnome-terminal", "-x {cmd}"), + ("foot", "-e {cmd}"), + ("xterm", "-C {cmd}"), + ] + .into_iter() + .map(|(t, v)| (which(t), v)) + .filter(|(c, _)| c.is_ok()) + .map(|(c, v)| (c.unwrap(), v)) + .map(|(p, v)| { + ( + p.to_str() + .expect("Failed to Parse Terminal Path") + .to_owned(), + v, + ) + }) + .find_map(|(p, v)| Some(format!("{p} {v}"))) + .expect("Failed to Find Terminal Executable!") +} + +#[inline] +fn parse_args(exec: &str) -> Vec { + match split(exec) { Ok(args) => args, - Err(err) => panic!("{:?} invalid command {err}", action.exec), + Err(err) => panic!("{:?} invalid command {err}", exec), + } +} + +pub fn execute(action: &Action, term: Option) { + log::info!("executing: {} {:?}", action.name, action.exec); + let args = match &action.exec { + Method::Run(exec) => parse_args(&exec), + Method::Terminal(exec) => { + let mut args = HashMap::new(); + let terminal = term.unwrap_or_else(find_terminal); + args.insert("cmd".to_string(), exec.to_owned()); + let command = strfmt(&terminal, &args).expect("Failed String Format"); + parse_args(&command) + } }; let err = Command::new(&args[0]).args(&args[1..]).exec(); panic!("Command Error: {err:?}"); diff --git a/rmenu/src/state.rs b/rmenu/src/state.rs index 77c0afa..c0458aa 100644 --- a/rmenu/src/state.rs +++ b/rmenu/src/state.rs @@ -119,16 +119,16 @@ impl<'a> AppState<'a> { /// Execute the Current Action pub fn execute(&self) { let (pos, subpos) = self.position(); - println!("double click {pos} {subpos}"); + log::debug!("execute {pos} {subpos}"); let Some(result) = self.results.get(pos) else { return; }; - println!("result: {result:?}"); + log::debug!("result: {result:?}"); let Some(action) = result.actions.get(subpos) else { return; }; - println!("action: {action:?}"); - execute(action); + log::debug!("action: {action:?}"); + execute(action, self.app.config.terminal.clone()); } /// Set Current Key/Action for Later Evaluation From 0a6a741f581e043eacc86a5c0c9c9d0d238ab082 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Sat, 5 Aug 2023 15:03:24 -0700 Subject: [PATCH 22/37] feat: reimplement desktop collection w/ better icon collection, file-cache png creations --- plugin-desktop2/Cargo.toml | 19 +++ plugin-desktop2/src/icons.rs | 306 +++++++++++++++++++++++++++++++++++ plugin-desktop2/src/main.rs | 142 ++++++++++++++++ rmenu/Cargo.toml | 2 +- rmenu/src/image.rs | 60 +++++-- 5 files changed, 513 insertions(+), 16 deletions(-) create mode 100644 plugin-desktop2/Cargo.toml create mode 100644 plugin-desktop2/src/icons.rs create mode 100644 plugin-desktop2/src/main.rs diff --git a/plugin-desktop2/Cargo.toml b/plugin-desktop2/Cargo.toml new file mode 100644 index 0000000..23f8f5c --- /dev/null +++ b/plugin-desktop2/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "desktop2" +version = "0.0.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +freedesktop-desktop-entry = "0.5.0" +freedesktop-icons = "0.2.3" +log = "0.4.19" +once_cell = "1.18.0" +regex = "1.9.1" +rmenu-plugin = { version = "0.0.0", path = "../rmenu-plugin" } +rust-ini = "0.19.0" +serde_json = "1.0.104" +shellexpand = "3.1.0" +thiserror = "1.0.44" +walkdir = "2.3.3" diff --git a/plugin-desktop2/src/icons.rs b/plugin-desktop2/src/icons.rs new file mode 100644 index 0000000..50afbc9 --- /dev/null +++ b/plugin-desktop2/src/icons.rs @@ -0,0 +1,306 @@ +use std::collections::{BTreeMap, HashMap}; +use std::fs::{read_dir, read_to_string}; +use std::path::PathBuf; + +use freedesktop_desktop_entry::DesktopEntry; +use ini::Ini; +use once_cell::sync::Lazy; +use thiserror::Error; +use walkdir::WalkDir; + +type ThemeSource<'a> = (&'a str, &'a str, &'a str); + +static INDEX_MAIN: &'static str = "Icon Theme"; +static INDEX_NAME: &'static str = "Name"; +static INDEX_SIZE: &'static str = "Size"; +static INDEX_DIRS: &'static str = "Directories"; +static INDEX_FILE: &'static str = "index.theme"; + +static DEFAULT_INDEX: &'static str = "default/index.theme"; +static DEFAULT_THEME: &'static str = "Hicolor"; + +static PIXMAPS: Lazy = Lazy::new(|| PathBuf::from("/usr/share/pixmaps/")); +static THEME_SOURCES: Lazy> = Lazy::new(|| { + vec![ + ("kdeglobals", "Icons", "Theme"), + ("gtk-4.0/settings.ini", "Settings", "gtk-icon-theme-name"), + ("gtk-3.0/settings.ini", "Settings", "gtk-icon-theme-name"), + ] +}); + +/// Title String +#[inline] +fn title(s: &str) -> String { + let mut c = s.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } +} + +/// Collect Theme Definitions in Common GUI Configurations +fn theme_inis(cfgdir: &PathBuf) -> Vec { + THEME_SOURCES + .iter() + .filter_map(|(path, sec, key)| { + let path = cfgdir.join(path); + let ini = Ini::load_from_file(path).ok()?; + ini.get_from(Some(sec.to_owned()), key).map(|s| title(s)) + }) + .collect() +} + +/// Parse FreeDesktop Theme-Name from Index File +fn get_theme_name(path: &PathBuf) -> Option { + let content = read_to_string(path).ok()?; + let config = DesktopEntry::decode(&path, &content).ok()?; + config + .groups + .get(INDEX_MAIN) + .and_then(|g| g.get(INDEX_NAME)) + .map(|key| key.0.to_owned()) +} + +/// Determine XDG Icon Theme based on Preexisting Configuration Files +pub fn active_themes(cfgdir: &PathBuf, icondirs: &Vec) -> Vec { + let mut themes: Vec = icondirs + .iter() + .map(|d| d.join(DEFAULT_INDEX)) + .filter(|p| p.exists()) + .filter_map(|p| get_theme_name(&p)) + .collect(); + themes.extend(theme_inis(cfgdir)); + let default = DEFAULT_THEME.to_string(); + if !themes.contains(&default) { + themes.push(default); + } + themes +} + +#[derive(Debug, Error)] +pub enum ThemeError { + #[error("Failed to Read Index")] + FileError(#[from] std::io::Error), + #[error("Failed to Parse Index")] + IndexError(#[from] freedesktop_desktop_entry::DecodeError), + #[error("No Such Group")] + NoSuchGroup(&'static str), + #[error("No Such Key")] + NoSuchKey(&'static str), + #[error("Unselected Theme")] + UnselectedTheme, + #[error("Invalid Path Name")] + BadPathName(PathBuf), +} + +/// Track Paths and their Priority according to Sizes preference +struct PathPriority { + path: PathBuf, + priority: usize, +} + +impl PathPriority { + fn new(path: PathBuf, priority: usize) -> Self { + Self { path, priority } + } +} + +/// Track Theme Information w/ Name/Priority/SubPaths +struct ThemeInfo { + name: String, + priority: usize, + paths: Vec, +} + +/// Single Theme Specification +struct ThemeSpec<'a> { + root: &'a PathBuf, + themes: &'a Vec, + sizes: &'a Vec, +} + +impl<'a> ThemeSpec<'a> { + fn new(root: &'a PathBuf, themes: &'a Vec, sizes: &'a Vec) -> Self { + Self { + root, + themes, + sizes, + } + } +} + +/// Sort Theme Directories by Priority, Append Root, and Collect Names Only +#[inline] +fn sort_dirs(base: &PathBuf, dirs: &mut Vec) -> Vec { + dirs.sort_by_key(|p| p.priority); + dirs.push(PathPriority::new("".into(), 0)); + dirs.into_iter().map(|p| p.path.to_owned()).collect() +} + +/// Parse Theme Index and Sort Directories based on Size Preference +fn parse_index(spec: &ThemeSpec) -> Result { + // parse file content + let index = spec.root.join(INDEX_FILE); + let content = read_to_string(&index)?; + let config = DesktopEntry::decode(&index, &content)?; + let main = config + .groups + .get(INDEX_MAIN) + .ok_or_else(|| ThemeError::NoSuchGroup(INDEX_MAIN))?; + // retrieve name and directories + let name = main + .get(INDEX_NAME) + .ok_or_else(|| ThemeError::NoSuchKey(INDEX_NAME))? + .0; + // check if name in supported themes + let index = spec + .themes + .iter() + .position(|t| t == &name) + .ok_or_else(|| ThemeError::UnselectedTheme)?; + // sort directories based on size preference + let mut directories = main + .get(INDEX_DIRS) + .ok_or_else(|| ThemeError::NoSuchKey(INDEX_DIRS))? + .0 + .split(',') + .into_iter() + .filter_map(|dir| { + let group = config.groups.get(dir)?; + let size = group + .get(INDEX_SIZE) + .and_then(|e| Some(e.0.to_owned())) + .and_then(|s| spec.sizes.iter().position(|is| &s == is)); + Some(match size { + Some(num) => PathPriority::new(spec.root.join(dir), num), + None => PathPriority::new(spec.root.join(dir), 99), + }) + }) + .collect(); + Ok(ThemeInfo { + priority: index, + name: name.to_owned(), + paths: sort_dirs(spec.root, &mut directories), + }) +} + +/// Guess Theme when Index is Missing +fn guess_index(spec: &ThemeSpec) -> Result { + // parse name and confirm active theme + let name = title( + spec.root + .file_name() + .ok_or_else(|| ThemeError::BadPathName(spec.root.to_owned()))? + .to_str() + .ok_or_else(|| ThemeError::BadPathName(spec.root.to_owned()))?, + ); + let index = spec + .themes + .iter() + .position(|t| t == &name) + .ok_or_else(|| ThemeError::UnselectedTheme)?; + // retrieve directories and include priority + let mut directories: Vec = read_dir(spec.root)? + .into_iter() + .filter_map(|e| e.ok()) + .filter_map(|e| { + let name = e.file_name().to_str().map(|n| n.to_owned())?; + Some(match name.split_once("x") { + Some((size, _)) => { + let index = spec.sizes.iter().position(|is| &size == is); + PathPriority::new(e.path(), index.unwrap_or(99)) + } + None => PathPriority::new(e.path(), 99), + }) + }) + .collect(); + // sort by priorty and only include matches + Ok(ThemeInfo { + name, + priority: index, + paths: sort_dirs(spec.root, &mut directories), + }) +} + +/// Specification for a Single Theme Path +pub struct IconSpec { + paths: Vec, + themes: Vec, + sizes: Vec, +} + +impl IconSpec { + pub fn new(paths: Vec, themes: Vec, sizes: Vec) -> Self { + Self { + paths, + themes, + sizes: sizes.into_iter().map(|i| i.to_string()).collect(), + } + } + + pub fn standard(cfg: &PathBuf, sizes: Vec) -> Self { + let mut icon_paths = crate::data_dirs("icons"); + let themes = active_themes(cfg, &icon_paths); + Self::new(icon_paths, themes, sizes) + } +} + +/// Parse and Collect a list of Directories to Find Icons in Order of Preference +fn parse_themes(icons: IconSpec) -> Vec { + // retrieve supported theme information + let mut infos: Vec = icons + .paths + // retrieve icon directories within main icon data paths + .into_iter() + .filter_map(|p| Some(read_dir(&p).ok()?.into_iter().filter_map(|d| d.ok()))) + .flatten() + .map(|readdir| readdir.path()) + // parse or guess index themes + .filter_map(|icondir| { + let spec = ThemeSpec::new(&icondir, &icons.themes, &icons.sizes); + parse_index(&spec) + .map(|r| Ok(r)) + .unwrap_or_else(|_| guess_index(&spec)) + .ok() + }) + .collect(); + // sort results by theme index + infos.sort_by_key(|i| i.priority); + // combine results from multiple directories for the same theme + let mut map = BTreeMap::new(); + for info in infos.into_iter() { + map.entry(info.name).or_insert(vec![]).extend(info.paths); + } + // finalize results from values + map.insert("pixmaps".to_owned(), vec![PIXMAPS.to_owned()]); + map.into_values().flatten().collect() +} + +pub type IconMap = HashMap; + +#[inline] +fn is_icon(fname: &str) -> bool { + fname.ends_with("png") || fname.ends_with("svg") || fname.ends_with("xpm") +} + +/// Collect Unique Icon Map based on Preffered Paths +pub fn collect_icons(spec: IconSpec) -> IconMap { + let mut map = HashMap::new(); + for path in parse_themes(spec).into_iter() { + let icons = WalkDir::new(path) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()); + for icon in icons { + let Some(fname) = icon.file_name().to_str() else { continue }; + if !is_icon(&fname) { + continue; + } + let Some((name, _)) = fname.rsplit_once(".") else { continue }; + map.entry(name.to_owned()) + .or_insert_with(|| icon.path().to_owned()); + } + } + map +} diff --git a/plugin-desktop2/src/main.rs b/plugin-desktop2/src/main.rs new file mode 100644 index 0000000..f03e845 --- /dev/null +++ b/plugin-desktop2/src/main.rs @@ -0,0 +1,142 @@ +use std::fs::read_to_string; +use std::path::PathBuf; + +use freedesktop_desktop_entry::{DesktopEntry, Iter}; +use freedesktop_icons::lookup; +use once_cell::sync::Lazy; +use regex::Regex; +use rmenu_plugin::{Action, Entry, Method}; + +mod icons; + +static XDG_DATA_ENV: &'static str = "XDG_DATA_DIRS"; +static XDG_CONFIG_ENV: &'static str = "XDG_CONFIG_HOME"; +static XDG_DATA_DEFAULT: &'static str = "/usr/share:/usr/local/share"; +static XDG_CONFIG_DEFAULT: &'static str = "~/.config"; + +static EXEC_RGX: Lazy = + Lazy::new(|| Regex::new(r"%\w").expect("Failed to Build Exec Regex")); + +/// Retrieve XDG-CONFIG-HOME Directory +#[inline] +fn config_dir() -> PathBuf { + let path = std::env::var(XDG_CONFIG_ENV).unwrap_or_else(|_| XDG_CONFIG_DEFAULT.to_string()); + PathBuf::from(shellexpand::tilde(&path).to_string()) +} + +/// Retrieve XDG-DATA Directories +fn data_dirs(dir: &str) -> Vec { + std::env::var(XDG_DATA_ENV) + .unwrap_or_else(|_| XDG_DATA_DEFAULT.to_string()) + .split(":") + .map(|p| shellexpand::tilde(p).to_string()) + .map(PathBuf::from) + .map(|p| p.join(dir.to_owned())) + .filter(|p| p.exists()) + .collect() +} + +/// Find Freedesktop Default Theme +fn default_theme() -> String { + data_dirs("icons") + .into_iter() + .map(|p| p.join("default/index.theme")) + .filter(|p| p.exists()) + .find_map(|p| { + let content = read_to_string(&p).ok()?; + let config = DesktopEntry::decode(&p, &content).ok()?; + config + .groups + .get("Icon Theme") + .and_then(|g| g.get("Name")) + .map(|key| key.0.to_owned()) + }) + .unwrap_or_else(|| "Hicolor".to_string()) +} + +/// Modify Exec Statements to Remove %u/%f/etc... +#[inline(always)] +fn fix_exec(exec: &str) -> String { + EXEC_RGX.replace_all(exec, "").trim().to_string() +} + +/// Parse XDG Desktop Entry into RMenu Entry +fn parse_desktop(path: &PathBuf, locale: Option<&str>) -> Option { + let bytes = read_to_string(path).ok()?; + let entry = DesktopEntry::decode(&path, &bytes).ok()?; + let name = entry.name(locale)?.to_string(); + let icon = entry.icon().map(|i| i.to_string()); + let comment = entry.comment(locale).map(|s| s.to_string()); + let terminal = entry.terminal(); + let mut actions = match entry.exec() { + Some(exec) => vec![Action { + name: "main".to_string(), + exec: Method::new(fix_exec(exec), terminal), + comment: None, + }], + None => vec![], + }; + actions.extend( + entry + .actions() + .unwrap_or("") + .split(";") + .into_iter() + .filter(|a| a.len() > 0) + .filter_map(|a| { + let name = entry.action_name(a, locale)?; + let exec = entry.action_exec(a)?; + Some(Action { + name: name.to_string(), + exec: Method::new(fix_exec(exec), terminal), + comment: None, + }) + }), + ); + Some(Entry { + name, + actions, + comment, + icon, + }) +} + +/// Assign XDG Icon based on Desktop-Entry +fn assign_icon(icon: String, map: &icons::IconMap) -> Option { + if !icon.contains("/") { + if let Some(icon) = map.get(&icon) { + if let Some(path) = icon.to_str() { + return Some(path.to_owned()); + } + } + } + Some(icon) +} + +fn main() { + let locale = Some("en"); + let sizes = vec![64, 32, 96, 22, 128]; + + // collect icons + let cfg = config_dir(); + let spec = icons::IconSpec::standard(&cfg, sizes); + let icons = icons::collect_icons(spec); + + // collect applications + let app_paths = data_dirs("applications"); + let mut desktops: Vec = Iter::new(app_paths) + .into_iter() + .filter_map(|f| parse_desktop(&f, locale)) + .map(|mut e| { + e.icon = e.icon.and_then(|s| assign_icon(s, &icons)); + e + }) + .collect(); + + desktops.sort_by_cached_key(|e| e.name.to_owned()); + desktops + .into_iter() + .filter_map(|e| serde_json::to_string(&e).ok()) + .map(|s| println!("{}", s)) + .last(); +} diff --git a/rmenu/Cargo.toml b/rmenu/Cargo.toml index 9d12acc..940d06d 100644 --- a/rmenu/Cargo.toml +++ b/rmenu/Cargo.toml @@ -6,7 +6,6 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -base64 = "0.21.2" cached = "0.44.0" clap = { version = "4.3.15", features = ["derive"] } dioxus = "0.3.2" @@ -15,6 +14,7 @@ env_logger = "0.10.0" heck = "0.4.1" keyboard-types = "0.6.2" log = "0.4.19" +once_cell = "1.18.0" png = "0.17.9" quick-xml = "0.30.0" regex = { version = "1.9.1" } diff --git a/rmenu/src/image.rs b/rmenu/src/image.rs index d6b9c0c..ff87319 100644 --- a/rmenu/src/image.rs +++ b/rmenu/src/image.rs @@ -1,11 +1,17 @@ //! GUI Image Processing -use std::fs::read_to_string; +use std::fs::{create_dir_all, read_to_string, write}; +use std::io; +use std::path::PathBuf; +use std::sync::Mutex; -use base64::{engine::general_purpose, Engine as _}; use cached::proc_macro::cached; +use once_cell::sync::Lazy; use resvg::usvg::TreeParsing; use thiserror::Error; +static TEMP_EXISTS: Lazy>> = Lazy::new(|| Mutex::new(vec![])); +static TEMP_DIR: Lazy = Lazy::new(|| PathBuf::from("/tmp/rmenu")); + #[derive(Debug, Error)] enum SvgError { #[error("Invalid SVG Filepath")] @@ -13,12 +19,23 @@ enum SvgError { #[error("Invalid Document")] InvalidTree(#[from] resvg::usvg::Error), #[error("Failed to Alloc PixBuf")] - NoPixBuf, + NoPixBuf(u32, u32, u32), #[error("Failed to Convert SVG to PNG")] PngError(#[from] png::EncodingError), } -fn svg_to_png(path: &str, pixels: u32) -> Result { +/// Make Temporary Directory for Generated PNGs +fn make_temp() -> Result<(), io::Error> { + let mut temp = TEMP_EXISTS.lock().expect("Failed to Access Global Mutex"); + if temp.len() == 0 { + create_dir_all(TEMP_DIR.to_owned())?; + temp.push(true); + } + Ok(()) +} + +/// Convert SVG to PNG Image +fn svg_to_png(path: &str, dest: &PathBuf, pixels: u32) -> Result<(), SvgError> { // read and convert to resvg document tree let xml = read_to_string(path)?; let opt = resvg::usvg::Options::default(); @@ -26,22 +43,35 @@ fn svg_to_png(path: &str, pixels: u32) -> Result { let rtree = resvg::Tree::from_usvg(&tree); // generate pixel-buffer and scale according to size preference let size = rtree.size.to_int_size(); - let scale = pixels / size.width(); - let width = size.width() * scale; - let height = size.height() * scale; - let fscale = scale as f32; - let mut pixmap = - resvg::tiny_skia::Pixmap::new(width, height).ok_or_else(|| SvgError::NoPixBuf)?; - let form = resvg::tiny_skia::Transform::from_scale(fscale, fscale); + let scale = pixels as f32 / size.width() as f32; + let width = (size.width() as f32 * scale) as u32; + let height = (size.height() as f32 * scale) as u32; + let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height) + .ok_or_else(|| SvgError::NoPixBuf(width, height, pixels))?; + let form = resvg::tiny_skia::Transform::from_scale(scale, scale); // render as png to memory rtree.render(form, &mut pixmap.as_mut()); - let mut png = pixmap.encode_png()?; + let png = pixmap.encode_png()?; // base64 encode png - let encoded = general_purpose::STANDARD.encode(&mut png); - Ok(format!("data:image/png;base64, {encoded}")) + Ok(write(dest, png)?) } #[cached] pub fn convert_svg(path: String) -> Option { - svg_to_png(&path, 64).ok() + // ensure temporary directory exists + let _ = make_temp(); + // convert path to new temporary png filepath + let (_, fname) = path.rsplit_once('/')?; + let (name, _) = fname.rsplit_once(".")?; + let name = format!("{name}.png"); + let new_path = TEMP_DIR.join(name); + // generate png if it doesnt already exist + if !new_path.exists() { + log::debug!("generating png {new_path:?}"); + match svg_to_png(&path, &new_path, 64) { + Err(err) => log::error!("failed svg->png: {err:?}"), + _ => {} + } + } + Some(new_path.to_str()?.to_string()) } From 07db986da1a35c49b5c5e949aa9d11bf3c954c8f Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Mon, 7 Aug 2023 15:18:28 -0700 Subject: [PATCH 23/37] feat: default config, cleaner code, added plugin-cache system --- rmenu/Cargo.toml | 2 + rmenu/public/config.yaml | 19 +++++++++ rmenu/src/cache.rs | 90 ++++++++++++++++++++++++++++++++++++++++ rmenu/src/config.rs | 56 ++++++++++++++++++++++++- rmenu/src/gui.rs | 2 - rmenu/src/main.rs | 38 ++++++++++++----- rmenu/src/state.rs | 14 +++---- 7 files changed, 200 insertions(+), 21 deletions(-) create mode 100644 rmenu/public/config.yaml create mode 100644 rmenu/src/cache.rs diff --git a/rmenu/Cargo.toml b/rmenu/Cargo.toml index 940d06d..de0807f 100644 --- a/rmenu/Cargo.toml +++ b/rmenu/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +bincode = "1.3.3" cached = "0.44.0" clap = { version = "4.3.15", features = ["derive"] } dioxus = "0.3.2" @@ -13,6 +14,7 @@ dioxus-desktop = "0.3.0" env_logger = "0.10.0" heck = "0.4.1" keyboard-types = "0.6.2" +lastlog = { version = "0.2.3", features = ["libc"] } log = "0.4.19" once_cell = "1.18.0" png = "0.17.9" diff --git a/rmenu/public/config.yaml b/rmenu/public/config.yaml new file mode 100644 index 0000000..0d47b72 --- /dev/null +++ b/rmenu/public/config.yaml @@ -0,0 +1,19 @@ +use_icons: true +ignore_case: true +search_regex: false + +plugins: + run: + exec: ["~/.config/rmenu/run"] + cache: 300 + drun: + exec: ["~/.config/rmenu/drun"] + cache: onlogin + +keybinds: + exec: ["Enter"] + exit: ["Escape"] + move_up: ["Arrow-Up", "Shift+Tab"] + move_down: ["Arrow-Down", "Tab"] + open_menu: ["Arrow-Right"] + close_menu: ["Arrow-Left"] diff --git a/rmenu/src/cache.rs b/rmenu/src/cache.rs new file mode 100644 index 0000000..97b75a8 --- /dev/null +++ b/rmenu/src/cache.rs @@ -0,0 +1,90 @@ +//! RMenu Plugin Result Cache +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::time::{Duration, SystemTime}; + +use once_cell::sync::Lazy; +use rmenu_plugin::Entry; +use thiserror::Error; + +use crate::config::{CacheSetting, PluginConfig}; +use crate::CONFIG_DIR; + +static CONFIG_PATH: Lazy = + Lazy::new(|| PathBuf::from(shellexpand::tilde(CONFIG_DIR).to_string())); + +#[derive(Debug, Error)] +pub enum CacheError { + #[error("Cache Not Available")] + NotAvailable, + #[error("Cache Invalid")] + InvalidCache, + #[error("Cache Expired")] + CacheExpired, + #[error("Cache File Error")] + FileError(#[from] std::io::Error), + #[error("Encoding Error")] + EncodingError(#[from] bincode::Error), +} + +#[inline] +fn cache_file(name: &str) -> PathBuf { + CONFIG_PATH.join(format!("{name}.cache")) +} + +/// Read Entries from Cache (if Valid and Available) +pub fn read_cache(name: &str, cfg: &PluginConfig) -> Result, CacheError> { + // confirm cache exists + let path = cache_file(name); + if !path.exists() { + return Err(CacheError::NotAvailable); + } + // get file modified date + let meta = path.metadata()?; + let modified = meta.modified()?; + // confirm cache is not expired + match cfg.cache { + CacheSetting::NoCache => return Err(CacheError::InvalidCache), + CacheSetting::Never => {} + CacheSetting::OnLogin => { + if let Ok(record) = lastlog::search_self() { + if let Some(last) = record.last_login.into() { + if modified <= last { + return Err(CacheError::CacheExpired); + } + } + } + } + CacheSetting::AfterSeconds(secs) => { + let now = SystemTime::now(); + let duration = Duration::from_secs(secs as u64); + let diff = now + .duration_since(modified) + .unwrap_or_else(|_| Duration::from_secs(0)); + if diff >= duration { + return Err(CacheError::CacheExpired); + } + } + } + // attempt to read content + let data = fs::read(path)?; + let results: Vec = bincode::deserialize(&data)?; + Ok(results) +} + +/// Write Results to Cache (if Allowed) +pub fn write_cache(name: &str, cfg: &PluginConfig, entries: &Vec) -> Result<(), CacheError> { + // write cache if allowed + match cfg.cache { + CacheSetting::NoCache => {} + _ => { + let path = cache_file(name); + println!("write! {:?}", path); + let data = bincode::serialize(entries)?; + let mut f = fs::File::create(path)?; + f.write_all(&data)?; + } + } + Ok(()) +} diff --git a/rmenu/src/config.rs b/rmenu/src/config.rs index a0567be..08ce35e 100644 --- a/rmenu/src/config.rs +++ b/rmenu/src/config.rs @@ -18,6 +18,7 @@ fn mod_from_str(s: &str) -> Option { } } +/// Single GUI Keybind for Configuration #[derive(Debug, PartialEq)] pub struct Keybind { pub mods: Modifiers, @@ -73,6 +74,7 @@ impl<'de> Deserialize<'de> for Keybind { } } +/// Global GUI Keybind Settings Options #[derive(Debug, PartialEq, Deserialize)] #[serde(default)] pub struct KeyConfig { @@ -97,6 +99,7 @@ impl Default for KeyConfig { } } +/// GUI Desktop Window Configuration Settings #[derive(Debug, PartialEq, Deserialize)] pub struct WindowConfig { pub title: String, @@ -131,6 +134,57 @@ impl Default for WindowConfig { } } +/// Cache Settings for Configured RMenu Plugins +#[derive(Debug, PartialEq)] +pub enum CacheSetting { + NoCache, + Never, + OnLogin, + AfterSeconds(usize), +} + +impl FromStr for CacheSetting { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "never" => Ok(Self::Never), + "false" | "disable" | "disabled" => Ok(Self::NoCache), + "true" | "login" | "onlogin" => Ok(Self::OnLogin), + _ => { + let secs: usize = s + .parse() + .map_err(|_| format!("Invalid Cache Setting: {s:?}"))?; + Ok(Self::AfterSeconds(secs)) + } + } + } +} + +impl<'de> Deserialize<'de> for CacheSetting { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s: &str = Deserialize::deserialize(deserializer)?; + CacheSetting::from_str(s).map_err(D::Error::custom) + } +} + +impl Default for CacheSetting { + fn default() -> Self { + Self::NoCache + } +} + +/// RMenu Data-Source Plugin Configuration +#[derive(Debug, PartialEq, Deserialize)] +pub struct PluginConfig { + pub exec: Vec, + #[serde(default)] + pub cache: CacheSetting, +} + +/// Global RMenu Complete Configuration #[derive(Debug, PartialEq, Deserialize)] #[serde(default)] pub struct Config { @@ -139,7 +193,7 @@ pub struct Config { pub use_icons: bool, pub search_regex: bool, pub ignore_case: bool, - pub plugins: BTreeMap>, + pub plugins: BTreeMap, pub keybinds: KeyConfig, pub window: WindowConfig, pub terminal: Option, diff --git a/rmenu/src/gui.rs b/rmenu/src/gui.rs index 2a4d821..21fa62c 100644 --- a/rmenu/src/gui.rs +++ b/rmenu/src/gui.rs @@ -1,7 +1,5 @@ //! RMENU GUI Implementation using Dioxus #![allow(non_snake_case)] -use std::fs::read_to_string; - use dioxus::prelude::*; use keyboard_types::{Code, Modifiers}; use rmenu_plugin::Entry; diff --git a/rmenu/src/main.rs b/rmenu/src/main.rs index 0043aaf..2986497 100644 --- a/rmenu/src/main.rs +++ b/rmenu/src/main.rs @@ -5,6 +5,7 @@ use std::io::{self, prelude::*, BufReader}; use std::process::{Command, ExitStatus, Stdio}; use std::str::FromStr; +mod cache; mod config; mod exec; mod gui; @@ -142,20 +143,30 @@ impl Args { log::debug!("config: {cfg:?}"); // execute commands to get a list of entries let mut entries = vec![]; - for plugin in self.run.iter() { - log::debug!("running plugin: {plugin}"); + for name in self.run.iter() { + log::debug!("running plugin: {name}"); // retrieve plugin command arguments - let Some(args) = cfg.plugins.get(plugin) else { - return Err(RMenuError::NoSuchPlugin(plugin.to_owned())); - }; + let plugin = cfg + .plugins + .get(name) + .ok_or_else(|| RMenuError::NoSuchPlugin(name.to_owned()))?; + // attempt to read cache rather than run command + match cache::read_cache(name, plugin) { + Ok(cached) => { + entries.extend(cached); + continue; + } + Err(err) => log::error!("cache read error: {err:?}"), + } // build command - let mut cmdargs: VecDeque = args + let mut cmdargs: VecDeque = plugin + .exec .iter() .map(|arg| shellexpand::tilde(arg).to_string()) .collect(); - let Some(main) = cmdargs.pop_front() else { - return Err(RMenuError::InvalidPlugin(plugin.to_owned())); - }; + let main = cmdargs + .pop_front() + .ok_or_else(|| RMenuError::InvalidPlugin(name.to_owned()))?; let mut cmd = Command::new(main); for arg in cmdargs.iter() { cmd.arg(arg); @@ -165,7 +176,7 @@ impl Args { let stdout = proc .stdout .as_mut() - .ok_or_else(|| RMenuError::CommandError(args.clone().into(), None))?; + .ok_or_else(|| RMenuError::CommandError(plugin.exec.clone().into(), None))?; let reader = BufReader::new(stdout); // read output line by line and parse content for line in reader.lines() { @@ -176,10 +187,15 @@ impl Args { let status = proc.wait()?; if !status.success() { return Err(RMenuError::CommandError( - args.clone().into(), + plugin.exec.clone().into(), Some(status.clone()), )); } + // write cache for entries collected + match cache::write_cache(name, plugin, &entries) { + Ok(_) => {} + Err(err) => log::error!("cache write error: {err:?}"), + }; } Ok(entries) } diff --git a/rmenu/src/state.rs b/rmenu/src/state.rs index c0458aa..3075d87 100644 --- a/rmenu/src/state.rs +++ b/rmenu/src/state.rs @@ -201,13 +201,13 @@ impl<'a> AppState<'a> { } /// Automatically Increase PageCount When Nearing Bottom - pub fn scroll_down(&self) { - self.state.with_mut(|s| { - if self.app.config.page_size * s.page < self.app.entries.len() { - s.page += 1; - } - }); - } + // pub fn scroll_down(&self) { + // self.state.with_mut(|s| { + // if self.app.config.page_size * s.page < self.app.entries.len() { + // s.page += 1; + // } + // }); + // } /// Move Position To SubMenu if it Exists pub fn open_menu(&self) { From 48af7cee47e0799971ed45e7bb49dc11799db1e9 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Mon, 7 Aug 2023 15:18:56 -0700 Subject: [PATCH 24/37] feat: cleaned up desktop-plugin --- Cargo.toml | 2 +- plugin-desktop2/src/icons.rs | 8 ++++---- plugin-desktop2/src/main.rs | 19 ------------------- 3 files changed, 5 insertions(+), 24 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9b94371..6d37605 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,5 +5,5 @@ members = [ "rmenu-plugin", "plugin-run", "plugin-desktop", - "rtest", + "plugin-desktop2", ] diff --git a/plugin-desktop2/src/icons.rs b/plugin-desktop2/src/icons.rs index 50afbc9..3f7e023 100644 --- a/plugin-desktop2/src/icons.rs +++ b/plugin-desktop2/src/icons.rs @@ -131,7 +131,7 @@ impl<'a> ThemeSpec<'a> { /// Sort Theme Directories by Priority, Append Root, and Collect Names Only #[inline] -fn sort_dirs(base: &PathBuf, dirs: &mut Vec) -> Vec { +fn sort_dirs(dirs: &mut Vec) -> Vec { dirs.sort_by_key(|p| p.priority); dirs.push(PathPriority::new("".into(), 0)); dirs.into_iter().map(|p| p.path.to_owned()).collect() @@ -180,7 +180,7 @@ fn parse_index(spec: &ThemeSpec) -> Result { Ok(ThemeInfo { priority: index, name: name.to_owned(), - paths: sort_dirs(spec.root, &mut directories), + paths: sort_dirs(&mut directories), }) } @@ -218,7 +218,7 @@ fn guess_index(spec: &ThemeSpec) -> Result { Ok(ThemeInfo { name, priority: index, - paths: sort_dirs(spec.root, &mut directories), + paths: sort_dirs(&mut directories), }) } @@ -239,7 +239,7 @@ impl IconSpec { } pub fn standard(cfg: &PathBuf, sizes: Vec) -> Self { - let mut icon_paths = crate::data_dirs("icons"); + let icon_paths = crate::data_dirs("icons"); let themes = active_themes(cfg, &icon_paths); Self::new(icon_paths, themes, sizes) } diff --git a/plugin-desktop2/src/main.rs b/plugin-desktop2/src/main.rs index f03e845..57497fc 100644 --- a/plugin-desktop2/src/main.rs +++ b/plugin-desktop2/src/main.rs @@ -2,7 +2,6 @@ use std::fs::read_to_string; use std::path::PathBuf; use freedesktop_desktop_entry::{DesktopEntry, Iter}; -use freedesktop_icons::lookup; use once_cell::sync::Lazy; use regex::Regex; use rmenu_plugin::{Action, Entry, Method}; @@ -36,24 +35,6 @@ fn data_dirs(dir: &str) -> Vec { .collect() } -/// Find Freedesktop Default Theme -fn default_theme() -> String { - data_dirs("icons") - .into_iter() - .map(|p| p.join("default/index.theme")) - .filter(|p| p.exists()) - .find_map(|p| { - let content = read_to_string(&p).ok()?; - let config = DesktopEntry::decode(&p, &content).ok()?; - config - .groups - .get("Icon Theme") - .and_then(|g| g.get("Name")) - .map(|key| key.0.to_owned()) - }) - .unwrap_or_else(|| "Hicolor".to_string()) -} - /// Modify Exec Statements to Remove %u/%f/etc... #[inline(always)] fn fix_exec(exec: &str) -> String { From 72267fb02930e02060cc7ef5a6f0e916857364bd Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Mon, 7 Aug 2023 15:20:20 -0700 Subject: [PATCH 25/37] feat: desktop2 replace desktop1 plugin, added makefile --- Cargo.toml | 1 - Makefile | 24 ++ plugin-desktop/Cargo.toml | 8 +- .../src/icons.rs | 0 plugin-desktop/src/main.rs | 234 +++--------------- plugin-desktop2/Cargo.toml | 19 -- plugin-desktop2/src/main.rs | 123 --------- 7 files changed, 70 insertions(+), 339 deletions(-) create mode 100644 Makefile rename {plugin-desktop2 => plugin-desktop}/src/icons.rs (100%) delete mode 100644 plugin-desktop2/Cargo.toml delete mode 100644 plugin-desktop2/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index 6d37605..88c38ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,5 +5,4 @@ members = [ "rmenu-plugin", "plugin-run", "plugin-desktop", - "plugin-desktop2", ] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0ba7794 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +# RMenu Installation/Deployment Configuration + +CARGO=cargo +FLAGS=--release + +DEST=$(HOME)/.config/rmenu + +install: build deploy + +deploy: + mkdir -p ${DEST} + cp -vf ./target/release/desktop ${DEST}/drun + cp -vf ./target/release/run ${DEST}/run + cp -vf ./rmenu/public/config.yaml ${DEST}/config.yaml + cp -vf ./rmenu/public/default.css ${DEST}/style.css + +build: build-rmenu build-plugins + +build-rmenu: + ${CARGO} build -p rmenu ${FLAGS} + +build-plugins: + ${CARGO} build -p run ${FLAGS} + ${CARGO} build -p desktop ${FLAGS} diff --git a/plugin-desktop/Cargo.toml b/plugin-desktop/Cargo.toml index 956c433..442dd79 100644 --- a/plugin-desktop/Cargo.toml +++ b/plugin-desktop/Cargo.toml @@ -7,9 +7,13 @@ edition = "2021" [dependencies] freedesktop-desktop-entry = "0.5.0" +freedesktop-icons = "0.2.3" +log = "0.4.19" +once_cell = "1.18.0" regex = "1.9.1" rmenu-plugin = { version = "0.0.0", path = "../rmenu-plugin" } -rust-ini = { version = "0.19.0", features = ["unicase"] } -serde_json = "1.0.103" +rust-ini = "0.19.0" +serde_json = "1.0.104" shellexpand = "3.1.0" +thiserror = "1.0.44" walkdir = "2.3.3" diff --git a/plugin-desktop2/src/icons.rs b/plugin-desktop/src/icons.rs similarity index 100% rename from plugin-desktop2/src/icons.rs rename to plugin-desktop/src/icons.rs diff --git a/plugin-desktop/src/main.rs b/plugin-desktop/src/main.rs index 0cbb306..57497fc 100644 --- a/plugin-desktop/src/main.rs +++ b/plugin-desktop/src/main.rs @@ -1,18 +1,20 @@ -use std::collections::{HashMap, HashSet}; +use std::fs::read_to_string; use std::path::PathBuf; -use std::{fs::read_to_string, path::Path}; -use freedesktop_desktop_entry::DesktopEntry; -use ini::Ini; +use freedesktop_desktop_entry::{DesktopEntry, Iter}; +use once_cell::sync::Lazy; use regex::Regex; use rmenu_plugin::{Action, Entry, Method}; -use walkdir::WalkDir; + +mod icons; static XDG_DATA_ENV: &'static str = "XDG_DATA_DIRS"; static XDG_CONFIG_ENV: &'static str = "XDG_CONFIG_HOME"; static XDG_DATA_DEFAULT: &'static str = "/usr/share:/usr/local/share"; static XDG_CONFIG_DEFAULT: &'static str = "~/.config"; -static DEFAULT_THEME: &'static str = "hicolor"; + +static EXEC_RGX: Lazy = + Lazy::new(|| Regex::new(r"%\w").expect("Failed to Build Exec Regex")); /// Retrieve XDG-CONFIG-HOME Directory #[inline] @@ -21,140 +23,6 @@ fn config_dir() -> PathBuf { PathBuf::from(shellexpand::tilde(&path).to_string()) } -/// Determine XDG Icon Theme based on Preexisting Configuration Files -fn find_theme(cfgdir: &PathBuf) -> Vec { - let mut themes: Vec = vec![ - ("kdeglobals", "Icons", "Theme"), - ("gtk-4.0/settings.ini", "Settings", "gtk-icon-theme-name"), - ("gtk-3.0/settings.ini", "Settings", "gtk-icon-theme-name"), - ] - .into_iter() - .filter_map(|(path, sec, key)| { - let path = cfgdir.join(path); - let ini = Ini::load_from_file(path).ok()?; - ini.get_from(Some(sec), key).map(|s| s.to_string()) - }) - .collect(); - let default = DEFAULT_THEME.to_string(); - if !themes.contains(&default) { - themes.push(default); - } - themes -} - -type IconGroup = HashMap; -type Icons = HashMap; - -/// Precalculate prefferred sizes folders -fn calculate_sizes(range: (usize, usize, usize)) -> HashSet { - let (min, preffered, max) = range; - let mut size = preffered.clone(); - let mut sizes = HashSet::new(); - while size < max { - sizes.insert(format!("{size}x{size}")); - sizes.insert(format!("{size}x{size}@2")); - size *= 2; - } - // attempt to match sizes down to lowest minimum - let mut size = preffered.clone(); - while size > min { - sizes.insert(format!("{size}x{size}")); - sizes.insert(format!("{size}x{size}@2")); - size /= 2; - } - sizes -} - -#[inline(always)] -fn is_valid_icon(name: &str) -> bool { - name.ends_with(".png") || name.ends_with(".svg") -} - -/// Parse Icon-Name from Filename -#[inline] -fn icon_name(name: &str) -> String { - name.rsplit_once(".") - .map(|(i, _)| i) - .unwrap_or(&name) - .to_owned() -} - -/// Parse and Categorize Icons Within the Specified Path -fn find_icons(path: &PathBuf, sizes: (usize, usize, usize)) -> Vec { - let sizes = calculate_sizes(sizes); - let mut extras = IconGroup::new(); - let icons: Icons = WalkDir::new(path) - // collect list of directories of icon subdirs - .max_depth(1) - .follow_links(true) - .into_iter() - .filter_map(|e| e.ok()) - .filter_map(|e| { - let name = e.file_name().to_str()?; - let path = e.path().to_owned(); - match e.file_type().is_dir() { - true => Some((name.to_owned(), path)), - false => { - if is_valid_icon(name) { - extras.insert(icon_name(name), path); - } - None - } - } - }) - // iterate content within subdirs - .map(|(name, path)| { - let group = WalkDir::new(path) - .follow_links(true) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.file_type().is_file()) - .filter_map(|e| { - let name = e.file_name().to_str()?; - if is_valid_icon(name) { - return Some((icon_name(name), e.path().to_owned())); - } - None - }) - .collect(); - (name, group) - }) - .collect(); - // organize icon groups according to prefference - let mut priority = vec![]; - let mut others = vec![]; - icons - .into_iter() - .map(|(folder, group)| match sizes.contains(&folder) { - true => priority.push(group), - false => match folder.contains("x") { - false => others.push(group), - _ => {} - }, - }) - .last(); - priority.append(&mut others); - priority.push(extras); - priority -} - -/// Retrieve Extras in Base Icon Directories -fn find_icon_extras(path: &PathBuf) -> IconGroup { - WalkDir::new(path) - .max_depth(1) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.file_type().is_file()) - .filter_map(|e| { - let name = e.file_name().to_str()?; - if is_valid_icon(name) { - return Some((icon_name(&name), e.path().to_owned())); - } - None - }) - .collect() -} - /// Retrieve XDG-DATA Directories fn data_dirs(dir: &str) -> Vec { std::env::var(XDG_DATA_ENV) @@ -167,23 +35,24 @@ fn data_dirs(dir: &str) -> Vec { .collect() } +/// Modify Exec Statements to Remove %u/%f/etc... #[inline(always)] -fn fix_exec(exec: &str, ematch: &Regex) -> String { - ematch.replace_all(exec, "").trim().to_string() +fn fix_exec(exec: &str) -> String { + EXEC_RGX.replace_all(exec, "").trim().to_string() } /// Parse XDG Desktop Entry into RMenu Entry -fn parse_desktop(path: &Path, locale: Option<&str>, ematch: &Regex) -> Option { +fn parse_desktop(path: &PathBuf, locale: Option<&str>) -> Option { let bytes = read_to_string(path).ok()?; let entry = DesktopEntry::decode(&path, &bytes).ok()?; let name = entry.name(locale)?.to_string(); - let icon = entry.icon().map(|s| s.to_string()); + let icon = entry.icon().map(|i| i.to_string()); let comment = entry.comment(locale).map(|s| s.to_string()); let terminal = entry.terminal(); let mut actions = match entry.exec() { Some(exec) => vec![Action { name: "main".to_string(), - exec: Method::new(fix_exec(exec, ematch), terminal), + exec: Method::new(fix_exec(exec), terminal), comment: None, }], None => vec![], @@ -200,7 +69,7 @@ fn parse_desktop(path: &Path, locale: Option<&str>, ematch: &Regex) -> Option, ematch: &Regex) -> Option, ematch: &Regex) -> Vec { - WalkDir::new(path) - .follow_links(true) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.file_name().to_string_lossy().ends_with(".desktop")) - .filter(|e| e.file_type().is_file()) - .filter_map(|e| parse_desktop(e.path(), locale, ematch)) - .collect() -} - -/// Find and Assign Icons from Icon-Cache when Possible -fn assign_icons(icons: &Vec, mut e: Entry) -> Entry { - if let Some(name) = e.icon.as_ref() { - if !name.contains("/") { - if let Some(path) = icons.iter().find_map(|i| i.get(name)) { - if let Some(fpath) = path.to_str() { - e.icon = Some(fpath.to_owned()); - } +/// Assign XDG Icon based on Desktop-Entry +fn assign_icon(icon: String, map: &icons::IconMap) -> Option { + if !icon.contains("/") { + if let Some(icon) = map.get(&icon) { + if let Some(path) = icon.to_str() { + return Some(path.to_owned()); } } } - e + Some(icon) } fn main() { let locale = Some("en"); - let sizes = (32, 64, 128); - // build regex desktop formatter args - let ematch = regex::Regex::new(r"%\w").expect("Failed Regex Compile"); - // build a collection of icons for configured themes - let cfgdir = config_dir(); - let themes = find_theme(&cfgdir); - let icon_paths = data_dirs("icons"); - let mut icons: Vec = icon_paths - // generate list of icon-paths that exist - .iter() - .map(|d| themes.iter().map(|t| d.join(t))) - .flatten() - .filter(|t| t.exists()) - // append icon-paths within supported themes - .map(|t| find_icons(&t, sizes)) - .flatten() - .collect(); - // add extra icons found in base folders - icons.extend(icon_paths.iter().map(|p| find_icon_extras(p))); - // retrieve desktop applications and sort alphabetically - let mut applications: Vec = data_dirs("applications") + let sizes = vec![64, 32, 96, 22, 128]; + + // collect icons + let cfg = config_dir(); + let spec = icons::IconSpec::standard(&cfg, sizes); + let icons = icons::collect_icons(spec); + + // collect applications + let app_paths = data_dirs("applications"); + let mut desktops: Vec = Iter::new(app_paths) .into_iter() - .map(|p| find_desktops(p, locale, &ematch)) - .flatten() + .filter_map(|f| parse_desktop(&f, locale)) + .map(|mut e| { + e.icon = e.icon.and_then(|s| assign_icon(s, &icons)); + e + }) .collect(); - applications.sort_by_cached_key(|e| e.name.to_owned()); - // assign icons and print results - applications + + desktops.sort_by_cached_key(|e| e.name.to_owned()); + desktops .into_iter() - .map(|e| assign_icons(&icons, e)) .filter_map(|e| serde_json::to_string(&e).ok()) .map(|s| println!("{}", s)) .last(); diff --git a/plugin-desktop2/Cargo.toml b/plugin-desktop2/Cargo.toml deleted file mode 100644 index 23f8f5c..0000000 --- a/plugin-desktop2/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "desktop2" -version = "0.0.0" -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -freedesktop-desktop-entry = "0.5.0" -freedesktop-icons = "0.2.3" -log = "0.4.19" -once_cell = "1.18.0" -regex = "1.9.1" -rmenu-plugin = { version = "0.0.0", path = "../rmenu-plugin" } -rust-ini = "0.19.0" -serde_json = "1.0.104" -shellexpand = "3.1.0" -thiserror = "1.0.44" -walkdir = "2.3.3" diff --git a/plugin-desktop2/src/main.rs b/plugin-desktop2/src/main.rs deleted file mode 100644 index 57497fc..0000000 --- a/plugin-desktop2/src/main.rs +++ /dev/null @@ -1,123 +0,0 @@ -use std::fs::read_to_string; -use std::path::PathBuf; - -use freedesktop_desktop_entry::{DesktopEntry, Iter}; -use once_cell::sync::Lazy; -use regex::Regex; -use rmenu_plugin::{Action, Entry, Method}; - -mod icons; - -static XDG_DATA_ENV: &'static str = "XDG_DATA_DIRS"; -static XDG_CONFIG_ENV: &'static str = "XDG_CONFIG_HOME"; -static XDG_DATA_DEFAULT: &'static str = "/usr/share:/usr/local/share"; -static XDG_CONFIG_DEFAULT: &'static str = "~/.config"; - -static EXEC_RGX: Lazy = - Lazy::new(|| Regex::new(r"%\w").expect("Failed to Build Exec Regex")); - -/// Retrieve XDG-CONFIG-HOME Directory -#[inline] -fn config_dir() -> PathBuf { - let path = std::env::var(XDG_CONFIG_ENV).unwrap_or_else(|_| XDG_CONFIG_DEFAULT.to_string()); - PathBuf::from(shellexpand::tilde(&path).to_string()) -} - -/// Retrieve XDG-DATA Directories -fn data_dirs(dir: &str) -> Vec { - std::env::var(XDG_DATA_ENV) - .unwrap_or_else(|_| XDG_DATA_DEFAULT.to_string()) - .split(":") - .map(|p| shellexpand::tilde(p).to_string()) - .map(PathBuf::from) - .map(|p| p.join(dir.to_owned())) - .filter(|p| p.exists()) - .collect() -} - -/// Modify Exec Statements to Remove %u/%f/etc... -#[inline(always)] -fn fix_exec(exec: &str) -> String { - EXEC_RGX.replace_all(exec, "").trim().to_string() -} - -/// Parse XDG Desktop Entry into RMenu Entry -fn parse_desktop(path: &PathBuf, locale: Option<&str>) -> Option { - let bytes = read_to_string(path).ok()?; - let entry = DesktopEntry::decode(&path, &bytes).ok()?; - let name = entry.name(locale)?.to_string(); - let icon = entry.icon().map(|i| i.to_string()); - let comment = entry.comment(locale).map(|s| s.to_string()); - let terminal = entry.terminal(); - let mut actions = match entry.exec() { - Some(exec) => vec![Action { - name: "main".to_string(), - exec: Method::new(fix_exec(exec), terminal), - comment: None, - }], - None => vec![], - }; - actions.extend( - entry - .actions() - .unwrap_or("") - .split(";") - .into_iter() - .filter(|a| a.len() > 0) - .filter_map(|a| { - let name = entry.action_name(a, locale)?; - let exec = entry.action_exec(a)?; - Some(Action { - name: name.to_string(), - exec: Method::new(fix_exec(exec), terminal), - comment: None, - }) - }), - ); - Some(Entry { - name, - actions, - comment, - icon, - }) -} - -/// Assign XDG Icon based on Desktop-Entry -fn assign_icon(icon: String, map: &icons::IconMap) -> Option { - if !icon.contains("/") { - if let Some(icon) = map.get(&icon) { - if let Some(path) = icon.to_str() { - return Some(path.to_owned()); - } - } - } - Some(icon) -} - -fn main() { - let locale = Some("en"); - let sizes = vec![64, 32, 96, 22, 128]; - - // collect icons - let cfg = config_dir(); - let spec = icons::IconSpec::standard(&cfg, sizes); - let icons = icons::collect_icons(spec); - - // collect applications - let app_paths = data_dirs("applications"); - let mut desktops: Vec = Iter::new(app_paths) - .into_iter() - .filter_map(|f| parse_desktop(&f, locale)) - .map(|mut e| { - e.icon = e.icon.and_then(|s| assign_icon(s, &icons)); - e - }) - .collect(); - - desktops.sort_by_cached_key(|e| e.name.to_owned()); - desktops - .into_iter() - .filter_map(|e| serde_json::to_string(&e).ok()) - .map(|s| println!("{}", s)) - .last(); -} From 5cc40a564ad1f5dec2fe5ce71cd35d44da6d0d74 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Mon, 7 Aug 2023 16:06:58 -0700 Subject: [PATCH 26/37] feat: cleanup and improve logging --- rmenu/Cargo.toml | 2 +- rmenu/src/cache.rs | 1 - rmenu/src/exec.rs | 2 +- rmenu/src/main.rs | 8 +++++--- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/rmenu/Cargo.toml b/rmenu/Cargo.toml index de0807f..4424d80 100644 --- a/rmenu/Cargo.toml +++ b/rmenu/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rmenu" -version = "0.0.0" +version = "0.0.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/rmenu/src/cache.rs b/rmenu/src/cache.rs index 97b75a8..f9522e4 100644 --- a/rmenu/src/cache.rs +++ b/rmenu/src/cache.rs @@ -80,7 +80,6 @@ pub fn write_cache(name: &str, cfg: &PluginConfig, entries: &Vec) -> Resu CacheSetting::NoCache => {} _ => { let path = cache_file(name); - println!("write! {:?}", path); let data = bincode::serialize(entries)?; let mut f = fs::File::create(path)?; f.write_all(&data)?; diff --git a/rmenu/src/exec.rs b/rmenu/src/exec.rs index f5858a4..ec0420d 100644 --- a/rmenu/src/exec.rs +++ b/rmenu/src/exec.rs @@ -41,7 +41,7 @@ fn parse_args(exec: &str) -> Vec { } pub fn execute(action: &Action, term: Option) { - log::info!("executing: {} {:?}", action.name, action.exec); + log::info!("executing: {:?} {:?}", action.name, action.exec); let args = match &action.exec { Method::Run(exec) => parse_args(&exec), Method::Terminal(exec) => { diff --git a/rmenu/src/main.rs b/rmenu/src/main.rs index 2986497..1c73f1b 100644 --- a/rmenu/src/main.rs +++ b/rmenu/src/main.rs @@ -128,6 +128,7 @@ impl Args { "-" => "/dev/stdin", _ => &self.input, }; + log::info!("reading from {fpath:?}"); let file = File::open(fpath).map_err(|e| RMenuError::FileError(e))?; let reader = BufReader::new(file); let mut entries = vec![]; @@ -236,10 +237,11 @@ impl Args { //TODO: improve looks and css fn main() -> Result<(), RMenuError> { - // enable log if env-var is present - if std::env::var("RUST_LOG").is_ok() { - env_logger::init(); + // enable log and set default level + if std::env::var("RUST_LOG").is_err() { + std::env::set_var("RUST_LOG", "info"); } + env_logger::init(); // parse cli / config / application-settings let app = Args::parse_app()?; // change directory to configuration dir From 32ec532697bd24d8cfa051274b72775e4034033c Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Mon, 7 Aug 2023 16:07:20 -0700 Subject: [PATCH 27/37] feat: install rmenu to cargo-bin path --- Makefile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Makefile b/Makefile index 0ba7794..4ed0322 100644 --- a/Makefile +++ b/Makefile @@ -4,11 +4,13 @@ CARGO=cargo FLAGS=--release DEST=$(HOME)/.config/rmenu +INSTALL=$(CARGO_PATH)/bin install: build deploy deploy: mkdir -p ${DEST} + cp -vf ./target/release/rmenu ${INSTALL}/rmenu cp -vf ./target/release/desktop ${DEST}/drun cp -vf ./target/release/run ${DEST}/run cp -vf ./rmenu/public/config.yaml ${DEST}/config.yaml From 713aec13450cb7afb28e194dc2df37c44ddfb287 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Mon, 7 Aug 2023 16:25:37 -0700 Subject: [PATCH 28/37] feat: enhanced config --- rmenu/public/config.yaml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/rmenu/public/config.yaml b/rmenu/public/config.yaml index 0d47b72..fdb4ef7 100644 --- a/rmenu/public/config.yaml +++ b/rmenu/public/config.yaml @@ -1,7 +1,23 @@ +# global search settings use_icons: true ignore_case: true search_regex: false +# window settings +window: + title: "Rmenu - Application Launcher" + size: + width: 500 + height: 500 + position: + x: 300 + y: 500 + focus: true + decorate: false + transparent: false + always_top: true + +# configured plugin settings plugins: run: exec: ["~/.config/rmenu/run"] @@ -10,6 +26,7 @@ plugins: exec: ["~/.config/rmenu/drun"] cache: onlogin +# custom keybindings keybinds: exec: ["Enter"] exit: ["Escape"] From 6b852bc2f49314a0ae69dff8ebf7537b9379561c Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Mon, 7 Aug 2023 20:50:01 -0700 Subject: [PATCH 29/37] feat: minor config tweaks --- rmenu/public/config.yaml | 4 ++-- rmenu/public/default.css | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rmenu/public/config.yaml b/rmenu/public/config.yaml index fdb4ef7..a2d9cd0 100644 --- a/rmenu/public/config.yaml +++ b/rmenu/public/config.yaml @@ -7,8 +7,8 @@ search_regex: false window: title: "Rmenu - Application Launcher" size: - width: 500 - height: 500 + width: 800 + height: 400 position: x: 300 y: 500 diff --git a/rmenu/public/default.css b/rmenu/public/default.css index d12e4aa..656085c 100644 --- a/rmenu/public/default.css +++ b/rmenu/public/default.css @@ -73,7 +73,7 @@ div.actions { } div.action-name { - width: 10%; + width: 50%; } div.actions.active { From 610d702c475e267929eb2377bcea0cb93537b4f5 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Tue, 8 Aug 2023 11:40:46 -0700 Subject: [PATCH 30/37] feat: ensure automatic focus --- rmenu/src/gui.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rmenu/src/gui.rs b/rmenu/src/gui.rs index 21fa62c..7d1d8da 100644 --- a/rmenu/src/gui.rs +++ b/rmenu/src/gui.rs @@ -153,6 +153,9 @@ fn matches(bind: &Vec, mods: &Modifiers, key: &Code) -> bool { fn App<'a>(cx: Scope) -> Element { let mut state = AppState::new(cx, cx.props); + // always ensure focus + focus(cx); + // log current position let search = state.search(); let (pos, subpos) = state.position(); @@ -206,7 +209,7 @@ fn App<'a>(cx: Scope) -> Element { cx.render(rsx! { style { "{cx.props.css}" } div { - onclick: |_| focus(cx), + // onclick: |_| focus(cx), onkeydown: keyboard_controls, div { class: "navbar", From ec52a892342fdff6e594fe3ede5f1fe965fd20cc Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Tue, 8 Aug 2023 12:29:25 -0700 Subject: [PATCH 31/37] feat: added sway subconfig, always include default css --- rmenu/public/99-rmenu-sway.conf | 3 +++ rmenu/public/default.css | 24 ++++++++++++------------ rmenu/src/gui.rs | 3 ++- rmenu/src/main.rs | 2 +- 4 files changed, 18 insertions(+), 14 deletions(-) create mode 100644 rmenu/public/99-rmenu-sway.conf diff --git a/rmenu/public/99-rmenu-sway.conf b/rmenu/public/99-rmenu-sway.conf new file mode 100644 index 0000000..3163368 --- /dev/null +++ b/rmenu/public/99-rmenu-sway.conf @@ -0,0 +1,3 @@ +# Configure RMenu to Spawn Floating in the Center of the Screen + +for_window [app_id="rmenu"] floating enable diff --git a/rmenu/public/default.css b/rmenu/public/default.css index 656085c..d768e5c 100644 --- a/rmenu/public/default.css +++ b/rmenu/public/default.css @@ -9,20 +9,20 @@ main { justify-content: center; } -div.navbar { +.navbar { top: 0; position: fixed; overflow: hidden; min-width: 99%; } -div.results { +.results { height: 100vh; margin-top: 50px; overflow-y: auto; } -div.selected { +.selected { background-color: lightblue; } @@ -34,49 +34,49 @@ input { /* Result CSS */ -div.result, div.action { +.result, .action { display: flex; align-items: center; justify-content: left; } -div.result > div, div.action > div { +.result > div, .action > div { margin: 2px 5px; } -div.result > div.icon { +.result > .icon { width: 4%; overflow: hidden; display: flex; justify-content: center; } -div.result > div.icon > img { +.result > .icon > img { width: 100%; height: 100%; object-fit: cover; } -div.result > div.name { +.result > .name { width: 30%; } -div.result > div.comment { +.result > .comment { flex: 1; } /* Action CSS */ -div.actions { +.actions { display: none; padding-left: 5%; } -div.action-name { +.action-name { width: 50%; } -div.actions.active { +.actions.active { display: flex; flex-direction: column; justify-content: center; diff --git a/rmenu/src/gui.rs b/rmenu/src/gui.rs index 7d1d8da..d2f384e 100644 --- a/rmenu/src/gui.rs +++ b/rmenu/src/gui.rs @@ -6,7 +6,7 @@ use rmenu_plugin::Entry; use crate::config::Keybind; use crate::state::{AppState, KeyEvent}; -use crate::App; +use crate::{App, DEFAULT_CSS_CONTENT}; /// spawn and run the app on the configured platform pub fn run(app: App) { @@ -207,6 +207,7 @@ fn App<'a>(cx: Scope) -> Element { }); cx.render(rsx! { + style { DEFAULT_CSS_CONTENT } style { "{cx.props.css}" } div { // onclick: |_| focus(cx), diff --git a/rmenu/src/main.rs b/rmenu/src/main.rs index 1c73f1b..3ccbc64 100644 --- a/rmenu/src/main.rs +++ b/rmenu/src/main.rs @@ -212,7 +212,7 @@ impl Args { Ok(css) => css, Err(err) => { log::error!("failed to load css: {err:?}"); - DEFAULT_CSS_CONTENT.to_owned() + "".to_owned() } }; // load entries from configured sources From e6f746a7ccbe1e2a80cccb45a2b58f99cc73c1f7 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Tue, 8 Aug 2023 15:07:54 -0700 Subject: [PATCH 32/37] feat: improve default css --- rmenu/public/default.css | 4 ++++ rmenu/src/gui.rs | 1 + 2 files changed, 5 insertions(+) diff --git a/rmenu/public/default.css b/rmenu/public/default.css index d768e5c..b2e0b75 100644 --- a/rmenu/public/default.css +++ b/rmenu/public/default.css @@ -30,6 +30,10 @@ main { input { width: 99%; + height: 5vw; + outline: none; + border: none; + font-size: large; } /* Result CSS */ diff --git a/rmenu/src/gui.rs b/rmenu/src/gui.rs index d2f384e..6aa9613 100644 --- a/rmenu/src/gui.rs +++ b/rmenu/src/gui.rs @@ -221,6 +221,7 @@ fn App<'a>(cx: Scope) -> Element { } } div { + id: "results", class: "results", rendered_results.into_iter() } From 34e59131f60e4fa2e38116cb8e758099c82fbc08 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Tue, 8 Aug 2023 15:08:10 -0700 Subject: [PATCH 33/37] feat: include sway install in makefile --- Makefile | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Makefile b/Makefile index 4ed0322..2651f6d 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,13 @@ FLAGS=--release DEST=$(HOME)/.config/rmenu INSTALL=$(CARGO_PATH)/bin +SWAY_CONF=/etc/sway/config.d + +all: install sway + +sway: + echo "Installing Configuration for Sway" + sudo cp -vf ./rmenu/public/99-rmenu-sway.conf ${SWAY_CONF}/. install: build deploy From 9d71d9ac2af59d8884c5cc6e4975eff0066f59e3 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Tue, 8 Aug 2023 15:20:18 -0700 Subject: [PATCH 34/37] feat: wrap result/actions in result-entry div --- rmenu/src/gui.rs | 49 +++++++++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/rmenu/src/gui.rs b/rmenu/src/gui.rs index 6aa9613..648c2f2 100644 --- a/rmenu/src/gui.rs +++ b/rmenu/src/gui.rs @@ -106,32 +106,35 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { }); cx.render(rsx! { div { - id: "result-{cx.props.index}", - class: "result {result_classes} {multi_classes}", - // onmouseenter: |_| cx.props.state.set_position(cx.props.index, 0), - onclick: |_| cx.props.state.set_position(cx.props.index, 0), - ondblclick: |_| cx.props.state.set_event(KeyEvent::Exec), - if cx.props.state.config().use_icons { - cx.render(rsx! { - div { - class: "icon", - render_image(cx, cx.props.entry.icon.as_ref()) - } - }) + class: "result-entry", + div { + id: "result-{cx.props.index}", + class: "result {result_classes} {multi_classes}", + // onmouseenter: |_| cx.props.state.set_position(cx.props.index, 0), + onclick: |_| cx.props.state.set_position(cx.props.index, 0), + ondblclick: |_| cx.props.state.set_event(KeyEvent::Exec), + if cx.props.state.config().use_icons { + cx.render(rsx! { + div { + class: "icon", + render_image(cx, cx.props.entry.icon.as_ref()) + } + }) + } + div { + class: "name", + "{cx.props.entry.name}" + } + div { + class: "comment", + render_comment(cx.props.entry.comment.as_ref()) + } } div { - class: "name", - "{cx.props.entry.name}" + id: "result-{cx.props.index}-actions", + class: "actions {action_classes}", + actions.into_iter() } - div { - class: "comment", - render_comment(cx.props.entry.comment.as_ref()) - } - } - div { - id: "result-{cx.props.index}-actions", - class: "actions {action_classes}", - actions.into_iter() } }) } From dfde39c5b7514b7a8dbffb16e91424e4f76862a6 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Tue, 8 Aug 2023 16:29:02 -0700 Subject: [PATCH 35/37] feat: improved default css, added example themes --- rmenu/public/default.css | 12 ++++++------ themes/dark.css | 25 +++++++++++++++++++++++++ themes/nord.css | 22 ++++++++++++++++++++++ themes/solarized.css | 27 +++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 themes/dark.css create mode 100644 themes/nord.css create mode 100644 themes/solarized.css diff --git a/rmenu/public/default.css b/rmenu/public/default.css index b2e0b75..8174ffd 100644 --- a/rmenu/public/default.css +++ b/rmenu/public/default.css @@ -3,17 +3,17 @@ body { overflow: hidden; } -main { - display: flex; - flex-direction: column; - justify-content: center; +body > div { + height: -webkit-fill-available; + overflow: hidden; } .navbar { top: 0; + left: 0; position: fixed; overflow: hidden; - min-width: 99%; + width: -webkit-fill-available; } .results { @@ -29,7 +29,7 @@ main { /* Navigation */ input { - width: 99%; + width: 100%; height: 5vw; outline: none; border: none; diff --git a/themes/dark.css b/themes/dark.css new file mode 100644 index 0000000..6d3917f --- /dev/null +++ b/themes/dark.css @@ -0,0 +1,25 @@ +* { + font-family: monospace; + color: white; +} + +html { + background-color: #383C4A; +} + +#search { + border: none; + border-radius: 0px; + border-bottom: 3px solid black; + background-color: #383C4A; + margin-bottom: 5px; + padding-bottom: 0px; +} + +.result-entry:nth-child(odd){ + background-color: #404552; +} + +.selected { + background-color: #5291e2; +} diff --git a/themes/nord.css b/themes/nord.css new file mode 100644 index 0000000..5be0eff --- /dev/null +++ b/themes/nord.css @@ -0,0 +1,22 @@ +* { + font-family: "Hack", monospace; + color: white; +} + +html { + background-color: #3B4252; +} + +#search { + border: none; + border-radius: 0px; + background-color: #3B4252; +} + +.result-entry:nth-child(odd){ + background-color: #404552; +} + +.selected { + background-color: #4C566A; +} diff --git a/themes/solarized.css b/themes/solarized.css new file mode 100644 index 0000000..e42a3b9 --- /dev/null +++ b/themes/solarized.css @@ -0,0 +1,27 @@ +* { + color: #839496; + font-family: monospace; + margin: 0px; +} + +html, body { + margin: 0px; + border: 1px solid #928374; + background-color: #002b36; +} + +.navbar { + margin: 2px; +} + +#search { + border: none; + color: #839496; + background-color: #073642; + padding: 5px; + width: -webkit-fill-available; +} + +.selected { + background-color: #073642; +} From 9032738f52233d2773c034dbb549a495fbc13e24 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Tue, 8 Aug 2023 17:10:34 -0700 Subject: [PATCH 36/37] fix: minor css changes, remove default-css from deployment --- Makefile | 1 - rmenu/public/default.css | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 2651f6d..476bd67 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,6 @@ deploy: cp -vf ./target/release/desktop ${DEST}/drun cp -vf ./target/release/run ${DEST}/run cp -vf ./rmenu/public/config.yaml ${DEST}/config.yaml - cp -vf ./rmenu/public/default.css ${DEST}/style.css build: build-rmenu build-plugins diff --git a/rmenu/public/default.css b/rmenu/public/default.css index 8174ffd..4c72c8f 100644 --- a/rmenu/public/default.css +++ b/rmenu/public/default.css @@ -31,8 +31,9 @@ body > div { input { width: 100%; height: 5vw; - outline: none; border: none; + outline: none; + padding: 5px; font-size: large; } From f2e31b3f3cf14cb6f94834252a6dbced4f8f10d4 Mon Sep 17 00:00:00 2001 From: imgurbot12 Date: Tue, 8 Aug 2023 18:52:41 -0700 Subject: [PATCH 37/37] feat: better readme --- README.md | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 272affe..ddbcb73 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,58 @@ -# RMenu ----------- +RMenu +------ Another customizable Application-Launcher written in Rust + +### Features + +* Blazingly Fast 🔥 +* Simple and Easy to Use +* Customizable (Configuration and CSS-Styling) +* Plugin Support +* Dmenu-Like Stdin Menu Generation + +### Installation + +```bash +$ make install +``` + +### Usage + +RMenu Comes with Two Bultin Plugins: "Desktop Run" aka `drun`. + +```bash +$ rmenu -r run +``` + +RMenu also comes with a "$PATH Run" plugin aka `run`. +Both are managed via the default configuration file after installation. + +```bash +$ rmenu -r drun +``` + +Custom Menus can also be passed much like Dmenu by passing items via +an input. The schema follows a standard as defined in [rmenu-plugin](./rmenu-plugin) + +```bash +$ generate-my-menu.sh > input.json +$ rmenu -i input.json +``` + +When neither a plugin nor an input are specified, rmenu defaults to +reading from stdin. + +```bash +$ generate-my-menu.sh | rmenu +``` + +### Configuration + +Customize RMenu Behavior and Appearal in a [single config](./rmenu/public/config.yaml) + +Customize the entire app's appearance with CSS. A few [Example Themes](./themes/) +are available as reference. To try them out use: `rmenu --css ` +or move the css file to `$HOME/.config/rmenu/style.css` + +