feat: impl keybindings, window-settings, action-execution

This commit is contained in:
imgurbot12 2023-07-20 23:52:04 -07:00
parent 9b8d626c4d
commit 31989d4ee8
7 changed files with 256 additions and 36 deletions

View File

@ -10,6 +10,7 @@ clap = { version = "4.3.15", features = ["derive"] }
dioxus = "0.3.2" dioxus = "0.3.2"
dioxus-desktop = "0.3.0" dioxus-desktop = "0.3.0"
dirs = "5.0.1" dirs = "5.0.1"
heck = "0.4.1"
keyboard-types = "0.6.2" keyboard-types = "0.6.2"
log = "0.4.19" log = "0.4.19"
regex = { version = "1.9.1", features = ["pattern"] } 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 = { version = "1.0.171", features = ["derive"] }
serde_json = "1.0.103" serde_json = "1.0.103"
serde_yaml = "0.9.24" serde_yaml = "0.9.24"
shell-words = "1.1.0"
shellexpand = "3.1.0"
thiserror = "1.0.43" thiserror = "1.0.43"

View File

@ -1,13 +1,143 @@
use serde::{Deserialize, Serialize}; //! RMENU Configuration Implementations
use std::collections::{BTreeMap, VecDeque}; 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<Modifiers> {
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<Self, Self::Err> {
// 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<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s: &str = Deserialize::deserialize(deserializer)?;
Keybind::from_str(s).map_err(D::Error::custom)
}
}
#[derive(Debug, PartialEq, Deserialize)]
pub struct KeyConfig {
pub exit: Vec<Keybind>,
pub move_up: Vec<Keybind>,
pub move_down: Vec<Keybind>,
#[serde(default)]
pub open_menu: Vec<Keybind>,
#[serde(default)]
pub close_menu: Vec<Keybind>,
}
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<f64>,
pub position: LogicalPosition<f64>,
pub focus: bool,
pub decorate: bool,
pub transparent: bool,
pub always_top: bool,
pub dark_mode: Option<bool>,
}
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 struct Config {
pub css: Vec<String>, pub css: Vec<String>,
pub use_icons: bool, pub use_icons: bool,
pub search_regex: bool, pub search_regex: bool,
pub ignore_case: bool, pub ignore_case: bool,
pub plugins: BTreeMap<String, VecDeque<String>>, #[serde(default)]
pub plugins: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub keybinds: KeyConfig,
#[serde(default)]
pub window: WindowConfig,
} }
impl Default for Config { impl Default for Config {
@ -18,6 +148,8 @@ impl Default for Config {
search_regex: false, search_regex: false,
ignore_case: true, ignore_case: true,
plugins: Default::default(), plugins: Default::default(),
keybinds: Default::default(),
window: Default::default(),
} }
} }
} }

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

@ -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();
}

View File

@ -1,15 +1,36 @@
//! RMENU GUI Implementation using Dioxus
#![allow(non_snake_case)] #![allow(non_snake_case)]
use dioxus::prelude::*; use dioxus::prelude::*;
use keyboard_types::{Code, Modifiers}; use keyboard_types::{Code, Modifiers};
use rmenu_plugin::Entry; use rmenu_plugin::Entry;
use crate::config::Config; use crate::config::{Config, Keybind};
use crate::exec::execute;
use crate::search::new_searchfn; use crate::search::new_searchfn;
use crate::state::PosTracker; use crate::state::PosTracker;
use crate::App; use crate::App;
/// spawn and run the app on the configured platform
pub fn run(app: App) { 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)] #[derive(PartialEq, Props)]
@ -21,6 +42,7 @@ struct GEntry<'a> {
subpos: usize, subpos: usize,
} }
/// render a single result entry w/ the given information
fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> { fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> {
// build css classes for result and actions (if nessesary) // build css classes for result and actions (if nessesary)
let main_select = cx.props.index == cx.props.pos; 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", true => "active",
false => "", false => "",
}; };
let multi_classes = match cx.props.entry.actions.len() > 1 {
true => "submenu",
false => "",
};
let result_classes = match main_select && !action_select { let result_classes = match main_select && !action_select {
true => "selected", true => "selected",
false => "", false => "",
@ -65,7 +91,19 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> {
cx.render(rsx! { cx.render(rsx! {
div { div {
id: "result-{cx.props.index}", 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 { if cx.props.config.use_icons {
cx.render(rsx! { cx.render(rsx! {
div { div {
@ -83,7 +121,7 @@ fn TableEntry<'a>(cx: Scope<'a, GEntry<'a>>) -> Element<'a> {
div { div {
class: "comment", class: "comment",
if let Some(comment) = cx.props.entry.comment.as_ref() { 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<T>(cx: Scope<T>) {
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<Keybind>, mods: &Modifiers, key: &Code) -> bool {
bind.iter().any(|b| mods.contains(b.mods) && &b.key == key)
}
/// main application function/loop
fn App(cx: Scope<App>) -> Element { fn App(cx: Scope<App>) -> Element {
let quit = use_state(cx, || false);
let search = use_state(cx, || "".to_string()); 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 results = &cx.props.entries;
let tracker = PosTracker::new(cx, results); let tracker = PosTracker::new(cx, results);
let (pos, subpos) = tracker.position(); let (pos, subpos) = tracker.position();
println!("pos: {pos}, {subpos}"); log::debug!("pos: {pos}, {subpos}");
// keyboard events // keyboard events
let eval = dioxus_desktop::use_eval(cx); let keybinds = &cx.props.config.keybinds;
let change_evt = move |evt: KeyboardEvent| { let keyboard_evt = move |evt: KeyboardEvent| {
match evt.code() { let key = &evt.code();
// modify position let mods = &evt.modifiers();
Code::ArrowUp => tracker.shift_up(), log::debug!("key: {key:?} mods: {mods:?}");
Code::ArrowDown => tracker.shift_down(), if matches(&keybinds.exit, mods, key) {
Code::Tab => match evt.modifiers().contains(Modifiers::SHIFT) { quit.set(true);
true => { } else if matches(&keybinds.move_up, mods, key) {
println!("close menu"); tracker.shift_up();
tracker.close_menu() } else if matches(&keybinds.move_down, mods, key) {
} tracker.shift_down();
false => { } else if matches(&keybinds.open_menu, mods, key) {
println!("open menu!"); tracker.open_menu();
tracker.open_menu() } else if matches(&keybinds.close_menu, mods, key) {
} tracker.close_menu();
},
_ => println!("key: {:?}", evt.key()),
} }
// always set focus back on input // always set focus back on input
let js = "document.getElementById(`search`).focus()"; focus(cx);
eval(js.to_owned());
}; };
// pre-render results into elements // pre-render results into elements
@ -150,7 +205,8 @@ fn App(cx: Scope<App>) -> Element {
cx.render(rsx! { cx.render(rsx! {
style { "{cx.props.css}" } style { "{cx.props.css}" }
div { div {
onkeydown: change_evt, onkeydown: keyboard_evt,
onclick: |_| focus(cx),
input { input {
id: "search", id: "search",
value: "{search}", value: "{search}",

View File

@ -1,3 +1,4 @@
use std::collections::VecDeque;
use std::fmt::Display; use std::fmt::Display;
use std::fs::{read_to_string, File}; use std::fs::{read_to_string, File};
use std::io::{self, prelude::*, BufReader}; use std::io::{self, prelude::*, BufReader};
@ -5,6 +6,7 @@ use std::process::{Command, ExitStatus, Stdio};
use std::str::FromStr; use std::str::FromStr;
mod config; mod config;
mod exec;
mod gui; mod gui;
mod search; mod search;
mod state; mod state;
@ -148,7 +150,10 @@ impl Args {
return Err(RMenuError::NoSuchPlugin(plugin.to_owned())); return Err(RMenuError::NoSuchPlugin(plugin.to_owned()));
}; };
// build command // build command
let mut cmdargs = args.clone(); let mut cmdargs: VecDeque<String> = args
.iter()
.map(|arg| shellexpand::tilde(arg).to_string())
.collect();
let Some(main) = cmdargs.pop_front() else { let Some(main) = cmdargs.pop_front() else {
return Err(RMenuError::InvalidPlugin(plugin.to_owned())); return Err(RMenuError::InvalidPlugin(plugin.to_owned()));
}; };
@ -188,6 +193,7 @@ impl Args {
config.css.extend(args.css.clone()); config.css.extend(args.css.clone());
let mut css = vec![]; let mut css = vec![];
for path in config.css.iter() { for path in config.css.iter() {
let path = shellexpand::tilde(path).to_string();
let src = read_to_string(path)?; let src = read_to_string(path)?;
css.push(src); css.push(src);
} }
@ -211,8 +217,7 @@ impl Args {
//TODO: config //TODO: config
// - default and cli accessable modules (instead of piped in) // - default and cli accessable modules (instead of piped in)
// - allow/disable icons (also available via CLI) // - should resolve arguments/paths with home expansion
// - custom keybindings (some available via CLI?)
//TODO: add exit key (Esc by default?) - part of keybindings //TODO: add exit key (Esc by default?) - part of keybindings

View File

@ -1,3 +1,4 @@
//! RMENU Entry Search Function Implementaton
use regex::RegexBuilder; use regex::RegexBuilder;
use rmenu_plugin::Entry; use rmenu_plugin::Entry;
@ -29,7 +30,7 @@ macro_rules! search {
} }
/// Generate a new dynamic Search Function based on /// 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<dyn Fn(&Entry) -> bool> { pub fn new_searchfn(cfg: &Config, search: &str) -> Box<dyn Fn(&Entry) -> bool> {
if cfg.search_regex { if cfg.search_regex {
let regex = RegexBuilder::new(search) let regex = RegexBuilder::new(search)

View File

@ -1,15 +1,26 @@
/// Application State Trackers and Utilities //! GUI Application State Trackers and Utilities
use dioxus::prelude::{use_state, Scope, UseState}; use dioxus::prelude::{use_state, Scope, UseState};
use rmenu_plugin::Entry; use rmenu_plugin::Entry;
use crate::App; use crate::App;
#[derive(PartialEq)]
pub struct PosTracker<'a> { pub struct PosTracker<'a> {
pos: &'a UseState<usize>, pos: &'a UseState<usize>,
subpos: &'a UseState<usize>, subpos: &'a UseState<usize>,
results: &'a Vec<Entry>, results: &'a Vec<Entry>,
} }
impl<'a> Clone for PosTracker<'a> {
fn clone(&self) -> Self {
Self {
pos: self.pos,
subpos: self.subpos,
results: self.results,
}
}
}
impl<'a> PosTracker<'a> { impl<'a> PosTracker<'a> {
pub fn new(cx: Scope<'a, App>, results: &'a Vec<Entry>) -> Self { pub fn new(cx: Scope<'a, App>, results: &'a Vec<Entry>) -> Self {
let pos = use_state(cx, || 0); let pos = use_state(cx, || 0);
@ -60,7 +71,6 @@ impl<'a> PosTracker<'a> {
let index = *self.pos.get(); let index = *self.pos.get();
let result = &self.results[index]; let result = &self.results[index];
let subpos = *self.subpos.get(); let subpos = *self.subpos.get();
println!("modify subpos? {} {}", subpos, result.actions.len());
if subpos > 0 && subpos < result.actions.len() - 1 { if subpos > 0 && subpos < result.actions.len() - 1 {
self.subpos.modify(|v| v + 1); self.subpos.modify(|v| v + 1);
return; return;