Unify notification center popovers and translucent card materials
This commit is contained in:
1 parent
3c532725ec
commit
23d84c0a77
4 files changed
+159
-170
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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(®ions) {
|
||||
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}")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user