diff --git a/rmenu/Cargo.toml b/rmenu/Cargo.toml index 50bb7ce..6dbda0a 100644 --- a/rmenu/Cargo.toml +++ b/rmenu/Cargo.toml @@ -10,6 +10,7 @@ clap = { version = "4.3.15", features = ["derive"] } dioxus = "0.3.2" dioxus-desktop = "0.3.0" dirs = "5.0.1" +heck = "0.4.1" keyboard-types = "0.6.2" log = "0.4.19" regex = { version = "1.9.1", features = ["pattern"] } @@ -17,4 +18,6 @@ rmenu-plugin = { version = "0.0.0", path = "../rmenu-plugin" } serde = { version = "1.0.171", features = ["derive"] } serde_json = "1.0.103" serde_yaml = "0.9.24" +shell-words = "1.1.0" +shellexpand = "3.1.0" thiserror = "1.0.43" diff --git a/rmenu/src/config.rs b/rmenu/src/config.rs index e616d79..32fe84b 100644 --- a/rmenu/src/config.rs +++ b/rmenu/src/config.rs @@ -1,13 +1,143 @@ -use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, VecDeque}; +//! RMENU Configuration Implementations +use heck::AsPascalCase; +use keyboard_types::{Code, Modifiers}; +use serde::{de::Error, Deserialize}; +use std::collections::BTreeMap; +use std::str::FromStr; -#[derive(Debug, PartialEq, Serialize, Deserialize)] +use dioxus_desktop::tao::dpi::{LogicalPosition, LogicalSize}; + +// parse supported modifiers from string +fn mod_from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "alt" => Some(Modifiers::ALT), + "ctrl" => Some(Modifiers::CONTROL), + "shift" => Some(Modifiers::SHIFT), + "super" => Some(Modifiers::SUPER), + _ => None, + } +} + +#[derive(Debug, PartialEq)] +pub struct Keybind { + pub mods: Modifiers, + pub key: Code, +} + +impl Keybind { + fn new(key: Code) -> Self { + Self { + mods: Modifiers::empty(), + key, + } + } +} + +impl FromStr for Keybind { + type Err = String; + + fn from_str(s: &str) -> Result { + // parse modifiers/keys from string + let mut mods = vec![]; + let mut keys = vec![]; + for item in s.split("+") { + let camel = format!("{}", AsPascalCase(item)); + match Code::from_str(&camel) { + Ok(key) => keys.push(key), + Err(_) => match mod_from_str(item) { + Some(keymod) => mods.push(keymod), + None => return Err(format!("invalid key/modifier: {item}")), + }, + } + } + // generate final keybind + let kmod = mods.into_iter().fold(Modifiers::empty(), |m1, m2| m1 | m2); + match keys.len() { + 0 => Err(format!("no keys specified")), + 1 => Ok(Keybind { + mods: kmod, + key: keys.pop().unwrap(), + }), + _ => Err(format!("too many keys: {keys:?}")), + } + } +} + +impl<'de> Deserialize<'de> for Keybind { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s: &str = Deserialize::deserialize(deserializer)?; + Keybind::from_str(s).map_err(D::Error::custom) + } +} + +#[derive(Debug, PartialEq, Deserialize)] +pub struct KeyConfig { + pub exit: Vec, + pub move_up: Vec, + pub move_down: Vec, + #[serde(default)] + pub open_menu: Vec, + #[serde(default)] + pub close_menu: Vec, +} + +impl Default for KeyConfig { + fn default() -> Self { + return Self { + exit: vec![Keybind::new(Code::Escape)], + move_up: vec![Keybind::new(Code::ArrowUp)], + move_down: vec![Keybind::new(Code::ArrowDown)], + open_menu: vec![], + close_menu: vec![], + }; + } +} + +#[derive(Debug, PartialEq, Deserialize)] +pub struct WindowConfig { + pub title: String, + pub size: LogicalSize, + pub position: LogicalPosition, + pub focus: bool, + pub decorate: bool, + pub transparent: bool, + pub always_top: bool, + pub dark_mode: Option, +} + +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, + dark_mode: None, + } + } +} + +#[derive(Debug, PartialEq, Deserialize)] pub struct Config { pub css: Vec, pub use_icons: bool, pub search_regex: bool, pub ignore_case: bool, - pub plugins: BTreeMap>, + #[serde(default)] + pub plugins: BTreeMap>, + #[serde(default)] + pub keybinds: KeyConfig, + #[serde(default)] + pub window: WindowConfig, } impl Default for Config { @@ -18,6 +148,8 @@ impl Default for Config { search_regex: false, ignore_case: true, plugins: Default::default(), + keybinds: Default::default(), + window: Default::default(), } } } diff --git a/rmenu/src/exec.rs b/rmenu/src/exec.rs new file mode 100644 index 0000000..9defe1d --- /dev/null +++ b/rmenu/src/exec.rs @@ -0,0 +1,13 @@ +//! Execution Implementation for Entry Actions +use std::os::unix::process::CommandExt; +use std::process::Command; + +use rmenu_plugin::Action; + +pub fn execute(action: &Action) { + let args = match shell_words::split(&action.exec) { + Ok(args) => args, + Err(err) => panic!("{:?} invalid command {err}", action.exec), + }; + Command::new(&args[0]).args(&args[1..]).exec(); +} diff --git a/rmenu/src/gui.rs b/rmenu/src/gui.rs index 2106be6..7c8b5e0 100644 --- a/rmenu/src/gui.rs +++ b/rmenu/src/gui.rs @@ -1,15 +1,36 @@ +//! RMENU GUI Implementation using Dioxus #![allow(non_snake_case)] use dioxus::prelude::*; use keyboard_types::{Code, Modifiers}; use rmenu_plugin::Entry; -use crate::config::Config; +use crate::config::{Config, Keybind}; +use crate::exec::execute; use crate::search::new_searchfn; use crate::state::PosTracker; use crate::App; +/// spawn and run the app on the configured platform pub fn run(app: App) { - dioxus_desktop::launch_with_props(App, app, dioxus_desktop::Config::default()); + // customize window + let theme = match app.config.window.dark_mode { + Some(dark) => match dark { + true => Some(dioxus_desktop::tao::window::Theme::Dark), + false => Some(dioxus_desktop::tao::window::Theme::Light), + }, + None => None, + }; + let builder = dioxus_desktop::WindowBuilder::new() + .with_title(app.config.window.title.clone()) + .with_inner_size(app.config.window.size) + .with_position(app.config.window.position) + .with_focused(app.config.window.focus) + .with_decorations(app.config.window.decorate) + .with_transparent(app.config.window.transparent) + .with_always_on_top(app.config.window.always_top) + .with_theme(theme); + let config = dioxus_desktop::Config::new().with_window(builder); + dioxus_desktop::launch_with_props(App, app, config); } #[derive(PartialEq, Props)] @@ -21,6 +42,7 @@ struct GEntry<'a> { subpos: usize, } +/// 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; @@ -29,6 +51,10 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { 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 => "", @@ -65,7 +91,19 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { cx.render(rsx! { div { id: "result-{cx.props.index}", - class: "result {result_classes}", + class: "result {result_classes} {multi_classes}", + ondblclick: |_| { + let action = match cx.props.entry.actions.get(0) { + Some(action) => action, + None => { + let name = &cx.props.entry.name; + log::warn!("no action to execute on {:?}", name); + return; + } + }; + log::info!("executing: {:?}", action.exec); + execute(action); + }, if cx.props.config.use_icons { cx.render(rsx! { div { @@ -83,7 +121,7 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { div { class: "comment", if let Some(comment) = cx.props.entry.comment.as_ref() { - format!("- {comment}") + comment.to_string() } } } @@ -95,37 +133,54 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { }) } +#[inline] +fn focus(cx: Scope) { + let eval = dioxus_desktop::use_eval(cx); + let js = "document.getElementById(`search`).focus()"; + eval(js.to_owned()); +} + +/// check if the current inputs match any of the given keybindings +#[inline] +fn matches(bind: &Vec, mods: &Modifiers, key: &Code) -> bool { + bind.iter().any(|b| mods.contains(b.mods) && &b.key == key) +} + +/// main application function/loop fn App(cx: Scope) -> Element { + let quit = use_state(cx, || false); let search = use_state(cx, || "".to_string()); - // retrieve build results tracker + // handle exit check + if *quit.get() { + std::process::exit(0); + } + + // retrieve results build and build position-tracker let results = &cx.props.entries; let tracker = PosTracker::new(cx, results); let (pos, subpos) = tracker.position(); - println!("pos: {pos}, {subpos}"); + log::debug!("pos: {pos}, {subpos}"); // keyboard events - let eval = dioxus_desktop::use_eval(cx); - let change_evt = move |evt: KeyboardEvent| { - match evt.code() { - // modify position - Code::ArrowUp => tracker.shift_up(), - Code::ArrowDown => tracker.shift_down(), - Code::Tab => match evt.modifiers().contains(Modifiers::SHIFT) { - true => { - println!("close menu"); - tracker.close_menu() - } - false => { - println!("open menu!"); - tracker.open_menu() - } - }, - _ => println!("key: {:?}", evt.key()), + let keybinds = &cx.props.config.keybinds; + let keyboard_evt = move |evt: KeyboardEvent| { + let key = &evt.code(); + let mods = &evt.modifiers(); + log::debug!("key: {key:?} mods: {mods:?}"); + if matches(&keybinds.exit, mods, key) { + quit.set(true); + } else if matches(&keybinds.move_up, mods, key) { + tracker.shift_up(); + } else if matches(&keybinds.move_down, mods, key) { + tracker.shift_down(); + } else if matches(&keybinds.open_menu, mods, key) { + tracker.open_menu(); + } else if matches(&keybinds.close_menu, mods, key) { + tracker.close_menu(); } // always set focus back on input - let js = "document.getElementById(`search`).focus()"; - eval(js.to_owned()); + focus(cx); }; // pre-render results into elements @@ -150,7 +205,8 @@ fn App(cx: Scope) -> Element { cx.render(rsx! { style { "{cx.props.css}" } div { - onkeydown: change_evt, + onkeydown: keyboard_evt, + onclick: |_| focus(cx), input { id: "search", value: "{search}", diff --git a/rmenu/src/main.rs b/rmenu/src/main.rs index eb5b811..26b069e 100644 --- a/rmenu/src/main.rs +++ b/rmenu/src/main.rs @@ -1,3 +1,4 @@ +use std::collections::VecDeque; use std::fmt::Display; use std::fs::{read_to_string, File}; use std::io::{self, prelude::*, BufReader}; @@ -5,6 +6,7 @@ use std::process::{Command, ExitStatus, Stdio}; use std::str::FromStr; mod config; +mod exec; mod gui; mod search; mod state; @@ -148,7 +150,10 @@ impl Args { return Err(RMenuError::NoSuchPlugin(plugin.to_owned())); }; // build command - let mut cmdargs = args.clone(); + let mut cmdargs: VecDeque = args + .iter() + .map(|arg| shellexpand::tilde(arg).to_string()) + .collect(); let Some(main) = cmdargs.pop_front() else { return Err(RMenuError::InvalidPlugin(plugin.to_owned())); }; @@ -188,6 +193,7 @@ impl Args { config.css.extend(args.css.clone()); let mut css = vec![]; for path in config.css.iter() { + let path = shellexpand::tilde(path).to_string(); let src = read_to_string(path)?; css.push(src); } @@ -211,8 +217,7 @@ impl Args { //TODO: config // - default and cli accessable modules (instead of piped in) -// - allow/disable icons (also available via CLI) -// - custom keybindings (some available via CLI?) +// - should resolve arguments/paths with home expansion //TODO: add exit key (Esc by default?) - part of keybindings diff --git a/rmenu/src/search.rs b/rmenu/src/search.rs index ba84f27..d9e2504 100644 --- a/rmenu/src/search.rs +++ b/rmenu/src/search.rs @@ -1,3 +1,4 @@ +//! RMENU Entry Search Function Implementaton use regex::RegexBuilder; use rmenu_plugin::Entry; @@ -29,7 +30,7 @@ macro_rules! search { } /// Generate a new dynamic Search Function based on -/// Configurtaion Settigns and Search-String +/// Configurtaion Settings and Search-String pub fn new_searchfn(cfg: &Config, search: &str) -> Box bool> { if cfg.search_regex { let regex = RegexBuilder::new(search) diff --git a/rmenu/src/state.rs b/rmenu/src/state.rs index 6477eaa..c610683 100644 --- a/rmenu/src/state.rs +++ b/rmenu/src/state.rs @@ -1,15 +1,26 @@ -/// Application State Trackers and Utilities +//! GUI Application State Trackers and Utilities use dioxus::prelude::{use_state, Scope, UseState}; use rmenu_plugin::Entry; use crate::App; +#[derive(PartialEq)] pub struct PosTracker<'a> { pos: &'a UseState, subpos: &'a UseState, results: &'a Vec, } +impl<'a> Clone for PosTracker<'a> { + fn clone(&self) -> Self { + Self { + pos: self.pos, + subpos: self.subpos, + results: self.results, + } + } +} + impl<'a> PosTracker<'a> { pub fn new(cx: Scope<'a, App>, results: &'a Vec) -> Self { let pos = use_state(cx, || 0); @@ -60,7 +71,6 @@ impl<'a> PosTracker<'a> { let index = *self.pos.get(); let result = &self.results[index]; let subpos = *self.subpos.get(); - println!("modify subpos? {} {}", subpos, result.actions.len()); if subpos > 0 && subpos < result.actions.len() - 1 { self.subpos.modify(|v| v + 1); return;