feat: basic reimpl complete for dioxus 5

This commit is contained in:
imgurbot12 2024-07-06 17:15:33 -07:00
parent cf84616bac
commit 64103fcd1b
9 changed files with 964 additions and 33 deletions

View File

@ -6,18 +6,26 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
cached = "0.52.0"
clap = { version = "4.5.4", features = ["derive", "env"] }
dioxus = { version = "0.5.1", features = ["desktop"] }
dioxus-desktop = "0.5.1"
env_logger = "0.11.3"
heck = "0.5.0"
lastlog = { git = "https://github.com/imgurbot12/lastlog", version = "0.3.0", features = ["libc"] }
log = "0.4.21"
once_cell = "1.19.0"
png = "0.17.13"
regex = { version = "1.10.4" }
resvg = "0.41.0"
resvg = { version = "0.41.0", default-features = false, features = ["png", "raster-images", "default"] }
rmenu-plugin = { version = "0.0.2", path = "../rmenu-plugin" }
serde = { version = "1.0.203", features = ["derive"] }
serde_json = "1.0.117"
serde_yaml = "0.9.34"
shell-words = "1.1.0"
shellexpand = "3.1.0"
strfmt = "0.2.4"
thiserror = "1.0.61"
tokio = { version = "*", default-features = false, features = ["time"] }
which = "6.0.1"
xdg = "2.5.2"

87
rmenu/src/cache.rs Normal file
View File

@ -0,0 +1,87 @@
///! RMenu Plugin Result Cache
use std::fs;
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
use rmenu_plugin::Entry;
use thiserror::Error;
use crate::config::{CacheSetting, PluginConfig};
use crate::XDG_PREFIX;
#[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] serde_json::Error),
}
#[inline]
fn cache_file(name: &str) -> PathBuf {
xdg::BaseDirectories::with_prefix(XDG_PREFIX)
.expect("Failed to read xdg base dirs")
.place_cache_file(format!("{name}.cache"))
.expect("Failed to write xdg cache dirs")
}
/// 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> = serde_json::from_slice(&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 => {}
_ => {
log::debug!("{name:?} writing {} entries", entries.len());
let path = cache_file(name);
let f = fs::File::create(path)?;
serde_json::to_writer(f, entries)?;
}
}
Ok(())
}

444
rmenu/src/cli.rs Normal file
View File

@ -0,0 +1,444 @@
///! CLI Argument Based Configuration and Application Setup
use std::fs::File;
use std::io::{BufRead, BufReader, Read};
use std::path::PathBuf;
use std::process::{Command, ExitStatus, Stdio};
use std::str::FromStr;
use std::{fmt::Display, fs::read_to_string};
use clap::Parser;
use rmenu_plugin::{Entry, Message};
use thiserror::Error;
use crate::config::{cfg_replace, Config, Keybind};
use crate::{DEFAULT_CONFIG, DEFAULT_THEME, ENV_ACTIVE_PLUGINS, XDG_PREFIX};
/// Allowed Formats for Entry Ingestion
#[derive(Debug, Clone)]
pub enum Format {
Json,
DMenu,
}
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) -> std::result::Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"json" => Ok(Format::Json),
"dmenu" => Ok(Format::DMenu),
_ => Err("No Such Format".to_owned()),
}
}
}
/// Dynamic Applicaiton-Menu Tool (Built with Rust)
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
#[command(propagate_version = true)]
pub struct Args {
// simple configuration arguments
/// Filepath for entry input
#[arg(short, long)]
input: Option<String>,
/// Format to accept entries
#[arg(short, long, default_value_t=Format::Json)]
format: Format,
/// Plugins to run
#[arg(short, long)]
run: Vec<String>,
/// Override default configuration path
#[arg(short, long, env = "RMENU_CONFIG")]
config: Option<PathBuf>,
/// Override base css theme styling
#[arg(long, env = "RMENU_THEME")]
theme: Option<PathBuf>,
/// Include additional css settings
#[arg(long, env = "RMENU_CSS")]
css: Option<PathBuf>,
// root config settings
/// Override terminal command
#[arg(long, env = "RMENU_TERMINAL")]
terminal: Option<String>,
/// Number of results to include for each page
#[arg(long)]
page_size: Option<usize>,
/// Control ratio on when to load next page
#[arg(long)]
page_load: Option<f64>,
/// Force enable/disable comments
#[arg(long)]
use_icons: Option<bool>,
/// Force enable/disable comments
#[arg(long)]
use_comments: Option<bool>,
/// Allow Selection by Mouse Hover
#[arg(long)]
hover_select: Option<bool>,
/// Activate Menu Result with Single Click
#[arg(long)]
single_click: Option<bool>,
// search settings
/// Enforce Regex Pattern on Search
#[arg(long)]
search_restrict: Option<String>,
/// Enforce Minimum Length on Search
#[arg(long)]
search_min_length: Option<usize>,
/// Enforce Maximum Length on Search
#[arg(long)]
search_max_length: Option<usize>,
/// Force enable/disable regex in search
#[arg(long)]
search_regex: Option<bool>,
/// Force enable/disable ignore-case in search
#[arg(long)]
ignore_case: Option<bool>,
/// Override placeholder in searchbar
#[arg(short, long)]
placeholder: Option<String>,
// keybinding settings
/// Override exec keybind
#[arg(long)]
key_exec: Option<Vec<Keybind>>,
/// Override exit keybind
#[arg(long)]
key_exit: Option<Vec<Keybind>>,
/// Override move-next keybind
#[arg(long)]
key_move_next: Option<Vec<Keybind>>,
/// Override move-previous keybind
#[arg(long)]
key_move_prev: Option<Vec<Keybind>>,
/// Override open-menu keybind
#[arg(long)]
key_open_menu: Option<Vec<Keybind>>,
/// Override close-menu keybind
#[arg(long)]
key_close_menu: Option<Vec<Keybind>>,
/// Override jump-next keybind
#[arg(long)]
key_jump_next: Option<Vec<Keybind>>,
/// Override jump-previous keybind
#[arg(long)]
key_jump_prev: Option<Vec<Keybind>>,
//window settings
/// Override Window Title
#[arg(long)]
title: Option<String>,
/// Override Window Width
#[arg(long)]
width: Option<f64>,
/// Override Window Height
#[arg(long)]
height: Option<f64>,
/// Override Window Focus on Startup
#[arg(long)]
focus: Option<bool>,
/// Override Window Decoration
#[arg(long)]
decorate: Option<bool>,
/// Override Window Transparent
#[arg(long)]
transparent: Option<bool>,
/// Override Window Always-On-Top
#[arg(long)]
always_top: Option<bool>,
/// Override Fullscreen Settings
#[arg(long)]
fullscreen: Option<bool>,
// hidden vars
#[clap(skip)]
pub threads: Vec<std::thread::JoinHandle<()>>,
}
#[derive(Error, Debug)]
pub enum RMenuError {
#[error("Invalid Config")]
InvalidConfig(#[from] serde_yaml::Error),
#[error("File Error")]
FileError(#[from] std::io::Error),
#[error("No Such Plugin")]
NoSuchPlugin(String),
#[error("Invalid Plugin Specified")]
InvalidPlugin(String),
#[error("Invalid Keybind Definition")]
InvalidKeybind(String),
#[error("Command Runtime Exception")]
CommandError(Option<ExitStatus>),
#[error("Invalid JSON Entry Object")]
InvalidJson(#[from] serde_json::Error),
}
pub type Result<T> = std::result::Result<T, RMenuError>;
impl Args {
/// Find a specifically named file across xdg config paths
fn find_xdg_file(&self, name: &str, base: &Option<PathBuf>) -> Option<String> {
return base
.clone()
.or_else(|| {
xdg::BaseDirectories::with_prefix(XDG_PREFIX)
.expect("Failed to read xdg base dirs")
.find_config_file(name)
})
.map(|f| {
let f = f.to_string_lossy().to_string();
shellexpand::tilde(&f).to_string()
});
}
/// Load Configuration File
pub fn get_config(&self) -> Result<Config> {
let config = self.find_xdg_file(DEFAULT_CONFIG, &self.config);
if let Some(path) = config {
let config: Config = match read_to_string(path) {
Ok(content) => serde_yaml::from_str(&content),
Err(err) => {
log::error!("Failed to Load Config: {err:?}");
Ok(Config::default())
}
}?;
return Ok(config);
}
log::error!("Failed to Load Config: no file found in xdg config paths");
Ok(Config::default())
}
/// Update Configuration w/ CLI Specified Settings
pub fn update_config(&self, mut config: Config) -> Config {
// override basic settings
config.terminal = self.terminal.clone().or_else(|| config.terminal);
config.page_size = self.page_size.unwrap_or(config.page_size);
config.page_load = self.page_load.unwrap_or(config.page_load);
config.use_icons = self.use_icons.unwrap_or(config.use_icons);
config.use_comments = self.use_icons.unwrap_or(config.use_comments);
config.hover_select = self.hover_select.unwrap_or(config.hover_select);
config.single_click = self.single_click.unwrap_or(config.single_click);
// override search settings
cfg_replace!(config.search.restrict, self.search_restrict);
cfg_replace!(config.search.max_length, self.search_max_length, true);
cfg_replace!(config.search.use_regex, self.search_regex, true);
cfg_replace!(config.search.ignore_case, self.ignore_case, true);
cfg_replace!(config.search.placeholder, self.placeholder);
// override keybind settings
cfg_replace!(config.keybinds.exec, self.key_exec, true);
cfg_replace!(config.keybinds.exit, self.key_exit, true);
cfg_replace!(config.keybinds.move_next, self.key_move_next, true);
cfg_replace!(config.keybinds.move_prev, self.key_move_prev, true);
cfg_replace!(config.keybinds.open_menu, self.key_open_menu, true);
cfg_replace!(config.keybinds.close_menu, self.key_close_menu, true);
cfg_replace!(config.keybinds.jump_next, self.key_jump_next, true);
cfg_replace!(config.keybinds.jump_prev, self.key_jump_prev, true);
// override window settings
cfg_replace!(config.window.title, self.title, true);
cfg_replace!(config.window.size.width, self.width, true);
cfg_replace!(config.window.size.height, self.height, true);
cfg_replace!(config.window.focus, self.focus, true);
cfg_replace!(config.window.decorate, self.decorate, true);
cfg_replace!(config.window.transparent, self.transparent, true);
cfg_replace!(config.window.always_top, self.always_top, true);
cfg_replace!(config.window.fullscreen, self.fullscreen);
config
}
/// Load CSS Theme or Default
pub fn get_theme(&self) -> String {
self.find_xdg_file(DEFAULT_THEME, &self.theme)
.map(read_to_string)
.map(|f| {
f.unwrap_or_else(|err| {
log::error!("Failed to load CSS: {err:?}");
String::new()
})
})
.unwrap_or_else(String::new)
}
/// Load Additional CSS or Default
pub fn get_css(&self, c: &Config) -> String {
let css = self
.css
.clone()
.map(|s| s.to_string_lossy().to_string())
.or(c.css.clone());
if let Some(path) = css {
let path = shellexpand::tilde(&path).to_string();
match read_to_string(&path) {
Ok(css) => return css,
Err(err) => log::error!("Failed to load Theme: {err:?}"),
}
}
String::new()
}
fn read_entries<T: Read>(
&mut self,
r: BufReader<T>,
v: &mut Vec<Entry>,
c: &mut Config,
) -> Result<()> {
for line in r.lines().filter_map(|l| l.ok()) {
match &self.format {
Format::DMenu => v.push(Entry::echo(line.trim(), None)),
Format::Json => {
let msg: Message = serde_json::from_str(&line)?;
match msg {
Message::Entry(entry) => v.push(entry),
Message::Options(options) => c
.update(&options)
.map_err(|s| RMenuError::InvalidKeybind(s))?,
}
}
}
}
Ok(())
}
/// Read Entries from a Configured Input
fn load_input(&mut self, input: &str, config: &mut Config) -> Result<Vec<Entry>> {
// retrieve input file
let input = if input == "-" { "/dev/stdin" } else { input };
let fpath = shellexpand::tilde(input).to_string();
// read entries into iterator and collect
log::info!("reading from: {fpath:?}");
let file = File::open(fpath)?;
let reader = BufReader::new(file);
let mut entries = vec![];
self.read_entries(reader, &mut entries, config)?;
Ok(entries)
}
/// Read Entries from a Plugin Source
fn load_plugins(&mut self, config: &mut Config) -> Result<Vec<Entry>> {
let mut entries = vec![];
for name in self.run.clone().into_iter() {
// retrieve plugin configuration
log::info!("running plugin: {name:?}");
let plugin = config
.plugins
.get(&name)
.cloned()
.ok_or_else(|| RMenuError::NoSuchPlugin(name.to_owned()))?;
// update config w/ plugin options when available
if let Some(options) = plugin.options.as_ref() {
config
.update(options)
.map_err(|e| RMenuError::InvalidKeybind(e))?;
}
// read cache when available
match crate::cache::read_cache(&name, &plugin) {
Err(err) => log::error!("cache read failed: {err:?}"),
Ok(cached) => {
entries.extend(cached);
continue;
}
}
// build command arguments
let args: Vec<String> = plugin
.exec
.iter()
.map(|s| shellexpand::tilde(s).to_string())
.collect();
let main = args
.get(0)
.ok_or_else(|| RMenuError::InvalidPlugin(name.to_owned()))?;
// spawn command
let mut command = Command::new(main)
.args(&args[1..])
.stdout(Stdio::piped())
.spawn()?;
let stdout = command
.stdout
.as_mut()
.ok_or_else(|| RMenuError::CommandError(None))?;
// parse and read entries into vector of results
let reader = BufReader::new(stdout);
let mut entry = vec![];
self.read_entries(reader, &mut entry, config)?;
let status = command.wait()?;
if !status.success() {
return Err(RMenuError::CommandError(Some(status)));
}
// finalize settings and save to cache
if config.search.placeholder.is_none() {
config.search.placeholder = plugin.placeholder.clone();
}
let write_entries = entry.clone();
self.threads
.push(std::thread::spawn(move || match crate::cache::write_cache(
&name,
&plugin,
&write_entries,
) {
Ok(_) => {}
Err(err) => log::error!("cache write error: {err:?}"),
}));
// write collected entries to main output
entries.append(&mut entry);
}
Ok(entries)
}
/// Load Entries from Enabled/Configured Entry-Sources
pub fn get_entries(&mut self, config: &mut Config) -> Result<Vec<Entry>> {
// configure default source if none are given
let mut input = self.input.clone();
let mut entries = vec![];
if input.is_none() && self.run.is_empty() {
input = Some("-".to_owned());
}
// load entries
if let Some(input) = input {
entries.extend(self.load_input(&input, config)?);
}
entries.extend(self.load_plugins(config)?);
Ok(entries)
}
/// Configure Environment Variables for Multi-Stage Execution
pub fn set_env(&self) {
let mut running = self.run.join(",");
if let Ok(already_running) = std::env::var(ENV_ACTIVE_PLUGINS) {
running = format!("{running},{already_running}");
}
std::env::set_var(ENV_ACTIVE_PLUGINS, running);
}
/// Load Settings from Environment Variables for Multi-Stage Execution
pub fn load_env(&mut self, config: &mut Config) -> Result<()> {
let env_plugins = std::env::var(ENV_ACTIVE_PLUGINS).unwrap_or_default();
let active_plugins: Vec<&str> = env_plugins
.split(",")
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
for name in active_plugins {
// retrieve plugin configuration
log::info!("reloading plugin configuration for {name:?}");
let plugin = config
.plugins
.get(name)
.cloned()
.ok_or_else(|| RMenuError::NoSuchPlugin(name.to_owned()))?;
// update config w/ plugin options when available
if let Some(options) = plugin.options.as_ref() {
config
.update(options)
.map_err(|e| RMenuError::InvalidKeybind(e))?;
}
}
Ok(())
}
}

View File

@ -1,5 +1,9 @@
///! File Based Configuration for RMenu
use std::collections::BTreeMap;
use std::str::FromStr;
use rmenu_plugin::Options;
use dioxus::events::{Code, Modifiers};
use serde::de::Error;
use serde::Deserialize;
@ -13,6 +17,8 @@ fn _true() -> bool {
#[derive(Debug, PartialEq, Deserialize)]
#[serde(default)]
pub struct Config {
pub css: Option<String>,
pub terminal: Option<String>,
pub page_size: usize,
pub page_load: f64,
pub jump_dist: usize,
@ -25,11 +31,14 @@ pub struct Config {
pub search: SearchConfig,
pub window: WindowConfig,
pub keybinds: KeyConfig,
pub plugins: BTreeMap<String, PluginConfig>,
}
impl Default for Config {
fn default() -> Self {
Self {
css: None,
terminal: None,
page_size: 50,
page_load: 0.8,
jump_dist: 5,
@ -40,10 +49,44 @@ impl Default for Config {
search: Default::default(),
window: Default::default(),
keybinds: Default::default(),
plugins: Default::default(),
}
}
}
impl Config {
/// Update Configuration from Options Object
pub fn update(&mut self, options: &Options) -> Result<(), String> {
cfg_replace!(self.css, options.css);
cfg_replace!(self.page_size, options.page_size, true);
cfg_replace!(self.page_load, options.page_load, true);
cfg_replace!(self.jump_dist, options.jump_dist, true);
cfg_replace!(self.hover_select, options.hover_select, true);
cfg_replace!(self.single_click, options.single_click, true);
// search settings
cfg_replace!(self.search.placeholder, options.placeholder);
cfg_replace!(self.search.restrict, options.search_restrict);
cfg_replace!(self.search.max_length, options.search_max_length, true);
// keybind settings
cfg_keybind!(self.keybinds.exec, options.key_exec);
cfg_keybind!(self.keybinds.exit, options.key_exit);
cfg_keybind!(self.keybinds.move_next, options.key_move_next);
cfg_keybind!(self.keybinds.move_prev, options.key_move_prev);
cfg_keybind!(self.keybinds.open_menu, options.key_open_menu);
cfg_keybind!(self.keybinds.close_menu, options.key_close_menu);
cfg_keybind!(self.keybinds.jump_next, options.key_jump_next);
cfg_keybind!(self.keybinds.jump_prev, options.key_jump_prev);
// window settings
cfg_replace!(self.window.title, options.title, true);
cfg_replace!(self.window.decorate, options.decorate, true);
cfg_replace!(self.window.fullscreen, options.fullscreen);
cfg_replace!(self.window.transparent, options.transparent, true);
cfg_replace!(self.window.size.width, options.window_width, true);
cfg_replace!(self.window.size.height, options.window_height, true);
Ok(())
}
}
#[inline]
fn _maxlen() -> usize {
999
@ -77,8 +120,8 @@ impl Default for SearchConfig {
#[derive(Debug, PartialEq, Deserialize)]
pub struct WindowSize {
width: f64,
height: f64,
pub width: f64,
pub height: f64,
}
impl Default for WindowSize {
@ -144,6 +187,50 @@ impl Default for WindowConfig {
}
}
/// Cache Settings for Configured RMenu Plugins
#[derive(Debug, Clone, PartialEq)]
pub enum CacheSetting {
NoCache,
Never,
OnLogin,
AfterSeconds(usize),
}
impl Default for CacheSetting {
fn default() -> Self {
Self::NoCache
}
}
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))
}
}
}
}
/// RMenu Data-Source Plugin Configuration
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct PluginConfig {
pub exec: Vec<String>,
#[serde(default)]
pub cache: CacheSetting,
#[serde(default)]
pub placeholder: Option<String>,
#[serde(default)]
pub options: Option<Options>,
}
/// GUI Keybind Settings Options
#[derive(Debug, PartialEq, Deserialize)]
#[serde(default)]
@ -165,8 +252,8 @@ impl Default for KeyConfig {
exit: vec![Keybind::new(Code::Escape)],
move_next: vec![Keybind::new(Code::ArrowDown)],
move_prev: vec![Keybind::new(Code::ArrowUp)],
open_menu: vec![],
close_menu: vec![],
open_menu: vec![Keybind::new(Code::ArrowRight)],
close_menu: vec![Keybind::new(Code::ArrowLeft)],
jump_next: vec![Keybind::new(Code::PageDown)],
jump_prev: vec![Keybind::new(Code::PageUp)],
};
@ -245,4 +332,35 @@ macro_rules! de_fromstr {
}
// implement `Deserialize` using `FromStr`
de_fromstr!(CacheSetting);
de_fromstr!(Keybind);
macro_rules! cfg_replace {
($key:expr, $repl:expr) => {
if $repl.is_some() {
$key = $repl.clone();
}
};
($key:expr, $repl:expr, true) => {
if let Some(value) = $repl.as_ref() {
$key = value.to_owned();
}
};
}
macro_rules! cfg_keybind {
($key:expr, $repl:expr) => {
if let Some(bind_strings) = $repl.as_ref() {
let mut keybinds = vec![];
for bind_str in bind_strings.iter() {
let bind = Keybind::from_str(bind_str)?;
keybinds.push(bind);
}
$key = keybinds;
}
};
}
pub(crate) use cfg_keybind;
pub(crate) use cfg_replace;
pub(crate) use de_fromstr;

63
rmenu/src/exec.rs Normal file
View File

@ -0,0 +1,63 @@
//! 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![
("wezterm", "-e {cmd}"),
("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),
}
}
/// Execute the Entry Action as Specified
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)
}
Method::Echo(echo) => {
println!("{echo}");
return;
}
};
let err = Command::new(&args[0]).args(&args[1..]).exec();
panic!("Command Error: {err:?}");
}

81
rmenu/src/gui/image.rs Normal file
View File

@ -0,0 +1,81 @@
//! GUI Image Processing
use std::fs::{create_dir_all, write};
use std::io;
use std::path::PathBuf;
use std::sync::Mutex;
use cached::proc_macro::cached;
use once_cell::sync::Lazy;
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 = std::fs::read(path)?;
let opt = resvg::usvg::Options::default();
let fontdb = resvg::usvg::fontdb::Database::default();
let tree = resvg::usvg::Tree::from_data(&xml, &opt, &fontdb)?;
// generate pixel-buffer and scale according to size preference
let size = tree.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
resvg::render(&tree, 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())
}
#[cached]
pub fn image_exists(path: String) -> bool {
PathBuf::from(path).exists()
}

View File

@ -3,6 +3,7 @@ use std::{cell::RefCell, rc::Rc};
use dioxus::prelude::*;
mod entry;
mod image;
mod state;
pub use state::ContextBuilder;
@ -27,7 +28,6 @@ pub fn run(ctx: Context) {
.with_cfg(config)
.with_context(Rc::new(RefCell::new(ctx)))
.launch(gui_main);
println!("hello world!");
}
#[derive(Clone, Props)]
@ -43,6 +43,22 @@ impl PartialEq for Row {
}
}
#[inline]
fn render_image(image: Option<&String>, alt: Option<&String>) -> Element {
if let Some(img) = image {
if img.ends_with(".svg") {
if let Some(content) = image::convert_svg(img.to_owned()) {
return rsx! { img { class: "image", src: "{content}" } };
}
}
if image::image_exists(img.to_owned()) {
return rsx! { img { class: "image", src: "{img}" } };
}
}
let alt = alt.map(|s| s.as_str()).unwrap_or_else(|| "?");
return rsx! { div { class: "icon_alt", dangerous_inner_html: "{alt}" } };
}
fn gui_entry(mut row: Row) -> Element {
// retrieve entry information based on index
let ctx = use_context::<Ctx>();
@ -51,15 +67,18 @@ fn gui_entry(mut row: Row) -> Element {
let hover_select = context.config.hover_select;
let (pos, subpos) = row.position.with(|p| (p.pos, p.subpos));
// build element from entry
let aclass = (pos == row.search_index && subpos > 0)
.then_some("active")
.unwrap_or_default();
let single_click = context.config.single_click;
let action_select = pos == row.search_index && subpos > 0;
let aclass = action_select.then_some("active").unwrap_or_default();
let rclass = (pos == row.search_index && subpos == 0)
.then_some("selected")
.unwrap_or_default();
let result_ctx1 = use_context::<Ctx>();
let result_ctx2 = use_context::<Ctx>();
rsx! {
div {
class: "result-entry",
// main-entry
div {
id: "result-{row.search_index}",
class: "result {rclass}",
@ -71,11 +90,24 @@ fn gui_entry(mut row: Row) -> Element {
},
onclick: move |_| {
row.position.with_mut(|p| p.set(row.search_index, 0));
if single_click {
let pos = row.position.clone();
result_ctx1.borrow().execute(row.entry_index, &pos);
}
},
ondoubleclick: move |_| {
// row.position.with_mut(|p| p.set(row.search_index, 0));
let pos = row.position.clone();
result_ctx2.borrow().execute(row.entry_index, &pos);
},
// content
if context.config.use_icons {
{rsx! {
div {
class: "icon",
{render_image(entry.icon.as_ref(), entry.icon_alt.as_ref())},
}
}}
},
if context.config.use_comments {
{rsx! {
div {
@ -96,6 +128,52 @@ fn gui_entry(mut row: Row) -> Element {
}}
}
}
// actions
div {
id: "result-{row.search_index}-actions",
class: "actions {aclass}",
for (idx, action, classes, ctx, ctx2) in entry.actions
.iter()
.enumerate()
.skip(1)
.map(|(idx, act)| {
let ctx = use_context::<Ctx>();
let ctx2 = use_context::<Ctx>();
let classes = (idx == subpos).then_some("selected").unwrap_or_default();
(idx, act, classes, ctx, ctx2)
})
{
div {
class: "action {classes}",
// actions
onmouseenter: move |_| {
if hover_select {
row.position.with_mut(|p| p.set(row.search_index, idx));
}
},
onclick: move |_| {
row.position.with_mut(|p| p.set(row.search_index, 0));
if single_click {
let pos = row.position.clone();
ctx.borrow().execute(row.entry_index, &pos);
}
},
ondoubleclick: move |_| {
let pos = row.position.clone();
ctx2.borrow().execute(row.entry_index, &pos);
},
// content
div {
class: "action-name",
dangerous_inner_html: "{action.name}"
}
div {
class: "action-comment",
dangerous_inner_html: action.comment.as_ref().map(|s| s.as_str()).unwrap_or(""),
}
}
}
}
}
}
}

View File

@ -113,7 +113,7 @@ impl Context {
// increment total results by 1 page if beyond threshold
let md = if ratio < threshold { 1 } else { 2 };
let limit = (page + md) * page_size;
println!("pos: {pos}, page: {page}, ratio: {ratio}, limit: {limit}");
log::debug!("pos: {pos}, page: {page}, ratio: {ratio}, limit: {limit}");
limit
}
@ -141,12 +141,25 @@ impl Context {
self.scroll(pos.with(|p| p.pos) + 3);
}
pub fn execute(&self, index: usize, pos: &Pos) {
let entry = self.get_entry(index);
let (pos, subpos) = pos.with(|p| (p.pos, p.subpos));
log::debug!("execute-pos {pos} {subpos}");
let Some(action) = entry.actions.get(subpos) else {
return;
};
log::debug!("execute-entry {entry:?}");
log::debug!("execute-action: {action:?}");
crate::exec::execute(action, self.config.terminal.clone());
}
pub fn handle_keybinds(&self, event: KeyboardEvent, index: usize, pos: &mut Pos) -> bool {
let code = event.code();
let modifiers = event.modifiers();
let keybinds = &self.config.keybinds;
if self.matches(&keybinds.exec, &modifiers, &code) {
println!("exec!");
self.execute(index, pos);
return true;
} else if self.matches(&keybinds.exit, &modifiers, &code) {
return true;
} else if self.matches(&keybinds.move_next, &modifiers, &code) {
@ -156,7 +169,9 @@ impl Context {
self.move_prev(pos);
self.scroll_up(pos);
} else if self.matches(&keybinds.open_menu, &modifiers, &code) {
self.open_menu(index, pos);
} else if self.matches(&keybinds.close_menu, &modifiers, &code) {
self.close_menu(pos);
} else if self.matches(&keybinds.jump_next, &modifiers, &code) {
self.move_down(self.config.jump_dist, pos);
self.scroll_down(pos);
@ -197,10 +212,21 @@ impl Context {
}
self.move_down(1, pos);
}
pub fn open_menu(&self, index: usize, pos: &mut Pos) {
let entry = self.get_entry(index);
if entry.actions.len() > 1 {
pos.with_mut(|s| s.subpos += 1);
}
}
#[inline]
pub fn close_menu(&self, pos: &mut Pos) {
pos.with_mut(|s| s.subpos = 0);
}
//** Cleanup **
pub fn cleanup(&mut self) {
log::debug!("cleaning up {} threads", self.threads.len());
while !self.threads.is_empty() {
let thread = self.threads.pop().unwrap();
let _ = thread.join();

View File

@ -1,35 +1,61 @@
mod cache;
mod cli;
mod config;
mod exec;
mod gui;
mod search;
use rmenu_plugin::Entry;
use clap::Parser;
#[derive(Debug)]
pub struct App {
css: String,
theme: String,
config: config::Config,
entries: Vec<Entry>,
}
static DEFAULT_THEME: &'static str = "style.css";
static DEFAULT_CONFIG: &'static str = "config.yaml";
static XDG_PREFIX: &'static str = "rmenu";
fn main() {
// temp building of app
let s = std::fs::read_to_string("/home/andrew/.cache/rmenu/drun.cache").unwrap();
let entries: Vec<Entry> = serde_json::from_str(&s).unwrap();
let mut config = config::Config::default();
config.search.max_length = 5;
static ENV_BIN: &'static str = "RMENU";
static ENV_ACTIVE_PLUGINS: &'static str = "RMENU_ACTIVE_PLUGINS";
let test = std::thread::spawn(move || {
println!("running thread!");
std::thread::sleep(std::time::Duration::from_secs(3));
println!("exiting!");
});
//TODO: remove min-length from search options in rmenu-lib
fn main() -> cli::Result<()> {
env_logger::init();
// export self to environment for other scripts
let exe = rmenu_plugin::self_exe();
std::env::set_var(ENV_BIN, exe);
// parse cli and retrieve values for app
let mut cli = cli::Args::parse();
let mut config = cli.get_config()?;
let entries = cli.get_entries(&mut config)?;
// update config based on cli-settings and entries
config = cli.update_config(config);
config.use_icons = config.use_icons
&& entries
.iter()
.any(|e| e.icon.is_some() || e.icon_alt.is_some());
config.use_comments = config.use_comments && entries.iter().any(|e| e.comment.is_some());
// load additional configuration settings from env
cli.load_env(&mut config)?;
// configure css theme and css overrides
let css = cli.get_css(&config);
let theme = cli.get_theme();
// set environment variables before running app
cli.set_env();
// run gui
let context = gui::ContextBuilder::default()
.with_css(css)
.with_theme(theme)
.with_config(config)
.with_entries(entries)
.with_bg_threads(vec![test])
.with_bg_threads(cli.threads)
.build();
gui::run(context)
gui::run(context);
Ok(())
}