use std::time::Instant; use cosmic::app::Task; use cosmic::iced::platform_specific::runtime::wayland::popup::{SctkPopupSettings, SctkPositioner}; use cosmic::iced::{Limits, Rectangle, window}; use wayland_client::Proxy; use super::{Menu, OpenMenu, PopoverAnchor}; use crate::status::Action; use crate::{FereseShell, Message}; fn menu_positioner( rectangle: Rectangle, height_limit: f32, edge: ferese_panel::Edge, ) -> SctkPositioner { // xdg-positioner: bottom/bottom-left for top bars, top/top-left for bottom bars. let (anchor, gravity, gap) = match edge { ferese_panel::Edge::Top => (2u32, 6u32, 8), ferese_panel::Edge::Bottom => (1u32, 5u32, -8), }; SctkPositioner { anchor_rect: rectangle, anchor: anchor .try_into() .expect("popup placement uses supported anchor and gravity combinations"), gravity: gravity .try_into() .expect("popup placement uses supported anchor and gravity combinations"), offset: (rectangle.width / 2, gap), size_limits: Limits::NONE .max_width(368.0) .max_height(720.0_f32.max(height_limit)), constraint_adjustment: 3, ..Default::default() } } impl OpenMenu { fn switch_panel(&mut self, kind: Menu, settings: crate::motion::Settings, now: Instant) { self.kind = kind; // Keep the Wayland popup and its material binding, but give the new // panel its own entrance instead of reusing the settled old panel. self.motion = crate::motion::PopupMotion::new(settings); self.motion.begin(now); } } impl FereseShell { pub fn open_menu(&mut self, kind: Menu, anchor: PopoverAnchor) -> Task { if (self.snapshot.binding_mode.is_some() && anchor.item.is_some()) || !anchor.belongs_to(&self.config.panels[0]) || !self .outputs .iter() .any(|output| output.bar == anchor.parent) || self.bar_hidden(anchor.parent) || !kind.available(&self.status) { return Task::none(); } if self.panel_for_bar(anchor.parent).style == ferese_panel::PanelStyle::Morph { return self.open_hub(anchor.parent, kind); } if self .menu .as_ref() .is_some_and(|menu| menu.anchor.parent != anchor.parent) { let close = self.destroy_menu(); let open = self.open_menu(kind, anchor); return close.chain(open); } if self.menu.as_ref().is_some_and(|menu| { menu.kind == kind && menu.anchor.same_item(&anchor) && menu.anchor.rectangle == anchor.rectangle }) { if let Some(menu) = &mut self.menu && menu.motion.closing() { if kind == Menu::Media { self.media.now_us = ferese_ipc::media::now_us(); self.media.refresh_art(true); } menu.motion.retarget(1.0, Instant::now()); return Task::none(); } return self.close_menu(); } if kind == Menu::Media { self.media.now_us = ferese_ipc::media::now_us(); self.media.refresh_art(true); self.media.error = None; } if kind != Menu::Media { self.refresh_media_art(false); } if kind == Menu::Calendar { self.calendar_offset = 0; } if !kind.available(&self.status) { return Task::none(); } if self .menu .as_ref() .is_some_and(|menu| menu.kind == Menu::Notifications) { self.notifications.history_open = false; self.notifications.hovered = None; } if kind == Menu::Notifications && self.notifications.ready { self.notifications.toggle_history(); self.sync_activity_presentations(); } let notifications = self.sync_notification_surface(); crate::EFFECT_FRAME_PENDING.store(true, std::sync::atomic::Ordering::Relaxed); self.status_error = None; let parent = anchor.parent; let rectangle = anchor.rectangle; let height_limit = if kind == Menu::Notifications { self.notification_history_height_limit() } else { kind.height_limit() }; let positioner = menu_positioner(rectangle, height_limit, self.config.panels[0].edge); if let Some(menu) = &mut self.menu { menu.anchor = anchor; menu.switch_panel(kind, self.config.animations, Instant::now()); return Task::batch([ notifications, cosmic::iced::platform_specific::shell::commands::popup::reposition( menu.id, positioner, ), ]); } let id = window::Id::unique(); self.menu = Some(OpenMenu { id, anchor, kind, motion: crate::motion::PopupMotion::new(self.config.animations), effects: None, regions: Default::default(), }); let action = cosmic::surface::action::app_popup::( |_| Default::default(), move |_| SctkPopupSettings { id, parent, parent_size: None, grab: true, close_with_children: false, input_zone: None, positioner: positioner.clone(), }, Some(Box::new(Self::view_status_menu)), ); notifications.chain(cosmic::task::message(cosmic::Action::Surface(action))) } pub fn destroy_menu(&mut self) -> Task { self.night_light_temperature = None; let tooltip = self.dismiss_workspace_tooltip(); crate::EFFECT_FRAME_PENDING.store(false, std::sync::atomic::Ordering::Relaxed); let Some(mut menu) = self.menu.take() else { return tooltip; }; // Keep opacity applied until Iced has destroyed the popup's Wayland surface. if let Some(effects) = menu.effects.take() { self.closing_menu_effects.insert(menu.id, effects); } self.refresh_media_art(false); if menu.kind == Menu::Notifications { self.notifications.history_open = false; self.notifications.hovered = None; } Task::batch([ tooltip, cosmic::task::message(cosmic::Action::Surface( cosmic::surface::action::destroy_popup(menu.id), )), self.sync_notification_surface(), ]) } pub fn close_menu(&mut self) -> Task { self.night_light_temperature = None; self.refresh_media_art(false); let Some(menu) = &mut self.menu else { return Task::none(); }; if menu.motion.closing() { return Task::none(); } if menu .effects .as_ref() .is_some_and(|effects| effects.surface.version() >= 3) { menu.motion.retarget(0.0, Instant::now()); if menu.animating() { return Task::none(); } } self.destroy_menu() } pub fn animate_menu(&mut self) -> Task { if let Some(menu) = &self.menu && menu.motion.closing() && !menu.animating() { return self.destroy_menu(); } Task::none() } pub fn optimistic_status(&mut self, action: &Action) { match action { Action::Volume(v) => { if let Some(a) = &mut self.status.audio { a.volume = *v; } } Action::Mute(v) => { if let Some(a) = &mut self.status.audio { a.muted = *v; } } Action::Brightness(v) => self.status.brightness = Some(*v), Action::NightLight(enabled) => { if let Some(status) = &mut self.status.night_light { status.enabled = *enabled; } } Action::NightLightTemperature(temperature) => { if let Some(status) = &mut self.status.night_light { status.temperature = *temperature; } } Action::Wifi(v) => { if let Some(n) = &mut self.status.network { n.enabled = *v; } } Action::Bluetooth(v) => { if let Some(b) = &mut self.status.bluetooth { b.enabled = *v; } } Action::Dnd(v) => { if let Some(n) = &mut self.status.notifications { n.dnd = *v; } } _ => {} } } } #[cfg(test)] mod tests { use super::*; use crate::motion::{PopupMotion, Settings}; use std::time::Duration; fn test_anchor() -> PopoverAnchor { PopoverAnchor { parent: window::Id::unique(), panel: crate::panel::PanelId("main".into()), item: Some(crate::panel::ItemId("battery".into())), rectangle: Rectangle::default(), } } #[test] fn clicking_the_same_control_at_new_bounds_repositions_instead_of_toggling() { let mut shell = crate::tests::shell_with_measured_panel(); shell.status.audio = Some(crate::status::Audio { volume: 50, muted: false, output: String::new(), }); let bar = shell.outputs[0].bar; let mut anchor = PopoverAnchor { parent: bar, panel: shell.config.panels[0].id.clone(), item: Some(crate::panel::ItemId("audio".into())), rectangle: Rectangle { x: 1000, y: 0, width: 60, height: 36, }, }; drop(shell.open_menu(Menu::Audio, anchor.clone())); let popup = shell.menu.as_ref().unwrap().id; anchor.rectangle.x = 800; drop(shell.open_menu(Menu::Audio, anchor.clone())); let menu = shell.menu.as_ref().unwrap(); assert_eq!(menu.id, popup); assert_eq!(menu.anchor, anchor); assert!(!menu.motion.closing()); drop(shell.open_menu(Menu::Audio, anchor)); assert!(shell.menu.is_none()); } #[test] fn menus_open_toward_the_desktop_from_either_panel_edge() { use ferese_panel::Edge; let rectangle = Rectangle { x: 400, y: 0, width: 32, height: 30, }; for (edge, anchor, gravity, gap) in [(Edge::Top, 2u32, 6u32, 8), (Edge::Bottom, 1u32, 5u32, -8)] { let positioner = menu_positioner(rectangle, 620., edge); assert_eq!(positioner.anchor_rect, rectangle); assert_eq!(positioner.anchor, anchor.try_into().unwrap()); assert_eq!(positioner.gravity, gravity.try_into().unwrap()); assert_eq!(positioner.offset, (16, gap)); } } #[test] fn popup_identity_includes_parent_panel_and_instance_but_not_rectangle() { let first = test_anchor(); let mut second = first.clone(); second.rectangle.x = 100; assert!(first.same_item(&second)); second.item = Some(crate::panel::ItemId("other-battery".into())); assert!(!first.same_item(&second)); second = first.clone(); second.parent = window::Id::unique(); assert!(!first.same_item(&second)); second = first.clone(); second.panel = crate::panel::PanelId("other".into()); assert!(!first.same_item(&second)); } #[test] fn switching_panels_replays_entrance_on_the_same_surface() { let now = Instant::now(); let settings = Settings::default(); let mut menu = OpenMenu { id: window::Id::unique(), anchor: test_anchor(), kind: Menu::Battery, motion: PopupMotion::new(settings), effects: None, regions: Default::default(), }; let id = menu.id; let regions = menu.regions.clone(); menu.motion.begin(now); for (index, kind) in [ Menu::Network, Menu::Bluetooth, Menu::Audio, Menu::Calendar, Menu::Recording, Menu::Notifications, Menu::System, Menu::Battery, ] .into_iter() .enumerate() { let at = now + Duration::from_secs(10 * (index as u64 + 1)); assert_eq!(menu.motion.progress_at(at), 1.0); menu.switch_panel(kind, settings, at); assert_eq!(menu.kind, kind); assert_eq!(menu.id, id); assert!(std::sync::Arc::ptr_eq(®ions, &menu.regions)); assert_eq!(menu.motion.progress_at(at), 0.0); assert!(menu.motion.frame_active(at)); let progress = menu.motion.progress_at(at + Duration::from_millis(30)); assert!(progress > 0.0 && progress < 1.0); } let at = now + Duration::from_secs(90); menu.motion.retarget(0.0, at); menu.switch_panel(Menu::Network, settings, at + Duration::from_millis(30)); assert!(!menu.motion.closing()); assert!(menu.motion.frame_active(at + Duration::from_millis(30))); } #[test] fn panel_switch_respects_reduced_motion_and_speed() { let now = Instant::now(); let mut menu = OpenMenu { id: window::Id::unique(), anchor: test_anchor(), kind: Menu::Battery, motion: PopupMotion::new(Settings::default()), effects: None, regions: Default::default(), }; for settings in [ Settings { reduced_motion: true, ..Default::default() }, Settings { enabled: false, ..Default::default() }, ] { menu.switch_panel(Menu::Network, settings, now); assert_eq!(menu.motion.progress_at(now), 1.0); assert!(!menu.motion.frame_active(now)); } menu.switch_panel(Menu::Battery, Settings::default(), now); let normal = menu.motion.progress_at(now + Duration::from_millis(30)); menu.switch_panel( Menu::Network, Settings { speed: 0.5, ..Default::default() }, now, ); assert_eq!( menu.motion.progress_at(now + Duration::from_millis(60)), normal ); } #[test] fn popup_anchors_reject_removed_hidden_items_and_other_panel_definitions() { let mut panel = crate::panel::Panel::default(); let anchor = PopoverAnchor { parent: window::Id::unique(), panel: panel.id.clone(), item: Some(crate::panel::ItemId("audio".into())), rectangle: Rectangle { x: 0, y: 0, width: 20, height: 20, }, }; assert!(anchor.belongs_to(&panel)); let items = &mut panel.end.groups[0].items; let network = items.iter().position(|item| item.id.0 == "audio").unwrap(); items[network].visible = false; assert!(!anchor.belongs_to(&panel)); panel.end.groups[0].items.remove(network); assert!(!anchor.belongs_to(&panel)); panel.id = crate::panel::PanelId("other".into()); let fallback = PopoverAnchor { item: None, ..anchor.clone() }; assert!(!fallback.belongs_to(&panel)); let fallback = PopoverAnchor { panel: panel.id.clone(), ..fallback }; assert!(fallback.belongs_to(&panel)); } }