forked from mirrors/rmenu
commit
b60360f074
43 changed files with 2132 additions and 1423 deletions
8
Cargo.toml
Normal file
8
Cargo.toml
Normal file
|
@ -0,0 +1,8 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"rmenu",
|
||||
"rmenu-plugin",
|
||||
"plugin-run",
|
||||
"plugin-desktop",
|
||||
]
|
32
Makefile
Normal file
32
Makefile
Normal file
|
@ -0,0 +1,32 @@
|
|||
# RMenu Installation/Deployment Configuration
|
||||
|
||||
CARGO=cargo
|
||||
FLAGS=--release
|
||||
|
||||
DEST=$(HOME)/.config/rmenu
|
||||
INSTALL=$(CARGO_PATH)/bin
|
||||
SWAY_CONF=/etc/sway/config.d
|
||||
|
||||
all: install sway
|
||||
|
||||
sway:
|
||||
echo "Installing Configuration for Sway"
|
||||
sudo cp -vf ./rmenu/public/99-rmenu-sway.conf ${SWAY_CONF}/.
|
||||
|
||||
install: build deploy
|
||||
|
||||
deploy:
|
||||
mkdir -p ${DEST}
|
||||
cp -vf ./target/release/rmenu ${INSTALL}/rmenu
|
||||
cp -vf ./target/release/desktop ${DEST}/drun
|
||||
cp -vf ./target/release/run ${DEST}/run
|
||||
cp -vf ./rmenu/public/config.yaml ${DEST}/config.yaml
|
||||
|
||||
build: build-rmenu build-plugins
|
||||
|
||||
build-rmenu:
|
||||
${CARGO} build -p rmenu ${FLAGS}
|
||||
|
||||
build-plugins:
|
||||
${CARGO} build -p run ${FLAGS}
|
||||
${CARGO} build -p desktop ${FLAGS}
|
58
README.md
58
README.md
|
@ -1,4 +1,58 @@
|
|||
# RMenu
|
||||
----------
|
||||
RMenu
|
||||
------
|
||||
|
||||
Another customizable Application-Launcher written in Rust
|
||||
|
||||
### Features
|
||||
|
||||
* Blazingly Fast 🔥
|
||||
* Simple and Easy to Use
|
||||
* Customizable (Configuration and CSS-Styling)
|
||||
* Plugin Support
|
||||
* Dmenu-Like Stdin Menu Generation
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
$ make install
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
RMenu Comes with Two Bultin Plugins: "Desktop Run" aka `drun`.
|
||||
|
||||
```bash
|
||||
$ rmenu -r run
|
||||
```
|
||||
|
||||
RMenu also comes with a "$PATH Run" plugin aka `run`.
|
||||
Both are managed via the default configuration file after installation.
|
||||
|
||||
```bash
|
||||
$ rmenu -r drun
|
||||
```
|
||||
|
||||
Custom Menus can also be passed much like Dmenu by passing items via
|
||||
an input. The schema follows a standard as defined in [rmenu-plugin](./rmenu-plugin)
|
||||
|
||||
```bash
|
||||
$ generate-my-menu.sh > input.json
|
||||
$ rmenu -i input.json
|
||||
```
|
||||
|
||||
When neither a plugin nor an input are specified, rmenu defaults to
|
||||
reading from stdin.
|
||||
|
||||
```bash
|
||||
$ generate-my-menu.sh | rmenu
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Customize RMenu Behavior and Appearal in a [single config](./rmenu/public/config.yaml)
|
||||
|
||||
Customize the entire app's appearance with CSS. A few [Example Themes](./themes/)
|
||||
are available as reference. To try them out use: `rmenu --css <my-css-theme>`
|
||||
or move the css file to `$HOME/.config/rmenu/style.css`
|
||||
|
||||
|
||||
|
|
|
@ -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,24 +0,0 @@
|
|||
[package]
|
||||
name = "rmenu"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
abi_stable = "0.11.1"
|
||||
clap = { version = "4.0.32", features = ["derive"] }
|
||||
dashmap = "5.4.0"
|
||||
eframe = "0.20.1"
|
||||
egui = "0.20.1"
|
||||
egui_extras = { version = "0.20.0", features = ["svg", "image"] }
|
||||
image = { version = "0.24.5", default-features = false, features = ["png"] }
|
||||
log = "0.4.17"
|
||||
rmenu-plugin = { version = "0.1.0", path = "../rmenu-plugin", features = ["rmenu_internals"] }
|
||||
serde = { version = "1.0.152", features = ["derive"] }
|
||||
shellexpand = "3.0.0"
|
||||
toml = "0.5.10"
|
||||
|
||||
[patch.crates-io]
|
||||
eframe = { git = "https://github.com/imgurbot12/egui", branch="feat/grid-color" }
|
||||
egui = { git = "https://github.com/imgurbot12/egui", branch="feat/grid-color" }
|
|
@ -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,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
|
||||
}
|
||||
}
|
19
plugin-desktop/Cargo.toml
Normal file
19
plugin-desktop/Cargo.toml
Normal file
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "desktop"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
freedesktop-desktop-entry = "0.5.0"
|
||||
freedesktop-icons = "0.2.3"
|
||||
log = "0.4.19"
|
||||
once_cell = "1.18.0"
|
||||
regex = "1.9.1"
|
||||
rmenu-plugin = { version = "0.0.0", path = "../rmenu-plugin" }
|
||||
rust-ini = "0.19.0"
|
||||
serde_json = "1.0.104"
|
||||
shellexpand = "3.1.0"
|
||||
thiserror = "1.0.44"
|
||||
walkdir = "2.3.3"
|
306
plugin-desktop/src/icons.rs
Normal file
306
plugin-desktop/src/icons.rs
Normal file
|
@ -0,0 +1,306 @@
|
|||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fs::{read_dir, read_to_string};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use freedesktop_desktop_entry::DesktopEntry;
|
||||
use ini::Ini;
|
||||
use once_cell::sync::Lazy;
|
||||
use thiserror::Error;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
type ThemeSource<'a> = (&'a str, &'a str, &'a str);
|
||||
|
||||
static INDEX_MAIN: &'static str = "Icon Theme";
|
||||
static INDEX_NAME: &'static str = "Name";
|
||||
static INDEX_SIZE: &'static str = "Size";
|
||||
static INDEX_DIRS: &'static str = "Directories";
|
||||
static INDEX_FILE: &'static str = "index.theme";
|
||||
|
||||
static DEFAULT_INDEX: &'static str = "default/index.theme";
|
||||
static DEFAULT_THEME: &'static str = "Hicolor";
|
||||
|
||||
static PIXMAPS: Lazy<PathBuf> = Lazy::new(|| PathBuf::from("/usr/share/pixmaps/"));
|
||||
static THEME_SOURCES: Lazy<Vec<ThemeSource>> = Lazy::new(|| {
|
||||
vec![
|
||||
("kdeglobals", "Icons", "Theme"),
|
||||
("gtk-4.0/settings.ini", "Settings", "gtk-icon-theme-name"),
|
||||
("gtk-3.0/settings.ini", "Settings", "gtk-icon-theme-name"),
|
||||
]
|
||||
});
|
||||
|
||||
/// Title String
|
||||
#[inline]
|
||||
fn title(s: &str) -> String {
|
||||
let mut c = s.chars();
|
||||
match c.next() {
|
||||
None => String::new(),
|
||||
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect Theme Definitions in Common GUI Configurations
|
||||
fn theme_inis(cfgdir: &PathBuf) -> Vec<String> {
|
||||
THEME_SOURCES
|
||||
.iter()
|
||||
.filter_map(|(path, sec, key)| {
|
||||
let path = cfgdir.join(path);
|
||||
let ini = Ini::load_from_file(path).ok()?;
|
||||
ini.get_from(Some(sec.to_owned()), key).map(|s| title(s))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse FreeDesktop Theme-Name from Index File
|
||||
fn get_theme_name(path: &PathBuf) -> Option<String> {
|
||||
let content = read_to_string(path).ok()?;
|
||||
let config = DesktopEntry::decode(&path, &content).ok()?;
|
||||
config
|
||||
.groups
|
||||
.get(INDEX_MAIN)
|
||||
.and_then(|g| g.get(INDEX_NAME))
|
||||
.map(|key| key.0.to_owned())
|
||||
}
|
||||
|
||||
/// Determine XDG Icon Theme based on Preexisting Configuration Files
|
||||
pub fn active_themes(cfgdir: &PathBuf, icondirs: &Vec<PathBuf>) -> Vec<String> {
|
||||
let mut themes: Vec<String> = icondirs
|
||||
.iter()
|
||||
.map(|d| d.join(DEFAULT_INDEX))
|
||||
.filter(|p| p.exists())
|
||||
.filter_map(|p| get_theme_name(&p))
|
||||
.collect();
|
||||
themes.extend(theme_inis(cfgdir));
|
||||
let default = DEFAULT_THEME.to_string();
|
||||
if !themes.contains(&default) {
|
||||
themes.push(default);
|
||||
}
|
||||
themes
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ThemeError {
|
||||
#[error("Failed to Read Index")]
|
||||
FileError(#[from] std::io::Error),
|
||||
#[error("Failed to Parse Index")]
|
||||
IndexError(#[from] freedesktop_desktop_entry::DecodeError),
|
||||
#[error("No Such Group")]
|
||||
NoSuchGroup(&'static str),
|
||||
#[error("No Such Key")]
|
||||
NoSuchKey(&'static str),
|
||||
#[error("Unselected Theme")]
|
||||
UnselectedTheme,
|
||||
#[error("Invalid Path Name")]
|
||||
BadPathName(PathBuf),
|
||||
}
|
||||
|
||||
/// Track Paths and their Priority according to Sizes preference
|
||||
struct PathPriority {
|
||||
path: PathBuf,
|
||||
priority: usize,
|
||||
}
|
||||
|
||||
impl PathPriority {
|
||||
fn new(path: PathBuf, priority: usize) -> Self {
|
||||
Self { path, priority }
|
||||
}
|
||||
}
|
||||
|
||||
/// Track Theme Information w/ Name/Priority/SubPaths
|
||||
struct ThemeInfo {
|
||||
name: String,
|
||||
priority: usize,
|
||||
paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// Single Theme Specification
|
||||
struct ThemeSpec<'a> {
|
||||
root: &'a PathBuf,
|
||||
themes: &'a Vec<String>,
|
||||
sizes: &'a Vec<String>,
|
||||
}
|
||||
|
||||
impl<'a> ThemeSpec<'a> {
|
||||
fn new(root: &'a PathBuf, themes: &'a Vec<String>, sizes: &'a Vec<String>) -> Self {
|
||||
Self {
|
||||
root,
|
||||
themes,
|
||||
sizes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort Theme Directories by Priority, Append Root, and Collect Names Only
|
||||
#[inline]
|
||||
fn sort_dirs(dirs: &mut Vec<PathPriority>) -> Vec<PathBuf> {
|
||||
dirs.sort_by_key(|p| p.priority);
|
||||
dirs.push(PathPriority::new("".into(), 0));
|
||||
dirs.into_iter().map(|p| p.path.to_owned()).collect()
|
||||
}
|
||||
|
||||
/// Parse Theme Index and Sort Directories based on Size Preference
|
||||
fn parse_index(spec: &ThemeSpec) -> Result<ThemeInfo, ThemeError> {
|
||||
// parse file content
|
||||
let index = spec.root.join(INDEX_FILE);
|
||||
let content = read_to_string(&index)?;
|
||||
let config = DesktopEntry::decode(&index, &content)?;
|
||||
let main = config
|
||||
.groups
|
||||
.get(INDEX_MAIN)
|
||||
.ok_or_else(|| ThemeError::NoSuchGroup(INDEX_MAIN))?;
|
||||
// retrieve name and directories
|
||||
let name = main
|
||||
.get(INDEX_NAME)
|
||||
.ok_or_else(|| ThemeError::NoSuchKey(INDEX_NAME))?
|
||||
.0;
|
||||
// check if name in supported themes
|
||||
let index = spec
|
||||
.themes
|
||||
.iter()
|
||||
.position(|t| t == &name)
|
||||
.ok_or_else(|| ThemeError::UnselectedTheme)?;
|
||||
// sort directories based on size preference
|
||||
let mut directories = main
|
||||
.get(INDEX_DIRS)
|
||||
.ok_or_else(|| ThemeError::NoSuchKey(INDEX_DIRS))?
|
||||
.0
|
||||
.split(',')
|
||||
.into_iter()
|
||||
.filter_map(|dir| {
|
||||
let group = config.groups.get(dir)?;
|
||||
let size = group
|
||||
.get(INDEX_SIZE)
|
||||
.and_then(|e| Some(e.0.to_owned()))
|
||||
.and_then(|s| spec.sizes.iter().position(|is| &s == is));
|
||||
Some(match size {
|
||||
Some(num) => PathPriority::new(spec.root.join(dir), num),
|
||||
None => PathPriority::new(spec.root.join(dir), 99),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(ThemeInfo {
|
||||
priority: index,
|
||||
name: name.to_owned(),
|
||||
paths: sort_dirs(&mut directories),
|
||||
})
|
||||
}
|
||||
|
||||
/// Guess Theme when Index is Missing
|
||||
fn guess_index(spec: &ThemeSpec) -> Result<ThemeInfo, ThemeError> {
|
||||
// parse name and confirm active theme
|
||||
let name = title(
|
||||
spec.root
|
||||
.file_name()
|
||||
.ok_or_else(|| ThemeError::BadPathName(spec.root.to_owned()))?
|
||||
.to_str()
|
||||
.ok_or_else(|| ThemeError::BadPathName(spec.root.to_owned()))?,
|
||||
);
|
||||
let index = spec
|
||||
.themes
|
||||
.iter()
|
||||
.position(|t| t == &name)
|
||||
.ok_or_else(|| ThemeError::UnselectedTheme)?;
|
||||
// retrieve directories and include priority
|
||||
let mut directories: Vec<PathPriority> = read_dir(spec.root)?
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| {
|
||||
let name = e.file_name().to_str().map(|n| n.to_owned())?;
|
||||
Some(match name.split_once("x") {
|
||||
Some((size, _)) => {
|
||||
let index = spec.sizes.iter().position(|is| &size == is);
|
||||
PathPriority::new(e.path(), index.unwrap_or(99))
|
||||
}
|
||||
None => PathPriority::new(e.path(), 99),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// sort by priorty and only include matches
|
||||
Ok(ThemeInfo {
|
||||
name,
|
||||
priority: index,
|
||||
paths: sort_dirs(&mut directories),
|
||||
})
|
||||
}
|
||||
|
||||
/// Specification for a Single Theme Path
|
||||
pub struct IconSpec {
|
||||
paths: Vec<PathBuf>,
|
||||
themes: Vec<String>,
|
||||
sizes: Vec<String>,
|
||||
}
|
||||
|
||||
impl IconSpec {
|
||||
pub fn new(paths: Vec<PathBuf>, themes: Vec<String>, sizes: Vec<usize>) -> Self {
|
||||
Self {
|
||||
paths,
|
||||
themes,
|
||||
sizes: sizes.into_iter().map(|i| i.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn standard(cfg: &PathBuf, sizes: Vec<usize>) -> Self {
|
||||
let icon_paths = crate::data_dirs("icons");
|
||||
let themes = active_themes(cfg, &icon_paths);
|
||||
Self::new(icon_paths, themes, sizes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse and Collect a list of Directories to Find Icons in Order of Preference
|
||||
fn parse_themes(icons: IconSpec) -> Vec<PathBuf> {
|
||||
// retrieve supported theme information
|
||||
let mut infos: Vec<ThemeInfo> = icons
|
||||
.paths
|
||||
// retrieve icon directories within main icon data paths
|
||||
.into_iter()
|
||||
.filter_map(|p| Some(read_dir(&p).ok()?.into_iter().filter_map(|d| d.ok())))
|
||||
.flatten()
|
||||
.map(|readdir| readdir.path())
|
||||
// parse or guess index themes
|
||||
.filter_map(|icondir| {
|
||||
let spec = ThemeSpec::new(&icondir, &icons.themes, &icons.sizes);
|
||||
parse_index(&spec)
|
||||
.map(|r| Ok(r))
|
||||
.unwrap_or_else(|_| guess_index(&spec))
|
||||
.ok()
|
||||
})
|
||||
.collect();
|
||||
// sort results by theme index
|
||||
infos.sort_by_key(|i| i.priority);
|
||||
// combine results from multiple directories for the same theme
|
||||
let mut map = BTreeMap::new();
|
||||
for info in infos.into_iter() {
|
||||
map.entry(info.name).or_insert(vec![]).extend(info.paths);
|
||||
}
|
||||
// finalize results from values
|
||||
map.insert("pixmaps".to_owned(), vec![PIXMAPS.to_owned()]);
|
||||
map.into_values().flatten().collect()
|
||||
}
|
||||
|
||||
pub type IconMap = HashMap<String, PathBuf>;
|
||||
|
||||
#[inline]
|
||||
fn is_icon(fname: &str) -> bool {
|
||||
fname.ends_with("png") || fname.ends_with("svg") || fname.ends_with("xpm")
|
||||
}
|
||||
|
||||
/// Collect Unique Icon Map based on Preffered Paths
|
||||
pub fn collect_icons(spec: IconSpec) -> IconMap {
|
||||
let mut map = HashMap::new();
|
||||
for path in parse_themes(spec).into_iter() {
|
||||
let icons = WalkDir::new(path)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_file());
|
||||
for icon in icons {
|
||||
let Some(fname) = icon.file_name().to_str() else { continue };
|
||||
if !is_icon(&fname) {
|
||||
continue;
|
||||
}
|
||||
let Some((name, _)) = fname.rsplit_once(".") else { continue };
|
||||
map.entry(name.to_owned())
|
||||
.or_insert_with(|| icon.path().to_owned());
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
123
plugin-desktop/src/main.rs
Normal file
123
plugin-desktop/src/main.rs
Normal file
|
@ -0,0 +1,123 @@
|
|||
use std::fs::read_to_string;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use freedesktop_desktop_entry::{DesktopEntry, Iter};
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use rmenu_plugin::{Action, Entry, Method};
|
||||
|
||||
mod icons;
|
||||
|
||||
static XDG_DATA_ENV: &'static str = "XDG_DATA_DIRS";
|
||||
static XDG_CONFIG_ENV: &'static str = "XDG_CONFIG_HOME";
|
||||
static XDG_DATA_DEFAULT: &'static str = "/usr/share:/usr/local/share";
|
||||
static XDG_CONFIG_DEFAULT: &'static str = "~/.config";
|
||||
|
||||
static EXEC_RGX: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"%\w").expect("Failed to Build Exec Regex"));
|
||||
|
||||
/// Retrieve XDG-CONFIG-HOME Directory
|
||||
#[inline]
|
||||
fn config_dir() -> PathBuf {
|
||||
let path = std::env::var(XDG_CONFIG_ENV).unwrap_or_else(|_| XDG_CONFIG_DEFAULT.to_string());
|
||||
PathBuf::from(shellexpand::tilde(&path).to_string())
|
||||
}
|
||||
|
||||
/// Retrieve XDG-DATA Directories
|
||||
fn data_dirs(dir: &str) -> Vec<PathBuf> {
|
||||
std::env::var(XDG_DATA_ENV)
|
||||
.unwrap_or_else(|_| XDG_DATA_DEFAULT.to_string())
|
||||
.split(":")
|
||||
.map(|p| shellexpand::tilde(p).to_string())
|
||||
.map(PathBuf::from)
|
||||
.map(|p| p.join(dir.to_owned()))
|
||||
.filter(|p| p.exists())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Modify Exec Statements to Remove %u/%f/etc...
|
||||
#[inline(always)]
|
||||
fn fix_exec(exec: &str) -> String {
|
||||
EXEC_RGX.replace_all(exec, "").trim().to_string()
|
||||
}
|
||||
|
||||
/// Parse XDG Desktop Entry into RMenu Entry
|
||||
fn parse_desktop(path: &PathBuf, locale: Option<&str>) -> Option<Entry> {
|
||||
let bytes = read_to_string(path).ok()?;
|
||||
let entry = DesktopEntry::decode(&path, &bytes).ok()?;
|
||||
let name = entry.name(locale)?.to_string();
|
||||
let icon = entry.icon().map(|i| i.to_string());
|
||||
let comment = entry.comment(locale).map(|s| s.to_string());
|
||||
let terminal = entry.terminal();
|
||||
let mut actions = match entry.exec() {
|
||||
Some(exec) => vec![Action {
|
||||
name: "main".to_string(),
|
||||
exec: Method::new(fix_exec(exec), terminal),
|
||||
comment: None,
|
||||
}],
|
||||
None => vec![],
|
||||
};
|
||||
actions.extend(
|
||||
entry
|
||||
.actions()
|
||||
.unwrap_or("")
|
||||
.split(";")
|
||||
.into_iter()
|
||||
.filter(|a| a.len() > 0)
|
||||
.filter_map(|a| {
|
||||
let name = entry.action_name(a, locale)?;
|
||||
let exec = entry.action_exec(a)?;
|
||||
Some(Action {
|
||||
name: name.to_string(),
|
||||
exec: Method::new(fix_exec(exec), terminal),
|
||||
comment: None,
|
||||
})
|
||||
}),
|
||||
);
|
||||
Some(Entry {
|
||||
name,
|
||||
actions,
|
||||
comment,
|
||||
icon,
|
||||
})
|
||||
}
|
||||
|
||||
/// Assign XDG Icon based on Desktop-Entry
|
||||
fn assign_icon(icon: String, map: &icons::IconMap) -> Option<String> {
|
||||
if !icon.contains("/") {
|
||||
if let Some(icon) = map.get(&icon) {
|
||||
if let Some(path) = icon.to_str() {
|
||||
return Some(path.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(icon)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let locale = Some("en");
|
||||
let sizes = vec![64, 32, 96, 22, 128];
|
||||
|
||||
// collect icons
|
||||
let cfg = config_dir();
|
||||
let spec = icons::IconSpec::standard(&cfg, sizes);
|
||||
let icons = icons::collect_icons(spec);
|
||||
|
||||
// collect applications
|
||||
let app_paths = data_dirs("applications");
|
||||
let mut desktops: Vec<Entry> = Iter::new(app_paths)
|
||||
.into_iter()
|
||||
.filter_map(|f| parse_desktop(&f, locale))
|
||||
.map(|mut e| {
|
||||
e.icon = e.icon.and_then(|s| assign_icon(s, &icons));
|
||||
e
|
||||
})
|
||||
.collect();
|
||||
|
||||
desktops.sort_by_cached_key(|e| e.name.to_owned());
|
||||
desktops
|
||||
.into_iter()
|
||||
.filter_map(|e| serde_json::to_string(&e).ok())
|
||||
.map(|s| println!("{}", s))
|
||||
.last();
|
||||
}
|
11
plugin-run/Cargo.toml
Normal file
11
plugin-run/Cargo.toml
Normal file
|
@ -0,0 +1,11 @@
|
|||
[package]
|
||||
name = "run"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
rmenu-plugin = { version = "0.0.0", path = "../rmenu-plugin" }
|
||||
serde_json = "1.0.103"
|
||||
walkdir = "2.3.3"
|
64
plugin-run/src/main.rs
Normal file
64
plugin-run/src/main.rs
Normal file
|
@ -0,0 +1,64 @@
|
|||
use std::env;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
use rmenu_plugin::Entry;
|
||||
use walkdir::{DirEntry, WalkDir};
|
||||
|
||||
static PATH: &'static str = "PATH";
|
||||
static DEFAULT_PATH: &'static str = "/bin:/usr/bin:/usr/sbin";
|
||||
static EXEC_FLAG: u32 = 0o111;
|
||||
|
||||
/// Retrieve Search Paths from OS-VAR or Default
|
||||
fn bin_paths() -> Vec<String> {
|
||||
env::var(PATH)
|
||||
.unwrap_or_else(|_| DEFAULT_PATH.to_string())
|
||||
.split(":")
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Ignore Entry if Hidden or Filename contains a `.`
|
||||
fn should_ignore(entry: &DirEntry) -> bool {
|
||||
entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.map(|s| s.contains("."))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Retrieve Binaries for the Specified Paths
|
||||
fn find_binaries(path: String) -> Vec<Entry> {
|
||||
WalkDir::new(path)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_entry(|e| !should_ignore(e))
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_file())
|
||||
.filter(|e| {
|
||||
e.metadata()
|
||||
.map(|m| m.permissions().mode() & EXEC_FLAG != 0)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|e| {
|
||||
let path = e.path().to_string_lossy();
|
||||
Entry::new(&e.file_name().to_string_lossy(), &path, Some(&path))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// collect entries for sorting
|
||||
let mut entries: Vec<Entry> = bin_paths()
|
||||
.into_iter()
|
||||
.map(find_binaries)
|
||||
.flatten()
|
||||
.collect();
|
||||
// sort entries and render to json
|
||||
entries.sort_by_cached_key(|e| e.name.clone());
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|e| serde_json::to_string(&e))
|
||||
.filter_map(|r| r.ok())
|
||||
.map(|s| println!("{}", s))
|
||||
.last();
|
||||
}
|
|
@ -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"] }
|
53
rmenu-plugin/src/lib.rs
Normal file
53
rmenu-plugin/src/lib.rs
Normal file
|
@ -0,0 +1,53 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Method {
|
||||
Terminal(String),
|
||||
Run(String),
|
||||
}
|
||||
|
||||
impl Method {
|
||||
pub fn new(exec: String, terminal: bool) -> Self {
|
||||
match terminal {
|
||||
true => Self::Terminal(exec),
|
||||
false => Self::Run(exec),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Action {
|
||||
pub name: String,
|
||||
pub exec: Method,
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn new(exec: &str) -> Self {
|
||||
Self {
|
||||
name: "main".to_string(),
|
||||
exec: Method::Run(exec.to_string()),
|
||||
comment: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Entry {
|
||||
pub name: String,
|
||||
pub actions: Vec<Action>,
|
||||
pub comment: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
pub fn new(name: &str, action: &str, comment: Option<&str>) -> Self {
|
||||
Self {
|
||||
name: name.to_owned(),
|
||||
actions: vec![Action::new(action)],
|
||||
comment: comment.map(|c| c.to_owned()),
|
||||
icon: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
32
rmenu/Cargo.toml
Normal file
32
rmenu/Cargo.toml
Normal file
|
@ -0,0 +1,32 @@
|
|||
[package]
|
||||
name = "rmenu"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
bincode = "1.3.3"
|
||||
cached = "0.44.0"
|
||||
clap = { version = "4.3.15", features = ["derive"] }
|
||||
dioxus = "0.3.2"
|
||||
dioxus-desktop = "0.3.0"
|
||||
env_logger = "0.10.0"
|
||||
heck = "0.4.1"
|
||||
keyboard-types = "0.6.2"
|
||||
lastlog = { version = "0.2.3", features = ["libc"] }
|
||||
log = "0.4.19"
|
||||
once_cell = "1.18.0"
|
||||
png = "0.17.9"
|
||||
quick-xml = "0.30.0"
|
||||
regex = { version = "1.9.1" }
|
||||
resvg = "0.35.0"
|
||||
rmenu-plugin = { version = "0.0.0", path = "../rmenu-plugin" }
|
||||
serde = { version = "1.0.171", features = ["derive"] }
|
||||
serde_json = "1.0.103"
|
||||
serde_yaml = "0.9.24"
|
||||
shell-words = "1.1.0"
|
||||
shellexpand = "3.1.0"
|
||||
strfmt = "0.2.4"
|
||||
thiserror = "1.0.43"
|
||||
which = "4.4.0"
|
3
rmenu/public/99-rmenu-sway.conf
Normal file
3
rmenu/public/99-rmenu-sway.conf
Normal file
|
@ -0,0 +1,3 @@
|
|||
# Configure RMenu to Spawn Floating in the Center of the Screen
|
||||
|
||||
for_window [app_id="rmenu"] floating enable
|
36
rmenu/public/config.yaml
Normal file
36
rmenu/public/config.yaml
Normal file
|
@ -0,0 +1,36 @@
|
|||
# global search settings
|
||||
use_icons: true
|
||||
ignore_case: true
|
||||
search_regex: false
|
||||
|
||||
# window settings
|
||||
window:
|
||||
title: "Rmenu - Application Launcher"
|
||||
size:
|
||||
width: 800
|
||||
height: 400
|
||||
position:
|
||||
x: 300
|
||||
y: 500
|
||||
focus: true
|
||||
decorate: false
|
||||
transparent: false
|
||||
always_top: true
|
||||
|
||||
# configured plugin settings
|
||||
plugins:
|
||||
run:
|
||||
exec: ["~/.config/rmenu/run"]
|
||||
cache: 300
|
||||
drun:
|
||||
exec: ["~/.config/rmenu/drun"]
|
||||
cache: onlogin
|
||||
|
||||
# custom keybindings
|
||||
keybinds:
|
||||
exec: ["Enter"]
|
||||
exit: ["Escape"]
|
||||
move_up: ["Arrow-Up", "Shift+Tab"]
|
||||
move_down: ["Arrow-Down", "Tab"]
|
||||
open_menu: ["Arrow-Right"]
|
||||
close_menu: ["Arrow-Left"]
|
88
rmenu/public/default.css
Normal file
88
rmenu/public/default.css
Normal file
|
@ -0,0 +1,88 @@
|
|||
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body > div {
|
||||
height: -webkit-fill-available;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
top: 0;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
width: -webkit-fill-available;
|
||||
}
|
||||
|
||||
.results {
|
||||
height: 100vh;
|
||||
margin-top: 50px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.selected {
|
||||
background-color: lightblue;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
height: 5vw;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 5px;
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
/* Result CSS */
|
||||
|
||||
.result, .action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: left;
|
||||
}
|
||||
|
||||
.result > div, .action > div {
|
||||
margin: 2px 5px;
|
||||
}
|
||||
|
||||
.result > .icon {
|
||||
width: 4%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.result > .icon > img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.result > .name {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.result > .comment {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Action CSS */
|
||||
|
||||
.actions {
|
||||
display: none;
|
||||
padding-left: 5%;
|
||||
}
|
||||
|
||||
.action-name {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.actions.active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
89
rmenu/src/cache.rs
Normal file
89
rmenu/src/cache.rs
Normal file
|
@ -0,0 +1,89 @@
|
|||
//! RMenu Plugin Result Cache
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use rmenu_plugin::Entry;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::config::{CacheSetting, PluginConfig};
|
||||
use crate::CONFIG_DIR;
|
||||
|
||||
static CONFIG_PATH: Lazy<PathBuf> =
|
||||
Lazy::new(|| PathBuf::from(shellexpand::tilde(CONFIG_DIR).to_string()));
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CacheError {
|
||||
#[error("Cache Not Available")]
|
||||
NotAvailable,
|
||||
#[error("Cache Invalid")]
|
||||
InvalidCache,
|
||||
#[error("Cache Expired")]
|
||||
CacheExpired,
|
||||
#[error("Cache File Error")]
|
||||
FileError(#[from] std::io::Error),
|
||||
#[error("Encoding Error")]
|
||||
EncodingError(#[from] bincode::Error),
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn cache_file(name: &str) -> PathBuf {
|
||||
CONFIG_PATH.join(format!("{name}.cache"))
|
||||
}
|
||||
|
||||
/// Read Entries from Cache (if Valid and Available)
|
||||
pub fn read_cache(name: &str, cfg: &PluginConfig) -> Result<Vec<Entry>, CacheError> {
|
||||
// confirm cache exists
|
||||
let path = cache_file(name);
|
||||
if !path.exists() {
|
||||
return Err(CacheError::NotAvailable);
|
||||
}
|
||||
// get file modified date
|
||||
let meta = path.metadata()?;
|
||||
let modified = meta.modified()?;
|
||||
// confirm cache is not expired
|
||||
match cfg.cache {
|
||||
CacheSetting::NoCache => return Err(CacheError::InvalidCache),
|
||||
CacheSetting::Never => {}
|
||||
CacheSetting::OnLogin => {
|
||||
if let Ok(record) = lastlog::search_self() {
|
||||
if let Some(last) = record.last_login.into() {
|
||||
if modified <= last {
|
||||
return Err(CacheError::CacheExpired);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
CacheSetting::AfterSeconds(secs) => {
|
||||
let now = SystemTime::now();
|
||||
let duration = Duration::from_secs(secs as u64);
|
||||
let diff = now
|
||||
.duration_since(modified)
|
||||
.unwrap_or_else(|_| Duration::from_secs(0));
|
||||
if diff >= duration {
|
||||
return Err(CacheError::CacheExpired);
|
||||
}
|
||||
}
|
||||
}
|
||||
// attempt to read content
|
||||
let data = fs::read(path)?;
|
||||
let results: Vec<Entry> = bincode::deserialize(&data)?;
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Write Results to Cache (if Allowed)
|
||||
pub fn write_cache(name: &str, cfg: &PluginConfig, entries: &Vec<Entry>) -> Result<(), CacheError> {
|
||||
// write cache if allowed
|
||||
match cfg.cache {
|
||||
CacheSetting::NoCache => {}
|
||||
_ => {
|
||||
let path = cache_file(name);
|
||||
let data = bincode::serialize(entries)?;
|
||||
let mut f = fs::File::create(path)?;
|
||||
f.write_all(&data)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
216
rmenu/src/config.rs
Normal file
216
rmenu/src/config.rs
Normal file
|
@ -0,0 +1,216 @@
|
|||
//! RMENU Configuration Implementations
|
||||
use heck::AsPascalCase;
|
||||
use keyboard_types::{Code, Modifiers};
|
||||
use serde::{de::Error, Deserialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::str::FromStr;
|
||||
|
||||
use dioxus_desktop::tao::dpi::{LogicalPosition, LogicalSize};
|
||||
|
||||
// parse supported modifiers from string
|
||||
fn mod_from_str(s: &str) -> Option<Modifiers> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"alt" => Some(Modifiers::ALT),
|
||||
"ctrl" => Some(Modifiers::CONTROL),
|
||||
"shift" => Some(Modifiers::SHIFT),
|
||||
"super" => Some(Modifiers::SUPER),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Single GUI Keybind for Configuration
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Keybind {
|
||||
pub mods: Modifiers,
|
||||
pub key: Code,
|
||||
}
|
||||
|
||||
impl Keybind {
|
||||
fn new(key: Code) -> Self {
|
||||
Self {
|
||||
mods: Modifiers::empty(),
|
||||
key,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Keybind {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
// parse modifiers/keys from string
|
||||
let mut mods = vec![];
|
||||
let mut keys = vec![];
|
||||
for item in s.split("+") {
|
||||
let camel = format!("{}", AsPascalCase(item));
|
||||
match Code::from_str(&camel) {
|
||||
Ok(key) => keys.push(key),
|
||||
Err(_) => match mod_from_str(item) {
|
||||
Some(keymod) => mods.push(keymod),
|
||||
None => return Err(format!("invalid key/modifier: {item}")),
|
||||
},
|
||||
}
|
||||
}
|
||||
// generate final keybind
|
||||
let kmod = mods.into_iter().fold(Modifiers::empty(), |m1, m2| m1 | m2);
|
||||
match keys.len() {
|
||||
0 => Err(format!("no keys specified")),
|
||||
1 => Ok(Keybind {
|
||||
mods: kmod,
|
||||
key: keys.pop().unwrap(),
|
||||
}),
|
||||
_ => Err(format!("too many keys: {keys:?}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Keybind {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let s: &str = Deserialize::deserialize(deserializer)?;
|
||||
Keybind::from_str(s).map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// Global GUI Keybind Settings Options
|
||||
#[derive(Debug, PartialEq, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct KeyConfig {
|
||||
pub exec: Vec<Keybind>,
|
||||
pub exit: Vec<Keybind>,
|
||||
pub move_up: Vec<Keybind>,
|
||||
pub move_down: Vec<Keybind>,
|
||||
pub open_menu: Vec<Keybind>,
|
||||
pub close_menu: Vec<Keybind>,
|
||||
}
|
||||
|
||||
impl Default for KeyConfig {
|
||||
fn default() -> Self {
|
||||
return Self {
|
||||
exec: vec![Keybind::new(Code::Enter)],
|
||||
exit: vec![Keybind::new(Code::Escape)],
|
||||
move_up: vec![Keybind::new(Code::ArrowUp)],
|
||||
move_down: vec![Keybind::new(Code::ArrowDown)],
|
||||
open_menu: vec![],
|
||||
close_menu: vec![],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// GUI Desktop Window Configuration Settings
|
||||
#[derive(Debug, PartialEq, Deserialize)]
|
||||
pub struct WindowConfig {
|
||||
pub title: String,
|
||||
pub size: LogicalSize<f64>,
|
||||
pub position: LogicalPosition<f64>,
|
||||
pub focus: bool,
|
||||
pub decorate: bool,
|
||||
pub transparent: bool,
|
||||
pub always_top: bool,
|
||||
pub dark_mode: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for WindowConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
title: "RMenu - App Launcher".to_owned(),
|
||||
// size: LogicalSize {
|
||||
// width: 700.0,
|
||||
// height: 400.0,
|
||||
// },
|
||||
size: LogicalSize {
|
||||
width: 1000.0,
|
||||
height: 400.0,
|
||||
},
|
||||
position: LogicalPosition { x: 100.0, y: 100.0 },
|
||||
focus: true,
|
||||
decorate: false,
|
||||
transparent: false,
|
||||
always_top: true,
|
||||
dark_mode: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache Settings for Configured RMenu Plugins
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum CacheSetting {
|
||||
NoCache,
|
||||
Never,
|
||||
OnLogin,
|
||||
AfterSeconds(usize),
|
||||
}
|
||||
|
||||
impl FromStr for CacheSetting {
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"never" => Ok(Self::Never),
|
||||
"false" | "disable" | "disabled" => Ok(Self::NoCache),
|
||||
"true" | "login" | "onlogin" => Ok(Self::OnLogin),
|
||||
_ => {
|
||||
let secs: usize = s
|
||||
.parse()
|
||||
.map_err(|_| format!("Invalid Cache Setting: {s:?}"))?;
|
||||
Ok(Self::AfterSeconds(secs))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for CacheSetting {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let s: &str = Deserialize::deserialize(deserializer)?;
|
||||
CacheSetting::from_str(s).map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CacheSetting {
|
||||
fn default() -> Self {
|
||||
Self::NoCache
|
||||
}
|
||||
}
|
||||
|
||||
/// RMenu Data-Source Plugin Configuration
|
||||
#[derive(Debug, PartialEq, Deserialize)]
|
||||
pub struct PluginConfig {
|
||||
pub exec: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub cache: CacheSetting,
|
||||
}
|
||||
|
||||
/// Global RMenu Complete Configuration
|
||||
#[derive(Debug, PartialEq, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub page_size: usize,
|
||||
pub page_load: f64,
|
||||
pub use_icons: bool,
|
||||
pub search_regex: bool,
|
||||
pub ignore_case: bool,
|
||||
pub plugins: BTreeMap<String, PluginConfig>,
|
||||
pub keybinds: KeyConfig,
|
||||
pub window: WindowConfig,
|
||||
pub terminal: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
page_size: 50,
|
||||
page_load: 0.8,
|
||||
use_icons: true,
|
||||
search_regex: false,
|
||||
ignore_case: true,
|
||||
plugins: Default::default(),
|
||||
keybinds: Default::default(),
|
||||
window: Default::default(),
|
||||
terminal: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
57
rmenu/src/exec.rs
Normal file
57
rmenu/src/exec.rs
Normal file
|
@ -0,0 +1,57 @@
|
|||
//! Execution Implementation for Entry Actions
|
||||
use std::process::Command;
|
||||
use std::{collections::HashMap, os::unix::process::CommandExt};
|
||||
|
||||
use rmenu_plugin::{Action, Method};
|
||||
use shell_words::split;
|
||||
use strfmt::strfmt;
|
||||
use which::which;
|
||||
|
||||
/// Find Best Terminal To Execute
|
||||
fn find_terminal() -> String {
|
||||
vec![
|
||||
("alacritty", "-e {cmd}"),
|
||||
("kitty", "{cmd}"),
|
||||
("gnome-terminal", "-x {cmd}"),
|
||||
("foot", "-e {cmd}"),
|
||||
("xterm", "-C {cmd}"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(t, v)| (which(t), v))
|
||||
.filter(|(c, _)| c.is_ok())
|
||||
.map(|(c, v)| (c.unwrap(), v))
|
||||
.map(|(p, v)| {
|
||||
(
|
||||
p.to_str()
|
||||
.expect("Failed to Parse Terminal Path")
|
||||
.to_owned(),
|
||||
v,
|
||||
)
|
||||
})
|
||||
.find_map(|(p, v)| Some(format!("{p} {v}")))
|
||||
.expect("Failed to Find Terminal Executable!")
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn parse_args(exec: &str) -> Vec<String> {
|
||||
match split(exec) {
|
||||
Ok(args) => args,
|
||||
Err(err) => panic!("{:?} invalid command {err}", exec),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn execute(action: &Action, term: Option<String>) {
|
||||
log::info!("executing: {:?} {:?}", action.name, action.exec);
|
||||
let args = match &action.exec {
|
||||
Method::Run(exec) => parse_args(&exec),
|
||||
Method::Terminal(exec) => {
|
||||
let mut args = HashMap::new();
|
||||
let terminal = term.unwrap_or_else(find_terminal);
|
||||
args.insert("cmd".to_string(), exec.to_owned());
|
||||
let command = strfmt(&terminal, &args).expect("Failed String Format");
|
||||
parse_args(&command)
|
||||
}
|
||||
};
|
||||
let err = Command::new(&args[0]).args(&args[1..]).exec();
|
||||
panic!("Command Error: {err:?}");
|
||||
}
|
233
rmenu/src/gui.rs
Normal file
233
rmenu/src/gui.rs
Normal file
|
@ -0,0 +1,233 @@
|
|||
//! RMENU GUI Implementation using Dioxus
|
||||
#![allow(non_snake_case)]
|
||||
use dioxus::prelude::*;
|
||||
use keyboard_types::{Code, Modifiers};
|
||||
use rmenu_plugin::Entry;
|
||||
|
||||
use crate::config::Keybind;
|
||||
use crate::state::{AppState, KeyEvent};
|
||||
use crate::{App, DEFAULT_CSS_CONTENT};
|
||||
|
||||
/// spawn and run the app on the configured platform
|
||||
pub fn run(app: App) {
|
||||
// customize window
|
||||
let theme = match app.config.window.dark_mode {
|
||||
Some(dark) => match dark {
|
||||
true => Some(dioxus_desktop::tao::window::Theme::Dark),
|
||||
false => Some(dioxus_desktop::tao::window::Theme::Light),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let builder = dioxus_desktop::WindowBuilder::new()
|
||||
.with_title(app.config.window.title.clone())
|
||||
.with_inner_size(app.config.window.size)
|
||||
.with_position(app.config.window.position)
|
||||
.with_focused(app.config.window.focus)
|
||||
.with_decorations(app.config.window.decorate)
|
||||
.with_transparent(app.config.window.transparent)
|
||||
.with_always_on_top(app.config.window.always_top)
|
||||
.with_theme(theme);
|
||||
let config = dioxus_desktop::Config::new().with_window(builder);
|
||||
dioxus_desktop::launch_with_props(App, app, config);
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Props)]
|
||||
struct GEntry<'a> {
|
||||
pos: usize,
|
||||
subpos: usize,
|
||||
index: usize,
|
||||
entry: &'a Entry,
|
||||
state: AppState<'a>,
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn render_comment(comment: Option<&String>) -> String {
|
||||
return comment.map(|s| s.as_str()).unwrap_or("").to_string();
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn render_image<'a, T>(cx: Scope<'a, T>, image: Option<&String>) -> Element<'a> {
|
||||
if let Some(img) = image {
|
||||
if img.ends_with(".svg") {
|
||||
if let Some(content) = crate::image::convert_svg(img.to_owned()) {
|
||||
return cx.render(rsx! { img { class: "image", src: "{content}" } });
|
||||
}
|
||||
}
|
||||
return cx.render(rsx! { img { class: "image", src: "{img}" } });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// render a single result entry w/ the given information
|
||||
fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> {
|
||||
// build css classes for result and actions (if nessesary)
|
||||
let main_select = cx.props.index == cx.props.pos;
|
||||
let action_select = main_select && cx.props.subpos > 0;
|
||||
let action_classes = match action_select {
|
||||
true => "active",
|
||||
false => "",
|
||||
};
|
||||
let multi_classes = match cx.props.entry.actions.len() > 1 {
|
||||
true => "submenu",
|
||||
false => "",
|
||||
};
|
||||
let result_classes = match main_select && !action_select {
|
||||
true => "selected",
|
||||
false => "",
|
||||
};
|
||||
// build sub-actions if present
|
||||
let actions = cx
|
||||
.props
|
||||
.entry
|
||||
.actions
|
||||
.iter()
|
||||
.skip(1)
|
||||
.enumerate()
|
||||
.map(|(idx, action)| {
|
||||
let act_class = match action_select && idx + 1 == cx.props.subpos {
|
||||
true => "selected",
|
||||
false => "",
|
||||
};
|
||||
cx.render(rsx! {
|
||||
div {
|
||||
class: "action {act_class}",
|
||||
onclick: move |_| cx.props.state.set_position(cx.props.index, idx + 1),
|
||||
ondblclick: |_| cx.props.state.set_event(KeyEvent::Exec),
|
||||
div {
|
||||
class: "action-name",
|
||||
"{action.name}"
|
||||
}
|
||||
div {
|
||||
class: "action-comment",
|
||||
render_comment(action.comment.as_ref())
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
cx.render(rsx! {
|
||||
div {
|
||||
class: "result-entry",
|
||||
div {
|
||||
id: "result-{cx.props.index}",
|
||||
class: "result {result_classes} {multi_classes}",
|
||||
// onmouseenter: |_| cx.props.state.set_position(cx.props.index, 0),
|
||||
onclick: |_| cx.props.state.set_position(cx.props.index, 0),
|
||||
ondblclick: |_| cx.props.state.set_event(KeyEvent::Exec),
|
||||
if cx.props.state.config().use_icons {
|
||||
cx.render(rsx! {
|
||||
div {
|
||||
class: "icon",
|
||||
render_image(cx, cx.props.entry.icon.as_ref())
|
||||
}
|
||||
})
|
||||
}
|
||||
div {
|
||||
class: "name",
|
||||
"{cx.props.entry.name}"
|
||||
}
|
||||
div {
|
||||
class: "comment",
|
||||
render_comment(cx.props.entry.comment.as_ref())
|
||||
}
|
||||
}
|
||||
div {
|
||||
id: "result-{cx.props.index}-actions",
|
||||
class: "actions {action_classes}",
|
||||
actions.into_iter()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn focus<T>(cx: Scope<T>) {
|
||||
let eval = dioxus_desktop::use_eval(cx);
|
||||
let js = "document.getElementById(`search`).focus()";
|
||||
eval(js.to_owned());
|
||||
}
|
||||
|
||||
/// check if the current inputs match any of the given keybindings
|
||||
#[inline]
|
||||
fn matches(bind: &Vec<Keybind>, mods: &Modifiers, key: &Code) -> bool {
|
||||
bind.iter().any(|b| mods.contains(b.mods) && &b.key == key)
|
||||
}
|
||||
|
||||
/// main application function/loop
|
||||
fn App<'a>(cx: Scope<App>) -> Element {
|
||||
let mut state = AppState::new(cx, cx.props);
|
||||
|
||||
// always ensure focus
|
||||
focus(cx);
|
||||
|
||||
// log current position
|
||||
let search = state.search();
|
||||
let (pos, subpos) = state.position();
|
||||
log::debug!("search: {search:?}, pos: {pos}, {subpos}");
|
||||
|
||||
// generate state tracker instances
|
||||
let results = state.results(&cx.props.entries);
|
||||
let s_updater = state.partial_copy();
|
||||
let k_updater = state.partial_copy();
|
||||
|
||||
//TODO: consider implementing some sort of
|
||||
// action channel reference to pass to keboard events
|
||||
|
||||
// build keyboard actions event handler
|
||||
let keybinds = &cx.props.config.keybinds;
|
||||
let keyboard_controls = move |e: KeyboardEvent| {
|
||||
let code = e.code();
|
||||
let mods = e.modifiers();
|
||||
if matches(&keybinds.exec, &mods, &code) {
|
||||
k_updater.set_event(KeyEvent::Exec);
|
||||
} else if matches(&keybinds.exit, &mods, &code) {
|
||||
k_updater.set_event(KeyEvent::Exit);
|
||||
} else if matches(&keybinds.move_up, &mods, &code) {
|
||||
k_updater.set_event(KeyEvent::ShiftUp);
|
||||
} else if matches(&keybinds.move_down, &mods, &code) {
|
||||
k_updater.set_event(KeyEvent::ShiftDown);
|
||||
} else if matches(&keybinds.open_menu, &mods, &code) {
|
||||
k_updater.set_event(KeyEvent::OpenMenu);
|
||||
} else if matches(&keybinds.close_menu, &mods, &code) {
|
||||
k_updater.set_event(KeyEvent::CloseMenu);
|
||||
}
|
||||
};
|
||||
|
||||
// handle keyboard events
|
||||
state.handle_events(cx);
|
||||
|
||||
// render results objects
|
||||
let rendered_results = results.iter().enumerate().map(|(i, e)| {
|
||||
let state = state.partial_copy();
|
||||
cx.render(rsx! {
|
||||
TableEntry{
|
||||
pos: pos,
|
||||
subpos: subpos,
|
||||
index: i,
|
||||
entry: e,
|
||||
state: state,
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
cx.render(rsx! {
|
||||
style { DEFAULT_CSS_CONTENT }
|
||||
style { "{cx.props.css}" }
|
||||
div {
|
||||
// onclick: |_| focus(cx),
|
||||
onkeydown: keyboard_controls,
|
||||
div {
|
||||
class: "navbar",
|
||||
input {
|
||||
id: "search",
|
||||
value: "{search}",
|
||||
oninput: move |evt| s_updater.set_search(cx, evt.value.clone()),
|
||||
}
|
||||
}
|
||||
div {
|
||||
id: "results",
|
||||
class: "results",
|
||||
rendered_results.into_iter()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
77
rmenu/src/image.rs
Normal file
77
rmenu/src/image.rs
Normal file
|
@ -0,0 +1,77 @@
|
|||
//! GUI Image Processing
|
||||
use std::fs::{create_dir_all, read_to_string, write};
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use cached::proc_macro::cached;
|
||||
use once_cell::sync::Lazy;
|
||||
use resvg::usvg::TreeParsing;
|
||||
use thiserror::Error;
|
||||
|
||||
static TEMP_EXISTS: Lazy<Mutex<Vec<bool>>> = Lazy::new(|| Mutex::new(vec![]));
|
||||
static TEMP_DIR: Lazy<PathBuf> = Lazy::new(|| PathBuf::from("/tmp/rmenu"));
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
enum SvgError {
|
||||
#[error("Invalid SVG Filepath")]
|
||||
InvalidFile(#[from] std::io::Error),
|
||||
#[error("Invalid Document")]
|
||||
InvalidTree(#[from] resvg::usvg::Error),
|
||||
#[error("Failed to Alloc PixBuf")]
|
||||
NoPixBuf(u32, u32, u32),
|
||||
#[error("Failed to Convert SVG to PNG")]
|
||||
PngError(#[from] png::EncodingError),
|
||||
}
|
||||
|
||||
/// Make Temporary Directory for Generated PNGs
|
||||
fn make_temp() -> Result<(), io::Error> {
|
||||
let mut temp = TEMP_EXISTS.lock().expect("Failed to Access Global Mutex");
|
||||
if temp.len() == 0 {
|
||||
create_dir_all(TEMP_DIR.to_owned())?;
|
||||
temp.push(true);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert SVG to PNG Image
|
||||
fn svg_to_png(path: &str, dest: &PathBuf, pixels: u32) -> Result<(), SvgError> {
|
||||
// read and convert to resvg document tree
|
||||
let xml = read_to_string(path)?;
|
||||
let opt = resvg::usvg::Options::default();
|
||||
let tree = resvg::usvg::Tree::from_str(&xml, &opt)?;
|
||||
let rtree = resvg::Tree::from_usvg(&tree);
|
||||
// generate pixel-buffer and scale according to size preference
|
||||
let size = rtree.size.to_int_size();
|
||||
let scale = pixels as f32 / size.width() as f32;
|
||||
let width = (size.width() as f32 * scale) as u32;
|
||||
let height = (size.height() as f32 * scale) as u32;
|
||||
let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height)
|
||||
.ok_or_else(|| SvgError::NoPixBuf(width, height, pixels))?;
|
||||
let form = resvg::tiny_skia::Transform::from_scale(scale, scale);
|
||||
// render as png to memory
|
||||
rtree.render(form, &mut pixmap.as_mut());
|
||||
let png = pixmap.encode_png()?;
|
||||
// base64 encode png
|
||||
Ok(write(dest, png)?)
|
||||
}
|
||||
|
||||
#[cached]
|
||||
pub fn convert_svg(path: String) -> Option<String> {
|
||||
// ensure temporary directory exists
|
||||
let _ = make_temp();
|
||||
// convert path to new temporary png filepath
|
||||
let (_, fname) = path.rsplit_once('/')?;
|
||||
let (name, _) = fname.rsplit_once(".")?;
|
||||
let name = format!("{name}.png");
|
||||
let new_path = TEMP_DIR.join(name);
|
||||
// generate png if it doesnt already exist
|
||||
if !new_path.exists() {
|
||||
log::debug!("generating png {new_path:?}");
|
||||
match svg_to_png(&path, &new_path, 64) {
|
||||
Err(err) => log::error!("failed svg->png: {err:?}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(new_path.to_str()?.to_string())
|
||||
}
|
255
rmenu/src/main.rs
Normal file
255
rmenu/src/main.rs
Normal file
|
@ -0,0 +1,255 @@
|
|||
use std::collections::VecDeque;
|
||||
use std::fmt::Display;
|
||||
use std::fs::{read_to_string, File};
|
||||
use std::io::{self, prelude::*, BufReader};
|
||||
use std::process::{Command, ExitStatus, Stdio};
|
||||
use std::str::FromStr;
|
||||
|
||||
mod cache;
|
||||
mod config;
|
||||
mod exec;
|
||||
mod gui;
|
||||
mod image;
|
||||
mod search;
|
||||
mod state;
|
||||
|
||||
use clap::Parser;
|
||||
use rmenu_plugin::Entry;
|
||||
use thiserror::Error;
|
||||
|
||||
static CONFIG_DIR: &'static str = "~/.config/rmenu/";
|
||||
static DEFAULT_CSS: &'static str = "~/.config/rmenu/style.css";
|
||||
static DEFAULT_CONFIG: &'static str = "~/.config/rmenu/config.yaml";
|
||||
static DEFAULT_CSS_CONTENT: &'static str = include_str!("../public/default.css");
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Format {
|
||||
Json,
|
||||
MsgPack,
|
||||
}
|
||||
|
||||
impl Display for Format {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&format!("{self:?}").to_lowercase())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Format {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"json" => Ok(Format::Json),
|
||||
"msgpack" => Ok(Format::MsgPack),
|
||||
_ => Err("No Such Format".to_owned()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum RMenuError {
|
||||
#[error("$HOME not found")]
|
||||
HomeNotFound,
|
||||
#[error("Invalid Config")]
|
||||
InvalidConfig(#[from] serde_yaml::Error),
|
||||
#[error("File Error")]
|
||||
FileError(#[from] io::Error),
|
||||
#[error("No Such Plugin")]
|
||||
NoSuchPlugin(String),
|
||||
#[error("Invalid Plugin Specified")]
|
||||
InvalidPlugin(String),
|
||||
#[error("Command Runtime Exception")]
|
||||
CommandError(Vec<String>, Option<ExitStatus>),
|
||||
#[error("Invalid JSON Entry Object")]
|
||||
InvalidJson(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
/// Application State for GUI
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct App {
|
||||
css: String,
|
||||
name: String,
|
||||
entries: Vec<Entry>,
|
||||
config: config::Config,
|
||||
}
|
||||
|
||||
/// Rofi Clone (Built with Rust)
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
#[command(propagate_version = true)]
|
||||
pub struct Args {
|
||||
#[arg(short, long, default_value_t=String::from("-"))]
|
||||
input: String,
|
||||
#[arg(short, long, default_value_t=Format::Json)]
|
||||
format: Format,
|
||||
#[arg(short, long)]
|
||||
run: Vec<String>,
|
||||
#[arg(long)]
|
||||
regex: Option<bool>,
|
||||
#[arg(short, long)]
|
||||
config: Option<String>,
|
||||
#[arg(long)]
|
||||
css: Option<String>,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
/// Load Config based on CLI Settings
|
||||
fn config(&self) -> Result<config::Config, RMenuError> {
|
||||
let path = match &self.config {
|
||||
Some(path) => path.to_owned(),
|
||||
None => shellexpand::tilde(DEFAULT_CONFIG).to_string(),
|
||||
};
|
||||
log::debug!("loading config from {path:?}");
|
||||
let cfg = match read_to_string(path) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(err) => {
|
||||
log::error!("failed to load config: {err:?}");
|
||||
return Ok(config::Config::default());
|
||||
}
|
||||
};
|
||||
serde_yaml::from_str(&cfg).map_err(|e| RMenuError::InvalidConfig(e))
|
||||
}
|
||||
|
||||
/// Read single entry from incoming line object
|
||||
fn readentry(&self, cfg: &config::Config, line: &str) -> Result<Entry, RMenuError> {
|
||||
let mut entry = match self.format {
|
||||
Format::Json => serde_json::from_str::<Entry>(line)?,
|
||||
Format::MsgPack => todo!(),
|
||||
};
|
||||
if !cfg.use_icons {
|
||||
entry.icon = None;
|
||||
}
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Load Entries From Input (Stdin by Default)
|
||||
fn load_default(&self, cfg: &config::Config) -> Result<Vec<Entry>, RMenuError> {
|
||||
let fpath = match self.input.as_str() {
|
||||
"-" => "/dev/stdin",
|
||||
_ => &self.input,
|
||||
};
|
||||
log::info!("reading from {fpath:?}");
|
||||
let file = File::open(fpath).map_err(|e| RMenuError::FileError(e))?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut entries = vec![];
|
||||
for line in reader.lines() {
|
||||
let entry = self.readentry(cfg, &line?)?;
|
||||
entries.push(entry);
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Load Entries From Specified Sources
|
||||
fn load_sources(&self, cfg: &config::Config) -> Result<Vec<Entry>, RMenuError> {
|
||||
log::debug!("config: {cfg:?}");
|
||||
// execute commands to get a list of entries
|
||||
let mut entries = vec![];
|
||||
for name in self.run.iter() {
|
||||
log::debug!("running plugin: {name}");
|
||||
// retrieve plugin command arguments
|
||||
let plugin = cfg
|
||||
.plugins
|
||||
.get(name)
|
||||
.ok_or_else(|| RMenuError::NoSuchPlugin(name.to_owned()))?;
|
||||
// attempt to read cache rather than run command
|
||||
match cache::read_cache(name, plugin) {
|
||||
Ok(cached) => {
|
||||
entries.extend(cached);
|
||||
continue;
|
||||
}
|
||||
Err(err) => log::error!("cache read error: {err:?}"),
|
||||
}
|
||||
// build command
|
||||
let mut cmdargs: VecDeque<String> = plugin
|
||||
.exec
|
||||
.iter()
|
||||
.map(|arg| shellexpand::tilde(arg).to_string())
|
||||
.collect();
|
||||
let main = cmdargs
|
||||
.pop_front()
|
||||
.ok_or_else(|| RMenuError::InvalidPlugin(name.to_owned()))?;
|
||||
let mut cmd = Command::new(main);
|
||||
for arg in cmdargs.iter() {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
// spawn command
|
||||
let mut proc = cmd.stdout(Stdio::piped()).spawn()?;
|
||||
let stdout = proc
|
||||
.stdout
|
||||
.as_mut()
|
||||
.ok_or_else(|| RMenuError::CommandError(plugin.exec.clone().into(), None))?;
|
||||
let reader = BufReader::new(stdout);
|
||||
// read output line by line and parse content
|
||||
for line in reader.lines() {
|
||||
let entry = self.readentry(cfg, &line?)?;
|
||||
entries.push(entry);
|
||||
}
|
||||
// check status of command on exit
|
||||
let status = proc.wait()?;
|
||||
if !status.success() {
|
||||
return Err(RMenuError::CommandError(
|
||||
plugin.exec.clone().into(),
|
||||
Some(status.clone()),
|
||||
));
|
||||
}
|
||||
// write cache for entries collected
|
||||
match cache::write_cache(name, plugin, &entries) {
|
||||
Ok(_) => {}
|
||||
Err(err) => log::error!("cache write error: {err:?}"),
|
||||
};
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Load Application
|
||||
pub fn parse_app() -> Result<App, RMenuError> {
|
||||
let args = Self::parse();
|
||||
let mut config = args.config()?;
|
||||
// load css files from settings
|
||||
let csspath = args.css.clone().unwrap_or_else(|| DEFAULT_CSS.to_owned());
|
||||
let csspath = shellexpand::tilde(&csspath).to_string();
|
||||
let css = match read_to_string(csspath) {
|
||||
Ok(css) => css,
|
||||
Err(err) => {
|
||||
log::error!("failed to load css: {err:?}");
|
||||
"".to_owned()
|
||||
}
|
||||
};
|
||||
// load entries from configured sources
|
||||
let entries = match args.run.len() > 0 {
|
||||
true => args.load_sources(&config)?,
|
||||
false => args.load_default(&config)?,
|
||||
};
|
||||
// update configuration based on cli
|
||||
config.use_icons = config.use_icons && entries.iter().any(|e| e.icon.is_some());
|
||||
config.search_regex = args.regex.unwrap_or(config.search_regex);
|
||||
// generate app object
|
||||
return Ok(App {
|
||||
css,
|
||||
name: "rmenu".to_owned(),
|
||||
entries,
|
||||
config,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//TODO: improve search w/ modes?
|
||||
//TODO: improve looks and css
|
||||
|
||||
fn main() -> Result<(), RMenuError> {
|
||||
// enable log and set default level
|
||||
if std::env::var("RUST_LOG").is_err() {
|
||||
std::env::set_var("RUST_LOG", "info");
|
||||
}
|
||||
env_logger::init();
|
||||
// parse cli / config / application-settings
|
||||
let app = Args::parse_app()?;
|
||||
// change directory to configuration dir
|
||||
let cfgdir = shellexpand::tilde(CONFIG_DIR).to_string();
|
||||
if let Err(err) = std::env::set_current_dir(&cfgdir) {
|
||||
log::error!("failed to change directory: {err:?}");
|
||||
}
|
||||
// run gui
|
||||
gui::run(app);
|
||||
Ok(())
|
||||
}
|
52
rmenu/src/search.rs
Normal file
52
rmenu/src/search.rs
Normal file
|
@ -0,0 +1,52 @@
|
|||
//! RMENU Entry Search Function Implementaton
|
||||
use regex::RegexBuilder;
|
||||
use rmenu_plugin::Entry;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
/// Generate a new dynamic Search Function based on
|
||||
/// Configurtaion Settings and Search-String
|
||||
pub fn new_searchfn(cfg: &Config, search: &str) -> Box<dyn Fn(&Entry) -> bool> {
|
||||
// build regex search expression
|
||||
if cfg.search_regex {
|
||||
let rgx = RegexBuilder::new(search)
|
||||
.case_insensitive(cfg.ignore_case)
|
||||
.build();
|
||||
let Ok(regex) = rgx else {
|
||||
return Box::new(|_| false);
|
||||
};
|
||||
return Box::new(move |entry: &Entry| {
|
||||
if regex.is_match(&entry.name) {
|
||||
return true;
|
||||
}
|
||||
if let Some(comment) = entry.comment.as_ref() {
|
||||
return regex.is_match(&comment);
|
||||
}
|
||||
false
|
||||
});
|
||||
}
|
||||
// build case-insensitive search expression
|
||||
if cfg.ignore_case {
|
||||
let matchstr = search.to_lowercase();
|
||||
return Box::new(move |entry: &Entry| {
|
||||
if entry.name.to_lowercase().contains(&matchstr) {
|
||||
return true;
|
||||
}
|
||||
if let Some(comment) = entry.comment.as_ref() {
|
||||
return comment.to_lowercase().contains(&matchstr);
|
||||
}
|
||||
false
|
||||
});
|
||||
}
|
||||
// build standard normal string comparison function
|
||||
let matchstr = search.to_owned();
|
||||
Box::new(move |entry: &Entry| {
|
||||
if entry.name.contains(&matchstr) {
|
||||
return true;
|
||||
}
|
||||
if let Some(comment) = entry.comment.as_ref() {
|
||||
return comment.contains(&matchstr);
|
||||
}
|
||||
false
|
||||
})
|
||||
}
|
239
rmenu/src/state.rs
Normal file
239
rmenu/src/state.rs
Normal file
|
@ -0,0 +1,239 @@
|
|||
use dioxus::prelude::{use_ref, Scope, UseRef};
|
||||
use rmenu_plugin::Entry;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::exec::execute;
|
||||
use crate::search::new_searchfn;
|
||||
use crate::App;
|
||||
|
||||
#[inline]
|
||||
fn scroll<T>(cx: Scope<T>, pos: usize) {
|
||||
let eval = dioxus_desktop::use_eval(cx);
|
||||
let js = format!("document.getElementById(`result-{pos}`).scrollIntoView(false)");
|
||||
eval(js);
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
pub enum KeyEvent {
|
||||
Exec,
|
||||
Exit,
|
||||
ShiftUp,
|
||||
ShiftDown,
|
||||
OpenMenu,
|
||||
CloseMenu,
|
||||
}
|
||||
|
||||
pub struct InnerState {
|
||||
pos: usize,
|
||||
subpos: usize,
|
||||
page: usize,
|
||||
search: String,
|
||||
event: Option<KeyEvent>,
|
||||
}
|
||||
|
||||
impl InnerState {
|
||||
/// Move X Primary Results Upwards
|
||||
pub fn move_up(&mut self, x: usize) {
|
||||
self.subpos = 0;
|
||||
self.pos = std::cmp::max(self.pos, x) - x;
|
||||
}
|
||||
|
||||
/// Move X Primary Results Downwards
|
||||
pub fn move_down(&mut self, x: usize, max: usize) {
|
||||
self.subpos = 0;
|
||||
self.pos = std::cmp::min(self.pos + x, max - 1)
|
||||
}
|
||||
|
||||
/// Move Up Once With Context of SubMenu
|
||||
pub fn shift_up(&mut self) {
|
||||
if self.subpos > 0 {
|
||||
self.subpos -= 1;
|
||||
return;
|
||||
}
|
||||
self.move_up(1);
|
||||
}
|
||||
|
||||
/// Move Down Once With Context of SubMenu
|
||||
pub fn shift_down(&mut self, results: &Vec<&Entry>) {
|
||||
if let Some(result) = results.get(self.pos) {
|
||||
if self.subpos > 0 && self.subpos < result.actions.len() - 1 {
|
||||
self.subpos += 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let max = std::cmp::max(results.len(), 1);
|
||||
self.move_down(1, max);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub struct AppState<'a> {
|
||||
state: &'a UseRef<InnerState>,
|
||||
app: &'a App,
|
||||
results: Vec<&'a Entry>,
|
||||
}
|
||||
|
||||
impl<'a> AppState<'a> {
|
||||
/// Spawn new Application State Tracker
|
||||
pub fn new<T>(cx: Scope<'a, T>, app: &'a App) -> Self {
|
||||
Self {
|
||||
state: use_ref(cx, || InnerState {
|
||||
pos: 0,
|
||||
subpos: 0,
|
||||
page: 0,
|
||||
search: "".to_string(),
|
||||
event: None,
|
||||
}),
|
||||
app,
|
||||
results: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Create Partial Copy of Self (Not Including Results)
|
||||
pub fn partial_copy(&self) -> Self {
|
||||
Self {
|
||||
state: self.state,
|
||||
app: self.app,
|
||||
results: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve Configuration
|
||||
#[inline]
|
||||
pub fn config(&self) -> &Config {
|
||||
&self.app.config
|
||||
}
|
||||
|
||||
/// Retrieve Current Position State
|
||||
#[inline]
|
||||
pub fn position(&self) -> (usize, usize) {
|
||||
self.state.with(|s| (s.pos, s.subpos))
|
||||
}
|
||||
|
||||
/// Retrieve Current Search String
|
||||
#[inline]
|
||||
pub fn search(&self) -> String {
|
||||
self.state.with(|s| s.search.clone())
|
||||
}
|
||||
|
||||
/// Execute the Current Action
|
||||
pub fn execute(&self) {
|
||||
let (pos, subpos) = self.position();
|
||||
log::debug!("execute {pos} {subpos}");
|
||||
let Some(result) = self.results.get(pos) else {
|
||||
return;
|
||||
};
|
||||
log::debug!("result: {result:?}");
|
||||
let Some(action) = result.actions.get(subpos) else {
|
||||
return;
|
||||
};
|
||||
log::debug!("action: {action:?}");
|
||||
execute(action, self.app.config.terminal.clone());
|
||||
}
|
||||
|
||||
/// Set Current Key/Action for Later Evaluation
|
||||
#[inline]
|
||||
pub fn set_event(&self, event: KeyEvent) {
|
||||
self.state.with_mut(|s| s.event = Some(event));
|
||||
}
|
||||
|
||||
/// React to Previously Activated KeyEvents
|
||||
pub fn handle_events(&self, cx: Scope<'a, App>) {
|
||||
match self.state.with(|s| s.event.clone()) {
|
||||
None => {}
|
||||
Some(event) => {
|
||||
match event {
|
||||
KeyEvent::Exit => std::process::exit(0),
|
||||
KeyEvent::Exec => self.execute(),
|
||||
KeyEvent::OpenMenu => self.open_menu(),
|
||||
KeyEvent::CloseMenu => self.close_menu(),
|
||||
KeyEvent::ShiftUp => {
|
||||
self.shift_up();
|
||||
let pos = self.position().0;
|
||||
scroll(cx, if pos <= 3 { pos } else { pos + 3 })
|
||||
}
|
||||
KeyEvent::ShiftDown => {
|
||||
self.shift_down();
|
||||
scroll(cx, self.position().0 + 3)
|
||||
}
|
||||
};
|
||||
self.state.with_mut(|s| s.event = None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate and return Results PTR
|
||||
pub fn results(&mut self, entries: &'a Vec<Entry>) -> Vec<&'a Entry> {
|
||||
let ratio = self.app.config.page_load;
|
||||
let page_size = self.app.config.page_size;
|
||||
let (pos, page, search) = self.state.with(|s| (s.pos, s.page, s.search.clone()));
|
||||
// determine current page based on position and configuration
|
||||
let next = (pos % page_size) as f64 / page_size as f64 > ratio;
|
||||
let pos_page = (pos + 1) / page_size + 1 + next as usize;
|
||||
let new_page = std::cmp::max(pos_page, page);
|
||||
let index = page_size * new_page;
|
||||
// update page counter if higher than before
|
||||
if new_page > page {
|
||||
self.state.with_mut(|s| s.page = new_page);
|
||||
}
|
||||
// render results and stop at page-limit
|
||||
let sfn = new_searchfn(&self.app.config, &search);
|
||||
self.results = entries.iter().filter(|e| sfn(e)).take(index).collect();
|
||||
self.results.clone()
|
||||
}
|
||||
|
||||
/// Update Search and Reset Position
|
||||
pub fn set_search(&self, cx: Scope<'_, App>, search: String) {
|
||||
self.state.with_mut(|s| {
|
||||
s.pos = 0;
|
||||
s.subpos = 0;
|
||||
s.search = search;
|
||||
});
|
||||
scroll(cx, 0);
|
||||
}
|
||||
|
||||
/// Manually Set Position/SubPosition (with Click)
|
||||
pub fn set_position(&self, pos: usize, subpos: usize) {
|
||||
self.state.with_mut(|s| {
|
||||
s.pos = pos;
|
||||
s.subpos = subpos;
|
||||
})
|
||||
}
|
||||
|
||||
/// Automatically Increase PageCount When Nearing Bottom
|
||||
// pub fn scroll_down(&self) {
|
||||
// self.state.with_mut(|s| {
|
||||
// if self.app.config.page_size * s.page < self.app.entries.len() {
|
||||
// s.page += 1;
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
|
||||
/// Move Position To SubMenu if it Exists
|
||||
pub fn open_menu(&self) {
|
||||
let pos = self.state.with(|s| s.pos);
|
||||
if let Some(result) = self.results.get(pos) {
|
||||
if result.actions.len() > 1 {
|
||||
self.state.with_mut(|s| s.subpos += 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset and Close SubMenu Position
|
||||
#[inline]
|
||||
pub fn close_menu(&self) {
|
||||
self.state.with_mut(|s| s.subpos = 0);
|
||||
}
|
||||
|
||||
/// Move Up Once With Context of SubMenu
|
||||
#[inline]
|
||||
pub fn shift_up(&self) {
|
||||
self.state.with_mut(|s| s.shift_up());
|
||||
}
|
||||
|
||||
/// Move Down Once With Context of SubMenu
|
||||
#[inline]
|
||||
pub fn shift_down(&self) {
|
||||
self.state.with_mut(|s| s.shift_down(&self.results))
|
||||
}
|
||||
}
|
25
themes/dark.css
Normal file
25
themes/dark.css
Normal file
|
@ -0,0 +1,25 @@
|
|||
* {
|
||||
font-family: monospace;
|
||||
color: white;
|
||||
}
|
||||
|
||||
html {
|
||||
background-color: #383C4A;
|
||||
}
|
||||
|
||||
#search {
|
||||
border: none;
|
||||
border-radius: 0px;
|
||||
border-bottom: 3px solid black;
|
||||
background-color: #383C4A;
|
||||
margin-bottom: 5px;
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
.result-entry:nth-child(odd){
|
||||
background-color: #404552;
|
||||
}
|
||||
|
||||
.selected {
|
||||
background-color: #5291e2;
|
||||
}
|
22
themes/nord.css
Normal file
22
themes/nord.css
Normal file
|
@ -0,0 +1,22 @@
|
|||
* {
|
||||
font-family: "Hack", monospace;
|
||||
color: white;
|
||||
}
|
||||
|
||||
html {
|
||||
background-color: #3B4252;
|
||||
}
|
||||
|
||||
#search {
|
||||
border: none;
|
||||
border-radius: 0px;
|
||||
background-color: #3B4252;
|
||||
}
|
||||
|
||||
.result-entry:nth-child(odd){
|
||||
background-color: #404552;
|
||||
}
|
||||
|
||||
.selected {
|
||||
background-color: #4C566A;
|
||||
}
|
27
themes/solarized.css
Normal file
27
themes/solarized.css
Normal file
|
@ -0,0 +1,27 @@
|
|||
* {
|
||||
color: #839496;
|
||||
font-family: monospace;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0px;
|
||||
border: 1px solid #928374;
|
||||
background-color: #002b36;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
#search {
|
||||
border: none;
|
||||
color: #839496;
|
||||
background-color: #073642;
|
||||
padding: 5px;
|
||||
width: -webkit-fill-available;
|
||||
}
|
||||
|
||||
.selected {
|
||||
background-color: #073642;
|
||||
}
|
Loading…
Add table
Reference in a new issue