diff --git a/.github/scripts/ubuntu_setup.sh b/.github/scripts/ubuntu_setup.sh index 74b93d2..b36dfea 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 770e1b0..7001467 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -859,6 +859,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" @@ -1648,6 +1671,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" @@ -1685,6 +1727,7 @@ dependencies = [ "color-eyre", "ctrlc", "dirs", + "evdev-rs", "futures-lite 2.5.0", "futures-signals", "futures-util", @@ -1693,6 +1736,8 @@ dependencies = [ "gtk-layer-shell", "hyprland", "indexmap", + "input", + "libc", "libpulse-binding", "lua-src", "mlua", @@ -1773,9 +1818,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" @@ -1837,6 +1882,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" @@ -1851,12 +1906,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" @@ -3586,6 +3638,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 4f7d337..df70fa6 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.39", optional = true, default-features = false, features = ["clock", "unstable-locales"] } +# keys +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/docs/Compiling.md b/docs/Compiling.md index ceafa75..278fda3 100644 --- a/docs/Compiling.md +++ b/docs/Compiling.md @@ -26,6 +26,8 @@ pacman -S openssl pacman -S libdbusmenu-gtk3 # for volume support pacman -S libpulse +# for keys support +pacman -S libinput # for lua/cairo support pacman -S luajit lua51-lgi ``` @@ -40,6 +42,8 @@ apt install libssl-dev apt install libdbusmenu-gtk3-dev # for volume support apt install libpulse-dev +# for keys support +apt install libinput-dev # for lua/cairo support apt install luajit-dev lua-lgi ``` @@ -54,6 +58,8 @@ dnf install openssl-devel dnf install libdbusmenu-gtk3-devel # for volume support dnf install pulseaudio-libs-devel +# for keys support +dnf install libinput-devel # for lua/cairo support dnf install luajit-devel lua-lgi ``` diff --git a/docs/modules/Keys.md b/docs/modules/Keys.md new file mode 100644 index 0000000..0fcc5d1 --- /dev/null +++ b/docs/modules/Keys.md @@ -0,0 +1,102 @@ +> [!NOTE] +> This module requires your user is in the `input` group. + +Displays the toggle state of the capslock, num lock and scroll lock keys. + +![Screenshot of clock widget with popup open](https://f.jstanger.dev/github/ironbar/keys.png) + +## Configuration + +> Type: `keys` + +| Name | Type | Default | Description | +|--------------------|-----------------------------|---------|--------------------------------------------------| +| `show_caps` | `boolean` | `true` | Whether to show capslock indicator. | +| `show_num` | `boolean` | `true` | Whether to show num lock indicator. | +| `show_scroll` | `boolean` | `true` | Whether to show scroll lock indicator. | +| `icon_size` | `integer` | `32` | Size to render icon at (image icons only). | +| `icons.caps_on` | `string` or [image](images) | `󰪛` | Icon to show for enabled capslock indicator. | +| `icons.caps_off` | `string` or [image](images) | `''` | Icon to show for disabled capslock indicator. | +| `icons.num_on` | `string` or [image](images) | `` | Icon to show for enabled num lock indicator. | +| `icons.num_off` | `string` or [image](images) | `''` | Icon to show for disabled num lock indicator. | +| `icons.scroll_on` | `string` or [image](images) | `` | Icon to show for enabled scroll lock indicator. | +| `icons.scroll_off` | `string` or [image](images) | `''` | Icon to show for disabled scroll lock indicator. | +| `seat` | `string` | `seat0` | ID of the Wayland seat to attach to. | + +
+JSON + +```json +{ + "end": [ + { + "type": "keys", + "show_scroll": false, + "icons": { + "caps_on": "󰪛" + } + } + ] +} +``` + +
+ +
+TOML + +```toml +[[end]] +type = "keys" +show_scroll = false + +[end.icons] +caps_on = "󰪛" +``` + +
+ +
+YAML + +```yaml +end: + - type: keys + show_scroll: false + icons: + caps_on: 󰪛 +``` + +
+ +
+Corn + +```corn +{ +end = [ + { + type = "keys" + show_scroll = false + icons.caps_on = "󰪛" + } + ] +} +``` + +
+ +## Styling + +| Selector | Description | +|------------------------|--------------------------------------------| +| `.keys` | Keys box container widget. | +| `.keys .key` | Individual key indicator container widget. | +| `.keys .key.enabled` | Key indicator where key is toggled on. | +| `.keys .key.caps` | Capslock key indicator. | +| `.keys .key.num` | Num lock key indicator. | +| `.keys .key.scroll` | Scroll lock key indicator. | +| `.keys .key.image` | Key indicator image icon. | +| `.keys .key.text-icon` | Key indicator textual icon. | + +For more information on styling, please see the [styling guide](styling-guide). diff --git a/docs/modules/Music.md b/docs/modules/Music.md index bdde681..148804d 100644 --- a/docs/modules/Music.md +++ b/docs/modules/Music.md @@ -33,8 +33,6 @@ in MPRIS mode, the widget will listen to all players and automatically detect/di | `host` | `string` | `localhost:6600` | [MPD Only] TCP or Unix socket for the MPD server. | | `music_dir` | `string` | `$HOME/Music` | [MPD Only] Path to MPD server's music directory on disc. Required for album art. | -See [here](images) for information on images. -
JSON diff --git a/examples/test.corn b/examples/test.corn deleted file mode 100644 index 219e303..0000000 --- a/examples/test.corn +++ /dev/null @@ -1,60 +0,0 @@ -let { - $config_dir = "/home/jake/.config/ironbar" - - $workspaces = { type = "workspaces" } - $launcher = { type = "launcher" } - $volume = { - type = "volume" - format = "{icon} {percentage}%" - max_volume = 100 - icons.volume_high = "󰕾" - icons.volume_medium = "󰖀" - icons.volume_low = "󰕿" - icons.muted = "󰝟" - } - $network_manager = { type = "networkmanager" } - $clock = { - type = "clock" - // disable_popup = true - // format = "%d/%m/%Y %H:%M:%S" - } - $tray = { type = "tray" prefer_theme_icons = false } - - // $label = { type = "label" label = "hello" } - $label = { type = "label" label = "#random" } - $clipboard = { type = "clipboard" } - - $notifications = { - type = "notifications" - show_count = true - - icons.closed_none = "󰍥" - icons.closed_some = "󱥂" - icons.closed_dnd = "󱅯" - icons.open_none = "󰍡" - icons.open_some = "󱥁" - icons.open_dnd = "󱅮" - } - - $focused = { type = "focused" } - - $cairo = { type = "cairo" path = "$config_dir/clock.lua" frequency = 50 width = 300 height = 300 } - - $custom = { - type = "custom" - bar = [ { type = "button" on_click = "popup:toggle" widgets = [ $focused ] } ] - popup = [ { type = "box" orientation = "v" widgets = [ $clock $cairo ] } ] - } - - $mpris = { type = "music" } -} in { - // ironvar_defaults.color = "red" - - position = "bottom" - - icon_theme = "Paper" - - start = [ $workspaces $label ] - center = [ $custom ] - end = [ $notifications $clock ] -} \ No newline at end of file diff --git a/flake.nix b/flake.nix index 888157c..33bced5 100644 --- a/flake.nix +++ b/flake.nix @@ -130,6 +130,8 @@ libxkbcommon libdbusmenu-gtk3 libpulseaudio + libinput + libevdev luajit luajitPackages.lgi ]; diff --git a/nix/default.nix b/nix/default.nix index f5ac597..e9c5dff 100644 --- a/nix/default.nix +++ b/nix/default.nix @@ -14,6 +14,8 @@ libxkbcommon, libdbusmenu-gtk3, libpulseaudio, + libinput, + libevdev, openssl, luajit, luajitPackages, @@ -59,7 +61,8 @@ ++ lib.optionals (hasFeature "http") [ openssl ] ++ lib.optionals (hasFeature "tray") [ libdbusmenu-gtk3 ] ++ lib.optionals (hasFeature "volume")[ libpulseaudio ] - ++ lib.optionals (hasFeature "cairo") [ luajit ]; + ++ lib.optionals (hasFeature "cairo") [ luajit ] + ++ lib.optionals (hasFeature "keys") [ libinput libevdev ]; propagatedBuildInputs = [ gtk3 ]; diff --git a/shell.nix b/shell.nix index 17db0f7..71c2cda 100644 --- a/shell.nix +++ b/shell.nix @@ -11,6 +11,8 @@ pkgs.mkShell { openssl libdbusmenu-gtk3 libpulseaudio + libinput + libevdev luajit luajitPackages.lgi ]; diff --git a/src/clients/libinput.rs b/src/clients/libinput.rs new file mode 100644 index 0000000..9259e00 --- /dev/null +++ b/src/clients/libinput.rs @@ -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 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 for Key { + type Error = Report; + + fn try_from(value: EV_KEY) -> std::result::Result { + 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>(self, device_path: P) -> Result { + 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> { + device_path: P, + key: EV_KEY, +} + +impl> TryFrom> 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