Add theme-aware notification cards with grouped popup stacks
This commit is contained in:
1 parent
45a9c0384c
commit
a98d7a75c3
7 files changed
+1558
-3
No files matched your search
@@ -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<String>,
|
||||
@@ -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<ShellConfig, ferese_config::Error> {
|
||||
match ferese_config::from_str::<FereseConfig>(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<ShellConfig, ferese_config::E
|
||||
nonnegative_or(config.appearance.corner_radius.unwrap_or(14.0), 14.0);
|
||||
|
||||
Ok(ShellConfig {
|
||||
notifications: config.notifications,
|
||||
desktop_widgets: config.desktop_widgets,
|
||||
animations: config.animations,
|
||||
font_family: config.theme.typography.font_family,
|
||||
|
||||
@@ -2,6 +2,8 @@ mod config;
|
||||
mod control;
|
||||
mod motion;
|
||||
mod note_store;
|
||||
mod notification_ui;
|
||||
mod notifications;
|
||||
mod status;
|
||||
mod status_ui;
|
||||
|
||||
@@ -199,6 +201,8 @@ struct FereseShell {
|
||||
clock: String,
|
||||
desktop_clock: (String, String),
|
||||
outputs: Vec<OutputSurfaces>,
|
||||
notifications: notifications::Center,
|
||||
notification_surface: Option<notification_ui::NotificationSurface>,
|
||||
status_service: status::Service,
|
||||
status: status::Snapshot,
|
||||
status_error: Option<String>,
|
||||
@@ -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<i32>),
|
||||
OpenMenuOn(window::Id, status_ui::Menu, cosmic::iced::Rectangle<i32>),
|
||||
@@ -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<Self::Message> {
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Element<'a, cosmic::Action<Message>>>,
|
||||
message: Message,
|
||||
foreground: Color,
|
||||
hover: Color,
|
||||
round: bool,
|
||||
) -> Element<'a, cosmic::Action<Message>> {
|
||||
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<Message> {
|
||||
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::<u32>()
|
||||
+ 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::<Self>(
|
||||
|_| 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<Message>> {
|
||||
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<Message>> = 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<Message>> = 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<Message>> = 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<Message>> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Duration>,
|
||||
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<u32, u64>,
|
||||
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<Event>,
|
||||
registry: Arc<Mutex<Registry>>,
|
||||
timeout: Arc<std::sync::atomic::AtomicU32>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
hints: HashMap<String, zbus::zvariant::OwnedValue>,
|
||||
expire_timeout: i32,
|
||||
) -> zbus::fdo::Result<u32> {
|
||||
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<tokio::sync::Mutex<tokio::sync::mpsc::Receiver<Event>>>);
|
||||
|
||||
impl std::hash::Hash for Events {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
std::ptr::hash(Arc::as_ptr(&self.0), state);
|
||||
}
|
||||
}
|
||||
|
||||
impl Events {
|
||||
fn stream(&self) -> impl cosmic::iced::futures::Stream<Item = Event> + 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<std::sync::atomic::AtomicU32>,
|
||||
events: Events,
|
||||
commands: SyncSender<Command>,
|
||||
}
|
||||
|
||||
impl Service {
|
||||
fn start(config: &ferese_core::notifications::NotificationConfig) -> Self {
|
||||
Self::connect(config, None)
|
||||
}
|
||||
|
||||
fn connect(
|
||||
config: &ferese_core::notifications::NotificationConfig,
|
||||
address: Option<String>,
|
||||
) -> 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<Instant>,
|
||||
remaining: Option<Duration>,
|
||||
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<u32>,
|
||||
pub entries: VecDeque<Notice>,
|
||||
toasts: VecDeque<Toast>,
|
||||
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<Item = (&Notice, f32)> {
|
||||
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<Event> {
|
||||
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<Command>) {
|
||||
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::<Vec<_>>(), [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::<String, zbus::zvariant::OwnedValue>::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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user