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, count: usize) -> u32 { let body = if notice.body.is_empty() && count == 1 { 0 } else { 22 }; 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::() } 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::() + 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, g: base.g + (foreground.g - base.g) * amount, b: base.b + (foreground.b - base.b) * amount, a: base.a, } } fn age_label(age: Duration) -> String { match age.as_secs() { 0..60 => "now".into(), 60..3600 => format!("{}m ago", age.as_secs() / 60), 3600..86400 => format!("{}h ago", age.as_secs() / 3600), seconds => format!("{}d ago", seconds / 86400), } } fn card_button<'a>( content: impl Into>>, message: Message, foreground: Color, hover: Color, round: bool, accessible_name: String, ) -> Element<'a, cosmic::Action> { let style = move |active: bool| button::Style { text_color: Some(foreground), icon_color: Some(foreground), background: (active || round).then_some(Background::Color(hover)), border_radius: (if round { motion::radius(11.0) } else { 0.0 }).into(), ..Default::default() }; button::custom(content) .name(accessible_name) .width(if round { Length::Shrink } else { Length::Fill }) .padding(0) .on_press(cosmic::Action::App(message)) .class(theme::Button::Custom { active: Box::new(move |_, _| style(false)), hovered: Box::new(move |_, _| style(true)), pressed: Box::new(move |_, _| style(true)), disabled: Box::new(move |_| style(false)), }) .into() } fn icon_control<'a>( source: &'static [u8], label: &'static str, message: Message, foreground: Color, background: Color, extent: u16, ) -> Element<'a, cosmic::Action> { 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: motion::radius(12.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 effects: Option, pub regions: motion::Regions, } impl FereseShell { pub(super) fn toggle_notification_history(&mut self) -> Task { if self.notifications.history_open { return self.close_menu(); } // 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 { self.close_menu() } pub(super) fn sync_notification_surface(&mut self) -> Task { if self.notifications.ready { self.status.notifications = Some(status::Notifications { count: self.notifications.unread(), dnd: self.notifications.dnd, }); } let count = self.notifications.popup_groups().len(); let wanted = self.notifications.ready && !self.notifications.history_open && count > 0; let selected = self .outputs .iter() .find(|entry| { self.snapshot.outputs.iter().any(|output| { output.focused && Some(output.name.as_str()) == entry.name.as_deref() }) }) .or_else(|| self.outputs.first()) .map(|entry| entry.output.clone()); let mut tasks = Vec::new(); if self .notification_surface .as_ref() .is_some_and(|surface| !wanted || selected.as_ref() != Some(&surface.output)) { let surface = self.notification_surface.take().unwrap(); tasks.push(destroy_layer_surface(surface.id)); } if !wanted { return Task::batch(tasks); } let Some(output) = selected else { return Task::batch(tasks); }; let desired_height = self .notifications .popup_groups() .iter() .map(|(notice, _, count)| { popup_height(notice, *count) + if *count > 1 { 12 } else { 0 } }) .sum::() + count.saturating_sub(1) as u32 * 8 + 8; let output_height = self .outputs .iter() .find(|entry| entry.output == output) .and_then(|entry| entry.size) .map_or(1080, |size| size.1); let top = self.config.theme.bar_height + self.config.theme.bar_margin_top as f32 + 12.0; let height = desired_height.min((output_height as f32 - top - 12.0).max(100.0) as u32); if let Some(surface) = &mut self.notification_surface { if surface.height != height { surface.height = height; tasks.push(set_size(surface.id, Some(POPUP_WIDTH), Some(height))); } } else { let id = window::Id::unique(); self.notification_surface = Some(NotificationSurface { id, output: output.clone(), height, effects: None, 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::( |_| Default::default(), move |_| SctkLayerSurfaceSettings { id, layer: Layer::Overlay, keyboard_interactivity: KeyboardInteractivity::None, anchor: Anchor::TOP | Anchor::RIGHT, output: IcedOutput::Output(output.clone()), margin: IcedMargin { top: top as i32, right: 12, ..Default::default() }, exclusive_zone: -1, size: Some((Some(POPUP_WIDTH), Some(height))), size_limits: Limits::NONE, namespace: "ferese-shell-notifications".into(), ..Default::default() }, Some(Box::new(Self::view_notifications)), ); tasks.push(cosmic::task::message(cosmic::Action::Surface(action))); } Task::batch(tasks) } fn notification_card( &self, notice: ¬ifications::Notice, opacity: f32, history: bool, count: usize, ) -> Element<'_, cosmic::Action> { let palette = self.config.theme; let tint = move |rgba| { let mut value = color(rgba); value.a *= opacity; value }; let foreground = tint(palette.text_primary); let muted = tint(palette.text_muted); let accent = tint(palette.accent); 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; 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() { accented_icon( include_bytes!("../assets/icons/ferese.svg"), 18, accent, accent, ) } else if notice.icon.starts_with('/') { icon::icon(icon::from_path(notice.icon.clone().into())).size(18) } else { icon::from_name(notice.icon.as_str()).size(18).icon() } .opacity(opacity); let icon_well = container(app_icon) .center_x(30) .center_y(30) .class(theme::Container::custom(move |_| container::Style { background: Some(Background::Color(well)), border: Border { radius: motion::radius(15.0).into(), ..Default::default() }, ..Default::default() })); let icon: Element<'_, cosmic::Action> = if count > 1 { let badge_fill = ferese_theme::composite( color(palette.accent), color(if history { palette.surface_base } else { palette.surface_popover }), ); let (badge_fill, badge_text) = ferese_theme::accent_pair(badge_fill, color(palette.text_primary)); let badge = container( text(count.to_string()) .size(10) .font(bold) .class(theme::Text::Color(badge_text.scale_alpha(opacity))), ) .center_x(16) .center_y(16) .class(theme::Container::custom(move |_| container::Style { background: Some(Background::Color(badge_fill.scale_alpha(opacity))), border: Border { radius: motion::radius(8.0).into(), ..Default::default() }, ..Default::default() })); cosmic::iced::widget::stack([ container(icon_well) .padding(cosmic::iced::Padding { top: 4.0, ..Default::default() }) .into(), container(badge).align_right(34).into(), ]) .into() } else { icon_well.into() }; let app = if notice.app.is_empty() { "NOTIFICATION".to_owned() } else { notice.app.to_uppercase() }; let app = if count > 1 { format!("{app} · {count} NOTIFICATIONS") } else { app }; let header = row([]) .spacing(10) .align_y(alignment::Vertical::Center) .push(icon) .push( container( text(app) .size(11) .font(bold) .class(theme::Text::Color(muted)), ) .width(Length::Fill) .height(16) .clip(true), ) .push( 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()) .size(14) .font(bold) .class(theme::Text::Color(foreground)), ) .height(18) .clip(true), ); let body = if count > 1 { if notice.body.is_empty() { format!("{} more", count - 1) } else { format!("{} · {} more", notice.body, count - 1) } } else { notice.body.clone() }; if !body.is_empty() { content = content.push( container(text(body).size(13).class(theme::Text::Color(muted))) .height(if history && count == 1 { Length::Shrink } else { Length::Fixed(18.0) }) .clip(true), ); } let mut face = column([]).push(container(content).padding([12, 14]).width(Length::Fill)); if notice.live { let available: Vec<_> = notice .actions .iter() .filter(|(key, _)| key != "default") .take(2) .collect(); if !available.is_empty() { let mut actions = row([]); for (index, (key, label)) in available.into_iter().enumerate() { if index > 0 { actions = actions.push( container(cosmic::iced::widget::Space::new().width(1).height(38)) .class(theme::Container::custom(move |_| container::Style { background: Some(Background::Color(divider)), ..Default::default() })), ); } actions = actions.push(card_button( 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 .push( container( cosmic::iced::widget::Space::new() .width(Length::Fill) .height(1), ) .class(theme::Container::custom(move |_| { container::Style { background: Some(Background::Color(divider)), ..Default::default() } })), ) .push(actions); } } 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: 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() }, ..Default::default() })); let card: Element<'_, cosmic::Action> = card.into(); let card: Element<'_, cosmic::Action> = if count > 1 { // 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(6), ) .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([0.0, inset]) }; column([card, back(6.0).into(), back(12.0).into()]).into() } else { card }; let mut area = cosmic::widget::mouse_area(card) .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(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, "default".into(), ))); } area.into() } pub(super) fn view_notifications(&self) -> Element<'_, cosmic::Action> { if self.notifications.history_open { let palette = self.config.theme; 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) { 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 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; 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) .font(bold) .class(theme::Text::Color(primary)), ) .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, 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( 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( 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(0), dnd_color, self.notifications.dnd, 1.0, )); 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() })); 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: motion::radius(28.0).into(), ..Default::default() }, ..Default::default() })), ) .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), ); } let panel = container(content) .id("ferese-blur-card") .padding(16) .height(history_height(&self.notifications) as f32) .width(Length::Fill) .class(theme::Container::custom(move |_| container::Style { background: (!compositor_material) .then_some(Background::Color(color(palette.surface_popover))), border: Border { color: color_with_opacity(palette.text_muted, 0.18 * opacity), 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)); } 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, ) } } }