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

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