> for Event {
+ type Error = Report;
+
+ fn try_from(data: KeyData) -> Result {
+ let key = Key::try_from(data.key)?;
+
+ key.get_state(data.device_path)
+ .map(|state| KeyEvent { key, state })
+ .map(Event::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(OwnedFd::from);
+
+ 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 init(seat: String) -> Arc {
+ let client = Arc::new(Self::new(seat));
+ {
+ let client = client.clone();
+ spawn_blocking(move || {
+ if let Err(err) = client.run() {
+ error!("{err:?}");
+ }
+ });
+ }
+ client
+ }
+
+ 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 Some(device) = (unsafe { event.device().udev_device() }) else {
+ continue;
+ };
+
+ let Some(
+ key @ (EV_KEY::KEY_CAPSLOCK
+ | EV_KEY::KEY_NUMLOCK
+ | EV_KEY::KEY_SCROLLLOCK),
+ ) = int_to_ev_key(event.key())
+ else {
+ continue;
+ };
+
+ if let Some(device_path) = device.devnode().map(PathBuf::from) {
+ 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 Some(device) = (unsafe { event.device().udev_device() }) else {
+ continue;
+ };
+
+ if let Some(device_path) = device.devnode() {
+ // not all devices which report as keyboards actually are one -
+ // fire test event so we can figure out if it is
+ let caps_event: Result = KeyData {
+ device_path,
+ key: EV_KEY::KEY_CAPSLOCK,
+ }
+ .try_into();
+
+ if caps_event.is_ok() {
+ debug!("new keyboard device: {name} | {}", device_path.display());
+ write_lock!(self.known_devices).push(device_path.to_path_buf());
+ 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(Result::ok)
+ .reduce(|state, curr| state || curr)
+ .unwrap_or_default()
+ }
+
+ pub fn subscribe(&self) -> broadcast::Receiver {
+ self.tx.subscribe()
+ }
+}
diff --git a/src/clients/mod.rs b/src/clients/mod.rs
index 7b6b312..c430921 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,8 @@ 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 +40,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 +116,14 @@ impl Clients {
.clone()
}
+ #[cfg(feature = "keys")]
+ pub fn libinput(&mut self, seat: &str) -> Arc {
+ self.libinput
+ .entry(seat.into())
+ .or_insert_with(|| libinput::Client::init(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 16191f5..b3bda4f 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/gtk_helpers.rs b/src/gtk_helpers.rs
index 1b5052b..5ce9357 100644
--- a/src/gtk_helpers.rs
+++ b/src/gtk_helpers.rs
@@ -20,6 +20,8 @@ pub struct WidgetGeometry {
pub trait IronbarGtkExt {
/// Adds a new CSS class to the widget.
fn add_class(&self, class: &str);
+ /// Removes a CSS class to the widget.
+ fn remove_class(&self, class: &str);
/// Gets the geometry for the widget
fn geometry(&self, orientation: Orientation) -> WidgetGeometry;
@@ -34,6 +36,10 @@ impl> IronbarGtkExt for W {
self.style_context().add_class(class);
}
+ fn remove_class(&self, class: &str) {
+ self.style_context().remove_class(class);
+ }
+
fn geometry(&self, orientation: Orientation) -> WidgetGeometry {
let allocation = self.allocation();
diff --git a/src/image/gtk.rs b/src/image/gtk.rs
index 8c17791..a35d544 100644
--- a/src/image/gtk.rs
+++ b/src/image/gtk.rs
@@ -1,7 +1,8 @@
use super::ImageProvider;
-use crate::gtk_helpers::IronbarGtkExt;
+use crate::gtk_helpers::{IronbarGtkExt, IronbarLabelExt};
use gtk::prelude::*;
use gtk::{Button, IconTheme, Image, Label, Orientation};
+use std::ops::Deref;
#[cfg(any(feature = "music", feature = "workspaces", feature = "clipboard"))]
pub fn new_icon_button(input: &str, icon_theme: &IconTheme, size: i32) -> Button {
@@ -30,26 +31,79 @@ pub fn new_icon_button(input: &str, icon_theme: &IconTheme, size: i32) -> Button
button
}
-#[cfg(feature = "music")]
-pub fn new_icon_label(input: &str, icon_theme: &IconTheme, size: i32) -> gtk::Box {
- let container = gtk::Box::new(Orientation::Horizontal, 0);
+#[cfg(any(feature = "music", feature = "keys"))]
+pub struct IconLabel {
+ container: gtk::Box,
+ label: Label,
+ image: Image,
+
+ icon_theme: IconTheme,
+ size: i32,
+}
+
+#[cfg(any(feature = "music", feature = "keys"))]
+impl IconLabel {
+ pub fn new(input: &str, icon_theme: &IconTheme, size: i32) -> Self {
+ let container = gtk::Box::new(Orientation::Horizontal, 0);
+
+ let label = Label::builder().use_markup(true).build();
+ label.add_class("icon");
+ label.add_class("text-icon");
- if ImageProvider::is_definitely_image_input(input) {
let image = Image::new();
image.add_class("icon");
image.add_class("image");
container.add(&image);
-
- ImageProvider::parse(input, icon_theme, false, size)
- .map(|provider| provider.load_into_image(&image));
- } else {
- let label = Label::builder().use_markup(true).label(input).build();
- label.add_class("icon");
- label.add_class("text-icon");
-
container.add(&label);
+
+ if ImageProvider::is_definitely_image_input(input) {
+ ImageProvider::parse(input, icon_theme, false, size)
+ .map(|provider| provider.load_into_image(&image));
+
+ image.show();
+ } else {
+ label.set_text(input);
+ label.show();
+ }
+
+ Self {
+ container,
+ label,
+ image,
+ icon_theme: icon_theme.clone(),
+ size,
+ }
}
- container
+ pub fn set_label(&self, input: Option<&str>) {
+ let label = &self.label;
+ let image = &self.image;
+
+ if let Some(input) = input {
+ if ImageProvider::is_definitely_image_input(input) {
+ ImageProvider::parse(input, &self.icon_theme, false, self.size)
+ .map(|provider| provider.load_into_image(image));
+
+ label.hide();
+ image.show();
+ } else {
+ label.set_label_escaped(input);
+
+ image.hide();
+ label.show();
+ }
+ } else {
+ label.hide();
+ image.hide();
+ }
+ }
+}
+
+impl Deref for IconLabel {
+ type Target = gtk::Box;
+
+ fn deref(&self) -> &Self::Target {
+ &self.container
+ }
}
diff --git a/src/macros.rs b/src/macros.rs
index bc565a9..2fe924f 100644
--- a/src/macros.rs
+++ b/src/macros.rs
@@ -41,7 +41,7 @@ macro_rules! send_async {
};
}
-/// Sends a message on an synchronous `Sender` using `send()`
+/// Sends a message on a synchronous `Sender` using `send()`
/// Panics if the message cannot be sent.
///
/// # Usage:
@@ -56,7 +56,7 @@ macro_rules! send {
};
}
-/// Sends a message on an synchronous `Sender` using `try_send()`
+/// Sends a message on a synchronous `Sender` using `try_send()`
/// Panics if the message cannot be sent.
///
/// # Usage:
@@ -71,6 +71,26 @@ macro_rules! try_send {
};
}
+/// Sends a message, wrapped inside a `ModuleUpdateEvent::Update` variant,
+/// on an asynchronous `Sender` using `send()`.
+///
+/// This is a convenience wrapper around `send_async`
+/// to avoid needing to write the full enum every time.
+///
+/// Panics if the message cannot be sent.
+///
+/// # Usage:
+///
+/// ```rs
+/// module_update!(tx, "my event");
+/// ```
+#[macro_export]
+macro_rules! module_update {
+ ($tx:expr, $msg:expr) => {
+ send_async!($tx, $crate::modules::ModuleUpdateEvent::Update($msg))
+ };
+}
+
/// Spawns a `GLib` future on the local thread, and calls `rx.recv()`
/// in a loop.
///
diff --git a/src/main.rs b/src/main.rs
index 4711b7c..03e5091 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 0000000..40dd196
--- /dev/null
+++ b/src/modules/keys.rs
@@ -0,0 +1,231 @@
+use color_eyre::Result;
+use gtk::prelude::*;
+use serde::Deserialize;
+use std::ops::Deref;
+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::IconLabel;
+use crate::{glib_recv, module_impl, module_update, send_async, spawn};
+
+#[derive(Debug, Deserialize, Clone)]
+#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
+pub struct KeysModule {
+ /// Whether to show capslock indicator.
+ ///
+ /// **Default**: `true`
+ #[serde(default = "crate::config::default_true")]
+ show_caps: bool,
+
+ /// Whether to show num lock indicator.
+ ///
+ /// **Default**: `true`
+ #[serde(default = "crate::config::default_true")]
+ show_num: bool,
+
+ /// Whether to show scroll lock indicator.
+ ///
+ /// **Default**: `true`
+ #[serde(default = "crate::config::default_true")]
+ show_scroll: bool,
+
+ /// Size to render the icons at, in pixels (image icons only).
+ ///
+ /// **Default** `32`
+ #[serde(default = "default_icon_size")]
+ icon_size: i32,
+
+ /// Player state icons.
+ ///
+ /// See [icons](#icons).
+ #[serde(default)]
+ icons: Icons,
+
+ /// The Wayland seat to attach to.
+ /// You almost certainly do not need to change this.
+ ///
+ /// **Default**: `seat0`
+ #[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 {
+ /// Icon to show when capslock is enabled.
+ ///
+ /// **Default**: ``
+ #[serde(default = "default_icon_caps")]
+ caps_on: String,
+
+ /// Icon to show when capslock is disabled.
+ ///
+ /// **Default**: `""`
+ #[serde(default)]
+ caps_off: String,
+
+ /// Icon to show when num lock is enabled.
+ ///
+ /// **Default**: ``
+ #[serde(default = "default_icon_num")]
+ num_on: String,
+
+ /// Icon to show when num lock is disabled.
+ ///
+ /// **Default**: `""`
+ #[serde(default)]
+ num_off: String,
+
+ /// Icon to show when scroll lock is enabled.
+ ///
+ /// **Default**: ``
+ #[serde(default = "default_icon_scroll")]
+ scroll_on: String,
+
+ /// Icon to show when scroll lock is disabled.
+ ///
+ /// **Default**: `""`
+ #[serde(default)]
+ scroll_off: String,
+}
+
+impl Default for Icons {
+ fn default() -> Self {
+ Self {
+ caps_on: default_icon_caps(),
+ caps_off: String::new(),
+ num_on: default_icon_num(),
+ num_off: String::new(),
+ scroll_on: default_icon_scroll(),
+ scroll_off: String::new(),
+ }
+ }
+}
+
+const fn default_icon_size() -> i32 {
+ 32
+}
+
+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 => {
+ for key in [Key::Caps, Key::Num, Key::Scroll] {
+ module_update!(
+ tx,
+ KeyEvent {
+ key: Key::Caps,
+ state: client.get_state(key)
+ }
+ );
+ }
+ }
+ 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 = IconLabel::new(&self.icons.caps_off, info.icon_theme, self.icon_size);
+ let num = IconLabel::new(&self.icons.num_off, info.icon_theme, self.icon_size);
+ let scroll = IconLabel::new(&self.icons.scroll_off, info.icon_theme, self.icon_size);
+
+ if self.show_caps {
+ caps.add_class("key");
+ caps.add_class("caps");
+ container.add(caps.deref());
+ }
+
+ if self.show_num {
+ num.add_class("key");
+ num.add_class("num");
+ container.add(num.deref());
+ }
+
+ if self.show_scroll {
+ scroll.add_class("key");
+ scroll.add_class("scroll");
+ container.add(scroll.deref());
+ }
+
+ let icons = self.icons;
+ let handle_event = move |ev: KeyEvent| {
+ let parts = match (ev.key, ev.state) {
+ (Key::Caps, true) if self.show_caps => Some((&caps, icons.caps_on.as_str())),
+ (Key::Caps, false) if self.show_caps => Some((&caps, icons.caps_off.as_str())),
+ (Key::Num, true) if self.show_num => Some((&num, icons.num_on.as_str())),
+ (Key::Num, false) if self.show_num => Some((&num, icons.num_off.as_str())),
+ (Key::Scroll, true) if self.show_scroll => {
+ Some((&scroll, icons.scroll_on.as_str()))
+ }
+ (Key::Scroll, false) if self.show_scroll => {
+ Some((&scroll, icons.scroll_off.as_str()))
+ }
+ _ => None,
+ };
+
+ if let Some((label, input)) = parts {
+ label.set_label(Some(input));
+
+ if ev.state {
+ label.add_class("enabled");
+ } else {
+ label.remove_class("enabled");
+ }
+ }
+ };
+
+ glib_recv!(context.subscribe(), handle_event);
+ Ok(ModuleParts::new(container, None))
+ }
+}
diff --git a/src/modules/mod.rs b/src/modules/mod.rs
index ae39683..dfcce62 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;
diff --git a/src/modules/music/mod.rs b/src/modules/music/mod.rs
index 53b6baa..d11684b 100644
--- a/src/modules/music/mod.rs
+++ b/src/modules/music/mod.rs
@@ -1,4 +1,5 @@
use std::cell::RefMut;
+use std::ops::Deref;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
@@ -17,7 +18,7 @@ use crate::clients::music::{
};
use crate::clients::Clients;
use crate::gtk_helpers::{IronbarGtkExt, IronbarLabelExt};
-use crate::image::{new_icon_button, new_icon_label, ImageProvider};
+use crate::image::{new_icon_button, IconLabel, ImageProvider};
use crate::modules::PopupButton;
use crate::modules::{
Module, ModuleInfo, ModuleParts, ModulePopup, ModuleUpdateEvent, WidgetContext,
@@ -187,8 +188,8 @@ impl Module