diff --git a/crates/rmenu-plugin/src/internal.rs b/crates/rmenu-plugin/src/internal.rs index b299556..f4388ec 100644 --- a/crates/rmenu-plugin/src/internal.rs +++ b/crates/rmenu-plugin/src/internal.rs @@ -1,7 +1,6 @@ /* * Internal Library Loading Implementation */ -use abi_stable::std_types::{RBox, RHashMap, RString}; use libloading::{Error, Library, Symbol}; use super::{Module, ModuleConfig}; diff --git a/crates/rmenu/Cargo.toml b/crates/rmenu/Cargo.toml index a6131d7..e34a75e 100644 --- a/crates/rmenu/Cargo.toml +++ b/crates/rmenu/Cargo.toml @@ -7,4 +7,18 @@ edition = "2021" [dependencies] abi_stable = "0.11.1" +clap = { version = "4.0.32", features = ["derive"] } +dashmap = "5.4.0" +eframe = "0.20.1" +egui = "0.20.1" +egui_extras = { version = "0.20.0", features = ["svg", "image"] } +image = { version = "0.24.5", default-features = false, features = ["png"] } +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 new file mode 100644 index 0000000..51241e5 --- /dev/null +++ b/crates/rmenu/src/config.rs @@ -0,0 +1,94 @@ +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 window_width: f32, + pub window_height: f32, + pub result_size: usize, +} + +#[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, + window_width: 500.0, + window_height: 300.0, + result_size: 15, + }, + 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() { + fs::create_dir(get_config_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/gui/gui.rs.bak b/crates/rmenu/src/gui/gui.rs.bak new file mode 100644 index 0000000..dd612a3 --- /dev/null +++ b/crates/rmenu/src/gui/gui.rs.bak @@ -0,0 +1,193 @@ +use std::cmp::min; +use std::process::exit; + +use eframe::egui; +use eframe::egui::ScrollArea; + +use super::exec::exec_command; +use super::icons::{background_load, IconCache}; +use super::modules::{Entries, Mode, ModuleSearch, Settings}; + +/* Application */ + +pub struct App { + modules: ModuleSearch, + search: String, + results: Option, + focus: usize, + images: IconCache, +} + +// application class-related functions and utilities +impl App { + pub fn new(modes: Vec, settings: Settings) -> Self { + let modules = ModuleSearch::new(modes, settings).expect("module search failed"); + let mut app = Self { + search: "".to_owned(), + modules, + results: None, + focus: 0, + images: IconCache::new(), + }; + app.search(); + app + } + + fn search(&mut self) { + // assign values + self.focus = 0; + self.results = self.modules.search(&self.search, 0).ok(); + // load icons in background + if let Some(results) = self.results.as_ref() { + background_load(&mut self.images, 20, results); + } + } + + // shift focus based on size of results and scope of valid range + fn shift_focus(&mut self, shift: i32) { + // handle shifts up + if shift < 0 { + let change = shift.abs() as usize; + if change > self.focus { + self.focus = 0; + return; + } + self.focus -= change; + return; + } + // handle shifts down + let max_pos = if let Some(r) = self.results.as_ref() { + r.len() - 1 + } else { + 0 + }; + self.focus = min(self.focus + shift as usize, max_pos); + } +} + +// ui component functions +impl App { + // implement keyboard navigation controls between menu items + #[inline] + fn keyboard_controls(&mut self, ctx: &egui::Context) { + // tab/ctrl+tab controls + if ctx.input().key_pressed(egui::Key::Tab) { + if ctx.input().modifiers.ctrl { + self.shift_focus(-1); + } else { + self.shift_focus(1); + }; + } + // arrow-key controls + if ctx.input().key_pressed(egui::Key::ArrowUp) && self.focus > 0 { + self.shift_focus(-1); + } + if ctx.input().key_pressed(egui::Key::ArrowDown) { + self.shift_focus(1); + } + // pageup/down controls + if ctx.input().key_pressed(egui::Key::PageUp) { + self.shift_focus(-5); + } + if ctx.input().key_pressed(egui::Key::PageDown) { + self.shift_focus(5); + } + // escape + if ctx.input().key_pressed(egui::Key::Escape) { + exit(1); + } + // enter - app selection + if ctx.input().key_pressed(egui::Key::Enter) { + let Some(results) = self.results.as_ref() else { return }; + let Some(entry) = results.get(self.focus) else { return }; + exec_command(&entry.exec, entry.terminal); + exit(0); + } + } + + // 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 = ui.text_edit_singleline(&mut self.search); + if search.changed() { + self.search(); + } + }); + } + + // implement simple scrolling grid-based results panel + fn simple_results(&mut self, ui: &mut egui::Ui) { + let focus = self.focus; + ScrollArea::vertical() + .auto_shrink([false; 2]) + .show_viewport(ui, |ui, viewport| { + // calculate top/bottom positions and size of each row + let top_pos = viewport.min.y; + let bottom_pos = viewport.max.y; + let spacing = ui.spacing(); + let row_height = spacing.interact_size.y + spacing.item_spacing.y; + // render results and their related fields + let Some(found) = self.results.as_ref() else { return }; + let results = found.clone(); + egui::Grid::new("results") + .with_row_color(move |row, style| { + if row == focus { + return Some(egui::Rgba::from(style.visuals.faint_bg_color)); + }; + None + }) + .show(ui, |ui| { + let has_icons = results + .iter() + .filter(|r| r.icon.is_some()) + .peekable() + .peek() + .is_some(); + for (n, record) in results.into_iter().enumerate() { + let y = n as f32 * row_height; + // load and render image field + // content is contained within a horizontal to keep + // scroll-pos from updating when icon renderings + // change + if has_icons { + ui.horizontal(|ui| { + // only render images that display within view window + if n == 0 || (y < bottom_pos && y > top_pos) { + if let Some(icon) = record.icon.as_ref() { + if let Ok(image) = self.images.load(icon) { + let size = egui::vec2(20.0, 20.0); + image.show_size(ui, size); + } + } + return; + } + ui.label(""); + }); + } + // render text fields + let label = ui.label(&record.name); + if n == self.focus { + label.scroll_to_me(None) + } + if let Some(extra) = record.comment.as_ref() { + ui.label(extra); + } + ui.end_row(); + } + }); + }); + } +} + +impl eframe::App for App { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + egui::CentralPanel::default().show(ctx, |ui| { + self.keyboard_controls(ctx); + self.simple_search(ui); + self.simple_results(ui); + }); + } +} diff --git a/crates/rmenu/src/gui/icons.rs b/crates/rmenu/src/gui/icons.rs new file mode 100644 index 0000000..fa34edb --- /dev/null +++ b/crates/rmenu/src/gui/icons.rs @@ -0,0 +1,93 @@ +/* + * 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(); + if !self.c.contains_key(name) { + self.c.insert( + name.to_owned(), + if name.ends_with(".svg") { + RetainedImage::from_svg_bytes(name, &icon.data)? + } else { + RetainedImage::from_image_bytes(name, &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 new file mode 100644 index 0000000..7623ba7 --- /dev/null +++ b/crates/rmenu/src/gui/mod.rs @@ -0,0 +1,205 @@ +/*! + * Rmenu - Egui implementation + */ +use std::cmp::min; + +use eframe::egui; +use rmenu_plugin::Entry; + +mod icons; +use icons::{load_images, IconCache}; + +use crate::{config::Config, plugins::Plugins}; + +/* Function */ + +// spawn gui application and run it +pub fn launch_gui(cfg: Config, plugins: Plugins) -> Result<(), eframe::Error> { + let options = eframe::NativeOptions { + initial_window_size: Some(egui::vec2(cfg.rmenu.window_width, cfg.rmenu.window_height)), + ..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, + results: Vec, + focus: usize, + focus_updated: bool, + images: IconCache, + config: Config, +} + +impl GUI { + pub fn new(config: Config, plugins: Plugins) -> Self { + let mut gui = Self { + config, + plugins, + search: "".to_owned(), + results: vec![], + focus: 0, + focus_updated: false, + images: IconCache::new(), + }; + // pre-run empty search to generate cache + gui.search(); + gui + } + + // complete search based on current internal search variable + fn search(&mut self) { + // update variables and complete search + self.focus = 0; + self.results = self.plugins.search(&self.search); + self.focus_updated = true; + // load icons in background + if self.results.len() > 0 { + load_images(&mut self.images, 20, &self.results); + } + } + + #[inline] + fn set_focus(&mut self, focus: usize) { + self.focus = focus; + self.focus_updated = true; + } + + // shift focus up a certain number of rows + #[inline] + fn focus_up(&mut self, shift: usize) { + self.set_focus(self.focus - min(shift, self.focus)); + } + + // shift focus down a certain number of rows + 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)); + } + + #[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.focus_down(1), + false => self.focus_up(1), + }; + } + // arrow-key controls + if ctx.input().key_pressed(egui::Key::ArrowUp) { + self.focus_up(1); + } + if ctx.input().key_pressed(egui::Key::ArrowDown) { + self.focus_down(1) + } + // pageup / pagedown controls + if ctx.input().key_pressed(egui::Key::PageUp) { + self.focus_up(5); + } + if ctx.input().key_pressed(egui::Key::PageDown) { + self.focus_down(5); + } + } +} + +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 = ui.text_edit_singleline(&mut self.search); + if search.changed() { + self.search(); + } + }); + } + + // check if results contain any icons at all + #[inline] + fn has_icons(&self) -> bool { + self.results + .iter() + .filter(|r| r.icon.is_some()) + .peekable() + .peek() + .is_some() + } + + #[inline] + fn grid_highlight(&self) -> Box Option> { + let focus = self.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) { + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show_viewport(ui, |ui, viewport| { + // calculate top/bottom positions and size of rows + let spacing = ui.spacing(); + let top_y = viewport.min.y; + let bot_y = viewport.max.y; + let row_h = spacing.interact_size.y + spacing.item_spacing.y; + // render results and their related fields + let results = &self.results; + let has_icons = self.has_icons(); + egui::Grid::new("results") + .with_row_color(self.grid_highlight()) + .show(ui, |ui| { + for (n, record) in results.iter().enumerate() { + // render icon if enabled and within visible bounds + if has_icons { + ui.horizontal(|ui| { + let y = n as f32 * row_h; + if n == 0 || (y < bot_y && y > top_y) { + 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 main label + let label = ui.label(record.name.as_str()); + // scroll to laebl when focus shifts + if n == self.focus && self.focus_updated { + label.scroll_to_me(None); + self.focus_updated = false; + } + // render comment (if any) + 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/main.rs b/crates/rmenu/src/main.rs index f367f8a..63f2a90 100644 --- a/crates/rmenu/src/main.rs +++ b/crates/rmenu/src/main.rs @@ -1,20 +1,55 @@ -use abi_stable::std_types::{RHashMap, RString}; -use rmenu_plugin::internal::load_plugin; +use clap::Parser; -static PLUGIN: &str = "../../plugins/run/target/release/librun.so"; +mod config; +mod gui; +mod plugins; -fn test() { - let mut cfg = RHashMap::new(); - // cfg.insert(RString::from("ignore_case"), RString::from("true")); +use config::{load_config, PluginConfig}; +use gui::launch_gui; +use plugins::Plugins; - let mut plugin = unsafe { load_plugin(PLUGIN, &cfg).unwrap() }; - let results = plugin.module.search(RString::from("br")); - for result in results.into_iter() { - println!("{} - {:?}", result.name, result.comment); - } - println!("ayy lmao done!"); +/* 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() { - test(); + // 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 new file mode 100644 index 0000000..1b876ea --- /dev/null +++ b/crates/rmenu/src/plugins.rs @@ -0,0 +1,33 @@ +use abi_stable::std_types::RString; +use rmenu_plugin::internal::{load_plugin, Plugin}; +use rmenu_plugin::Entry; + +use super::config::PluginConfig; + +/// Convenient wrapper used to execute configured plugins +pub struct Plugins { + plugins: Vec, +} + +impl Plugins { + pub fn new(enable: Vec, plugins: Vec) -> Self { + Self { + plugins: plugins + .into_iter() + .map(|p| unsafe { load_plugin(&p.path, &p.config) }.expect("failed to load plugin")) + .filter(|plugin| enable.contains(&plugin.module.name().as_str().to_owned())) + .collect(), + } + } + + /// complete search w/ the configured plugins + pub fn search(&mut self, search: &str) -> Vec { + let mut entries = vec![]; + for plugin in self.plugins.iter_mut() { + let found = plugin.module.search(RString::from(search)); + entries.append(&mut found.into()); + continue; + } + entries + } +} diff --git a/plugins/drun/src/lib.rs b/plugins/drun/src/lib.rs index dde9c48..cc5092e 100644 --- a/plugins/drun/src/lib.rs +++ b/plugins/drun/src/lib.rs @@ -16,7 +16,7 @@ static PREFIX: &str = "app"; static XDG_DATA_DIRS: &str = "XDG_DATA_DIRS"; -static DEFAULT_XDG_PATHS: &str = "/usr/share/"; +static DEFAULT_XDG_PATHS: &str = "/usr/share/:/usr/local/share"; static DEFAULT_APP_PATHS: &str = ""; static DEFAULT_ICON_PATHS: &str = "/usr/share/pixmaps/";