1
0
Fork 0
mirror of https://github.com/Zedfrigg/ironbar.git synced 2025-08-16 22:31:03 +02:00

feat(keyboard): ability to display and switch kb layout (#836)

This extends the existing `keys` module to be able to show the current keyboard layout, and cycle between layouts (using the `next` command) by clicking. The `keys` module has been renamed to `keyboard` to more accurately reflect its extended featureset.
This commit is contained in:
kuzy000 2025-02-04 00:19:30 +03:00 committed by GitHub
parent ee19176a2c
commit 03e6f10141
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 552 additions and 202 deletions

View file

@ -18,7 +18,7 @@ default = [
"focused", "focused",
"http", "http",
"ipc", "ipc",
"keys", "keyboard+all",
"launcher", "launcher",
"music+all", "music+all",
"network_manager", "network_manager",
@ -56,7 +56,10 @@ clock = ["chrono"]
focused = [] 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 = [] launcher = []
@ -137,7 +140,7 @@ cairo-rs = { version = "0.18.5", optional = true, features = ["png"] }
# clock # clock
chrono = { version = "0.4.39", optional = true, default-features = false, features = ["clock", "unstable-locales"] } chrono = { version = "0.4.39", optional = true, default-features = false, features = ["clock", "unstable-locales"] }
# keys # keyboard
input = { version = "0.9.1", optional = true } input = { version = "0.9.1", optional = true }
evdev-rs = { version = "0.6.1", optional = true } evdev-rs = { version = "0.6.1", optional = true }
libc = { version = "0.2.164", optional = true } libc = { version = "0.2.164", optional = true }
@ -161,10 +164,6 @@ upower_dbus = { version = "0.3.2", optional = true }
# volume # volume
libpulse-binding = { version = "2.28.2", optional = true } 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 # shared
futures-lite = { version = "2.6.0", optional = true } # network_manager, upower, workspaces 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 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", "std",
], optional = true } # music, sys_info ], optional = true } # music, sys_info
zbus = { version = "3.15.2", default-features = false, features = ["tokio"], optional = true } # network_manager, notifications, upower 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 # schema
schemars = { version = "0.8.21", optional = true } schemars = { version = "0.8.21", optional = true }

View file

@ -26,7 +26,7 @@ pacman -S openssl
pacman -S libdbusmenu-gtk3 pacman -S libdbusmenu-gtk3
# for volume support # for volume support
pacman -S libpulse pacman -S libpulse
# for keys support # for keyboard support
pacman -S libinput pacman -S libinput
# for lua/cairo support # for lua/cairo support
pacman -S luajit lua51-lgi pacman -S luajit lua51-lgi
@ -42,7 +42,7 @@ apt install libssl-dev
apt install libdbusmenu-gtk3-dev apt install libdbusmenu-gtk3-dev
# for volume support # for volume support
apt install libpulse-dev apt install libpulse-dev
# for keys support # for keyboard support
apt install libinput-dev apt install libinput-dev
# for lua/cairo support # for lua/cairo support
apt install luajit-dev lua-lgi apt install luajit-dev lua-lgi
@ -58,7 +58,7 @@ dnf install openssl-devel
dnf install libdbusmenu-gtk3-devel dnf install libdbusmenu-gtk3-devel
# for volume support # for volume support
dnf install pulseaudio-libs-devel dnf install pulseaudio-libs-devel
# for keys support # for keyboard support
dnf install libinput-devel dnf install libinput-devel
# for lua/cairo support # for lua/cairo support
dnf install luajit-devel lua-lgi 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! > ⚠ Make sure you enable at least one `config` feature otherwise you will not be able to start the bar!
| Feature | Description | | Feature | Description |
|---------------------|-----------------------------------------------------------------------------------| | ------------------- | --------------------------------------------------------------------------------- |
| **Core** | | | **Core** | |
| http | Enables HTTP features. Currently this includes the ability to load remote images. | | http | Enables HTTP features. Currently this includes the ability to load remote images. |
| ipc | Enables the IPC server. | | ipc | Enables the IPC server. |
@ -101,6 +101,10 @@ cargo build --release --no-default-features \
| clipboard | Enables the `clipboard` module. | | clipboard | Enables the `clipboard` module. |
| clock | Enables the `clock` module. | | clock | Enables the `clock` module. |
| focused | Enables the `focused` 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. | | launcher | Enables the `launcher` module. |
| music+all | Enables the `music` module with support for all player types. | | music+all | Enables the `music` module with support for all player types. |
| music+mpris | Enables the `music` module with MPRIS support. | | music+mpris | Enables the `music` module with MPRIS support. |

View file

@ -29,7 +29,7 @@
- [Clock](clock) - [Clock](clock)
- [Custom](custom) - [Custom](custom)
- [Focused](focused) - [Focused](focused)
- [Keys](keys) - [Keyboard](keyboard)
- [Label](label) - [Label](label)
- [Launcher](launcher) - [Launcher](launcher)
- [Music](music) - [Music](music)

121
docs/modules/Keyboard.md Normal file
View file

@ -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<string, string or image>` | `{}` | 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. |
<details>
<summary>JSON</summary>
```json
{
"end": [
{
"type": "keyboard",
"show_scroll": false,
"icons": {
"caps_on": "󰪛",
"layout_map": {
"English (US)": "🇺🇸",
"Ukrainian": "🇺🇦"
}
}
}
]
}
```
</details>
<details>
<summary>TOML</summary>
```toml
[[end]]
type = "keyboard"
show_scroll = false
[end.icons]
caps_on = "󰪛"
[end.icons.layout_map]
"English (US)" = "🇺🇸"
Ukrainian = "🇺🇦"
```
</details>
<details>
<summary>YAML</summary>
```yaml
end:
- type: keyboard
show_scroll: false
icons:
caps_on: 󰪛
layout_map:
"English (US)": 🇺🇸
Ukrainian: 🇺🇦
```
</details>
<details>
<summary>Corn</summary>
```corn
{
end = [
{
type = "keyboard"
show_scroll = false
icons.caps_on = "󰪛"
icons.layout_map.'English (US)' = "🇺🇸"
icons.layout_map.Ukrainian = "🇺🇦"
}
]
}
```
</details>
## 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).

View file

@ -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. |
<details>
<summary>JSON</summary>
```json
{
"end": [
{
"type": "keys",
"show_scroll": false,
"icons": {
"caps_on": "󰪛"
}
}
]
}
```
</details>
<details>
<summary>TOML</summary>
```toml
[[end]]
type = "keys"
show_scroll = false
[end.icons]
caps_on = "󰪛"
```
</details>
<details>
<summary>YAML</summary>
```yaml
end:
- type: keys
show_scroll: false
icons:
caps_on: 󰪛
```
</details>
<details>
<summary>Corn</summary>
```corn
{
end = [
{
type = "keys"
show_scroll = false
icons.caps_on = "󰪛"
}
]
}
```
</details>
## 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).

View file

@ -64,7 +64,7 @@
++ lib.optionals (hasFeature "tray") [ libdbusmenu-gtk3 ] ++ lib.optionals (hasFeature "tray") [ libdbusmenu-gtk3 ]
++ lib.optionals (hasFeature "volume")[ libpulseaudio ] ++ lib.optionals (hasFeature "volume")[ libpulseaudio ]
++ lib.optionals (hasFeature "cairo") [ luajit ] ++ lib.optionals (hasFeature "cairo") [ luajit ]
++ lib.optionals (hasFeature "keys") [ libinput libevdev ]; ++ lib.optionals (hasFeature "keyboard") [ libinput libevdev ];
propagatedBuildInputs = [ gtk3 ]; propagatedBuildInputs = [ gtk3 ];

View file

@ -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 crate::{arc_mut, lock, send, spawn_blocking};
use color_eyre::Result; 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::dispatch::{Dispatch, DispatchType, WorkspaceIdentifierWithSpecial};
use hyprland::event_listener::EventListener; use hyprland::event_listener::EventListener;
use hyprland::prelude::*; use hyprland::prelude::*;
@ -13,15 +17,21 @@ use tracing::{debug, error, info};
pub struct Client { pub struct Client {
workspace_tx: Sender<WorkspaceUpdate>, workspace_tx: Sender<WorkspaceUpdate>,
_workspace_rx: Receiver<WorkspaceUpdate>, _workspace_rx: Receiver<WorkspaceUpdate>,
keyboard_layout_tx: Sender<KeyboardLayoutUpdate>,
_keyboard_layout_rx: Receiver<KeyboardLayoutUpdate>,
} }
impl Client { impl Client {
pub(crate) fn new() -> Self { pub(crate) fn new() -> Self {
let (workspace_tx, workspace_rx) = channel(16); let (workspace_tx, workspace_rx) = channel(16);
let (keyboard_layout_tx, keyboard_layout_rx) = channel(4);
let instance = Self { let instance = Self {
workspace_tx, workspace_tx,
_workspace_rx: workspace_rx, _workspace_rx: workspace_rx,
keyboard_layout_tx,
_keyboard_layout_rx: keyboard_layout_rx,
}; };
instance.listen_workspace_events(); instance.listen_workspace_events();
@ -32,6 +42,7 @@ impl Client {
info!("Starting Hyprland event listener"); info!("Starting Hyprland event listener");
let tx = self.workspace_tx.clone(); let tx = self.workspace_tx.clone();
let keyboard_layout_tx = self.keyboard_layout_tx.clone();
spawn_blocking(move || { spawn_blocking(move || {
let mut event_listener = EventListener::new(); 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| { event_listener.add_urgent_state_handler(move |address| {
let _lock = lock!(lock); let _lock = lock!(lock);
debug!("Received urgent state: {address:?}"); 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 event_listener
.start_listener() .start_listener()
.expect("Failed to start listener"); .expect("Failed to start listener");
@ -264,36 +327,73 @@ impl Client {
} }
impl WorkspaceClient for Client { impl WorkspaceClient for Client {
fn focus(&self, id: String) -> Result<()> { fn focus(&self, id: String) {
let identifier = id.parse::<i32>().map_or_else( let identifier = id.parse::<i32>().map_or_else(
|_| WorkspaceIdentifierWithSpecial::Name(&id), |_| WorkspaceIdentifierWithSpecial::Name(&id),
WorkspaceIdentifierWithSpecial::Id, WorkspaceIdentifierWithSpecial::Id,
); );
Dispatch::call(DispatchType::Workspace(identifier))?; if let Err(e) = Dispatch::call(DispatchType::Workspace(identifier)) {
Ok(()) error!("Couldn't focus workspace '{id}': {e:#}");
}
} }
fn subscribe_workspace_change(&self) -> Receiver<WorkspaceUpdate> { fn subscribe(&self) -> Receiver<WorkspaceUpdate> {
let rx = self.workspace_tx.subscribe(); let rx = self.workspace_tx.subscribe();
{ let active_id = HWorkspace::get_active().ok().map(|active| active.name);
let tx = self.workspace_tx.clone(); let is_visible = create_is_visible();
let active_id = HWorkspace::get_active().ok().map(|active| active.name); let workspaces = Workspaces::get()
let is_visible = create_is_visible(); .expect("Failed to get workspaces")
.into_iter()
.map(|w| {
let vis = Visibility::from((&w, active_id.as_deref(), &is_visible));
let workspaces = Workspaces::get() Workspace::from((vis, w))
.expect("Failed to get workspaces") })
.into_iter() .collect();
.map(|w| {
let vis = Visibility::from((&w, active_id.as_deref(), &is_visible));
Workspace::from((vis, w)) send!(self.workspace_tx, WorkspaceUpdate::Init(workspaces));
})
.collect();
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<KeyboardLayoutUpdate> {
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 rx

View file

@ -54,6 +54,26 @@ impl Compositor {
} }
} }
pub fn create_keyboard_layout_client(
clients: &mut super::Clients,
) -> Result<Arc<dyn KeyboardLayoutClient + Send + Sync>> {
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<dyn KeyboardLayoutClient + Send + Sync>),
#[cfg(feature = "keyboard+hyprland")]
Self::Hyprland => clients
.hyprland()
.map(|client| client as Arc<dyn KeyboardLayoutClient + Send + Sync>),
Self::Unsupported => Err(Report::msg("Unsupported compositor").note(
"Currently keyboard layout functionality are only supported by Sway and Hyprland",
)),
}
}
/// Creates a new instance of /// Creates a new instance of
/// the workspace client for the current compositor. /// the workspace client for the current compositor.
pub fn create_workspace_client( pub fn create_workspace_client(
@ -67,7 +87,9 @@ impl Compositor {
.sway() .sway()
.map(|client| client as Arc<dyn WorkspaceClient + Send + Sync>), .map(|client| client as Arc<dyn WorkspaceClient + Send + Sync>),
#[cfg(feature = "workspaces+hyprland")] #[cfg(feature = "workspaces+hyprland")]
Self::Hyprland => Ok(Arc::new(hyprland::Client::new())), Self::Hyprland => clients
.hyprland()
.map(|client| client as Arc<dyn WorkspaceClient + Send + Sync>),
Self::Unsupported => Err(Report::msg("Unsupported compositor") Self::Unsupported => Err(Report::msg("Unsupported compositor")
.note("Currently workspaces are only supported by Sway and Hyprland")), .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)] #[derive(Debug, Clone)]
pub enum WorkspaceUpdate { pub enum WorkspaceUpdate {
/// Provides an initial list of workspaces. /// Provides an initial list of workspaces.
@ -146,10 +171,20 @@ pub enum WorkspaceUpdate {
pub trait WorkspaceClient: Debug + Send + Sync { pub trait WorkspaceClient: Debug + Send + Sync {
/// Requests the workspace with this name is focused. /// 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. /// Creates a new to workspace event receiver.
fn subscribe_workspace_change(&self) -> broadcast::Receiver<WorkspaceUpdate>; fn subscribe(&self) -> broadcast::Receiver<WorkspaceUpdate>;
} }
register_fallible_client!(dyn WorkspaceClient, workspaces); 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<KeyboardLayoutUpdate>;
}
register_fallible_client!(dyn KeyboardLayoutClient, keyboard_layout);

View file

@ -1,21 +1,25 @@
use super::{Visibility, Workspace, WorkspaceClient, WorkspaceUpdate}; use super::{
use crate::{await_sync, send}; KeyboardLayoutClient, KeyboardLayoutUpdate, Visibility, Workspace, WorkspaceClient,
use color_eyre::Result; WorkspaceUpdate,
use swayipc_async::{Node, WorkspaceChange, WorkspaceEvent}; };
use crate::{await_sync, error, send, spawn};
use swayipc_async::{InputChange, InputEvent, Node, WorkspaceChange, WorkspaceEvent};
use tokio::sync::broadcast::{channel, Receiver}; use tokio::sync::broadcast::{channel, Receiver};
use crate::clients::sway::Client; use crate::clients::sway::Client;
impl WorkspaceClient for Client { impl WorkspaceClient for Client {
fn focus(&self, id: String) -> Result<()> { fn focus(&self, id: String) {
await_sync(async move { let client = self.connection().clone();
let mut client = self.connection().lock().await; spawn(async move {
client.run_command(format!("workspace {id}")).await let mut client = client.lock().await;
})?; if let Err(e) = client.run_command(format!("workspace {id}")).await {
Ok(()) error!("Couldn't focus workspace '{id}': {e:#}");
}
});
} }
fn subscribe_workspace_change(&self) -> Receiver<WorkspaceUpdate> { fn subscribe(&self) -> Receiver<WorkspaceUpdate> {
let (tx, rx) = channel(16); let (tx, rx) = channel(16);
let client = self.connection().clone(); let client = self.connection().clone();
@ -133,3 +137,77 @@ impl From<WorkspaceEvent> 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<KeyboardLayoutUpdate> {
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::<InputEvent>(move |event| {
if let Ok(layout) = KeyboardLayoutUpdate::try_from(event.clone()) {
send!(tx, layout);
}
})
.await
.expect("to add listener");
});
rx
}
}
impl TryFrom<InputEvent> for KeyboardLayoutUpdate {
type Error = ();
fn try_from(value: InputEvent) -> std::result::Result<Self, Self::Error> {
match value.change {
InputChange::XkbLayout => {
if let Some(layout) = value.input.xkb_active_layout_name {
Ok(KeyboardLayoutUpdate(layout))
} else {
Err(())
}
}
_ => Err(()),
}
}
}

View file

@ -9,7 +9,7 @@ use std::sync::Arc;
pub mod clipboard; pub mod clipboard;
#[cfg(feature = "workspaces")] #[cfg(feature = "workspaces")]
pub mod compositor; pub mod compositor;
#[cfg(feature = "keys")] #[cfg(feature = "keyboard")]
pub mod libinput; pub mod libinput;
#[cfg(feature = "cairo")] #[cfg(feature = "cairo")]
pub mod lua; pub mod lua;
@ -38,10 +38,14 @@ pub struct Clients {
workspaces: Option<Arc<dyn compositor::WorkspaceClient>>, workspaces: Option<Arc<dyn compositor::WorkspaceClient>>,
#[cfg(feature = "sway")] #[cfg(feature = "sway")]
sway: Option<Arc<sway::Client>>, sway: Option<Arc<sway::Client>>,
#[cfg(feature = "hyprland")]
hyprland: Option<Arc<compositor::hyprland::Client>>,
#[cfg(feature = "clipboard")] #[cfg(feature = "clipboard")]
clipboard: Option<Arc<clipboard::Client>>, clipboard: Option<Arc<clipboard::Client>>,
#[cfg(feature = "keys")] #[cfg(feature = "keyboard")]
libinput: HashMap<Box<str>, Arc<libinput::Client>>, libinput: HashMap<Box<str>, Arc<libinput::Client>>,
#[cfg(any(feature = "keyboard+sway", feature = "keyboard+hyprland"))]
keyboard_layout: Option<Arc<dyn compositor::KeyboardLayoutClient>>,
#[cfg(feature = "cairo")] #[cfg(feature = "cairo")]
lua: Option<Rc<lua::LuaEngine>>, lua: Option<Rc<lua::LuaEngine>>,
#[cfg(feature = "music")] #[cfg(feature = "music")]
@ -93,6 +97,19 @@ impl Clients {
Ok(client) Ok(client)
} }
#[cfg(any(feature = "keyboard+sway", feature = "keyboard+hyprland"))]
pub fn keyboard_layout(&mut self) -> ClientResult<dyn compositor::KeyboardLayoutClient> {
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")] #[cfg(feature = "sway")]
pub fn sway(&mut self) -> ClientResult<sway::Client> { pub fn sway(&mut self) -> ClientResult<sway::Client> {
let client = if let Some(client) = &self.sway { let client = if let Some(client) = &self.sway {
@ -107,6 +124,19 @@ impl Clients {
Ok(client) Ok(client)
} }
#[cfg(feature = "hyprland")]
pub fn hyprland(&mut self) -> ClientResult<compositor::hyprland::Client> {
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")] #[cfg(feature = "cairo")]
pub fn lua(&mut self, config_dir: &Path) -> Rc<lua::LuaEngine> { pub fn lua(&mut self, config_dir: &Path) -> Rc<lua::LuaEngine> {
self.lua self.lua
@ -114,7 +144,7 @@ impl Clients {
.clone() .clone()
} }
#[cfg(feature = "keys")] #[cfg(feature = "keyboard")]
pub fn libinput(&mut self, seat: &str) -> Arc<libinput::Client> { pub fn libinput(&mut self, seat: &str) -> Arc<libinput::Client> {
self.libinput self.libinput
.entry(seat.into()) .entry(seat.into())

View file

@ -11,8 +11,8 @@ use crate::modules::clock::ClockModule;
use crate::modules::custom::CustomModule; use crate::modules::custom::CustomModule;
#[cfg(feature = "focused")] #[cfg(feature = "focused")]
use crate::modules::focused::FocusedModule; use crate::modules::focused::FocusedModule;
#[cfg(feature = "keys")] #[cfg(feature = "keyboard")]
use crate::modules::keys::KeysModule; use crate::modules::keyboard::KeyboardModule;
use crate::modules::label::LabelModule; use crate::modules::label::LabelModule;
#[cfg(feature = "launcher")] #[cfg(feature = "launcher")]
use crate::modules::launcher::LauncherModule; use crate::modules::launcher::LauncherModule;
@ -61,8 +61,8 @@ pub enum ModuleConfig {
Custom(Box<CustomModule>), Custom(Box<CustomModule>),
#[cfg(feature = "focused")] #[cfg(feature = "focused")]
Focused(Box<FocusedModule>), Focused(Box<FocusedModule>),
#[cfg(feature = "keys")] #[cfg(feature = "keyboard")]
Keys(Box<KeysModule>), Keyboard(Box<KeyboardModule>),
Label(Box<LabelModule>), Label(Box<LabelModule>),
#[cfg(feature = "launcher")] #[cfg(feature = "launcher")]
Launcher(Box<LauncherModule>), Launcher(Box<LauncherModule>),
@ -110,8 +110,8 @@ impl ModuleConfig {
Self::Custom(module) => create!(module), Self::Custom(module) => create!(module),
#[cfg(feature = "focused")] #[cfg(feature = "focused")]
Self::Focused(module) => create!(module), Self::Focused(module) => create!(module),
#[cfg(feature = "keys")] #[cfg(feature = "keyboard")]
Self::Keys(module) => create!(module), Self::Keyboard(module) => create!(module),
Self::Label(module) => create!(module), Self::Label(module) => create!(module),
#[cfg(feature = "launcher")] #[cfg(feature = "launcher")]
Self::Launcher(module) => create!(module), Self::Launcher(module) => create!(module),

View file

@ -31,7 +31,7 @@ pub fn new_icon_button(input: &str, icon_theme: &IconTheme, size: i32) -> Button
button button
} }
#[cfg(any(feature = "music", feature = "keys"))] #[cfg(any(feature = "music", feature = "keyboard"))]
pub struct IconLabel { pub struct IconLabel {
container: gtk::Box, container: gtk::Box,
label: Label, label: Label,
@ -41,7 +41,7 @@ pub struct IconLabel {
size: i32, size: i32,
} }
#[cfg(any(feature = "music", feature = "keys"))] #[cfg(any(feature = "music", feature = "keyboard"))]
impl IconLabel { impl IconLabel {
pub fn new(input: &str, icon_theme: &IconTheme, size: i32) -> Self { pub fn new(input: &str, icon_theme: &IconTheme, size: i32) -> Self {
let container = gtk::Box::new(Orientation::Horizontal, 0); let container = gtk::Box::new(Orientation::Horizontal, 0);

View file

@ -1,18 +1,23 @@
use std::collections::HashMap;
use color_eyre::eyre::Report;
use color_eyre::Result; use color_eyre::Result;
use gtk::prelude::*; use gtk::{prelude::*, Button};
use serde::Deserialize; use serde::Deserialize;
use tokio::sync::mpsc; 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::clients::libinput::{Event, Key, KeyEvent};
use crate::config::CommonConfig; use crate::config::CommonConfig;
use crate::gtk_helpers::IronbarGtkExt; use crate::gtk_helpers::IronbarGtkExt;
use crate::image::IconLabel; 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)] #[derive(Debug, Deserialize, Clone)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct KeysModule { pub struct KeyboardModule {
/// Whether to show capslock indicator. /// Whether to show capslock indicator.
/// ///
/// **Default**: `true` /// **Default**: `true`
@ -31,6 +36,12 @@ pub struct KeysModule {
#[serde(default = "crate::config::default_true")] #[serde(default = "crate::config::default_true")]
show_scroll: bool, 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). /// Size to render the icons at, in pixels (image icons only).
/// ///
/// **Default** `32` /// **Default** `32`
@ -93,6 +104,26 @@ struct Icons {
/// **Default**: `""` /// **Default**: `""`
#[serde(default)] #[serde(default)]
scroll_off: String, 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<String, String>,
} }
impl Default for Icons { impl Default for Icons {
@ -104,6 +135,7 @@ impl Default for Icons {
num_off: String::new(), num_off: String::new(),
scroll_on: default_icon_scroll(), scroll_on: default_icon_scroll(),
scroll_off: String::new(), scroll_off: String::new(),
layout_map: HashMap::new(),
} }
} }
} }
@ -128,17 +160,23 @@ fn default_icon_scroll() -> String {
String::from("") String::from("")
} }
impl Module<gtk::Box> for KeysModule { #[derive(Debug, Clone)]
type SendMessage = KeyEvent; pub enum KeyboardUpdate {
Key(KeyEvent),
Layout(KeyboardLayoutUpdate),
}
impl Module<gtk::Box> for KeyboardModule {
type SendMessage = KeyboardUpdate;
type ReceiveMessage = (); type ReceiveMessage = ();
module_impl!("keys"); module_impl!("keyboard");
fn spawn_controller( fn spawn_controller(
&self, &self,
_info: &ModuleInfo, _info: &ModuleInfo,
context: &WidgetContext<Self::SendMessage, Self::ReceiveMessage>, context: &WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
_rx: mpsc::Receiver<Self::ReceiveMessage>, mut rx: mpsc::Receiver<Self::ReceiveMessage>,
) -> Result<()> { ) -> Result<()> {
let client = context.ironbar.clients.borrow_mut().libinput(&self.seat); let client = context.ironbar.clients.borrow_mut().libinput(&self.seat);
@ -149,22 +187,47 @@ impl Module<gtk::Box> for KeysModule {
match ev { match ev {
Event::Device => { Event::Device => {
for key in [Key::Caps, Key::Num, Key::Scroll] { for key in [Key::Caps, Key::Num, Key::Scroll] {
module_update!( let event = KeyEvent {
tx, key,
KeyEvent { state: client.get_state(key),
key: Key::Caps, };
state: client.get_state(key) module_update!(tx, KeyboardUpdate::Key(event));
}
);
} }
} }
Event::Key(ev) => { Event::Key(ev) => {
send_async!(tx, ModuleUpdateEvent::Update(ev)); module_update!(tx, KeyboardUpdate::Key(ev));
} }
} }
} }
}); });
let client = context.try_client::<dyn compositor::KeyboardLayoutClient>()?;
{
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(()) Ok(())
} }
@ -173,12 +236,16 @@ impl Module<gtk::Box> for KeysModule {
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>, context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo, info: &ModuleInfo,
) -> Result<ModuleParts<gtk::Box>> { ) -> Result<ModuleParts<gtk::Box>> {
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 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 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 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 { if self.show_caps {
caps.add_class("key"); caps.add_class("key");
caps.add_class("caps"); caps.add_class("caps");
@ -197,31 +264,49 @@ impl Module<gtk::Box> for KeysModule {
container.add(&*scroll); 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 icons = self.icons;
let handle_event = move |ev: KeyEvent| { let handle_event = move |ev: KeyboardUpdate| match ev {
let parts = match (ev.key, ev.state) { KeyboardUpdate::Key(ev) => {
(Key::Caps, true) if self.show_caps => Some((&caps, icons.caps_on.as_str())), let parts = match (ev.key, ev.state) {
(Key::Caps, false) if self.show_caps => Some((&caps, icons.caps_off.as_str())), (Key::Caps, true) if self.show_caps => Some((&caps, icons.caps_on.as_str())),
(Key::Num, true) if self.show_num => Some((&num, icons.num_on.as_str())), (Key::Caps, false) if self.show_caps => Some((&caps, icons.caps_off.as_str())),
(Key::Num, false) if self.show_num => Some((&num, icons.num_off.as_str())), (Key::Num, true) if self.show_num => Some((&num, icons.num_on.as_str())),
(Key::Scroll, true) if self.show_scroll => { (Key::Num, false) if self.show_num => Some((&num, icons.num_off.as_str())),
Some((&scroll, icons.scroll_on.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())) (Key::Scroll, false) if self.show_scroll => {
} Some((&scroll, icons.scroll_off.as_str()))
_ => None, }
}; _ => None,
};
if let Some((label, input)) = parts { if let Some((label, input)) = parts {
label.set_label(Some(input)); label.set_label(Some(input));
if ev.state { if ev.state {
label.add_class("enabled"); label.add_class("enabled");
} else { } else {
label.remove_class("enabled"); 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); glib_recv!(context.subscribe(), handle_event);

View file

@ -31,8 +31,8 @@ pub mod clock;
pub mod custom; pub mod custom;
#[cfg(feature = "focused")] #[cfg(feature = "focused")]
pub mod focused; pub mod focused;
#[cfg(feature = "keys")] #[cfg(feature = "keyboard")]
pub mod keys; pub mod keyboard;
pub mod label; pub mod label;
#[cfg(feature = "launcher")] #[cfg(feature = "launcher")]
pub mod launcher; pub mod launcher;

View file

@ -196,7 +196,7 @@ impl Module<gtk::Box> for WorkspacesModule {
let client = context.ironbar.clients.borrow_mut().workspaces()?; let client = context.ironbar.clients.borrow_mut().workspaces()?;
// Subscribe & send events // Subscribe & send events
spawn(async move { spawn(async move {
let mut srx = client.subscribe_workspace_change(); let mut srx = client.subscribe();
trace!("Set up workspace subscription"); trace!("Set up workspace subscription");
@ -213,9 +213,7 @@ impl Module<gtk::Box> for WorkspacesModule {
trace!("Setting up UI event handler"); trace!("Setting up UI event handler");
while let Some(name) = rx.recv().await { while let Some(name) = rx.recv().await {
if let Err(e) = client.focus(name.clone()) { client.focus(name.clone());
warn!("Couldn't focus workspace '{name}': {e:#}");
};
} }
Ok::<(), Report>(()) Ok::<(), Report>(())