From d18f0fd8bee10ed67a89f6d7f23d090c6dda388b Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Fri, 23 Oct 2026 13:08:52 +0100 Subject: [PATCH] Keep notification popovers open when unread badges clear --- shell/ferese-shell/src/main.rs | 79 +++++++++++++++++++++++++ shell/ferese-shell/src/status_ui/bar.rs | 32 +++++----- 2 files changed, 97 insertions(+), 14 deletions(-) diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 14776bd..496fc4a 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -1596,6 +1596,85 @@ mod tests { assert_eq!(shell.outputs[0].bar_margin_horizontal, before); } + #[test] + fn reading_notifications_does_not_reallocate_and_dismiss_the_popover() { + use cosmic::Application; + use cosmic::iced::advanced::renderer::Headless; + use cosmic::iced::advanced::{layout, widget::Tree}; + use cosmic::iced::{Font, Pixels, Size}; + use panel_layout::{Placement, Resolution}; + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let renderer = runtime + .block_on(::new( + Font::default(), + Pixels(14.0), + Some("tiny-skia"), + )) + .unwrap(); + let mut shell = shell_with_measured_panel(); + shell.notifications.ready = true; + shell.status.notifications = Some(status::Notifications { + count: 3, + dnd: false, + }); + let item = panel::ItemId("notifications".into()); + let bar = shell.outputs[0].bar; + let resolution = |shell: &FereseShell| { + let mut control = shell.view_status_item(status_ui::Menu::Notifications, false, false); + let mut tree = Tree::new(control.as_widget()); + let node = control.as_widget_mut().layout( + &mut tree, + &renderer, + &layout::Limits::new(Size::ZERO, Size::new(200.0, 60.0)), + ); + Resolution { + items: [( + item.clone(), + Placement::Visible { + representation: panel::Representation::Icon, + width: node.size().width, + }, + )] + .into(), + zone_widths: [0.0, 0.0, node.size().width], + ..Default::default() + } + }; + shell.outputs[0].panel_resolution = Some(resolution(&shell)); + drop(shell.open_menu( + status_ui::Menu::Notifications, + status_ui::PopoverAnchor { + parent: bar, + panel: shell.config.panels[0].id.clone(), + item: Some(item.clone()), + rectangle: cosmic::iced::Rectangle { + x: 1000, + y: 0, + width: 40, + height: 36, + }, + }, + )); + let popup = shell.menu.as_ref().expect("first click opens history").id; + assert!(shell.notifications.history_open); + assert_eq!(shell.status.notifications.as_ref().unwrap().count, 0); + let after_reading = resolution(&shell); + drop(shell.update(Message::PanelResolved(bar, after_reading))); + assert_eq!( + shell + .menu + .as_ref() + .expect("clearing the badge must not close history") + .id, + popup + ); + assert!(shell.notifications.history_open); + } + #[test] fn panel_reallocation_dismisses_popovers_in_both_overflow_directions() { use cosmic::Application; diff --git a/shell/ferese-shell/src/status_ui/bar.rs b/shell/ferese-shell/src/status_ui/bar.rs index 3087f55..27c746c 100644 --- a/shell/ferese-shell/src/status_ui/bar.rs +++ b/shell/ferese-shell/src/status_ui/bar.rs @@ -78,24 +78,28 @@ impl FereseShell { ); } - if kind == Menu::Notifications - && self + if kind == Menu::Notifications { + // Reading notifications clears the dot. Keep its space so the + // resulting panel allocation does not dismiss the new popover. + let unread = self .status .notifications .as_ref() - .is_some_and(|n| n.count > 0 && !n.dnd) - { - content = content.push(container(self.render.text("")).width(4).height(4).class( - theme::Container::custom(move |_| container::Style { - background: Some(Background::Color(foreground)), - border: Border { - shape: BorderShape::Continuous, - radius: self.render.radius(2.0).into(), + .is_some_and(|n| n.count > 0 && !n.dnd); + content = content.push( + container(cosmic::widget::Space::new()) + .width(4) + .height(4) + .class(theme::Container::custom(move |_| container::Style { + background: unread.then_some(Background::Color(foreground)), + border: Border { + shape: BorderShape::Continuous, + radius: self.render.radius(2.0).into(), + ..Default::default() + }, ..Default::default() - }, - ..Default::default() - }), - )); + })), + ); } let content = bar_content(content, metrics.group_item_height); let recording_busy = kind == Menu::Recording && self.recorder.busy();