From c5ff8313d8d5e864b563780f6acb2c8e6f0adad0 Mon Sep 17 00:00:00 2001 From: Johannes Date: Sun, 26 Oct 2025 08:33:58 +0100 Subject: [PATCH 1/4] main: move loop variables into the loop the 'key' and 'state' variables are read into inside the loop, and should be reset upon re-entering the loop. Otherwise an previously processed event could leave some stale state behind. --- src/main.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 9b2fe64..8e8d45e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -55,9 +55,10 @@ fn main() { script_manager.load_script(&script).unwrap(); let mut active_keys = Vec::new(); - let mut key:u32 = 0; - let mut state: KeyState = KeyState::Released; loop { + let mut key:u32 = 0; + let mut state: KeyState = KeyState::Released; + input.dispatch().unwrap(); for event in &mut input { match event { From c866a2989ff5096da6a16c9772bbc309cfd7a22a Mon Sep 17 00:00:00 2001 From: Johannes Date: Sun, 26 Oct 2025 07:17:31 +0100 Subject: [PATCH 2/4] main: convert mouse buttons to KeyEvents Convert mouse buttons to KeyEvents, so that actions can be bound to them too. Also extend the number of known button events, as there are (many) mice out there with more than just five buttons. --- src/main.rs | 10 +++++++++- src/parser.rs | 23 ++++++++++++++++------- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/main.rs b/src/main.rs index 8e8d45e..70b44a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use dirs::config_dir; use input::event::keyboard::{KeyState, KeyboardEventTrait}; -use input::event::pointer::PointerScrollEvent; +use input::event::pointer::{ButtonState, PointerScrollEvent}; use input::event::PointerEvent; use input::{Event, Libinput, LibinputInterface}; use libc::{O_RDONLY, O_RDWR, O_WRONLY}; @@ -35,6 +35,13 @@ impl LibinputInterface for WBindKeysInterface { } } +fn convert_button_to_key_state(button_state: ButtonState) -> KeyState { + match button_state { + ButtonState::Pressed => KeyState::Pressed, + ButtonState::Released => KeyState::Released, + } +} + fn main() { let mut input = Libinput::new_with_udev(WBindKeysInterface); input.udev_assign_seat("seat0").unwrap(); @@ -65,6 +72,7 @@ fn main() { Event::Pointer(PointerEvent::Motion(_)) => {} // If event is mouse movement do nothing Event::Pointer(PointerEvent::Button(mouse_button)) => { key = mouse_button.button(); + state = convert_button_to_key_state(mouse_button.button_state()); } Event::Pointer(PointerEvent::ScrollWheel(scroll_event)) => { if scroll_event.has_axis(input::event::pointer::Axis::Vertical) == true { diff --git a/src/parser.rs b/src/parser.rs index 6bbfb50..cf97d0f 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -86,6 +86,11 @@ pub enum Keys { Mouse3 = 0x112, Mouse4 = 0x113, Mouse5 = 0x114, + Mouse6 = 0x115, + Mouse7 = 0x116, + Mouse8 = 0x117, + Mouse9 = 0x118, + Mouse10 = 0x119, ScrollLeft = 0x996, ScrollRight = 0x997, ScrollUp = 0x998, @@ -186,11 +191,16 @@ pub fn parse_binding(binding: &str) -> Vec { "Mouse2"=> keys.push(Keys::Mouse2 as u32), "Mouse3"=> keys.push(Keys::Mouse3 as u32), "Mouse4"=> keys.push(Keys::Mouse4 as u32), - "Mouse5"=> keys.push(Keys::Mouse5 as u32), - "ScrollLeft"=> keys.push(Keys::ScrollLeft as u32), - "ScrollRight"=> keys.push(Keys::ScrollRight as u32), - "ScrollUp"=> keys.push(Keys::ScrollUp as u32), - "ScrollDown"=> keys.push(Keys::ScrollDown as u32), + "Mouse5"=> keys.push(Keys::Mouse5 as u32), + "Mouse6"=> keys.push(Keys::Mouse6 as u32), + "Mouse7"=> keys.push(Keys::Mouse7 as u32), + "Mouse8"=> keys.push(Keys::Mouse8 as u32), + "Mouse9"=> keys.push(Keys::Mouse9 as u32), + "Mouse10"=> keys.push(Keys::Mouse10 as u32), + "ScrollLeft"=> keys.push(Keys::ScrollLeft as u32), + "ScrollRight"=> keys.push(Keys::ScrollRight as u32), + "ScrollUp"=> keys.push(Keys::ScrollUp as u32), + "ScrollDown"=> keys.push(Keys::ScrollDown as u32), _ => {} } } @@ -287,11 +297,10 @@ mod tests { ("F11", Keys::F11 as u32), ("F12", Keys::F12 as u32), ("F12", Keys::F12 as u32), - ]; for (input, expected_output) in test_cases { assert_eq!(parse_binding(input), vec![expected_output], "Failed for input: {}", input); } } -} \ No newline at end of file +} From aaefdb1204a98cd3e8d1bec28229c3627303930a Mon Sep 17 00:00:00 2001 From: Johannes Date: Sun, 26 Oct 2025 08:28:23 +0100 Subject: [PATCH 3/4] main: create KeyStates from ScrollEvents To be able to bind things to scroll events, they have to be converted to KeyEvents with a pressed/released state. Scroll events only send a scroll increment, repeatedly. This has to be converted into (synthetic) press and release events. To this end some additional book-keeping on the key states, event times, ... is added. --- src/main.rs | 120 ++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 103 insertions(+), 17 deletions(-) diff --git a/src/main.rs b/src/main.rs index 70b44a1..ada60f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,20 @@ use dirs::config_dir; +use input::event::PointerEvent; use input::event::keyboard::{KeyState, KeyboardEventTrait}; use input::event::pointer::{ButtonState, PointerScrollEvent}; -use input::event::PointerEvent; use input::{Event, Libinput, LibinputInterface}; use libc::{O_RDONLY, O_RDWR, O_WRONLY}; use parser::Keys; use script_manager::ScriptManager; +use std::collections::HashMap; use std::fs::{File, OpenOptions}; use std::os::unix::{fs::OpenOptionsExt, io::OwnedFd}; use std::path::Path; +use std::time::{Duration, Instant}; use std::u32; +const SCROLL_HOLD_MS: u64 = 500; // how long a scroll "press" lasts + mod parser; mod script_manager; @@ -42,6 +46,19 @@ fn convert_button_to_key_state(button_state: ButtonState) -> KeyState { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum ScrollDir { + Up, + Down, + Left, + Right, +} + +struct ScrollState { + last_time: Instant, + active: bool, +} + fn main() { let mut input = Libinput::new_with_udev(WBindKeysInterface); input.udev_assign_seat("seat0").unwrap(); @@ -62,11 +79,16 @@ fn main() { script_manager.load_script(&script).unwrap(); let mut active_keys = Vec::new(); + let mut key_states: HashMap = HashMap::new(); + let mut scroll_states: HashMap = HashMap::new(); + loop { - let mut key:u32 = 0; + let mut key: u32 = 0; let mut state: KeyState = KeyState::Released; input.dispatch().unwrap(); + + // --- Handle libinput events --- for event in &mut input { match event { Event::Pointer(PointerEvent::Motion(_)) => {} // If event is mouse movement do nothing @@ -75,21 +97,28 @@ fn main() { state = convert_button_to_key_state(mouse_button.button_state()); } Event::Pointer(PointerEvent::ScrollWheel(scroll_event)) => { - if scroll_event.has_axis(input::event::pointer::Axis::Vertical) == true { - if scroll_event.scroll_value(input::event::pointer::Axis::Vertical) > 0.0 { - println!("Scroll Down!"); - key = 0x999 - }else { - println!("Scroll Up!"); - key = 0x998 - } - } else { - if scroll_event.scroll_value(input::event::pointer::Axis::Horizontal) > 0.0 { - print!("Scroll Right!"); - key = 0x997 + if let Some((scroll_dir, virtual_key)) = detect_scroll_direction(&scroll_event) { + let now = Instant::now(); + let entry = scroll_states.entry(scroll_dir).or_insert(ScrollState { + last_time: now, + active: false, + }); + + // Only emit "Pressed" if not active or expired + if !entry.active + || now.duration_since(entry.last_time) + > Duration::from_millis(SCROLL_HOLD_MS) + { + #[cfg(debug_assertions)] + println!("Scroll {:?} => Pressed ({:#03x})", scroll_dir, virtual_key); + + entry.active = true; + entry.last_time = now; + + key = virtual_key; + state = KeyState::Pressed; } else { - println!("Scroll Left!"); - key = 0x996 + // ignore repeated scrolls in the same direction } } } @@ -112,7 +141,12 @@ fn main() { } _ => {} // Ignore all other events } - if state == KeyState::Pressed { + + // Only trigger on transition: Released → Pressed + let prev_state = key_states.get(&key).copied().unwrap_or(KeyState::Released); + key_states.insert(key, state); + + if state == KeyState::Pressed && prev_state == KeyState::Released { let total_combo = active_keys .iter() .chain(std::iter::once(&key)) @@ -122,5 +156,57 @@ fn main() { script_manager.handle_action(total_combo, state); } } + + // --- Handle synthetic scroll releases --- + let now = Instant::now(); + for (dir, state_entry) in scroll_states.iter_mut() { + if state_entry.active + && now.duration_since(state_entry.last_time) > Duration::from_millis(SCROLL_HOLD_MS) + { + let release_key = scroll_dir_to_key(*dir); + let prev_state = key_states.get(&release_key).copied().unwrap_or(KeyState::Released); + + if prev_state == KeyState::Pressed { + #[cfg(debug_assertions)] + println!("Scroll {:?} => Released ({:#03x})", dir, release_key); + + key_states.insert(release_key, KeyState::Released); + state_entry.active = false; + } + } + } + + // small sleep to avoid busy loop (libinput often blocks anyway) + std::thread::sleep(Duration::from_millis(5)); + } +} + +fn detect_scroll_direction(scroll_event: &E) -> Option<(ScrollDir, u32)> +where +E: PointerScrollEvent, +{ + if scroll_event.has_axis(input::event::pointer::Axis::Vertical) { + if scroll_event.scroll_value(input::event::pointer::Axis::Vertical) > 0.0 { + Some((ScrollDir::Down, 0x999)) + } else { + Some((ScrollDir::Up, 0x998)) + } + } else if scroll_event.has_axis(input::event::pointer::Axis::Horizontal) { + if scroll_event.scroll_value(input::event::pointer::Axis::Horizontal) > 0.0 { + Some((ScrollDir::Right, 0x997)) + } else { + Some((ScrollDir::Left, 0x996)) + } + } else { + None + } +} + +fn scroll_dir_to_key(dir: ScrollDir) -> u32 { + match dir { + ScrollDir::Up => 0x998, + ScrollDir::Down => 0x999, + ScrollDir::Left => 0x996, + ScrollDir::Right => 0x997, } } From e27134a59049f950167fc343c670e2b937308691 Mon Sep 17 00:00:00 2001 From: Johannes Date: Tue, 28 Oct 2025 04:05:18 +0100 Subject: [PATCH 4/4] scriptmanager: add device filters and table-based Lua binding syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change extends ScriptManager to support optional per-device filtering for key bindings, allowing actions to be triggered only for specific USB devices (based on vendor/product IDs). The Lua `bind` API was refactored to use a table-based syntax instead of positional parameters. This makes configuration files clearer, allows optional fields (like vid/pid), and is more extensible for future binding attributes. Bindings are now stored as a vector of structs containing key combos, actions, and an optional DeviceFilter { vid, pid }. During event handling, ScriptManager checks both the key combination and the originating device’s VID/PID before executing the command. ```lua -- Global binding (no device filter) bind{ keys = "Ctrl+Alt+T", command = "gnome-terminal" } -- Device-specific binding (only matches this keyboard) bind{ vid = 0x046d, pid = 0xc31c, keys = "Ctrl+Alt+L", command = "notify-send 'Logitech keyboard triggered'" } -- Vendor-wide binding (any device from the same vendor) bind{ vid = 0x046d, keys = "Ctrl+Alt+P", command = "notify-send 'Any Logitech device triggered this'" } --- src/main.rs | 8 ++- src/script_manager.rs | 129 +++++++++++++++++++++++++++++++----------- 2 files changed, 100 insertions(+), 37 deletions(-) diff --git a/src/main.rs b/src/main.rs index ada60f0..f5bd9cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,8 @@ use dirs::config_dir; -use input::event::PointerEvent; +use input::event::{EventTrait, PointerEvent}; use input::event::keyboard::{KeyState, KeyboardEventTrait}; use input::event::pointer::{ButtonState, PointerScrollEvent}; -use input::{Event, Libinput, LibinputInterface}; +use input::{Event, Device, Libinput, LibinputInterface}; use libc::{O_RDONLY, O_RDWR, O_WRONLY}; use parser::Keys; use script_manager::ScriptManager; @@ -90,6 +90,8 @@ fn main() { // --- Handle libinput events --- for event in &mut input { + let d: Device = event.device(); + match event { Event::Pointer(PointerEvent::Motion(_)) => {} // If event is mouse movement do nothing Event::Pointer(PointerEvent::Button(mouse_button)) => { @@ -153,7 +155,7 @@ fn main() { .copied() .collect::>(); - script_manager.handle_action(total_combo, state); + script_manager.handle_action(total_combo, state, d.id_vendor(), d.id_product()); } } diff --git a/src/script_manager.rs b/src/script_manager.rs index 69c71c8..e59daf2 100644 --- a/src/script_manager.rs +++ b/src/script_manager.rs @@ -1,68 +1,129 @@ use input::event::keyboard::KeyState; use mlua::Lua; -use std::collections::HashMap; use std::process::{Command, Stdio}; use std::sync::{Arc, Mutex}; -use std::u32; use crate::parser::parse_binding; -#[derive(Debug)] +// ---------------------------------- +// Data structures +// ---------------------------------- + +#[derive(Debug, Clone)] +struct DeviceFilter { + vid: Option, + pid: Option, +} + +#[derive(Debug, Clone)] enum Bindtype { Command(String), } +#[derive(Debug, Clone)] +struct Binding { + keys: Vec, + action: Bindtype, + filter: DeviceFilter, +} + +// ---------------------------------- +// ScriptManager definition +// ---------------------------------- + pub struct ScriptManager { lua: &'static Lua, - actions: Arc, Bindtype>>>, + actions: Arc>>, } impl ScriptManager { pub fn new() -> Self { let lua = Box::leak(Box::new(Lua::new())); - let actions = Arc::new(Mutex::new(HashMap::new())); + let actions = Arc::new(Mutex::new(Vec::new())); ScriptManager { lua, actions } } + // -------------------------------------------------------- + // Register Lua functions + // -------------------------------------------------------- + pub fn register_functions(&self) -> Result<(), mlua::Error> { - let actions_str = Arc::clone(&self.actions); - - let basic_bind = - self.lua - .create_function(move |_, (binding, target): (String, String)| { - println!("Binding key: {:?}", binding); - println!("Target: {:?}", target); - let mut actions_lock = actions_str.lock().unwrap(); - let binding = parse_binding(&binding); - let target = Bindtype::Command(target); - actions_lock.insert(binding, target); - Ok(()) - })?; - self.lua.globals().set("bind", basic_bind)?; + let actions_ref = Arc::clone(&self.actions); + + // This defines the Lua function `bind{ ... }` + let bind_func = self.lua.create_function(move |_, tbl: mlua::Table| { + // Read required fields + let keys: String = tbl.get("keys")?; + let command: String = tbl.get("command")?; + + // Read optional fields + let vid: Option = tbl.get("vid").ok(); + let pid: Option = tbl.get("pid").ok(); + + println!("new binding: {:?}->{:?} (device filter: {:?}:{:?})", keys, command, vid, pid); + + let mut actions = actions_ref.lock().unwrap(); + + // Build the binding + let binding = Binding { + keys: parse_binding(&keys), + action: Bindtype::Command(command), + filter: DeviceFilter { vid, pid }, + }; + + actions.push(binding); + Ok(()) + })?; + + // Make it available globally in Lua + self.lua.globals().set("bind", bind_func)?; Ok(()) } + // -------------------------------------------------------- + // Load and execute a Lua script (e.g. config.lua) + // -------------------------------------------------------- + pub fn load_script(&self, script: &str) -> Result<(), mlua::Error> { self.lua.load(script).exec() } - pub fn handle_action(&self, total_combo: Vec, state: KeyState) { - if let Some(action) = self.actions.lock().unwrap().get(&total_combo) { - if state == KeyState::Pressed { - println!("Action: {:?}", action); - - match action { - Bindtype::Command(command) => { - // run_command_as_user(command); - Command::new("sh") - .arg("-c") - .arg(command) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("Failed to execute command"); + // -------------------------------------------------------- + // Handle key events, considering device filters + // -------------------------------------------------------- + + pub fn handle_action(&self, combo: Vec, state: KeyState, device_vid: u32, device_pid: u32) { + if state != KeyState::Pressed { + return; + } + + let actions = self.actions.lock().unwrap(); + + for binding in actions.iter() { + if binding.keys == combo { + let f = &binding.filter; + + // Device filtering logic: + let vid_ok = f.vid.map_or(true, |v| v == device_vid); + let pid_ok = f.pid.map_or(true, |p| p == device_pid); + + if vid_ok && pid_ok { + match &binding.action { + Bindtype::Command(cmd) => { + println!( + "Executing {:?} (VID={:#06x}, PID={:#06x})", + cmd, device_vid, device_pid + ); + Command::new("sh") + .arg("-c") + .arg(cmd) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("Failed to execute command"); + } } } }