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-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"

View File

@ -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<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 css: Vec<String>,
pub use_icons: bool,
pub search_regex: 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 {
@ -18,6 +148,8 @@ impl Default for Config {
search_regex: false,
ignore_case: true,
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)]
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<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 {
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<App>) -> Element {
cx.render(rsx! {
style { "{cx.props.css}" }
div {
onkeydown: change_evt,
onkeydown: keyboard_evt,
onclick: |_| focus(cx),
input {
id: "search",
value: "{search}",

View File

@ -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<String> = 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

View File

@ -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<dyn Fn(&Entry) -> bool> {
if cfg.search_regex {
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 rmenu_plugin::Entry;
use crate::App;
#[derive(PartialEq)]
pub struct PosTracker<'a> {
pos: &'a UseState<usize>,
subpos: &'a UseState<usize>,
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> {
pub fn new(cx: Scope<'a, App>, results: &'a Vec<Entry>) -> 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;