mirror of
https://github.com/imgurbot12/rmenu.git
synced 2025-02-12 13:15:07 +01:00
feat: upgrade deps, incomplete work on dioxus 5 rewrite
This commit is contained in:
parent
c8926f5ec4
commit
965d53263d
16 changed files with 2168 additions and 2531 deletions
2522
Cargo.lock
generated
2522
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -6,19 +6,19 @@ edition = "2021"
|
|||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.72"
|
||||
anyhow = "1.0.86"
|
||||
async-std = "1.12.0"
|
||||
clap = { version = "4.3.21", features = ["derive"] }
|
||||
dioxus = "0.4.3"
|
||||
dioxus-desktop = "0.4.3"
|
||||
dioxus-free-icons = { version = "0.7.0", features = ["font-awesome-regular"] }
|
||||
dioxus-html = "0.4.3"
|
||||
env_logger = "0.10.0"
|
||||
futures-channel = "0.3.28"
|
||||
clap = { version = "4.5.4", features = ["derive"] }
|
||||
dioxus = "0.5.1"
|
||||
dioxus-desktop = "0.5.1"
|
||||
dioxus-free-icons = { version = "0.8.5", features = ["font-awesome-regular"] }
|
||||
dioxus-html = "0.5.1"
|
||||
env_logger = "0.11.3"
|
||||
futures-channel = "0.3.30"
|
||||
glib = { git = "https://github.com/gtk-rs/gtk-rs-core", rev = 'cab7e1c549675cbe98af461ebbcc04c33c8f1295' }
|
||||
keyboard-types = "0.6.2"
|
||||
log = "0.4.20"
|
||||
keyboard-types = "0.7.0"
|
||||
log = "0.4.21"
|
||||
nm = { git = "https://github.com/imgurbot12/libnm-rs.git", version = "0.4.0" }
|
||||
once_cell = "1.18.0"
|
||||
once_cell = "1.19.0"
|
||||
rmenu-plugin = { version = "0.0.2", path = "../rmenu-plugin" }
|
||||
serde_json = "1.0.104"
|
||||
serde_json = "1.0.117"
|
||||
|
|
|
@ -10,8 +10,8 @@ default = ["sway"]
|
|||
sway = []
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.72"
|
||||
clap = { version = "4.3.21", features = ["derive"] }
|
||||
anyhow = "1.0.86"
|
||||
clap = { version = "4.5.4", features = ["derive"] }
|
||||
rmenu-plugin = { version = "0.0.2", path = "../rmenu-plugin" }
|
||||
serde = { version = "1.0.183", features = ["derive"] }
|
||||
serde_json = "1.0.104"
|
||||
serde = { version = "1.0.203", features = ["derive"] }
|
||||
serde_json = "1.0.117"
|
||||
|
|
|
@ -14,7 +14,6 @@ name = "rmenu-build"
|
|||
path = "src/bin/main.rs"
|
||||
|
||||
[dependencies]
|
||||
bincode = "1.3.3"
|
||||
clap = { version = "4.3.22", features = ["derive"] }
|
||||
serde = { version = "1.0.171", features = ["derive"] }
|
||||
serde_json = "1.0.105"
|
||||
clap = { version = "4.5.4", features = ["derive"] }
|
||||
serde = { version = "1.0.203", features = ["derive"] }
|
||||
serde_json = "1.0.117"
|
||||
|
|
|
@ -6,28 +6,17 @@ edition = "2021"
|
|||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
cached = "0.44.0"
|
||||
clap = { version = "4.3.15", features = ["derive", "env"] }
|
||||
dioxus = "0.4.3"
|
||||
dioxus-desktop = "0.4.3"
|
||||
dioxus-html = "0.4.3"
|
||||
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"
|
||||
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"
|
||||
log = "0.4.21"
|
||||
regex = { version = "1.10.4" }
|
||||
resvg = "0.41.0"
|
||||
rmenu-plugin = { version = "0.0.2", 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"
|
||||
serde = { version = "1.0.203", features = ["derive"] }
|
||||
serde_json = "1.0.117"
|
||||
serde_yaml = "0.9.34"
|
||||
shellexpand = "3.1.0"
|
||||
strfmt = "0.2.4"
|
||||
thiserror = "1.0.43"
|
||||
which = "4.4.0"
|
||||
xdg = "2.5.2"
|
||||
|
|
|
@ -1,87 +0,0 @@
|
|||
//! 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(())
|
||||
}
|
443
rmenu/src/cli.rs
443
rmenu/src/cli.rs
|
@ -1,443 +0,0 @@
|
|||
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 X Position
|
||||
#[arg(long)]
|
||||
xpos: Option<f64>,
|
||||
/// Override Window Y Position
|
||||
#[arg(long)]
|
||||
ypos: 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>,
|
||||
}
|
||||
|
||||
#[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.min_length, self.search_min_length);
|
||||
cfg_replace!(config.search.max_length, self.search_max_length);
|
||||
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.position.x, self.xpos, true);
|
||||
cfg_replace!(config.window.position.y, self.ypos, 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();
|
||||
}
|
||||
match crate::cache::write_cache(&name, &plugin, &entry) {
|
||||
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(())
|
||||
}
|
||||
}
|
|
@ -1,15 +1,106 @@
|
|||
//! RMENU Configuration Implementations
|
||||
use std::collections::BTreeMap;
|
||||
use std::str::FromStr;
|
||||
|
||||
use dioxus_desktop::tao::{
|
||||
dpi::{LogicalPosition, LogicalSize},
|
||||
window::Fullscreen,
|
||||
use dioxus::events::{Code, Modifiers};
|
||||
use serde::de::Error;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[inline]
|
||||
fn _true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Global RMenu Complete Configuration
|
||||
#[derive(Debug, PartialEq, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub page_size: usize,
|
||||
pub page_load: f64,
|
||||
pub jump_dist: usize,
|
||||
#[serde(default = "_true")]
|
||||
pub use_icons: bool,
|
||||
#[serde(default = "_true")]
|
||||
pub use_comments: bool,
|
||||
pub hover_select: bool,
|
||||
pub single_click: bool,
|
||||
pub search: SearchConfig,
|
||||
pub keybinds: KeyConfig,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
page_size: 50,
|
||||
page_load: 0.8,
|
||||
jump_dist: 5,
|
||||
use_icons: true,
|
||||
use_comments: true,
|
||||
hover_select: false,
|
||||
single_click: false,
|
||||
search: Default::default(),
|
||||
keybinds: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn _maxlen() -> usize {
|
||||
999
|
||||
}
|
||||
|
||||
/// Search Configuration Settings
|
||||
#[derive(Debug, PartialEq, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SearchConfig {
|
||||
pub restrict: Option<String>,
|
||||
#[serde(default = "_maxlen")]
|
||||
pub max_length: usize,
|
||||
pub placeholder: Option<String>,
|
||||
#[serde(default = "_true")]
|
||||
pub use_regex: bool,
|
||||
#[serde(default = "_true")]
|
||||
pub ignore_case: bool,
|
||||
}
|
||||
|
||||
impl Default for SearchConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
restrict: Default::default(),
|
||||
max_length: 999,
|
||||
placeholder: Default::default(),
|
||||
use_regex: true,
|
||||
ignore_case: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Global GUI Keybind Settings Options
|
||||
#[derive(Debug, PartialEq, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct KeyConfig {
|
||||
pub exec: Vec<Keybind>,
|
||||
pub exit: Vec<Keybind>,
|
||||
pub move_next: Vec<Keybind>,
|
||||
pub move_prev: Vec<Keybind>,
|
||||
pub open_menu: Vec<Keybind>,
|
||||
pub close_menu: Vec<Keybind>,
|
||||
pub jump_next: Vec<Keybind>,
|
||||
pub jump_prev: Vec<Keybind>,
|
||||
}
|
||||
|
||||
impl Default for KeyConfig {
|
||||
fn default() -> Self {
|
||||
return Self {
|
||||
exec: vec![Keybind::new(Code::Enter)],
|
||||
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![],
|
||||
jump_next: vec![Keybind::new(Code::PageDown)],
|
||||
jump_prev: vec![Keybind::new(Code::PageUp)],
|
||||
};
|
||||
use dioxus_html::input_data::keyboard_types::{Code, Modifiers};
|
||||
use heck::AsPascalCase;
|
||||
use rmenu_plugin::Options;
|
||||
use serde::{de::Error, Deserialize};
|
||||
}
|
||||
}
|
||||
|
||||
// parse supported modifiers from string
|
||||
fn mod_from_str(s: &str) -> Option<Modifiers> {
|
||||
|
@ -42,309 +133,45 @@ impl FromStr for Keybind {
|
|||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
// parse modifiers/keys from string
|
||||
// parse keys & modifiers from string
|
||||
let mut mods = vec![];
|
||||
let mut keys = vec![];
|
||||
for item in s.split("+") {
|
||||
let camel = format!("{}", AsPascalCase(item));
|
||||
let camel = format!("{}", heck::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}")),
|
||||
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")),
|
||||
0 => Err(format!("No keys specified")),
|
||||
1 => Ok(Keybind {
|
||||
mods: kmod,
|
||||
key: keys.pop().unwrap(),
|
||||
}),
|
||||
_ => Err(format!("too many keys: {keys:?}")),
|
||||
_ => Err(format!("Too many keys: {keys:?}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Keybind {
|
||||
macro_rules! de_fromstr {
|
||||
($s:ident) => {
|
||||
impl<'de> Deserialize<'de> for $s {
|
||||
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)
|
||||
$s::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_next: Vec<Keybind>,
|
||||
pub move_prev: Vec<Keybind>,
|
||||
pub open_menu: Vec<Keybind>,
|
||||
pub close_menu: Vec<Keybind>,
|
||||
pub jump_next: Vec<Keybind>,
|
||||
pub jump_prev: Vec<Keybind>,
|
||||
}
|
||||
|
||||
impl Default for KeyConfig {
|
||||
fn default() -> Self {
|
||||
return Self {
|
||||
exec: vec![Keybind::new(Code::Enter)],
|
||||
exit: vec![Keybind::new(Code::Escape)],
|
||||
move_next: vec![Keybind::new(Code::ArrowUp)],
|
||||
move_prev: vec![Keybind::new(Code::ArrowDown)],
|
||||
open_menu: vec![],
|
||||
close_menu: vec![],
|
||||
jump_next: vec![Keybind::new(Code::PageDown)],
|
||||
jump_prev: vec![Keybind::new(Code::PageUp)],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// GUI Desktop Window Configuration Settings
|
||||
#[derive(Debug, PartialEq, Deserialize)]
|
||||
pub struct WindowConfig {
|
||||
pub title: String,
|
||||
pub size: LogicalSize<f64>,
|
||||
pub position: LogicalPosition<f64>,
|
||||
#[serde(default = "_true")]
|
||||
pub focus: bool,
|
||||
pub decorate: bool,
|
||||
pub transparent: bool,
|
||||
#[serde(default = "_true")]
|
||||
pub always_top: bool,
|
||||
pub fullscreen: Option<bool>,
|
||||
pub dark_mode: Option<bool>,
|
||||
}
|
||||
|
||||
impl WindowConfig {
|
||||
/// Retrieve Desktop Compatabible Fullscreen Settings
|
||||
pub fn get_fullscreen(&self) -> Option<Fullscreen> {
|
||||
self.fullscreen.and_then(|fs| match fs {
|
||||
true => Some(Fullscreen::Borderless(None)),
|
||||
false => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WindowConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
title: "RMenu - App Launcher".to_owned(),
|
||||
size: LogicalSize {
|
||||
width: 700.0,
|
||||
height: 400.0,
|
||||
},
|
||||
position: LogicalPosition { x: 100.0, y: 100.0 },
|
||||
focus: true,
|
||||
decorate: false,
|
||||
transparent: false,
|
||||
always_top: true,
|
||||
fullscreen: None,
|
||||
dark_mode: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache Settings for Configured RMenu Plugins
|
||||
#[derive(Debug, Clone, 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, 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>,
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn _true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn _false() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SearchConfig {
|
||||
pub restrict: Option<String>,
|
||||
pub min_length: Option<usize>,
|
||||
pub max_length: Option<usize>,
|
||||
pub placeholder: Option<String>,
|
||||
#[serde(default = "_true")]
|
||||
pub use_regex: bool,
|
||||
#[serde(default = "_true")]
|
||||
pub ignore_case: bool,
|
||||
}
|
||||
|
||||
impl Default for SearchConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
restrict: Default::default(),
|
||||
min_length: Default::default(),
|
||||
max_length: Default::default(),
|
||||
placeholder: Default::default(),
|
||||
use_regex: true,
|
||||
ignore_case: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Global RMenu Complete Configuration
|
||||
#[derive(Debug, PartialEq, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub page_size: usize,
|
||||
pub page_load: f64,
|
||||
pub jump_dist: usize,
|
||||
#[serde(default = "_true")]
|
||||
pub use_icons: bool,
|
||||
#[serde(default = "_true")]
|
||||
pub use_comments: bool,
|
||||
#[serde(default = "_false")]
|
||||
pub hover_select: bool,
|
||||
#[serde(default = "_false")]
|
||||
pub single_click: bool,
|
||||
pub search: SearchConfig,
|
||||
pub plugins: BTreeMap<String, PluginConfig>,
|
||||
pub keybinds: KeyConfig,
|
||||
pub window: WindowConfig,
|
||||
pub css: Option<String>,
|
||||
pub terminal: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
page_size: 50,
|
||||
page_load: 0.8,
|
||||
jump_dist: 5,
|
||||
use_icons: true,
|
||||
use_comments: true,
|
||||
hover_select: false,
|
||||
single_click: false,
|
||||
search: Default::default(),
|
||||
plugins: Default::default(),
|
||||
keybinds: Default::default(),
|
||||
window: Default::default(),
|
||||
css: None,
|
||||
terminal: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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.min_length, options.search_min_length);
|
||||
cfg_replace!(self.search.max_length, options.search_max_length);
|
||||
// keybind settings
|
||||
cfg_keybind!(self.keybinds.exec, options.key_exec);
|
||||
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(())
|
||||
}
|
||||
}
|
||||
// implement `Deserialize` using `FromStr`
|
||||
de_fromstr!(Keybind);
|
||||
|
|
|
@ -1,61 +0,0 @@
|
|||
//! 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)
|
||||
}
|
||||
Method::Echo(echo) => {
|
||||
println!("{echo}");
|
||||
std::process::exit(0);
|
||||
}
|
||||
};
|
||||
let err = Command::new(&args[0]).args(&args[1..]).exec();
|
||||
panic!("Command Error: {err:?}");
|
||||
}
|
308
rmenu/src/gui.rs
308
rmenu/src/gui.rs
|
@ -1,308 +0,0 @@
|
|||
//! RMENU GUI Implementation using Dioxus
|
||||
#![allow(non_snake_case)]
|
||||
use std::fmt::Display;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_html::input_data::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) {
|
||||
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_fullscreen(app.config.window.get_fullscreen())
|
||||
.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>) -> &str {
|
||||
comment.map(|s| s.as_str()).unwrap_or("")
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn render_image<'a, T>(
|
||||
cx: Scope<'a, T>,
|
||||
image: Option<&String>,
|
||||
alt: 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}" } });
|
||||
}
|
||||
}
|
||||
if crate::image::image_exists(img.to_owned()) {
|
||||
return cx.render(rsx! { img { class: "image", src: "{img}" } });
|
||||
}
|
||||
}
|
||||
let alt = alt.map(|s| s.as_str()).unwrap_or_else(|| "?");
|
||||
return cx.render(rsx! { div { class: "icon_alt", dangerous_inner_html: "{alt}" } });
|
||||
}
|
||||
|
||||
/// 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 hover_select = cx.props.state.config().hover_select;
|
||||
let single_click = cx.props.state.config().single_click;
|
||||
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}",
|
||||
onmouseenter: move |_| {
|
||||
if hover_select {
|
||||
cx.props.state.set_position(cx.props.index, idx + 1);
|
||||
}
|
||||
},
|
||||
onclick: move |_| {
|
||||
cx.props.state.set_position(cx.props.index, idx + 1);
|
||||
if single_click {
|
||||
cx.props.state.set_event(KeyEvent::Exec);
|
||||
}
|
||||
},
|
||||
ondblclick: |_| cx.props.state.set_event(KeyEvent::Exec),
|
||||
div {
|
||||
class: "action-name",
|
||||
dangerous_inner_html: "{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: move |_| {
|
||||
if hover_select {
|
||||
cx.props.state.set_position(cx.props.index, 0);
|
||||
}
|
||||
},
|
||||
onclick: move |_| {
|
||||
cx.props.state.set_position(cx.props.index, 0);
|
||||
if single_click {
|
||||
cx.props.state.set_event(KeyEvent::Exec);
|
||||
}
|
||||
},
|
||||
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(), cx.props.entry.icon_alt.as_ref())
|
||||
}
|
||||
})
|
||||
}
|
||||
match cx.props.state.config().use_comments {
|
||||
true => cx.render(rsx! {
|
||||
div {
|
||||
class: "name",
|
||||
dangerous_inner_html: "{cx.props.entry.name}"
|
||||
}
|
||||
div {
|
||||
class: "comment",
|
||||
dangerous_inner_html: render_comment(cx.props.entry.comment.as_ref())
|
||||
}
|
||||
}),
|
||||
false => cx.render(rsx! {
|
||||
div {
|
||||
class: "entry",
|
||||
dangerous_inner_html: "{cx.props.entry.name}"
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
div {
|
||||
id: "result-{cx.props.index}-actions",
|
||||
class: "actions {action_classes}",
|
||||
actions.into_iter()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn focus<T>(cx: Scope<T>) {
|
||||
let eval = use_eval(cx);
|
||||
let js = "document.getElementById(`search`).focus()";
|
||||
let _ = eval(js);
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// retrieve string value for display-capable enum
|
||||
#[inline]
|
||||
fn get_str<T: Display>(item: Option<T>) -> String {
|
||||
item.map(|i| i.to_string()).unwrap_or_else(String::new)
|
||||
}
|
||||
|
||||
/// 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 k_updater = state.partial_copy();
|
||||
let s_updater = state.partial_copy();
|
||||
|
||||
// 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_next, &mods, &code) {
|
||||
k_updater.set_event(KeyEvent::MoveNext);
|
||||
} else if matches(&keybinds.move_prev, &mods, &code) {
|
||||
k_updater.set_event(KeyEvent::MovePrev);
|
||||
} 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);
|
||||
} else if matches(&keybinds.jump_next, &mods, &code) {
|
||||
k_updater.set_event(KeyEvent::JumpNext)
|
||||
} else if matches(&keybinds.jump_prev, &mods, &code) {
|
||||
k_updater.set_event(KeyEvent::JumpPrev)
|
||||
}
|
||||
};
|
||||
|
||||
// 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,
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// get input settings
|
||||
let minlen = get_str(cx.props.config.search.min_length.as_ref());
|
||||
let maxlen = get_str(cx.props.config.search.max_length.as_ref());
|
||||
let placeholder = get_str(cx.props.config.search.placeholder.as_ref());
|
||||
|
||||
// complete final rendering
|
||||
cx.render(rsx! {
|
||||
style { DEFAULT_CSS_CONTENT }
|
||||
style { "{cx.props.theme}" }
|
||||
style { "{cx.props.css}" }
|
||||
div {
|
||||
id: "content",
|
||||
class: "content",
|
||||
div {
|
||||
id: "navbar",
|
||||
class: "navbar",
|
||||
match cx.props.config.search.restrict.as_ref() {
|
||||
Some(pattern) => cx.render(rsx! {
|
||||
input {
|
||||
id: "search",
|
||||
value: "{search}",
|
||||
pattern: "{pattern}",
|
||||
minlength: "{minlen}",
|
||||
maxlength: "{maxlen}",
|
||||
placeholder: "{placeholder}",
|
||||
oninput: move |e| s_updater.set_search(cx, e.value.clone()),
|
||||
onkeydown: keyboard_controls,
|
||||
}
|
||||
}),
|
||||
None => cx.render(rsx! {
|
||||
input {
|
||||
id: "search",
|
||||
value: "{search}",
|
||||
minlength: "{minlen}",
|
||||
maxlength: "{maxlen}",
|
||||
placeholder: "{placeholder}",
|
||||
oninput: move |e| s_updater.set_search(cx, e.value.clone()),
|
||||
onkeydown: keyboard_controls,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
div {
|
||||
id: "results",
|
||||
class: "results",
|
||||
rendered_results.into_iter()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
11
rmenu/src/gui/entry.rs
Normal file
11
rmenu/src/gui/entry.rs
Normal file
|
@ -0,0 +1,11 @@
|
|||
use dioxus::prelude::*;
|
||||
use rmenu_plugin::Entry;
|
||||
|
||||
/// Dioxus Search Entry Component
|
||||
#[derive(PartialEq, Clone, Props)]
|
||||
pub struct SearchEntry {
|
||||
pub index: usize,
|
||||
pub entry: MappedSignal<Entry>,
|
||||
pub pos: usize,
|
||||
pub subpos: usize,
|
||||
}
|
158
rmenu/src/gui/mod.rs
Normal file
158
rmenu/src/gui/mod.rs
Normal file
|
@ -0,0 +1,158 @@
|
|||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
mod entry;
|
||||
mod state;
|
||||
|
||||
use crate::App;
|
||||
use state::{Context, Position};
|
||||
|
||||
const DEFAULT_CSS_CONTENT: &'static str = include_str!("../../public/default.css");
|
||||
|
||||
type Ctx = Rc<RefCell<Context>>;
|
||||
|
||||
pub fn run(app: App) {
|
||||
let ctx = Context::new(app.css, app.theme, app.config, app.entries);
|
||||
LaunchBuilder::desktop()
|
||||
.with_context(Rc::new(RefCell::new(ctx)))
|
||||
.launch(gui_main);
|
||||
}
|
||||
|
||||
#[derive(Clone, Props)]
|
||||
struct Row {
|
||||
position: Signal<Position>,
|
||||
search_index: usize,
|
||||
entry_index: usize,
|
||||
}
|
||||
|
||||
impl PartialEq for Row {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.entry_index == other.entry_index
|
||||
}
|
||||
}
|
||||
|
||||
fn gui_entry(mut row: Row) -> Element {
|
||||
// retrieve entry information based on index
|
||||
let ctx = use_context::<Ctx>();
|
||||
let context = ctx.borrow();
|
||||
let entry = context.get_entry(row.entry_index);
|
||||
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 rclass = (pos == row.search_index && subpos == 0)
|
||||
.then_some("selected")
|
||||
.unwrap_or_default();
|
||||
rsx! {
|
||||
div {
|
||||
class: "result-entry",
|
||||
div {
|
||||
id: "result-{row.entry_index}",
|
||||
class: "result {rclass}",
|
||||
// actions
|
||||
onmouseenter: move |_| {
|
||||
if hover_select {
|
||||
row.position.with_mut(|p| p.set(row.search_index, 0));
|
||||
}
|
||||
},
|
||||
onclick: move |_| {
|
||||
row.position.with_mut(|p| p.set(row.search_index, 0));
|
||||
},
|
||||
ondoubleclick: move |_| {
|
||||
// row.position.with_mut(|p| p.set(row.search_index, 0));
|
||||
},
|
||||
// content
|
||||
if context.config.use_comments {
|
||||
{rsx! {
|
||||
div {
|
||||
class: "name",
|
||||
dangerous_inner_html: "{entry.name}"
|
||||
}
|
||||
div {
|
||||
class: "comment",
|
||||
dangerous_inner_html: entry.comment.as_ref().map(|s| s.as_str()).unwrap_or(""),
|
||||
}
|
||||
}}
|
||||
} else {
|
||||
{rsx! {
|
||||
div {
|
||||
class: "entry",
|
||||
dangerous_inner_html: "{entry.name}"
|
||||
}
|
||||
}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn gui_main() -> Element {
|
||||
// build context and signals for state
|
||||
let ctx = use_context::<Ctx>();
|
||||
let mut search = use_signal(String::new);
|
||||
let mut position = use_signal(Position::default);
|
||||
let mut results = use_signal(|| ctx.borrow().all_results());
|
||||
|
||||
// update search results on search
|
||||
use_effect(move || {
|
||||
let ctx = use_context::<Ctx>();
|
||||
let search = search();
|
||||
results.set(ctx.borrow_mut().set_search(&search, &mut position));
|
||||
});
|
||||
|
||||
// declare keyboard handler
|
||||
let keydown = move |e: KeyboardEvent| {
|
||||
let ctx = use_context::<Ctx>();
|
||||
let context = ctx.borrow();
|
||||
// calculate current entry
|
||||
let pos = position.with(|p| p.pos);
|
||||
let index = results.with(|r| r[pos]);
|
||||
let entry = context.get_entry(index);
|
||||
// update keybinds
|
||||
context.handle_keybinds(e, entry, &mut position);
|
||||
// scroll when required
|
||||
let script = format!("document.getElementById(`result-{index}`).scrollIntoView(false)");
|
||||
eval(&script);
|
||||
};
|
||||
|
||||
let context = ctx.borrow();
|
||||
let pattern = context.config.search.restrict.clone();
|
||||
let maxlength = context.config.search.max_length as i64;
|
||||
let max_result = context.calc_limit(&position);
|
||||
rsx! {
|
||||
style { "{DEFAULT_CSS_CONTENT}" }
|
||||
style { "{context.theme}" }
|
||||
style { "{context.css}" }
|
||||
div {
|
||||
id: "content",
|
||||
class: "content",
|
||||
onkeydown: keydown,
|
||||
div {
|
||||
id: "navbar",
|
||||
class: "navbar",
|
||||
input {
|
||||
id: "search",
|
||||
value: "{search}",
|
||||
pattern: pattern,
|
||||
maxlength: maxlength,
|
||||
oninput: move |e| search.set(e.value()),
|
||||
}
|
||||
}
|
||||
div {
|
||||
id: "results",
|
||||
class: "results",
|
||||
for (pos, index) in results().iter().take(max_result).enumerate() {
|
||||
gui_entry {
|
||||
key: "{pos}-{index}",
|
||||
position,
|
||||
search_index: pos,
|
||||
entry_index: *index,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
152
rmenu/src/gui/state.rs
Normal file
152
rmenu/src/gui/state.rs
Normal file
|
@ -0,0 +1,152 @@
|
|||
use dioxus::prelude::*;
|
||||
use rmenu_plugin::Entry;
|
||||
|
||||
use crate::config::{Config, Keybind};
|
||||
use crate::search::new_searchfn;
|
||||
|
||||
/// Global Position Tracker
|
||||
#[derive(Default)]
|
||||
pub struct Position {
|
||||
pub pos: usize,
|
||||
pub subpos: usize,
|
||||
}
|
||||
|
||||
impl Position {
|
||||
pub fn set(&mut self, pos: usize, subpos: usize) {
|
||||
self.pos = pos;
|
||||
self.subpos = subpos;
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.set(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Alias for Signal wrapped Position
|
||||
type Pos = Signal<Position>;
|
||||
|
||||
/// Contain and Track Search Results
|
||||
pub struct Context {
|
||||
pub css: String,
|
||||
pub theme: String,
|
||||
pub config: Config,
|
||||
// search results and controls
|
||||
entries: Vec<Entry>,
|
||||
num_results: usize,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
pub fn new(css: String, theme: String, config: Config, entries: Vec<Entry>) -> Self {
|
||||
println!(
|
||||
"page_size: {}, threshold: {}",
|
||||
config.page_size, config.page_load
|
||||
);
|
||||
Self {
|
||||
num_results: entries.len(),
|
||||
entries,
|
||||
config,
|
||||
theme,
|
||||
css,
|
||||
}
|
||||
}
|
||||
|
||||
// ** Search Results Management **
|
||||
|
||||
pub fn all_results(&self) -> Vec<usize> {
|
||||
(0..self.entries.len()).collect()
|
||||
}
|
||||
|
||||
pub fn set_search(&mut self, search: &str, pos: &mut Pos) -> Vec<usize> {
|
||||
let _ = pos.with_mut(|p| p.reset());
|
||||
let filter = new_searchfn(&self.config, &search);
|
||||
let results: Vec<usize> = self
|
||||
.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, e)| filter(e))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
self.num_results = results.len();
|
||||
results
|
||||
}
|
||||
|
||||
pub fn calc_limit(&self, pos: &Pos) -> usize {
|
||||
let pos = pos.with(|p| p.pos);
|
||||
let page_size = self.config.page_size;
|
||||
// calc current page number
|
||||
let partial = pos % page_size;
|
||||
let page = (pos - partial) / page_size;
|
||||
// calc ratio of completion for current page
|
||||
let ratio = partial as f64 / page_size as f64;
|
||||
let threshold = self.config.page_load;
|
||||
// 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}");
|
||||
limit
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn get_entry(&self, index: usize) -> &Entry {
|
||||
&self.entries[index]
|
||||
}
|
||||
|
||||
// ** Keybind Management **
|
||||
|
||||
#[inline]
|
||||
fn matches(&self, bind: &Vec<Keybind>, mods: &Modifiers, key: &Code) -> bool {
|
||||
bind.iter().any(|b| mods.contains(b.mods) && &b.key == key)
|
||||
}
|
||||
|
||||
pub fn handle_keybinds(&self, event: KeyboardEvent, index: usize, pos: &mut Pos) {
|
||||
let code = event.code();
|
||||
let modifiers = event.modifiers();
|
||||
let keybinds = &self.config.keybinds;
|
||||
if self.matches(&keybinds.exec, &modifiers, &code) {
|
||||
println!("exec!");
|
||||
} else if self.matches(&keybinds.exit, &modifiers, &code) {
|
||||
std::process::exit(0);
|
||||
} else if self.matches(&keybinds.move_next, &modifiers, &code) {
|
||||
self.move_next(index, pos);
|
||||
} else if self.matches(&keybinds.move_prev, &modifiers, &code) {
|
||||
self.move_prev(pos);
|
||||
} else if self.matches(&keybinds.open_menu, &modifiers, &code) {
|
||||
} else if self.matches(&keybinds.close_menu, &modifiers, &code) {
|
||||
} else if self.matches(&keybinds.jump_next, &modifiers, &code) {
|
||||
self.move_down(self.config.jump_dist, pos);
|
||||
} else if self.matches(&keybinds.jump_prev, &modifiers, &code) {
|
||||
self.move_up(self.config.jump_dist, pos);
|
||||
}
|
||||
}
|
||||
|
||||
// ** Position Management **
|
||||
|
||||
pub fn move_up(&self, dist: usize, pos: &mut Pos) {
|
||||
pos.with_mut(|p| {
|
||||
p.subpos = 0;
|
||||
p.pos = std::cmp::max(p.pos, dist) - dist;
|
||||
})
|
||||
}
|
||||
pub fn move_down(&self, dist: usize, pos: &mut Pos) {
|
||||
let max_pos = self.num_results;
|
||||
pos.with_mut(move |p| {
|
||||
p.subpos = 0;
|
||||
p.pos = std::cmp::min(p.pos + dist, max_pos);
|
||||
})
|
||||
}
|
||||
pub fn move_prev(&self, pos: &mut Pos) {
|
||||
let subpos = pos.with(|p| p.subpos);
|
||||
match subpos > 0 {
|
||||
true => pos.with_mut(|p| p.subpos -= 1),
|
||||
false => self.move_up(1, pos),
|
||||
}
|
||||
}
|
||||
pub fn move_next(&self, index: usize, pos: &mut Pos) {
|
||||
let entry = self.get_entry(index);
|
||||
let subpos = pos.with(|p| p.subpos);
|
||||
if subpos > 0 && subpos < entry.actions.len() - 1 {
|
||||
return pos.with_mut(|p| p.subpos += 1);
|
||||
}
|
||||
println!("moving down 1");
|
||||
self.move_down(1, pos);
|
||||
}
|
||||
}
|
|
@ -1,82 +0,0 @@
|
|||
//! 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 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 = std::fs::read(path)?;
|
||||
let opt = resvg::usvg::Options::default();
|
||||
let tree = resvg::usvg::Tree::from_data(&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())
|
||||
}
|
||||
|
||||
#[cached]
|
||||
pub fn image_exists(path: String) -> bool {
|
||||
PathBuf::from(path).exists()
|
||||
}
|
|
@ -1,80 +1,30 @@
|
|||
mod cache;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod exec;
|
||||
mod gui;
|
||||
mod image;
|
||||
mod search;
|
||||
mod state;
|
||||
|
||||
use clap::Parser;
|
||||
use rmenu_plugin::{self_exe, Entry};
|
||||
use rmenu_plugin::Entry;
|
||||
|
||||
static DEFAULT_THEME: &'static str = "style.css";
|
||||
static DEFAULT_CONFIG: &'static str = "config.yaml";
|
||||
static XDG_PREFIX: &'static str = "rmenu";
|
||||
static DEFAULT_CSS_CONTENT: &'static str = include_str!("../public/default.css");
|
||||
|
||||
static ENV_BIN: &'static str = "RMENU";
|
||||
static ENV_ACTIVE_PLUGINS: &'static str = "RMENU_ACTIVE_PLUGINS";
|
||||
|
||||
/// Application State for GUI
|
||||
#[derive(Debug, PartialEq)]
|
||||
#[derive(Debug)]
|
||||
pub struct App {
|
||||
css: String,
|
||||
name: String,
|
||||
theme: String,
|
||||
entries: Vec<Entry>,
|
||||
config: config::Config,
|
||||
entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
//TODO: how should scripting work?
|
||||
// - need a better mechanism for rmenu and another executable to go back and forth
|
||||
// - need some way to preserve settings between executions of rmenu
|
||||
// - need some way for plugins to customize configuration according to preference
|
||||
fn main() {
|
||||
// temp building of app
|
||||
let s = std::fs::read_to_string("/home/andrew/.cache/rmenu/run.cache").unwrap();
|
||||
let entries: Vec<Entry> = serde_json::from_str(&s).unwrap();
|
||||
let mut config = config::Config::default();
|
||||
config.search.max_length = 5;
|
||||
|
||||
fn main() -> cli::Result<()> {
|
||||
// export self to environment for other scripts
|
||||
let exe = self_exe();
|
||||
std::env::set_var(ENV_BIN, exe);
|
||||
|
||||
// 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 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 theme = cli.get_theme();
|
||||
let css = cli.get_css(&config);
|
||||
|
||||
// set environment variables before running app
|
||||
cli.set_env();
|
||||
|
||||
// genrate app context and run gui
|
||||
gui::run(App {
|
||||
name: "rmenu".to_owned(),
|
||||
css,
|
||||
theme,
|
||||
entries,
|
||||
let app = App {
|
||||
css: String::new(),
|
||||
theme: String::new(),
|
||||
config,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
entries,
|
||||
};
|
||||
// run gui
|
||||
gui::run(app);
|
||||
}
|
||||
|
|
|
@ -1,314 +0,0 @@
|
|||
use dioxus::prelude::{use_eval, use_ref, Scope, UseRef};
|
||||
use regex::Regex;
|
||||
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 = use_eval(cx);
|
||||
let js = format!("document.getElementById(`result-{pos}`).scrollIntoView(false)");
|
||||
let _ = eval(&js);
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
pub enum KeyEvent {
|
||||
Exec,
|
||||
Exit,
|
||||
MovePrev,
|
||||
MoveNext,
|
||||
OpenMenu,
|
||||
CloseMenu,
|
||||
JumpNext,
|
||||
JumpPrev,
|
||||
}
|
||||
|
||||
pub struct InnerState {
|
||||
pos: usize,
|
||||
subpos: usize,
|
||||
page: usize,
|
||||
search: String,
|
||||
event: Option<KeyEvent>,
|
||||
search_regex: Option<Regex>,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// Jump a spefified number of results upwards
|
||||
#[inline]
|
||||
pub fn jump_up(&mut self, jump: usize) {
|
||||
self.move_up(jump)
|
||||
}
|
||||
|
||||
/// Jump a specified number of results downwards
|
||||
pub fn jump_down(&mut self, jump: usize, results: &Vec<&Entry>) {
|
||||
let max = std::cmp::max(results.len(), 1);
|
||||
self.move_down(jump, max);
|
||||
}
|
||||
|
||||
/// Move Up Once With Context of SubMenu
|
||||
pub fn move_prev(&mut self) {
|
||||
if self.subpos > 0 {
|
||||
self.subpos -= 1;
|
||||
return;
|
||||
}
|
||||
self.move_up(1);
|
||||
}
|
||||
|
||||
/// Move Down Once With Context of SubMenu
|
||||
pub fn move_next(&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;
|
||||
}
|
||||
}
|
||||
self.jump_down(1, results)
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
search_regex: app.config.search.restrict.clone().and_then(|mut r| {
|
||||
if !r.starts_with('^') {
|
||||
r = format!("^{r}")
|
||||
};
|
||||
if !r.ends_with('$') {
|
||||
r = format!("{r}$")
|
||||
};
|
||||
match Regex::new(&r) {
|
||||
Ok(regex) => Some(regex),
|
||||
Err(err) => {
|
||||
log::error!("Invalid Regex Expression: {:?}", err);
|
||||
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::MovePrev => {
|
||||
self.move_prev();
|
||||
let pos = self.position().0;
|
||||
scroll(cx, if pos <= 3 { pos } else { pos + 3 })
|
||||
}
|
||||
KeyEvent::MoveNext => {
|
||||
self.move_next();
|
||||
scroll(cx, self.position().0 + 3)
|
||||
}
|
||||
KeyEvent::JumpPrev => {
|
||||
self.jump_prev();
|
||||
let pos = self.position().0;
|
||||
scroll(cx, if pos <= 3 { pos } else { pos + 3 })
|
||||
}
|
||||
KeyEvent::JumpNext => {
|
||||
self.jump_next();
|
||||
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) {
|
||||
// confirm search meets required criteria
|
||||
if let Some(min) = self.app.config.search.min_length.as_ref() {
|
||||
if search.len() < *min {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(min) = self.app.config.search.min_length.as_ref() {
|
||||
if search.len() < *min {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let is_match = self.state.with(|s| {
|
||||
s.search_regex
|
||||
.as_ref()
|
||||
.map(|r| r.is_match(&search))
|
||||
.unwrap_or(true)
|
||||
});
|
||||
if !is_match {
|
||||
return;
|
||||
}
|
||||
// update search w/ new content
|
||||
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 move_prev(&self) {
|
||||
self.state.with_mut(|s| s.move_prev());
|
||||
}
|
||||
|
||||
/// Move Down Once With Context of SubMenu
|
||||
#[inline]
|
||||
pub fn move_next(&self) {
|
||||
self.state.with_mut(|s| s.move_next(&self.results))
|
||||
}
|
||||
|
||||
/// Jump a Configured Distance Up the Results
|
||||
#[inline]
|
||||
pub fn jump_prev(&self) {
|
||||
let distance = self.app.config.jump_dist;
|
||||
self.state.with_mut(|s| s.jump_up(distance))
|
||||
}
|
||||
|
||||
/// Jump a Configured Distance Down the Results
|
||||
#[inline]
|
||||
pub fn jump_next(&self) {
|
||||
let distance = self.app.config.jump_dist;
|
||||
self.state
|
||||
.with_mut(|s| s.jump_down(distance, &self.results))
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue