diff --git a/Cargo.toml b/Cargo.toml index 0f53558..d5061d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ default = [ "focused", "http", "ipc", - "keys", + "keyboard+all", "launcher", "music+all", "network_manager", @@ -56,7 +56,10 @@ clock = ["chrono"] focused = [] -keys = ["dep:input", "dep:evdev-rs", "dep:libc", "dep:nix"] +keyboard = ["dep:input", "dep:evdev-rs", "dep:libc", "dep:nix"] +"keyboard+all" = ["keyboard", "keyboard+sway", "keyboard+hyprland"] +"keyboard+sway" = ["keyboard", "sway"] +"keyboard+hyprland" = ["keyboard", "hyprland"] launcher = [] @@ -137,7 +140,7 @@ cairo-rs = { version = "0.18.5", optional = true, features = ["png"] } # clock chrono = { version = "0.4.39", optional = true, default-features = false, features = ["clock", "unstable-locales"] } -# keys +# keyboard input = { version = "0.9.1", optional = true } evdev-rs = { version = "0.6.1", optional = true } libc = { version = "0.2.164", optional = true } @@ -161,10 +164,6 @@ upower_dbus = { version = "0.3.2", optional = true } # volume libpulse-binding = { version = "2.28.2", optional = true } -# workspaces -swayipc-async = { version = "2.0.1", optional = true } -hyprland = { version = "0.4.0-alpha.3", features = ["silent"], optional = true } - # shared futures-lite = { version = "2.6.0", optional = true } # network_manager, upower, workspaces nix = { version = "0.29.0", optional = true, features = ["event", "fs", "poll"] } # clipboard, input @@ -172,6 +171,8 @@ regex = { version = "1.11.1", default-features = false, features = [ "std", ], optional = true } # music, sys_info zbus = { version = "3.15.2", default-features = false, features = ["tokio"], optional = true } # network_manager, notifications, upower +swayipc-async = { version = "2.0.1", optional = true } # workspaces, keyboard +hyprland = { version = "0.4.0-alpha.3", features = ["silent"], optional = true } # workspaces, keyboard # schema schemars = { version = "0.8.21", optional = true } diff --git a/docs/Compiling.md b/docs/Compiling.md index a198bf4..c883dd3 100644 --- a/docs/Compiling.md +++ b/docs/Compiling.md @@ -26,7 +26,7 @@ pacman -S openssl pacman -S libdbusmenu-gtk3 # for volume support pacman -S libpulse -# for keys support +# for keyboard support pacman -S libinput # for lua/cairo support pacman -S luajit lua51-lgi @@ -42,7 +42,7 @@ apt install libssl-dev apt install libdbusmenu-gtk3-dev # for volume support apt install libpulse-dev -# for keys support +# for keyboard support apt install libinput-dev # for lua/cairo support apt install luajit-dev lua-lgi @@ -58,7 +58,7 @@ dnf install openssl-devel dnf install libdbusmenu-gtk3-devel # for volume support dnf install pulseaudio-libs-devel -# for keys support +# for keyboard support dnf install libinput-devel # for lua/cairo support dnf install luajit-devel lua-lgi @@ -85,7 +85,7 @@ cargo build --release --no-default-features \ > ⚠ Make sure you enable at least one `config` feature otherwise you will not be able to start the bar! | Feature | Description | -|---------------------|-----------------------------------------------------------------------------------| +| ------------------- | --------------------------------------------------------------------------------- | | **Core** | | | http | Enables HTTP features. Currently this includes the ability to load remote images. | | ipc | Enables the IPC server. | @@ -101,6 +101,10 @@ cargo build --release --no-default-features \ | clipboard | Enables the `clipboard` module. | | clock | Enables the `clock` module. | | focused | Enables the `focused` module. | +| keyboard | Enables the `keyboard` module without keyboard layout support. | +| keyboard+all | Enables the `keyboard` module with keyboard layout support for all compositors. | +| keyboard+sway | Enables the `keyboard` module with keyboard layout support for Sway. | +| keyboard+hyprland | Enables the `keyboard` module with keyboard layout support for Hyprland. | | launcher | Enables the `launcher` module. | | music+all | Enables the `music` module with support for all player types. | | music+mpris | Enables the `music` module with MPRIS support. | diff --git a/docs/_Sidebar.md b/docs/_Sidebar.md index e12a265..1463115 100644 --- a/docs/_Sidebar.md +++ b/docs/_Sidebar.md @@ -29,7 +29,7 @@ - [Clock](clock) - [Custom](custom) - [Focused](focused) -- [Keys](keys) +- [Keyboard](keyboard) - [Label](label) - [Launcher](launcher) - [Music](music) diff --git a/docs/modules/Keyboard.md b/docs/modules/Keyboard.md new file mode 100644 index 0000000..ddb7df0 --- /dev/null +++ b/docs/modules/Keyboard.md @@ -0,0 +1,121 @@ +> [!NOTE] +> This module requires your user is in the `input` group. + +> [!IMPORTANT] +> The keyboard layout feature is only available on Sway and Hyprland. + +Displays the toggle state of the capslock, num lock and scroll lock keys, and the current keyboard layout. + +![Screenshot of keyboard widget](https://f.jstanger.dev/github/ironbar/keys.png) + +## Configuration + +> Type: `keyboard` + +| 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. | +| `icons.layout_map` | `Map` | `{}` | Map of icons or labels to show for a particular keyboard layout. Layouts use their actual name if not present in the map. | +| `seat` | `string` | `seat0` | ID of the Wayland seat to attach to. | + +
+JSON + +```json +{ + "end": [ + { + "type": "keyboard", + "show_scroll": false, + "icons": { + "caps_on": "󰪛", + "layout_map": { + "English (US)": "🇺🇸", + "Ukrainian": "🇺🇦" + } + } + } + ] +} +``` + +
+ +
+TOML + +```toml +[[end]] +type = "keyboard" +show_scroll = false + +[end.icons] +caps_on = "󰪛" + +[end.icons.layout_map] +"English (US)" = "🇺🇸" +Ukrainian = "🇺🇦" +``` + +
+ +
+YAML + +```yaml +end: + - type: keyboard + show_scroll: false + icons: + caps_on: 󰪛 + layout_map: + "English (US)": 🇺🇸 + Ukrainian: 🇺🇦 + +``` + +
+ +
+Corn + +```corn +{ +end = [ + { + type = "keyboard" + show_scroll = false + icons.caps_on = "󰪛" + icons.layout_map.'English (US)' = "🇺🇸" + icons.layout_map.Ukrainian = "🇺🇦" + } + ] +} +``` + +
+ +## Styling + +| Selector | Description | +| -------------------------- | ------------------------------------------ | +| `.keyboard` | Keys box container widget. | +| `.keyboard .key` | Individual key indicator container widget. | +| `.keyboard .key.enabled` | Key indicator where key is toggled on. | +| `.keyboard .key.caps` | Capslock key indicator. | +| `.keyboard .key.num` | Num lock key indicator. | +| `.keyboard .key.scroll` | Scroll lock key indicator. | +| `.keyboard .key.image` | Key indicator image icon. | +| `.keyboard .key.text-icon` | Key indicator textual icon. | +| `.keyboard .layout` | Keyboard layout indicator. | + +For more information on styling, please see the [styling guide](styling-guide). diff --git a/docs/modules/Keys.md b/docs/modules/Keys.md deleted file mode 100644 index 0fcc5d1..0000000 --- a/docs/modules/Keys.md +++ /dev/null @@ -1,102 +0,0 @@ -> [!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/nix/default.nix b/nix/default.nix index f819579..18ba622 100644 --- a/nix/default.nix +++ b/nix/default.nix @@ -64,7 +64,7 @@ ++ lib.optionals (hasFeature "tray") [ libdbusmenu-gtk3 ] ++ lib.optionals (hasFeature "volume")[ libpulseaudio ] ++ lib.optionals (hasFeature "cairo") [ luajit ] - ++ lib.optionals (hasFeature "keys") [ libinput libevdev ]; + ++ lib.optionals (hasFeature "keyboard") [ libinput libevdev ]; propagatedBuildInputs = [ gtk3 ]; diff --git a/src/clients/compositor/hyprland.rs b/src/clients/compositor/hyprland.rs index 063e7b7..d843ced 100644 --- a/src/clients/compositor/hyprland.rs +++ b/src/clients/compositor/hyprland.rs @@ -1,7 +1,11 @@ -use super::{Visibility, Workspace, WorkspaceClient, WorkspaceUpdate}; +use super::{ + KeyboardLayoutClient, KeyboardLayoutUpdate, Visibility, Workspace, WorkspaceClient, + WorkspaceUpdate, +}; use crate::{arc_mut, lock, send, spawn_blocking}; use color_eyre::Result; -use hyprland::data::{Workspace as HWorkspace, Workspaces}; +use hyprland::ctl::switch_xkb_layout; +use hyprland::data::{Devices, Workspace as HWorkspace, Workspaces}; use hyprland::dispatch::{Dispatch, DispatchType, WorkspaceIdentifierWithSpecial}; use hyprland::event_listener::EventListener; use hyprland::prelude::*; @@ -13,15 +17,21 @@ use tracing::{debug, error, info}; pub struct Client { workspace_tx: Sender, _workspace_rx: Receiver, + + keyboard_layout_tx: Sender, + _keyboard_layout_rx: Receiver, } impl Client { pub(crate) fn new() -> Self { let (workspace_tx, workspace_rx) = channel(16); + let (keyboard_layout_tx, keyboard_layout_rx) = channel(4); let instance = Self { workspace_tx, _workspace_rx: workspace_rx, + keyboard_layout_tx, + _keyboard_layout_rx: keyboard_layout_rx, }; instance.listen_workspace_events(); @@ -32,6 +42,7 @@ impl Client { info!("Starting Hyprland event listener"); let tx = self.workspace_tx.clone(); + let keyboard_layout_tx = self.keyboard_layout_tx.clone(); spawn_blocking(move || { let mut event_listener = EventListener::new(); @@ -178,6 +189,9 @@ impl Client { } { + let tx = tx.clone(); + let lock = lock.clone(); + event_listener.add_urgent_state_handler(move |address| { let _lock = lock!(lock); debug!("Received urgent state: {address:?}"); @@ -206,6 +220,55 @@ impl Client { }); } + { + let tx = keyboard_layout_tx.clone(); + let lock = lock.clone(); + + event_listener.add_keyboard_layout_change_handler(move |layout_event| { + let _lock = lock!(lock); + + let layout = if layout_event.layout_name.is_empty() { + // FIXME: This field is empty due to bug in `hyprland-rs_0.4.0-alpha.3`. Which is already fixed in last betas + + // The layout may be empty due to a bug in `hyprland-rs`, because of which the `layout_event` is incorrect. + // + // Instead of: + // ``` + // LayoutEvent { + // keyboard_name: "keychron-keychron-c2", + // layout_name: "English (US)", + // } + // ``` + // + // We get: + // ``` + // LayoutEvent { + // keyboard_name: "keychron-keychron-c2,English (US)", + // layout_name: "", + // } + // ``` + // + // Here we are trying to recover `layout_name` from `keyboard_name` + + let layout = layout_event.keyboard_name.as_str().split(",").nth(1); + let Some(layout) = layout else { + error!( + "Failed to get layout from string: {}. The failed logic is a workaround for a bug in `hyprland 0.4.0-alpha.3`", layout_event.keyboard_name); + return; + }; + + layout.into() + } + else { + layout_event.layout_name + }; + + debug!("Received layout: {layout:?}"); + + send!(tx, KeyboardLayoutUpdate(layout)); + }); + } + event_listener .start_listener() .expect("Failed to start listener"); @@ -264,36 +327,73 @@ impl Client { } impl WorkspaceClient for Client { - fn focus(&self, id: String) -> Result<()> { + fn focus(&self, id: String) { let identifier = id.parse::().map_or_else( |_| WorkspaceIdentifierWithSpecial::Name(&id), WorkspaceIdentifierWithSpecial::Id, ); - Dispatch::call(DispatchType::Workspace(identifier))?; - Ok(()) + if let Err(e) = Dispatch::call(DispatchType::Workspace(identifier)) { + error!("Couldn't focus workspace '{id}': {e:#}"); + } } - fn subscribe_workspace_change(&self) -> Receiver { + fn subscribe(&self) -> Receiver { let rx = self.workspace_tx.subscribe(); - { - let tx = self.workspace_tx.clone(); + let active_id = HWorkspace::get_active().ok().map(|active| active.name); + let is_visible = create_is_visible(); - let active_id = HWorkspace::get_active().ok().map(|active| active.name); - let is_visible = create_is_visible(); + let workspaces = Workspaces::get() + .expect("Failed to get workspaces") + .into_iter() + .map(|w| { + let vis = Visibility::from((&w, active_id.as_deref(), &is_visible)); - let workspaces = Workspaces::get() - .expect("Failed to get workspaces") - .into_iter() - .map(|w| { - let vis = Visibility::from((&w, active_id.as_deref(), &is_visible)); + Workspace::from((vis, w)) + }) + .collect(); - Workspace::from((vis, w)) - }) - .collect(); + send!(self.workspace_tx, WorkspaceUpdate::Init(workspaces)); - send!(tx, WorkspaceUpdate::Init(workspaces)); + rx + } +} + +impl KeyboardLayoutClient for Client { + fn set_next_active(&self) { + let device = Devices::get() + .expect("Failed to get devices") + .keyboards + .iter() + .find(|k| k.main) + .map(|k| k.name.clone()); + + if let Some(device) = device { + if let Err(e) = + switch_xkb_layout::call(device, switch_xkb_layout::SwitchXKBLayoutCmdTypes::Next) + { + error!("Failed to switch keyboard layout due to Hyprland error: {e}"); + } + } else { + error!("Failed to get keyboard device from hyprland"); + } + } + + fn subscribe(&self) -> Receiver { + let rx = self.keyboard_layout_tx.subscribe(); + + let layout = Devices::get() + .expect("Failed to get devices") + .keyboards + .iter() + .find(|k| k.main) + .map(|k| k.active_keymap.clone()); + + if let Some(layout) = layout { + send!(self.keyboard_layout_tx, KeyboardLayoutUpdate(layout)); + } else { + error!("Failed to get current keyboard layout hyprland"); } rx diff --git a/src/clients/compositor/mod.rs b/src/clients/compositor/mod.rs index 5c92e4e..2423ae2 100644 --- a/src/clients/compositor/mod.rs +++ b/src/clients/compositor/mod.rs @@ -54,6 +54,26 @@ impl Compositor { } } + pub fn create_keyboard_layout_client( + clients: &mut super::Clients, + ) -> Result> { + let current = Self::get_current(); + debug!("Getting keyboard_layout client for: {current}"); + match current { + #[cfg(feature = "keyboard+sway")] + Self::Sway => clients + .sway() + .map(|client| client as Arc), + #[cfg(feature = "keyboard+hyprland")] + Self::Hyprland => clients + .hyprland() + .map(|client| client as Arc), + Self::Unsupported => Err(Report::msg("Unsupported compositor").note( + "Currently keyboard layout functionality are only supported by Sway and Hyprland", + )), + } + } + /// Creates a new instance of /// the workspace client for the current compositor. pub fn create_workspace_client( @@ -67,7 +87,9 @@ impl Compositor { .sway() .map(|client| client as Arc), #[cfg(feature = "workspaces+hyprland")] - Self::Hyprland => Ok(Arc::new(hyprland::Client::new())), + Self::Hyprland => clients + .hyprland() + .map(|client| client as Arc), Self::Unsupported => Err(Report::msg("Unsupported compositor") .note("Currently workspaces are only supported by Sway and Hyprland")), } @@ -112,6 +134,9 @@ impl Visibility { } } +#[derive(Debug, Clone)] +pub struct KeyboardLayoutUpdate(pub String); + #[derive(Debug, Clone)] pub enum WorkspaceUpdate { /// Provides an initial list of workspaces. @@ -146,10 +171,20 @@ pub enum WorkspaceUpdate { pub trait WorkspaceClient: Debug + Send + Sync { /// Requests the workspace with this name is focused. - fn focus(&self, name: String) -> Result<()>; + fn focus(&self, name: String); /// Creates a new to workspace event receiver. - fn subscribe_workspace_change(&self) -> broadcast::Receiver; + fn subscribe(&self) -> broadcast::Receiver; } register_fallible_client!(dyn WorkspaceClient, workspaces); + +pub trait KeyboardLayoutClient: Debug + Send + Sync { + /// Switches to the next layout. + fn set_next_active(&self); + + /// Creates a new to keyboard layout event receiver. + fn subscribe(&self) -> broadcast::Receiver; +} + +register_fallible_client!(dyn KeyboardLayoutClient, keyboard_layout); diff --git a/src/clients/compositor/sway.rs b/src/clients/compositor/sway.rs index e7091a1..f16aaf2 100644 --- a/src/clients/compositor/sway.rs +++ b/src/clients/compositor/sway.rs @@ -1,21 +1,25 @@ -use super::{Visibility, Workspace, WorkspaceClient, WorkspaceUpdate}; -use crate::{await_sync, send}; -use color_eyre::Result; -use swayipc_async::{Node, WorkspaceChange, WorkspaceEvent}; +use super::{ + KeyboardLayoutClient, KeyboardLayoutUpdate, Visibility, Workspace, WorkspaceClient, + WorkspaceUpdate, +}; +use crate::{await_sync, error, send, spawn}; +use swayipc_async::{InputChange, InputEvent, Node, WorkspaceChange, WorkspaceEvent}; use tokio::sync::broadcast::{channel, Receiver}; use crate::clients::sway::Client; impl WorkspaceClient for Client { - fn focus(&self, id: String) -> Result<()> { - await_sync(async move { - let mut client = self.connection().lock().await; - client.run_command(format!("workspace {id}")).await - })?; - Ok(()) + fn focus(&self, id: String) { + let client = self.connection().clone(); + spawn(async move { + let mut client = client.lock().await; + if let Err(e) = client.run_command(format!("workspace {id}")).await { + error!("Couldn't focus workspace '{id}': {e:#}"); + } + }); } - fn subscribe_workspace_change(&self) -> Receiver { + fn subscribe(&self) -> Receiver { let (tx, rx) = channel(16); let client = self.connection().clone(); @@ -133,3 +137,77 @@ impl From for WorkspaceUpdate { } } } + +impl KeyboardLayoutClient for Client { + fn set_next_active(&self) { + let client = self.connection().clone(); + spawn(async move { + let mut client = client.lock().await; + + let inputs = client.get_inputs().await.expect("to get inputs"); + + if let Some(keyboard) = inputs + .into_iter() + .find(|i| i.xkb_active_layout_name.is_some()) + { + if let Err(e) = client + .run_command(format!( + "input {} xkb_switch_layout next", + keyboard.identifier + )) + .await + { + error!("Failed to switch keyboard layout due to Sway error: {e}"); + } + } else { + error!("Failed to get keyboard identifier from Sway"); + } + }); + } + + fn subscribe(&self) -> Receiver { + let (tx, rx) = channel(4); + + let client = self.connection().clone(); + + await_sync(async { + let mut client = client.lock().await; + let inputs = client.get_inputs().await.expect("to get inputs"); + + if let Some(layout) = inputs.into_iter().find_map(|i| i.xkb_active_layout_name) { + send!(tx, KeyboardLayoutUpdate(layout)); + } else { + error!("Failed to get keyboard layout from Sway!"); + } + + drop(client); + + self.add_listener::(move |event| { + if let Ok(layout) = KeyboardLayoutUpdate::try_from(event.clone()) { + send!(tx, layout); + } + }) + .await + .expect("to add listener"); + }); + + rx + } +} + +impl TryFrom for KeyboardLayoutUpdate { + type Error = (); + + fn try_from(value: InputEvent) -> std::result::Result { + match value.change { + InputChange::XkbLayout => { + if let Some(layout) = value.input.xkb_active_layout_name { + Ok(KeyboardLayoutUpdate(layout)) + } else { + Err(()) + } + } + _ => Err(()), + } + } +} diff --git a/src/clients/mod.rs b/src/clients/mod.rs index 5f62937..0b0a2f5 100644 --- a/src/clients/mod.rs +++ b/src/clients/mod.rs @@ -9,7 +9,7 @@ use std::sync::Arc; pub mod clipboard; #[cfg(feature = "workspaces")] pub mod compositor; -#[cfg(feature = "keys")] +#[cfg(feature = "keyboard")] pub mod libinput; #[cfg(feature = "cairo")] pub mod lua; @@ -38,10 +38,14 @@ pub struct Clients { workspaces: Option>, #[cfg(feature = "sway")] sway: Option>, + #[cfg(feature = "hyprland")] + hyprland: Option>, #[cfg(feature = "clipboard")] clipboard: Option>, - #[cfg(feature = "keys")] + #[cfg(feature = "keyboard")] libinput: HashMap, Arc>, + #[cfg(any(feature = "keyboard+sway", feature = "keyboard+hyprland"))] + keyboard_layout: Option>, #[cfg(feature = "cairo")] lua: Option>, #[cfg(feature = "music")] @@ -93,6 +97,19 @@ impl Clients { Ok(client) } + #[cfg(any(feature = "keyboard+sway", feature = "keyboard+hyprland"))] + pub fn keyboard_layout(&mut self) -> ClientResult { + let client = if let Some(keyboard_layout) = &self.keyboard_layout { + keyboard_layout.clone() + } else { + let client = compositor::Compositor::create_keyboard_layout_client(self)?; + self.keyboard_layout.replace(client.clone()); + client + }; + + Ok(client) + } + #[cfg(feature = "sway")] pub fn sway(&mut self) -> ClientResult { let client = if let Some(client) = &self.sway { @@ -107,6 +124,19 @@ impl Clients { Ok(client) } + #[cfg(feature = "hyprland")] + pub fn hyprland(&mut self) -> ClientResult { + let client = if let Some(client) = &self.hyprland { + client.clone() + } else { + let client = Arc::new(compositor::hyprland::Client::new()); + self.hyprland.replace(client.clone()); + client + }; + + Ok(client) + } + #[cfg(feature = "cairo")] pub fn lua(&mut self, config_dir: &Path) -> Rc { self.lua @@ -114,7 +144,7 @@ impl Clients { .clone() } - #[cfg(feature = "keys")] + #[cfg(feature = "keyboard")] pub fn libinput(&mut self, seat: &str) -> Arc { self.libinput .entry(seat.into()) diff --git a/src/config/mod.rs b/src/config/mod.rs index b4d7371..52363ea 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -11,8 +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; +#[cfg(feature = "keyboard")] +use crate::modules::keyboard::KeyboardModule; use crate::modules::label::LabelModule; #[cfg(feature = "launcher")] use crate::modules::launcher::LauncherModule; @@ -61,8 +61,8 @@ pub enum ModuleConfig { Custom(Box), #[cfg(feature = "focused")] Focused(Box), - #[cfg(feature = "keys")] - Keys(Box), + #[cfg(feature = "keyboard")] + Keyboard(Box), Label(Box), #[cfg(feature = "launcher")] Launcher(Box), @@ -110,8 +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), + #[cfg(feature = "keyboard")] + Self::Keyboard(module) => create!(module), Self::Label(module) => create!(module), #[cfg(feature = "launcher")] Self::Launcher(module) => create!(module), diff --git a/src/image/gtk.rs b/src/image/gtk.rs index a35d544..6a59dcd 100644 --- a/src/image/gtk.rs +++ b/src/image/gtk.rs @@ -31,7 +31,7 @@ pub fn new_icon_button(input: &str, icon_theme: &IconTheme, size: i32) -> Button button } -#[cfg(any(feature = "music", feature = "keys"))] +#[cfg(any(feature = "music", feature = "keyboard"))] pub struct IconLabel { container: gtk::Box, label: Label, @@ -41,7 +41,7 @@ pub struct IconLabel { size: i32, } -#[cfg(any(feature = "music", feature = "keys"))] +#[cfg(any(feature = "music", feature = "keyboard"))] impl IconLabel { pub fn new(input: &str, icon_theme: &IconTheme, size: i32) -> Self { let container = gtk::Box::new(Orientation::Horizontal, 0); diff --git a/src/modules/keys.rs b/src/modules/keyboard.rs similarity index 53% rename from src/modules/keys.rs rename to src/modules/keyboard.rs index 0fe18a0..19efa4e 100644 --- a/src/modules/keys.rs +++ b/src/modules/keyboard.rs @@ -1,18 +1,23 @@ +use std::collections::HashMap; + +use color_eyre::eyre::Report; use color_eyre::Result; -use gtk::prelude::*; +use gtk::{prelude::*, Button}; use serde::Deserialize; use tokio::sync::mpsc; +use tracing::{debug, trace}; -use super::{Module, ModuleInfo, ModuleParts, ModuleUpdateEvent, WidgetContext}; +use super::{Module, ModuleInfo, ModuleParts, WidgetContext}; +use crate::clients::compositor::{self, KeyboardLayoutUpdate}; 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}; +use crate::{glib_recv, module_impl, module_update, send_async, spawn, try_send}; #[derive(Debug, Deserialize, Clone)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -pub struct KeysModule { +pub struct KeyboardModule { /// Whether to show capslock indicator. /// /// **Default**: `true` @@ -31,6 +36,12 @@ pub struct KeysModule { #[serde(default = "crate::config::default_true")] show_scroll: bool, + /// Whether to show the current keyboard layout. + /// + /// **Default**: `true` + #[serde(default = "crate::config::default_true")] + show_layout: bool, + /// Size to render the icons at, in pixels (image icons only). /// /// **Default** `32` @@ -93,6 +104,26 @@ struct Icons { /// **Default**: `""` #[serde(default)] scroll_off: String, + + /// Map of icons or labels to show for a particular keyboard layout. + /// + /// If a layout is not present in the map, + /// it will fall back to using its actual name. + /// + /// **Default**: `{}` + /// + /// # Example + /// + /// ```corn + /// { + /// type = "keyboard" + /// show_layout = true + /// icons.layout_map.'English (US)' = "EN" + /// icons.layout_map.Ukrainian = "UA" + /// } + /// ``` + #[serde(default)] + layout_map: HashMap, } impl Default for Icons { @@ -104,6 +135,7 @@ impl Default for Icons { num_off: String::new(), scroll_on: default_icon_scroll(), scroll_off: String::new(), + layout_map: HashMap::new(), } } } @@ -128,17 +160,23 @@ fn default_icon_scroll() -> String { String::from("") } -impl Module for KeysModule { - type SendMessage = KeyEvent; +#[derive(Debug, Clone)] +pub enum KeyboardUpdate { + Key(KeyEvent), + Layout(KeyboardLayoutUpdate), +} + +impl Module for KeyboardModule { + type SendMessage = KeyboardUpdate; type ReceiveMessage = (); - module_impl!("keys"); + module_impl!("keyboard"); fn spawn_controller( &self, _info: &ModuleInfo, context: &WidgetContext, - _rx: mpsc::Receiver, + mut rx: mpsc::Receiver, ) -> Result<()> { let client = context.ironbar.clients.borrow_mut().libinput(&self.seat); @@ -149,22 +187,47 @@ impl Module for KeysModule { 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) - } - ); + let event = KeyEvent { + key, + state: client.get_state(key), + }; + module_update!(tx, KeyboardUpdate::Key(event)); } } Event::Key(ev) => { - send_async!(tx, ModuleUpdateEvent::Update(ev)); + module_update!(tx, KeyboardUpdate::Key(ev)); } } } }); + let client = context.try_client::()?; + { + let client = client.clone(); + let tx = context.tx.clone(); + spawn(async move { + let mut srx = client.subscribe(); + + trace!("Set up keyboard_layout subscription"); + + while let Ok(payload) = srx.recv().await { + debug!("Received update: {payload:?}"); + module_update!(tx, KeyboardUpdate::Layout(payload)); + } + }); + } + + // Change keyboard layout + spawn(async move { + trace!("Setting up keyboard_layout UI event handler"); + + while let Some(()) = rx.recv().await { + client.set_next_active(); + } + + Ok::<(), Report>(()) + }); + Ok(()) } @@ -173,12 +236,16 @@ impl Module for KeysModule { context: WidgetContext, info: &ModuleInfo, ) -> Result> { - let container = gtk::Box::new(info.bar_position.orientation(), 5); + let container = gtk::Box::new(info.bar_position.orientation(), 0); 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); + let layout_button = Button::new(); + let layout = IconLabel::new("", info.icon_theme, self.icon_size); + layout_button.add(&*layout); + if self.show_caps { caps.add_class("key"); caps.add_class("caps"); @@ -197,31 +264,49 @@ impl Module for KeysModule { container.add(&*scroll); } + if self.show_layout { + layout.add_class("layout"); + container.add(&layout_button); + } + + { + let tx = context.controller_tx.clone(); + layout_button.connect_clicked(move |_| { + try_send!(tx, ()); + }); + } + 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, - }; + let handle_event = move |ev: KeyboardUpdate| match ev { + KeyboardUpdate::Key(ev) => { + 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 let Some((label, input)) = parts { + label.set_label(Some(input)); - if ev.state { - label.add_class("enabled"); - } else { - label.remove_class("enabled"); + if ev.state { + label.add_class("enabled"); + } else { + label.remove_class("enabled"); + } } } + KeyboardUpdate::Layout(KeyboardLayoutUpdate(language)) => { + let text = icons.layout_map.get(&language).unwrap_or(&language); + layout.set_label(Some(text)); + } }; glib_recv!(context.subscribe(), handle_event); diff --git a/src/modules/mod.rs b/src/modules/mod.rs index dfcce62..edc0169 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -31,8 +31,8 @@ pub mod clock; pub mod custom; #[cfg(feature = "focused")] pub mod focused; -#[cfg(feature = "keys")] -pub mod keys; +#[cfg(feature = "keyboard")] +pub mod keyboard; pub mod label; #[cfg(feature = "launcher")] pub mod launcher; diff --git a/src/modules/workspaces/mod.rs b/src/modules/workspaces/mod.rs index acb3b9d..e5dfcca 100644 --- a/src/modules/workspaces/mod.rs +++ b/src/modules/workspaces/mod.rs @@ -196,7 +196,7 @@ impl Module for WorkspacesModule { let client = context.ironbar.clients.borrow_mut().workspaces()?; // Subscribe & send events spawn(async move { - let mut srx = client.subscribe_workspace_change(); + let mut srx = client.subscribe(); trace!("Set up workspace subscription"); @@ -213,9 +213,7 @@ impl Module for WorkspacesModule { trace!("Setting up UI event handler"); while let Some(name) = rx.recv().await { - if let Err(e) = client.focus(name.clone()) { - warn!("Couldn't focus workspace '{name}': {e:#}"); - }; + client.focus(name.clone()); } Ok::<(), Report>(())