feat: complete rewrite for v2

This commit is contained in:
imgurbot12 2023-07-17 22:49:07 -07:00
parent b806f2b26d
commit 6fe171c398
26 changed files with 243 additions and 1471 deletions

5
Cargo.toml Normal file
View File

@ -0,0 +1,5 @@
[workspace]
members = [
"rmenu",
"rmenu-plugin"
]

View File

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

View File

@ -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<String, String> {
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::<CacheSetting>()
.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<Self, Self::Err> {
if s.chars().all(char::is_numeric) {
let i = s.parse::<u64>().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<String, Entries>,
}
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<Entries> {
// 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<F>(&mut self, name: &str, valid: &CacheSetting, func: F) -> Result<Entries>
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())
}
}

View File

@ -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<dyn Module>,
}
type LoadFunc = unsafe extern "C" fn(&ModuleConfig) -> Box<dyn Module>;
/* Functions */
pub unsafe fn load_plugin(path: &str, cfg: &ModuleConfig) -> Result<Plugin, Error> {
// 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::<Symbol<LoadFunc>>(b"load_module")?(cfg);
Ok(Plugin { lib, module })
}

View File

@ -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<u8>,
}
/// 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<RString>,
pub icon: ROption<Icon>,
}
/// Alias for FFI-safe vector of `Entry` object
pub type Entries = RVec<Entry>;
/// Alias for FFI-Safe hashmap for config entries
pub type ModuleConfig = RHashMap<RString, RString>;
/// 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<dyn Module> {
let inst = $module::new(config);
Box::new(inst)
}
};
}

View File

@ -1,17 +0,0 @@
[package]
name = "rmenu"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
abi_stable = "0.11.1"
clap = { version = "4.0.32", features = ["derive"] }
dioxus = "0.3.2"
dioxus-desktop = "0.3.0"
log = "0.4.17"
rmenu-plugin = { version = "0.1.0", path = "../rmenu-plugin", features = ["rmenu_internals"] }
serde = { version = "1.0.152", features = ["derive"] }
shellexpand = "3.0.0"
toml = "0.5.10"

View File

@ -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<bool>,
pub window_pos: Option<[f32; 2]>,
pub window_size: Option<[f32; 2]>,
pub result_size: Option<usize>,
pub decorate_window: bool,
}
#[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,
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<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() {
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
}

View File

@ -1,57 +0,0 @@
use dioxus::prelude::*;
use dioxus_desktop::{Config, WindowBuilder};
use rmenu_plugin::Entry;
use crate::{config, plugins::Plugins};
/// Spawn GUI instance with the specified config and plugins
pub fn launch_gui(cfg: config::Config, plugins: Plugins) -> Result<(), String> {
// simple_logger::init_with_level(log::Level::Debug).unwrap();
let size = cfg.rmenu.window_size.unwrap_or([550.0, 350.0]);
let gui = GUI::new(cfg, plugins);
dioxus_desktop::launch_cfg(
App,
Config::default().with_window(WindowBuilder::new().with_resizable(true).with_inner_size(
dioxus_desktop::wry::application::dpi::LogicalSize::new(size[0], size[1]),
)),
);
Ok(())
}
struct GUI {
plugins: Plugins,
search: String,
config: config::Config,
}
impl GUI {
pub fn new(config: config::Config, plugins: Plugins) -> Self {
Self {
config,
plugins,
search: "".to_owned(),
}
}
fn search(&mut self, search: &str) -> Vec<Entry> {
self.plugins.search(search)
}
pub fn app(&mut self, cx: Scope) -> Element {
let results = self.search("");
cx.render(rsx! {
div {
h1 { "Hello World!" }
result.iter().map(|entry| {
div {
div { entry.name }
div { entry.comment }
}
})
}
})
}
}

View File

@ -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<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();
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<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),
}
}
}

View File

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

View File

@ -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<Entry>,
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<Entry>) {
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<Entry>,
}
impl<'a> PageIter<'a> {
pub fn new(start: usize, stop: usize, results: &'a Vec<Entry>) -> Self {
Self {
stop,
results,
cursor: start,
}
}
}
impl<'a> Iterator for PageIter<'a> {
type Item = &'a Entry;
fn next(&mut self) -> Option<Self::Item> {
if self.cursor >= self.stop {
return None;
}
let result = self.results.get(self.cursor);
self.cursor += 1;
result
}
}

View File

@ -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<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() {
// 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

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

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

View File

@ -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<IconFile>) -> Option<String> {
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<IconFile>) -> Option<Icon> {
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<String>, icon_paths: &Vec<String>) -> Vec<Entry> {
// iterate and collect all existing icon paths
let mut imap: HashMap<String, IconFile> = 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
}

View File

@ -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<String> {
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<String>) -> Vec<String> {
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<String>,
app_paths: Vec<String>,
icon_paths: Vec<String>,
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<Entries> {
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);

View File

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

View File

@ -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<String> {
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<String> {
parse_config_paths(&env::var(PATH_VAR).expect("Unable to read $PATH"))
}
/* Module */
struct Settings {
paths: Vec<String>,
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<Entries> {
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);

View File

@ -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<String>) -> Vec<Entry> {
let mut execs: HashMap<String, Entry> = 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<Entry> {
// let cfg = &settings.run;
// let paths = match cfg.paths.as_ref() {
// Some(paths) => paths.clone(),
// None => get_paths(),
// };
// find_executables(&paths)
// }
// }

9
rmenu-plugin/Cargo.toml Normal file
View File

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

33
rmenu-plugin/src/lib.rs Normal file
View File

@ -0,0 +1,33 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub enum Method {
Terminal,
Desktop,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub struct Action {
pub exec: String,
pub comment: Option<String>,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub struct Entry {
pub name: String,
pub actions: BTreeMap<String, Action>,
pub comment: Option<String>,
pub icon: Option<String>,
}
impl Entry {
pub fn new(name: &str) -> Self {
Self {
name: name.to_owned(),
actions: Default::default(),
comment: Default::default(),
icon: Default::default(),
}
}
}

18
rmenu/Cargo.toml Normal file
View File

@ -0,0 +1,18 @@
[package]
name = "rmenu"
version = "0.0.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
clap = { version = "4.3.15", features = ["derive"] }
dioxus = "0.3.2"
rmenu-plugin = { version = "0.0.0", path = "../rmenu-plugin" }
serde_json = "1.0.103"
[target.'cfg(any(unix, windows))'.dependencies]
dioxus-desktop = { version = "0.3.0" }
[target.'cfg(target_family = "wasm")'.dependencies]
dioxus-web = { version = "0.3.1" }

40
rmenu/public/default.css Normal file
View File

@ -0,0 +1,40 @@
main {
display: flex;
flex-direction: column;
justify-content: center;
}
input {
min-width: 99%;
}
div.result {
display: flex;
align-items: center;
justify-content: left;
}
div.result > div {
margin: 2px 5px;
}
div.result > div.icon {
width: 4%;
overflow: hidden;
display: flex;
justify-content: center;
}
div.result > div.icon > img {
width: 100%;
height: 100%;
object-fit: cover;
}
div.result > div.name {
width: 30%;
}
div.result > div.comment {
flex: 1;
}

69
rmenu/src/gui.rs Normal file
View File

@ -0,0 +1,69 @@
#![allow(non_snake_case)]
use dioxus::prelude::*;
use rmenu_plugin::Entry;
use crate::App;
pub fn run(app: App) {
#[cfg(target_family = "wasm")]
dioxus_web::launch(App, app, dioxus_web::Config::default());
#[cfg(any(windows, unix))]
dioxus_desktop::launch_with_props(App, app, dioxus_desktop::Config::default());
}
#[derive(PartialEq, Props)]
struct GEntry<'a> {
o: &'a Entry,
}
fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> {
cx.render(rsx! {
div {
class: "result",
div {
class: "icon",
if let Some(icon) = cx.props.o.icon.as_ref() {
cx.render(rsx! { img { src: "{icon}" } })
}
}
div {
class: "name",
"{cx.props.o.name}"
}
div {
class: "comment",
if let Some(comment) = cx.props.o.comment.as_ref() {
format!("- {comment}")
}
}
}
})
}
fn App(cx: Scope<App>) -> Element {
let search = use_state(cx, || "".to_string());
let results = &cx.props.entries;
let searchstr = search.as_str();
let results_rendered = results
.iter()
.filter(|entry| {
if entry.name.contains(searchstr) {
return true;
}
if let Some(comment) = entry.comment.as_ref() {
return comment.contains(searchstr);
}
false
})
.map(|entry| cx.render(rsx! { TableEntry{ o: entry } }));
cx.render(rsx! {
style { "{cx.props.css}" }
input {
value: "{search}",
oninput: move |evt| search.set(evt.value.clone()),
}
results_rendered
})
}

69
rmenu/src/main.rs Normal file
View File

@ -0,0 +1,69 @@
use std::fs::{read_to_string, File};
use std::io::{prelude::*, BufReader, Error};
mod gui;
use clap::*;
use rmenu_plugin::Entry;
/// Rofi Clone (Built with Rust)
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
#[command(propagate_version = true)]
pub struct Args {
#[arg(short, long, default_value_t=String::from("-"))]
input: String,
#[arg(short, long)]
json: bool,
#[arg(short, long)]
msgpack: bool,
#[arg(short, long)]
run: Vec<String>,
#[arg(long)]
css: Option<String>,
}
//TODO: improve search w/ options for regex/case-insensivity/modes?
//TODO: improve looks and css
/// Application State for GUI
#[derive(Debug, PartialEq)]
pub struct App {
css: String,
name: String,
entries: Vec<Entry>,
}
fn default(args: &Args) -> Result<App, Error> {
// read entries from specified input
let fpath = if args.input == "-" {
"/dev/stdin"
} else {
&args.input
};
let file = File::open(fpath)?;
let reader = BufReader::new(file);
let mut entries = vec![];
for line in reader.lines() {
let entry = serde_json::from_str::<Entry>(&line?)?;
entries.push(entry);
}
// generate app object based on configured args
let css = args
.css
.clone()
.unwrap_or("rmenu/public/default.css".to_owned());
let args = App {
name: "default".to_string(),
css: read_to_string(css)?,
entries,
};
Ok(args)
}
fn main() {
let cli = Args::parse();
let app = default(&cli).unwrap();
println!("{:?}", app);
gui::run(app);
}