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:
1 parent
a98d7a75c3
commit
ab6bf8b961
16 files changed
+921
-123
No files matched your search
@@ -4,6 +4,7 @@ pub enum Page {
|
||||
Wallpaper,
|
||||
Desktop,
|
||||
Bar,
|
||||
Notifications,
|
||||
Windows,
|
||||
Motion,
|
||||
Keyboard,
|
||||
@@ -13,11 +14,12 @@ pub enum Page {
|
||||
}
|
||||
|
||||
impl Page {
|
||||
pub const ALL: [Self; 10] = [
|
||||
pub const ALL: [Self; 11] = [
|
||||
Self::Appearance,
|
||||
Self::Wallpaper,
|
||||
Self::Desktop,
|
||||
Self::Bar,
|
||||
Self::Notifications,
|
||||
Self::Windows,
|
||||
Self::Motion,
|
||||
Self::Keyboard,
|
||||
@@ -32,6 +34,7 @@ impl Page {
|
||||
Self::Wallpaper => "Wallpaper",
|
||||
Self::Desktop => "Desktop widgets",
|
||||
Self::Bar => "Menu bar",
|
||||
Self::Notifications => "Notifications",
|
||||
Self::Windows => "Windows",
|
||||
Self::Motion => "Motion",
|
||||
Self::Keyboard => "Keyboard & mouse",
|
||||
@@ -47,6 +50,7 @@ impl Page {
|
||||
Self::Wallpaper => "Set the scene for your workspace.",
|
||||
Self::Desktop => "A clock that feels at home on your wallpaper.",
|
||||
Self::Bar => "Everything you need, within reach.",
|
||||
Self::Notifications => "Stay informed on your terms.",
|
||||
Self::Windows => "Make room for the way you work.",
|
||||
Self::Motion => "Find your rhythm.",
|
||||
Self::Keyboard => "Fine-tune the everyday details.",
|
||||
@@ -64,6 +68,7 @@ impl Page {
|
||||
Self::Wallpaper => "M4 4h16v16H4z M4 16l5-5 4 4 3-3 4 4 M15 8h.01",
|
||||
Self::Desktop => "M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M12 7v5l3 2",
|
||||
Self::Bar => "M3 5h18v14H3z M3 9h18 M6 7h.01 M18 7h.01",
|
||||
Self::Notifications => "M6 8a6 6 0 0 1 12 0v6l2 3H4l2-3z M10 21h4",
|
||||
Self::Windows => "M3 4h12v12H3z M8 9h13v12H8z",
|
||||
Self::Motion => "M3 8h9a3 3 0 1 0-3-3 M3 12h15a3 3 0 1 1-3 3 M3 16h5",
|
||||
Self::Keyboard => {
|
||||
@@ -626,6 +631,31 @@ pub fn fields(page: Page) -> Vec<Field> {
|
||||
),
|
||||
],
|
||||
Page::Desktop => clock_fields(),
|
||||
Page::Notifications => vec![
|
||||
toggle(
|
||||
"notifications.show_popups",
|
||||
"Show popup notifications",
|
||||
"Keep notifications in history when popups are hidden.",
|
||||
true,
|
||||
),
|
||||
toggle(
|
||||
"notifications.do_not_disturb",
|
||||
"Do Not Disturb",
|
||||
"Silence normal popups; critical notifications can still appear.",
|
||||
false,
|
||||
),
|
||||
range(
|
||||
"notifications.timeout_ms",
|
||||
"Popup timeout",
|
||||
"Default duration; apps can request another timeout. Pauses while hovered.",
|
||||
6000.0,
|
||||
1000.0,
|
||||
30000.0,
|
||||
1000.0,
|
||||
" ms",
|
||||
true,
|
||||
),
|
||||
],
|
||||
Page::Bar => vec![
|
||||
range(
|
||||
"theme.geometry.top_bar_height",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m5 15 7-7 7 7"/></svg>
|
||||
|
After Width: | Height: | Size: 192 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="m6 6 12 12M18 6 6 18"/></svg>
|
||||
|
After Width: | Height: | Size: 175 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v5M14 11v5"/></svg>
|
||||
|
After Width: | Height: | Size: 229 B |
@@ -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,
|
||||
|
||||
@@ -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: ¬ifications::Notice, hovered: bool, count: usize) -> u32 {
|
||||
fn popup_height(notice: ¬ifications::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: ¬ifications::Center) -> u32 {
|
||||
if center.entries.is_empty() {
|
||||
return 280;
|
||||
}
|
||||
let groups = center.history_groups();
|
||||
let card_height = |notice: ¬ifications::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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user