From a98d7a75c32f44a9d00240d9fd5a12cef6d71d2c Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Sun, 27 Sep 2026 08:17:57 +0100 Subject: [PATCH] Add theme-aware notification cards with grouped popup stacks --- crates/ferese-core/src/lib.rs | 1 + crates/ferese-core/src/notifications.rs | 28 + shell/ferese-shell/src/config.rs | 8 + shell/ferese-shell/src/main.rs | 116 ++- shell/ferese-shell/src/motion.rs | 8 + shell/ferese-shell/src/notification_ui.rs | 549 ++++++++++++++ shell/ferese-shell/src/notifications.rs | 851 ++++++++++++++++++++++ 7 files changed, 1558 insertions(+), 3 deletions(-) create mode 100644 crates/ferese-core/src/notifications.rs create mode 100644 shell/ferese-shell/src/notification_ui.rs create mode 100644 shell/ferese-shell/src/notifications.rs diff --git a/crates/ferese-core/src/lib.rs b/crates/ferese-core/src/lib.rs index 41b894a..6b8851a 100644 --- a/crates/ferese-core/src/lib.rs +++ b/crates/ferese-core/src/lib.rs @@ -1,4 +1,5 @@ pub mod desktop; +pub mod notifications; mod output; mod workspace; diff --git a/crates/ferese-core/src/notifications.rs b/crates/ferese-core/src/notifications.rs new file mode 100644 index 0000000..94f0e2c --- /dev/null +++ b/crates/ferese-core/src/notifications.rs @@ -0,0 +1,28 @@ +use serde::Deserialize; + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(default, deny_unknown_fields)] +pub struct NotificationConfig { + pub show_popups: bool, + pub do_not_disturb: bool, + pub timeout_ms: u32, +} + +impl Default for NotificationConfig { + fn default() -> Self { + Self { + show_popups: true, + do_not_disturb: false, + timeout_ms: 6000, + } + } +} + +impl NotificationConfig { + pub fn validate(&self) -> Result<(), String> { + if !(1000..=30000).contains(&self.timeout_ms) { + return Err("notifications.timeout_ms must be between 1000 and 30000".into()); + } + Ok(()) + } +} diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs index 1eb4c4f..dd3c061 100644 --- a/shell/ferese-shell/src/config.rs +++ b/shell/ferese-shell/src/config.rs @@ -7,6 +7,7 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20]; #[derive(Clone, Debug, Default)] pub(crate) struct ShellConfig { + pub(crate) notifications: ferese_core::notifications::NotificationConfig, pub(crate) desktop_widgets: ferese_core::desktop::DesktopWidgets, pub(crate) animations: crate::motion::Settings, pub(crate) font_family: Option, @@ -126,6 +127,8 @@ pub(crate) enum WallpaperMode { #[derive(Debug, Default, Deserialize)] struct FereseConfig { + #[serde(default)] + notifications: ferese_core::notifications::NotificationConfig, #[serde(default)] desktop_widgets: ferese_core::desktop::DesktopWidgets, #[serde(default)] @@ -318,6 +321,10 @@ pub(crate) fn load() -> ShellConfig { pub(crate) fn parse_source(source: &str) -> Result { match ferese_config::from_str::(source) { Ok(config) => { + config + .notifications + .validate() + .map_err(ferese_config::Error::from)?; config .desktop_widgets .validate() @@ -327,6 +334,7 @@ pub(crate) fn parse_source(source: &str) -> Result, + notifications: notifications::Center, + notification_surface: Option, status_service: status::Service, status: status::Snapshot, status_error: Option, @@ -260,6 +264,14 @@ enum Message { ActivateWorkspace(u64), ToggleOverview, StatusUpdated(status::Update), + NotificationEvent(notifications::Event), + NotificationTick, + DismissNotification(u32), + RemoveNotification(u32), + InvokeNotification(u32, String), + HoverNotification(u32, bool), + ToggleNotificationHistory, + ClearNotifications, AnimateMenu, OpenMenu(status_ui::Menu, cosmic::iced::Rectangle), OpenMenuOn(window::Id, status_ui::Menu, cosmic::iced::Rectangle), @@ -293,6 +305,8 @@ impl cosmic::Application for FereseShell { let app = Self { core, bar_surface_id, + notifications: notifications::Center::new(config.notifications.clone()), + notification_surface: None, status_service: status::Service::start(config.status.settings_command.clone()), status: status::Snapshot::default(), status_error: None, @@ -332,6 +346,21 @@ impl cosmic::Application for FereseShell { fn subscription(&self) -> Subscription { Subscription::batch([ + self.notifications + .subscription() + .map(Message::NotificationEvent), + if self.notifications.has_toasts() { + cosmic::iced::time::every(Duration::from_millis( + if self.notifications.animating() { + 16 + } else { + 250 + }, + )) + .map(|_| Message::NotificationTick) + } else { + Subscription::none() + }, event::listen_with(|event, _status, id| match &event { Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(..))) => { EFFECT_FRAME_PENDING @@ -498,18 +527,74 @@ impl cosmic::Application for FereseShell { self.status = update.snapshot; self.status_error = update.error; } - Task::none() + self.notifications.tick(); + if self.notifications.ready { + self.status.notifications = Some(status::Notifications { + count: self.notifications.unread(), + dnd: self.notifications.dnd, + }); + } + self.sync_notification_surface() + } + Message::NotificationEvent(event) => { + self.notifications.handle_event(event); + if self.notifications.ready { + self.status.notifications = Some(status::Notifications { + count: self.notifications.unread(), + dnd: self.notifications.dnd, + }); + } + self.sync_notification_surface() + } + Message::NotificationTick => { + self.notifications.tick(); + self.sync_notification_surface() + } + Message::DismissNotification(id) => { + self.notifications.close(id, 2); + self.sync_notification_surface() + } + Message::RemoveNotification(id) => { + self.notifications.dismiss(id); + self.sync_notification_surface() + } + Message::InvokeNotification(id, action) => { + self.notifications.invoke(id, action); + self.sync_notification_surface() + } + Message::HoverNotification(id, hovered) => { + self.notifications.hover(id, hovered); + self.sync_notification_surface() + } + Message::ToggleNotificationHistory => { + self.notifications.toggle_history(); + self.sync_notification_surface() + } + Message::ClearNotifications => { + self.notifications.clear(); + self.sync_notification_surface() } Message::AnimateMenu => self.animate_menu(), Message::OpenMenu(kind, anchor) => self.open_menu(kind, anchor), Message::OpenMenuOn(id, kind, anchor) => { + if kind == status_ui::Menu::Notifications && self.notifications.ready { + self.notifications.toggle_history(); + return Task::batch([self.destroy_menu(), self.sync_notification_surface()]); + } + let hide_history = if self.notifications.history_open { + self.notifications.history_open = false; + self.sync_notification_surface() + } else { + Task::none() + }; if self.bar_surface_id != id { let destroy = self.destroy_menu(); self.bar_surface_id = id; let open = self.open_menu(kind, anchor); - Task::batch([destroy, open]) + Task::batch([hide_history, destroy, open]) } else { - self.open_menu(kind, anchor) + let open = self.open_menu(kind, anchor); + Task::batch([hide_history, open]) } } Message::ConfirmPower(action) => { @@ -525,6 +610,26 @@ impl cosmic::Application for FereseShell { Task::none() } Message::Control(action) => { + if self.notifications.ready { + match action { + status::Action::Dnd(value) => { + self.notifications.dnd = value; + self.status.notifications = Some(status::Notifications { + count: self.notifications.unread(), + dnd: value, + }); + return Task::none(); + } + status::Action::Notifications => { + self.notifications.toggle_history(); + return Task::batch([ + self.close_menu(), + self.sync_notification_surface(), + ]); + } + _ => {} + } + } self.status_error = self.status_service.send(action.clone()).err(); if self.status_error.is_none() { self.optimistic_status(&action); @@ -675,6 +780,7 @@ impl FereseShell { || old.bar_margin_top != theme.bar_margin_top || old.bar_margin_horizontal != theme.bar_margin_horizontal || old.bar_window_gap != theme.bar_window_gap; + self.notifications.configure(config.notifications.clone()); self.config = config; self.desktop_clock = self .config @@ -1623,6 +1729,10 @@ impl FereseShell { if self.note_editor.is_some() { return self.finish_note_edit(); } + if self.notifications.history_open { + self.notifications.history_open = false; + return self.sync_notification_surface(); + } } match event { Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output( diff --git a/shell/ferese-shell/src/motion.rs b/shell/ferese-shell/src/motion.rs index 48641db..0f75734 100644 --- a/shell/ferese-shell/src/motion.rs +++ b/shell/ferese-shell/src/motion.rs @@ -49,6 +49,14 @@ pub(crate) fn configure(settings: Settings) { .unwrap() = settings; } +pub(crate) fn notification_duration() -> std::time::Duration { + SETTINGS + .get_or_init(|| std::sync::RwLock::new(Settings::default())) + .read() + .unwrap() + .duration(180.0) +} + pub(crate) struct PopupMotion { start: f32, target: f32, diff --git a/shell/ferese-shell/src/notification_ui.rs b/shell/ferese-shell/src/notification_ui.rs new file mode 100644 index 0000000..3ff427e --- /dev/null +++ b/shell/ferese-shell/src/notification_ui.rs @@ -0,0 +1,549 @@ +use super::*; +use cosmic::widget::{column, scrollable}; + +const POPUP_WIDTH: u32 = 368; + +fn popup_height(notice: ¬ifications::Notice, hovered: bool, count: usize) -> u32 { + let body = if notice.body.is_empty() && count == 1 { + 0 + } else { + 22 + }; + let actions = + if hovered && notice.live && notice.actions.iter().any(|(key, _)| key != "default") { + 39 + } else { + 0 + }; + 76 + body + actions + if count > 1 { 4 } else { 0 } +} + +fn blend(base: Color, foreground: Color, amount: f32) -> Color { + Color { + r: base.r + (foreground.r - base.r) * amount, + g: base.g + (foreground.g - base.g) * amount, + b: base.b + (foreground.b - base.b) * amount, + a: base.a, + } +} + +fn contrast_text(background: Color, opacity: f32) -> Color { + let level = background.r * 0.2126 + background.g * 0.7152 + background.b * 0.0722; + Color { + a: opacity, + ..if level > 0.6 { + Color::BLACK + } else { + Color::WHITE + } + } +} + +fn age_label(age: Duration) -> String { + match age.as_secs() { + 0..60 => "now".into(), + 60..3600 => format!("{}m ago", age.as_secs() / 60), + 3600..86400 => format!("{}h ago", age.as_secs() / 3600), + seconds => format!("{}d ago", seconds / 86400), + } +} + +fn card_button<'a>( + content: impl Into>>, + message: Message, + foreground: Color, + hover: Color, + round: bool, +) -> Element<'a, cosmic::Action> { + let style = move |active: bool| button::Style { + text_color: Some(foreground), + icon_color: Some(foreground), + background: (active || round).then_some(Background::Color(hover)), + border_radius: (if round { 9.0 } else { 0.0 }).into(), + ..Default::default() + }; + button::custom(content) + .width(if round { Length::Shrink } else { Length::Fill }) + .padding(0) + .on_press(cosmic::Action::App(message)) + .class(theme::Button::Custom { + active: Box::new(move |_, _| style(false)), + hovered: Box::new(move |_, _| style(true)), + pressed: Box::new(move |_, _| style(true)), + disabled: Box::new(move |_| style(false)), + }) + .into() +} + +pub(super) struct NotificationSurface { + pub id: window::Id, + output: wl_output::WlOutput, + height: u32, +} + +impl FereseShell { + pub(super) fn sync_notification_surface(&mut self) -> Task { + if self.notifications.ready { + self.status.notifications = Some(status::Notifications { + count: self.notifications.unread(), + dnd: self.notifications.dnd, + }); + } + let count = self.notifications.popup_groups().len(); + let wanted = self.notifications.ready && (self.notifications.history_open || count > 0); + let selected = self + .outputs + .iter() + .find(|entry| { + self.snapshot.outputs.iter().any(|output| { + output.focused && Some(output.name.as_str()) == entry.name.as_deref() + }) + }) + .or_else(|| self.outputs.first()) + .map(|entry| entry.output.clone()); + let mut tasks = Vec::new(); + if self + .notification_surface + .as_ref() + .is_some_and(|surface| !wanted || selected.as_ref() != Some(&surface.output)) + { + let surface = self.notification_surface.take().unwrap(); + tasks.push(destroy_layer_surface(surface.id)); + } + if !wanted { + return Task::batch(tasks); + } + let Some(output) = selected else { + return Task::batch(tasks); + }; + let desired_height = if self.notifications.history_open { + 520 + } else { + self.notifications + .popup_groups() + .iter() + .map(|(notice, _, count)| { + popup_height( + notice, + self.notifications.hovered == Some(notice.id), + *count, + ) + if *count > 1 { 12 } else { 0 } + }) + .sum::() + + count.saturating_sub(1) as u32 * 8 + + 8 + }; + let output_height = self + .outputs + .iter() + .find(|entry| entry.output == output) + .and_then(|entry| entry.size) + .map_or(1080, |size| size.1); + let top = self.config.theme.bar_height + self.config.theme.bar_margin_top as f32 + 12.0; + let height = desired_height.min((output_height as f32 - top - 12.0).max(100.0) as u32); + if let Some(surface) = &mut self.notification_surface { + if surface.height != height { + surface.height = height; + tasks.push(set_size(surface.id, Some(POPUP_WIDTH), Some(height))); + } + } else { + let id = window::Id::unique(); + self.notification_surface = Some(NotificationSurface { + id, + output: output.clone(), + height, + }); + let top = self.config.theme.bar_height + self.config.theme.bar_margin_top as f32 + 12.0; + let action = cosmic::surface::action::app_layer_shell::( + |_| Default::default(), + move |_| SctkLayerSurfaceSettings { + id, + layer: Layer::Overlay, + keyboard_interactivity: KeyboardInteractivity::None, + anchor: Anchor::TOP | Anchor::RIGHT, + output: IcedOutput::Output(output.clone()), + margin: IcedMargin { + top: top as i32, + right: 12, + ..Default::default() + }, + exclusive_zone: -1, + size: Some((Some(POPUP_WIDTH), Some(height))), + size_limits: Limits::NONE, + namespace: "ferese-shell-notifications".into(), + ..Default::default() + }, + Some(Box::new(Self::view_notifications)), + ); + tasks.push(cosmic::task::message(cosmic::Action::Surface(action))); + } + Task::batch(tasks) + } + + fn notification_card( + &self, + notice: ¬ifications::Notice, + opacity: f32, + history: bool, + count: usize, + ) -> Element<'_, cosmic::Action> { + let palette = self.config.theme; + let tint = move |rgba| { + let mut value = color(rgba); + value.a *= opacity; + value + }; + let foreground = tint(palette.text_primary); + let muted = tint(palette.text_muted); + let accent = tint(palette.accent); + let base = tint(palette.surface_base); + let divider = tint(palette.border); + let well = blend(base, foreground, 0.07); + let id = notice.id; + let hovered = self.notifications.hovered == Some(id); + let mut bold = *SHELL_FONT.get().unwrap().read().unwrap(); + bold.weight = cosmic::iced::font::Weight::Bold; + let app_icon = if notice.app.to_lowercase().starts_with("ferese") || notice.icon.is_empty() + { + accented_icon( + include_bytes!("../assets/icons/ferese.svg"), + 18, + accent, + accent, + ) + } else if notice.icon.starts_with('/') { + icon::icon(icon::from_path(notice.icon.clone().into())).size(18) + } else { + icon::from_name(notice.icon.as_str()).size(18).icon() + } + .opacity(opacity); + let icon_well = + container(app_icon) + .center_x(30) + .center_y(30) + .class(theme::Container::custom(move |_| container::Style { + background: Some(Background::Color(well)), + border: Border { + radius: 8.0.into(), + ..Default::default() + }, + ..Default::default() + })); + let icon: Element<'_, cosmic::Action> = if count > 1 { + let badge = container( + text(count.to_string()) + .size(10) + .font(bold) + .class(theme::Text::Color(contrast_text(accent, opacity))), + ) + .center_x(16) + .center_y(16) + .class(theme::Container::custom(move |_| container::Style { + background: Some(Background::Color(accent)), + border: Border { + radius: 8.0.into(), + ..Default::default() + }, + ..Default::default() + })); + cosmic::iced::widget::stack([ + container(icon_well) + .padding(cosmic::iced::Padding { + top: 4.0, + ..Default::default() + }) + .into(), + container(badge).align_right(34).into(), + ]) + .into() + } else { + icon_well.into() + }; + let app = if notice.app.is_empty() { + "NOTIFICATION".to_owned() + } else { + notice.app.to_uppercase() + }; + let app = if count > 1 { + format!("{app} · {count} NOTIFICATIONS") + } else { + app + }; + let header = row([]) + .spacing(10) + .align_y(alignment::Vertical::Center) + .push(icon) + .push( + container( + text(app) + .size(11) + .font(bold) + .class(theme::Text::Color(muted)), + ) + .width(Length::Fill) + .height(16) + .clip(true), + ) + .push( + text(age_label(notice.received_at.elapsed())) + .size(11) + .class(theme::Text::Color(muted)), + ); + let mut content = column([]).spacing(4).push(header).push( + container( + text(notice.title.clone()) + .size(14) + .font(bold) + .class(theme::Text::Color(foreground)), + ) + .height(18) + .clip(true), + ); + let body = if count > 1 { + if notice.body.is_empty() { + format!("{} more", count - 1) + } else { + format!("{} · {} more", notice.body, count - 1) + } + } else { + notice.body.clone() + }; + if !body.is_empty() { + content = content.push( + container(text(body).size(13).class(theme::Text::Color(muted))) + .height(if history { + Length::Shrink + } else { + Length::Fixed(18.0) + }) + .clip(true), + ); + } + let mut face = column([]).push(container(content).padding([12, 14]).width(Length::Fill)); + if hovered && notice.live { + let available: Vec<_> = notice + .actions + .iter() + .filter(|(key, _)| key != "default") + .take(2) + .collect(); + if !available.is_empty() { + let mut actions = row([]); + for (index, (key, label)) in available.into_iter().enumerate() { + if index > 0 { + actions = actions.push( + container(cosmic::iced::widget::Space::new().width(1).height(38)) + .class(theme::Container::custom(move |_| container::Style { + background: Some(Background::Color(divider)), + ..Default::default() + })), + ); + } + actions = actions.push(card_button( + container(text(label.clone()).font(bold).size(13)) + .center_x(Length::Fill) + .center_y(38), + Message::InvokeNotification(id, key.clone()), + accent, + well, + false, + )); + } + face = face + .push( + container( + cosmic::iced::widget::Space::new() + .width(Length::Fill) + .height(1), + ) + .class(theme::Container::custom(move |_| { + container::Style { + background: Some(Background::Color(divider)), + ..Default::default() + } + })), + ) + .push(actions); + } + } + let card = container(face) + .width(Length::Fill) + .clip(true) + .class(theme::Container::custom(move |_| container::Style { + background: Some(Background::Color(base)), + border: Border { + radius: palette.material_radius.into(), + ..Default::default() + }, + ..Default::default() + })); + let card: Element<'_, cosmic::Action> = if hovered { + let close = card_button( + container(text("×").size(14)).center_x(18).center_y(18), + if history { + Message::RemoveNotification(id) + } else { + Message::DismissNotification(id) + }, + muted, + well, + true, + ); + cosmic::iced::widget::stack([ + card.into(), + container(close) + .align_right(Length::Fill) + .padding([5, 6]) + .into(), + ]) + .into() + } else { + card.into() + }; + let card: Element<'_, cosmic::Action> = if count > 1 { + let height = popup_height(notice, hovered, count) as f32; + let back = move |inset: f32, offset: f32| { + container( + container( + cosmic::iced::widget::Space::new() + .width(Length::Fill) + .height(height), + ) + .class(theme::Container::custom(move |_| { + container::Style { + background: Some(Background::Color(blend(base, foreground, 0.025))), + border: Border { + radius: palette.material_radius.into(), + ..Default::default() + }, + ..Default::default() + } + })), + ) + .padding(cosmic::iced::Padding { + top: offset, + left: inset, + right: inset, + bottom: 0.0, + }) + }; + cosmic::iced::widget::stack([back(12.0, 12.0).into(), back(6.0, 6.0).into(), card]) + .into() + } else { + card + }; + let mut area = cosmic::widget::mouse_area(card) + .on_enter(cosmic::Action::App(Message::HoverNotification(id, true))) + .on_exit(cosmic::Action::App(Message::HoverNotification(id, false))); + if count > 1 { + area = area.on_press(cosmic::Action::App(Message::ToggleNotificationHistory)); + } else if notice.live && notice.actions.iter().any(|(key, _)| key == "default") { + area = area.on_press(cosmic::Action::App(Message::InvokeNotification( + id, + "default".into(), + ))); + } + area.into() + } + + fn view_notifications(&self) -> Element<'_, cosmic::Action> { + if self.notifications.history_open { + let palette = self.config.theme; + let primary = color(palette.text_primary); + let muted = color(palette.text_muted); + let accent = color(palette.accent); + let base = color(palette.surface_base); + let header = row([]) + .spacing(8) + .align_y(alignment::Vertical::Center) + .push( + text("Notifications") + .size(16) + .class(theme::Text::Color(primary)) + .width(Length::Fill), + ) + .push(motion::button( + button::custom(text("Clear all").size(11)) + .on_press(cosmic::Action::App(Message::ClearNotifications)) + .padding([4, 6]), + muted, + false, + 1.0, + )) + .push(card_button( + container(text("×").size(14)).center_x(18).center_y(18), + Message::ToggleNotificationHistory, + muted, + blend(base, primary, 0.07), + true, + )); + let dnd = row([]) + .align_y(alignment::Vertical::Center) + .push( + text("Do Not Disturb") + .size(12) + .class(theme::Text::Color(muted)) + .width(Length::Fill), + ) + .push(motion::button( + button::custom( + text(if self.notifications.dnd { "On" } else { "Off" }).size(12), + ) + .on_press(cosmic::Action::App(Message::Control(status::Action::Dnd( + !self.notifications.dnd, + )))) + .padding([4, 8]), + if self.notifications.dnd { + accent + } else { + muted + }, + self.notifications.dnd, + 1.0, + )); + let controls = container(column([]).spacing(8).push(header).push(dnd)) + .padding(14) + .width(Length::Fill) + .class(theme::Container::custom(move |_| container::Style { + background: Some(Background::Color(base)), + border: Border { + radius: palette.material_radius.into(), + ..Default::default() + }, + ..Default::default() + })); + let mut entries = column([]).spacing(8); + if self.notifications.entries.is_empty() { + entries = entries.push( + container( + text("You're all caught up") + .size(14) + .class(theme::Text::Color(muted)), + ) + .padding(32) + .center_x(Length::Fill), + ); + } + for notice in self.notifications.entries.iter().rev() { + entries = entries.push(self.notification_card(notice, 1.0, true, 1)); + } + let content = column([]) + .spacing(8) + .push(controls) + .push(scrollable(entries).height(Length::Fill)); + container(content) + .padding(4) + .height(Length::Fill) + .width(Length::Fill) + .into() + } else { + let mut cards = column([]).spacing(8); + for (notice, opacity, count) in self.notifications.popup_groups() { + cards = cards.push(self.notification_card(notice, opacity, false, count)); + } + container(scrollable(cards).height(Length::Fill)) + .padding(4) + .into() + } + } +} diff --git a/shell/ferese-shell/src/notifications.rs b/shell/ferese-shell/src/notifications.rs new file mode 100644 index 0000000..2c4852a --- /dev/null +++ b/shell/ferese-shell/src/notifications.rs @@ -0,0 +1,851 @@ +use std::{ + collections::{HashMap, HashSet, VecDeque}, + sync::{ + Arc, Mutex, + mpsc::{self, SyncSender}, + }, + time::{Duration, Instant}, +}; + +const PATH: &str = "/org/freedesktop/Notifications"; +const INTERFACE: &str = "org.freedesktop.Notifications"; +const HISTORY_LIMIT: usize = 100; + +#[derive(Clone, Debug)] +pub struct Notice { + pub id: u32, + revision: u64, + pub app: String, + pub icon: String, + pub title: String, + pub body: String, + pub actions: Vec<(String, String)>, + pub timeout: Option, + pub critical: bool, + pub transient: bool, + pub resident: bool, + pub live: bool, + pub unread: bool, + pub received_at: Instant, +} + +#[derive(Clone, Debug)] +pub enum Event { + Ready, + Failed(String), + Notice(Notice), + Close(u32, u64), +} + +enum Command { + Close(u32, u64, u32), + Action(u32, u64, String), +} + +#[derive(Default)] +struct Registry { + next: u32, + live: HashMap, + revision: u64, +} + +impl Registry { + fn close(&mut self, id: u32, revision: u64, reason: u32) -> bool { + match self.live.get(&id) { + Some(current) if *current == revision => { + self.live.remove(&id); + true + } + None if reason == 3 => true, + _ => false, + } + } +} + +struct Server { + events: tokio::sync::mpsc::Sender, + registry: Arc>, + timeout: Arc, +} + +fn limited(value: &str, max: usize) -> String { + value.chars().take(max).collect() +} + +#[zbus::interface(name = "org.freedesktop.Notifications")] +impl Server { + fn get_capabilities(&self) -> Vec<&str> { + vec!["actions", "body", "icon-static"] + } + + fn get_server_information(&self) -> (&str, &str, &str, &str) { + ("Ferese", "Ferese", env!("CARGO_PKG_VERSION"), "1.2") + } + + #[allow(clippy::too_many_arguments)] + fn notify( + &self, + app_name: &str, + replaces_id: u32, + app_icon: &str, + summary: &str, + body: &str, + actions: Vec, + hints: HashMap, + expire_timeout: i32, + ) -> zbus::fdo::Result { + let mut registry = self.registry.lock().unwrap(); + let replacing = registry.live.contains_key(&replaces_id); + if !replacing && registry.live.len() >= HISTORY_LIMIT { + return Err(zbus::fdo::Error::LimitsExceeded( + "Too many active notifications".into(), + )); + } + let id = if replacing { + replaces_id + } else { + loop { + registry.next = registry.next.wrapping_add(1).max(1); + if !registry.live.contains_key(®istry.next) { + break registry.next; + } + } + }; + let critical = hints.get("urgency").and_then(|v| u8::try_from(v).ok()) == Some(2); + let timeout = if expire_timeout == 0 || (expire_timeout < 0 && critical) { + None + } else { + Some(Duration::from_millis(if expire_timeout < 0 { + u64::from(self.timeout.load(std::sync::atomic::Ordering::Relaxed)) + } else { + expire_timeout as u64 + })) + }; + let notice = Notice { + id, + revision: registry.revision.wrapping_add(1), + app: limited(app_name, 80), + icon: limited(app_icon, 512), + title: limited(summary, 160), + body: limited(body, 500), + actions: actions + .chunks_exact(2) + .take(8) + .map(|pair| (limited(&pair[0], 128), limited(&pair[1], 40))) + .collect(), + timeout, + critical, + transient: hints + .get("transient") + .and_then(|v| bool::try_from(v).ok()) + .unwrap_or(false), + resident: hints + .get("resident") + .and_then(|v| bool::try_from(v).ok()) + .unwrap_or(false), + live: true, + unread: true, + received_at: Instant::now(), + }; + let revision = notice.revision; + self.events + .try_send(Event::Notice(notice)) + .map_err(|_| zbus::fdo::Error::LimitsExceeded("Notification queue is full".into()))?; + registry.revision = revision; + registry.live.insert(id, revision); + Ok(id) + } + + fn close_notification(&self, id: u32) -> zbus::fdo::Result<()> { + let mut registry = self.registry.lock().unwrap(); + let revision = *registry + .live + .get(&id) + .ok_or_else(|| zbus::fdo::Error::InvalidArgs("Unknown notification ID".into()))?; + self.events + .try_send(Event::Close(id, revision)) + .map_err(|_| zbus::fdo::Error::LimitsExceeded("Notification queue is full".into()))?; + registry.live.remove(&id); + Ok(()) + } +} + +#[derive(Clone)] +struct Events(Arc>>); + +impl std::hash::Hash for Events { + fn hash(&self, state: &mut H) { + std::ptr::hash(Arc::as_ptr(&self.0), state); + } +} + +impl Events { + fn stream(&self) -> impl cosmic::iced::futures::Stream + use<> { + cosmic::iced::futures::stream::unfold(self.0.clone(), |receiver| async move { + let event = receiver.lock().await.recv().await?; + Some((event, receiver)) + }) + } +} + +pub struct Service { + timeout: Arc, + events: Events, + commands: SyncSender, +} + +impl Service { + fn start(config: &ferese_core::notifications::NotificationConfig) -> Self { + Self::connect(config, None) + } + + fn connect( + config: &ferese_core::notifications::NotificationConfig, + address: Option, + ) -> Self { + let timeout = Arc::new(std::sync::atomic::AtomicU32::new(config.timeout_ms)); + let worker_timeout = timeout.clone(); + let (sender, events) = tokio::sync::mpsc::channel(256); + let (commands, receiver) = mpsc::sync_channel(256); + std::thread::spawn(move || { + let registry = Arc::new(Mutex::new(Registry::default())); + let server = Server { + events: sender.clone(), + registry: registry.clone(), + timeout: worker_timeout, + }; + let connection = match address.as_deref() { + Some(address) => zbus::blocking::connection::Builder::address(address), + None => zbus::blocking::connection::Builder::session(), + } + .and_then(|builder| builder.serve_at(PATH, server)) + .and_then(|builder| builder.name(INTERFACE)) + .and_then(|builder| builder.build()); + let connection = match connection { + Ok(connection) => connection, + Err(error) => { + let _ = sender.try_send(Event::Failed(error.to_string())); + return; + } + }; + let _ = sender.try_send(Event::Ready); + while let Ok(command) = receiver.recv() { + let result = match command { + Command::Close(id, revision, reason) => { + if !registry.lock().unwrap().close(id, revision, reason) { + continue; + } + connection.emit_signal( + None::<&str>, + PATH, + INTERFACE, + "NotificationClosed", + &(id, reason), + ) + } + Command::Action(id, revision, action) => { + if registry.lock().unwrap().live.get(&id) != Some(&revision) { + continue; + } + connection.emit_signal( + None::<&str>, + PATH, + INTERFACE, + "ActionInvoked", + &(id, action), + ) + } + }; + if let Err(error) = result { + eprintln!("ferese-shell: notification signal failed: {error}"); + } + } + }); + Self { + timeout, + events: Events(Arc::new(tokio::sync::Mutex::new(events))), + commands, + } + } +} + +struct Toast { + id: u32, + born: Instant, + closing: Option, + remaining: Option, + hovered: bool, +} + +pub struct Center { + config: ferese_core::notifications::NotificationConfig, + service: Service, + pub ready: bool, + pub dnd: bool, + pub history_open: bool, + pub hovered: Option, + pub entries: VecDeque, + toasts: VecDeque, + last_tick: Instant, +} + +impl Center { + pub fn new(config: ferese_core::notifications::NotificationConfig) -> Self { + Self { + service: Service::start(&config), + ready: false, + dnd: config.do_not_disturb, + config, + history_open: false, + hovered: None, + entries: VecDeque::new(), + toasts: VecDeque::new(), + last_tick: Instant::now(), + } + } + + pub fn configure(&mut self, config: ferese_core::notifications::NotificationConfig) { + if self.config.do_not_disturb != config.do_not_disturb { + self.dnd = config.do_not_disturb; + } + self.service + .timeout + .store(config.timeout_ms, std::sync::atomic::Ordering::Relaxed); + self.config = config; + if !self.config.show_popups { + let ids: Vec<_> = self.toasts.iter().map(|toast| toast.id).collect(); + for id in ids { + self.close(id, 1); + } + } + } + + pub fn unread(&self) -> u32 { + self.entries.iter().filter(|notice| notice.unread).count() as u32 + } + + pub fn visible(&self) -> impl Iterator { + let duration = super::motion::notification_duration(); + self.toasts.iter().rev().filter_map(move |toast| { + let notice = self.entries.iter().find(|notice| notice.id == toast.id)?; + let p = if duration.is_zero() { + if toast.closing.is_some() { 0.0 } else { 1.0 } + } else { + toast.closing.map_or_else( + || (toast.born.elapsed().as_secs_f32() / duration.as_secs_f32()).min(1.0), + |closing| { + (1.0 - closing.elapsed().as_secs_f32() / duration.as_secs_f32()).max(0.0) + }, + ) + }; + Some((notice, p * p * (3.0 - 2.0 * p))) + }) + } + + pub fn animating(&self) -> bool { + self.toasts.iter().any(|toast| { + toast.closing.is_some() || toast.born.elapsed() < super::motion::notification_duration() + }) + } + + pub fn popup_groups(&self) -> Vec<(&Notice, f32, usize)> { + let mut groups: Vec<(&Notice, f32, usize)> = Vec::new(); + for (notice, opacity) in self.visible() { + if let Some(group) = groups + .iter_mut() + .find(|(head, _, _)| head.app == notice.app) + { + group.2 += 1; + } else { + groups.push((notice, opacity, 1)); + } + } + groups + } + + pub fn hover(&mut self, id: u32, hovered: bool) { + if hovered { + self.hovered = Some(id); + } else if self.hovered == Some(id) { + self.hovered = None; + } + // A grouped card represents every toast from its application, so keep + // the whole stack alive while the user reads it or selects an action. + let app = self + .entries + .iter() + .find(|notice| notice.id == id) + .map(|notice| notice.app.clone()); + for toast in &mut self.toasts { + if self + .entries + .iter() + .any(|notice| notice.id == toast.id && Some(¬ice.app) == app.as_ref()) + { + toast.hovered = hovered; + } + } + } + + fn command(&self, command: Command) { + if let Err(error) = self.service.commands.try_send(command) { + eprintln!("ferese-shell: notification command failed: {error}"); + } + } + + pub fn close(&mut self, id: u32, reason: u32) { + if let Some(notice) = self.entries.iter_mut().find(|notice| notice.id == id) { + let was_live = notice.live; + let revision = notice.revision; + notice.live = false; + if was_live || reason == 3 { + self.command(Command::Close(id, revision, reason)); + } + } + if let Some(toast) = self.toasts.iter_mut().find(|toast| toast.id == id) { + toast.closing.get_or_insert_with(Instant::now); + } + } + + pub fn dismiss(&mut self, id: u32) { + self.close(id, 2); + self.toasts.retain(|toast| toast.id != id); + self.entries.retain(|notice| notice.id != id); + } + + pub fn invoke(&mut self, id: u32, action: String) { + if let Some(notice) = self + .entries + .iter() + .find(|notice| notice.id == id && notice.live) + && notice.actions.iter().any(|(key, _)| key == &action) + { + let resident = notice.resident; + let revision = notice.revision; + self.command(Command::Action(id, revision, action)); + if !resident { + self.close(id, 2); + } + } + } + + pub fn toggle_history(&mut self) { + self.history_open = !self.history_open; + if self.history_open { + for notice in &mut self.entries { + notice.unread = false; + } + } + } + + pub fn clear(&mut self) { + let ids: Vec<_> = self.entries.iter().map(|notice| notice.id).collect(); + for id in ids { + self.close(id, 2); + } + self.entries.clear(); + self.toasts.clear(); + } + + fn receive(&mut self, mut notice: Notice) { + let group_hovered = self + .hovered + .and_then(|id| self.entries.iter().find(|entry| entry.id == id)) + .is_some_and(|entry| entry.app == notice.app); + if group_hovered { + self.hovered = Some(notice.id); + } + self.entries.retain(|entry| entry.id != notice.id); + self.toasts.retain(|toast| toast.id != notice.id); + if self.entries.len() == HISTORY_LIMIT { + if let Some(oldest) = self.entries.front() { + self.close(oldest.id, 1); + } + if let Some(oldest) = self.entries.pop_front() { + self.toasts.retain(|toast| toast.id != oldest.id); + } + } + notice.unread = !self.history_open; + let show = self.config.show_popups && (!self.dnd || notice.critical); + if show { + self.toasts.push_back(Toast { + id: notice.id, + born: Instant::now(), + closing: None, + remaining: notice.timeout, + hovered: group_hovered, + }); + } + let id = notice.id; + self.entries.push_back(notice); + // Bound visible application groups rather than discarding the fourth + // message from one app before its count badge can represent the stack. + while self.popup_groups().len() > 3 { + let Some(oldest) = self + .toasts + .front() + .and_then(|toast| self.entries.iter().find(|notice| notice.id == toast.id)) + else { + break; + }; + let ids: Vec<_> = self + .entries + .iter() + .filter(|notice| notice.app == oldest.app) + .map(|notice| notice.id) + .collect(); + for id in &ids { + self.close(*id, 1); + } + self.toasts.retain(|toast| !ids.contains(&toast.id)); + } + if !show { + self.close(id, 1); + } + } + + pub fn subscription(&self) -> cosmic::iced::Subscription { + cosmic::iced::Subscription::run_with(self.service.events.clone(), Events::stream) + } + + pub fn has_toasts(&self) -> bool { + !self.toasts.is_empty() + } + + pub fn handle_event(&mut self, event: Event) { + self.tick(); + match event { + Event::Ready => self.ready = true, + Event::Failed(error) => { + eprintln!("ferese-shell: native notifications unavailable: {error}") + } + Event::Notice(notice) => self.receive(notice), + Event::Close(id, revision) => { + if self + .entries + .iter() + .any(|notice| notice.id == id && notice.revision == revision) + { + self.close(id, 3); + } else { + self.command(Command::Close(id, revision, 3)); + } + } + } + } + + pub fn tick(&mut self) { + let now = Instant::now(); + let delta = now.saturating_duration_since(self.last_tick); + self.last_tick = now; + let mut expired = Vec::new(); + for toast in &mut self.toasts { + if toast.closing.is_none() + && !toast.hovered + && let Some(remaining) = &mut toast.remaining + { + *remaining = remaining.saturating_sub(delta); + if remaining.is_zero() { + expired.push(toast.id); + } + } + } + for id in expired { + self.close(id, 1); + } + self.toasts.retain(|toast| { + toast.closing.is_none_or(|closing| { + now.duration_since(closing) < super::motion::notification_duration() + }) + }); + let visible: HashSet<_> = self.toasts.iter().map(|toast| toast.id).collect(); + self.entries + .retain(|notice| !notice.transient || notice.live || visible.contains(¬ice.id)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ferese_core::notifications::NotificationConfig; + + fn fixture() -> (Center, std::sync::mpsc::Receiver) { + let (_, events) = tokio::sync::mpsc::channel(256); + let (commands, receiver) = mpsc::sync_channel(256); + let center = Center { + config: NotificationConfig::default(), + service: Service { + timeout: Arc::new(std::sync::atomic::AtomicU32::new(6000)), + events: Events(Arc::new(tokio::sync::Mutex::new(events))), + commands, + }, + ready: true, + dnd: false, + history_open: false, + hovered: None, + entries: VecDeque::new(), + toasts: VecDeque::new(), + last_tick: Instant::now(), + }; + (center, receiver) + } + + fn notice(id: u32) -> Notice { + Notice { + id, + revision: 1, + app: "Test".into(), + icon: String::new(), + title: "Title".into(), + body: "Body".into(), + actions: vec![("open".into(), "Open".into())], + timeout: Some(Duration::from_secs(6)), + critical: false, + transient: false, + resident: false, + live: true, + unread: true, + received_at: Instant::now(), + } + } + + #[test] + fn groups_are_newest_first_and_hover_keeps_the_whole_stack_alive() { + let (mut center, _) = fixture(); + for id in 1..=4 { + center.receive(notice(id)); + } + let groups = center.popup_groups(); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].0.id, 4); + assert_eq!(groups[0].2, 4); + center.hover(4, true); + center.receive(notice(5)); + assert_eq!(center.hovered, Some(5)); + center.last_tick = Instant::now() - Duration::from_secs(10); + center.tick(); + assert!(center.entries.iter().all(|notice| notice.live)); + center.hover(5, false); + center.last_tick = Instant::now() - Duration::from_secs(10); + center.tick(); + assert!(center.entries.iter().all(|notice| !notice.live)); + } + + #[test] + fn popup_limit_counts_apps_and_evicts_the_oldest_group() { + let (mut center, _) = fixture(); + for id in 1..=4 { + let mut n = notice(id); + n.app = format!("App {id}"); + center.receive(n); + } + let groups = center.popup_groups(); + assert_eq!(groups.iter().map(|g| g.0.id).collect::>(), [4, 3, 2]); + assert!(!center.entries[0].live); + assert_eq!(center.entries.len(), 4); + } + + #[test] + fn hover_pauses_expiry_and_replacements_reset_the_timer() { + let (mut center, commands) = fixture(); + center.receive(notice(1)); + center.hover(1, true); + center.last_tick = Instant::now() - Duration::from_secs(10); + center.tick(); + assert!(center.entries[0].live); + center.hover(1, false); + center.receive(notice(1)); + assert_eq!(center.toasts.len(), 1); + assert_eq!(center.toasts[0].remaining, Some(Duration::from_secs(6))); + center.last_tick = Instant::now() - Duration::from_secs(10); + center.tick(); + assert!(!center.entries[0].live); + assert!(matches!( + commands.try_recv().unwrap(), + Command::Close(1, 1, 1) + )); + } + + #[test] + fn dnd_keeps_history_but_only_critical_notifications_pop_up() { + let (mut center, _) = fixture(); + center.dnd = true; + center.receive(notice(1)); + let mut critical = notice(2); + critical.critical = true; + critical.timeout = None; + center.receive(critical); + assert_eq!(center.entries.len(), 2); + assert_eq!(center.toasts.len(), 1); + assert_eq!(center.toasts[0].id, 2); + center.toggle_history(); + assert_eq!(center.unread(), 0); + center.clear(); + assert!(center.entries.is_empty() && center.toasts.is_empty()); + } + + #[test] + fn history_is_bounded_and_transient_messages_are_not_retained() { + let (mut center, _commands) = fixture(); + for id in 1..=150 { + center.receive(notice(id)); + } + assert_eq!(center.entries.len(), HISTORY_LIMIT); + assert!(center.popup_groups().len() <= 3); + let mut transient = notice(151); + transient.transient = true; + center.receive(transient); + center.close(151, 1); + center.toasts.clear(); + center.tick(); + assert!(!center.entries.iter().any(|notice| notice.id == 151)); + } + + #[test] + fn actions_are_validated_and_resident_messages_remain_live() { + let (mut center, commands) = fixture(); + let mut notification = notice(1); + notification.resident = true; + center.receive(notification); + center.invoke(1, "invalid".into()); + assert!(commands.try_recv().is_err()); + center.invoke(1, "open".into()); + assert!(matches!( + commands.try_recv().unwrap(), + Command::Action(1, 1, _) + )); + assert!(center.entries[0].live); + } + + #[test] + fn an_old_timeout_cannot_close_a_new_replacement() { + let mut registry = Registry::default(); + registry.live.insert(7, 2); + assert!(!registry.close(7, 1, 1)); + assert_eq!(registry.live.get(&7), Some(&2)); + assert!(registry.close(7, 2, 1)); + } + + #[test] + fn config_changes_update_timeout_without_resetting_a_runtime_dnd_toggle() { + let (mut center, _) = fixture(); + center.dnd = true; + center.configure(NotificationConfig { + timeout_ms: 10000, + ..Default::default() + }); + assert!(center.dnd); + assert_eq!( + center + .service + .timeout + .load(std::sync::atomic::Ordering::Relaxed), + 10000 + ); + center.receive(notice(1)); + center.configure(NotificationConfig { + show_popups: false, + ..Default::default() + }); + assert!(center.visible().all(|(notice, _)| !notice.live)); + } + + #[test] + fn real_dbus_delivery_replacement_and_close_signal() { + use std::io::{BufRead, BufReader}; + use std::process::{Command as ProcessCommand, Stdio}; + struct Daemon(std::process::Child); + impl Drop for Daemon { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + let mut daemon = Daemon( + ProcessCommand::new("dbus-daemon") + .args(["--session", "--nofork", "--nopidfile", "--print-address=1"]) + .stdout(Stdio::piped()) + .spawn() + .unwrap(), + ); + let mut address = String::new(); + BufReader::new(daemon.0.stdout.take().unwrap()) + .read_line(&mut address) + .unwrap(); + let config = NotificationConfig { + timeout_ms: 11000, + ..Default::default() + }; + let service = Service::connect(&config, Some(address.trim().to_owned())); + let received = |service: &Service| { + let end = Instant::now() + Duration::from_secs(3); + loop { + if let Ok(event) = service.events.0.blocking_lock().try_recv() { + break event; + } + assert!(Instant::now() < end, "notification event timed out"); + std::thread::sleep(Duration::from_millis(5)); + } + }; + assert!(matches!(received(&service), Event::Ready)); + let connection = zbus::blocking::connection::Builder::address(address.trim()) + .unwrap() + .method_timeout(Duration::from_secs(2)) + .build() + .unwrap(); + let proxy = zbus::blocking::Proxy::new(&connection, INTERFACE, PATH, INTERFACE).unwrap(); + let info: (String, String, String, String) = + proxy.call("GetServerInformation", &()).unwrap(); + assert_eq!(info.0, "Ferese"); + let notify = |replace| -> u32 { + proxy + .call( + "Notify", + &( + "Test", + replace, + "", + "Title", + "Body", + vec!["open", "Open"], + HashMap::::new(), + -1_i32, + ), + ) + .unwrap() + }; + let id = notify(0_u32); + let Event::Notice(first) = received(&service) else { + panic!("Expected notification"); + }; + assert_eq!(first.id, id); + assert_eq!(first.timeout, Some(Duration::from_secs(11))); + assert_eq!(notify(id), id); + let Event::Notice(replacement) = received(&service) else { + panic!("Expected replacement"); + }; + assert_ne!(first.revision, replacement.revision); + let mut signals = proxy.receive_signal("NotificationClosed").unwrap(); + let (sender, receiver) = mpsc::channel(); + let worker = std::thread::spawn(move || { + let signal = signals.next().unwrap(); + sender + .send(signal.body().deserialize::<(u32, u32)>().unwrap()) + .unwrap(); + }); + proxy.call::<_, _, ()>("CloseNotification", &(id,)).unwrap(); + let Event::Close(closed, revision) = received(&service) else { + panic!("Expected close"); + }; + service + .commands + .send(Command::Close(closed, revision, 3)) + .unwrap(); + assert_eq!( + receiver.recv_timeout(Duration::from_secs(3)).unwrap(), + (id, 3) + ); + worker.join().unwrap(); + assert!(proxy.call::<_, _, ()>("CloseNotification", &(id,)).is_err()); + } +}