From 994887445436602c735b11bd0c8af0b2fd9eb343 Mon Sep 17 00:00:00 2001 From: "Axel H." Date: Fri, 15 May 2026 20:09:07 +0200 Subject: [PATCH] feat(keyboard_locks): add Caps/Num/Scroll lock indicators with click-to-toggle --- Cargo.lock | 69 ++- Cargo.toml | 1 + README.md | 1 + src/app.rs | 11 + src/app/message.rs | 1 + src/components/icons.rs | 6 + src/config.rs | 37 ++ src/modules/keyboard_locks.rs | 141 +++++ src/modules/mod.rs | 10 + src/services/keyboard_locks.rs | 527 ++++++++++++++++++ src/services/mod.rs | 1 + website/docs/configuration/modules/index.md | 4 + .../docs/configuration/modules/keyboard.md | 82 ++- 13 files changed, 888 insertions(+), 3 deletions(-) create mode 100644 src/modules/keyboard_locks.rs create mode 100644 src/services/keyboard_locks.rs diff --git a/Cargo.lock b/Cargo.lock index 55cb64b06..cf70dd9c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -143,6 +143,7 @@ dependencies = [ "chrono", "chrono-tz", "clap", + "evdev", "flexi_logger", "freedesktop-icons", "hex_color", @@ -343,6 +344,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block" version = "0.1.6" @@ -1014,6 +1027,19 @@ dependencies = [ "num-traits", ] +[[package]] +name = "evdev" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b686663ba7f08d92880ff6ba22170f1df4e83629341cba34cf82cd65ebea99" +dependencies = [ + "bitvec", + "cfg-if", + "libc", + "nix 0.29.0", + "tokio", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -1265,6 +1291,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.32" @@ -2518,7 +2550,7 @@ dependencies = [ "cookie-factory", "libc", "libspa-sys", - "nix", + "nix 0.30.1", "nom 8.0.0", "system-deps", ] @@ -2828,6 +2860,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.12.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nix" version = "0.30.1" @@ -3216,7 +3260,7 @@ dependencies = [ "libc", "libspa", "libspa-sys", - "nix", + "nix 0.30.1", "once_cell", "pipewire-sys", "thiserror 2.0.18", @@ -3491,6 +3535,12 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.6" @@ -4433,6 +4483,12 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "target-lexicon" version = "0.13.5" @@ -5985,6 +6041,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "xcursor" version = "0.3.10" diff --git a/Cargo.toml b/Cargo.toml index cbc6ee7fb..afe904200 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -99,6 +99,7 @@ i18n-embed = { version = "0.16", default-features = false, features = [ i18n-embed-fl = "0.10" rust-embed = "8" unic-langid = "0.9" +evdev = { version = "0.13.2", features = ["tokio"] } [patch.crates-io] # Patched softbuffer that uses wl_shm Argb8888 so the tiny-skia fallback diff --git a/README.md b/README.md index c9b6e9e25..ca27e0b5e 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ - System Information (CPU, RAM, Disk, IP address, Network speed, Temperature) with warn/alert thresholds - Keyboard Layout with custom labels (Hyprland/Niri/MangoWC) - Keyboard Submap (Hyprland/MangoWC) +- Keyboard Locks indicator (Caps Lock, Num Lock, Scroll Lock) - System Tray with context menus - Clock with calendar, weather, timezone cycling, and format cycling (Tempo) - Privacy indicators (microphone, camera, and screenshare usage) diff --git a/src/app.rs b/src/app.rs index 831660edf..cd8fe2f68 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,6 +9,7 @@ use crate::{ self, custom_module::Custom, keyboard_layout::KeyboardLayout, + keyboard_locks::KeyboardLocks, keyboard_submap::KeyboardSubmap, media_player::MediaPlayer, notifications::Notifications, @@ -62,6 +63,7 @@ pub struct App { pub window_title: WindowTitle, pub system_info: SystemInfo, pub keyboard_layout: KeyboardLayout, + pub keyboard_locks: KeyboardLocks, pub keyboard_submap: KeyboardSubmap, pub tray: TrayModule, pub tempo: Tempo, @@ -132,6 +134,7 @@ impl App { window_title: WindowTitle::new(config.window_title), system_info: SystemInfo::new(config.system_info), keyboard_layout: KeyboardLayout::new(config.keyboard_layout), + keyboard_locks: KeyboardLocks::new(config.keyboard_locks), keyboard_submap: KeyboardSubmap::default(), tray: TrayModule::new(config.tray), tempo: Tempo::new(config.tempo), @@ -197,6 +200,10 @@ impl App { .map(Message::KeyboardLayout); self.keyboard_submap = KeyboardSubmap::default(); + self.keyboard_locks + .update(modules::keyboard_locks::Message::ConfigReloaded( + config.keyboard_locks, + )); self.tempo .update(modules::tempo::Message::ConfigReloaded(config.tempo)); self.settings @@ -342,6 +349,10 @@ impl App { self.keyboard_submap.update(message); Task::none() } + Message::KeyboardLocks(message) => { + self.keyboard_locks.update(message); + Task::none() + } Message::Tray(msg) => match self.tray.update(msg) { modules::tray::Action::None => Task::none(), modules::tray::Action::ToggleMenu(name, id, button_ui_ref) => { diff --git a/src/app/message.rs b/src/app/message.rs index 82efe48ff..89c3c4bdd 100644 --- a/src/app/message.rs +++ b/src/app/message.rs @@ -19,6 +19,7 @@ pub enum Message { WindowTitle(modules::window_title::Message), SystemInfo(modules::system_info::Message), KeyboardLayout(modules::keyboard_layout::Message), + KeyboardLocks(modules::keyboard_locks::Message), KeyboardSubmap(modules::keyboard_submap::Message), Tray(modules::tray::Message), Tempo(modules::tempo::Message), diff --git a/src/components/icons.rs b/src/components/icons.rs index 949b7611c..ae3bafebf 100644 --- a/src/components/icons.rs +++ b/src/components/icons.rs @@ -106,6 +106,9 @@ pub enum StaticIcon { LeftChevron, RightChevron, Keyboard, + CapsLock, + NumLock, + ScrollLock, Mouse, Gamepad, KeyboardBatteryFull, @@ -222,6 +225,9 @@ impl StaticIcon { StaticIcon::LeftChevron => "\u{f0141}", StaticIcon::RightChevron => "\u{f0142}", StaticIcon::Keyboard => "\u{f030c}", + StaticIcon::CapsLock => "\u{f0a9b}", + StaticIcon::NumLock => "\u{f03a6}", + StaticIcon::ScrollLock => "\u{f0792}", StaticIcon::Mouse => "\u{f037d}", StaticIcon::Gamepad => "\u{f05ba}", StaticIcon::KeyboardBatteryFull => "\u{c0000}", diff --git a/src/config.rs b/src/config.rs index 3c5eb1383..20f4defc3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -43,6 +43,7 @@ pub struct Config { pub appearance: Appearance, pub media_player: MediaPlayerModuleConfig, pub keyboard_layout: KeyboardLayoutModuleConfig, + pub keyboard_locks: KeyboardLocksModuleConfig, pub animations: AnimationsConfig, pub enable_esc_key: bool, pub osd: OsdConfig, @@ -69,6 +70,7 @@ impl Default for Config { appearance: Appearance::default(), media_player: MediaPlayerModuleConfig::default(), keyboard_layout: KeyboardLayoutModuleConfig::default(), + keyboard_locks: KeyboardLocksModuleConfig::default(), animations: AnimationsConfig::default(), custom_modules: vec![], enable_esc_key: false, @@ -182,6 +184,39 @@ pub struct KeyboardLayoutModuleConfig { pub labels: HashMap, } +#[derive(Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum LockVisibility { + AlwaysVisible, + #[default] + ActiveOnly, +} + +#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(default)] +pub struct LockIndicatorConfig { + pub enabled: bool, + pub visibility: LockVisibility, + pub icon: Option, +} + +#[derive(Deserialize, Clone, Debug, Default, PartialEq, Eq)] +#[serde(default)] +pub struct KeyboardLocksModuleConfig { + pub caps_lock: LockIndicatorConfig, + pub num_lock: LockIndicatorConfig, + pub scroll_lock: LockIndicatorConfig, +} + +impl Default for LockIndicatorConfig { + fn default() -> Self { + Self { + enabled: true, + visibility: LockVisibility::ActiveOnly, + icon: None, + } + } +} + #[derive(Deserialize, Clone, Debug)] #[serde(default)] pub struct SystemInfoCpu { @@ -1151,6 +1186,7 @@ pub enum ModuleName { WindowTitle, SystemInfo, KeyboardLayout, + KeyboardLocks, KeyboardSubmap, Tray, Tempo, @@ -1182,6 +1218,7 @@ impl<'de> Deserialize<'de> for ModuleName { "WindowTitle" => ModuleName::WindowTitle, "SystemInfo" => ModuleName::SystemInfo, "KeyboardLayout" => ModuleName::KeyboardLayout, + "KeyboardLocks" => ModuleName::KeyboardLocks, "KeyboardSubmap" => ModuleName::KeyboardSubmap, "Tray" => ModuleName::Tray, "Notifications" => ModuleName::Notifications, diff --git a/src/modules/keyboard_locks.rs b/src/modules/keyboard_locks.rs new file mode 100644 index 000000000..8693a4aba --- /dev/null +++ b/src/modules/keyboard_locks.rs @@ -0,0 +1,141 @@ +use crate::{ + components::icons::{IconKind, StaticIcon, icon_mono}, + config::{KeyboardLocksModuleConfig, LockIndicatorConfig, LockVisibility}, + services::{ + ReadOnlyService, Service, ServiceEvent, + keyboard_locks::{KeyboardLocksCommand, KeyboardLocksService, LockKind}, + }, + theme::use_theme, +}; +use iced::{ + Alignment, Element, Length, Subscription, + widget::{Row, button, container}, +}; + +#[derive(Debug, Clone)] +pub enum Message { + ServiceEvent(ServiceEvent), + ConfigReloaded(KeyboardLocksModuleConfig), + Toggle(LockKind), +} + +pub struct KeyboardLocks { + config: KeyboardLocksModuleConfig, + service: Option, +} + +impl KeyboardLocks { + pub fn new(config: KeyboardLocksModuleConfig) -> Self { + Self { + config, + service: None, + } + } + + pub fn update(&mut self, message: Message) { + match message { + Message::ServiceEvent(event) => match event { + ServiceEvent::Init(service) => self.service = Some(service), + ServiceEvent::Update(update) => { + if let Some(service) = self.service.as_mut() { + service.update(update); + } + } + ServiceEvent::Error(_) => {} + }, + Message::ConfigReloaded(config) => self.config = config, + Message::Toggle(kind) => { + if let Some(service) = self.service.as_mut() { + let _ = service.command(KeyboardLocksCommand::Toggle(kind)); + } + } + } + } + + pub fn view(&self) -> Option> { + let data = self.service.as_ref().map(|s| s.data).unwrap_or_default(); + let inactive_color = use_theme(|t| t.iced_theme.extended_palette().background.strong.color); + + let entries = [ + ( + &self.config.caps_lock, + data.caps_lock, + StaticIcon::CapsLock, + LockKind::Caps, + ), + ( + &self.config.num_lock, + data.num_lock, + StaticIcon::NumLock, + LockKind::Num, + ), + ( + &self.config.scroll_lock, + data.scroll_lock, + StaticIcon::ScrollLock, + LockKind::Scroll, + ), + ]; + + let mut row = Row::new(); + let mut any_visible = false; + for (cfg, active, default_icon, kind) in entries { + if let Some(element) = render_indicator(cfg, active, default_icon, kind, inactive_color) + { + row = row.push(element); + any_visible = true; + } + } + + if any_visible { Some(row.into()) } else { None } + } + + pub fn subscription(&self) -> Subscription { + KeyboardLocksService::subscribe().map(Message::ServiceEvent) + } +} + +fn render_indicator<'a>( + cfg: &'a LockIndicatorConfig, + active: bool, + default_icon: StaticIcon, + kind: LockKind, + inactive_color: iced::Color, +) -> Option> { + if !cfg.enabled { + return None; + } + let visible = match cfg.visibility { + LockVisibility::ActiveOnly => active, + LockVisibility::AlwaysVisible => true, + }; + if !visible { + return None; + } + + let icon_kind: IconKind = match cfg.icon.as_ref() { + Some(glyph) => IconKind::Dynamic(glyph.clone()), + None => IconKind::from(default_icon), + }; + + let (font_size, space, module_button_style) = + use_theme(|t| (t.font_size.xs, t.space, t.module_button_style())); + + let glyph = icon_mono(icon_kind) + .size(font_size) + .color_maybe((!active).then_some(inactive_color)); + + Some( + button( + container(glyph) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .height(Length::Fill), + ) + .padding([0.0, space.xxs]) + .height(Length::Fill) + .style(module_button_style) + .on_press(Message::Toggle(kind)) + .into(), + ) +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 85f63bfc2..a7f92f08d 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -10,6 +10,7 @@ use iced::{Alignment, Element, Length, Subscription, SurfaceId, widget::Row}; pub mod custom_module; pub mod keyboard_layout; +pub mod keyboard_locks; pub mod keyboard_submap; pub mod media_player; pub mod notifications; @@ -287,6 +288,10 @@ impl App { .keyboard_submap .view() .map(|view| (view.map(Message::KeyboardSubmap), None)), + ModuleName::KeyboardLocks => self + .keyboard_locks + .view() + .map(|view| (view.map(Message::KeyboardLocks), None)), ModuleName::Tray => self .tray .view(id) @@ -353,6 +358,11 @@ impl App { .subscription() .map(Message::KeyboardSubmap), ), + ModuleName::KeyboardLocks => Some( + self.keyboard_locks + .subscription() + .map(Message::KeyboardLocks), + ), ModuleName::Tray => Some(self.tray.subscription().map(Message::Tray)), ModuleName::Tempo => Some(self.tempo.subscription().map(Message::Tempo)), ModuleName::Privacy => Some(self.privacy.subscription().map(Message::Privacy)), diff --git a/src/services/keyboard_locks.rs b/src/services/keyboard_locks.rs new file mode 100644 index 000000000..682abfe9d --- /dev/null +++ b/src/services/keyboard_locks.rs @@ -0,0 +1,527 @@ +use super::{ReadOnlyService, Service, ServiceEvent}; +use evdev::{ + AttributeSet, Device, EventStream, EventSummary, KeyCode, KeyEvent, LedCode, + SynchronizationCode, SynchronizationEvent, uinput::VirtualDevice, +}; +use iced::{ + Subscription, Task, + futures::{ + SinkExt, StreamExt, + channel::mpsc::Sender, + stream::{self, BoxStream, SelectAll, pending}, + }, + stream::channel, +}; +use log::{debug, info, warn}; +use std::{ + any::TypeId, + collections::HashMap, + io, + path::{Path, PathBuf}, + time::Duration, +}; +use tokio::{ + io::{Interest, unix::AsyncFd}, + sync::mpsc::{UnboundedReceiver, UnboundedSender}, +}; + +/// Aggregate LED state across all keyboards. +/// +/// A lock is considered "on" if any monitored keyboard reports it as on. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct KeyboardLocksData { + pub caps_lock: bool, + pub num_lock: bool, + pub scroll_lock: bool, +} + +/// One of the three keyboard locks we track. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LockKind { + Caps, + Num, + Scroll, +} + +#[derive(Debug, Clone)] +pub struct KeyboardLocksService { + pub data: KeyboardLocksData, + commander: UnboundedSender, +} + +#[derive(Debug, Clone)] +pub struct KeyboardLocksEvent(pub KeyboardLocksData); + +#[derive(Debug, Clone)] +pub enum KeyboardLocksCommand { + Toggle(LockKind), +} + +impl ReadOnlyService for KeyboardLocksService { + type UpdateEvent = KeyboardLocksEvent; + type Error = (); + + fn update(&mut self, event: Self::UpdateEvent) { + self.data = event.0; + } + + fn subscribe() -> Subscription> { + Subscription::run_with(TypeId::of::(), |_| { + channel(100, async |mut output| { + let mut state = State::Init; + loop { + state = run(state, &mut output).await; + } + }) + }) + } +} + +impl Service for KeyboardLocksService { + type Command = KeyboardLocksCommand; + + fn command(&mut self, command: Self::Command) -> Task> { + let _ = self.commander.send(command); + Task::none() + } +} + +/// A per-device update: `Some(state)` on a LED change, `None` when the +/// device's stream ends (unplugged or read error) so it can be forgotten. +type DeviceUpdate = (PathBuf, Option); + +/// The set of live per-device streams, polled as a single stream. +type DeviceStreams = SelectAll>; + +enum State { + Init, + Active(UnboundedReceiver), + Error, +} + +fn input_subsystem_monitor() -> anyhow::Result> { + let socket = udev::MonitorBuilder::new()? + .match_subsystem("input")? + .listen()?; + Ok(AsyncFd::with_interest( + socket, + Interest::READABLE | Interest::WRITABLE, + )?) +} + +fn enumerate_event_devices() -> io::Result> { + let mut paths = Vec::new(); + for entry in std::fs::read_dir("/dev/input")? { + let Ok(entry) = entry else { continue }; + let path = entry.path(); + if let Some(name) = path.file_name().and_then(|n| n.to_str()) + && name.starts_with("event") + { + paths.push(path); + } + } + paths.sort(); + Ok(paths) +} + +/// Try opening the device, retrying briefly to avoid racing with kernel/udev. +fn open_device_with_retry(path: &Path) -> io::Result> { + let mut last_err: Option = None; + for attempt in 0..3 { + match Device::open(path) { + Ok(device) => return Ok(Some(device)), + Err(err) if err.kind() == io::ErrorKind::PermissionDenied => { + // Don't retry on EACCES — it won't change. + return Err(err); + } + Err(err) if err.kind() == io::ErrorKind::NotFound => { + // Device disappeared between enumeration and open. + return Ok(None); + } + Err(err) => { + last_err = Some(err); + if attempt < 2 { + std::thread::sleep(Duration::from_millis(50)); + } + } + } + } + Err(last_err.unwrap_or_else(|| io::Error::other("failed to open device"))) +} + +/// Returns true if the device exposes at least one of the locks we track. +fn has_lock_leds(device: &Device) -> bool { + device.supported_leds().is_some_and(|leds| { + leds.contains(LedCode::LED_CAPSL) + || leds.contains(LedCode::LED_NUML) + || leds.contains(LedCode::LED_SCROLLL) + }) +} + +fn read_lock_state(device: &Device) -> io::Result { + let leds = device.get_led_state()?; + Ok(KeyboardLocksData { + caps_lock: leds.contains(LedCode::LED_CAPSL), + num_lock: leds.contains(LedCode::LED_NUML), + scroll_lock: leds.contains(LedCode::LED_SCROLLL), + }) +} + +/// Open a device and prepare its event stream. +/// +/// Returns `None` when the device doesn't qualify (no tracked LEDs) or can't +/// be opened/read — those cases are expected and only logged at debug level. +fn open_lock_stream(path: &Path) -> Option<(KeyboardLocksData, EventStream)> { + let device = match open_device_with_retry(path) { + Ok(Some(device)) => device, + Ok(None) => return None, + Err(err) if err.kind() == io::ErrorKind::PermissionDenied => { + debug!("permission denied opening {}: {err}", path.display()); + return None; + } + Err(err) => { + debug!("failed to open {}: {err}", path.display()); + return None; + } + }; + + if !has_lock_leds(&device) { + return None; + } + + let initial = read_lock_state(&device) + .map_err(|err| debug!("failed to read LED state for {}: {err}", path.display())) + .ok()?; + + let stream = device + .into_event_stream() + .map_err(|err| debug!("failed to start event stream for {}: {err}", path.display())) + .ok()?; + + Some((initial, stream)) +} + +/// Turn a device's raw event stream into a stream of lock-state changes. +/// +/// Yields only on an actual state change, and yields a final `None` payload +/// when the underlying stream errors out (device gone), after which the +/// stream terminates and `SelectAll` drops it automatically. +fn device_lock_stream( + path: PathBuf, + initial: KeyboardLocksData, + stream: EventStream, +) -> BoxStream<'static, DeviceUpdate> { + stream::unfold( + (stream, initial, path, false), + |(mut stream, mut current, path, ended)| async move { + if ended { + return None; + } + loop { + match stream.next_event().await { + Ok(event) => { + let EventSummary::Led(_, code, value) = event.destructure() else { + continue; + }; + let on = value != 0; + let changed = match code { + LedCode::LED_CAPSL if current.caps_lock != on => { + current.caps_lock = on; + true + } + LedCode::LED_NUML if current.num_lock != on => { + current.num_lock = on; + true + } + LedCode::LED_SCROLLL if current.scroll_lock != on => { + current.scroll_lock = on; + true + } + _ => false, + }; + if changed { + return Some(( + (path.clone(), Some(current)), + (stream, current, path, false), + )); + } + } + Err(err) => { + debug!( + "event stream error for {}: {err}; dropping device", + path.display() + ); + return Some(((path.clone(), None), (stream, current, path, true))); + } + } + } + }, + ) + .boxed() +} + +/// Start tracking a device, unless it is already tracked or doesn't qualify. +fn add_device( + path: PathBuf, + per_device: &mut HashMap, + streams: &mut DeviceStreams, +) { + if per_device.contains_key(&path) { + return; + } + if let Some((initial, stream)) = open_lock_stream(&path) { + per_device.insert(path.clone(), initial); + streams.push(device_lock_stream(path, initial, stream)); + } +} + +fn aggregate(per_device: &HashMap) -> KeyboardLocksData { + let mut acc = KeyboardLocksData::default(); + for state in per_device.values() { + acc.caps_lock |= state.caps_lock; + acc.num_lock |= state.num_lock; + acc.scroll_lock |= state.scroll_lock; + } + acc +} + +async fn run(state: State, output: &mut Sender>) -> State { + match state { + State::Init => match init().await { + Ok(initial) => { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let _ = output + .send(ServiceEvent::Init(KeyboardLocksService { + data: initial, + commander: tx, + })) + .await; + State::Active(rx) + } + Err(err) => { + warn!("Keyboard locks service disabled: {err}"); + State::Error + } + }, + State::Active(rx) => { + supervise(output, rx).await; + // If supervise returns, give up. + State::Error + } + State::Error => { + let _ = pending::().next().await; + State::Error + } + } +} + +/// Verify that at least one qualifying keyboard is accessible. +/// +/// Returns the initial aggregate state on success. Fails (and disables the +/// service) only when *no* device could be opened (all-EACCES or no input +/// nodes found). +async fn init() -> anyhow::Result { + let paths = enumerate_event_devices()?; + let mut accessible_any = false; + let mut aggregate_state = KeyboardLocksData::default(); + + for path in &paths { + match open_device_with_retry(path) { + Ok(Some(device)) => { + accessible_any = true; + if !has_lock_leds(&device) { + continue; + } + if let Ok(state) = read_lock_state(&device) { + aggregate_state.caps_lock |= state.caps_lock; + aggregate_state.num_lock |= state.num_lock; + aggregate_state.scroll_lock |= state.scroll_lock; + } + } + Ok(None) => {} + Err(err) if err.kind() == io::ErrorKind::PermissionDenied => { + debug!("permission denied opening {}: {err}", path.display()); + } + Err(err) => { + debug!("failed to probe {}: {err}", path.display()); + } + } + } + + if !accessible_any { + return Err(anyhow::anyhow!( + "no readable input devices (is the user in the `input` group?)" + )); + } + + Ok(aggregate_state) +} + +/// Run the supervisor: track per-device streams, react to hotplug, emit +/// aggregated updates, and process toggle commands. +async fn supervise( + output: &mut Sender>, + mut commands: UnboundedReceiver, +) { + let mut monitor = match input_subsystem_monitor() { + Ok(monitor) => monitor, + Err(err) => { + warn!("Failed to set up input udev monitor: {err}"); + return; + } + }; + + let mut per_device: HashMap = HashMap::new(); + let mut streams = DeviceStreams::new(); + let mut current_aggregate = KeyboardLocksData::default(); + let mut emitter = ToggleEmitter::new(); + + if let Ok(paths) = enumerate_event_devices() { + for path in paths { + add_device(path, &mut per_device, &mut streams); + } + } + + info!( + "Keyboard locks service listening (tracking {} devices)", + per_device.len() + ); + + loop { + tokio::select! { + Some((path, update)) = streams.next() => { + match update { + Some(state) => { per_device.insert(path, state); } + None => { per_device.remove(&path); } + } + let new_aggregate = aggregate(&per_device); + if new_aggregate != current_aggregate { + current_aggregate = new_aggregate; + if output + .send(ServiceEvent::Update(KeyboardLocksEvent(current_aggregate))) + .await + .is_err() + { + break; + } + } + } + cmd = commands.recv() => { + let Some(cmd) = cmd else { break }; + match cmd { + KeyboardLocksCommand::Toggle(kind) => emitter.toggle(kind), + } + } + guard = monitor.writable_mut() => { + match guard { + Ok(mut guard) => { + let events: Vec<_> = guard.get_inner().iter().collect(); + for evt in events { + handle_udev_event(&evt, &mut per_device, &mut streams); + } + guard.clear_ready(); + } + Err(err) => { + warn!("input udev monitor failed: {err}"); + break; + } + } + } + } + } +} + +fn handle_udev_event( + evt: &udev::Event, + per_device: &mut HashMap, + streams: &mut DeviceStreams, +) { + let Some(devnode) = evt.device().devnode().map(|p| p.to_path_buf()) else { + return; + }; + let Some(name) = devnode.file_name().and_then(|n| n.to_str()) else { + return; + }; + if !name.starts_with("event") { + return; + } + + // On `Remove` the device's stream surfaces a read error and self-removes, + // emitting the `None` that clears `per_device` — nothing to do here. + if evt.event_type() == udev::EventType::Add { + add_device(devnode, per_device, streams); + } +} + +/// Lazily-created uinput emitter for toggling lock keys. +/// +/// The virtual device is created on the first toggle request. If creation +/// fails (typically because `/dev/uinput` is not accessible), the emitter +/// permanently disables itself and logs a warning once. Read-only LED +/// monitoring keeps working in either case. +struct ToggleEmitter { + state: EmitterState, +} + +enum EmitterState { + Uninitialized, + Ready(VirtualDevice), + Disabled, +} + +impl ToggleEmitter { + fn new() -> Self { + Self { + state: EmitterState::Uninitialized, + } + } + + fn toggle(&mut self, kind: LockKind) { + if matches!(self.state, EmitterState::Uninitialized) { + match build_virtual_device() { + Ok(device) => { + info!("Keyboard locks: uinput virtual device ready"); + self.state = EmitterState::Ready(device); + } + Err(err) => { + warn!( + "Keyboard locks: cannot toggle locks: failed to open /dev/uinput: {err}. \ + The user likely needs write access to /dev/uinput \ + (e.g. via a udev rule giving the `input` group access)." + ); + self.state = EmitterState::Disabled; + } + } + } + + let EmitterState::Ready(device) = &mut self.state else { + return; + }; + + let key = match kind { + LockKind::Caps => KeyCode::KEY_CAPSLOCK, + LockKind::Num => KeyCode::KEY_NUMLOCK, + LockKind::Scroll => KeyCode::KEY_SCROLLLOCK, + }; + + let press = *KeyEvent::new(key, 1); + let release = *KeyEvent::new(key, 0); + let syn = *SynchronizationEvent::new(SynchronizationCode::SYN_REPORT, 0); + + if let Err(err) = device.emit(&[press, syn, release, syn]) { + warn!("Keyboard locks: failed to emit {kind:?} toggle: {err}"); + } + } +} + +fn build_virtual_device() -> io::Result { + let mut keys = AttributeSet::::new(); + keys.insert(KeyCode::KEY_CAPSLOCK); + keys.insert(KeyCode::KEY_NUMLOCK); + keys.insert(KeyCode::KEY_SCROLLLOCK); + + VirtualDevice::builder()? + .name("ashell keyboard locks") + .with_keys(&keys)? + .build() +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 7bd035f0c..ba8ba99c2 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -5,6 +5,7 @@ pub mod bluetooth; pub mod brightness; pub mod compositor; pub mod idle_inhibitor; +pub mod keyboard_locks; pub mod logind; pub mod mpris; pub mod network; diff --git a/website/docs/configuration/modules/index.md b/website/docs/configuration/modules/index.md index 749ee74a0..e113422ad 100644 --- a/website/docs/configuration/modules/index.md +++ b/website/docs/configuration/modules/index.md @@ -66,6 +66,10 @@ Displays the current keyboard layout and allows switching between layouts. Displays the current keyboard submap. +### KeyboardLocks + +Displays the state of the keyboard lock keys (Caps Lock, Num Lock, Scroll Lock). + ### CustomModule Allows creating custom modules with user-defined functionality. diff --git a/website/docs/configuration/modules/keyboard.md b/website/docs/configuration/modules/keyboard.md index 9834124d8..7af3b7bc7 100644 --- a/website/docs/configuration/modules/keyboard.md +++ b/website/docs/configuration/modules/keyboard.md @@ -4,7 +4,7 @@ sidebar_position: 3 # Keyboard -There are two keyboard modules available in the status bar. +There are three keyboard modules available in the status bar. ## Keyboard Layout @@ -40,3 +40,83 @@ the "Italian" layout to the 🇮🇹 flag. This module displays the current keyboard submap in use. It only appears when a submap is active. You can find more information about submap in the [Hyprland documentation](https://wiki.hypr.land/Configuring/Binds/#submaps). + +## Keyboard Locks + +This module displays the state of the keyboard lock keys: Caps Lock, Num Lock, +and Scroll Lock. State is read from `/dev/input/event*` via evdev, so the +module works on any compositor (Hyprland, Niri, …) without compositor-specific +IPC. Clicking an indicator toggles the corresponding lock. + +:::info + +Ashell needs access to two device nodes to provide the lock indicators: + +- Read access to `/dev/input/event*` (typically mode `660 root:input`) to + observe lock state. +- Write access to `/dev/uinput` (typically mode `660 root:input`) to toggle + locks when an indicator is clicked. + +Both nodes are owned by the `input` group on most distributions, so the user +running ashell just needs to be a member of that group: + +``` +sudo usermod -aG input "$USER" +``` + +Log out and back in for the new group to take effect. + +If no input devices are readable, the module silently disables itself and a +warning is logged. If `/dev/uinput` cannot be opened, the display still works +but clicks become no-ops and a single warning is logged. + +
+Manual permission setup + +On systems where `/dev/uinput` or `/dev/input/event*` are not granted to the +`input` group out of the box, a udev rule can be used to align them: + +``` +# /etc/udev/rules.d/99-uinput.rules +KERNEL=="uinput", GROUP="input", MODE="0660" +``` + +Reload udev with `sudo udevadm control --reload-rules && sudo udevadm trigger` +for the rule to take effect. + +
+ +::: + +Each lock indicator can be configured independently with three options: + +- `enabled` (`true` / `false`): whether the indicator is considered at all. +- `visibility`: when the indicator is rendered. + - `ActiveOnly` (default): the indicator is only shown while the lock is on. + - `AlwaysVisible`: the indicator is always shown; rendered dim when the lock + is off and bright when it is on. +- `icon` (optional): a custom glyph or string to render instead of the default + icon. When unset, ashell uses the bundled Nerd Font glyphs (no extra font + needed): caps-lock, numeric and arrow-up-down icons for Caps Lock, Num Lock + and Scroll Lock respectively. When set, the value is rendered through the + same `DynamicIcon` path used by custom modules, so any Nerd Font glyph + (e.g. `"\uF11C"`) or plain text (e.g. `"NUM"`) works. + +By default all three indicators are enabled in `ActiveOnly` mode, so the +module stays out of sight until a lock is engaged. + +### Example + +```toml +[keyboard_locks.caps_lock] +enabled = true +visibility = "AlwaysVisible" + +[keyboard_locks.num_lock] +enabled = true +visibility = "ActiveOnly" +icon = "NUM" + +[keyboard_locks.scroll_lock] +enabled = false +```