Unify notification center popovers and translucent card materials

This commit is contained in:
Samuel Onoja committed 2026-09-27 12:40:08 +01:00
1 parent 3c532725ec
commit 23d84c0a77
4 files changed
+159 -170

No files matched your search

+4 -1
View File
@@ -3,7 +3,10 @@
Ferese Shell provides themed notification cards and an in-session history panel.
Cards appear beneath the bar on the focused output and follow the selected theme.
Click the notification icon in the bar to open history, toggle Do Not Disturb, or
clear notifications. Escape or the close button closes the history panel.
clear notifications. Escape, clicking outside, or the close button closes the history panel.
The center uses the same anchored popup and animation as the other bar menus.
Both the center and popup cards follow the shell material: translucent mode uses
the compositor’s blur and tint, while solid mode stays opaque.
## Send a notification
+18 -40
View File
@@ -400,10 +400,6 @@ 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 {
@@ -424,6 +420,14 @@ impl cosmic::Application for FereseShell {
eprintln!("ferese-shell: could not update card materials: {error}");
}
}
if let Some(surface) = &self.notification_surface
&& let Some(effects) = &surface.effects
{
let regions = surface.regions.lock().unwrap().clone();
if let Err(error) = effects.set_regions(&regions) {
eprintln!("ferese-shell: could not update notification materials: {error}");
}
}
match message {
Message::BeginNoteEdit(id) => {
let mut tasks = vec![self.finish_note_edit()];
@@ -509,7 +513,7 @@ impl cosmic::Application for FereseShell {
&& entry.id == id
&& entry.effects.is_none()
{
match EffectsBinding::attach_role(&surface, None, entry.progress()) {
match EffectsBinding::attach_role(&surface, None, 1.0) {
Ok(binding) => entry.effects = Some(binding),
Err(error) => eprintln!(
"ferese-shell: notification effects unavailable: {error}"
@@ -596,27 +600,17 @@ impl cosmic::Application for FereseShell {
self.notifications.clear();
self.sync_notification_surface()
}
Message::AnimateMenu => {
Task::batch([self.animate_menu(), self.animate_notification_history()])
}
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 {
return Task::batch([self.destroy_menu(), self.toggle_notification_history()]);
}
let hide_history = if self.notifications.history_open {
self.close_notification_history()
} 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([hide_history, destroy, open])
Task::batch([destroy, open])
} else {
let open = self.open_menu(kind, anchor);
Task::batch([hide_history, open])
open
}
}
Message::ConfirmPower(action) => {
@@ -777,14 +771,6 @@ 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;
@@ -1786,14 +1772,6 @@ impl FereseShell {
.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
@@ -1821,9 +1799,13 @@ impl FereseShell {
{
match event {
wayland::PopupEvent::Done => {
if menu.kind == status_ui::Menu::Notifications {
self.notifications.history_open = false;
self.notifications.hovered = None;
}
self.menu = None;
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
return Task::none();
return self.sync_notification_surface();
}
wayland::PopupEvent::Focused => {
if menu.effects.is_none() {
@@ -1912,12 +1894,8 @@ impl FereseShell {
.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()) {
match EffectsBinding::attach_role(&surface, None, 1.0) {
Ok(binding) => entry.effects = Some(binding),
Err(error) => {
eprintln!("ferese-shell: notification effects unavailable: {error}")
+105 -124
View File
@@ -154,76 +154,44 @@ 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)
}
pub regions: motion::Regions,
}
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();
return self.close_menu();
}
self.notifications.toggle_history();
self.sync_notification_surface()
// Cards can open history too. Anchor those requests to the active bar;
// bell clicks pass their exact anchor through the normal menu path.
if let Some(output) = self.outputs.iter().find(|entry| {
self.snapshot
.outputs
.iter()
.any(|output| output.focused && Some(output.name.as_str()) == entry.name.as_deref())
}) {
self.bar_surface_id = output.bar;
}
let width = self
.outputs
.iter()
.find(|entry| entry.bar == self.bar_surface_id)
.and_then(|entry| entry.size)
.map_or(POPUP_WIDTH as i32, |size| size.0);
self.open_menu(
status_ui::Menu::Notifications,
cosmic::iced::Rectangle {
x: (width - 48).max(0),
y: 0,
width: 24,
height: self.config.theme.bar_height.round() as i32,
},
)
}
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()
self.close_menu()
}
pub(super) fn sync_notification_surface(&mut self) -> Task<Message> {
@@ -234,7 +202,7 @@ impl FereseShell {
});
}
let count = self.notifications.popup_groups().len();
let wanted = self.notifications.ready && (self.notifications.history_open || count > 0);
let wanted = self.notifications.ready && !self.notifications.history_open && count > 0;
let selected = self
.outputs
.iter()
@@ -260,19 +228,16 @@ impl FereseShell {
let Some(output) = selected else {
return Task::batch(tasks);
};
let desired_height = if self.notifications.history_open {
history_height(&self.notifications)
} else {
self.notifications
.popup_groups()
.iter()
.map(|(notice, _, count)| {
popup_height(notice, *count) + if *count > 1 { 12 } else { 0 }
})
.sum::<u32>()
+ count.saturating_sub(1) as u32 * 8
+ 8
};
let desired_height = self
.notifications
.popup_groups()
.iter()
.map(|(notice, _, count)| {
popup_height(notice, *count) + if *count > 1 { 12 } else { 0 }
})
.sum::<u32>()
+ count.saturating_sub(1) as u32 * 8
+ 8;
let output_height = self
.outputs
.iter()
@@ -292,9 +257,7 @@ 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;
@@ -321,19 +284,6 @@ 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)
}
@@ -353,7 +303,16 @@ impl FereseShell {
let foreground = tint(palette.text_primary);
let muted = tint(palette.text_muted);
let accent = tint(palette.accent);
let base = tint(palette.surface_base);
let base = tint(if history {
palette.surface_base
} else {
palette.surface_popover
});
let compositor_material = !history
&& self
.notification_surface
.as_ref()
.is_some_and(|surface| surface.effects.is_some());
let divider = tint(palette.border);
let well = blend(base, foreground, 0.07);
let id = notice.id;
@@ -543,11 +502,25 @@ impl FereseShell {
}
}
let card = container(face)
.id(if history {
"ferese-history-card"
} else {
"ferese-blur-card"
})
.width(Length::Fill)
.clip(true)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(base)),
background: if history {
Some(Background::Color(Color {
a: 0.045 * opacity,
..foreground
}))
} else {
(!compositor_material).then_some(Background::Color(base))
},
border: Border {
color: color_with_opacity(palette.text_muted, 0.18 * opacity),
width: if history { 0.0 } else { 1.0 },
radius: palette.material_radius.into(),
..Default::default()
},
@@ -555,34 +528,32 @@ impl FereseShell {
}));
let card: Element<'_, cosmic::Action<Message>> = card.into();
let card: Element<'_, cosmic::Action<Message>> = if count > 1 {
let height = popup_height(notice, count) as f32;
let back = move |inset: f32, offset: f32| {
// With translucent materials, complete backing rectangles show
// through the face and multiply its tint. Paint only the exposed
// bottom edges, after the card's measured height.
let back = move |inset: f32| {
container(
container(
cosmic::iced::widget::Space::new()
.width(Length::Fill)
.height(height),
.height(6),
)
.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()
},
.width(Length::Fill)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(Color {
a: 0.045 * opacity,
..foreground
})),
border: Border {
radius: [0.0, 0.0, motion::radius(6.0), motion::radius(6.0)].into(),
..Default::default()
}
},
..Default::default()
})),
)
.padding(cosmic::iced::Padding {
top: offset,
left: inset,
right: inset,
bottom: 0.0,
})
.padding([0.0, inset])
};
cosmic::iced::widget::stack([back(12.0, 12.0).into(), back(6.0, 6.0).into(), card])
.into()
column([card, back(6.0).into(), back(12.0).into()]).into()
} else {
card
};
@@ -604,11 +575,11 @@ impl FereseShell {
area.into()
}
fn view_notifications(&self) -> Element<'_, cosmic::Action<Message>> {
pub(super) 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 surface = self.menu.as_ref();
let progress = surface.map_or(1.0, status_ui::OpenMenu::progress);
let opacity = if surface
.and_then(|surface| surface.effects.as_ref())
.is_some_and(|effects| effects.surface.version() >= 3)
@@ -622,7 +593,7 @@ impl FereseShell {
let muted = color(palette.text_muted);
let accent = color(palette.accent);
let base = color(palette.surface_base);
let panel = blend(base, primary, 0.035);
let compositor_material = surface.is_some_and(|menu| menu.effects.is_some());
let well = blend(base, primary, 0.07);
let mut bold = *SHELL_FONT.get().unwrap().read().unwrap();
bold.weight = cosmic::iced::font::Weight::Bold;
@@ -845,13 +816,15 @@ impl FereseShell {
);
}
let panel = container(content)
.padding(14)
.height(Length::Fill)
.id("ferese-blur-card")
.padding(16)
.height(history_height(&self.notifications) as f32)
.width(Length::Fill)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(panel)),
background: (!compositor_material)
.then_some(Background::Color(color(palette.surface_popover))),
border: Border {
color: color(palette.border),
color: color_with_opacity(palette.text_muted, 0.18 * opacity),
width: 1.0,
radius: palette.material_radius.into(),
},
@@ -871,13 +844,21 @@ impl FereseShell {
for (notice, opacity, count) in self.notifications.popup_groups() {
cards = cards.push(self.notification_card(notice, opacity, false, count));
}
container(
scrollable(cards)
.direction(Direction::Vertical(Scrollbar::hidden()))
.height(Length::Fill),
motion::animated(
container(
scrollable(cards)
.direction(Direction::Vertical(Scrollbar::hidden()))
.height(Length::Fill),
)
.padding(4)
.into(),
1.0,
self.notification_surface
.as_ref()
.map(|surface| surface.regions.clone())
.unwrap_or_default(),
self.config.theme.material_radius,
)
.padding(4)
.into()
}
}
}
+32 -5
View File
@@ -25,6 +25,7 @@ impl Menu {
match self {
Self::System => 360.0,
Self::Battery => 280.0,
Self::Notifications => 368.0,
_ => 300.0,
}
}
@@ -90,6 +91,10 @@ impl FereseShell {
if !kind.available(&self.status) {
return destroy;
}
if kind == Menu::Notifications && self.notifications.ready {
self.notifications.toggle_history();
}
let notifications = self.sync_notification_surface();
let id = window::Id::unique();
self.menu = Some(OpenMenu {
id,
@@ -149,16 +154,25 @@ impl FereseShell {
},
Some(Box::new(Self::view_status_menu)),
);
destroy.chain(cosmic::task::message(cosmic::Action::Surface(action)))
Task::batch([destroy, notifications])
.chain(cosmic::task::message(cosmic::Action::Surface(action)))
}
pub fn destroy_menu(&mut self) -> Task<Message> {
super::EFFECT_FRAME_PENDING.store(false, std::sync::atomic::Ordering::Relaxed);
self.menu.take().map_or_else(Task::none, |menu| {
let Some(menu) = self.menu.take() else {
return Task::none();
};
if menu.kind == Menu::Notifications {
self.notifications.history_open = false;
self.notifications.hovered = None;
}
Task::batch([
cosmic::task::message(cosmic::Action::Surface(
cosmic::surface::action::destroy_popup(menu.id),
))
})
)),
self.sync_notification_surface(),
])
}
pub fn close_menu(&mut self) -> Task<Message> {
@@ -320,6 +334,19 @@ impl FereseShell {
let Some(menu) = &self.menu else {
return text("").into();
};
if menu.kind == Menu::Notifications && self.notifications.ready {
return cosmic::widget::autosize::autosize(
self.view_notifications(),
cosmic::iced::advanced::widget::Id::new("ferese-notification-center"),
)
.limits(
Limits::NONE
.min_width(menu.kind.width())
.max_width(menu.kind.width())
.max_height(720.0),
)
.into();
}
let theme = self.config.theme;
let p = if menu.effects.is_some() {
1.0
@@ -1182,7 +1209,7 @@ mod tests {
assert_eq!(Menu::Bluetooth.width(), 300.0);
assert_eq!(Menu::Battery.width(), 280.0);
assert_eq!(Menu::Audio.width(), 300.0);
assert_eq!(Menu::Notifications.width(), 300.0);
assert_eq!(Menu::Notifications.width(), 368.0);
}
#[test]