//! Status popup lifecycle, shared frame, and content dispatch. mod audio; mod bar; mod battery; mod bluetooth; mod calendar; mod controls; mod lifecycle; mod media; mod network; mod night_light; mod system; use bar::status_icon; use controls::{control_card, menu_button, toggle_row}; use cosmic::iced::border::Shape as BorderShape; use cosmic::widget::column; use status::{Action, Snapshot}; use super::*; type MenuHeading<'a> = cosmic::widget::Row<'a, cosmic::Action, cosmic::Theme, cosmic::Renderer>; type MenuRows<'a> = cosmic::widget::Column<'a, cosmic::Action, cosmic::Theme, cosmic::Renderer>; #[derive(Clone, Copy)] struct MenuStyle { theme: ShellTheme, primary: Color, muted: Color, opacity: f32, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Menu { NightLight, Activities, Workspaces, Network, Bluetooth, Audio, Battery, Calendar, Recording, Media, Notifications, System, Overflow, } impl Menu { fn action_error(self, error: &status::ActionError) -> Option<&str> { let relevant = self == Self::System || matches!( (self, &error.action), ( Self::NightLight, Action::NightLight(_) | Action::NightLightTemperature(_) ) | (Self::Network, Action::Wifi(_)) | (Self::Bluetooth, Action::Bluetooth(_)) | (Self::Audio, Action::Volume(_) | Action::Mute(_)) | ( Self::Battery, Action::Brightness(_) | Action::PowerProfile(_) ) | (Self::Notifications, Action::Dnd(_) | Action::Notifications) ); relevant.then_some(error.message.as_str()) } pub(crate) fn width(self) -> f32 { match self { Self::System => 360.0, Self::Battery => 328.0, Self::Calendar => 268.0, Self::Notifications | Self::Media => 368.0, _ => 300.0, } } fn height_limit(self) -> f32 { if self == Self::Calendar { 270.0 } else { 720.0 } } fn title(self) -> &'static str { match self { Self::NightLight => "Night light", Self::Activities => "Activities", Self::Workspaces => "Workspaces", Self::System => "Control Center", Self::Overflow => "Panel items", Self::Network => "Wi-Fi", Self::Bluetooth => "Bluetooth", Self::Audio => "Sound", Self::Battery => "Battery", Self::Calendar => "Calendar", Self::Recording => "Screen recording", Self::Media => "Now playing", Self::Notifications => "Notifications", } } fn available(self, status: &Snapshot) -> bool { match self { Self::NightLight => true, Self::Network => status.network.is_some(), Self::Bluetooth => status.bluetooth.is_some(), Self::Audio => status.audio.is_some(), Self::Battery => status.battery.is_some(), Self::Calendar | Self::Recording | Self::Media => true, Self::Notifications => status.notifications.is_some(), Self::System | Self::Overflow | Self::Workspaces | Self::Activities => true, } } } pub struct OpenMenu { pub id: window::Id, pub anchor: PopoverAnchor, pub kind: Menu, pub motion: super::motion::PopupMotion, pub effects: Option, pub regions: super::motion::Regions, } #[derive(Clone, Debug, PartialEq)] pub(crate) struct PopoverAnchor { pub parent: window::Id, pub panel: crate::panel::PanelId, // Notifications can be opened from a toast without a panel item. pub item: Option, pub rectangle: cosmic::iced::Rectangle, } impl PopoverAnchor { pub(crate) fn belongs_to(&self, panel: &crate::panel::Panel) -> bool { self.panel == panel.id && self.item.as_ref().is_none_or(|id| { id.0 == "_overflow" || panel.item(id).is_some_and(|item| item.visible) }) } pub fn same_item(&self, other: &Self) -> bool { self.parent == other.parent && self.panel == other.panel && self.item == other.item } } impl OpenMenu { pub(super) fn prepare_surface( &mut self, context: &mut Option>, surface: &wl_surface::WlSurface, ) { if self.effects.is_none() { match EffectsBinding::attach_role(context, surface, None, 0.0) { Ok(binding) => self.effects = Some(binding), Err(error) => eprintln!("ferese-shell: popover material unavailable: {error}"), } } if let Some(effects) = &self.effects { let regions = self.regions.lock().unwrap(); if let Err(error) = effects.set_regions(®ions) { eprintln!("ferese-shell: could not prepare popover material: {error}"); } } self.motion.begin(Instant::now()); super::EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); } #[cfg(test)] pub fn progress(&self) -> f32 { self.motion.progress() } pub fn animating(&self) -> bool { self.motion.animating() } } impl FereseShell { pub(crate) fn view_hub_menu( &self, kind: Menu, opacity: f32, ) -> Element<'_, cosmic::Action> { let theme = self.config.theme; let style = MenuStyle { theme, primary: color_with_opacity(theme.text_primary, opacity), muted: color_with_opacity(theme.text_muted, opacity), opacity, }; let mut rows = column::with_capacity(8).spacing(8).width(Length::Fill); if kind == Menu::Network && let Some(network) = &self.status.network { rows = rows.push(toggle_row( self.render, "Wi-Fi", network.enabled, Action::Wifi(!network.enabled), theme, opacity, )); } if kind == Menu::Audio && let Some(audio) = &self.status.audio { rows = rows.push(toggle_row( self.render, "Mute", audio.muted, Action::Mute(!audio.muted), theme, opacity, )); } let mut rows = match kind { Menu::System => system::view(self, rows, style, true), Menu::NightLight => night_light::view(self, rows, style), Menu::Network => network::view(self, rows, style), Menu::Bluetooth => bluetooth::view(self, rows, style, true), Menu::Audio => audio::view(self, rows, style, false), Menu::Battery => battery::view(self, rows, style), Menu::Calendar => rows.push(calendar::hub( self.render, self.calendar_offset, theme, opacity, )), Menu::Media => media::hub(self, rows, style), Menu::Notifications => { let mut rows = notification_controls(self, rows, style, true); if self.notifications.entries.is_empty() { rows = rows.push( self.render .text("No notifications") .size(13) .class(theme::Text::Color(style.muted)), ); } else { rows = rows.push(menu_button( self.render, "Clear notifications", Message::ClearNotifications, style.muted, opacity, )); for notice in &self.notifications.entries { rows = rows.push(control_card( self.render, column![ row![ self.render .text(notice.app.clone()) .size(11) .class(theme::Text::Color(style.muted)) .width(Length::Fill), menu_button( self.render, "×", Message::RemoveNotification(notice.id), style.muted, opacity ) ], self.render .text(notice.title.clone()) .size(13) .class(theme::Text::Color(style.primary)), self.render .text(notice.body.clone()) .size(12) .class(theme::Text::Color(style.muted)), ] .spacing(4) .into(), style.primary, opacity, )); for (action, label) in ¬ice.actions { rows = rows.push(menu_button( self.render, label, Message::InvokeNotification(notice.id, action.clone()), style.primary, opacity, )); } } } rows } Menu::Recording => rows.push(menu_button( self.render, if self.recorder.busy() { "Stop recording" } else { "Record screen" }, if self.recorder.busy() { Message::StopRecording } else { Message::StartRecording }, style.primary, opacity, )), Menu::Activities | Menu::Overflow | Menu::Workspaces => rows, }; if !kind.available(&self.status) { rows = rows.push( self.render .text("Service unavailable") .size(13) .class(theme::Text::Color(style.muted)), ); } if let Some(error) = self .status_error .as_ref() .and_then(|error| kind.action_error(error)) { rows = rows.push( self.render .text(error) .size(12) .class(theme::Text::Color(style.muted)), ); } container(rows) .width(Length::Fill) .class(theme::Container::custom(move |_| container::Style { text_color: Some(style.primary), icon_color: Some(style.primary), ..Default::default() })) .into() } fn view_status_menu(&self) -> Element<'_, cosmic::Action> { let Some(menu) = &self.menu else { return self.render.text("").into(); }; motion::frame_driven( menu.motion.revision(), |now| self.view_status_menu_at(now), |now| menu.motion.frame_active(now), |now| { if let Some(effects) = &menu.effects { let _ = effects.set_presentation( &menu.regions.lock().unwrap(), menu.motion.progress_at(now), [], super::ferese_surface_effects_v1::Role::Popover, ); } }, |now| { (menu.motion.closing() && !menu.motion.animating_at(now)) .then_some(cosmic::Action::App(Message::AnimateMenu)) }, ) } fn view_status_menu_at(&self, now: Instant) -> Element<'_, cosmic::Action> { let Some(menu) = &self.menu else { return self.render.text("").into(); }; if menu.kind == Menu::Notifications && self.notifications.ready { return cosmic::widget::autosize::autosize( self.view_notifications_at(now), 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 = 1.0; let kind = menu.kind; let primary = color_with_opacity(theme.text_primary, p); let muted = color_with_opacity(theme.text_muted, p); let heading_icon = if kind == Menu::Battery { self.battery_indicator(theme, p, false) } else { accented_icon( status_icon(kind, &self.status).0, 22, primary, color_with_opacity(theme.accent, p), ) .width(Length::Fixed(22.)) .into() }; let badge = ferese_theme::menus::badge( heading_icon, color(theme.accent), p, self.render.radius(20.), ); let mut heading = ferese_theme::menus::heading(badge, kind.title(), self.render.font); let style = MenuStyle { theme, primary, muted, opacity: p, }; heading = match kind { Menu::System => system::heading(self, heading, style), Menu::Network => network::heading(self, heading, style), Menu::Audio => audio::heading(self, heading, style), _ => heading, }; let mut rows = column::with_capacity(12).spacing(12).width(Length::Fill); if kind != Menu::Calendar { rows = rows.push(heading); } rows = match kind { Menu::System => system::view(self, rows, style, false), Menu::NightLight => night_light::view(self, rows, style), Menu::Network => network::view(self, rows, style), Menu::Bluetooth => bluetooth::view(self, rows, style, false), Menu::Audio => audio::view(self, rows, style, false), Menu::Battery => battery::view(self, rows, style), Menu::Calendar => { rows.push(calendar::view(self.render, self.calendar_offset, theme, p)) } Menu::Notifications => notification_controls(self, rows, style, false), Menu::Activities => rows.push(self.activity_list(menu.anchor.parent, p)), Menu::Recording | Menu::Workspaces => rows, Menu::Overflow => self.view_panel_overflow(rows), Menu::Media => media::view(self, rows, style), }; if kind != Menu::Calendar && !kind.available(&self.status) { rows = rows.push( self.render .text("Service unavailable") .size(13) .class(theme::Text::Color(muted)), ); } if let Some(error) = self .status_error .as_ref() .and_then(|error| menu.kind.action_error(error)) { rows = rows.push( self.render .text(error) .size(12) .class(theme::Text::Color(Color { a: p, ..Color::from_rgb8(230, 172, 90) })), ); } let compositor_material = menu.effects.is_some(); let body: Element<'_, cosmic::Action> = if matches!(kind, Menu::Overflow | Menu::Activities) { cosmic::widget::scrollable(rows) .height(Length::Shrink) .into() } else { rows.into() }; let panel = container(body) .id("ferese-blur-card") .width(kind.width()) .padding(16) .class(theme::Container::custom(move |_| container::Style { background: (!compositor_material).then_some(Background::Color( color_with_opacity(theme.surface_popover, p), )), text_color: Some(primary), icon_color: Some(primary), border: Border { shape: BorderShape::Continuous, color: color_with_opacity(theme.border, p), width: 1.0, radius: theme.material_radius.into(), ..Default::default() }, snap: true, ..Default::default() })); cosmic::widget::autosize::autosize( super::motion::animated_from_edge( panel.into(), menu.motion.progress_at(now), menu.regions.clone(), theme.material_radius, self.config.panels[0].edge, ), cosmic::iced::advanced::widget::Id::new("ferese-status-menu"), ) .limits( Limits::NONE .min_width(kind.width()) .max_width(kind.width()) .max_height(kind.height_limit()), ) .into() } } fn notification_controls<'a>( shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle, combined: bool, ) -> MenuRows<'a> { let MenuStyle { theme, primary, muted, opacity: p, } = style; if let Some(n) = &shell.status.notifications { let toggle = toggle_row( shell.render, "Do Not Disturb", n.dnd, Action::Dnd(!n.dnd), theme, p, ); rows = rows.push(if combined { control_card(shell.render, toggle, primary, p) } else { toggle }); if !combined { rows = rows .push( shell .render .text(if n.count == 0 { "No notifications".to_owned() } else if n.count == 1 { "1 notification".to_owned() } else { format!("{} notifications", n.count) }) .size(13) .class(theme::Text::Color(muted)), ) .push(menu_button( shell.render, "Open notification center", Message::Control(Action::Notifications), primary, p, )); } } rows } #[cfg(test)] mod tests { use super::*; #[test] fn activity_popups_scroll_to_the_last_row_in_regular_panels() { use cosmic::iced::advanced::{ Layout, Shell, clipboard, layout, renderer::Headless, widget, }; use cosmic::iced::{Event, Rectangle, Size, Vector, mouse}; use ferese_activities::{Data, Owner, Source, Store, Template, Value}; #[derive(Default)] struct ScrollBounds(Option<(Rectangle, Rectangle, Vector)>); impl widget::Operation for ScrollBounds { fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { visit(self); } fn scrollable( &mut self, _: Option<&widget::Id>, bounds: Rectangle, content: Rectangle, translation: Vector, _: &mut dyn widget::operation::Scrollable, ) { self.0 = Some((bounds, content, translation)); } } let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( cosmic::iced::Font::default(), cosmic::iced::Pixels(14.), Some("tiny-skia"), )) .unwrap(); for style in [ ferese_panel::PanelStyle::Flat, ferese_panel::PanelStyle::Floating, ferese_panel::PanelStyle::Adaptive, ] { let mut app = crate::tests::shell_with_measured_panel(); let bar = app.outputs[0].bar; app.config.panels[0].style = style; app.snapshot.outputs = vec![crate::control::OutputSnapshot { id: 1, name: "test".into(), active_workspace: 1, focused: true, }]; app.outputs[0] .runtime .reset(&app.config.panels[0], &app.snapshot, Some("test")); let mut store = Store::new(1); for n in 0..24 { store .start( Owner { pid: n + 1, birth: 1, }, n.to_string(), Data { source: Source::Publisher, output: Some("test".into()), priority: 100, icon: "hammer".into(), title: format!("Task {n}"), value: Value::Indeterminate, template: Template::Progress, }, 0, 0, None, true, true, None, ) .unwrap(); } app.activities.snapshot = std::sync::Arc::new(ferese_ipc::activities::Snapshot { activities: store.entries().cloned().collect(), ..Default::default() }); app.sync_activity_presentations(); drop(app.open_menu( Menu::Activities, PopoverAnchor { parent: bar, panel: app.config.panels[0].id.clone(), item: None, rectangle: Rectangle { x: 0, y: 0, width: 100, height: 28, }, }, )); let mut view = app.view_status_menu_at(Instant::now() + Duration::from_secs(1)); let mut tree = widget::Tree::new(&view); let node = view.as_widget_mut().layout( &mut tree, &renderer, &layout::Limits::new(Size::ZERO, Size::new(300., 500.)), ); let mut before = ScrollBounds::default(); view.as_widget_mut() .operate(&mut tree, Layout::new(&node), &renderer, &mut before); let (viewport, content, _) = before.0.expect("activity popup must be scrollable"); assert!(content.height > viewport.height); let mut messages = Vec::new(); view.as_widget_mut().update( &mut tree, &Event::Mouse(mouse::Event::WheelScrolled { delta: mouse::ScrollDelta::Lines { x: 0., y: -1000. }, }), Layout::new(&node), mouse::Cursor::Available(viewport.center()), &renderer, &mut clipboard::Null, &mut Shell::new(&mut messages), &node.bounds(), ); let mut after = ScrollBounds::default(); view.as_widget_mut() .operate(&mut tree, Layout::new(&node), &renderer, &mut after); let (viewport, content, translation) = after.0.unwrap(); assert!(translation.y > 0., "{style:?} did not scroll"); assert!( (translation.y - (content.height - viewport.height)).abs() < 1., "last row must be reachable" ); } } #[test] fn failed_controls_only_show_errors_in_related_popups() { for (action, relevant) in [ (Action::Bluetooth(true), Menu::Bluetooth), (Action::Wifi(true), Menu::Network), (Action::Volume(50), Menu::Audio), (Action::Mute(true), Menu::Audio), (Action::Brightness(50), Menu::Battery), (Action::PowerProfile("balanced"), Menu::Battery), (Action::Dnd(true), Menu::Notifications), ] { let error = status::ActionError { action, message: "control failed".into(), }; for menu in [ Menu::Network, Menu::Bluetooth, Menu::Audio, Menu::Battery, Menu::Calendar, Menu::Recording, Menu::Notifications, Menu::System, ] { assert_eq!( menu.action_error(&error), (menu == relevant || menu == Menu::System).then_some("control failed"), "{menu:?} showed the wrong control error: {:?}", error.action ); } } } #[test] fn smaller_popups_have_content_specific_widths() { assert_eq!(Menu::System.width(), 360.0); assert_eq!(Menu::Network.width(), 300.0); assert_eq!(Menu::Bluetooth.width(), 300.0); assert_eq!(Menu::Battery.width(), 328.0); assert_eq!(Menu::Calendar.width(), 268.0); assert_eq!(Menu::Calendar.height_limit(), 270.0); assert_eq!(Menu::Audio.width(), 300.0); assert_eq!(Menu::Notifications.width(), 368.0); } #[test] fn completed_open_stops_requesting_animation_ticks() { let past = Instant::now() - Duration::from_secs(1); let mut motion = super::motion::PopupMotion::new(Default::default()); motion.begin(past); let menu = OpenMenu { id: window::Id::unique(), anchor: PopoverAnchor { parent: window::Id::unique(), panel: crate::panel::PanelId("main".into()), item: None, rectangle: Default::default(), }, kind: Menu::Network, motion, effects: None, regions: Default::default(), }; assert_eq!(menu.progress(), 1.0); assert!(!menu.animating()); } #[test] fn services_are_hidden_until_available() { let s = Snapshot::default(); assert!(!Menu::Network.available(&s)); assert!(!Menu::Notifications.available(&s)); assert!(Menu::System.available(&s)); } }