diff --git a/Cargo.toml b/Cargo.toml
index ad93883c..5ff4630f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -19,6 +19,7 @@ default = [
"ipc",
"launcher",
"music+all",
+ "notifications",
"sys_info",
"tray",
"upower",
@@ -57,6 +58,8 @@ music = ["regex"]
"music+mpris" = ["music", "mpris"]
"music+mpd" = ["music", "mpd-utils"]
+notifications = ["zbus"]
+
sys_info = ["sysinfo", "regex"]
tray = ["system-tray"]
@@ -133,7 +136,6 @@ system-tray = { version = "0.1.5", optional = true }
# upower
upower_dbus = { version = "0.3.2", optional = true }
futures-lite = { version = "2.2.0", optional = true }
-zbus = { version = "3.15.2", optional = true }
# volume
libpulse-binding = { version = "2.28.1", optional = true }
@@ -148,3 +150,4 @@ futures-util = { version = "0.3.30", optional = true }
regex = { version = "1.10.3", default-features = false, features = [
"std",
], optional = true } # music, sys_info
+zbus = { version = "3.15.2", optional = true } # notifications, upower
\ No newline at end of file
diff --git a/docs/_Sidebar.md b/docs/_Sidebar.md
index cbe5e0ab..766da7eb 100644
--- a/docs/_Sidebar.md
+++ b/docs/_Sidebar.md
@@ -30,6 +30,7 @@
- [Label](label)
- [Launcher](launcher)
- [Music](music)
+- [Notifications](notifications)
- [Script](script)
- [Sys_Info](sys-info)
- [Tray](tray)
diff --git a/docs/modules/Notifications.md b/docs/modules/Notifications.md
new file mode 100644
index 00000000..3f12f5e2
--- /dev/null
+++ b/docs/modules/Notifications.md
@@ -0,0 +1,116 @@
+Displays information about the current SwayNC state such as notification count and DnD.
+Clicking the widget opens the SwayNC panel.
+
+[!Notifications widget in its closed state showing 3 notifications](https://f.jstanger.dev/github/ironbar/notifications.png)
+
+> ![NOTE]
+> This widget requires the [SwayNC](https://github.com/ErikReider/SwayNotificationCenter)
+> daemon to be running to use.
+
+## Configuration
+
+> Type: `notifications`
+
+| Name | Type | Default | Description |
+|---------------------|-----------|---------|--------------------------------------------------------------------------------------------------------|
+| `show_count` | `boolean` | `true` | Whether to show the current notification count. |
+| `icons.closed_none` | `string` | `` | Icon to show when the panel is closed, with no notifications. |
+| `icons.closed_some` | `string` | `` | Icon to show when the panel is closed, with notifications. |
+| `icons.closed_dnd` | `string` | `` | Icon to show when the panel is closed, with DnD enabled. Takes higher priority than count-based icons. |
+| `icons.open_none` | `string` | `` | Icon to show when the panel is open, with no notifications. |
+| `icons.open_some` | `string` | `` | Icon to show when the panel is open, with notifications. |
+| `icons.open_dnd` | `string` | `` | Icon to show when the panel is open, with DnD enabled. Takes higher priority than count-based icons. |
+
+
+
+JSON
+
+```json
+{
+ "end": [
+ {
+ "type": "notifications",
+ "show_count": true,
+ "icons": {
+ "closed_none": "",
+ "closed_some": "",
+ "closed_dnd": "",
+ "open_none": "",
+ "open_some": "",
+ "open_dnd": ""
+ }
+ }
+ ]
+}
+```
+
+
+
+
+TOML
+
+```toml
+[[end]]
+type = "notifications"
+show_count = true
+
+[[end.icons]]
+closed_none = ""
+closed_some = ""
+closed_dnd = ""
+open_none = ""
+open_some = ""
+open_dnd = ""
+```
+
+
+
+
+YAML
+
+```yaml
+end:
+ - type: notifications
+ show_count: true
+ icons:
+ closed_none:
+ closed_some:
+ closed_dnd:
+ open_none:
+ open_some:
+ open_dnd:
+```
+
+
+
+
+Corn
+
+```corn
+{
+ end = [
+ {
+ type = "notifications"
+ show_count = true
+
+ icons.closed_none = ""
+ icons.closed_some = ""
+ icons.closed_dnd = ""
+ icons.open_none = ""
+ icons.open_some = ""
+ icons.open_dnd = ""
+ }
+ ]
+}
+```
+
+
+
+## Styling
+
+| Selector | Description |
+|-------------------------|---------------------------------------|
+| `.notifications` | Notifications widget button |
+| `.notifications .count` | Notifications count indicator overlay |
+
+For more information on styling, please see the [styling guide](styling-guide).
\ No newline at end of file
diff --git a/examples/config.corn b/examples/config.corn
index 718e8303..69b3d5fa 100644
--- a/examples/config.corn
+++ b/examples/config.corn
@@ -33,6 +33,18 @@ let {
$mpd_local = { type = "music" player_type = "mpd" music_dir = "/home/jake/Music" truncate.mode = "end" truncate.max_length = 100 }
$mpd_server = { type = "music" player_type = "mpd" host = "chloe:6600" truncate = "end" }
+ $notifications = {
+ type = "notifications"
+ show_count = true
+
+ icons.closed_none = ""
+ icons.closed_some = ""
+ icons.closed_dnd = ""
+ icons.open_none = ""
+ icons.open_some = ""
+ icons.open_dnd = ""
+ }
+
$sys_info = {
type = "sys_info"
@@ -110,7 +122,7 @@ let {
// -- end custom --
$left = [ $workspaces $launcher $label ]
- $right = [ $mpd_local $mpd_server $phone_battery $sys_info $volume $clipboard $power_menu $clock ]
+ $right = [ $mpd_local $mpd_server $phone_battery $sys_info $volume $clipboard $power_menu $clock $notifications ]
}
in {
anchor_to_edges = true
diff --git a/examples/config.json b/examples/config.json
index f32664e3..87b788b6 100644
--- a/examples/config.json
+++ b/examples/config.json
@@ -141,6 +141,18 @@
},
{
"type": "clock"
+ },
+ {
+ "type": "notifications",
+ "show_count": true,
+ "icons": {
+ "closed_none": "",
+ "closed_some": "",
+ "closed_dnd": "",
+ "open_none": "",
+ "open_some": "",
+ "open_dnd": ""
+ }
}
]
}
diff --git a/examples/config.toml b/examples/config.toml
index 489605c4..830e8581 100644
--- a/examples/config.toml
+++ b/examples/config.toml
@@ -131,3 +131,15 @@ label = "Uptime: {{30000:uptime -p | cut -d ' ' -f2-}}"
[[end]]
type = "clock"
+[[end]]
+type = "notifications"
+show_count = true
+
+[end.icons]
+closed_none = ""
+closed_some = ""
+closed_dnd = ""
+open_none = ""
+open_some = ""
+open_dnd = ""
+
diff --git a/examples/config.yaml b/examples/config.yaml
index 49923ee4..76934143 100644
--- a/examples/config.yaml
+++ b/examples/config.yaml
@@ -92,4 +92,13 @@ end:
label: 'Uptime: {{30000:uptime -p | cut -d '' '' -f2-}}'
tooltip: 'Up: {{30000:uptime -p | cut -d '' '' -f2-}}'
- type: clock
+- type: notifications
+ show_count: true
+ icons:
+ closed_none:
+ closed_some:
+ closed_dnd:
+ open_none:
+ open_some:
+ open_dnd:
diff --git a/examples/style.css b/examples/style.css
index 8887538a..f32fbcc2 100644
--- a/examples/style.css
+++ b/examples/style.css
@@ -150,6 +150,20 @@ scale trough {
border-radius: 100%;
}
+/* notifications */
+
+.notifications .count {
+ font-size: 0.6rem;
+ background-color: @color_text;
+ color: @color_bg;
+ border-radius: 100%;
+ margin-right: 3px;
+ margin-top: 3px;
+ padding-left: 4px;
+ padding-right: 4px;
+ opacity: 0.7;
+}
+
/* -- script -- */
.script {
diff --git a/src/bar.rs b/src/bar.rs
index 0263d393..eb355dd2 100644
--- a/src/bar.rs
+++ b/src/bar.rs
@@ -385,6 +385,8 @@ fn add_modules(
ModuleConfig::Launcher(mut module) => add_module!(module, id),
#[cfg(feature = "music")]
ModuleConfig::Music(mut module) => add_module!(module, id),
+ #[cfg(feature = "notifications")]
+ ModuleConfig::Notifications(mut module) => add_module!(module, id),
ModuleConfig::Script(mut module) => add_module!(module, id),
#[cfg(feature = "sys_info")]
ModuleConfig::SysInfo(mut module) => add_module!(module, id),
diff --git a/src/clients/mod.rs b/src/clients/mod.rs
index 8edec61e..fa9c876b 100644
--- a/src/clients/mod.rs
+++ b/src/clients/mod.rs
@@ -6,6 +6,8 @@ pub mod clipboard;
pub mod compositor;
#[cfg(feature = "music")]
pub mod music;
+#[cfg(feature = "notifications")]
+pub mod swaync;
#[cfg(feature = "tray")]
pub mod system_tray;
#[cfg(feature = "upower")]
@@ -25,6 +27,8 @@ pub struct Clients {
clipboard: Option>,
#[cfg(feature = "music")]
music: std::collections::HashMap>,
+ #[cfg(feature = "notifications")]
+ notifications: Option>,
#[cfg(feature = "tray")]
tray: Option>,
#[cfg(feature = "upower")]
@@ -71,6 +75,15 @@ impl Clients {
.clone()
}
+ #[cfg(feature = "notifications")]
+ pub fn notifications(&mut self) -> Arc {
+ self.notifications
+ .get_or_insert_with(|| {
+ Arc::new(crate::await_sync(async { swaync::Client::new().await }))
+ })
+ .clone()
+ }
+
#[cfg(feature = "tray")]
pub fn tray(&mut self) -> Arc {
self.tray
@@ -122,7 +135,7 @@ macro_rules! register_client {
where
TSend: Clone,
{
- fn provide(&self) -> Arc<$ty> {
+ fn provide(&self) -> std::sync::Arc<$ty> {
self.ironbar.clients.borrow_mut().$method()
}
}
diff --git a/src/clients/swaync/dbus.rs b/src/clients/swaync/dbus.rs
new file mode 100644
index 00000000..1bc292bc
--- /dev/null
+++ b/src/clients/swaync/dbus.rs
@@ -0,0 +1,111 @@
+//! # D-Bus interface proxy for: `org.erikreider.swaync.cc`
+//!
+//! This code was generated by `zbus-xmlgen` `4.0.1` from D-Bus introspection data.
+//! Source: `Interface '/org/erikreider/swaync/cc' from service 'org.erikreider.swaync.cc' on session bus`.
+//!
+//! You may prefer to adapt it, instead of using it verbatim.
+//!
+//! More information can be found in the [Writing a client proxy] section of the zbus
+//! documentation.
+//!
+//! This type implements the [D-Bus standard interfaces], (`org.freedesktop.DBus.*`) for which the
+//! following zbus API can be used:
+//!
+//! * [`zbus::fdo::PropertiesProxy`]
+//! * [`zbus::fdo::IntrospectableProxy`]
+//! * [`zbus::fdo::PeerProxy`]
+//!
+//! Consequently `zbus-xmlgen` did not generate code for the above interfaces.
+//!
+//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html
+//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces,
+
+#[zbus::dbus_proxy(
+ interface = "org.erikreider.swaync.cc",
+ default_service = "org.erikreider.swaync.cc",
+ default_path = "/org/erikreider/swaync/cc"
+)]
+trait SwayNc {
+ /// AddInhibitor method
+ fn add_inhibitor(&self, application_id: &str) -> zbus::Result;
+
+ /// ChangeConfigValue method
+ fn change_config_value(
+ &self,
+ name: &str,
+ value: &zbus::zvariant::Value<'_>,
+ write_to_file: bool,
+ path: &str,
+ ) -> zbus::Result<()>;
+
+ /// ClearInhibitors method
+ fn clear_inhibitors(&self) -> zbus::Result;
+
+ /// CloseAllNotifications method
+ fn close_all_notifications(&self) -> zbus::Result<()>;
+
+ /// CloseNotification method
+ fn close_notification(&self, id: u32) -> zbus::Result<()>;
+
+ /// GetDnd method
+ fn get_dnd(&self) -> zbus::Result;
+
+ /// GetSubscribeData method
+ fn get_subscribe_data(&self) -> zbus::Result<(bool, bool, u32, bool)>;
+
+ /// GetVisibility method
+ fn get_visibility(&self) -> zbus::Result;
+
+ /// HideLatestNotifications method
+ fn hide_latest_notifications(&self, close: bool) -> zbus::Result<()>;
+
+ /// IsInhibited method
+ fn is_inhibited(&self) -> zbus::Result;
+
+ /// NotificationCount method
+ fn notification_count(&self) -> zbus::Result;
+
+ /// NumberOfInhibitors method
+ fn number_of_inhibitors(&self) -> zbus::Result;
+
+ /// ReloadConfig method
+ fn reload_config(&self) -> zbus::Result<()>;
+
+ /// ReloadCss method
+ fn reload_css(&self) -> zbus::Result;
+
+ /// RemoveInhibitor method
+ fn remove_inhibitor(&self, application_id: &str) -> zbus::Result;
+
+ /// SetDnd method
+ fn set_dnd(&self, state: bool) -> zbus::Result<()>;
+
+ /// SetVisibility method
+ fn set_visibility(&self, visibility: bool) -> zbus::Result<()>;
+
+ /// ToggleDnd method
+ fn toggle_dnd(&self) -> zbus::Result;
+
+ /// ToggleVisibility method
+ fn toggle_visibility(&self) -> zbus::Result<()>;
+
+ /// Subscribe signal
+ #[dbus_proxy(signal)]
+ fn subscribe(&self, count: u32, dnd: bool, cc_open: bool) -> zbus::Result<()>;
+
+ /// SubscribeV2 signal
+ #[dbus_proxy(signal)]
+ fn subscribe_v2(
+ &self,
+ count: u32,
+ dnd: bool,
+ cc_open: bool,
+ inhibited: bool,
+ ) -> zbus::Result<()>;
+
+ /// Inhibited property
+ #[dbus_proxy(property)]
+ fn inhibited(&self) -> zbus::Result;
+ #[dbus_proxy(property)]
+ fn set_inhibited(&self, value: bool) -> zbus::Result<()>;
+}
diff --git a/src/clients/swaync/mod.rs b/src/clients/swaync/mod.rs
new file mode 100644
index 00000000..c7d226ba
--- /dev/null
+++ b/src/clients/swaync/mod.rs
@@ -0,0 +1,88 @@
+mod dbus;
+
+use crate::{register_client, send, spawn};
+use color_eyre::{Report, Result};
+use dbus::SwayNcProxy;
+use serde::Deserialize;
+use tokio::sync::broadcast;
+use tracing::{debug, error};
+use zbus::export::ordered_stream::OrderedStreamExt;
+use zbus::zvariant::Type;
+
+#[derive(Debug, Clone, Copy, Type, Deserialize)]
+pub struct Event {
+ pub count: u32,
+ pub dnd: bool,
+ pub cc_open: bool,
+ pub inhibited: bool,
+}
+
+type GetSubscribeData = (bool, bool, u32, bool);
+
+/// Converts the data returned from
+/// `get_subscribe_data` into an event for convenience.
+impl From for Event {
+ fn from((dnd, cc_open, count, inhibited): (bool, bool, u32, bool)) -> Self {
+ Self {
+ dnd,
+ cc_open,
+ count,
+ inhibited,
+ }
+ }
+}
+
+#[derive(Debug)]
+pub struct Client {
+ proxy: SwayNcProxy<'static>,
+ tx: broadcast::Sender,
+ _rx: broadcast::Receiver,
+}
+
+impl Client {
+ pub async fn new() -> Self {
+ let dbus = Box::pin(zbus::Connection::session())
+ .await
+ .expect("failed to create connection to system bus");
+
+ let proxy = SwayNcProxy::new(&dbus).await.unwrap();
+ let (tx, rx) = broadcast::channel(8);
+
+ let mut stream = proxy.receive_subscribe_v2().await.unwrap();
+
+ {
+ let tx = tx.clone();
+
+ spawn(async move {
+ while let Some(ev) = stream.next().await {
+ let ev = ev.body::().expect("to deserialize");
+ debug!("Received event: {ev:?}");
+ send!(tx, ev);
+ }
+ });
+ }
+
+ Self { proxy, tx, _rx: rx }
+ }
+
+ pub fn subscribe(&self) -> broadcast::Receiver {
+ self.tx.subscribe()
+ }
+
+ pub async fn state(&self) -> Result {
+ debug!("Getting subscribe data (current state)");
+ match self.proxy.get_subscribe_data().await {
+ Ok(data) => Ok(data.into()),
+ Err(err) => Err(Report::new(err)),
+ }
+ }
+
+ pub async fn toggle_visibility(&self) {
+ debug!("Toggling visibility");
+ if let Err(err) = self.proxy.toggle_visibility().await {
+ error!("{err:?}");
+ }
+ }
+}
+
+register_client!(Client, notifications);
diff --git a/src/config/mod.rs b/src/config/mod.rs
index bfd301b2..4661a68e 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -14,6 +14,8 @@ use crate::modules::label::LabelModule;
use crate::modules::launcher::LauncherModule;
#[cfg(feature = "music")]
use crate::modules::music::MusicModule;
+#[cfg(feature = "notifications")]
+use crate::modules::notifications::NotificationsModule;
use crate::modules::script::ScriptModule;
#[cfg(feature = "sys_info")]
use crate::modules::sysinfo::SysInfoModule;
@@ -47,6 +49,8 @@ pub enum ModuleConfig {
Launcher(Box),
#[cfg(feature = "music")]
Music(Box),
+ #[cfg(feature = "notifications")]
+ Notifications(Box),
Script(Box),
#[cfg(feature = "sys_info")]
SysInfo(Box),
diff --git a/src/modules/mod.rs b/src/modules/mod.rs
index d8d671b6..67a7f215 100644
--- a/src/modules/mod.rs
+++ b/src/modules/mod.rs
@@ -34,6 +34,8 @@ pub mod label;
pub mod launcher;
#[cfg(feature = "music")]
pub mod music;
+#[cfg(feature = "notifications")]
+pub mod notifications;
pub mod script;
#[cfg(feature = "sys_info")]
pub mod sysinfo;
diff --git a/src/modules/notifications.rs b/src/modules/notifications.rs
new file mode 100644
index 00000000..232b5863
--- /dev/null
+++ b/src/modules/notifications.rs
@@ -0,0 +1,190 @@
+use crate::clients::swaync;
+use crate::config::CommonConfig;
+use crate::gtk_helpers::IronbarGtkExt;
+use crate::modules::{Module, ModuleInfo, ModuleParts, ModuleUpdateEvent, WidgetContext};
+use crate::{glib_recv, send_async, spawn, try_send};
+use gtk::prelude::*;
+use gtk::{Align, Button, Label, Overlay};
+use serde::Deserialize;
+use tokio::sync::mpsc::Receiver;
+use tracing::error;
+
+#[derive(Debug, Deserialize, Clone)]
+pub struct NotificationsModule {
+ #[serde(default = "crate::config::default_true")]
+ show_count: bool,
+
+ #[serde(default)]
+ icons: Icons,
+
+ #[serde(flatten)]
+ pub common: Option,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+struct Icons {
+ #[serde(default = "default_icon_closed_none")]
+ closed_none: String,
+ #[serde(default = "default_icon_closed_some")]
+ closed_some: String,
+ #[serde(default = "default_icon_closed_dnd")]
+ closed_dnd: String,
+ #[serde(default = "default_icon_open_none")]
+ open_none: String,
+ #[serde(default = "default_icon_open_some")]
+ open_some: String,
+ #[serde(default = "default_icon_open_dnd")]
+ open_dnd: String,
+}
+
+impl Default for Icons {
+ fn default() -> Self {
+ Self {
+ closed_none: default_icon_closed_none(),
+ closed_some: default_icon_closed_some(),
+ closed_dnd: default_icon_closed_dnd(),
+ open_none: default_icon_open_none(),
+ open_some: default_icon_open_some(),
+ open_dnd: default_icon_open_dnd(),
+ }
+ }
+}
+
+fn default_icon_closed_none() -> String {
+ String::from("")
+}
+
+fn default_icon_closed_some() -> String {
+ String::from("")
+}
+
+fn default_icon_closed_dnd() -> String {
+ String::from("")
+}
+
+fn default_icon_open_none() -> String {
+ String::from("")
+}
+
+fn default_icon_open_some() -> String {
+ String::from("")
+}
+
+fn default_icon_open_dnd() -> String {
+ String::from("")
+}
+
+impl Icons {
+ fn icon(&self, value: &swaync::Event) -> &str {
+ match (value.cc_open, value.count > 0, value.dnd) {
+ (true, _, true) => &self.open_dnd,
+ (true, true, false) => &self.open_some,
+ (true, false, false) => &self.open_none,
+ (false, _, true) => &self.closed_dnd,
+ (false, true, false) => &self.closed_some,
+ (false, false, false) => &self.closed_none,
+ }
+ .as_str()
+ }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub enum UiEvent {
+ ToggleVisibility,
+}
+
+impl Module for NotificationsModule {
+ type SendMessage = swaync::Event;
+ type ReceiveMessage = UiEvent;
+
+ fn name() -> &'static str {
+ "notifications"
+ }
+
+ fn spawn_controller(
+ &self,
+ _info: &ModuleInfo,
+ context: &WidgetContext,
+ mut rx: Receiver,
+ ) -> color_eyre::Result<()>
+ where
+ >::SendMessage: Clone,
+ {
+ let client = context.client::();
+
+ {
+ let client = client.clone();
+ let mut rx = client.subscribe();
+ let tx = context.tx.clone();
+
+ spawn(async move {
+ let initial_state = client.state().await;
+
+ match initial_state {
+ Ok(ev) => send_async!(tx, ModuleUpdateEvent::Update(ev)),
+ Err(err) => error!("{err:?}"),
+ };
+
+ while let Ok(ev) = rx.recv().await {
+ send_async!(tx, ModuleUpdateEvent::Update(ev));
+ }
+ });
+ }
+
+ spawn(async move {
+ while let Some(event) = rx.recv().await {
+ match event {
+ UiEvent::ToggleVisibility => client.toggle_visibility().await,
+ }
+ }
+ });
+
+ Ok(())
+ }
+
+ fn into_widget(
+ self,
+ context: WidgetContext,
+ _info: &ModuleInfo,
+ ) -> color_eyre::Result>
+ where
+ >::SendMessage: Clone,
+ {
+ let overlay = Overlay::new();
+ let button = Button::with_label(&self.icons.closed_none);
+ overlay.add(&button);
+
+ let label = Label::builder()
+ .label("0")
+ .halign(Align::End)
+ .valign(Align::Start)
+ .build();
+
+ if self.show_count {
+ label.add_class("count");
+ overlay.add_overlay(&label);
+ }
+
+ let ctx = context.controller_tx.clone();
+ button.connect_clicked(move |_| {
+ try_send!(ctx, UiEvent::ToggleVisibility);
+ });
+
+ {
+ let button = button.clone();
+
+ glib_recv!(context.subscribe(), ev => {
+ let icon = self.icons.icon(&ev);
+ button.set_label(icon);
+
+ label.set_label(&ev.count.to_string());
+ label.set_visible(self.show_count && ev.count > 0);
+ });
+ }
+
+ Ok(ModuleParts {
+ widget: overlay,
+ popup: None,
+ })
+ }
+}