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, + }) + } +}