From dc5e6ba5d7be08ca1cb2e7cfacce1f4cff8692da Mon Sep 17 00:00:00 2001 From: Jake Stanger Date: Sun, 17 Nov 2024 23:46:02 +0000 Subject: [PATCH] feat: libinput `keys` module Adds a new module which shows the status of toggle mod keys (capslock, num lock, scroll lock). Resolves #700 --- .github/scripts/ubuntu_setup.sh | 1 + Cargo.lock | 78 ++++++++- Cargo.toml | 14 +- src/clients/libinput.rs | 273 ++++++++++++++++++++++++++++++++ src/clients/mod.rs | 16 +- src/config/mod.rs | 6 + src/main.rs | 2 +- src/modules/keys.rs | 176 ++++++++++++++++++++ src/modules/mod.rs | 2 + 9 files changed, 555 insertions(+), 13 deletions(-) create mode 100644 src/clients/libinput.rs create mode 100644 src/modules/keys.rs diff --git a/.github/scripts/ubuntu_setup.sh b/.github/scripts/ubuntu_setup.sh index 74b93d24..b36dfea7 100755 --- a/.github/scripts/ubuntu_setup.sh +++ b/.github/scripts/ubuntu_setup.sh @@ -17,6 +17,7 @@ $SUDO apt-get update && $SUDO apt-get install --assume-yes \ libssl-dev${CROSS_DEB_ARCH:+:$CROSS_DEB_ARCH} \ libgtk-3-dev${CROSS_DEB_ARCH:+:$CROSS_DEB_ARCH} \ libgtk-layer-shell-dev${CROSS_DEB_ARCH:+:$CROSS_DEB_ARCH} \ + libinput-dev${CROSS_DEB_ARCH:+:$CROSS_DEB_ARCH} \ libdbusmenu-gtk3-dev${CROSS_DEB_ARCH:+:$CROSS_DEB_ARCH} \ libpulse-dev${CROSS_DEB_ARCH:+:$CROSS_DEB_ARCH} \ libluajit-5.1-dev${CROSS_DEB_ARCH:+:$CROSS_DEB_ARCH} diff --git a/Cargo.lock b/Cargo.lock index cffe3e00..63a06645 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -858,6 +858,29 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "evdev-rs" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9812d5790fb6fcce449333eb6713dad335e8c979225ed98755c84a3987e06dba" +dependencies = [ + "bitflags 1.3.2", + "evdev-sys", + "libc", + "log", +] + +[[package]] +name = "evdev-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14ead42b547b15d47089c1243d907bcf0eb94e457046d3b315a26ac9c9e9ea6d" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "event-listener" version = "2.5.3" @@ -1647,6 +1670,25 @@ dependencies = [ "libc", ] +[[package]] +name = "input" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbdc09524a91f9cacd26f16734ff63d7dc650daffadd2b6f84d17a285bd875a9" +dependencies = [ + "bitflags 2.4.0", + "input-sys", + "libc", + "log", + "udev", +] + +[[package]] +name = "input-sys" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd4f5b4d1c00331c5245163aacfe5f20be75b564c7112d45893d4ae038119eb0" + [[package]] name = "instant" version = "0.1.12" @@ -1684,6 +1726,7 @@ dependencies = [ "color-eyre", "ctrlc", "dirs", + "evdev-rs", "futures-lite 2.5.0", "futures-signals", "futures-util", @@ -1692,6 +1735,8 @@ dependencies = [ "gtk-layer-shell", "hyprland", "indexmap", + "input", + "libc", "libpulse-binding", "lua-src", "mlua", @@ -1766,9 +1811,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.155" +version = "0.2.164" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "433bfe06b8c75da9b2e3fbea6e5329ff87748f0b144ef75306e674c3f6f7c13f" [[package]] name = "libcorn" @@ -1830,6 +1875,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "linux-raw-sys" version = "0.3.8" @@ -1844,12 +1899,9 @@ checksum = "c4cd1a83af159aa67994778be9070f0ae1bd732942279cabb14f86f986a21456" [[package]] name = "log" -version = "0.4.17" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" -dependencies = [ - "cfg-if", -] +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "lua-src" @@ -3579,6 +3631,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e79c4d996edb816c91e4308506774452e55e95c3c9de07b6729e17e15a5ef81" +[[package]] +name = "udev" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d5c197b95f1769931c89f85c33c407801d1fb7a311113bc0b39ad036f1bd81" +dependencies = [ + "io-lifetimes", + "libc", + "libudev-sys", + "pkg-config", +] + [[package]] name = "uds_windows" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index b90979a0..bee6715d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ default = [ "focused", "http", "ipc", + "keys", "launcher", "music+all", "network_manager", @@ -49,12 +50,14 @@ http = ["dep:reqwest"] cairo = ["lua-src", "mlua", "cairo-rs"] -clipboard = ["nix"] +clipboard = ["dep:nix"] clock = ["chrono"] focused = [] +keys = ["dep:input", "dep:evdev-rs", "dep:libc", "dep:nix"] + launcher = [] music = ["regex"] @@ -131,12 +134,14 @@ lua-src = { version = "547.0.0", optional = true } mlua = { version = "0.9.9", optional = true, features = ["luajit"] } cairo-rs = { version = "0.18.5", optional = true, features = ["png"] } -# clipboard -nix = { version = "0.29.0", optional = true, features = ["event", "fs"] } - # clock chrono = { version = "0.4.38", optional = true, default-features = false, features = ["clock", "unstable-locales"] } +# input +input = { version = "0.9.1", optional = true } +evdev-rs = { version = "0.6.1", optional = true } +libc = { version = "0.2.164", optional = true } + # music mpd-utils = { version = "0.2.1", optional = true } mpris = { version = "2.0.1", optional = true } @@ -163,6 +168,7 @@ futures-util = { version = "0.3.31", optional = true } # shared futures-lite = { version = "2.5.0", optional = true } # network_manager, upower, workspaces +nix = { version = "0.29.0", optional = true, features = ["event", "fs", "poll"] } # clipboard, input regex = { version = "1.11.1", default-features = false, features = [ "std", ], optional = true } # music, sys_info diff --git a/src/clients/libinput.rs b/src/clients/libinput.rs new file mode 100644 index 00000000..b4e75c28 --- /dev/null +++ b/src/clients/libinput.rs @@ -0,0 +1,273 @@ +use crate::{arc_rw, read_lock, send, spawn, spawn_blocking, write_lock}; +use color_eyre::{Report, Result}; +use evdev_rs::enums::{int_to_ev_key, EventCode, EV_KEY, EV_LED}; +use evdev_rs::DeviceWrapper; +use input::event::keyboard::{KeyState, KeyboardEventTrait}; +use input::event::{DeviceEvent, EventTrait, KeyboardEvent}; +use input::{DeviceCapability, Libinput, LibinputInterface}; +use libc::{O_ACCMODE, O_RDONLY, O_RDWR}; +use std::fs::{File, OpenOptions}; +use std::os::unix::{fs::OpenOptionsExt, io::OwnedFd}; +use std::path::Path; +use std::sync::{Arc, RwLock}; +use std::time::Duration; +use tokio::sync::broadcast; +use tokio::time::sleep; +use tracing::{debug, error}; + +#[derive(Debug, Copy, Clone)] +pub enum Key { + Caps, + Num, + Scroll, +} + +impl From for EV_KEY { + fn from(value: Key) -> Self { + match value { + Key::Caps => Self::KEY_CAPSLOCK, + Key::Num => Self::KEY_NUMLOCK, + Key::Scroll => Self::KEY_SCROLLLOCK, + } + } +} + +impl Key { + fn get_state>(self, device_path: P) -> Result { + let device = evdev_rs::Device::new_from_path(device_path)?; + + let state = match self { + Self::Caps => device + .event_value(&EventCode::EV_LED(EV_LED::LED_CAPSL)) + .map(|v| v > 0), + Self::Num => device + .event_value(&EventCode::EV_LED(EV_LED::LED_NUML)) + .map(|v| v > 0), + Self::Scroll => device + .event_value(&EventCode::EV_LED(EV_LED::LED_SCROLLL)) + .map(|v| v > 0), + }; + + state.ok_or_else(|| Report::msg("failed to get key status")) + } +} + +#[derive(Debug, Copy, Clone)] +pub struct KeyEvent { + pub key: Key, + pub state: bool, +} + +#[derive(Debug, Copy, Clone)] +pub enum Event { + Device, + Key(KeyEvent), +} + +impl Event { + fn caps(state: bool) -> Self { + Self::Key(KeyEvent { + key: Key::Caps, + state, + }) + } + + fn num(state: bool) -> Self { + Self::Key(KeyEvent { + key: Key::Num, + state, + }) + } + + fn scroll(state: bool) -> Self { + Self::Key(KeyEvent { + key: Key::Scroll, + state, + }) + } +} + +struct KeyData> { + device_path: P, + key: EV_KEY, +} + +impl> TryFrom> for Event { + type Error = Report; + + fn try_from(data: KeyData

) -> Result { + let device = evdev_rs::Device::new_from_path(data.device_path)?; + + let event = match data.key { + EV_KEY::KEY_CAPSLOCK => device + .event_value(&EventCode::EV_LED(EV_LED::LED_CAPSL)) + .map(|v| v > 0) + .map(Event::caps), + EV_KEY::KEY_NUMLOCK => device + .event_value(&EventCode::EV_LED(EV_LED::LED_NUML)) + .map(|v| v > 0) + .map(Event::num), + EV_KEY::KEY_SCROLLLOCK => device + .event_value(&EventCode::EV_LED(EV_LED::LED_SCROLLL)) + .map(|v| v > 0) + .map(Event::scroll), + _ => None, + }; + + event.ok_or_else(|| Report::msg("provided key is not supported toggle key")) + } +} + +pub struct Interface; + +impl LibinputInterface for Interface { + fn open_restricted(&mut self, path: &Path, flags: i32) -> Result { + // No idea what these flags do honestly, just copied them from the example. + let op = OpenOptions::new() + .custom_flags(flags) + .read((flags & O_ACCMODE == O_RDONLY) | (flags & O_ACCMODE == O_RDWR)) + .open(path) + .map(|file| file.into()); + + if let Err(err) = &op { + error!("Error opening {}: {err:?}", path.display()); + } + + op.map_err(|err| err.raw_os_error().unwrap_or(-1)) + } + fn close_restricted(&mut self, fd: OwnedFd) { + drop(File::from(fd)); + } +} + +#[derive(Debug)] +pub struct Client { + tx: broadcast::Sender, + _rx: broadcast::Receiver, + + seat: String, + known_devices: Arc>>, +} + +impl Client { + pub fn new(seat: String) -> Self { + let (tx, rx) = broadcast::channel(4); + + Self { + tx, + _rx: rx, + seat, + known_devices: arc_rw!(vec![]), + } + } + + fn run(&self) -> Result<()> { + let mut input = Libinput::new_with_udev(Interface); + input + .udev_assign_seat(&self.seat) + .map_err(|()| Report::msg("failed to assign seat"))?; + + loop { + input.dispatch()?; + + for event in &mut input { + match event { + input::Event::Keyboard(KeyboardEvent::Key(event)) + if event.key_state() == KeyState::Released => + { + let device = match unsafe { event.device().udev_device() } { + Some(device) => device, + None => continue, + }; + + let key = match int_to_ev_key(event.key()) { + Some(key @ EV_KEY::KEY_CAPSLOCK) + | Some(key @ EV_KEY::KEY_NUMLOCK) + | Some(key @ EV_KEY::KEY_SCROLLLOCK) => key, + _ => continue, + }; + + if let Some(device_path) = device + .devnode() + .and_then(|p| p.to_str()) + .map(ToString::to_string) + { + let tx = self.tx.clone(); + + // need to spawn a task to avoid blocking + spawn(async move { + // wait for kb to change + sleep(Duration::from_millis(50)).await; + + let data = KeyData { device_path, key }; + + if let Ok(event) = data.try_into() { + send!(tx, event); + } + }); + } + } + input::Event::Device(DeviceEvent::Added(event)) => { + let device = event.device(); + if !device.has_capability(DeviceCapability::Keyboard) { + continue; + } + + let name = device.name(); + let device = match unsafe { event.device().udev_device() } { + Some(device) => device, + None => continue, + }; + + if let Some(device_path) = device + .devnode() + .and_then(|p| p.to_str()) + .map(ToString::to_string) + { + // not all devices which report as keyboards actually are + // fire test event so we can figure out if it is + let caps_event: Result = KeyData { + device_path: &device_path, + key: EV_KEY::KEY_CAPSLOCK, + } + .try_into(); + + if caps_event.is_ok() { + debug!("new keyboard device: {name} | {device_path}"); + write_lock!(self.known_devices).push(device_path); + send!(self.tx, Event::Device); + } + } + } + _ => {} + } + } + } + } + + pub fn get_state(&self, key: Key) -> bool { + read_lock!(self.known_devices) + .iter() + .map(|device_path| key.get_state(device_path)) + .filter_map(|state| state.ok()) + .reduce(|state, curr| state || curr) + .unwrap_or_default() + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.tx.subscribe() + } +} + +pub fn create_client(seat: String) -> Arc { + let client = Arc::new(Client::new(seat)); + { + let client = client.clone(); + spawn_blocking(move || { + if let Err(err) = client.run() { + error!("{err:?}"); + } + }); + } + client +} diff --git a/src/clients/mod.rs b/src/clients/mod.rs index 7b6b3123..e6b6b425 100644 --- a/src/clients/mod.rs +++ b/src/clients/mod.rs @@ -1,5 +1,6 @@ use crate::await_sync; use color_eyre::Result; +use std::collections::HashMap; use std::path::Path; use std::rc::Rc; use std::sync::Arc; @@ -8,6 +9,9 @@ use std::sync::Arc; pub mod clipboard; #[cfg(feature = "workspaces")] pub mod compositor; + +#[cfg(feature = "keys")] +pub mod libinput; #[cfg(feature = "cairo")] pub mod lua; #[cfg(feature = "music")] @@ -37,10 +41,12 @@ pub struct Clients { sway: Option>, #[cfg(feature = "clipboard")] clipboard: Option>, + #[cfg(feature = "keys")] + libinput: HashMap, Arc>, #[cfg(feature = "cairo")] lua: Option>, #[cfg(feature = "music")] - music: std::collections::HashMap>, + music: HashMap>, #[cfg(feature = "network_manager")] network_manager: Option>, #[cfg(feature = "notifications")] @@ -111,6 +117,14 @@ impl Clients { .clone() } + #[cfg(feature = "keys")] + pub fn libinput(&mut self, seat: &str) -> Arc { + self.libinput + .entry(seat.into()) + .or_insert_with(|| libinput::create_client(seat.to_string())) + .clone() + } + #[cfg(feature = "music")] pub fn music(&mut self, client_type: music::ClientType) -> Arc { self.music diff --git a/src/config/mod.rs b/src/config/mod.rs index 0c3ece63..42874871 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -11,6 +11,8 @@ use crate::modules::clock::ClockModule; use crate::modules::custom::CustomModule; #[cfg(feature = "focused")] use crate::modules::focused::FocusedModule; +#[cfg(feature = "keys")] +use crate::modules::keys::KeysModule; use crate::modules::label::LabelModule; #[cfg(feature = "launcher")] use crate::modules::launcher::LauncherModule; @@ -59,6 +61,8 @@ pub enum ModuleConfig { Custom(Box), #[cfg(feature = "focused")] Focused(Box), + #[cfg(feature = "keys")] + Keys(Box), Label(Box), #[cfg(feature = "launcher")] Launcher(Box), @@ -106,6 +110,8 @@ impl ModuleConfig { Self::Custom(module) => create!(module), #[cfg(feature = "focused")] Self::Focused(module) => create!(module), + #[cfg(feature = "keys")] + Self::Keys(module) => create!(module), Self::Label(module) => create!(module), #[cfg(feature = "launcher")] Self::Launcher(module) => create!(module), diff --git a/src/main.rs b/src/main.rs index 4711b7c0..03e50910 100644 --- a/src/main.rs +++ b/src/main.rs @@ -205,7 +205,7 @@ impl Ironbar { }); { - let instance = instance2; + let instance = instance2.clone(); let app = app.clone(); glib::spawn_future_local(async move { diff --git a/src/modules/keys.rs b/src/modules/keys.rs new file mode 100644 index 00000000..ef642596 --- /dev/null +++ b/src/modules/keys.rs @@ -0,0 +1,176 @@ +use color_eyre::Result; +use gtk::prelude::*; +use serde::Deserialize; +use tokio::sync::mpsc; + +use super::{Module, ModuleInfo, ModuleParts, ModuleUpdateEvent, WidgetContext}; +use crate::clients::libinput::{Event, Key, KeyEvent}; +use crate::config::CommonConfig; +use crate::gtk_helpers::IronbarGtkExt; +use crate::image::new_icon_label; +use crate::{glib_recv, module_impl, send_async, spawn}; + +#[derive(Debug, Deserialize, Clone)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct KeysModule { + /// Whether to show capslock indicator. + #[serde(default = "crate::config::default_true")] + show_caps: bool, + /// Whether to show num lock indicator. + #[serde(default = "crate::config::default_true")] + show_num: bool, + /// Whether to show scroll lock indicator. + #[serde(default = "crate::config::default_true")] + show_scroll: bool, + + #[serde(default)] + icons: Icons, + + /// The Wayland seat to attach to. + /// You almost certainly do not need to change this. + #[serde(default = "default_seat")] + seat: String, + + /// See [common options](module-level-options#common-options). + #[serde(flatten)] + pub common: Option, +} + +#[derive(Debug, Deserialize, Clone)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +struct Icons { + #[serde(default = "default_icon_caps")] + caps: String, + #[serde(default = "default_icon_num")] + num: String, + #[serde(default = "default_icon_scroll")] + scroll: String, +} + +impl Default for Icons { + fn default() -> Self { + Self { + caps: default_icon_caps(), + num: default_icon_num(), + scroll: default_icon_scroll(), + } + } +} + +fn default_seat() -> String { + String::from("seat0") +} + +fn default_icon_caps() -> String { + String::from("󰪛") +} + +fn default_icon_num() -> String { + String::from("") +} + +fn default_icon_scroll() -> String { + String::from("") +} + +impl Module for KeysModule { + type SendMessage = KeyEvent; + type ReceiveMessage = (); + + module_impl!("keys"); + + fn spawn_controller( + &self, + _info: &ModuleInfo, + context: &WidgetContext, + _rx: mpsc::Receiver, + ) -> Result<()> { + let client = context.ironbar.clients.borrow_mut().libinput(&self.seat); + + let tx = context.tx.clone(); + spawn(async move { + let mut rx = client.subscribe(); + while let Ok(ev) = rx.recv().await { + match ev { + Event::Device => { + let caps_state = client.get_state(Key::Caps); + send_async!( + tx, + ModuleUpdateEvent::Update(KeyEvent { + key: Key::Caps, + state: caps_state + }) + ); + + let num_state = client.get_state(Key::Num); + send_async!( + tx, + ModuleUpdateEvent::Update(KeyEvent { + key: Key::Num, + state: num_state + }) + ); + + let scroll_state = client.get_state(Key::Scroll); + send_async!( + tx, + ModuleUpdateEvent::Update(KeyEvent { + key: Key::Scroll, + state: scroll_state + }) + ); + } + Event::Key(ev) => { + send_async!(tx, ModuleUpdateEvent::Update(ev)); + } + } + } + }); + + Ok(()) + } + + fn into_widget( + self, + context: WidgetContext, + info: &ModuleInfo, + ) -> Result> { + let container = gtk::Box::new(info.bar_position.orientation(), 5); + + let caps = new_icon_label(&self.icons.caps, info.icon_theme, 24); + let num = new_icon_label(&self.icons.num, info.icon_theme, 24); + let scroll = new_icon_label(&self.icons.scroll, info.icon_theme, 24); + + if self.show_caps { + caps.add_class("caps"); + container.add(&caps); + } + + if self.show_num { + caps.add_class("num"); + container.add(&num); + } + + if self.show_scroll { + caps.add_class("scroll"); + container.add(&scroll); + } + + let handle_event = move |ev: KeyEvent| { + let label = match ev.key { + Key::Caps if self.show_caps => Some(&caps), + Key::Num if self.show_num => Some(&num), + Key::Scroll if self.show_scroll => Some(&scroll), + _ => None, + }; + + if let Some(label) = label { + label.set_visible(ev.state); + } + }; + + glib_recv!(context.subscribe(), handle_event); + + Ok(ModuleParts::new(container, None)) + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index ae396834..dfcce622 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -31,6 +31,8 @@ pub mod clock; pub mod custom; #[cfg(feature = "focused")] pub mod focused; +#[cfg(feature = "keys")] +pub mod keys; pub mod label; #[cfg(feature = "launcher")] pub mod launcher;