1
0
Fork 0
mirror of https://github.com/Zedfrigg/ironbar.git synced 2025-08-17 06:41:03 +02:00

feat: libinput keys module

Adds a new module which shows the status of toggle mod keys (capslock, num lock, scroll lock).

Resolves #700
This commit is contained in:
Jake Stanger 2024-11-17 23:46:02 +00:00
parent 353ee92d48
commit ccfe73f6a7
No known key found for this signature in database
GPG key ID: C51FC8F9CB0BEA61
20 changed files with 799 additions and 107 deletions

235
src/clients/libinput.rs Normal file
View file

@ -0,0 +1,235 @@
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, PathBuf};
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<Key> 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 TryFrom<EV_KEY> for Key {
type Error = Report;
fn try_from(value: EV_KEY) -> std::result::Result<Self, Self::Error> {
match value {
EV_KEY::KEY_CAPSLOCK => Ok(Key::Caps),
EV_KEY::KEY_NUMLOCK => Ok(Key::Num),
EV_KEY::KEY_SCROLLLOCK => Ok(Key::Scroll),
_ => Err(Report::msg("provided key is not supported toggle key")),
}
}
}
impl Key {
fn get_state<P: AsRef<Path>>(self, device_path: P) -> Result<bool> {
let device = evdev_rs::Device::new_from_path(device_path)?;
match self {
Self::Caps => device.event_value(&EventCode::EV_LED(EV_LED::LED_CAPSL)),
Self::Num => device.event_value(&EventCode::EV_LED(EV_LED::LED_NUML)),
Self::Scroll => device.event_value(&EventCode::EV_LED(EV_LED::LED_SCROLLL)),
}
.map(|v| v > 0)
.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),
}
struct KeyData<P: AsRef<Path>> {
device_path: P,
key: EV_KEY,
}
impl<P: AsRef<Path>> TryFrom<KeyData<P>> for Event {
type Error = Report;
fn try_from(data: KeyData<P>) -> Result<Self> {
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<OwnedFd, i32> {
// 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<Event>,
_rx: broadcast::Receiver<Event>,
seat: String,
known_devices: Arc<RwLock<Vec<PathBuf>>>,
}
impl Client {
pub fn init(seat: String) -> Arc<Self> {
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<Event> = 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<Event> {
self.tx.subscribe()
}
}

View file

@ -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<Arc<sway::Client>>,
#[cfg(feature = "clipboard")]
clipboard: Option<Arc<clipboard::Client>>,
#[cfg(feature = "keys")]
libinput: HashMap<Box<str>, Arc<libinput::Client>>,
#[cfg(feature = "cairo")]
lua: Option<Rc<lua::LuaEngine>>,
#[cfg(feature = "music")]
music: std::collections::HashMap<music::ClientType, Arc<dyn music::MusicClient>>,
music: HashMap<music::ClientType, Arc<dyn music::MusicClient>>,
#[cfg(feature = "network_manager")]
network_manager: Option<Arc<networkmanager::Client>>,
#[cfg(feature = "notifications")]
@ -111,6 +116,14 @@ impl Clients {
.clone()
}
#[cfg(feature = "keys")]
pub fn libinput(&mut self, seat: &str) -> Arc<libinput::Client> {
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<dyn music::MusicClient> {
self.music

View file

@ -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<CustomModule>),
#[cfg(feature = "focused")]
Focused(Box<FocusedModule>),
#[cfg(feature = "keys")]
Keys(Box<KeysModule>),
Label(Box<LabelModule>),
#[cfg(feature = "launcher")]
Launcher(Box<LauncherModule>),
@ -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),

View file

@ -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<W: IsA<Widget>> 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();

View file

@ -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
}
}

View file

@ -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.
///

View file

@ -205,7 +205,7 @@ impl Ironbar {
});
{
let instance = instance2;
let instance = instance2.clone();
let app = app.clone();
glib::spawn_future_local(async move {

231
src/modules/keys.rs Normal file
View file

@ -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<CommonConfig>,
}
#[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<gtk::Box> for KeysModule {
type SendMessage = KeyEvent;
type ReceiveMessage = ();
module_impl!("keys");
fn spawn_controller(
&self,
_info: &ModuleInfo,
context: &WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
_rx: mpsc::Receiver<Self::ReceiveMessage>,
) -> 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<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> Result<ModuleParts<gtk::Box>> {
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))
}
}

View file

@ -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;

View file

@ -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<Button> for MusicModule {
button.add(&button_contents);
let icon_play = new_icon_label(&self.icons.play, info.icon_theme, self.icon_size);
let icon_pause = new_icon_label(&self.icons.pause, info.icon_theme, self.icon_size);
let icon_play = IconLabel::new(&self.icons.play, info.icon_theme, self.icon_size);
let icon_pause = IconLabel::new(&self.icons.pause, info.icon_theme, self.icon_size);
let label = Label::builder()
.use_markup(true)
@ -199,8 +200,8 @@ impl Module<Button> for MusicModule {
label.truncate(truncate);
}
button_contents.add(&icon_pause);
button_contents.add(&icon_play);
button_contents.add(icon_pause.deref());
button_contents.add(icon_play.deref());
button_contents.add(&label);
{
@ -282,9 +283,9 @@ impl Module<Button> for MusicModule {
let icons = self.icons;
let info_box = gtk::Box::new(Orientation::Vertical, 10);
let title_label = IconLabel::new(&icons.track, None, icon_theme);
let album_label = IconLabel::new(&icons.album, None, icon_theme);
let artist_label = IconLabel::new(&icons.artist, None, icon_theme);
let title_label = IconPrefixedLabel::new(&icons.track, None, icon_theme);
let album_label = IconPrefixedLabel::new(&icons.album, None, icon_theme);
let artist_label = IconPrefixedLabel::new(&icons.artist, None, icon_theme);
title_label.container.add_class("title");
album_label.container.add_class("album");
@ -323,11 +324,11 @@ impl Module<Button> for MusicModule {
volume_slider.set_inverted(true);
volume_slider.add_class("slider");
let volume_icon = new_icon_label(&icons.volume, icon_theme, self.icon_size);
let volume_icon = IconLabel::new(&icons.volume, icon_theme, self.icon_size);
volume_icon.add_class("icon");
volume_box.pack_start(&volume_slider, true, true, 0);
volume_box.pack_end(&volume_icon, false, false, 0);
volume_box.pack_end(volume_icon.deref(), false, false, 0);
main_container.add(&album_image);
main_container.add(&info_box);
@ -496,7 +497,7 @@ impl Module<Button> for MusicModule {
}
}
fn update_popup_metadata_label(text: Option<String>, label: &IconLabel) {
fn update_popup_metadata_label(text: Option<String>, label: &IconPrefixedLabel) {
match text {
Some(value) => {
label.label.set_label_escaped(&value);
@ -536,16 +537,16 @@ fn get_token_value(song: &Track, token: &str) -> String {
}
#[derive(Clone, Debug)]
struct IconLabel {
struct IconPrefixedLabel {
label: Label,
container: gtk::Box,
}
impl IconLabel {
impl IconPrefixedLabel {
fn new(icon_input: &str, label: Option<&str>, icon_theme: &IconTheme) -> Self {
let container = gtk::Box::new(Orientation::Horizontal, 5);
let icon = new_icon_label(icon_input, icon_theme, 24);
let icon = IconLabel::new(icon_input, icon_theme, 24);
let mut builder = Label::builder().use_markup(true);
@ -558,7 +559,7 @@ impl IconLabel {
icon.add_class("icon-box");
label.add_class("label");
container.add(&icon);
container.add(icon.deref());
container.add(&label);
Self { label, container }