mirror of
https://github.com/imgurbot12/rmenu.git
synced 2025-01-27 13:28:03 +01:00
feat: complete rewrite for v2
This commit is contained in:
parent
b806f2b26d
commit
6fe171c398
26 changed files with 243 additions and 1471 deletions
5
Cargo.toml
Normal file
5
Cargo.toml
Normal file
|
@ -0,0 +1,5 @@
|
|||
[workspace]
|
||||
members = [
|
||||
"rmenu",
|
||||
"rmenu-plugin"
|
||||
]
|
|
@ -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 }
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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 })
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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"
|
|
@ -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
|
||||
}
|
|
@ -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 }
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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"
|
|
@ -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
|
||||
}
|
|
@ -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);
|
|
@ -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"
|
|
@ -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);
|
|
@ -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
9
rmenu-plugin/Cargo.toml
Normal 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
33
rmenu-plugin/src/lib.rs
Normal 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
18
rmenu/Cargo.toml
Normal 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
40
rmenu/public/default.css
Normal 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
69
rmenu/src/gui.rs
Normal 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
69
rmenu/src/main.rs
Normal 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);
|
||||
}
|
Loading…
Reference in a new issue