add basic support for custom keys

this also adds an example for a vaultwarden client
This commit is contained in:
Alexander Mohr 2025-05-04 18:10:05 +02:00
parent d15f178f27
commit 96455074e9
6 changed files with 2382 additions and 34 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
/target
.idea
*target*

2143
examples/worf-warden/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,9 @@
[package]
name = "worf-warden"
version = "0.1.0"
edition = "2024"
[dependencies]
gdk4 = "0.9.6"
worf = {path = "../.."}
enigo = "0.1.3"

View file

@ -0,0 +1,106 @@
use std::process::Command;
use std::thread;
use std::thread::sleep;
use std::time::Duration;
use enigo::{Enigo, Key, KeyboardControllable};
use worf_lib::{config, gui, Error};
use worf_lib::config::Config;
use worf_lib::gui::{CustomKey, ItemProvider, MenuItem};
#[derive(Clone)]
struct PasswordProvider {
items: Vec<MenuItem<String>>
}
impl PasswordProvider {
fn new(config: &Config) -> Self {
let output = Command::new("rbw")
.arg("list")
.output()
.expect("Failed to execute command");
let stdout = String::from_utf8_lossy(&output.stdout);
// todo the own solution should support images.
let mut items: Vec<_>= stdout.lines().map(|line|
MenuItem::new(line.to_owned(), None, None, vec![], None, 0.0, Some(String::new()))
).collect();
gui::apply_sort(&mut items, &config.sort_order());
Self {
items
}
}
}
impl ItemProvider<String> for PasswordProvider {
fn get_elements(&mut self, _: Option<&str>) -> (bool, Vec<MenuItem<String>>) {
(false, self.items.clone())
}
fn get_sub_elements(&mut self, _: &MenuItem<String>) -> (bool, Option<Vec<MenuItem<String>>>) {
(false, None)
}
}
fn rbw_get(name: &str, field: &str) -> String {
let output = Command::new("rbw")
.arg("get")
.arg(name)
.arg("--field")
.arg(field)
.output()
.expect("Failed to execute command");
String::from_utf8_lossy(&output.stdout).trim_end().to_string()
}
fn rbw_get_user(name: &str) -> String{
rbw_get(name, "user")
}
fn rbw_get_password(name: &str) -> String {
rbw_get(name, "password")
}
fn main() {
let args = config::parse_args();
let config = config::load_config(Some(&args)).unwrap_or(args);
// todo eventually use a propper rust client for this, for now rbw is good enough
let provider = PasswordProvider::new(&config);
let type_all = CustomKey {
key: gdk4::Key::_1, // todo do not expose gdk4
modifiers: gdk4::ModifierType::ALT_MASK,
label: "<b>Alt+1</b> Type All".to_string(),
};
let type_user = CustomKey {
key: gdk4::Key::_2, // todo do not expose gdk4
modifiers: gdk4::ModifierType::ALT_MASK,
label: "<b>Alt+2</b> Type All".to_string(),
};
match gui::show(config, provider, false, None, Some(vec![type_all.clone(), type_user])) {
Ok(selection) => {
let mut enigo = Enigo::new();
let id = selection.menu.label.replace("\n", "");
sleep(Duration::from_millis(250));
if let Some(key) = selection.custom_key {
if key.label == type_all.label {
enigo.key_sequence(&rbw_get_user(&id));
enigo.key_down(Key::Tab);
enigo.key_up(Key::Tab);
enigo.key_sequence(&rbw_get_password(&id));
}
}
}
Err(e) => {
if e.ne(&Error::NoSelection) {
println!("Error occurred: {e}")
}
}
}
}

View file

@ -2,6 +2,7 @@ use std::collections::HashMap;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use std::thread;
use std::thread::sleep;
use std::time::{Duration, Instant};
use crossbeam::channel;
@ -9,7 +10,7 @@ use crossbeam::channel::Sender;
use gdk4::gio::File;
use gdk4::glib::{Propagation, timeout_add_local};
use gdk4::prelude::{Cast, DisplayExt, MonitorExt, SurfaceExt};
use gdk4::{Display, Key};
use gdk4::{Display, Key, ModifierType};
use gtk4::glib::ControlFlow;
use gtk4::prelude::{
ApplicationExt, ApplicationExtManual, BoxExt, EditableExt, FlowBoxChildExt, GestureSingleExt,
@ -31,7 +32,12 @@ use crate::{Error, config, desktop};
type ArcMenuMap<T> = Arc<Mutex<HashMap<FlowBoxChild, MenuItem<T>>>>;
type ArcProvider<T> = Arc<Mutex<dyn ItemProvider<T> + Send>>;
type MenuItemSender<T> = Sender<Result<MenuItem<T>, Error>>;
pub struct Selection<T: Clone + Send> {
pub menu: MenuItem<T>,
pub custom_key: Option<CustomKey>,
}
type SelectionSender<T> = Sender<Result<Selection<T>, Error>>;
pub trait ItemProvider<T: Clone> {
fn get_elements(&mut self, search: Option<&str>) -> (bool, Vec<MenuItem<T>>);
@ -104,6 +110,13 @@ pub struct MenuItem<T: Clone> {
visible: bool,
}
#[derive(Clone, PartialEq)]
pub struct CustomKey {
pub key: Key,
pub modifiers: ModifierType, // acts as a mask, so multiple things can be set.
pub label: String,
}
impl<T: Clone> MenuItem<T> {
#[must_use]
pub fn new(
@ -135,9 +148,9 @@ impl<T: Clone> AsRef<MenuItem<T>> for MenuItem<T> {
}
}
struct MetaData<T: Clone> {
struct MetaData<T: Clone + Send> {
item_provider: ArcProvider<T>,
selected_sender: MenuItemSender<T>,
selected_sender: SelectionSender<T>,
config: Rc<Config>,
new_on_empty: bool,
search_ignored_words: Option<Vec<Regex>>,
@ -161,7 +174,8 @@ pub fn show<T, P>(
item_provider: P,
new_on_empty: bool,
search_ignored_words: Option<Vec<Regex>>,
) -> Result<MenuItem<T>, Error>
custom_keys: Option<Vec<CustomKey>>,
) -> Result<Selection<T>, Error>
where
T: Clone + 'static + Send,
P: ItemProvider<T> + 'static + Clone + Send,
@ -192,6 +206,7 @@ where
app.clone(),
new_on_empty,
search_ignored_words.clone(),
custom_keys.clone(),
);
});
@ -203,10 +218,11 @@ where
fn build_ui<T, P>(
config: &Config,
item_provider: P,
sender: Sender<Result<MenuItem<T>, Error>>,
sender: Sender<Result<Selection<T>, Error>>,
app: Application,
new_on_empty: bool,
search_ignored_words: Option<Vec<Regex>>,
custom_keys: Option<Vec<CustomKey>>,
) where
T: Clone + 'static + Send,
P: ItemProvider<T> + 'static + Send,
@ -245,7 +261,7 @@ fn build_ui<T, P>(
});
// handle keys as soon as possible
setup_key_event_handler(&ui_elements, &meta);
setup_key_event_handler(&ui_elements, &meta, &custom_keys);
log::debug!("keyboard ready after {:?}", start.elapsed());
@ -272,6 +288,8 @@ fn build_ui<T, P>(
let outer_box = gtk4::Box::new(config.orientation().into(), 0);
outer_box.set_widget_name("outer-box");
outer_box.append(&ui_elements.search);
build_custom_key_view(config, &ui_elements, &custom_keys, &outer_box);
ui_elements.window.set_child(Some(&outer_box));
let scroll = ScrolledWindow::new();
@ -315,7 +333,6 @@ fn build_ui<T, P>(
log::debug!("Building UI took {:?}", start.elapsed(),);
}
fn build_main_box<T: Clone + 'static>(config: &Config, ui_elements: &Rc<UiElements<T>>) {
ui_elements.main_box.set_widget_name("inner-box");
ui_elements.main_box.set_css_classes(&["inner-box"]);
@ -348,7 +365,11 @@ fn build_main_box<T: Clone + 'static>(config: &Config, ui_elements: &Rc<UiElemen
});
}
fn build_search_entry<T: Clone>(config: &Config, ui_elements: &UiElements<T>, meta: &MetaData<T>) {
fn build_search_entry<T: Clone + Send>(
config: &Config,
ui_elements: &UiElements<T>,
meta: &MetaData<T>,
) {
ui_elements.search.set_widget_name("input");
ui_elements.search.set_css_classes(&["input"]);
ui_elements
@ -363,7 +384,35 @@ fn build_search_entry<T: Clone>(config: &Config, ui_elements: &UiElements<T>, me
}
}
fn set_search_text<T: Clone>(text: &str, ui: &UiElements<T>, meta: &MetaData<T>) {
fn build_custom_key_view<T>(
config: &Config,
ui: &Rc<UiElements<T>>,
custom_keys: &Option<Vec<CustomKey>>,
outer_box: &gtk4::Box,
) where
T: 'static + Clone + Send,
{
let inner_box = gtk4::Box::new(Orientation::Horizontal, 0);
inner_box.set_halign(Align::Start);
inner_box.set_widget_name("custom-key-box");
if let Some(custom_keys) = custom_keys {
for key in custom_keys {
let label_box = gtk4::Box::new(Orientation::Horizontal, 0);
label_box.set_halign(Align::Start);
label_box.set_widget_name("custom-key-label-box");
inner_box.append(&label_box);
let label = Label::new(Some(&key.label));
label.set_use_markup(true);
label.set_hexpand(true);
label.set_widget_name("custom-key-label-text");
label.set_wrap(true);
label_box.append(&label);
}
}
outer_box.append(&inner_box);
}
fn set_search_text<T: Clone + Send>(text: &str, ui: &UiElements<T>, meta: &MetaData<T>) {
let mut lock = ui.search_text.lock().unwrap();
text.clone_into(&mut lock);
if let Some(pw) = meta.config.password() {
@ -377,7 +426,7 @@ fn set_search_text<T: Clone>(text: &str, ui: &UiElements<T>, meta: &MetaData<T>)
}
}
fn build_ui_from_menu_items<T: Clone + 'static>(
fn build_ui_from_menu_items<T: Clone + 'static + Send>(
ui: &Rc<UiElements<T>>,
meta: &Rc<MetaData<T>>,
mut items: Vec<MenuItem<T>>,
@ -443,21 +492,31 @@ fn build_ui_from_menu_items<T: Clone + 'static>(
fn setup_key_event_handler<T: Clone + 'static + Send>(
ui: &Rc<UiElements<T>>,
meta: &Rc<MetaData<T>>,
custom_keys: &Option<Vec<CustomKey>>,
) {
let key_controller = EventControllerKey::new();
let ui_clone = Rc::clone(ui);
let meta_clone = Rc::clone(meta);
key_controller.connect_key_pressed(move |_, key_value, _, _| {
handle_key_press(&ui_clone, &meta_clone, key_value)
let keys_clone = custom_keys.clone();
key_controller.connect_key_pressed(move |_, key_value, _, modifier| {
handle_key_press(
&ui_clone,
&meta_clone,
key_value,
modifier,
keys_clone.as_ref(),
)
});
ui.window.add_controller(key_controller);
}
fn handle_key_press<T: Clone + 'static>(
fn handle_key_press<T: Clone + 'static + Send>(
ui: &Rc<UiElements<T>>,
meta: &Rc<MetaData<T>>,
keyboard_key: Key,
modifier_type: ModifierType,
custom_keys: Option<&Vec<CustomKey>>,
) -> Propagation {
let update_view = |query: &String| {
let mut lock = ui.menu_rows.lock().unwrap();
@ -480,6 +539,24 @@ fn handle_key_press<T: Clone + 'static>(
update_view(query);
};
if let Some(custom_keys) = custom_keys {
for custom_key in custom_keys {
if custom_key.key == keyboard_key && custom_key.modifiers == modifier_type {
let search_lock = ui.search_text.lock().unwrap();
if let Err(e) = handle_selected_item(
ui,
meta,
Some(&search_lock),
None,
meta.new_on_empty,
Some(&custom_key),
) {
log::error!("{e}");
}
}
}
}
match keyboard_key {
Key::Escape => {
if let Err(e) = meta.selected_sender.send(Err(Error::NoSelection)) {
@ -490,7 +567,7 @@ fn handle_key_press<T: Clone + 'static>(
Key::Return => {
let search_lock = ui.search_text.lock().unwrap();
if let Err(e) =
handle_selected_item(ui, meta, Some(&search_lock), None, meta.new_on_empty)
handle_selected_item(ui, meta, Some(&search_lock), None, meta.new_on_empty, None)
{
log::error!("{e}");
}
@ -627,12 +704,17 @@ fn handle_selected_item<T>(
query: Option<&str>,
item: Option<MenuItem<T>>,
new_on_empty: bool,
custom_key: Option<&CustomKey>,
) -> Result<(), String>
where
T: Clone,
T: Clone + Send,
{
if let Some(selected_item) = item {
if let Err(e) = meta.selected_sender.send(Ok(selected_item.clone())) {
close_gui(ui.app.clone(), ui.window.clone(), &meta.config);
if let Err(e) = meta.selected_sender.send(Ok(Selection {
menu: selected_item.clone(),
custom_key: custom_key.map(|k| k.clone()),
})) {
log::error!("failed to send message {e}");
}
@ -663,7 +745,10 @@ where
visible: true,
};
if let Err(e) = meta.selected_sender.send(Ok(item.clone())) {
if let Err(e) = meta.selected_sender.send(Ok(Selection {
menu: item.clone(),
custom_key: custom_key.map(|k| k.clone()),
})) {
log::error!("failed to send message {e}");
}
close_gui(&ui.app);
@ -673,7 +758,7 @@ where
}
}
fn add_menu_item<T: Clone + 'static>(
fn add_menu_item<T: Clone + 'static + Send>(
ui: &Rc<UiElements<T>>,
meta: &Rc<MetaData<T>>,
element_to_add: &MenuItem<T>,
@ -718,7 +803,7 @@ fn add_menu_item<T: Clone + 'static>(
child
}
fn create_menu_row<T: Clone + 'static>(
fn create_menu_row<T: Clone + 'static + Send>(
ui: &Rc<UiElements<T>>,
meta: &Rc<MetaData<T>>,
element_to_add: &MenuItem<T>,
@ -782,6 +867,7 @@ fn create_menu_row<T: Clone + 'static>(
None,
Some(element_clone.clone()),
false,
None,
) {
log::error!("{e}");
}

View file

@ -712,9 +712,9 @@ pub fn d_run(config: &Config) -> Result<(), Error> {
let mut cache = provider.cache.clone();
// todo ues a arc instead of cloning the config
let selection_result = gui::show(config.clone(), provider, false, None);
let selection_result = gui::show(config.clone(), provider, false, None, None);
match selection_result {
Ok(s) => update_drun_cache_and_run(cache_path, &mut cache, s)?,
Ok(s) => update_drun_cache_and_run(cache_path, &mut cache, s.menu)?,
Err(_) => {
log::error!("No item selected");
}
@ -732,9 +732,9 @@ pub fn run(config: &Config) -> Result<(), Error> {
let cache_path = provider.cache_path.clone();
let mut cache = provider.cache.clone();
let selection_result = gui::show(config.clone(), provider, false, None);
let selection_result = gui::show(config.clone(), provider, false, None, None);
match selection_result {
Ok(s) => update_run_cache_and_run(cache_path, &mut cache, s)?,
Ok(s) => update_run_cache_and_run(cache_path, &mut cache, s.menu)?,
Err(_) => {
log::error!("No item selected");
}
@ -768,9 +768,11 @@ pub fn auto(config: &Config) -> Result<(), Error> {
.map(|s| Regex::new(s).unwrap())
.collect(),
),
None,
);
if let Ok(mut selection_result) = selection_result {
let mut selection_result = selection_result.menu;
if let Some(data) = &selection_result.data {
match data {
AutoRunType::Math => {
@ -829,9 +831,10 @@ pub fn file(config: &Config) -> Result<(), Error> {
provider,
false,
Some(vec![Regex::new("^\\$\\w+").unwrap()]),
None,
)?;
if let Some(action) = selection_result.action {
spawn_fork(&action, selection_result.working_dir.as_ref())
if let Some(action) = selection_result.menu.action {
spawn_fork(&action, selection_result.menu.working_dir.as_ref())
} else {
Err(Error::MissingAction)
}
@ -866,9 +869,9 @@ fn ssh_launch<T: Clone>(menu_item: &MenuItem<T>, config: &Config) -> Result<(),
/// * if it didn't find a terminal
pub fn ssh(config: &Config) -> Result<(), Error> {
let provider = SshProvider::new(0, &config.sort_order());
let selection_result = gui::show(config.clone(), provider, true, None);
let selection_result = gui::show(config.clone(), provider, true, None, None);
if let Ok(mi) = selection_result {
ssh_launch(&mi, config)?;
ssh_launch(&mi.menu, config)?;
} else {
log::error!("No item selected");
}
@ -881,9 +884,9 @@ pub fn math(config: &Config) {
loop {
let mut provider = MathProvider::new(String::new());
provider.add_elements(&mut calc.clone());
let selection_result = gui::show(config.clone(), provider, true, None);
let selection_result = gui::show(config.clone(), provider, true, None, None);
if let Ok(mi) = selection_result {
calc.push(mi);
calc.push(mi.menu);
} else {
log::error!("No item selected");
break;
@ -897,8 +900,8 @@ pub fn math(config: &Config) {
/// Forwards errors from the gui. See `gui::show` for details.
pub fn emoji(config: &Config) -> Result<(), Error> {
let provider = EmojiProvider::new(0, &config.sort_order());
let selection_result = gui::show(config.clone(), provider, true, None)?;
match selection_result.action {
let selection_result = gui::show(config.clone(), provider, true, None, None)?;
match selection_result.menu.action {
None => Err(Error::MissingAction),
Some(action) => copy_to_clipboard(action),
}
@ -911,10 +914,10 @@ pub fn emoji(config: &Config) -> Result<(), Error> {
pub fn dmenu(config: &Config) -> Result<(), Error> {
let provider = DMenuProvider::new(&config.sort_order())?;
let selection_result = gui::show(config.clone(), provider, true, None);
let selection_result = gui::show(config.clone(), provider, true, None, None);
match selection_result {
Ok(s) => {
println!("{}", s.label);
println!("{}", s.menu.label);
Ok(())
}
Err(_) => Err(Error::InvalidSelection),