feat: overhaul rmenu-cli, start on powermenu impl, start on scripting interface

This commit is contained in:
imgurbot12 2023-08-16 23:21:55 -07:00
parent 63a87c3873
commit 8634227e22
15 changed files with 794 additions and 285 deletions

View File

@ -8,4 +8,5 @@ members = [
"plugin-audio", "plugin-audio",
"plugin-network", "plugin-network",
"plugin-window", "plugin-window",
"plugin-powermenu",
] ]

View File

@ -0,0 +1,12 @@
[package]
name = "powermenu"
version = "0.0.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
clap = { version = "4.3.21", features = ["derive"] }
rmenu-plugin = { version = "0.0.1", path = "../rmenu-plugin" }
serde_json = "1.0.105"
tempfile = "3.7.1"

View File

@ -0,0 +1,28 @@
.navbar {
width: 0;
height: 0;
}
.results {
margin: 0;
display: flex;
justify-content: center;
align-items: center;
}
.result {
display: flex;
flex-direction: column;
font-size: 30px;
width: 10rem;
}
.icon {
width: 100%;
font-size: 6rem;
}
.entry {
font-size: 1rem;
}

View File

@ -0,0 +1,107 @@
///! Functions to Build PowerMenu Actions
use std::collections::BTreeMap;
use std::env;
use std::io::Write;
use std::process;
use rmenu_plugin::{Action, Entry};
use tempfile::NamedTempFile;
use crate::Command;
//TODO: dynamically determine actions based on OS/Desktop/etc...
/// Ordered Map of Configured Actions
pub type Actions = BTreeMap<Command, Entry>;
/// Generate Confirmation for Specific Command
fn build_confirm(command: Command, actions: &Actions) -> Vec<Entry> {
let entry = actions.get(&command).expect("Invalid Command");
let cancel = format!("echo '{command} Cancelled'");
vec![
Entry {
name: "Cancel".to_owned(),
actions: vec![Action::new(&cancel)],
comment: None,
icon: None,
icon_alt: Some("".to_owned()),
},
Entry {
name: "Confirm".to_owned(),
actions: entry.actions.to_owned(),
comment: None,
icon: None,
icon_alt: Some("".to_owned()),
},
]
}
/// Generate Confirm Actions and Run Rmenu
pub fn confirm(command: Command, actions: &Actions) {
let rmenu = env::var("RMENU").unwrap_or_else(|_| "rmenu".to_owned());
let entries = build_confirm(command, actions);
// write to temporary file
let mut f = NamedTempFile::new().expect("Failed to Open Temporary File");
for entry in entries {
let json = serde_json::to_string(&entry).expect("Failed Serde Serialize");
write!(f, "{json}\n").expect("Failed Write");
}
// run command to read from temporary file
let path = f.path().to_str().expect("Invalid Temporary File Path");
let mut command = process::Command::new(rmenu)
.args(["-i", path])
.spawn()
.expect("Command Spawn Failed");
let status = command.wait().expect("Command Wait Failed");
if !status.success() {
panic!("Command Failed: {status:?}");
}
}
/// Calculate and Generate PowerMenu Actions
pub fn list_actions() -> Actions {
let mut actions = BTreeMap::new();
actions.extend(vec![
(
Command::Shutdown,
Entry {
name: "Shut Down".to_owned(),
actions: vec![Action::new("systemctl poweroff")],
comment: None,
icon: None,
icon_alt: Some("".to_owned()),
},
),
(
Command::Reboot,
Entry {
name: "Reboot".to_owned(),
actions: vec![Action::new("systemctl reboot")],
comment: None,
icon: None,
icon_alt: Some("".to_owned()),
},
),
(
Command::Suspend,
Entry {
name: "Suspend".to_owned(),
actions: vec![Action::new("systemctl suspend")],
comment: None,
icon: None,
icon_alt: Some("".to_owned()),
},
),
(
Command::Logout,
Entry {
name: "Log Out".to_owned(),
actions: vec![Action::new("sway exit")],
comment: None,
icon: None,
icon_alt: Some("".to_owned()),
},
),
]);
actions
}

View File

@ -0,0 +1,57 @@
mod action;
use std::fmt::Display;
use clap::{Parser, Subcommand};
use rmenu_plugin::{self_exe, Method};
#[derive(Debug, PartialEq, PartialOrd, Eq, Ord, Subcommand)]
pub enum Command {
ListActions { no_confirm: bool },
Shutdown,
Reboot,
Suspend,
Logout,
}
impl Display for Command {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Command::ListActions { .. } => write!(f, "list-actions"),
Command::Shutdown => write!(f, "shutdown"),
Command::Reboot => write!(f, "reboot"),
Command::Suspend => write!(f, "suspend"),
Command::Logout => write!(f, "logout"),
}
}
}
#[derive(Debug, Parser)]
struct Cli {
#[clap(subcommand)]
command: Option<Command>,
}
fn main() {
let cli = Cli::parse();
let exe = self_exe();
let actions = action::list_actions();
let command = cli
.command
.unwrap_or(Command::ListActions { no_confirm: false });
match command {
Command::ListActions { no_confirm } => {
for (command, mut entry) in actions {
if !no_confirm {
let exec = format!("{exe} {command}");
entry.actions[0].exec = Method::Run(exec);
}
println!("{}", serde_json::to_string(&entry).unwrap());
}
}
command => {
action::confirm(command, &actions);
}
}
}

View File

@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, PartialEq, Serialize, Deserialize)] #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum Method { pub enum Method {
Terminal(String), Terminal(String),
@ -17,7 +17,7 @@ impl Method {
} }
} }
#[derive(Debug, PartialEq, Serialize, Deserialize)] #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
pub struct Action { pub struct Action {
pub name: String, pub name: String,
pub exec: Method, pub exec: Method,
@ -41,12 +41,13 @@ impl Action {
} }
} }
#[derive(Debug, PartialEq, Serialize, Deserialize)] #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
pub struct Entry { pub struct Entry {
pub name: String, pub name: String,
pub actions: Vec<Action>, pub actions: Vec<Action>,
pub comment: Option<String>, pub comment: Option<String>,
pub icon: Option<String>, pub icon: Option<String>,
pub icon_alt: Option<String>,
} }
impl Entry { impl Entry {
@ -56,6 +57,7 @@ impl Entry {
actions: vec![Action::new(action)], actions: vec![Action::new(action)],
comment: comment.map(|c| c.to_owned()), comment: comment.map(|c| c.to_owned()),
icon: Default::default(), icon: Default::default(),
icon_alt: Default::default(),
} }
} }
@ -65,6 +67,17 @@ impl Entry {
actions: vec![Action::echo(echo)], actions: vec![Action::echo(echo)],
comment: comment.map(|c| c.to_owned()), comment: comment.map(|c| c.to_owned()),
icon: Default::default(), icon: Default::default(),
icon_alt: Default::default(),
} }
} }
} }
/// Retrieve EXE of Self
#[inline]
pub fn self_exe() -> String {
std::env::current_exe()
.expect("Cannot Find EXE of Self")
.to_str()
.unwrap()
.to_string()
}

View File

@ -42,7 +42,7 @@ plugins:
keybinds: keybinds:
exec: ["Enter"] exec: ["Enter"]
exit: ["Escape"] exit: ["Escape"]
move_up: ["Arrow-Up", "Shift+Tab"] move_next: ["Arrow-Down", "Tab"]
move_down: ["Arrow-Down", "Tab"] move_prev: ["Arrow-Up", "Shift+Tab"]
open_menu: ["Arrow-Right"] open_menu: ["Arrow-Right"]
close_menu: ["Arrow-Left"] close_menu: ["Arrow-Left"]

View File

@ -8,6 +8,12 @@ body > div {
overflow: hidden; overflow: hidden;
} }
html,
body,
.content {
margin: 0;
}
.navbar { .navbar {
top: 0; top: 0;
left: 0; left: 0;
@ -28,8 +34,12 @@ body > div {
/* Navigation */ /* Navigation */
#search:invalid {
border: 1px solid red;
}
input { input {
width: 100%; width: -webkit-fill-available;
height: 5vw; height: 5vw;
border: none; border: none;
outline: none; outline: none;
@ -49,24 +59,24 @@ input {
margin: 2px 5px; margin: 2px 5px;
} }
.result > .icon { .icon {
width: 4%; width: 4%;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
.result > .icon > img { img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
.result > .name { .name {
width: 30%; width: 30%;
} }
.result > .comment { .comment {
flex: 1; flex: 1;
} }

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

@ -0,0 +1,360 @@
use std::fs::File;
use std::io::{BufRead, BufReader, Read};
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;
use thiserror::Error;
use crate::config::{Config, Keybind};
use crate::{DEFAULT_CONFIG, DEFAULT_CSS};
/// 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)]
config: Option<String>,
/// Override base css styling
#[arg(long, default_value_t=String::from(DEFAULT_CSS))]
css: String,
/// Include additional css settings for themeing
#[arg(long)]
theme: Option<String>,
// root config settings
/// Override terminal command
#[arg(long)]
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>,
// 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>>,
//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("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>;
type MaybeEntry = Result<Entry>;
macro_rules! cli_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();
}
};
}
impl Args {
/// Load Configuration File and Update w/ Argument Overrides
pub fn get_config(&self) -> Result<Config> {
// read configuration
let path = self
.config
.as_ref()
.map(|v| v.as_str())
.unwrap_or(DEFAULT_CONFIG);
let path = shellexpand::tilde(path).to_string();
let mut 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())
}
}?;
// 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);
// override search settings
cli_replace!(config.search.restrict, self.search_restrict);
cli_replace!(config.search.min_length, self.search_min_length);
cli_replace!(config.search.max_length, self.search_max_length);
cli_replace!(config.search.use_regex, self.search_regex, true);
cli_replace!(config.search.ignore_case, self.ignore_case, true);
cli_replace!(config.search.placeholder, self.placeholder);
// override keybind settings
cli_replace!(config.keybinds.exec, self.key_exec, true);
cli_replace!(config.keybinds.exit, self.key_exit, true);
cli_replace!(config.keybinds.move_next, self.key_move_next, true);
cli_replace!(config.keybinds.move_prev, self.key_move_prev, true);
cli_replace!(config.keybinds.open_menu, self.key_open_menu, true);
cli_replace!(config.keybinds.close_menu, self.key_close_menu, true);
// override window settings
cli_replace!(config.window.title, self.title, true);
cli_replace!(config.window.size.width, self.width, true);
cli_replace!(config.window.size.height, self.height, true);
cli_replace!(config.window.position.x, self.xpos, true);
cli_replace!(config.window.position.y, self.ypos, true);
cli_replace!(config.window.focus, self.focus, true);
cli_replace!(config.window.decorate, self.decorate, true);
cli_replace!(config.window.transparent, self.transparent, true);
cli_replace!(config.window.always_top, self.always_top, true);
cli_replace!(config.window.fullscreen, self.fullscreen);
Ok(config)
}
/// Load CSS or Default
pub fn get_css(&self) -> String {
let path = shellexpand::tilde(&self.css).to_string();
match read_to_string(&path) {
Ok(css) => css,
Err(err) => {
log::error!("Failed to load CSS: {err:?}");
String::new()
}
}
}
/// Load CSS Theme or Default
pub fn get_theme(&self) -> String {
if let Some(theme) = self.theme.as_ref() {
let path = shellexpand::tilde(&theme).to_string();
match read_to_string(&path) {
Ok(theme) => return theme,
Err(err) => log::error!("Failed to load Theme: {err:?}"),
}
}
String::new()
}
/// Read Entries Contained within the Given Reader
fn read_entries<T: Read>(&self, reader: BufReader<T>) -> impl Iterator<Item = MaybeEntry> {
let format = self.format.clone();
reader
.lines()
.filter_map(|l| l.ok())
.map(move |l| match format {
Format::Json => serde_json::from_str(&l).map_err(|e| RMenuError::InvalidJson(e)),
Format::DMenu => Ok(Entry::echo(l.trim(), None)),
})
}
/// Read Entries from a Configured Input
fn load_input(&self, input: &str) -> 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![];
for entry in self.read_entries(reader) {
entries.push(entry?);
}
Ok(entries)
}
/// Read Entries from a Plugin Source
fn load_plugins(&self, config: &mut Config) -> Result<Vec<Entry>> {
let mut entries = vec![];
for name in self.run.iter() {
// retrieve plugin configuration
log::info!("running plugin: {name:?}");
let plugin = config
.plugins
.get(name)
.ok_or_else(|| RMenuError::NoSuchPlugin(name.to_owned()))?;
// 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 and handle command entries
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))?;
let reader = BufReader::new(stdout);
for entry in self.read_entries(reader) {
entries.push(entry?);
}
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, &entries) {
Ok(_) => {}
Err(err) => log::error!("cache write error: {err:?}"),
}
}
Ok(entries)
}
/// Load Entries from Enabled/Configured Entry-Sources
pub fn get_entries(&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)?);
}
entries.extend(self.load_plugins(config)?);
Ok(entries)
}
}

View File

@ -5,7 +5,10 @@ use serde::{de::Error, Deserialize};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::str::FromStr; use std::str::FromStr;
use dioxus_desktop::tao::dpi::{LogicalPosition, LogicalSize}; use dioxus_desktop::tao::{
dpi::{LogicalPosition, LogicalSize},
window::Fullscreen,
};
// parse supported modifiers from string // parse supported modifiers from string
fn mod_from_str(s: &str) -> Option<Modifiers> { fn mod_from_str(s: &str) -> Option<Modifiers> {
@ -19,7 +22,7 @@ fn mod_from_str(s: &str) -> Option<Modifiers> {
} }
/// Single GUI Keybind for Configuration /// Single GUI Keybind for Configuration
#[derive(Debug, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct Keybind { pub struct Keybind {
pub mods: Modifiers, pub mods: Modifiers,
pub key: Code, pub key: Code,
@ -80,8 +83,8 @@ impl<'de> Deserialize<'de> for Keybind {
pub struct KeyConfig { pub struct KeyConfig {
pub exec: Vec<Keybind>, pub exec: Vec<Keybind>,
pub exit: Vec<Keybind>, pub exit: Vec<Keybind>,
pub move_up: Vec<Keybind>, pub move_next: Vec<Keybind>,
pub move_down: Vec<Keybind>, pub move_prev: Vec<Keybind>,
pub open_menu: Vec<Keybind>, pub open_menu: Vec<Keybind>,
pub close_menu: Vec<Keybind>, pub close_menu: Vec<Keybind>,
} }
@ -91,8 +94,8 @@ impl Default for KeyConfig {
return Self { return Self {
exec: vec![Keybind::new(Code::Enter)], exec: vec![Keybind::new(Code::Enter)],
exit: vec![Keybind::new(Code::Escape)], exit: vec![Keybind::new(Code::Escape)],
move_up: vec![Keybind::new(Code::ArrowUp)], move_next: vec![Keybind::new(Code::ArrowUp)],
move_down: vec![Keybind::new(Code::ArrowDown)], move_prev: vec![Keybind::new(Code::ArrowDown)],
open_menu: vec![], open_menu: vec![],
close_menu: vec![], close_menu: vec![],
}; };
@ -105,13 +108,26 @@ pub struct WindowConfig {
pub title: String, pub title: String,
pub size: LogicalSize<f64>, pub size: LogicalSize<f64>,
pub position: LogicalPosition<f64>, pub position: LogicalPosition<f64>,
#[serde(default = "_true")]
pub focus: bool, pub focus: bool,
pub decorate: bool, pub decorate: bool,
pub transparent: bool, pub transparent: bool,
#[serde(default = "_true")]
pub always_top: bool, pub always_top: bool,
pub fullscreen: Option<bool>,
pub dark_mode: 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 { impl Default for WindowConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
@ -125,6 +141,7 @@ impl Default for WindowConfig {
decorate: false, decorate: false,
transparent: false, transparent: false,
always_top: true, always_top: true,
fullscreen: None,
dark_mode: None, dark_mode: None,
} }
} }
@ -187,6 +204,32 @@ fn _true() -> bool {
true true
} }
#[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 /// Global RMenu Complete Configuration
#[derive(Debug, PartialEq, Deserialize)] #[derive(Debug, PartialEq, Deserialize)]
#[serde(default)] #[serde(default)]
@ -197,11 +240,7 @@ pub struct Config {
pub use_icons: bool, pub use_icons: bool,
#[serde(default = "_true")] #[serde(default = "_true")]
pub use_comments: bool, pub use_comments: bool,
#[serde(default = "_true")] pub search: SearchConfig,
pub search_regex: bool,
#[serde(default = "_true")]
pub ignore_case: bool,
pub placeholder: Option<String>,
pub plugins: BTreeMap<String, PluginConfig>, pub plugins: BTreeMap<String, PluginConfig>,
pub keybinds: KeyConfig, pub keybinds: KeyConfig,
pub window: WindowConfig, pub window: WindowConfig,
@ -215,9 +254,7 @@ impl Default for Config {
page_load: 0.8, page_load: 0.8,
use_icons: true, use_icons: true,
use_comments: true, use_comments: true,
search_regex: false, search: Default::default(),
ignore_case: true,
placeholder: Default::default(),
plugins: Default::default(), plugins: Default::default(),
keybinds: Default::default(), keybinds: Default::default(),
window: Default::default(), window: Default::default(),

View File

@ -1,5 +1,7 @@
//! RMENU GUI Implementation using Dioxus //! RMENU GUI Implementation using Dioxus
#![allow(non_snake_case)] #![allow(non_snake_case)]
use std::fmt::Display;
use dioxus::prelude::*; use dioxus::prelude::*;
use keyboard_types::{Code, Modifiers}; use keyboard_types::{Code, Modifiers};
use rmenu_plugin::Entry; use rmenu_plugin::Entry;
@ -26,6 +28,7 @@ pub fn run(app: App) {
.with_decorations(app.config.window.decorate) .with_decorations(app.config.window.decorate)
.with_transparent(app.config.window.transparent) .with_transparent(app.config.window.transparent)
.with_always_on_top(app.config.window.always_top) .with_always_on_top(app.config.window.always_top)
.with_fullscreen(app.config.window.get_fullscreen())
.with_theme(theme); .with_theme(theme);
let config = dioxus_desktop::Config::new().with_window(builder); let config = dioxus_desktop::Config::new().with_window(builder);
dioxus_desktop::launch_with_props(App, app, config); dioxus_desktop::launch_with_props(App, app, config);
@ -41,19 +44,28 @@ struct GEntry<'a> {
} }
#[inline] #[inline]
fn render_comment(comment: Option<&String>) -> String { fn render_comment(comment: Option<&String>) -> &str {
return comment.map(|s| s.as_str()).unwrap_or("").to_string(); comment.map(|s| s.as_str()).unwrap_or("")
} }
#[inline] #[inline]
fn render_image<'a, T>(cx: Scope<'a, T>, image: Option<&String>) -> Element<'a> { fn render_image<'a, T>(
cx: Scope<'a, T>,
image: Option<&String>,
alt: Option<&String>,
) -> Element<'a> {
if let Some(img) = image { if let Some(img) = image {
if img.ends_with(".svg") { if img.ends_with(".svg") {
if let Some(content) = crate::image::convert_svg(img.to_owned()) { if let Some(content) = crate::image::convert_svg(img.to_owned()) {
return cx.render(rsx! { img { class: "image", src: "{content}" } }); return cx.render(rsx! { img { class: "image", src: "{content}" } });
} }
} }
return cx.render(rsx! { img { class: "image", src: "{img}" } }); if crate::image::image_exists(img.to_owned()) {
return cx.render(rsx! { img { class: "image", src: "{img}" } });
}
}
if let Some(alt) = alt {
return cx.render(rsx! { div { class: "icon_alt", dangerous_inner_html: "{alt}" } });
} }
None None
} }
@ -95,7 +107,7 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> {
ondblclick: |_| cx.props.state.set_event(KeyEvent::Exec), ondblclick: |_| cx.props.state.set_event(KeyEvent::Exec),
div { div {
class: "action-name", class: "action-name",
"{action.name}" dangerous_inner_html: "{action.name}"
} }
div { div {
class: "action-comment", class: "action-comment",
@ -117,7 +129,7 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> {
cx.render(rsx! { cx.render(rsx! {
div { div {
class: "icon", class: "icon",
render_image(cx, cx.props.entry.icon.as_ref()) render_image(cx, cx.props.entry.icon.as_ref(), cx.props.entry.icon_alt.as_ref())
} }
}) })
} }
@ -125,17 +137,17 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> {
true => cx.render(rsx! { true => cx.render(rsx! {
div { div {
class: "name", class: "name",
"{cx.props.entry.name}" dangerous_inner_html: "{cx.props.entry.name}"
} }
div { div {
class: "comment", class: "comment",
render_comment(cx.props.entry.comment.as_ref()) dangerous_inner_html: render_comment(cx.props.entry.comment.as_ref())
} }
}), }),
false => cx.render(rsx! { false => cx.render(rsx! {
div { div {
class: "entry", class: "entry",
"{cx.props.entry.name}" dangerous_inner_html: "{cx.props.entry.name}"
} }
}) })
} }
@ -162,6 +174,12 @@ fn matches(bind: &Vec<Keybind>, mods: &Modifiers, key: &Code) -> bool {
bind.iter().any(|b| mods.contains(b.mods) && &b.key == key) 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 /// main application function/loop
fn App<'a>(cx: Scope<App>) -> Element { fn App<'a>(cx: Scope<App>) -> Element {
let mut state = AppState::new(cx, cx.props); let mut state = AppState::new(cx, cx.props);
@ -176,11 +194,8 @@ fn App<'a>(cx: Scope<App>) -> Element {
// generate state tracker instances // generate state tracker instances
let results = state.results(&cx.props.entries); let results = state.results(&cx.props.entries);
let s_updater = state.partial_copy();
let k_updater = state.partial_copy(); let k_updater = state.partial_copy();
let s_updater = state.partial_copy();
//TODO: consider implementing some sort of
// action channel reference to pass to keboard events
// build keyboard actions event handler // build keyboard actions event handler
let keybinds = &cx.props.config.keybinds; let keybinds = &cx.props.config.keybinds;
@ -191,9 +206,9 @@ fn App<'a>(cx: Scope<App>) -> Element {
k_updater.set_event(KeyEvent::Exec); k_updater.set_event(KeyEvent::Exec);
} else if matches(&keybinds.exit, &mods, &code) { } else if matches(&keybinds.exit, &mods, &code) {
k_updater.set_event(KeyEvent::Exit); k_updater.set_event(KeyEvent::Exit);
} else if matches(&keybinds.move_up, &mods, &code) { } else if matches(&keybinds.move_prev, &mods, &code) {
k_updater.set_event(KeyEvent::ShiftUp); k_updater.set_event(KeyEvent::ShiftUp);
} else if matches(&keybinds.move_down, &mods, &code) { } else if matches(&keybinds.move_next, &mods, &code) {
k_updater.set_event(KeyEvent::ShiftDown); k_updater.set_event(KeyEvent::ShiftDown);
} else if matches(&keybinds.open_menu, &mods, &code) { } else if matches(&keybinds.open_menu, &mods, &code) {
k_updater.set_event(KeyEvent::OpenMenu); k_updater.set_event(KeyEvent::OpenMenu);
@ -219,29 +234,46 @@ fn App<'a>(cx: Scope<App>) -> Element {
}) })
}); });
// retreive placeholder // get input settings
let placeholder = cx let minlen = get_str(cx.props.config.search.min_length.as_ref());
.props let maxlen = get_str(cx.props.config.search.max_length.as_ref());
.config let placeholder = get_str(cx.props.config.search.placeholder.as_ref());
.placeholder
.as_ref()
.map(|s| s.to_string())
.unwrap_or_else(|| "".to_owned());
// complete final rendering // complete final rendering
cx.render(rsx! { cx.render(rsx! {
style { DEFAULT_CSS_CONTENT } style { DEFAULT_CSS_CONTENT }
style { "{cx.props.css}" } style { "{cx.props.css}" }
style { "{cx.props.theme}" }
div { div {
// onclick: |_| focus(cx), id: "content",
onkeydown: keyboard_controls, class: "content",
div { div {
id: "navbar",
class: "navbar", class: "navbar",
input { match cx.props.config.search.restrict.as_ref() {
id: "search", Some(pattern) => cx.render(rsx! {
value: "{search}", input {
placeholder: "{placeholder}", id: "search",
oninput: move |evt| s_updater.set_search(cx, evt.value.clone()), 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 { div {

View File

@ -75,3 +75,8 @@ pub fn convert_svg(path: String) -> Option<String> {
} }
Some(new_path.to_str()?.to_string()) Some(new_path.to_str()?.to_string())
} }
#[cached]
pub fn image_exists(path: String) -> bool {
PathBuf::from(path).exists()
}

View File

@ -1,11 +1,5 @@
use std::collections::VecDeque;
use std::fmt::Display;
use std::fs::{read_to_string, File};
use std::io::{self, prelude::*, BufReader};
use std::process::{Command, ExitStatus, Stdio};
use std::str::FromStr;
mod cache; mod cache;
mod cli;
mod config; mod config;
mod exec; mod exec;
mod gui; mod gui;
@ -14,56 +8,13 @@ mod search;
mod state; mod state;
use clap::Parser; use clap::Parser;
use rmenu_plugin::Entry; use rmenu_plugin::{self_exe, Entry};
use thiserror::Error;
static CONFIG_DIR: &'static str = "~/.config/rmenu/"; static CONFIG_DIR: &'static str = "~/.config/rmenu/";
static DEFAULT_CSS: &'static str = "~/.config/rmenu/style.css"; static DEFAULT_CSS: &'static str = "~/.config/rmenu/style.css";
static DEFAULT_CONFIG: &'static str = "~/.config/rmenu/config.yaml"; static DEFAULT_CONFIG: &'static str = "~/.config/rmenu/config.yaml";
static DEFAULT_CSS_CONTENT: &'static str = include_str!("../public/default.css"); static DEFAULT_CSS_CONTENT: &'static str = include_str!("../public/default.css");
#[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) -> 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()),
}
}
}
#[derive(Error, Debug)]
pub enum RMenuError {
#[error("$HOME not found")]
HomeNotFound,
#[error("Invalid Config")]
InvalidConfig(#[from] serde_yaml::Error),
#[error("File Error")]
FileError(#[from] io::Error),
#[error("No Such Plugin")]
NoSuchPlugin(String),
#[error("Invalid Plugin Specified")]
InvalidPlugin(String),
#[error("Command Runtime Exception")]
CommandError(Vec<String>, Option<ExitStatus>),
#[error("Invalid JSON Entry Object")]
InvalidJson(#[from] serde_json::Error),
}
/// Application State for GUI /// Application State for GUI
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub struct App { pub struct App {
@ -71,195 +22,53 @@ pub struct App {
name: String, name: String,
entries: Vec<Entry>, entries: Vec<Entry>,
config: config::Config, config: config::Config,
theme: String,
} }
/// Rofi Clone (Built with Rust) //TODO: how should scripting work?
#[derive(Parser, Debug)] // - need a better mechanism for rmenu and another executable to go back and forth
#[command(author, version, about, long_about = None)] // - need some way to preserve settings between executions of rmenu
#[command(propagate_version = true)] // - need some way for plugins to customize configuration according to preference
pub struct Args {
#[arg(short, long, default_value_t=String::from("-"))]
input: String,
#[arg(short, long, default_value_t=Format::Json)]
format: Format,
#[arg(short, long)]
run: Vec<String>,
#[arg(long)]
regex: Option<bool>,
#[arg(short, long)]
config: Option<String>,
#[arg(long)]
css: Option<String>,
#[arg(short, long)]
placehold: Option<String>,
}
impl Args { fn main() -> cli::Result<()> {
/// Load Config based on CLI Settings // export self to environment for other scripts
fn config(&self) -> Result<config::Config, RMenuError> { let exe = self_exe();
let path = match &self.config { std::env::set_var("RMENU", exe);
Some(path) => path.to_owned(),
None => shellexpand::tilde(DEFAULT_CONFIG).to_string(),
};
log::debug!("loading config from {path:?}");
let cfg = match read_to_string(path) {
Ok(cfg) => cfg,
Err(err) => {
log::error!("failed to load config: {err:?}");
return Ok(config::Config::default());
}
};
serde_yaml::from_str(&cfg).map_err(|e| RMenuError::InvalidConfig(e))
}
/// Read single entry from incoming line object
fn readentry(&self, cfg: &config::Config, line: &str) -> Result<Entry, RMenuError> {
let mut entry = match self.format {
Format::Json => serde_json::from_str::<Entry>(line)?,
Format::DMenu => Entry::echo(line.trim(), None),
};
if !cfg.use_icons {
entry.icon = None;
}
Ok(entry)
}
/// Load Entries From Input (Stdin by Default)
fn load_default(&self, cfg: &config::Config) -> Result<Vec<Entry>, RMenuError> {
let fpath = match self.input.as_str() {
"-" => "/dev/stdin",
_ => &self.input,
};
log::info!("reading from {fpath:?}");
let file = File::open(fpath).map_err(|e| RMenuError::FileError(e))?;
let reader = BufReader::new(file);
let mut entries = vec![];
for line in reader.lines() {
let entry = self.readentry(cfg, &line?)?;
entries.push(entry);
}
Ok(entries)
}
/// Load Entries From Specified Sources
fn load_sources(&self, cfg: &mut config::Config) -> Result<Vec<Entry>, RMenuError> {
log::debug!("config: {cfg:?}");
// execute commands to get a list of entries
let mut entries = vec![];
for name in self.run.iter() {
log::debug!("running plugin: {name}");
// retrieve plugin command arguments
let plugin = cfg
.plugins
.get(name)
.ok_or_else(|| RMenuError::NoSuchPlugin(name.to_owned()))?;
// attempt to read cache rather than run command
match cache::read_cache(name, plugin) {
Ok(cached) => {
entries.extend(cached);
continue;
}
Err(err) => log::error!("cache read error: {err:?}"),
}
// build command
let mut cmdargs: VecDeque<String> = plugin
.exec
.iter()
.map(|arg| shellexpand::tilde(arg).to_string())
.collect();
let main = cmdargs
.pop_front()
.ok_or_else(|| RMenuError::InvalidPlugin(name.to_owned()))?;
let mut cmd = Command::new(main);
for arg in cmdargs.iter() {
cmd.arg(arg);
}
// spawn command
let mut proc = cmd.stdout(Stdio::piped()).spawn()?;
let stdout = proc
.stdout
.as_mut()
.ok_or_else(|| RMenuError::CommandError(plugin.exec.clone().into(), None))?;
let reader = BufReader::new(stdout);
// read output line by line and parse content
for line in reader.lines() {
let entry = self.readentry(cfg, &line?)?;
entries.push(entry);
}
// check status of command on exit
let status = proc.wait()?;
if !status.success() {
return Err(RMenuError::CommandError(
plugin.exec.clone().into(),
Some(status.clone()),
));
}
// update placeholder if empty
if cfg.placeholder.is_none() {
cfg.placeholder = plugin.placeholder.clone();
}
// write cache for entries collected
match cache::write_cache(name, plugin, &entries) {
Ok(_) => {}
Err(err) => log::error!("cache write error: {err:?}"),
};
}
Ok(entries)
}
/// Load Application
pub fn parse_app() -> Result<App, RMenuError> {
let args = Self::parse();
let mut config = args.config()?;
// load css files from settings
let csspath = args.css.clone().unwrap_or_else(|| DEFAULT_CSS.to_owned());
let csspath = shellexpand::tilde(&csspath).to_string();
let css = match read_to_string(csspath) {
Ok(css) => css,
Err(err) => {
log::error!("failed to load css: {err:?}");
"".to_owned()
}
};
// load entries from configured sources
let entries = match args.run.len() > 0 {
true => args.load_sources(&mut config)?,
false => args.load_default(&config)?,
};
// update configuration based on cli
config.use_icons = config.use_icons && entries.iter().any(|e| e.icon.is_some());
config.use_comments = config.use_icons && entries.iter().any(|e| e.comment.is_some());
config.search_regex = args.regex.unwrap_or(config.search_regex);
if args.placehold.is_some() {
config.placeholder = args.placehold.clone();
};
// generate app object
return Ok(App {
css,
name: "rmenu".to_owned(),
entries,
config,
});
}
}
//TODO: improve search w/ modes?
//TODO: improve looks and css
fn main() -> Result<(), RMenuError> {
// enable log and set default level // enable log and set default level
if std::env::var("RUST_LOG").is_err() { if std::env::var("RUST_LOG").is_err() {
std::env::set_var("RUST_LOG", "info"); std::env::set_var("RUST_LOG", "info");
} }
env_logger::init(); env_logger::init();
// parse cli / config / application-settings
let app = Args::parse_app()?; // parse cli and retrieve values for app
// change directory to configuration dir let cli = cli::Args::parse();
let mut config = cli.get_config()?;
let css = cli.get_css();
let theme = cli.get_theme();
let entries = cli.get_entries(&mut config)?;
// update config based on entries
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());
// change directory to config folder
let cfgdir = shellexpand::tilde(CONFIG_DIR).to_string(); let cfgdir = shellexpand::tilde(CONFIG_DIR).to_string();
if let Err(err) = std::env::set_current_dir(&cfgdir) { if let Err(err) = std::env::set_current_dir(&cfgdir) {
log::error!("failed to change directory: {err:?}"); log::error!("failed to change directory: {err:?}");
} }
// run gui
gui::run(app); // genrate app context and run gui
gui::run(App {
name: "rmenu".to_owned(),
css,
entries,
config,
theme,
});
Ok(()) Ok(())
} }

View File

@ -8,9 +8,9 @@ use crate::config::Config;
/// Configurtaion Settings and Search-String /// Configurtaion Settings and Search-String
pub fn new_searchfn(cfg: &Config, search: &str) -> Box<dyn Fn(&Entry) -> bool> { pub fn new_searchfn(cfg: &Config, search: &str) -> Box<dyn Fn(&Entry) -> bool> {
// build regex search expression // build regex search expression
if cfg.search_regex { if cfg.search.use_regex {
let rgx = RegexBuilder::new(search) let rgx = RegexBuilder::new(search)
.case_insensitive(cfg.ignore_case) .case_insensitive(cfg.search.ignore_case)
.build(); .build();
let Ok(regex) = rgx else { let Ok(regex) = rgx else {
return Box::new(|_| false); return Box::new(|_| false);
@ -26,7 +26,7 @@ pub fn new_searchfn(cfg: &Config, search: &str) -> Box<dyn Fn(&Entry) -> bool> {
}); });
} }
// build case-insensitive search expression // build case-insensitive search expression
if cfg.ignore_case { if cfg.search.ignore_case {
let matchstr = search.to_lowercase(); let matchstr = search.to_lowercase();
return Box::new(move |entry: &Entry| { return Box::new(move |entry: &Entry| {
if entry.name.to_lowercase().contains(&matchstr) { if entry.name.to_lowercase().contains(&matchstr) {

View File

@ -1,4 +1,5 @@
use dioxus::prelude::{use_eval, use_ref, Scope, UseRef}; use dioxus::prelude::{use_eval, use_ref, Scope, UseRef};
use regex::Regex;
use rmenu_plugin::Entry; use rmenu_plugin::Entry;
use crate::config::Config; use crate::config::Config;
@ -29,6 +30,7 @@ pub struct InnerState {
page: usize, page: usize,
search: String, search: String,
event: Option<KeyEvent>, event: Option<KeyEvent>,
search_regex: Option<Regex>,
} }
impl InnerState { impl InnerState {
@ -83,6 +85,21 @@ impl<'a> AppState<'a> {
page: 0, page: 0,
search: "".to_string(), search: "".to_string(),
event: None, 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, app,
results: vec![], results: vec![],
@ -184,6 +201,27 @@ impl<'a> AppState<'a> {
/// Update Search and Reset Position /// Update Search and Reset Position
pub fn set_search(&self, cx: Scope<'_, App>, search: String) { 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| { self.state.with_mut(|s| {
s.pos = 0; s.pos = 0;
s.subpos = 0; s.subpos = 0;