Refine notification center and configure native notifications

Keep notification actions and dismiss controls visible, compact and animate history, and add themed icon controls and grouping.

Include notification settings, session integration and documentation, on-demand workspaces, and performance baseline notes.
This commit is contained in:
Samuel Onoja committed 2026-09-27 08:35:38 +01:00
1 parent a98d7a75c3
commit ab6bf8b961
16 files changed
+921 -123

No files matched your search

+76 -12
View File
@@ -272,6 +272,8 @@ enum Message {
HoverNotification(u32, bool),
ToggleNotificationHistory,
ClearNotifications,
ToggleNotificationGroup(String),
RemoveNotificationGroup(String),
AnimateMenu,
OpenMenu(status_ui::Menu, cosmic::iced::Rectangle<i32>),
OpenMenuOn(window::Id, status_ui::Menu, cosmic::iced::Rectangle<i32>),
@@ -398,6 +400,10 @@ impl cosmic::Application for FereseShell {
.menu
.as_ref()
.is_some_and(|menu| menu.animating() || menu.motion.closing())
|| self
.notification_surface
.as_ref()
.is_some_and(|surface| surface.animating())
{
cosmic::iced::time::every(Duration::from_millis(16)).map(|_| Message::AnimateMenu)
} else {
@@ -499,6 +505,17 @@ impl cosmic::Application for FereseShell {
self.attach_effects(id, &surface)
}
Ok((_connection, surface)) => {
if let Some(entry) = &mut self.notification_surface
&& entry.id == id
&& entry.effects.is_none()
{
match EffectsBinding::attach_role(&surface, None, entry.progress()) {
Ok(binding) => entry.effects = Some(binding),
Err(error) => eprintln!(
"ferese-shell: notification effects unavailable: {error}"
),
}
}
if let Some(menu) = &mut self.menu
&& menu.id == id
&& menu.effects.is_none()
@@ -566,24 +583,29 @@ impl cosmic::Application for FereseShell {
self.notifications.hover(id, hovered);
self.sync_notification_surface()
}
Message::ToggleNotificationHistory => {
self.notifications.toggle_history();
Message::ToggleNotificationHistory => self.toggle_notification_history(),
Message::ToggleNotificationGroup(app) => {
self.notifications.toggle_group(app);
self.sync_notification_surface()
}
Message::RemoveNotificationGroup(app) => {
self.notifications.dismiss_group(&app);
self.sync_notification_surface()
}
Message::ClearNotifications => {
self.notifications.clear();
self.sync_notification_surface()
}
Message::AnimateMenu => self.animate_menu(),
Message::AnimateMenu => {
Task::batch([self.animate_menu(), self.animate_notification_history()])
}
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()]);
return Task::batch([self.destroy_menu(), self.toggle_notification_history()]);
}
let hide_history = if self.notifications.history_open {
self.notifications.history_open = false;
self.sync_notification_surface()
self.close_notification_history()
} else {
Task::none()
};
@@ -621,10 +643,9 @@ impl cosmic::Application for FereseShell {
return Task::none();
}
status::Action::Notifications => {
self.notifications.toggle_history();
return Task::batch([
self.close_menu(),
self.sync_notification_surface(),
self.toggle_notification_history(),
]);
}
_ => {}
@@ -756,6 +777,14 @@ impl FereseShell {
{
menu.motion.update_settings(config.animations);
}
if self.config.animations != config.animations
&& let Some(motion) = self
.notification_surface
.as_mut()
.and_then(|surface| surface.motion.as_mut())
{
motion.update_settings(config.animations);
}
self.status_service
.update_settings(config.status.settings_command.clone());
let old = self.config.theme;
@@ -1730,8 +1759,7 @@ impl FereseShell {
return self.finish_note_edit();
}
if self.notifications.history_open {
self.notifications.history_open = false;
return self.sync_notification_surface();
return self.close_notification_history();
}
}
match event {
@@ -1741,8 +1769,20 @@ impl FereseShell {
))) => self.output_event(event, output),
Event::Window(window::Event::Opened { .. })
if self.outputs.iter().any(|entry| entry.bar == id)
|| self.menu.as_ref().is_some_and(|menu| menu.id == id) =>
|| self.menu.as_ref().is_some_and(|menu| menu.id == id)
|| self
.notification_surface
.as_ref()
.is_some_and(|surface| surface.id == id) =>
{
if let Some(surface) = &mut self.notification_surface
&& surface.id == id
{
surface.ready = true;
if let Some(motion) = &mut surface.motion {
motion.begin(Instant::now());
}
}
// Iced emits the first xdg_popup configure as Window::Opened,
// not Popup::Configured. This is the popup's ready signal.
if let Some(menu) = &mut self.menu
@@ -1851,6 +1891,30 @@ impl FereseShell {
self.attach_effects(frame_id, &surface);
Task::none()
}
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer(
_,
surface,
layer_id,
))) if self
.notification_surface
.as_ref()
.is_some_and(|entry| entry.id == layer_id) =>
{
let entry = self.notification_surface.as_mut().unwrap();
entry.ready = true;
if let Some(motion) = &mut entry.motion {
motion.begin(Instant::now());
}
if entry.effects.is_none() {
match EffectsBinding::attach_role(&surface, None, entry.progress()) {
Ok(binding) => entry.effects = Some(binding),
Err(error) => {
eprintln!("ferese-shell: notification effects unavailable: {error}")
}
}
}
Task::none()
}
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer(
_,
surface,
+439 -105
View File
@@ -1,23 +1,61 @@
use super::*;
use cosmic::iced::widget::scrollable::{Direction, Scrollbar};
use cosmic::widget::{column, scrollable};
const POPUP_WIDTH: u32 = 368;
fn popup_height(notice: &notifications::Notice, hovered: bool, count: usize) -> u32 {
fn popup_height(notice: &notifications::Notice, 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
};
let actions = if notice.live && notice.actions.iter().any(|(key, _)| key != "default") {
39
} else {
0
};
76 + body + actions + if count > 1 { 4 } else { 0 }
}
fn history_height(center: &notifications::Center) -> u32 {
if center.entries.is_empty() {
return 280;
}
let groups = center.history_groups();
let card_height = |notice: &notifications::Notice, count| {
let wrapped_lines = if count == 1 {
notice
.body
.lines()
.map(|line| line.chars().count().div_ceil(40).max(1))
.sum::<usize>()
} else {
1
};
popup_height(notice, count)
+ wrapped_lines.saturating_sub(1) as u32 * 17
+ if count > 1 { 12 } else { 0 }
};
let cards: u32 = groups
.iter()
.map(|group| {
if group.len() > 1 && center.expanded_apps.contains(&group[0].app) {
32 + group
.iter()
.map(|notice| card_height(notice, 1))
.sum::<u32>()
+ group.len().saturating_sub(1) as u32 * 8
} else {
card_height(group[0], group.len())
}
})
.sum();
// Header, DND row and section tools share the same compact rhythm as the
// cards. Long histories scroll; short histories do not reserve blank space.
(170 + cards + groups.len().saturating_sub(1) as u32 * 8).min(620)
}
fn blend(base: Color, foreground: Color, amount: f32) -> Color {
Color {
r: base.r + (foreground.r - base.r) * amount,
@@ -54,6 +92,7 @@ fn card_button<'a>(
foreground: Color,
hover: Color,
round: bool,
accessible_name: String,
) -> Element<'a, cosmic::Action<Message>> {
let style = move |active: bool| button::Style {
text_color: Some(foreground),
@@ -63,6 +102,7 @@ fn card_button<'a>(
..Default::default()
};
button::custom(content)
.name(accessible_name)
.width(if round { Length::Shrink } else { Length::Fill })
.padding(0)
.on_press(cosmic::Action::App(message))
@@ -75,13 +115,117 @@ fn card_button<'a>(
.into()
}
fn icon_control<'a>(
source: &'static [u8],
label: &'static str,
message: Message,
foreground: Color,
background: Color,
extent: u16,
) -> Element<'a, cosmic::Action<Message>> {
let control = card_button(
container(bar_icon(source, 14, foreground))
.center_x(extent)
.center_y(extent),
message,
foreground,
background,
true,
label.into(),
);
cosmic::widget::tooltip(
control,
text(label).size(11).class(theme::Text::Color(foreground)),
cosmic::widget::tooltip::Position::Left,
)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(background)),
border: Border {
radius: 6.0.into(),
..Default::default()
},
text_color: Some(foreground),
..Default::default()
}))
.into()
}
pub(super) struct NotificationSurface {
pub id: window::Id,
output: wl_output::WlOutput,
height: u32,
pub motion: Option<motion::PopupMotion>,
pub effects: Option<EffectsBinding>,
pub ready: bool,
regions: motion::Regions,
}
impl NotificationSurface {
pub fn animating(&self) -> bool {
self.motion
.as_ref()
.is_some_and(|motion| motion.animating() || motion.closing())
}
pub fn progress(&self) -> f32 {
self.motion
.as_ref()
.map_or(1.0, motion::PopupMotion::progress)
}
}
impl FereseShell {
pub(super) fn toggle_notification_history(&mut self) -> Task<Message> {
if self.notifications.history_open {
if let Some(motion) = self
.notification_surface
.as_mut()
.and_then(|surface| surface.motion.as_mut())
{
if motion.closing() {
motion.retarget(1.0, Instant::now());
return self.sync_notification_surface();
}
}
return self.close_notification_history();
}
self.notifications.toggle_history();
self.sync_notification_surface()
}
pub(super) fn close_notification_history(&mut self) -> Task<Message> {
if let Some(motion) = self
.notification_surface
.as_mut()
.and_then(|surface| surface.motion.as_mut())
{
if !motion.closing() {
motion.retarget(0.0, Instant::now());
}
return self.animate_notification_history();
}
self.notifications.history_open = false;
self.sync_notification_surface()
}
pub(super) fn animate_notification_history(&mut self) -> Task<Message> {
if let Some(surface) = &mut self.notification_surface {
if surface
.motion
.as_ref()
.is_some_and(|motion| motion.closing() && !motion.animating())
{
self.notifications.history_open = false;
self.notifications.hovered = None;
surface.motion = None;
}
if let Some(effects) = &surface.effects {
let _ = effects.set_opacity(surface.progress());
}
}
self.sync_notification_surface()
}
pub(super) fn sync_notification_surface(&mut self) -> Task<Message> {
if self.notifications.ready {
self.status.notifications = Some(status::Notifications {
@@ -117,17 +261,13 @@ impl FereseShell {
return Task::batch(tasks);
};
let desired_height = if self.notifications.history_open {
520
history_height(&self.notifications)
} 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 }
popup_height(notice, *count) + if *count > 1 { 12 } else { 0 }
})
.sum::<u32>()
+ count.saturating_sub(1) as u32 * 8
@@ -152,6 +292,10 @@ impl FereseShell {
id,
output: output.clone(),
height,
motion: None,
effects: None,
ready: false,
regions: Default::default(),
});
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>(
@@ -177,6 +321,19 @@ impl FereseShell {
);
tasks.push(cosmic::task::message(cosmic::Action::Surface(action)));
}
if self.notifications.history_open {
let surface = self.notification_surface.as_mut().unwrap();
if surface.motion.is_none() {
let mut motion = motion::PopupMotion::new(self.config.animations);
if surface.ready {
motion.begin(Instant::now());
}
surface.motion = Some(motion);
}
if let Some(effects) = &surface.effects {
let _ = effects.set_opacity(surface.progress());
}
}
Task::batch(tasks)
}
@@ -200,7 +357,6 @@ impl FereseShell {
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()
@@ -288,7 +444,21 @@ impl FereseShell {
text(age_label(notice.received_at.elapsed()))
.size(11)
.class(theme::Text::Color(muted)),
);
)
.push(icon_control(
include_bytes!("../assets/icons/status/close.svg"),
"Dismiss notification",
if history && count > 1 {
Message::RemoveNotificationGroup(notice.app.clone())
} else if history {
Message::RemoveNotification(id)
} else {
Message::DismissNotification(id)
},
muted,
well,
22,
));
let mut content = column([]).spacing(4).push(header).push(
container(
text(notice.title.clone())
@@ -311,7 +481,7 @@ impl FereseShell {
if !body.is_empty() {
content = content.push(
container(text(body).size(13).class(theme::Text::Color(muted)))
.height(if history {
.height(if history && count == 1 {
Length::Shrink
} else {
Length::Fixed(18.0)
@@ -320,7 +490,7 @@ impl FereseShell {
);
}
let mut face = column([]).push(container(content).padding([12, 14]).width(Length::Fill));
if hovered && notice.live {
if notice.live {
let available: Vec<_> = notice
.actions
.iter()
@@ -340,13 +510,19 @@ impl FereseShell {
);
}
actions = actions.push(card_button(
container(text(label.clone()).font(bold).size(13))
.center_x(Length::Fill)
.center_y(38),
container(
text(label.clone())
.font(bold)
.size(13)
.class(theme::Text::Color(accent)),
)
.center_x(Length::Fill)
.center_y(38),
Message::InvokeNotification(id, key.clone()),
accent,
well,
false,
label.clone(),
));
}
face = face
@@ -377,31 +553,9 @@ impl FereseShell {
},
..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>> = card.into();
let card: Element<'_, cosmic::Action<Message>> = if count > 1 {
let height = popup_height(notice, hovered, count) as f32;
let height = popup_height(notice, count) as f32;
let back = move |inset: f32, offset: f32| {
container(
container(
@@ -436,7 +590,11 @@ impl FereseShell {
.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));
area = area.on_press(cosmic::Action::App(if history {
Message::ToggleNotificationGroup(notice.app.clone())
} else {
Message::ToggleNotificationHistory
}));
} else if notice.live && notice.actions.iter().any(|(key, _)| key == "default") {
area = area.on_press(cosmic::Action::App(Message::InvokeNotification(
id,
@@ -449,101 +607,277 @@ impl FereseShell {
fn view_notifications(&self) -> Element<'_, cosmic::Action<Message>> {
if self.notifications.history_open {
let palette = self.config.theme;
let surface = self.notification_surface.as_ref();
let progress = surface.map_or(1.0, NotificationSurface::progress);
let opacity = if surface
.and_then(|surface| surface.effects.as_ref())
.is_some_and(|effects| effects.surface.version() >= 3)
{
1.0
} else {
progress
};
let color = move |rgba| color_with_opacity(rgba, opacity);
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)
let panel = blend(base, primary, 0.035);
let well = blend(base, primary, 0.07);
let mut bold = *SHELL_FONT.get().unwrap().read().unwrap();
bold.weight = cosmic::iced::font::Weight::Bold;
let groups = self.notifications.history_groups();
let count = self.notifications.entries.len();
let subtitle = if count == 0 {
"A quiet moment".to_owned()
} else {
format!(
"{count} {} · {} {}",
if count == 1 {
"notification"
} else {
"notifications"
},
groups.len(),
if groups.len() == 1 { "app" } else { "apps" }
)
};
let heading = column([])
.spacing(3)
.push(
text("Notifications")
.size(16)
.class(theme::Text::Color(primary))
.width(Length::Fill),
.font(bold)
.class(theme::Text::Color(primary)),
)
.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),
.push(text(subtitle).size(11).class(theme::Text::Color(muted)));
let header = row([])
.spacing(10)
.align_y(alignment::Vertical::Center)
.push(container(heading).width(Length::Fill))
.push(icon_control(
include_bytes!("../assets/icons/status/close.svg"),
"Close notification center",
Message::ToggleNotificationHistory,
muted,
blend(base, primary, 0.07),
true,
well,
28,
));
let dnd_color = if self.notifications.dnd {
accent
} else {
muted
};
let dnd_icon = if self.notifications.dnd {
include_bytes!("../assets/icons/status/notifications-off.svg").as_slice()
} else {
include_bytes!("../assets/icons/status/notifications.svg").as_slice()
};
let dnd = row([])
.spacing(10)
.align_y(alignment::Vertical::Center)
.push(accented_icon(dnd_icon, 20, dnd_color, dnd_color))
.push(
text("Do Not Disturb")
.size(12)
.class(theme::Text::Color(muted))
.width(Length::Fill),
container(
column([])
.spacing(2)
.push(
text("Do Not Disturb")
.size(12)
.font(bold)
.class(theme::Text::Color(primary)),
)
.push(
text(if self.notifications.dnd {
"Popups paused"
} else {
"Popups enabled"
})
.size(11)
.class(theme::Text::Color(muted)),
),
)
.width(Length::Fill),
)
.push(motion::button(
button::custom(
text(if self.notifications.dnd { "On" } else { "Off" }).size(12),
container(
text(if self.notifications.dnd { "On" } else { "Off" })
.size(12)
.font(bold)
.class(theme::Text::Color(dnd_color)),
)
.center_x(40)
.center_y(26),
)
.on_press(cosmic::Action::App(Message::Control(status::Action::Dnd(
!self.notifications.dnd,
))))
.padding([4, 8]),
if self.notifications.dnd {
accent
} else {
muted
},
.padding(0),
dnd_color,
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(),
let controls =
container(dnd)
.padding(10)
.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()
},
..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)),
}));
let mut content = column([]).spacing(10).push(header).push(controls);
if count == 0 {
let empty = column([])
.spacing(10)
.align_x(alignment::Horizontal::Center)
.push(
container(accented_icon(
include_bytes!("../assets/icons/status/notifications.svg"),
26,
muted,
accent,
))
.center_x(56)
.center_y(56)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(well)),
border: Border {
radius: 28.0.into(),
..Default::default()
},
..Default::default()
})),
)
.padding(32)
.center_x(Length::Fill),
.push(
text("You're all caught up")
.size(15)
.font(bold)
.class(theme::Text::Color(primary)),
)
.push(
text("New notifications will appear here.")
.size(11)
.class(theme::Text::Color(muted)),
);
content = content.push(
container(empty)
.center_x(Length::Fill)
.center_y(Length::Fill),
);
} else {
content = content.push(
row([])
.align_y(alignment::Vertical::Center)
.push(
text("Recent")
.size(11)
.font(bold)
.class(theme::Text::Color(muted))
.width(Length::Fill),
)
.push(icon_control(
include_bytes!("../assets/icons/status/trash.svg"),
"Clear all notifications",
Message::ClearNotifications,
muted,
well,
26,
)),
);
let mut entries = column([]).spacing(8);
for group in groups {
let latest = group[0];
if group.len() > 1 && self.notifications.expanded_apps.contains(&latest.app) {
let app = if latest.app.is_empty() {
"Notifications"
} else {
&latest.app
};
let group_header = row([])
.spacing(6)
.align_y(alignment::Vertical::Center)
.push(
text(format!("{} · {}", app, group.len()))
.size(11)
.font(bold)
.class(theme::Text::Color(muted))
.width(Length::Fill),
)
.push(icon_control(
include_bytes!("../assets/icons/status/chevron-up.svg"),
"Collapse group",
Message::ToggleNotificationGroup(latest.app.clone()),
muted,
well,
24,
))
.push(icon_control(
include_bytes!("../assets/icons/status/trash.svg"),
"Clear group",
Message::RemoveNotificationGroup(latest.app.clone()),
muted,
well,
24,
));
let mut cards = column([]).spacing(8).push(group_header);
for notice in group {
cards = cards.push(self.notification_card(notice, opacity, true, 1));
}
entries = entries.push(cards);
} else {
entries = entries.push(self.notification_card(
latest,
opacity,
true,
group.len(),
));
}
}
content = content.push(
scrollable(entries)
.direction(Direction::Vertical(Scrollbar::hidden()))
.height(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)
let panel = container(content)
.padding(14)
.height(Length::Fill)
.width(Length::Fill)
.into()
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(panel)),
border: Border {
color: color(palette.border),
width: 1.0,
radius: palette.material_radius.into(),
},
..Default::default()
}))
.into();
motion::animated(
panel,
progress,
surface
.map(|surface| surface.regions.clone())
.unwrap_or_default(),
palette.material_radius,
)
} 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()
container(
scrollable(cards)
.direction(Direction::Vertical(Scrollbar::hidden()))
.height(Length::Fill),
)
.padding(4)
.into()
}
}
}
+74
View File
@@ -284,6 +284,7 @@ pub struct Center {
pub dnd: bool,
pub history_open: bool,
pub hovered: Option<u32>,
pub expanded_apps: HashSet<String>,
pub entries: VecDeque<Notice>,
toasts: VecDeque<Toast>,
last_tick: Instant,
@@ -298,6 +299,7 @@ impl Center {
config,
history_open: false,
hovered: None,
expanded_apps: HashSet::new(),
entries: VecDeque::new(),
toasts: VecDeque::new(),
last_tick: Instant::now(),
@@ -363,6 +365,39 @@ impl Center {
groups
}
pub fn history_groups(&self) -> Vec<Vec<&Notice>> {
let mut groups: Vec<Vec<&Notice>> = Vec::new();
for notice in self.entries.iter().rev() {
if let Some(group) = groups.iter_mut().find(|group| group[0].app == notice.app) {
group.push(notice);
} else {
groups.push(vec![notice]);
}
}
groups
}
pub fn toggle_group(&mut self, app: String) {
if !self.expanded_apps.remove(&app) {
self.expanded_apps.insert(app);
}
self.hovered = None;
}
pub fn dismiss_group(&mut self, app: &str) {
let ids: Vec<_> = self
.entries
.iter()
.filter(|notice| notice.app == app)
.map(|notice| notice.id)
.collect();
for id in ids {
self.dismiss(id);
}
self.expanded_apps.remove(app);
self.hovered = None;
}
pub fn hover(&mut self, id: u32, hovered: bool) {
if hovered {
self.hovered = Some(id);
@@ -445,6 +480,8 @@ impl Center {
}
self.entries.clear();
self.toasts.clear();
self.expanded_apps.clear();
self.hovered = None;
}
fn receive(&mut self, mut notice: Notice) {
@@ -583,6 +620,7 @@ mod tests {
dnd: false,
history_open: false,
hovered: None,
expanded_apps: HashSet::new(),
entries: VecDeque::new(),
toasts: VecDeque::new(),
last_tick: Instant::now(),
@@ -609,6 +647,42 @@ mod tests {
}
}
#[test]
fn history_groups_expand_and_clear_without_removing_other_apps() {
let (mut center, commands) = fixture();
center.receive(notice(1));
let mut other = notice(2);
other.app = "Other".into();
center.receive(other);
center.receive(notice(3));
let groups = center.history_groups();
assert_eq!(
groups
.iter()
.map(|group| group.iter().map(|n| n.id).collect::<Vec<_>>())
.collect::<Vec<_>>(),
[vec![3, 1], vec![2]]
);
center.toggle_group("Test".into());
assert!(center.expanded_apps.contains("Test"));
center.toggle_group("Test".into());
assert!(!center.expanded_apps.contains("Test"));
center.dismiss_group("Test");
assert_eq!(center.entries.iter().map(|n| n.id).collect::<Vec<_>>(), [2]);
assert_eq!(center.toasts.iter().map(|n| n.id).collect::<Vec<_>>(), [2]);
let closed: Vec<_> = commands
.try_iter()
.map(|command| match command {
Command::Close(id, _, 2) => id,
_ => panic!("unexpected action"),
})
.collect();
assert_eq!(closed, [1, 3]);
center.toggle_group("Other".into());
center.clear();
assert!(center.entries.is_empty() && center.expanded_apps.is_empty());
}
#[test]
fn groups_are_newest_first_and_hover_keeps_the_whole_stack_alive() {
let (mut center, _) = fixture();