diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..88c38ca --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[workspace] +resolver = "2" +members = [ + "rmenu", + "rmenu-plugin", + "plugin-run", + "plugin-desktop", +] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..476bd67 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +# RMenu Installation/Deployment Configuration + +CARGO=cargo +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 + +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 + +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/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` + + 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 e34a75e..0000000 --- a/crates/rmenu/Cargo.toml +++ /dev/null @@ -1,24 +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"] } -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"] } -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/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/icons.rs b/crates/rmenu/src/gui/icons.rs deleted file mode 100644 index a692e45..0000000 --- a/crates/rmenu/src/gui/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/mod.rs b/crates/rmenu/src/gui/mod.rs deleted file mode 100644 index 408e353..0000000 --- a/crates/rmenu/src/gui/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/page.rs b/crates/rmenu/src/gui/page.rs deleted file mode 100644 index fe6049f..0000000 --- a/crates/rmenu/src/gui/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/plugin-desktop/Cargo.toml b/plugin-desktop/Cargo.toml new file mode 100644 index 0000000..442dd79 --- /dev/null +++ b/plugin-desktop/Cargo.toml @@ -0,0 +1,19 @@ +[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] +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-desktop/src/icons.rs b/plugin-desktop/src/icons.rs new file mode 100644 index 0000000..3f7e023 --- /dev/null +++ b/plugin-desktop/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(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(&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(&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 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-desktop/src/main.rs b/plugin-desktop/src/main.rs new file mode 100644 index 0000000..57497fc --- /dev/null +++ b/plugin-desktop/src/main.rs @@ -0,0 +1,123 @@ +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(); +} 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/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..e766c2c --- /dev/null +++ b/rmenu-plugin/src/lib.rs @@ -0,0 +1,53 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Method { + 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: Method, + pub comment: Option, +} + +impl Action { + pub fn new(exec: &str) -> Self { + Self { + name: "main".to_string(), + exec: Method::Run(exec.to_string()), + comment: None, + } + } +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub struct Entry { + pub name: String, + pub actions: Vec, + pub comment: Option, + pub icon: Option, +} + +impl Entry { + pub fn new(name: &str, action: &str, comment: Option<&str>) -> Self { + Self { + name: name.to_owned(), + actions: vec![Action::new(action)], + comment: comment.map(|c| c.to_owned()), + icon: Default::default(), + } + } +} diff --git a/rmenu/Cargo.toml b/rmenu/Cargo.toml new file mode 100644 index 0000000..4424d80 --- /dev/null +++ b/rmenu/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "rmenu" +version = "0.0.1" +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" +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" +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" +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/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/config.yaml b/rmenu/public/config.yaml new file mode 100644 index 0000000..a2d9cd0 --- /dev/null +++ b/rmenu/public/config.yaml @@ -0,0 +1,36 @@ +# global search settings +use_icons: true +ignore_case: true +search_regex: false + +# window settings +window: + title: "Rmenu - Application Launcher" + size: + width: 800 + height: 400 + position: + x: 300 + y: 500 + focus: true + decorate: false + transparent: false + always_top: true + +# configured plugin settings +plugins: + run: + exec: ["~/.config/rmenu/run"] + cache: 300 + drun: + exec: ["~/.config/rmenu/drun"] + cache: onlogin + +# custom keybindings +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/public/default.css b/rmenu/public/default.css new file mode 100644 index 0000000..4c72c8f --- /dev/null +++ b/rmenu/public/default.css @@ -0,0 +1,88 @@ + +body { + overflow: hidden; +} + +body > div { + height: -webkit-fill-available; + overflow: hidden; +} + +.navbar { + top: 0; + left: 0; + position: fixed; + overflow: hidden; + width: -webkit-fill-available; +} + +.results { + height: 100vh; + margin-top: 50px; + overflow-y: auto; +} + +.selected { + background-color: lightblue; +} + +/* Navigation */ + +input { + width: 100%; + height: 5vw; + border: none; + outline: none; + padding: 5px; + font-size: large; +} + +/* Result CSS */ + +.result, .action { + display: flex; + align-items: center; + justify-content: left; +} + +.result > div, .action > div { + margin: 2px 5px; +} + +.result > .icon { + width: 4%; + overflow: hidden; + display: flex; + justify-content: center; +} + +.result > .icon > img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.result > .name { + width: 30%; +} + +.result > .comment { + flex: 1; +} + +/* Action CSS */ + +.actions { + display: none; + padding-left: 5%; +} + +.action-name { + width: 50%; +} + +.actions.active { + display: flex; + flex-direction: column; + justify-content: center; +} diff --git a/rmenu/src/cache.rs b/rmenu/src/cache.rs new file mode 100644 index 0000000..f9522e4 --- /dev/null +++ b/rmenu/src/cache.rs @@ -0,0 +1,89 @@ +//! 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); + 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 new file mode 100644 index 0000000..08ce35e --- /dev/null +++ b/rmenu/src/config.rs @@ -0,0 +1,216 @@ +//! RMENU Configuration Implementations +use heck::AsPascalCase; +use keyboard_types::{Code, Modifiers}; +use serde::{de::Error, Deserialize}; +use std::collections::BTreeMap; +use std::str::FromStr; + +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, + } +} + +/// Single GUI Keybind for Configuration +#[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) + } +} + +/// Global GUI Keybind Settings Options +#[derive(Debug, PartialEq, Deserialize)] +#[serde(default)] +pub struct KeyConfig { + pub exec: Vec, + pub exit: Vec, + pub move_up: Vec, + pub move_down: Vec, + pub open_menu: Vec, + pub close_menu: Vec, +} + +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)], + open_menu: vec![], + close_menu: vec![], + }; + } +} + +/// GUI Desktop Window Configuration Settings +#[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, + // }, + size: LogicalSize { + width: 1000.0, + height: 400.0, + }, + position: LogicalPosition { x: 100.0, y: 100.0 }, + focus: true, + decorate: false, + transparent: false, + always_top: true, + dark_mode: None, + } + } +} + +/// 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 { + pub page_size: usize, + pub page_load: f64, + pub use_icons: bool, + pub search_regex: bool, + pub ignore_case: bool, + pub plugins: BTreeMap, + pub keybinds: KeyConfig, + pub window: WindowConfig, + pub terminal: Option, +} + +impl Default for Config { + fn default() -> Self { + Self { + page_size: 50, + page_load: 0.8, + use_icons: true, + search_regex: false, + ignore_case: true, + plugins: Default::default(), + keybinds: Default::default(), + window: Default::default(), + terminal: Default::default(), + } + } +} diff --git a/rmenu/src/exec.rs b/rmenu/src/exec.rs new file mode 100644 index 0000000..ec0420d --- /dev/null +++ b/rmenu/src/exec.rs @@ -0,0 +1,57 @@ +//! Execution Implementation for Entry Actions +use std::process::Command; +use std::{collections::HashMap, os::unix::process::CommandExt}; + +use rmenu_plugin::{Action, Method}; +use shell_words::split; +use strfmt::strfmt; +use which::which; + +/// 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}", 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/gui.rs b/rmenu/src/gui.rs new file mode 100644 index 0000000..648c2f2 --- /dev/null +++ b/rmenu/src/gui.rs @@ -0,0 +1,233 @@ +//! RMENU GUI Implementation using Dioxus +#![allow(non_snake_case)] +use dioxus::prelude::*; +use keyboard_types::{Code, Modifiers}; +use rmenu_plugin::Entry; + +use crate::config::Keybind; +use crate::state::{AppState, KeyEvent}; +use crate::{App, DEFAULT_CSS_CONTENT}; + +/// spawn and run the app on the configured platform +pub fn run(app: App) { + // 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)] +struct GEntry<'a> { + pos: usize, + subpos: usize, + index: usize, + entry: &'a Entry, + 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) + 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 multi_classes = match cx.props.entry.actions.len() > 1 { + true => "submenu", + 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}", + 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}" + } + div { + class: "action-comment", + render_comment(action.comment.as_ref()) + } + } + }) + }); + cx.render(rsx! { + div { + 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 { + id: "result-{cx.props.index}-actions", + class: "actions {action_classes}", + actions.into_iter() + } + } + }) +} + +#[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<'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(); + log::debug!("search: {search:?}, pos: {pos}, {subpos}"); + + // 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_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); + } + }; + + // 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, + } + }) + }); + + cx.render(rsx! { + style { DEFAULT_CSS_CONTENT } + style { "{cx.props.css}" } + div { + // onclick: |_| focus(cx), + onkeydown: keyboard_controls, + div { + class: "navbar", + input { + id: "search", + value: "{search}", + oninput: move |evt| s_updater.set_search(cx, evt.value.clone()), + } + } + div { + id: "results", + class: "results", + rendered_results.into_iter() + } + } + }) +} diff --git a/rmenu/src/image.rs b/rmenu/src/image.rs new file mode 100644 index 0000000..ff87319 --- /dev/null +++ b/rmenu/src/image.rs @@ -0,0 +1,77 @@ +//! GUI Image Processing +use std::fs::{create_dir_all, read_to_string, write}; +use std::io; +use std::path::PathBuf; +use std::sync::Mutex; + +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")] + InvalidFile(#[from] std::io::Error), + #[error("Invalid Document")] + InvalidTree(#[from] resvg::usvg::Error), + #[error("Failed to Alloc PixBuf")] + NoPixBuf(u32, u32, u32), + #[error("Failed to Convert SVG to PNG")] + PngError(#[from] png::EncodingError), +} + +/// 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(); + let tree = resvg::usvg::Tree::from_str(&xml, &opt)?; + 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 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 png = pixmap.encode_png()?; + // base64 encode png + Ok(write(dest, png)?) +} + +#[cached] +pub fn convert_svg(path: String) -> Option { + // 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()) +} diff --git a/rmenu/src/main.rs b/rmenu/src/main.rs new file mode 100644 index 0000000..3ccbc64 --- /dev/null +++ b/rmenu/src/main.rs @@ -0,0 +1,255 @@ +use std::collections::VecDeque; +use std::fmt::Display; +use std::fs::{read_to_string, File}; +use std::io::{self, prelude::*, BufReader}; +use std::process::{Command, ExitStatus, Stdio}; +use std::str::FromStr; + +mod cache; +mod config; +mod exec; +mod gui; +mod image; +mod search; +mod state; + +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, + 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)] +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)] +#[command(propagate_version = true)] +pub struct Args { + #[arg(short, long, default_value_t=String::from("-"))] + input: String, + #[arg(short, long, default_value_t=Format::Json)] + format: Format, + #[arg(short, long)] + run: Vec, + #[arg(long)] + regex: Option, + #[arg(short, long)] + config: Option, + #[arg(long)] + css: Option, +} + +impl Args { + /// Load Config based on CLI Settings + fn config(&self) -> Result { + let path = match &self.config { + Some(path) => path.to_owned(), + None => shellexpand::tilde(DEFAULT_CONFIG).to_string(), + }; + 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()); + } + }; + 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, cfg: &config::Config) -> Result, RMenuError> { + let fpath = match self.input.as_str() { + "-" => "/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![]; + for line in reader.lines() { + let entry = self.readentry(cfg, &line?)?; + entries.push(entry); + } + Ok(entries) + } + + /// Load Entries From Specified Sources + fn load_sources(&self, cfg: &config::Config) -> Result, RMenuError> { + log::debug!("config: {cfg:?}"); + // execute commands to get a list of entries + let mut entries = vec![]; + for name in self.run.iter() { + log::debug!("running plugin: {name}"); + // retrieve plugin command arguments + 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 = plugin + .exec + .iter() + .map(|arg| shellexpand::tilde(arg).to_string()) + .collect(); + 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); + } + // spawn command + let mut proc = cmd.stdout(Stdio::piped()).spawn()?; + let stdout = proc + .stdout + .as_mut() + .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() { + 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( + 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) + } + + /// Load Application + pub fn parse_app() -> Result { + let args = Self::parse(); + 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(); + let css = match read_to_string(csspath) { + Ok(css) => css, + Err(err) => { + log::error!("failed to load css: {err:?}"); + "".to_owned() + } + }; + // load entries from configured sources + let entries = match args.run.len() > 0 { + 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, + name: "rmenu".to_owned(), + entries, + config, + }); + } +} + +//TODO: improve search w/ modes? +//TODO: improve looks and css + +fn main() -> Result<(), RMenuError> { + // 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 + 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:?}"); + } + // run gui + gui::run(app); + Ok(()) +} diff --git a/rmenu/src/search.rs b/rmenu/src/search.rs new file mode 100644 index 0000000..1ec0cef --- /dev/null +++ b/rmenu/src/search.rs @@ -0,0 +1,52 @@ +//! RMENU Entry Search Function Implementaton +use regex::RegexBuilder; +use rmenu_plugin::Entry; + +use crate::config::Config; + +/// 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 rgx = RegexBuilder::new(search) + .case_insensitive(cfg.ignore_case) + .build(); + 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 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(); + 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 + }) +} diff --git a/rmenu/src/state.rs b/rmenu/src/state.rs new file mode 100644 index 0000000..3075d87 --- /dev/null +++ b/rmenu/src/state.rs @@ -0,0 +1,239 @@ +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; + +#[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); +} + +#[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(&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(&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(&mut self) { + 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, 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; + } + } + 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(); + log::debug!("execute {pos} {subpos}"); + let Some(result) = self.results.get(pos) else { + return; + }; + log::debug!("result: {result:?}"); + let Some(action) = result.actions.get(subpos) else { + return; + }; + log::debug!("action: {action:?}"); + execute(action, self.app.config.terminal.clone()); + } + + /// 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(); + let pos = self.position().0; + scroll(cx, if pos <= 3 { pos } else { pos + 3 }) + } + KeyEvent::ShiftDown => { + self.shift_down(); + scroll(cx, self.position().0 + 3) + } + }; + 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, 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) + 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)) + } +} 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; +}