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

+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]