mirror of
https://github.com/imgurbot12/rmenu.git
synced 2024-11-10 11:33:48 +01:00
feat: impl keybindings, window-settings, action-execution
This commit is contained in:
parent
9b8d626c4d
commit
31989d4ee8
@ -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"
|
||||
|
@ -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
13
rmenu/src/exec.rs
Normal 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();
|
||||
}
|
108
rmenu/src/gui.rs
108
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<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}",
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user