diff --git a/Cargo.toml b/Cargo.toml index 317cfd6..1295ac6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,4 +8,5 @@ members = [ "plugin-audio", "plugin-network", "plugin-window", + "plugin-powermenu", ] diff --git a/plugin-powermenu/Cargo.toml b/plugin-powermenu/Cargo.toml new file mode 100644 index 0000000..b3cab19 --- /dev/null +++ b/plugin-powermenu/Cargo.toml @@ -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" diff --git a/plugin-powermenu/public/powermenu.css b/plugin-powermenu/public/powermenu.css new file mode 100644 index 0000000..c56af5a --- /dev/null +++ b/plugin-powermenu/public/powermenu.css @@ -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; +} diff --git a/plugin-powermenu/src/action.rs b/plugin-powermenu/src/action.rs new file mode 100644 index 0000000..db8b182 --- /dev/null +++ b/plugin-powermenu/src/action.rs @@ -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; + +/// Generate Confirmation for Specific Command +fn build_confirm(command: Command, actions: &Actions) -> Vec { + 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 +} diff --git a/plugin-powermenu/src/main.rs b/plugin-powermenu/src/main.rs new file mode 100644 index 0000000..6b97f71 --- /dev/null +++ b/plugin-powermenu/src/main.rs @@ -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, +} + +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); + } + } +} diff --git a/rmenu-plugin/src/lib.rs b/rmenu-plugin/src/lib.rs index afa8b77..a1c9782 100644 --- a/rmenu-plugin/src/lib.rs +++ b/rmenu-plugin/src/lib.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Method { Terminal(String), @@ -17,7 +17,7 @@ impl Method { } } -#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] pub struct Action { pub name: String, pub exec: Method, @@ -41,12 +41,13 @@ impl Action { } } -#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] pub struct Entry { pub name: String, pub actions: Vec, pub comment: Option, pub icon: Option, + pub icon_alt: Option, } impl Entry { @@ -56,6 +57,7 @@ impl Entry { actions: vec![Action::new(action)], comment: comment.map(|c| c.to_owned()), icon: Default::default(), + icon_alt: Default::default(), } } @@ -65,6 +67,17 @@ impl Entry { actions: vec![Action::echo(echo)], comment: comment.map(|c| c.to_owned()), 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() +} diff --git a/rmenu/public/config.yaml b/rmenu/public/config.yaml index 5a74bef..7714161 100644 --- a/rmenu/public/config.yaml +++ b/rmenu/public/config.yaml @@ -42,7 +42,7 @@ plugins: keybinds: exec: ["Enter"] exit: ["Escape"] - move_up: ["Arrow-Up", "Shift+Tab"] - move_down: ["Arrow-Down", "Tab"] + move_next: ["Arrow-Down", "Tab"] + move_prev: ["Arrow-Up", "Shift+Tab"] open_menu: ["Arrow-Right"] close_menu: ["Arrow-Left"] diff --git a/rmenu/public/default.css b/rmenu/public/default.css index 4c72c8f..2031940 100644 --- a/rmenu/public/default.css +++ b/rmenu/public/default.css @@ -8,6 +8,12 @@ body > div { overflow: hidden; } +html, +body, +.content { + margin: 0; +} + .navbar { top: 0; left: 0; @@ -28,8 +34,12 @@ body > div { /* Navigation */ +#search:invalid { + border: 1px solid red; +} + input { - width: 100%; + width: -webkit-fill-available; height: 5vw; border: none; outline: none; @@ -49,24 +59,24 @@ input { margin: 2px 5px; } -.result > .icon { +.icon { width: 4%; overflow: hidden; display: flex; justify-content: center; } -.result > .icon > img { +img { width: 100%; height: 100%; object-fit: cover; } -.result > .name { +.name { width: 30%; } -.result > .comment { +.comment { flex: 1; } diff --git a/rmenu/src/cli.rs b/rmenu/src/cli.rs new file mode 100644 index 0000000..202893f --- /dev/null +++ b/rmenu/src/cli.rs @@ -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 { + 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, + /// Format to accept entries + #[arg(short, long, default_value_t=Format::Json)] + format: Format, + /// Plugins to run + #[arg(short, long)] + run: Vec, + /// Override default configuration path + #[arg(short, long)] + config: Option, + /// 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, + + // root config settings + /// Override terminal command + #[arg(long)] + terminal: Option, + /// Number of results to include for each page + #[arg(long)] + page_size: Option, + /// Control ratio on when to load next page + #[arg(long)] + page_load: Option, + /// Force enable/disable comments + #[arg(long)] + use_icons: Option, + /// Force enable/disable comments + #[arg(long)] + use_comments: Option, + + // search settings + /// Enforce Regex Pattern on Search + #[arg(long)] + search_restrict: Option, + /// Enforce Minimum Length on Search + #[arg(long)] + search_min_length: Option, + /// Enforce Maximum Length on Search + #[arg(long)] + search_max_length: Option, + /// Force enable/disable regex in search + #[arg(long)] + search_regex: Option, + /// Force enable/disable ignore-case in search + #[arg(long)] + ignore_case: Option, + /// Override placeholder in searchbar + #[arg(short, long)] + placeholder: Option, + + // keybinding settings + /// Override exec keybind + #[arg(long)] + key_exec: Option>, + /// Override exit keybind + #[arg(long)] + key_exit: Option>, + /// Override move-next keybind + #[arg(long)] + key_move_next: Option>, + /// Override move-previous keybind + #[arg(long)] + key_move_prev: Option>, + /// Override open-menu keybind + #[arg(long)] + key_open_menu: Option>, + /// Override close-menu keybind + #[arg(long)] + key_close_menu: Option>, + + //window settings + /// Override Window Title + #[arg(long)] + title: Option, + /// Override Window Width + #[arg(long)] + width: Option, + /// Override Window Height + #[arg(long)] + height: Option, + /// Override Window X Position + #[arg(long)] + xpos: Option, + /// Override Window Y Position + #[arg(long)] + ypos: Option, + /// Override Window Focus on Startup + #[arg(long)] + focus: Option, + /// Override Window Decoration + #[arg(long)] + decorate: Option, + /// Override Window Transparent + #[arg(long)] + transparent: Option, + /// Override Window Always-On-Top + #[arg(long)] + always_top: Option, + /// Override Fullscreen Settings + #[arg(long)] + fullscreen: Option, +} + +#[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), + #[error("Invalid JSON Entry Object")] + InvalidJson(#[from] serde_json::Error), +} + +pub type Result = std::result::Result; +type MaybeEntry = Result; + +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 { + // 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(&self, reader: BufReader) -> impl Iterator { + 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> { + // 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> { + 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 = 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> { + // 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) + } +} diff --git a/rmenu/src/config.rs b/rmenu/src/config.rs index 001c3bd..c83c27f 100644 --- a/rmenu/src/config.rs +++ b/rmenu/src/config.rs @@ -5,7 +5,10 @@ use serde::{de::Error, Deserialize}; use std::collections::BTreeMap; 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 fn mod_from_str(s: &str) -> Option { @@ -19,7 +22,7 @@ fn mod_from_str(s: &str) -> Option { } /// Single GUI Keybind for Configuration -#[derive(Debug, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub struct Keybind { pub mods: Modifiers, pub key: Code, @@ -80,8 +83,8 @@ impl<'de> Deserialize<'de> for Keybind { pub struct KeyConfig { pub exec: Vec, pub exit: Vec, - pub move_up: Vec, - pub move_down: Vec, + pub move_next: Vec, + pub move_prev: Vec, pub open_menu: Vec, pub close_menu: Vec, } @@ -91,8 +94,8 @@ impl Default for KeyConfig { return Self { exec: vec![Keybind::new(Code::Enter)], exit: vec![Keybind::new(Code::Escape)], - move_up: vec![Keybind::new(Code::ArrowUp)], - move_down: vec![Keybind::new(Code::ArrowDown)], + move_next: vec![Keybind::new(Code::ArrowUp)], + move_prev: vec![Keybind::new(Code::ArrowDown)], open_menu: vec![], close_menu: vec![], }; @@ -105,13 +108,26 @@ pub struct WindowConfig { pub title: String, pub size: LogicalSize, pub position: LogicalPosition, + #[serde(default = "_true")] pub focus: bool, pub decorate: bool, pub transparent: bool, + #[serde(default = "_true")] pub always_top: bool, + pub fullscreen: Option, pub dark_mode: Option, } +impl WindowConfig { + /// Retrieve Desktop Compatabible Fullscreen Settings + pub fn get_fullscreen(&self) -> Option { + self.fullscreen.and_then(|fs| match fs { + true => Some(Fullscreen::Borderless(None)), + false => None, + }) + } +} + impl Default for WindowConfig { fn default() -> Self { Self { @@ -125,6 +141,7 @@ impl Default for WindowConfig { decorate: false, transparent: false, always_top: true, + fullscreen: None, dark_mode: None, } } @@ -187,6 +204,32 @@ fn _true() -> bool { true } +#[derive(Debug, PartialEq, Deserialize)] +#[serde(default)] +pub struct SearchConfig { + pub restrict: Option, + pub min_length: Option, + pub max_length: Option, + pub placeholder: Option, + #[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)] @@ -197,11 +240,7 @@ pub struct Config { pub use_icons: bool, #[serde(default = "_true")] pub use_comments: bool, - #[serde(default = "_true")] - pub search_regex: bool, - #[serde(default = "_true")] - pub ignore_case: bool, - pub placeholder: Option, + pub search: SearchConfig, pub plugins: BTreeMap, pub keybinds: KeyConfig, pub window: WindowConfig, @@ -215,9 +254,7 @@ impl Default for Config { page_load: 0.8, use_icons: true, use_comments: true, - search_regex: false, - ignore_case: true, - placeholder: Default::default(), + search: Default::default(), plugins: Default::default(), keybinds: Default::default(), window: Default::default(), diff --git a/rmenu/src/gui.rs b/rmenu/src/gui.rs index a47e45a..267a856 100644 --- a/rmenu/src/gui.rs +++ b/rmenu/src/gui.rs @@ -1,5 +1,7 @@ //! RMENU GUI Implementation using Dioxus #![allow(non_snake_case)] +use std::fmt::Display; + use dioxus::prelude::*; use keyboard_types::{Code, Modifiers}; use rmenu_plugin::Entry; @@ -26,6 +28,7 @@ pub fn run(app: App) { .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); @@ -41,19 +44,28 @@ struct GEntry<'a> { } #[inline] -fn render_comment(comment: Option<&String>) -> String { - return comment.map(|s| s.as_str()).unwrap_or("").to_string(); +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>) -> 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 img.ends_with(".svg") { if let Some(content) = crate::image::convert_svg(img.to_owned()) { return cx.render(rsx! { img { class: "image", src: "{content}" } }); } } - return cx.render(rsx! { img { class: "image", src: "{img}" } }); + 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 } @@ -95,7 +107,7 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { ondblclick: |_| cx.props.state.set_event(KeyEvent::Exec), div { class: "action-name", - "{action.name}" + dangerous_inner_html: "{action.name}" } div { class: "action-comment", @@ -117,7 +129,7 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { cx.render(rsx! { div { 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! { div { class: "name", - "{cx.props.entry.name}" + dangerous_inner_html: "{cx.props.entry.name}" } div { 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! { div { class: "entry", - "{cx.props.entry.name}" + dangerous_inner_html: "{cx.props.entry.name}" } }) } @@ -162,6 +174,12 @@ fn matches(bind: &Vec, 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(item: Option) -> String { + item.map(|i| i.to_string()).unwrap_or_else(String::new) +} + /// main application function/loop fn App<'a>(cx: Scope) -> Element { let mut state = AppState::new(cx, cx.props); @@ -176,11 +194,8 @@ fn App<'a>(cx: Scope) -> Element { // generate state tracker instances let results = state.results(&cx.props.entries); - let s_updater = state.partial_copy(); let k_updater = state.partial_copy(); - - //TODO: consider implementing some sort of - // action channel reference to pass to keboard events + let s_updater = state.partial_copy(); // build keyboard actions event handler let keybinds = &cx.props.config.keybinds; @@ -191,9 +206,9 @@ fn App<'a>(cx: Scope) -> Element { k_updater.set_event(KeyEvent::Exec); } else if matches(&keybinds.exit, &mods, &code) { k_updater.set_event(KeyEvent::Exit); - } else if matches(&keybinds.move_up, &mods, &code) { + } else if matches(&keybinds.move_prev, &mods, &code) { 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); } else if matches(&keybinds.open_menu, &mods, &code) { k_updater.set_event(KeyEvent::OpenMenu); @@ -219,29 +234,46 @@ fn App<'a>(cx: Scope) -> Element { }) }); - // retreive placeholder - let placeholder = cx - .props - .config - .placeholder - .as_ref() - .map(|s| s.to_string()) - .unwrap_or_else(|| "".to_owned()); + // 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.css}" } + style { "{cx.props.theme}" } div { - // onclick: |_| focus(cx), - onkeydown: keyboard_controls, + id: "content", + class: "content", div { + id: "navbar", class: "navbar", - input { - id: "search", - value: "{search}", - placeholder: "{placeholder}", - oninput: move |evt| s_updater.set_search(cx, evt.value.clone()), + 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 { diff --git a/rmenu/src/image.rs b/rmenu/src/image.rs index ff87319..a2388ca 100644 --- a/rmenu/src/image.rs +++ b/rmenu/src/image.rs @@ -75,3 +75,8 @@ pub fn convert_svg(path: String) -> Option { } Some(new_path.to_str()?.to_string()) } + +#[cached] +pub fn image_exists(path: String) -> bool { + PathBuf::from(path).exists() +} diff --git a/rmenu/src/main.rs b/rmenu/src/main.rs index 50708b5..121eaed 100644 --- a/rmenu/src/main.rs +++ b/rmenu/src/main.rs @@ -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 cli; mod config; mod exec; mod gui; @@ -14,56 +8,13 @@ mod search; mod state; use clap::Parser; -use rmenu_plugin::Entry; -use thiserror::Error; +use rmenu_plugin::{self_exe, Entry}; static CONFIG_DIR: &'static str = "~/.config/rmenu/"; static DEFAULT_CSS: &'static str = "~/.config/rmenu/style.css"; static DEFAULT_CONFIG: &'static str = "~/.config/rmenu/config.yaml"; static DEFAULT_CSS_CONTENT: &'static str = include_str!("../public/default.css"); -#[derive(Debug, Clone)] -pub enum Format { - Json, - 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 { - 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, Option), - #[error("Invalid JSON Entry Object")] - InvalidJson(#[from] serde_json::Error), -} - /// Application State for GUI #[derive(Debug, PartialEq)] pub struct App { @@ -71,195 +22,53 @@ pub struct App { name: String, entries: Vec, config: config::Config, + theme: String, } -/// Rofi Clone (Built with Rust) -#[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] -#[command(propagate_version = true)] -pub struct Args { - #[arg(short, long, default_value_t=String::from("-"))] - input: String, - #[arg(short, long, default_value_t=Format::Json)] - format: Format, - #[arg(short, long)] - run: Vec, - #[arg(long)] - regex: Option, - #[arg(short, long)] - config: Option, - #[arg(long)] - css: Option, - #[arg(short, long)] - placehold: Option, -} +//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 -impl Args { - /// Load Config based on CLI Settings - fn config(&self) -> Result { - let path = match &self.config { - Some(path) => path.to_owned(), - None => shellexpand::tilde(DEFAULT_CONFIG).to_string(), - }; - log::debug!("loading config from {path:?}"); - let cfg = match read_to_string(path) { - Ok(cfg) => cfg, - Err(err) => { - log::error!("failed to load config: {err:?}"); - return Ok(config::Config::default()); - } - }; - serde_yaml::from_str(&cfg).map_err(|e| RMenuError::InvalidConfig(e)) - } +fn main() -> cli::Result<()> { + // export self to environment for other scripts + let exe = self_exe(); + std::env::set_var("RMENU", exe); - /// Read single entry from incoming line object - fn readentry(&self, cfg: &config::Config, line: &str) -> Result { - let mut entry = match self.format { - Format::Json => serde_json::from_str::(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, 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, 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 = 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 { - 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 if std::env::var("RUST_LOG").is_err() { std::env::set_var("RUST_LOG", "info"); } env_logger::init(); - // parse cli / config / application-settings - let app = Args::parse_app()?; - // change directory to configuration dir + + // parse cli and retrieve values for app + 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(); if let Err(err) = std::env::set_current_dir(&cfgdir) { 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(()) } diff --git a/rmenu/src/search.rs b/rmenu/src/search.rs index 1ec0cef..d9b7191 100644 --- a/rmenu/src/search.rs +++ b/rmenu/src/search.rs @@ -8,9 +8,9 @@ use crate::config::Config; /// Configurtaion Settings and Search-String pub fn new_searchfn(cfg: &Config, search: &str) -> Box bool> { // build regex search expression - if cfg.search_regex { + if cfg.search.use_regex { let rgx = RegexBuilder::new(search) - .case_insensitive(cfg.ignore_case) + .case_insensitive(cfg.search.ignore_case) .build(); let Ok(regex) = rgx else { return Box::new(|_| false); @@ -26,7 +26,7 @@ pub fn new_searchfn(cfg: &Config, search: &str) -> Box bool> { }); } // build case-insensitive search expression - if cfg.ignore_case { + if cfg.search.ignore_case { let matchstr = search.to_lowercase(); return Box::new(move |entry: &Entry| { if entry.name.to_lowercase().contains(&matchstr) { diff --git a/rmenu/src/state.rs b/rmenu/src/state.rs index 62e6253..43d0ae1 100644 --- a/rmenu/src/state.rs +++ b/rmenu/src/state.rs @@ -1,4 +1,5 @@ use dioxus::prelude::{use_eval, use_ref, Scope, UseRef}; +use regex::Regex; use rmenu_plugin::Entry; use crate::config::Config; @@ -29,6 +30,7 @@ pub struct InnerState { page: usize, search: String, event: Option, + search_regex: Option, } impl InnerState { @@ -83,6 +85,21 @@ impl<'a> AppState<'a> { 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![], @@ -184,6 +201,27 @@ impl<'a> AppState<'a> { /// 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;