feat: more work on rmenu implementation

This commit is contained in:
imgurbot12 2023-01-05 18:10:37 -07:00
parent 18e8cb978c
commit 5e114ef36a
9 changed files with 681 additions and 15 deletions

View File

@ -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};

View File

@ -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" }

View File

@ -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<String, PluginConfig>,
}
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<String>) -> 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
}

View File

@ -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<Entries>,
focus: usize,
images: IconCache,
}
// application class-related functions and utilities
impl App {
pub fn new(modes: Vec<Mode>, 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);
});
}
}

View File

@ -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<String, RetainedImage>;
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<Entry>) {
// retrieve icons from results
let icons: Vec<Icon> = 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<Cache>,
}
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<IconRef<'_>, 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<Icon>) {
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),
}
}
}

205
crates/rmenu/src/gui/mod.rs Normal file
View File

@ -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<Entry>,
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<dyn Fn(usize, &egui::Style) -> Option<egui::Rgba>> {
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);
});
}
}

View File

@ -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<String>,
/// terminal command override
#[arg(short, long)]
pub term: Option<String>,
/// declared and enabled plugin modes
#[arg(short, long)]
pub show: Option<Vec<String>>,
}
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<PluginConfig> = 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")
}

View File

@ -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<Plugin>,
}
impl Plugins {
pub fn new(enable: Vec<String>, plugins: Vec<PluginConfig>) -> 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<Entry> {
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
}
}

View File

@ -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/";