use std::cell::RefCell; use std::time::Instant; use crate::KeyboardInteractivity; use cosmic::app::Task; use cosmic::iced::platform_specific::shell::commands::layer_surface::{ set_keyboard_interactivity, set_size, }; use cosmic::iced::{Alignment, Length, window}; use cosmic::widget::{button, column, container, row, scrollable}; mod activity; mod battery; mod content_frame; mod equalizer; mod scroll; mod transition; use transition::{Event, Transient}; mod workspace_grid; mod workspaces; use cosmic::{Element, theme}; use ferese_panel::morph::{Content, Hub, Mode, Sample}; use ferese_panel::{Edge, ItemKind, Panel, PanelStyle}; use ferese_theme::icons; use crate::{FereseShell, Message, color_with_opacity, motion, status_ui}; /// Morph owns its inset; regular-panel side margins and padding stay saved for /// the other styles without reducing the hub's output budget. fn available_size(panel: &Panel, size: Option<(i32, i32)>) -> [f64; 2] { let (width, height) = size.unwrap_or((1280, 720)); [ f64::from(width).max(24.), (f64::from(height) - f64::from(panel.resolved_geometry().edge_margin)).max(24.), ] } pub(super) fn content_width(panel: &Panel, size: Option<(i32, i32)>) -> f64 { (available_size(panel, size)[0] - if panel.morph.attached { 24. } else { 0. }).max(24.) } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Peek { Recording, Capture, Battery, Media, Date, Workspaces, } struct ControlAction { icon: &'static [u8], label: &'static str, message: Option, menu: Option, } #[derive(Clone, Copy)] struct PreviousContent { menu: Option, peek: Peek, toast: Option, size: [f64; 2], } pub(super) struct Runtime { pub(super) hub: Hub, pub(super) menu: Option, previous: PreviousContent, transient: Transient, pub(super) allocation: [u32; 2], minimum_allocation: [u32; 2], fillet_space: u32, pub(super) revision: Instant, sampled: Instant, motion: motion::Settings, last_menu: status_ui::Menu, priority: Option, peek: Peek, observed_workspace: Option, workspace_hover: Option, workspace_selected: Option, workspace_selection_pending: Option, hovered: bool, pub(crate) idle_content_width: Option, available: [f64; 2], peek_width: f64, idle_width: f64, workspace_width: f64, workspace_scroll: workspaces::Scroll, pub(super) activities: crate::activities::Presentation, } impl Runtime { pub(super) fn new(panel: &Panel) -> Self { let mut hub = Hub::try_new(panel.morph, panel.resolved_geometry().height.into()) .expect("validated panel hub"); hub.pin_radii(panel.corner_radius.map(|r| r.0.map(f64::from))) .expect("validated panel radii"); hub.advance(std::time::Duration::ZERO, true); let now = Instant::now(); // Keep the native surface stable through hover, including spring overshoot. let fillet_space = if panel.morph.attached { 24 } else { 0 }; let minimum_allocation = [ (panel.morph.peek_width.max(panel.morph.idle_width).max(200.) * 1.1).ceil() as u32 + fillet_space, (panel .morph .peek_height .max(f64::from(panel.resolved_geometry().height)) * 1.1) .ceil() as u32, ]; Self { allocation: minimum_allocation, minimum_allocation, fillet_space, hub, menu: None, previous: PreviousContent { menu: None, peek: Peek::Date, toast: None, size: [ panel.morph.idle_width, f64::from(panel.resolved_geometry().height), ], }, transient: Transient::None, revision: now, sampled: now, motion: Default::default(), last_menu: status_ui::Menu::Workspaces, priority: None, peek: Peek::Date, observed_workspace: None, workspace_hover: None, workspace_selected: None, workspace_selection_pending: None, hovered: false, idle_content_width: None, available: [1280., 720.], peek_width: 200., idle_width: panel.morph.idle_width, workspace_width: 0., workspace_scroll: Default::default(), activities: Default::default(), } } fn sample(&mut self, now: Instant) -> Sample { let policy = self.motion; self.activities.advance( now.saturating_duration_since(self.sampled), !policy.motion_enabled(), ); self.hub.advance( now.saturating_duration_since(self.sampled) .mul_f64(policy.speed), !policy.motion_enabled(), ); self.sampled = self.sampled.max(now); self.hub.sample() } pub(crate) fn refresh_activity_size(&mut self) { if self.activities.selection.visible.is_empty() { return; } if self.hub.mode() == Mode::Idle { let width = self .idle_content_width .map_or(self.idle_width + self.workspace_width, |width| { width + self.workspace_width }) .clamp(24., 1024.); let _ = self.hub.retarget_idle(width, self.available); } else if self.hub.mode() == Mode::Peek && self.peek != Peek::Workspaces && self.priority.is_none() && let Some(width) = self.idle_content_width { self.peek_width = width + self.workspace_width; let _ = self .hub .retarget_peek(self.peek_width.clamp(24., 1024.), self.available); } } fn retarget(&mut self, mode: Mode, available: [f64; 2], menu: Option) { let now = Instant::now(); let shape_available = [ (available[0] - f64::from(self.fillet_space)).max(24.), available[1], ]; self.available = shape_available; self.sample(now); self.menu = menu; if mode == Mode::Idle { self.hub.retarget_idle( self.idle_content_width .map(|width| { if self.activities.selection.visible.is_empty() { width } else { width + self.workspace_width } }) .unwrap_or(self.idle_width + self.workspace_width) .clamp(24., 1024.), shape_available, ) } else if mode == Mode::Peek { self.hub .retarget_peek(self.peek_width.min(1024.), shape_available) } else { self.hub.retarget(mode, shape_available) } .expect("bounded hub area"); let mut target = self.hub.allocation_size().map(|n| n.ceil() as u32); target[0] = target[0] .saturating_add(self.fillet_space) .min(available[0].ceil() as u32); // Retain the backing surface between opens; resizing after settlement moves its origin. self.minimum_allocation = std::array::from_fn(|i| self.minimum_allocation[i].min(available[i].ceil() as u32)); self.allocation = std::array::from_fn(|i| { self.allocation[i] .max(target[i]) .max(self.minimum_allocation[i]) .min(available[i].ceil() as u32) }); self.revision = now; } } pub(super) fn new(panel: &Panel) -> Option> { (panel.style == PanelStyle::Morph).then(|| RefCell::new(Runtime::new(panel))) } fn content(kind: status_ui::Menu) -> Content { match kind { status_ui::Menu::Activities => Content::Activities, status_ui::Menu::Workspaces => Content::Workspaces, status_ui::Menu::Media => Content::Media, status_ui::Menu::Calendar => Content::Calendar, status_ui::Menu::Notifications => Content::Notifications, _ => Content::Controls, } } impl FereseShell { pub(super) fn hub_mode(&self, id: window::Id) -> Option { self.outputs .iter() .find(|entry| entry.bar == id)? .runtime .hub .as_ref() .map(|hub| hub.borrow().hub.mode()) } pub(super) fn hub_hover(&mut self, id: window::Id, entered: bool) -> Task { if self.bar_hidden(id) { return Task::none(); } if let Some(runtime) = self .outputs .iter() .find(|entry| entry.bar == id) .and_then(|entry| entry.runtime.hub.as_ref()) { let mut runtime = runtime.borrow_mut(); if entered { runtime.activities.selection.enter(); } else { runtime.activities.selection.leave(); } } self.sync_activity_presentations(); self.transition_hub( id, Event::Hover { entered, enabled: self.panel_for_bar(id).morph.peek_on_hover, peek: self.hub_peek_content(id), priority: self.hub_priority_for(id), }, ) } pub(super) fn open_hub(&mut self, id: window::Id, kind: status_ui::Menu) -> Task { if self.snapshot.binding_mode.is_some() || self.bar_hidden(id) || self.hub_mode(id).is_none() { return Task::none(); } if kind == status_ui::Menu::Notifications { for notice in &mut self.notifications.entries { notice.unread = false; } if let Some(status) = &mut self.status.notifications { status.count = 0; } self.sync_activity_presentations(); } if kind == status_ui::Menu::Calendar { self.calendar_offset = 0; } if kind == status_ui::Menu::Media { self.media.now_us = ferese_ipc::media::now_us(); self.media.refresh_art(true); self.media.error = None; } let close = self.destroy_menu(); let open = self.transition_hub(id, Event::Open(kind)); Task::batch([close, open]) } pub(super) fn suspend_hub(&mut self, id: window::Id) -> Task { let close = self.close_hub(id); let Some(hub) = self .outputs .iter() .find(|entry| entry.bar == id) .and_then(|entry| entry.runtime.hub.as_ref()) else { return close; }; let mut hub = hub.borrow_mut(); hub.hub.advance(std::time::Duration::ZERO, true); hub.allocation = hub.minimum_allocation; Task::batch([ close, set_size(id, Some(hub.allocation[0]), Some(hub.allocation[1])), ]) } pub(super) fn hub_notice(&mut self, notice: u32) -> Task { let Some(id) = self.active_bar() else { return Task::none(); }; if self.snapshot.binding_mode.is_some() || self.bar_hidden(id) || self.hub_priority_for(id).is_some() || !matches!( self.hub_mode(id), Some(Mode::Idle | Mode::Peek | Mode::Toast) ) || !self .notifications .popup_groups() .iter() .any(|(entry, _, _)| entry.id == notice) { return Task::none(); } let revision = Instant::now(); let open = self.transition_hub( id, Event::Toast { notice, token: revision, }, ); let timeout = std::time::Duration::from_millis(u64::from(self.config.notifications.timeout_ms)); Task::batch([ open, Task::perform( async move { tokio::time::sleep(timeout).await; }, move |_| cosmic::Action::App(Message::HubToastExpired(id, revision)), ), ]) } pub(super) fn sync_hub_notices(&mut self) -> Task { let closing: Vec<_> = self .outputs .iter() .filter_map(|entry| { let hub = entry.runtime.hub.as_ref()?.borrow(); (hub.hub.mode() == Mode::Toast && hub.transient.toast().is_some_and(|id| { !self .notifications .entries .iter() .any(|notice| notice.id == id && notice.live) })) .then_some(entry.bar) }) .collect(); Task::batch(closing.into_iter().map(|id| self.close_hub(id))) } pub(super) fn hub_toast_expired(&mut self, id: window::Id, revision: Instant) -> Task { self.transition_hub(id, Event::ToastExpired(revision)) } pub(super) fn close_hub(&mut self, id: window::Id) -> Task { self.night_light_temperature = None; self.transition_hub(id, Event::Close) } #[cfg(test)] fn set_hub_mode( &mut self, id: window::Id, mode: Mode, menu: Option, ) -> Task { self.transition_hub(id, Event::Present(mode, menu)) } pub(super) fn resize_hub(&mut self, id: window::Id) -> Task { self.transition_hub(id, Event::Resize) } fn transition_hub(&mut self, id: window::Id, event: Event) -> Task { if self.snapshot.binding_mode.is_some() && !matches!(event, Event::Close | Event::Resize) { return Task::none(); } let workspace_width = self.hub_workspace_width(id); let panel = self.panel_for_bar(id); let Some(entry) = self.outputs.iter().find(|entry| entry.bar == id) else { return Task::none(); }; let Some(hub) = &entry.runtime.hub else { return Task::none(); }; let available = available_size(panel, entry.size); let (resize, mode) = { let mut hub = hub.borrow_mut(); hub.motion = self.render.motion; let before = hub.allocation; hub.workspace_width = if workspace_width > 0. { workspace_width + 10. } else { 0. }; if !hub.transition(event, available) { return Task::none(); } ( (before != hub.allocation).then_some(hub.allocation), hub.hub.mode(), ) }; Task::batch([ resize.map_or_else(Task::none, |size| { set_size(id, Some(size[0]), Some(size[1])) }), set_keyboard_interactivity( id, if mode.expanded() { KeyboardInteractivity::OnDemand } else { KeyboardInteractivity::None }, ), self.dismiss_workspace_tooltip_for_bar(id), ]) } fn hub_idle_width_limit(&self, id: window::Id) -> f32 { let runtime = self .outputs .iter() .find(|entry| entry.bar == id) .and_then(|entry| entry.runtime.hub.as_ref()) .expect("Morph runtime"); let runtime = runtime.borrow(); if !runtime.activities.selection.visible.is_empty() && self.compact_activities_configured(id) { return runtime.hub.target_size()[0].min(runtime.available[0]) as f32; } let artwork_width = if self.hub_priority_for(id).is_some() { 64. } else if self .media .snapshot .selected .as_ref() .is_some_and(|player| player.status == ferese_ipc::media::Playback::Playing) { 32. } else { 0. }; (runtime.idle_width + artwork_width + runtime.workspace_width) .min(runtime.available[0]) .min(f64::from(runtime.allocation[0].saturating_sub(runtime.fillet_space)) / 1.1) as f32 } pub(super) fn view_hub(&self, id: window::Id) -> Element<'_, cosmic::Action> { // The toolkit may request one last frame while output destruction is queued. let Some(entry) = self.outputs.iter().find(|entry| entry.bar == id) else { return cosmic::widget::Space::new().into(); }; let runtime = entry.runtime.hub.as_ref().expect("Morph runtime"); runtime.borrow_mut().motion = self.render.motion; let revision = runtime.borrow().revision; let mode = runtime.borrow().hub.mode(); let workspace_feedback = mode == Mode::Peek && runtime.borrow().peek == Peek::Workspaces; let measurement = if (mode == Mode::Idle && !self.has_compact_activities(id)) || workspace_feedback { let cap = if workspace_feedback { let runtime = runtime.borrow(); 260_f64.min(runtime.available[0]).min( f64::from(runtime.allocation[0].saturating_sub(runtime.fillet_space)) / 1.1, ) as f32 } else { self.hub_idle_width_limit(id) }; Some(motion::ContentMeasurement { content: self.hub_content(id, mode, 1., None, false, true), width: cap, measured: Box::new(move |size| { let mut runtime = runtime.borrow_mut(); let width = f64::from(size.width).ceil().clamp(24., 1024.); let available = runtime.available; if workspace_feedback { runtime.peek_width = width; runtime .hub .retarget_peek(width, available) .expect("measured workspace feedback"); } else { runtime.idle_content_width = Some(width); runtime .hub .retarget_idle(width, available) .expect("measured idle content"); } }), }) } else { (runtime.borrow().hub.mode() == Mode::Expanded(Content::Controls)).then(|| { let menu = runtime.borrow().menu.unwrap_or(status_ui::Menu::Overflow); let content = if menu == status_ui::Menu::Overflow { self.hub_controls(id, 1.) } else { self.view_hub_menu(menu, 1.) }; let width = runtime.borrow().hub.target_size()[0] as f32; motion::ContentMeasurement { content, width: (width - 28.).max(1.), measured: Box::new(move |size| { runtime .borrow_mut() .hub .fit_content_height(f64::from(size.height).ceil() + 56.) .expect("measured hub content"); }), } }) }; motion::geometry_driven( Some(revision), move |now| { let (sample, menu, previous_menu) = { let mut runtime = runtime.borrow_mut(); (runtime.sample(now), runtime.menu, runtime.previous.menu) }; self.view_hub_at(id, sample, menu, previous_menu) }, move |_| { let runtime = runtime.borrow(); runtime.hub.active() || runtime.activities.active() }, |_| {}, |_| None, measurement, ) } fn view_hub_at( &self, id: window::Id, sample: Sample, menu: Option, previous_menu: Option, ) -> Element<'_, cosmic::Action> { let Some(entry) = self.outputs.iter().find(|entry| entry.bar == id) else { return cosmic::widget::Space::new().into(); }; let panel = self.panel_for_bar(id); let effects = entry.effects.as_ref(); let bounds = sample.geometry.bounds; let grid = ferese_panel::shape::PixelGrid::try_new(entry.scale) .expect("panel output scale is positive and finite"); let (target_size, previous_size) = { let runtime = entry.runtime.hub.as_ref().expect("Morph runtime").borrow(); (runtime.hub.target_size(), runtime.previous.size) }; let mut layers = cosmic::iced::widget::Stack::new(); if sample.previous_opacity > 0. { layers = layers.push(content_frame::view( self.hub_content( id, sample.previous, sample.previous_opacity, previous_menu, false, false, ), previous_size, panel.edge, )); } if sample.opacity > 0. { layers = layers.push(content_frame::view( container(self.hub_content(id, sample.current, sample.opacity, menu, true, false)) .id("ferese-hub-content") .width(Length::Fill) .height(Length::Fill) .into(), target_size, panel.edge, )); } let body = container(layers) .id("ferese-panel-hub") // Snap dimensions before centering. Snapping the two edges of an // unsnapped hub can alternate its width by a pixel on odd surfaces. .width(grid.snap(bounds.width) as f32) .height(grid.snap(bounds.height) as f32) .clip(true); let root = container(body) .center_x(Length::Fill) .height(Length::Fill) .align_y(if panel.edge == Edge::Bottom { cosmic::iced::alignment::Vertical::Bottom } else { cosmic::iced::alignment::Vertical::Top }); let palette = self.config.theme.for_bar(); let mut background = palette.surface_raised.map(|value| f32::from(value) / 255.); background[3] = palette.panel_opacity(panel.background_opacity); let background = cosmic::iced::Color::from_rgba( background[0], background[1], background[2], background[3], ); let mut border = crate::color(palette.border); if let Some((tint, alpha)) = self.activity_result_tint(id) { border.r += (tint.r - border.r) * alpha; border.g += (tint.g - border.g) * alpha; border.b += (tint.b - border.b) * alpha; } ferese_theme::panel_surface::frame( self.with_workspace_hover(id, root.into()), ferese_theme::panel_surface::Options { parameters: ferese_panel::scene::Parameters { radius: Some(ferese_panel::shape::AnchoredRadii( sample.geometry.radii.map(|r| r as f32), )), ..panel.shape_parameters() }, bounds: Some(cosmic::iced::advanced::widget::Id::new("ferese-panel-hub")), groups: Vec::new(), scale: entry.scale, high_contrast: palette.high_contrast, background, border: if panel.border { border } else { cosmic::iced::Color::TRANSPARENT }, divider: cosmic::iced::Color::TRANSPARENT, native: effects.is_some(), }, move |scene| { if let Some(effects) = effects && let Err(error) = effects.set_panel_presentation(scene) { eprintln!("ferese-shell: could not update hub material: {error}"); } }, Some(Box::new(move |scene| { cosmic::Action::App(Message::PanelSurfaceChanged(id, Box::new(scene.clone()))) })), ) } fn hub_content( &self, id: window::Id, mode: Mode, opacity: f32, menu: Option, interactive: bool, intrinsic: bool, ) -> Element<'_, cosmic::Action> { let palette = self.config.theme; let primary = color_with_opacity(palette.text_primary, opacity); let icon_primary = crate::color(palette.text_primary); let icon_muted = crate::color(palette.text_muted); let peek = self .outputs .iter() .find(|entry| entry.bar == id) .and_then(|entry| entry.runtime.hub.as_ref()) .map_or(Peek::Date, |runtime| { let runtime = runtime.borrow(); if !interactive && !intrinsic { runtime.previous.peek } else { runtime.peek } }); let content: Element<'_, cosmic::Action> = match mode { Mode::Idle | Mode::Peek if self.has_compact_activities(id) && self.hub_priority_for(id).is_none() && !(mode == Mode::Peek && peek == Peek::Workspaces) => { self.activity_compact(id, opacity, intrinsic) } Mode::Idle | Mode::Peek | Mode::Toast => { let playing = self .media .snapshot .selected .as_ref() .filter(|player| player.status == ferese_ipc::media::Playback::Playing); let time = self .clock .split_once(", ") .map_or(self.clock.as_str(), |(_, time)| time); let priority = self.hub_priority_for(id); let activity = if mode == Mode::Idle { priority } else if mode == Mode::Peek && matches!(peek, Peek::Recording | Peek::Capture | Peek::Battery) { Some(peek) } else { None }; let label = if mode == Mode::Toast { self.outputs .iter() .find(|entry| entry.bar == id) .and_then(|entry| entry.runtime.hub.as_ref()) .and_then(|hub| { let hub = hub.borrow(); if !interactive && !intrinsic { hub.previous.toast } else { hub.transient.toast() } }) .and_then(|id| { self.notifications .entries .iter() .find(|notice| notice.id == id) }) .map_or_else(|| time.to_owned(), |notice| notice.title.clone()) } else if mode == Mode::Peek { match peek { Peek::Recording | Peek::Capture | Peek::Battery => String::new(), Peek::Media => self.media.snapshot.selected.as_ref().map_or_else( || "Playback ended".to_owned(), |player| player.label().to_owned(), ), Peek::Date => self.clock.clone(), Peek::Workspaces => self .output_for_bar(id) .and_then(|output| { self.snapshot .workspaces .iter() .find(|w| w.id == output.active_workspace) }) .map_or_else( || "Workspaces".to_owned(), |workspace| { if workspace.name == workspace.index.to_string() { format!("Workspace {}", workspace.index) } else { format!("{} ยท {}", workspace.index, workspace.name) } }, ), } } else if let Some(player) = playing { player.label().to_owned() } else { let title = crate::bar::focused_bar_title(&self.snapshot, self.output_for_bar(id)); if title.trim().is_empty() { self.clock .split_once(", ") .map_or(self.clock.as_str(), |(date, _)| date) } else { title } .to_owned() }; let kind = if let Some(activity) = activity { match activity { Peek::Capture => status_ui::Menu::CaptureStatus, Peek::Recording => status_ui::Menu::Overflow, _ => status_ui::Menu::Battery, } } else if mode == Mode::Toast { status_ui::Menu::Notifications } else if mode == Mode::Peek { match peek { Peek::Recording => status_ui::Menu::Overflow, Peek::Capture => status_ui::Menu::CaptureStatus, Peek::Battery => status_ui::Menu::Battery, Peek::Media => status_ui::Menu::Media, Peek::Date => status_ui::Menu::Workspaces, Peek::Workspaces => status_ui::Menu::Workspaces, } } else if playing.is_some() { status_ui::Menu::Media } else if crate::bar::focused_bar_title(&self.snapshot, self.output_for_bar(id)) .trim() .is_empty() { status_ui::Menu::Workspaces } else { self.outputs .iter() .find(|entry| entry.bar == id) .and_then(|entry| entry.runtime.hub.as_ref()) .map_or(status_ui::Menu::Workspaces, |runtime| { runtime.borrow().last_menu }) }; let label = self .render .text(label) .size(13) .class(theme::Text::Color(primary)) .ellipsize(cosmic::iced::widget::text::Ellipsize::End( cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1), )); let body: Element<'_, cosmic::Action> = match mode { _ if activity.is_some() => self.hub_activity_strip( id, activity.expect( "the activity-specific hub view requires its selected activity", ), mode == Mode::Peek, intrinsic, opacity, ), Mode::Peek => { let leading = match peek { Peek::Recording | Peek::Capture | Peek::Battery => { unreachable!("activity strip handled above") } Peek::Media => self.hub_music_art(opacity), Peek::Workspaces => self.hub_workspaces(id, opacity), Peek::Date => icons::tinted(icons::morph::CALENDAR, 18, icon_primary) .opacity(opacity) .into(), }; let width = if intrinsic { Length::Shrink } else { Length::Fill }; let label = label.width(width).align_x(if peek == Peek::Date { Alignment::End } else { Alignment::Start }); let mut strip = row![leading, label] .spacing(10) .width(width) .align_y(Alignment::Center); if peek == Peek::Media { strip = strip.push(self.hub_equalizer(id, opacity)); } strip.into() } Mode::Toast => row![ icons::tinted(icons::morph::NOTIFICATIONS, 16, icon_muted).opacity(opacity), label.width(Length::Fill) ] .spacing(10) .align_y(Alignment::Center) .into(), _ if playing.is_some() => { // Measure the title only after reserving both side widgets, // outer padding, and gaps. During rendering, flex gives it // whatever remains as the shell animates. let workspaces = self.hub_workspace_width(id) as f32; let title_limit = (self.hub_idle_width_limit(id) - if workspaces > 0. { workspaces + 10. } else { 0. } - 28. - 24. - 13. - 16.) .max(0.); let title = if intrinsic { container(label).max_width(title_limit) } else { container(label.width(Length::Fill)).width(Length::Fill) }; row![ self.hub_music_art(opacity), title, self.hub_equalizer(id, opacity) ] .spacing(8) .width(if intrinsic { Length::Shrink } else { Length::Fill }) .align_y(Alignment::Center) .into() } _ => label.into(), }; let appearance = button::Style { text_color: Some(primary), icon_color: Some(primary), ..Default::default() }; let content_width = if intrinsic || (mode == Mode::Idle && (playing.is_none() || activity.is_some())) { Length::Shrink } else { Length::Fill }; let content_height = if intrinsic { Length::Fixed(28.) } else { Length::Fill }; let context = button::custom( container(body) .width(content_width) .center_y(content_height), ) .name(match activity { Some(Peek::Capture) => format!( "{}. Open capture status", self.capture_status.label().unwrap_or_default() ), Some(Peek::Recording) => format!( "{}. Open recording controls", self.recorder.elapsed().map_or_else( || "Saving recording".into(), |elapsed| format!("Recording, {elapsed}") ) ), Some(Peek::Battery) => "Low battery. Open battery controls".into(), _ => "Expand panel hub".into(), }) .padding(0) .width(content_width) .height(content_height) .class(theme::Button::Custom { active: Box::new(move |_, _| appearance), hovered: Box::new(move |_, _| appearance), pressed: Box::new(move |_, _| appearance), disabled: Box::new(move |_| appearance), }) .on_press(cosmic::Action::App(Message::HubOpen(id, kind))); let mut strip = row::with_capacity(2).spacing(10).align_y(Alignment::Center); if mode == Mode::Idle && self.hub_workspace_width(id) > 0. { strip = strip.push(self.hub_workspaces(id, opacity)); } let group = container( strip .push(context) .width(content_width) .height(content_height), ) .padding([0, 14]) .height(content_height); if intrinsic { group.into() } else { group.center_x(Length::Fill).into() } } Mode::Expanded(selected) => { let mut tabs = row::with_capacity(6) .width(Length::Fill) .spacing(2) .align_y(Alignment::Center); let shortcuts = self.hub_shortcuts(id); for (kind, label, icon) in [ ( status_ui::Menu::Workspaces, "Workspaces", icons::morph::WORKSPACES, ), (status_ui::Menu::Media, "Music", icons::morph::MUSIC), ( status_ui::Menu::Calendar, "Calendar", icons::morph::CALENDAR, ), ( status_ui::Menu::Notifications, "Notifications", icons::morph::NOTIFICATIONS, ), ( status_ui::Menu::Overflow, "Controls", icons::morph::CONTROLS, ), ( status_ui::Menu::Activities, "Activities", icons::morph::ACTIVITIES, ), ] { if kind == status_ui::Menu::Media && self.media.snapshot.selected.is_none() { continue; } if kind == status_ui::Menu::Activities { // Activities follows both the main tabs and optional shortcuts. for shortcut in shortcuts .iter() .copied() .filter(|kind| *kind != ItemKind::Overview) { tabs = tabs.push(self.hub_shortcut_button(id, shortcut, menu, opacity)); } if !self.activities_configured(id) { continue; } } let active = if selected == Content::Controls { menu.unwrap_or(status_ui::Menu::Overflow) == kind } else { selected == content(kind) }; let unread = if kind == status_ui::Menu::Notifications { self.notifications.unread() } else { 0 }; let foreground = if unread > 0 { crate::color(palette.accent) } else { icon_primary }; let accessible_name = if unread > 0 { format!("{label}, {unread} unread") } else { label.to_owned() }; tabs = tabs.push(motion::button( self.render, button::custom(icons::tinted(icon, 18, foreground).opacity(opacity)) .name(accessible_name) .padding([5, 3]) .width(30) .height(28) .on_press(cosmic::Action::App(Message::HubOpen(id, kind))), primary, active, opacity, )); } let navigation = scrollable(tabs.width(Length::Shrink)) .direction(cosmic::iced::widget::scrollable::Direction::Horizontal( cosmic::iced::widget::scrollable::Scrollbar::default() .width(0) .scroller_width(0), )) .width(Length::Fill); let tabs = row::with_capacity(2).spacing(2).align_y(Alignment::Center); let mut tabs = tabs.push(navigation); tabs = tabs.push(self.hub_shortcut_button(id, ItemKind::NightLight, menu, opacity)); if let Some(battery) = self.hub_battery(id, menu == Some(status_ui::Menu::Battery), opacity) { tabs = tabs.push(battery); } tabs = tabs.push(motion::button( self.render, button::custom( icons::tinted(icons::morph::CLOSE, 18, icon_primary).opacity(opacity), ) .name("Collapse panel hub") .padding([5, 3]) .width(30) .height(28) .on_press(cosmic::Action::App(Message::HubClose(id))), primary, false, opacity, )); let kind = match selected { Content::Activities => status_ui::Menu::Activities, Content::Workspaces => status_ui::Menu::Workspaces, Content::Media => status_ui::Menu::Media, Content::Calendar => status_ui::Menu::Calendar, Content::Notifications => status_ui::Menu::Notifications, Content::Controls => menu.unwrap_or(status_ui::Menu::Overflow), }; let body = if kind == status_ui::Menu::Activities { self.activity_list(id, opacity) } else if kind == status_ui::Menu::Workspaces { self.hub_workspace_grid(id, opacity) } else if kind == status_ui::Menu::Overflow { self.hub_controls(id, opacity) } else { self.view_hub_menu(kind, opacity) }; column![ container(tabs).height(32), scrollable(body) .direction(cosmic::iced::widget::scrollable::Direction::Vertical( cosmic::iced::widget::scrollable::Scrollbar::default() .width(0) .scroller_width(0) )) .height(Length::Fill) ] .spacing(8) .padding([8, 14]) .width(Length::Fill) .height(Length::Fill) .into() } }; let content = if matches!(mode, Mode::Idle | Mode::Peek) { scroll::area(content, id) } else { content }; if interactive && opacity >= 0.5 { content } else { content.map(|_| cosmic::Action::App(Message::HubIgnore)) } } fn hub_music_art(&self, opacity: f32) -> Element<'_, cosmic::Action> { match self .media .artwork .as_ref() .and_then(|art| art.handle.as_ref()) { Some(handle) => cosmic::widget::image(handle.clone()) .width(24) .height(24) .opacity(opacity) .content_fit(cosmic::iced::ContentFit::Cover) .border_radius(5.) .into(), None => container( icons::tinted( icons::morph::MUSIC, 18, crate::color(self.config.theme.text_primary), ) .opacity(opacity), ) .center_x(24) .center_y(24) .into(), } } fn hub_equalizer(&self, id: window::Id, opacity: f32) -> Element<'_, cosmic::Action> { let playing = self .media .snapshot .selected .as_ref() .is_some_and(|player| player.status == ferese_ipc::media::Playback::Playing); let animate = playing && self.render.motion.motion_enabled(); let started = self .outputs .iter() .find(|entry| entry.bar == id) .and_then(|entry| entry.runtime.hub.as_ref()) .map_or_else(Instant::now, |runtime| runtime.borrow().revision); equalizer::view( started, animate, color_with_opacity(self.config.theme.accent, opacity), ) } fn hub_shortcut_button( &self, id: window::Id, kind: ItemKind, menu: Option, opacity: f32, ) -> Element<'_, cosmic::Action> { let action = self.hub_control_action(id, kind).expect("shortcut action"); let night_light_on = kind == ItemKind::NightLight && self .status .night_light .as_ref() .is_some_and(|status| status.enabled); let palette = self.config.theme.for_bar(); let primary = crate::color(if night_light_on { palette.accent } else { palette.text_primary }); motion::button( self.render, button::custom(icons::tinted(action.icon, 18, primary).opacity(opacity)) .name(if kind == ItemKind::NightLight { if night_light_on { "Night light: on" } else { "Night light: off" } } else { action.label }) .padding([5, 3]) .width(30) .height(28) .on_press_maybe(action.message.map(cosmic::Action::App)), primary.scale_alpha(opacity), action.menu.is_some() && action.menu == menu, opacity, ) } fn hub_control_action(&self, id: window::Id, kind: ItemKind) -> Option { let (icon, menu) = match kind { ItemKind::Overview => (icons::morph::WORKSPACES, None), ItemKind::Activities => (icons::morph::ACTIVITIES, Some(status_ui::Menu::Activities)), ItemKind::QuickSettings => (icons::morph::SETTINGS, Some(status_ui::Menu::System)), ItemKind::NightLight => (ferese_theme::icons::MOON, Some(status_ui::Menu::NightLight)), ItemKind::Network => (icons::WIFI, Some(status_ui::Menu::Network)), ItemKind::CaptureStatus if self.capture_status.active() => ( self.capture_status.icon(), Some(status_ui::Menu::CaptureStatus), ), ItemKind::Recording => ( match self.recorder.state { crate::recording::State::Selecting => icons::RECORD_CANCEL, crate::recording::State::Recording(_) => icons::RECORD_STOP, crate::recording::State::Saving => icons::RECORD_SAVING, _ => icons::morph::RECORD, }, None, ), ItemKind::Audio => (icons::VOLUME, Some(status_ui::Menu::Audio)), ItemKind::Battery { .. } => (icons::BATTERY, Some(status_ui::Menu::Battery)), ItemKind::DisplayMode if self.display_mode.external_connected() => { (icons::morph::DISPLAY, None) } _ => return None, }; let message = match kind { ItemKind::Overview => Some(Message::ToggleOverview), ItemKind::Recording => match self.recorder.state { crate::recording::State::Selecting | crate::recording::State::Recording(_) => { Some(Message::StopRecording) } crate::recording::State::Saving => None, _ => Some(Message::StartRecording), }, ItemKind::DisplayMode => Some(Message::OpenDisplays( self.outputs .iter() .find(|entry| entry.bar == id) .and_then(|entry| entry.name.clone()), )), _ => menu.map(|menu| Message::HubOpen(id, menu)), }; Some(ControlAction { icon, menu, message, label: if kind == ItemKind::Recording { self.recorder.label() } else { kind.label() }, }) } fn hub_shortcuts(&self, id: window::Id) -> Vec { let panel = self.panel_for_bar(id); let available = content_width( panel, self.outputs .iter() .find(|entry| entry.bar == id) .and_then(|entry| entry.size), ); let width = panel.morph.expanded_width.min(available); // Built-in tabs, battery and Close keep their space through the morph. let tabs = 6. + f64::from(self.media.snapshot.selected.is_some()) + f64::from(self.activities_configured(id)); let battery = if self.status.battery.is_some() { battery::WIDTH + 2. } else { 0. }; let capacity = ((width - 28. - tabs * 32. - battery - 8.).max(0.) / 32.).floor() as usize; let mut shortcuts = Vec::new(); for item in [&panel.start, &panel.center, &panel.end] .into_iter() .flat_map(|zone| &zone.groups) .flat_map(|group| &group.items) .filter(|item| item.visible && item.overflow != ferese_panel::OverflowPolicy::Always) { if shortcuts.len() == capacity { break; } if matches!( item.kind, ItemKind::Overview | ItemKind::Activities | ItemKind::NightLight ) { continue; } if matches!( item.kind, ItemKind::Network | ItemKind::Audio | ItemKind::Battery { .. } ) { continue; } if self.hub_control_action(id, item.kind).is_some() && !shortcuts.contains(&item.kind) { shortcuts.push(item.kind); } } shortcuts } fn hub_controls(&self, id: window::Id, opacity: f32) -> Element<'_, cosmic::Action> { let primary = color_with_opacity(self.config.theme.text_primary, opacity); let mut rows = column::with_capacity(8).spacing(2).width(Length::Fill); let mut tiles = Vec::new(); let panel = self.panel_for_bar(id); let shortcuts = self.hub_shortcuts(id); for item in [&panel.start, &panel.center, &panel.end] .into_iter() .flat_map(|zone| &zone.groups) .flat_map(|group| &group.items) .filter(|item| item.visible) { if shortcuts.contains(&item.kind) || matches!(item.kind, ItemKind::Battery { .. }) { continue; } if matches!(item.kind, ItemKind::Overview | ItemKind::Activities) { continue; } let Some(action) = self.hub_control_action(id, item.kind) else { continue; }; let ControlAction { icon, label, message, .. } = action; tiles.push(motion::button( self.render, button::custom( row![ container( icons::tinted(icon, 22, crate::color(self.config.theme.text_primary)) .opacity(opacity) ) .center_x(30) .center_y(28), self.render .text(label) .size(12) .class(theme::Text::Color(primary)) .height(28) .width(Length::Fill) .align_y(cosmic::iced::alignment::Vertical::Center) .ellipsize(cosmic::iced::widget::text::Ellipsize::End( cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1), )) ] .spacing(8) .align_y(Alignment::Center), ) .name(label) .padding([4, 8]) .height(36) .width(Length::Fill) .on_press_maybe(message.map(cosmic::Action::App)), primary, false, opacity, )); } let mut tiles = tiles.into_iter(); while let Some(left) = tiles.next() { let right = tiles .next() .unwrap_or_else(|| cosmic::widget::Space::new().width(Length::Fill).into()); rows = rows.push(row![left, right].spacing(8)); } rows.into() } } #[cfg(test)] mod tests { use super::*; use cosmic::Application; use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, mouse, widget}; use cosmic::iced::{Event, Rectangle, Size}; use std::time::Duration; fn shell() -> FereseShell { let mut shell = crate::tests::shell_with_measured_panel(); shell.config.panels[0].style = PanelStyle::Morph; shell.config.panels[0].border = true; shell.outputs[0] .runtime .reset(&shell.config.panels[0], &shell.snapshot, Some("test")); shell.clock = "11 oct, 10:09 am".into(); shell } #[test] fn queued_hub_frame_after_output_removal_is_empty_instead_of_panicking() { let mut app = shell(); let id = app.outputs[0].bar; let sample = app.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .hub .sample(); app.outputs.clear(); drop(app.view_hub(id)); drop(app.view_hub_at(id, sample, None, None)); } #[test] fn saved_regular_margins_do_not_constrain_morph() { let mut app = shell(); let bar = app.outputs[0].bar; app.outputs[0].size = Some((1280, 720)); let baseline = content_width(&app.config.panels[0], app.outputs[0].size); let mut config = app.config.clone(); config.panels[0].style = PanelStyle::Flat; config.panels[0].geometry.side_margins = Some(640); drop(app.apply_config(config.clone())); config.panels[0].style = PanelStyle::Morph; drop(app.apply_config(config.clone())); assert_eq!( content_width(&app.config.panels[0], app.outputs[0].size), baseline ); drop(app.open_hub(bar, status_ui::Menu::Calendar)); assert!( app.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .hub .target_size()[0] > 300. ); config.panels[0].style = PanelStyle::Flat; drop(app.apply_config(config)); assert_eq!(app.config.panels[0].geometry.side_margins, Some(640)); } #[test] fn large_radius_pins_survive_startup_reload_and_style_switches() { for radius in [129., 4096., f32::MAX] { let mut app = shell(); let mut config = app.config.clone(); config.panels[0].corner_radius = Some(ferese_panel::CornerRadii::uniform(radius)); ferese_panel::validate(&config.panels).unwrap(); let mut startup = Runtime::new(&config.panels[0]); for _ in 0..60 { startup.hub.advance(Duration::from_millis(8), false); assert!( startup .hub .sample() .geometry .radii .iter() .all(|r| (*r as f32).is_finite()) ); } drop(app.apply_config(config.clone())); config.panels[0].style = PanelStyle::Flat; drop(app.apply_config(config.clone())); config.panels[0].style = PanelStyle::Morph; drop(app.apply_config(config)); assert!(app.outputs[0].runtime.hub.is_some()); } } #[test] fn activity_destination_remains_available_for_every_overflow_policy() { let mut app = shell(); seed_activities(&mut app, 1); let bar = app.outputs[0].bar; for policy in [ ferese_panel::OverflowPolicy::Auto, ferese_panel::OverflowPolicy::Always, ferese_panel::OverflowPolicy::Never, ferese_panel::OverflowPolicy::Always, ] { let mut config = app.config.clone(); let panel = &mut config.panels[0]; for item in [&mut panel.start, &mut panel.center, &mut panel.end] .into_iter() .flat_map(|zone| &mut zone.groups) .flat_map(|group| &mut group.items) { if item.kind == ItemKind::Activities { item.overflow = policy; } } drop(app.apply_config(config)); assert!(app.activities_configured(bar)); assert_eq!( app.compact_activities_configured(bar), policy != ferese_panel::OverflowPolicy::Always ); drop(app.open_hub(bar, status_ui::Menu::Activities)); assert_eq!(app.hub_mode(bar), Some(Mode::Expanded(Content::Activities))); if policy == ferese_panel::OverflowPolicy::Always { assert!( app.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .activities .selection .visible .is_empty() ); } } } #[test] fn output_resize_refits_every_mode_and_caps_the_retained_surface() { for edge in [Edge::Top, Edge::Bottom] { for attached in [false, true] { for mode in [Mode::Idle, Mode::Peek, Mode::Expanded(Content::Calendar)] { for settled in [false, true] { let mut app = shell(); let panel = &mut app.config.panels[0]; panel.edge = edge; panel.morph.attached = attached; app.outputs[0] .runtime .reset(panel, &app.snapshot, Some("test")); let bar = app.outputs[0].bar; drop(app.transition_hub( bar, transition::Event::Present( mode, mode.expanded().then_some(status_ui::Menu::Calendar), ), )); app.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow_mut() .hub .advance(Duration::from_millis(16), settled); let mut info = cosmic::cctk::sctk::output::OutputData::new(1) .with_output_info(Clone::clone); info.logical_size = Some((220, 170)); let output = app.outputs[0].output.clone(); drop(app.output_event( cosmic::iced::event::wayland::OutputEvent::InfoUpdate(info), output, )); let runtime = app.outputs[0].runtime.hub.as_ref().unwrap().borrow(); let available = available_size(&app.config.panels[0], app.outputs[0].size); assert_eq!(runtime.hub.mode(), mode); for (i, limit) in available.into_iter().enumerate() { assert!(f64::from(runtime.allocation[i]) <= limit.ceil()); assert!(runtime.hub.target_size()[i] <= limit); } assert!( runtime.hub.target_size()[0] <= content_width(&app.config.panels[0], app.outputs[0].size) ); } } } } } fn seed_activities(shell: &mut FereseShell, count: usize) { use ferese_activities::{Data, Owner, Source, Store, Template, Value}; let now = ferese_ipc::activities::now_ms(); if !shell.config.panels[0] .end .groups .iter() .flat_map(|group| &group.items) .any(|item| item.kind == ItemKind::Activities) { shell.config.panels[0].end.groups[0] .items .push(ferese_panel::Item::new( "test-activities", ItemKind::Activities, )); } if shell.snapshot.outputs.is_empty() { shell.snapshot.outputs.push(crate::control::OutputSnapshot { id: 4, name: "test".into(), active_workspace: 1, focused: true, }); } let mut store = Store::new(7); for index in 0..count { store .start( Owner { pid: 40, birth: 1 }, format!("task-{index}"), Data { source: Source::Publisher, output: shell .output_for_bar(shell.outputs[0].bar) .map(|output| output.name.clone()), priority: 100 + index as u8, icon: if index == 0 { "hammer" } else { "download" }.into(), title: if index == 2 { "Installing update" } else if index == 0 { "Cargo test: a deliberately long title which must leave all neighboring controls accessible" } else { "Downloading update" } .into(), value: if index == 0 { Value::Indeterminate } else { Value::Progress(0.62) }, template: Template::Progress, }, now.saturating_sub(500), 0, None, true, true, None, ) .unwrap(); } shell.activities.snapshot = std::sync::Arc::new(ferese_ipc::activities::Snapshot { activities: store.entries().cloned().collect(), now_ms: now, revision: 1, ..Default::default() }); } #[test] fn pomodoro_controls_fit_and_tab_enter_targets_the_current_session() { use cosmic::iced::advanced::renderer::Headless; use cosmic::iced::advanced::widget::operation; use cosmic::iced::keyboard::{Event as KeyEvent, Key, Location, Modifiers, key}; use ferese_activities::pomodoro::{Session, Status}; use ferese_ipc::activities::PomodoroAction; #[derive(Default)] struct TextBounds(Vec<(String, Rectangle)>); impl widget::Operation for TextBounds { fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { visit(self); } fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) { self.0.push((text.into(), bounds)); } } let mut renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( cosmic::iced::Font::default(), cosmic::iced::Pixels(14.), Some("tiny-skia"), )) .unwrap(); let mut app = shell(); app.config.font_family = Some("JetBrains Mono".into()); app.render = crate::rendering::Rendering::new(&app.config); let bar = app.outputs[0].bar; seed_activities(&mut app, 1); let id = app.activities.snapshot.activities[0].id.clone(); for status in [ None, Some(Status::Running), Some(Status::Paused), Some(Status::Ready), Some(Status::Finished), ] { if let Some(status) = status { let mut timer = Session::start(Default::default(), ferese_ipc::activities::now_ms()).unwrap(); timer.status = status; if status != Status::Running { timer.deadline = None; } if status == Status::Finished { timer.remaining_ms = 0; timer.completed_work = timer.settings.intervals; } std::sync::Arc::make_mut(&mut app.activities.snapshot).activities[0] .data .title = "Pomodoro ยท Focus".into(); std::sync::Arc::make_mut(&mut app.activities.snapshot).pomodoro = Some(ferese_ipc::activities::Pomodoro { id: id.clone(), timer, }); } for width in [240., 260., 312.] { let mut view = app.activity_list(bar, 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(width, 600.)), ); let mut labels = TextBounds::default(); view.as_widget_mut() .operate(&mut tree, Layout::new(&node), &renderer, &mut labels); for (text, bounds) in &labels.0 { assert!( bounds.x >= 0. && bounds.x + bounds.width <= width + 0.5, "{status:?} {width} {text}: {bounds:?}" ); } assert!( !labels.0.iter().any(|(text, _)| text == "Pomodoro ยท Focus"), "the timer must not appear twice" ); if let Some(directory) = std::env::var_os("FERESE_POMODORO_CAPTURE_DIR") { use cosmic::iced::advanced::renderer::Renderer as _; let directory = std::path::PathBuf::from(directory); std::fs::create_dir_all(&directory).unwrap(); let viewport = Rectangle::with_size(node.size()); renderer.reset(viewport); view.as_widget().draw( &tree, &mut renderer, &app.config.theme.palette().native_theme(), &cosmic::iced::advanced::renderer::Style { text_color: crate::color(app.config.theme.text_primary), icon_color: crate::color(app.config.theme.text_primary), scale_factor: 1.5, }, Layout::new(&node), mouse::Cursor::Unavailable, &viewport, ); let physical = Size::new( (node.size().width * 1.5).ceil() as u32, (node.size().height * 1.5).ceil() as u32, ); let pixels = renderer.screenshot( physical, 1.5, crate::color(app.config.theme.surface_popover), ); image_codec::save_buffer( directory.join(format!("{status:?}-{width}.png")), &pixels, physical.width, physical.height, image_codec::ColorType::Rgba8, ) .unwrap(); } let primary = match status { Some(Status::Running) => PomodoroAction::Pause, Some(Status::Paused | Status::Ready) => PomodoroAction::Resume, _ => PomodoroAction::Start, }; let mut expected = vec![primary]; if matches!(status, Some(Status::Running | Status::Paused)) { expected.push(PomodoroAction::Next); } if status.is_some_and(|status| status != Status::Finished) { expected.push(PomodoroAction::Reset); } if status.is_some() { expected.push(PomodoroAction::Stop); } for expected in expected { let mut tab: Box = Box::new(operation::focusable::focus_next()); loop { view.as_widget_mut().operate( &mut tree, Layout::new(&node), &renderer, &mut tab, ); match tab.finish() { operation::Outcome::Chain(next) => tab = next, _ => break, } } let event = Event::Keyboard(KeyEvent::KeyPressed { key: Key::Named(key::Named::Enter), modified_key: Key::Named(key::Named::Enter), physical_key: key::Physical::Code(key::Code::Enter), location: Location::Standard, modifiers: Modifiers::empty(), text: None, repeat: false, }); let mut messages = Vec::new(); view.as_widget_mut().update( &mut tree, &event, Layout::new(&node), mouse::Cursor::Unavailable, &renderer, &mut clipboard::Null, &mut Shell::new(&mut messages), &Rectangle::with_size(Size::new(width, 600.)), ); assert!(messages.iter().any(|message|matches!(message,cosmic::Action::App(Message::Pomodoro(action,session)) if *action==expected && *session==status.map(|_|id.clone()))),"{status:?} {expected:?}: {messages:?}"); } } } } #[test] fn paused_pomodoro_never_requests_countdown_frames() { let mut app = shell(); let bar = app.outputs[0].bar; seed_activities(&mut app, 1); let now = ferese_ipc::activities::now_ms(); let entry = &mut std::sync::Arc::make_mut(&mut app.activities.snapshot).activities[0]; entry.data.template = ferese_activities::Template::Timer; entry.data.value = ferese_activities::Value::Countdown { remaining_ms: 50_000, sampled_at: now, }; drop(app.sync_hub_activity()); assert!(app.activity_tick_subscription().units() > 0); std::sync::Arc::make_mut(&mut app.activities.snapshot).activities[0] .data .value = ferese_activities::Value::PausedCountdown { remaining_ms: 50_000, }; drop(app.sync_hub_activity()); assert_eq!(app.activity_tick_subscription().units(), 0); assert!(app.activity_available(bar)); } #[test] fn activity_target_must_still_exist_and_activities_has_no_duplicate_shortcut() { let mut app = shell(); let bar = app.outputs[0].bar; seed_activities(&mut app, 1); let activity = app.activities.snapshot.activities[0].id.clone(); assert_eq!(app.activity_window(&activity), None); std::sync::Arc::make_mut(&mut app.activities.snapshot) .windows .push(ferese_ipc::activities::ActivityWindow { activity: activity.clone(), window: 42, }); assert_eq!(app.activity_window(&activity), None); app.snapshot.windows.push(crate::control::WindowSnapshot { id: 42, workspace: 1, geometry: None, app_id: "foot".into(), title: "Terminal".into(), focused: false, urgent: false, fullscreen: false, maximized: false, floating: false, }); assert_eq!(app.activity_window(&activity), Some(42)); app.snapshot.windows.clear(); assert_eq!(app.activity_window(&activity), None); app.config.panels[0].morph.expanded_width = 400.; assert!(!app.hub_shortcuts(bar).contains(&ItemKind::Activities)); app.status.battery = None; assert!(app.hub_battery(bar, false, 1.).is_none()); app.status.battery = Some(crate::status::Battery { percent: 100, status: "Full".into(), }); assert!(app.hub_battery(bar, false, 1.).is_some()); } #[test] fn open_activity_list_survives_publisher_churn() { use cosmic::iced::advanced::renderer::Headless; let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( cosmic::iced::Font::default(), cosmic::iced::Pixels(14.), Some("tiny-skia"), )) .unwrap(); let mut app = shell(); let id = app.outputs[0].bar; seed_activities(&mut app, 3); drop(app.sync_hub_activity()); drop(app.open_hub(id, status_ui::Menu::Activities)); let mut tree = widget::Tree::empty(); for count in [3, 2, 1, 3, 0, 2, 3] { let opacity = app.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .hub .sample() .opacity; seed_activities(&mut app, count); app.sync_activity_presentations(); assert_eq!( app.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .hub .sample() .opacity, opacity, "publisher changes must not restart the open panel's crossfade" ); for _ in 0..50 { let runtime = app.outputs[0].runtime.hub.as_ref().unwrap(); let (sample, previous_menu) = { let mut runtime = runtime.borrow_mut(); runtime.hub.advance(Duration::from_millis(16), false); (runtime.hub.sample(), runtime.previous.menu) }; let mut view = app.view_hub_at(id, sample, Some(status_ui::Menu::Activities), previous_menu); widget::tree::NAMED.with(|named| *named.borrow_mut() = tree.take_all_named()); tree.diff(&mut view); view.as_widget_mut().layout( &mut tree, &renderer, &layout::Limits::new(Size::ZERO, Size::new(1200., 800.)), ); widget::tree::NAMED.with(|named| named.borrow_mut().clear()); } } } #[test] fn activity_transitions_reconcile_the_existing_widget_tree() { use cosmic::iced::advanced::renderer::Headless; let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( cosmic::iced::Font::default(), cosmic::iced::Pixels(14.), Some("tiny-skia"), )) .unwrap(); let mut app = shell(); let id = app.outputs[0].bar; seed_activities(&mut app, 3); drop(app.sync_hub_activity()); let mut tree = widget::Tree::empty(); for menu in [ None, Some(status_ui::Menu::Activities), Some(status_ui::Menu::Calendar), Some(status_ui::Menu::Activities), None, ] { if let Some(menu) = menu { drop(app.open_hub(id, menu)); } else { drop(app.close_hub(id)); } for _ in 0..50 { let runtime = app.outputs[0].runtime.hub.as_ref().unwrap(); let (sample, previous_menu) = { let mut runtime = runtime.borrow_mut(); runtime.hub.advance(Duration::from_millis(16), false); (runtime.hub.sample(), runtime.previous.menu) }; let mut view = app.view_hub_at(id, sample, menu, previous_menu); // Application rebuilds temporarily extract named widget state. widget::tree::NAMED.with(|named| *named.borrow_mut() = tree.take_all_named()); tree.diff(&mut view); view.as_widget_mut().layout( &mut tree, &renderer, &layout::Limits::new(Size::ZERO, Size::new(1200., 800.)), ); widget::tree::NAMED.with(|named| named.borrow_mut().clear()); } } } #[test] fn live_activity_keeps_workspace_wheel_handler() { use cosmic::iced::advanced::renderer::Headless; use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, widget}; use cosmic::iced::{Event, Size, mouse}; let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( cosmic::iced::Font::default(), cosmic::iced::Pixels(14.), Some("tiny-skia"), )) .unwrap(); for with_activity in [false, true] { let mut app = shell(); let id = app.outputs[0].bar; if with_activity { seed_activities(&mut app, 1); } drop(app.sync_hub_activity()); let mut view = app.hub_content(id, Mode::Peek, 1., None, true, false); let mut tree = widget::Tree::new(&view); let node = view.as_widget_mut().layout( &mut tree, &renderer, &layout::Limits::new(Size::ZERO, Size::new(260., 44.)), ); let viewport = cosmic::iced::Rectangle::with_size(Size::new(260., 44.)); let mut messages = Vec::new(); view.as_widget_mut().update( &mut tree, &Event::Mouse(mouse::Event::WheelScrolled { delta: mouse::ScrollDelta::Lines { x: 0., y: 1. }, }), Layout::new(&node), mouse::Cursor::Available(cosmic::iced::Point::new(130., 20.)), &renderer, &mut clipboard::Null, &mut Shell::new(&mut messages), &viewport, ); assert!( messages.iter().any(|message| matches!( message, cosmic::Action::App(Message::HubWorkspaceScroll(_, _)) )), "workspace scrolling stopped with_activity={with_activity}" ); } } #[test] fn workspace_feedback_takes_precedence_over_compact_activities() { use cosmic::iced::advanced::renderer::Headless; #[derive(Debug, Default)] struct Labels(Vec); impl widget::Operation for Labels { fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { visit(self); } fn text(&mut self, _: Option<&widget::Id>, _: Rectangle, text: &str) { self.0.push(text.into()); } } let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( cosmic::iced::Font::default(), cosmic::iced::Pixels(14.), Some("tiny-skia"), )) .unwrap(); let mut app = shell(); let bar = app.outputs[0].bar; seed_activities(&mut app, 1); app.snapshot.workspaces = vec![crate::control::WorkspaceSnapshot { id: 1, name: "Code".into(), output: Some(4), index: 1, window_count: 1, visible: true, focused: true, }]; drop(app.sync_hub_activity()); drop(app.transition_hub(bar, transition::Event::Workspaces(Instant::now()))); assert_eq!(app.hub_mode(bar), Some(Mode::Peek)); let mut view = app.hub_content(bar, Mode::Peek, 1., None, true, false); let mut tree = widget::Tree::new(&view); let node = view.as_widget_mut().layout( &mut tree, &renderer, &layout::Limits::new(Size::ZERO, Size::new(260., 44.)), ); let mut labels = Labels::default(); view.as_widget_mut() .operate(&mut tree, Layout::new(&node), &renderer, &mut labels); assert!(labels.0.iter().any(|text| text == "1 ยท Code"), "{labels:?}"); assert!(!labels.0.iter().any(|text| text.contains("Cargo test"))); } #[test] fn dnd_list_keeps_result_rows_reachable_and_preserves_focus_on_update() { use cosmic::iced::keyboard::key::Named; let mut app = shell(); let bar = app.outputs[0].bar; seed_activities(&mut app, 3); let activity = &mut std::sync::Arc::make_mut(&mut app.activities.snapshot).activities[0]; activity.state = ferese_activities::State::Done; activity.result_deadline = Some(ferese_ipc::activities::now_ms() + 10_000); let completed = activity.id.clone(); app.notifications.dnd = true; drop(app.sync_hub_activity()); drop(app.open_hub(bar, status_ui::Menu::Activities)); let expected: Vec<_> = ferese_activities::ranked( app.activities.snapshot.activities.iter(), ferese_ipc::activities::now_ms(), app.activities.snapshot.limits, app.output_for_bar(bar).unwrap().name.as_str(), false, ) .iter() .map(|entry| entry.id.clone()) .collect(); let runtime = app.outputs[0].runtime.hub.as_ref().unwrap(); assert!(expected.contains(&completed)); assert!( !runtime .borrow() .activities .selection .visible .contains(&completed) ); for (index, id) in expected.iter().enumerate() { let key = if index == 0 { Named::Home } else { Named::ArrowDown }; drop(app.activity_navigation(bar, None, Some(key))); assert_eq!( app.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .activities .focus .as_ref(), Some(id) ); } drop(app.activity_navigation(bar, Some(completed.clone()), None)); drop(app.sync_hub_activity()); assert_eq!( app.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .activities .focus, Some(completed) ); } #[test] fn activity_arrows_follow_tab_focus() { use cosmic::iced::Size; use cosmic::iced::advanced::renderer::Headless; use cosmic::iced::advanced::widget::operation; use cosmic::iced::advanced::{Layout, layout, widget}; use cosmic::iced::keyboard::key::Named; let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( cosmic::iced::Font::default(), cosmic::iced::Pixels(14.), Some("tiny-skia"), )) .unwrap(); let mut app = shell(); let bar = app.outputs[0].bar; seed_activities(&mut app, 3); drop(app.sync_hub_activity()); drop(app.open_hub(bar, status_ui::Menu::Activities)); let ranked = ferese_activities::ranked( app.activities.snapshot.activities.iter(), ferese_ipc::activities::now_ms(), app.activities.snapshot.limits, app.output_for_bar(bar).unwrap().name.as_str(), false, ); let ids: Vec<_> = ranked.iter().map(|entry| entry.id.clone()).collect(); let id = &ids[0]; let first = widget::Id::new(format!( "activity-{bar:?}-{}-{}-{}-{}", id.session, id.generation, id.owner.pid, id.owner.birth )); let focused = { let mut query = app.activity_focus_query(bar).unwrap(); let mut view = app.activity_list(bar, 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(312., 600.)), ); view.as_widget_mut().operate( &mut tree, Layout::new(&node), &renderer, &mut operation::focusable::focus::<()>(first), ); let mut tab: Box = Box::new(operation::focusable::focus_next()); loop { view.as_widget_mut() .operate(&mut tree, Layout::new(&node), &renderer, &mut tab); match tab.finish() { operation::Outcome::Chain(next) => tab = next, _ => break, } } view.as_widget_mut().operate( &mut tree, Layout::new(&node), &renderer, &mut operation::black_box(&mut query), ); match widget::Operation::finish(&query) { operation::Outcome::Some(focused) => focused, _ => panic!("missing focus query result"), } }; assert_eq!(focused, Some(ids[1].clone())); drop(app.activity_navigation(bar, focused, Some(Named::ArrowDown))); assert_eq!( app.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .activities .focus, Some(ids[2].clone()), "Tab focuses row two, so Down must focus row three" ); } #[test] fn compact_activities_fit_titles_and_keep_the_badge_adjacent() { use cosmic::iced::advanced::renderer::Headless; #[derive(Default)] struct TextBounds(Vec<(String, Rectangle)>); impl widget::Operation for TextBounds { fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { visit(self); } fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) { self.0.push((text.into(), bounds)); } } let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( cosmic::iced::Font::default(), cosmic::iced::Pixels(14.), Some("tiny-skia"), )) .unwrap(); let mut app = shell(); let id = app.outputs[0].bar; let mut widths = Vec::new(); for count in [1, 2, 3] { seed_activities(&mut app, count); for activity in &mut std::sync::Arc::make_mut(&mut app.activities.snapshot).activities { activity.data.title = "Build".into(); } drop(app.sync_hub_activity()); let width = app.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .hub .target_size()[0]; widths.push(width); drop(app.hub_hover(id, true)); assert_eq!( app.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .hub .target_size()[0], width ); let mut view = app.activity_compact(id, 1., false); let mut tree = widget::Tree::new(&view); let node = view.as_widget_mut().layout( &mut tree, &renderer, &layout::Limits::new(Size::ZERO, Size::new(width as f32, 44.)), ); let mut labels = TextBounds::default(); view.as_widget_mut() .operate(&mut tree, Layout::new(&node), &renderer, &mut labels); if count == 3 { let title = labels .0 .iter() .rfind(|(text, _)| text == "Build") .unwrap() .1; let badge = labels.0.iter().find(|(text, _)| text == "+1").unwrap().1; assert!( (badge.x - title.x - title.width - 8.).abs() < 1., "{:#?}", labels.0 ); assert!( (width as f32 - badge.x - badge.width - 14.).abs() < 1., "{:#?}", labels.0 ); } drop(view); drop(app.hub_hover(id, false)); } assert!( widths[0] < 180., "short single activity should shrink: {widths:?}" ); assert!(widths[0] < widths[1] && widths[1] < widths[2], "{widths:?}"); for activity in &mut std::sync::Arc::make_mut(&mut app.activities.snapshot).activities { activity.data.value = ferese_activities::Value::Text("A much longer changing phase".into()); } drop(app.sync_hub_activity()); assert_eq!( app.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .hub .target_size()[0], widths[2] ); std::sync::Arc::make_mut(&mut app.activities.snapshot) .limits .show_badge = false; drop(app.sync_hub_activity()); assert_eq!( app.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .hub .target_size()[0], widths[1], "hiding the badge must remove its reserved width" ); std::sync::Arc::make_mut(&mut app.activities.snapshot) .limits .show_badge = true; drop(app.sync_hub_activity()); for activity in &mut std::sync::Arc::make_mut(&mut app.activities.snapshot).activities { activity.data.title = "A very long title which must be capped in the compact strip".into(); } drop(app.sync_hub_activity()); let wider = app.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .hub .target_size()[0]; assert!(wider > widths[2] && wider < 450., "{wider}"); app.outputs[0].size = Some((240, 800)); drop(app.sync_hub_activity()); let width = app.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .hub .target_size()[0] as f32; assert!(width <= 240.); let mut view = app.activity_compact(id, 1., false); let mut tree = widget::Tree::new(&view); let node = view.as_widget_mut().layout( &mut tree, &renderer, &layout::Limits::new(Size::ZERO, Size::new(width, 44.)), ); let mut labels = TextBounds::default(); view.as_widget_mut() .operate(&mut tree, Layout::new(&node), &renderer, &mut labels); let badge = labels.0.iter().find(|(text, _)| text == "+1").unwrap().1; assert!( (width - badge.x - badge.width - 14.).abs() < 1., "badge must fit a narrow output: {:#?}", labels.0 ); } #[test] fn activity_values_do_not_resize_and_hover_pins_survivors_and_dnd_overrides() { let mut shell = shell(); let id = shell.outputs[0].bar; seed_activities(&mut shell, 3); drop(shell.sync_hub_activity()); let runtime = shell.outputs[0].runtime.hub.as_ref().unwrap(); let width = runtime.borrow().hub.target_size()[0]; let first = runtime.borrow().activities.selection.visible.clone(); drop(shell.hub_hover(id, true)); std::sync::Arc::make_mut(&mut shell.activities.snapshot).activities[0] .data .priority = 255; std::sync::Arc::make_mut(&mut shell.activities.snapshot).activities[2] .data .value = ferese_activities::Value::Text("A changed phase".into()); drop(shell.sync_hub_activity()); let runtime = shell.outputs[0].runtime.hub.as_ref().unwrap(); assert_eq!(runtime.borrow().activities.selection.visible, first); assert_eq!(runtime.borrow().hub.target_size()[0], width); std::sync::Arc::make_mut(&mut shell.activities.snapshot) .activities .retain(|entry| entry.id != first[0]); drop(shell.sync_hub_activity()); assert_eq!( shell.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .activities .selection .visible, vec![first[1].clone()] ); shell.notifications.dnd = true; drop(shell.sync_hub_activity()); let runtime = shell.outputs[0].runtime.hub.as_ref().unwrap(); assert_eq!(runtime.borrow().activities.selection.visible.len(), 1); assert_eq!(runtime.borrow().activities.selection.hidden, 0); drop(shell.hub_hover(id, false)); drop(shell.open_hub(id, status_ui::Menu::Activities)); assert_eq!( shell.hub_mode(id), Some(Mode::Expanded(Content::Activities)) ); } #[test] fn shared_activity_item_and_popup_work_in_flat_floating_and_adaptive_panels() { for style in [PanelStyle::Flat, PanelStyle::Floating, PanelStyle::Adaptive] { let mut shell = shell(); let id = shell.outputs[0].bar; seed_activities(&mut shell, 2); shell.config.panels[0].style = style; shell.outputs[0] .runtime .reset(&shell.config.panels[0], &shell.snapshot, Some("test")); drop(shell.sync_hub_activity()); assert!(shell.activity_available(id)); assert!(shell.outputs[0].runtime.hub.is_none()); let item = shell.config.panels[0] .item(&ferese_panel::ItemId("test-activities".into())) .unwrap(); assert!( shell .view_activity_item( id, item, ferese_panel::Representation::Compact, false, Some(160.), 28. ) .1 ); let anchor = crate::status_ui::PopoverAnchor { parent: id, panel: shell.config.panels[0].id.clone(), item: Some(item.id.clone()), rectangle: cosmic::iced::Rectangle { x: 20, y: 0, width: 160, height: 28, }, }; drop(shell.open_menu(status_ui::Menu::Activities, anchor)); assert_eq!( shell.menu.as_ref().unwrap().kind, status_ui::Menu::Activities ); assert!( shell .activity_key( shell.menu.as_ref().unwrap().id, cosmic::iced::keyboard::key::Named::ArrowDown ) .is_some() ); shell.activities.snapshot = std::sync::Arc::new(Default::default()); drop(shell.sync_hub_activity()); assert!(!shell.activity_available(id)); } } #[test] fn empty_activity_updates_preserve_the_measured_idle_width() { let mut app = shell(); seed_activities(&mut app, 0); drop(app.sync_hub_activity()); { let mut runtime = app.outputs[0].runtime.hub.as_ref().unwrap().borrow_mut(); let available = runtime.available; // Layout measures the whole ordinary idle row, including workspace dots. runtime.idle_content_width = Some(224.); runtime.hub.retarget_idle(224., available).unwrap(); runtime.hub.advance(Duration::ZERO, true); } for _ in 0..3 { drop(app.sync_hub_activity()); let runtime = app.outputs[0].runtime.hub.as_ref().unwrap().borrow(); assert_eq!(runtime.idle_content_width, Some(224.)); assert_eq!(runtime.hub.target_size()[0], 224.); assert!( !runtime.hub.active(), "status refresh restarted the idle morph" ); } } #[test] fn activity_updates_keep_the_binding_indicator_as_the_only_geometry_owner() { let mut app = shell(); let bar = app.outputs[0].bar; app.snapshot.binding_mode = Some("resize".into()); drop(app.binding_mode_transition(false)); let (allocation, target) = { let runtime = app.outputs[0].runtime.hub.as_ref().unwrap().borrow(); (runtime.allocation, runtime.hub.target_size()) }; seed_activities(&mut app, 3); drop(app.sync_hub_activity()); { let runtime = app.outputs[0].runtime.hub.as_ref().unwrap().borrow(); assert_eq!( runtime.activities.selection.visible.len(), 2, "activity data must still update" ); assert_eq!( runtime.hub.target_size(), target, "hidden activity content cannot retarget the hub" ); assert_eq!(runtime.allocation, allocation); assert!(!runtime.hub.active()); } app.snapshot.binding_mode = None; drop(app.binding_mode_transition(true)); assert_ne!( app.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .hub .target_size(), target ); drop(app.open_hub(bar, status_ui::Menu::Activities)); assert_eq!(app.hub_mode(bar), Some(Mode::Expanded(Content::Activities))); } #[test] fn progress_updates_do_not_start_a_second_hub_animation() { let mut app = shell(); let bar = app.outputs[0].bar; seed_activities(&mut app, 1); drop(app.sync_hub_activity()); let runtime = app.outputs[0].runtime.hub.as_ref().unwrap(); runtime .borrow_mut() .activities .advance(Duration::ZERO, true); assert!(!runtime.borrow().activities.active()); std::sync::Arc::make_mut(&mut app.activities.snapshot).activities[0] .data .value = ferese_activities::Value::Progress(0.75); drop(app.sync_hub_activity()); assert!( !app.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .activities .active() ); assert!(app.activity_available(bar)); } #[test] fn shell_rendering_configuration_is_instance_owned() { let mut animated = shell(); let mut snap = shell(); let original = animated.render; let mut config = snap.config.clone(); config.font_family = Some("monospace".into()); config.theme.material_radius = 3.; config.animations.reduced_motion = true; drop(snap.apply_config(config)); assert_eq!(animated.render.font, original.font); assert_ne!(snap.render.font, animated.render.font); assert_eq!(animated.render.radius(20.), original.radius(20.)); assert_eq!(snap.render.radius(20.), 3.); for instance in [&mut animated, &mut snap] { drop(instance.open_hub(instance.outputs[0].bar, status_ui::Menu::Calendar)); } let mut a = animated.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow_mut(); let mut b = snap.outputs[0].runtime.hub.as_ref().unwrap().borrow_mut(); let now = Instant::now() + Duration::from_millis(50); assert_ne!(a.sample(now).geometry.bounds.height, a.hub.target_size()[1]); assert_eq!(b.sample(now).geometry.bounds.height, b.hub.target_size()[1]); assert!(a.motion.motion_enabled()); assert!(!b.motion.motion_enabled()); } #[test] fn closing_and_repeated_feedback_preserve_the_outgoing_content() { use transition::Event as Transition; let mut runtime = Runtime::new(&Panel::default()); let area = [1000., 700.]; runtime.transition( Transition::Hover { entered: true, enabled: true, peek: (Peek::Media, 240.), priority: None, }, area, ); runtime.hub.advance(Duration::from_secs(1), false); runtime.transition(Transition::Close, area); assert_eq!( runtime.previous.peek, Peek::Media, "the closing fade keeps its media content" ); runtime.hub.advance(Duration::from_secs(1), false); let token = Instant::now(); runtime.transition(Transition::Toast { notice: 7, token }, area); runtime.hub.advance(Duration::from_secs(1), false); runtime.transition(Transition::ToastExpired(token), area); assert_eq!( runtime.previous.toast, Some(7), "expiry keeps the outgoing toast for its fade" ); runtime.hub.advance(Duration::from_secs(1), false); runtime.transition( Transition::Hover { entered: true, enabled: true, peek: (Peek::Media, 240.), priority: None, }, area, ); runtime.hub.advance(Duration::from_secs(1), false); runtime.transition(Transition::Workspaces(token), area); runtime.hub.advance(Duration::from_millis(60), false); runtime.transition( Transition::Workspaces(token + Duration::from_millis(1)), area, ); assert_eq!( runtime.previous.peek, Peek::Media, "repeated feedback must not replace the fading content" ); assert_eq!(runtime.peek, Peek::Workspaces); } #[test] fn stale_transient_expiry_cannot_close_new_content() { use transition::Event as Transition; let panel = Panel::default(); let mut runtime = Runtime::new(&panel); let area = [1000., 700.]; let first = Instant::now(); let second = first + Duration::from_millis(1); runtime.transition( Transition::Toast { notice: 1, token: first, }, area, ); runtime.transition( Transition::Toast { notice: 2, token: second, }, area, ); assert!(!runtime.transition(Transition::ToastExpired(first), area)); assert_eq!(runtime.transient.toast(), Some(2)); runtime.transition(Transition::Open(status_ui::Menu::Calendar), area); assert!(!runtime.transition(Transition::ToastExpired(second), area)); assert_eq!(runtime.hub.mode(), Mode::Expanded(Content::Calendar)); runtime.transition(Transition::Close, area); runtime.transition(Transition::Workspaces(first), area); runtime.transition( Transition::Activity { priority: Some(Peek::Recording), peek: (Peek::Recording, 240.), }, area, ); assert!(!runtime.transition(Transition::WorkspacesExpired(first), area)); assert_eq!(runtime.peek, Peek::Recording); assert!(matches!(runtime.transient, Transient::None)); assert!(!runtime.transition( Transition::Toast { notice: 3, token: second }, area )); assert!(!runtime.transition(Transition::Workspaces(second), area)); } #[test] fn recording_and_low_battery_take_priority_without_interrupting_open_panels() { let mut shell = shell(); let id = shell.outputs[0].bar; std::sync::Arc::make_mut(&mut shell.media.snapshot).selected = Some(ferese_ipc::media::Player { title: "Music".into(), status: ferese_ipc::media::Playback::Playing, ..Default::default() }); drop(shell.hub_hover(id, true)); assert_eq!(shell.hub_peek_content(id).0, Peek::Media); shell.status.battery = Some(crate::status::Battery { percent: 12, status: "Discharging".into(), }); drop(shell.sync_hub_activity()); assert_eq!(shell.hub_priority(), Some(Peek::Battery)); assert_eq!( shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, Peek::Battery ); shell.recorder.state = crate::recording::State::Recording(std::time::Instant::now()); drop(shell.sync_hub_activity()); assert_eq!(shell.hub_priority(), Some(Peek::Recording)); assert_eq!( shell.hub_low_battery(), Some(12), "battery warning must remain available beside the timer" ); assert_eq!( shell.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .peek_width, 280. ); drop(shell.hub_notice(99)); assert_eq!( shell.hub_mode(id), Some(Mode::Peek), "notifications cannot hide recording" ); shell.status.battery.as_mut().unwrap().status = "Charging".into(); drop(shell.sync_hub_activity()); assert_eq!(shell.hub_low_battery(), None); assert_eq!( shell.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .peek_width, 240. ); shell.recorder.state = crate::recording::State::Saving; drop(shell.sync_hub_activity()); assert_eq!(shell.hub_priority(), Some(Peek::Recording)); shell.recorder.state = crate::recording::State::Idle; drop(shell.sync_hub_activity()); assert_eq!( shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, Peek::Media ); drop(shell.open_hub(id, status_ui::Menu::Calendar)); shell.recorder.state = crate::recording::State::Recording(std::time::Instant::now()); drop(shell.sync_hub_activity()); assert_eq!(shell.hub_mode(id), Some(Mode::Expanded(Content::Calendar))); drop(shell.close_hub(id)); assert_eq!(shell.hub_priority(), Some(Peek::Recording)); assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); } #[test] fn low_battery_respects_threshold_charging_and_config_reload() { let mut shell = shell(); let id = shell.outputs[0].bar; shell.config.status.low_battery_threshold = 20; for (percent, status, expected) in [ (20, "Discharging", false), (19, "Discharging", true), (0, "Empty", true), (10, "Pending discharge", true), (10, "Charging", false), (10, "Pending charge", false), (10, "Full", false), (10, "Unknown", false), ] { shell.status.battery = Some(crate::status::Battery { percent, status: status.into(), }); assert_eq!( shell.hub_low_battery().is_some(), expected, "{percent}%, {status}" ); } shell.status.battery = Some(crate::status::Battery { percent: 15, status: "Discharging".into(), }); drop(shell.hub_hover(id, true)); assert_eq!( shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, Peek::Battery ); let mut config = shell.config.clone(); config.status.low_battery_threshold = 10; drop(shell.apply_config(config)); assert_eq!( shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, Peek::Date ); shell.recorder.state = crate::recording::State::Selecting; assert_eq!( shell.hub_priority(), None, "selection is not an active recording" ); } #[test] fn priority_status_supersedes_workspace_feedback_and_toasts() { let mut shell = shell(); let id = shell.outputs[0].bar; shell.snapshot.outputs = vec![crate::control::OutputSnapshot { id: 4, name: "test".into(), active_workspace: 1, focused: true, }]; shell.snapshot.workspaces = (1..=3) .map(|id| crate::control::WorkspaceSnapshot { id, name: id.to_string(), output: Some(4), index: id as u32, window_count: 1, visible: id == 1, focused: id == 1, }) .collect(); drop(shell.sync_hub_workspace_feedback(false)); shell.snapshot.outputs[0].active_workspace = 2; drop(shell.sync_hub_workspace_feedback(false)); assert_eq!(shell.hub_mode(id), Some(Mode::Peek)); shell.recorder.state = crate::recording::State::Recording(std::time::Instant::now()); drop(shell.sync_hub_activity()); assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); assert!( shell.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .transient .workspace_token() .is_none() ); shell.snapshot.outputs[0].active_workspace = 3; drop(shell.sync_hub_workspace_feedback(false)); assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); shell.recorder.state = crate::recording::State::Idle; drop(shell.sync_hub_activity()); drop(shell.set_hub_mode(id, Mode::Toast, None)); shell.status.battery = Some(crate::status::Battery { percent: 1, status: "Discharging".into(), }); drop(shell.sync_hub_activity()); assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); } #[test] fn workspace_strip_is_output_local_and_does_not_interrupt_open_content() { let mut shell = shell(); let id = shell.outputs[0].bar; shell.snapshot.outputs = vec![crate::control::OutputSnapshot { id: 4, name: "test".into(), active_workspace: 9, focused: true, }]; shell.snapshot.workspaces = [(9, 4, 0), (10, 7, 10), (11, 4, 0)] .into_iter() .map( |(workspace, output, count)| crate::control::WorkspaceSnapshot { id: workspace, name: workspace.to_string(), output: Some(output), index: workspace as u32, window_count: count, visible: workspace == 9, focused: workspace == 9, }, ) .collect(); // A bar can be created after the initial compositor snapshot arrives. drop(shell.update(Message::PanelScale(id, 1.25))); let width = shell.hub_workspace_width(id); assert!(width > 0., "the active empty workspace remains visible"); shell.snapshot.workspaces[1].window_count = 20; drop(shell.update_workspace_ui()); assert_eq!( shell.hub_workspace_width(id), width, "another output cannot change this strip" ); { let mut runtime = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow_mut(); runtime.hub.advance(Duration::ZERO, true); assert_eq!( runtime.hub.target_size()[0], shell.config.panels[0].morph.idle_width + width + 10. ); assert_eq!(runtime.hub.sample().opacity, 1.); } drop(shell.hub_hover(id, true)); { let runtime = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow(); assert_eq!(runtime.hub.target_size()[0], runtime.peek_width); } drop(shell.open_hub(id, status_ui::Menu::Calendar)); let before = shell.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .hub .sample(); shell.snapshot.workspaces[2].window_count = 1; drop(shell.update_workspace_ui()); assert!(shell.hub_workspace_width(id) > width); assert_eq!( shell.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .hub .sample(), before ); assert_eq!(shell.hub_mode(id), Some(Mode::Expanded(Content::Calendar))); drop(shell.open_hub_overview(id)); assert!(!shell.overview_active); assert_eq!( shell.hub_mode(id), Some(Mode::Expanded(Content::Workspaces)) ); } #[test] fn workspace_tile_selection_closes_without_confirmation_and_hover_resets() { let mut shell = shell(); let id = shell.outputs[0].bar; shell.snapshot.outputs = vec![crate::control::OutputSnapshot { id: 4, name: "test".into(), active_workspace: 1, focused: true, }]; shell.snapshot.workspaces = (1..=3) .map(|id| crate::control::WorkspaceSnapshot { id, name: id.to_string(), output: Some(if id == 3 { 9 } else { 4 }), index: id as u32, window_count: 0, visible: id == 1, focused: id == 1, }) .collect(); drop(shell.sync_hub_workspaces()); drop(shell.open_hub_overview(id)); drop(shell.hub_workspace_hover(id, Some(2))); drop(shell.hub_workspace_hover(id, None)); assert_eq!( shell.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .workspace_hover, None ); drop(shell.hub_workspace_select(id, 3)); assert_eq!( shell.hub_mode(id), Some(Mode::Expanded(Content::Workspaces)), "another output must be rejected" ); drop(shell.hub_workspace_select(id, 2)); shell.snapshot.outputs[0].active_workspace = 2; drop(shell.sync_hub_workspace_feedback(false)); assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); assert!(!shell.overview_active); assert_eq!( shell.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .workspace_selection_pending, None ); shell.snapshot.outputs[0].active_workspace = 1; drop(shell.sync_hub_workspace_feedback(false)); assert_eq!( shell.hub_mode(id), Some(Mode::Peek), "later keyboard switches still get feedback" ); } #[test] fn workspace_feedback_updates_in_place_and_restores_hover_content() { let mut shell = shell(); let id = shell.outputs[0].bar; shell.snapshot.outputs = vec![crate::control::OutputSnapshot { id: 4, name: "test".into(), active_workspace: 1, focused: true, }]; shell.snapshot.workspaces = (1..=3) .map(|id| crate::control::WorkspaceSnapshot { id, name: id.to_string(), output: Some(4), index: id as u32, window_count: 1, visible: id == 1, focused: id == 1, }) .collect(); drop(shell.sync_hub_workspace_feedback(false)); assert_eq!( shell.hub_mode(id), Some(Mode::Idle), "initial snapshot must not flash" ); drop(shell.hub_hover(id, true)); shell.snapshot.outputs[0].active_workspace = 2; drop(shell.sync_hub_workspace_feedback(false)); let first = { let mut runtime = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow_mut(); assert_eq!(runtime.peek, Peek::Workspaces); runtime.hub.advance(Duration::ZERO, true); runtime.transient.workspace_token().unwrap() }; shell.snapshot.outputs[0].active_workspace = 3; drop(shell.sync_hub_workspace_feedback(false)); let latest = { let runtime = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow(); assert_eq!( runtime.hub.sample().opacity, 1., "repeated switches must not restart the fade" ); runtime.transient.workspace_token().unwrap() }; assert_ne!(first, latest); drop(shell.hub_workspace_feedback_expired(id, first)); assert_eq!( shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, Peek::Workspaces ); drop(shell.hub_workspace_feedback_expired(id, latest)); assert_eq!(shell.hub_mode(id), Some(Mode::Peek)); assert_eq!( shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, Peek::Date ); drop(shell.hub_hover(id, false)); shell.snapshot.outputs[0].active_workspace = 1; drop(shell.sync_hub_workspace_feedback(false)); let revision = shell.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .transient .workspace_token() .unwrap(); drop(shell.hub_workspace_feedback_expired(id, revision)); assert_eq!( shell.hub_mode(id), Some(Mode::Idle), "keyboard feedback returns to idle" ); shell.snapshot.outputs[0].active_workspace = 2; drop(shell.sync_hub_workspace_feedback(true)); assert_eq!( shell.hub_mode(id), Some(Mode::Idle), "overview selection must not flash" ); drop(shell.hub_hover(id, true)); shell.snapshot.outputs[0].active_workspace = 3; drop(shell.sync_hub_workspace_feedback(false)); drop(shell.hub_hover(id, false)); assert_eq!( shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, Peek::Date, "leaving early must clear temporary workspace content" ); } #[test] fn native_pointer_reentry_always_peeks_without_resizing_the_surface() { let mut shell = shell(); let id = shell.outputs[0].bar; let allocation = shell.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .allocation; for _ in 0..20 { drop(shell.handle_event(Event::Mouse(mouse::Event::CursorEntered), id)); assert_eq!(shell.hub_mode(id), Some(Mode::Peek)); drop(shell.handle_event(Event::Mouse(mouse::Event::CursorLeft), id)); assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); let mut runtime = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow_mut(); runtime.hub.advance(Duration::from_secs(1), false); assert_eq!(runtime.allocation, allocation); } drop(shell.open_hub(id, status_ui::Menu::Calendar)); drop(shell.handle_event(Event::Mouse(mouse::Event::CursorLeft), id)); drop(shell.handle_event(Event::Mouse(mouse::Event::CursorEntered), id)); assert_eq!(shell.hub_mode(id), Some(Mode::Expanded(Content::Calendar))); } #[test] fn hover_only_peeks_and_respects_preference_and_visibility() { let mut shell = shell(); let id = shell.outputs[0].bar; drop(shell.hub_hover(id, true)); assert_eq!(shell.hub_mode(id), Some(Mode::Peek)); drop(shell.hub_hover(id, true)); assert_eq!(shell.hub_mode(id), Some(Mode::Peek)); drop(shell.hub_hover(id, false)); assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); drop(shell.set_hub_mode(id, Mode::Toast, None)); drop(shell.hub_hover(id, true)); drop(shell.hub_hover(id, false)); assert_eq!(shell.hub_mode(id), Some(Mode::Toast)); drop(shell.close_hub(id)); let mut config = shell.config.clone(); config.panels[0].morph.peek_on_hover = false; drop(shell.apply_config(config)); drop(shell.hub_hover(id, true)); assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); let mut config = shell.config.clone(); config.panels[0].morph.peek_on_hover = true; drop(shell.apply_config(config)); shell.snapshot.outputs.push(crate::control::OutputSnapshot { id: 1, name: "test".into(), active_workspace: 7, focused: true, }); shell.snapshot.windows.push(crate::control::WindowSnapshot { id: 42, workspace: 7, app_id: "test".into(), title: "Test".into(), focused: true, urgent: false, fullscreen: true, maximized: false, floating: false, geometry: None, }); drop(shell.hub_hover(id, true)); assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); } #[test] fn peek_category_and_width_stay_fixed_until_the_next_hover() { let mut shell = shell(); let id = shell.outputs[0].bar; drop(shell.hub_hover(id, true)); assert_eq!( shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, Peek::Date ); assert_eq!( shell.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .hub .target_size(), [200., 44.] ); std::sync::Arc::make_mut(&mut shell.media.snapshot).selected = Some(ferese_ipc::media::Player { title: "A long track title that needs the wider preview".into(), status: ferese_ipc::media::Playback::Paused, ..Default::default() }); drop(shell.hub_hover(id, true)); assert_eq!( shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, Peek::Date ); drop(shell.hub_hover(id, false)); drop(shell.hub_hover(id, true)); let size = shell.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .hub .target_size(); assert_eq!( shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, Peek::Media ); assert!((200. ..=280.).contains(&size[0])); assert_eq!(size[1], 44.); std::sync::Arc::make_mut(&mut shell.media.snapshot).selected = None; drop(shell.hub_hover(id, true)); assert_eq!( shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, Peek::Media ); assert_eq!( shell.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .hub .target_size(), size ); drop(shell.hub_hover(id, false)); drop(shell.hub_hover(id, true)); assert_eq!( shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().peek, Peek::Date ); } #[test] fn date_click_opens_workspaces_in_idle_and_peek_regardless_of_last_tab() { use cosmic::iced::advanced::renderer::Headless; let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( cosmic::iced::Font::default(), cosmic::iced::Pixels(14.), Some("tiny-skia"), )) .unwrap(); let mut app = shell(); let id = app.outputs[0].bar; app.snapshot.windows.clear(); app.snapshot.workspaces.clear(); app.status.battery = None; std::sync::Arc::make_mut(&mut app.media.snapshot).selected = None; for last in [status_ui::Menu::Calendar, status_ui::Menu::Activities] { app.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow_mut() .last_menu = last; for mode in [Mode::Idle, Mode::Peek] { let mut view = app.hub_content(id, mode, 1., None, true, false); let mut tree = widget::Tree::new(&view); let viewport = Rectangle::with_size(Size::new(280., 44.)); let node = view.as_widget_mut().layout( &mut tree, &renderer, &layout::Limits::new(viewport.size(), viewport.size()), ); let mut messages = Vec::new(); for event in [ mouse::Event::ButtonPressed(mouse::Button::Left), mouse::Event::ButtonReleased(mouse::Button::Left), ] { view.as_widget_mut().update( &mut tree, &Event::Mouse(event), Layout::new(&node), mouse::Cursor::Available(viewport.center()), &renderer, &mut clipboard::Null, &mut Shell::new(&mut messages), &viewport, ); } let opened: Vec<_> = messages .iter() .filter_map(|message| match message { cosmic::Action::App(Message::HubOpen(_, menu)) => Some(*menu), _ => None, }) .collect(); assert_eq!( opened, vec![status_ui::Menu::Workspaces], "{mode:?}, last tab {last:?}" ); } } } #[test] fn collapse_remembers_the_last_selected_panel() { let mut shell = shell(); let id = shell.outputs[0].bar; assert_eq!( shell.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .last_menu, status_ui::Menu::Workspaces ); drop(shell.open_hub(id, status_ui::Menu::Calendar)); drop(shell.close_hub(id)); drop(shell.hub_hover(id, true)); assert_eq!( shell.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .last_menu, status_ui::Menu::Calendar ); } #[test] fn header_shortcuts_fit_the_settled_width_and_respect_item_visibility() { let mut shell = shell(); let id = shell.outputs[0].bar; for (playback, counts) in [ (None, [0, 2, 2]), (Some(ferese_ipc::media::Playback::Playing), [0, 2, 2]), (Some(ferese_ipc::media::Playback::Paused), [0, 2, 2]), ] { std::sync::Arc::make_mut(&mut shell.media.snapshot).selected = playback.map(|status| ferese_ipc::media::Player { status, ..Default::default() }); for (width, count) in [240., 340., 400.].into_iter().zip(counts) { shell.config.panels[0].morph.expanded_width = width; let shortcuts = shell.hub_shortcuts(id); assert_eq!(shortcuts.len(), count); assert!(!shortcuts.contains(&ItemKind::NightLight)); assert!(!shortcuts.contains(&ItemKind::Network)); assert_eq!(shortcuts.contains(&ItemKind::Recording), count > 1); assert!(!shortcuts.contains(&ItemKind::DisplayMode)); assert!(!shortcuts.contains(&ItemKind::Audio)); assert!( !shortcuts .iter() .any(|kind| matches!(kind, ItemKind::Battery { .. })) ); } } let before = shell.hub_shortcuts(id); drop(shell.open_hub(id, status_ui::Menu::Calendar)); assert_eq!( shell.hub_shortcuts(id), before, "shortcuts must not shuffle during the spring" ); let panel = &mut shell.config.panels[0]; for item in [&mut panel.start, &mut panel.center, &mut panel.end] .into_iter() .flat_map(|zone| &mut zone.groups) .flat_map(|group| &mut group.items) { if item.kind == ItemKind::Overview { item.visible = false; } if matches!(item.kind, ItemKind::DisplayMode | ItemKind::Recording) { item.overflow = ferese_panel::OverflowPolicy::Always; } } let shortcuts = shell.hub_shortcuts(id); assert!(!shortcuts.contains(&ItemKind::Overview)); assert!(!shortcuts.contains(&ItemKind::DisplayMode)); assert!(!shortcuts.contains(&ItemKind::Recording)); shell.outputs[0].size = Some((200, 600)); assert!( shell.hub_shortcuts(id).is_empty(), "small outputs must keep optional controls in the grid" ); } #[test] fn opening_and_switching_panels_do_not_resize_again_after_settlement() { let panel = Panel::from_preset(PanelStyle::Morph); let mut runtime = Runtime::new(&panel); for menu in [ status_ui::Menu::Media, status_ui::Menu::Calendar, status_ui::Menu::Notifications, status_ui::Menu::Overflow, ] { runtime.retarget(Mode::Expanded(content(menu)), [1000., 700.], Some(menu)); let allocation = runtime.allocation; for _ in 0..200 { runtime.hub.advance(Duration::from_millis(8), false); } assert!(!runtime.hub.active()); assert_eq!(runtime.allocation, allocation); } } #[test] fn attached_shoulders_fit_the_surface_through_morphs_and_reversals() { let mut panel = Panel::from_preset(PanelStyle::Morph); panel.morph.attached = true; for edge in [Edge::Top, Edge::Bottom] { panel.edge = edge; let mut runtime = Runtime::new(&panel); for mode in [ Mode::Peek, Mode::Expanded(Content::Calendar), Mode::Idle, Mode::Peek, Mode::Idle, ] { runtime.retarget(mode, [800., 600.], None); let allocation = runtime.allocation; for _ in 0..18 { runtime.hub.advance(Duration::from_millis(8), false); let sample = runtime.hub.sample(); let bounds = sample.geometry.bounds; let scene = ferese_panel::scene::derive( ferese_panel::scene::Parameters { radius: Some(ferese_panel::shape::AnchoredRadii( sample.geometry.radii.map(|r| r as f32), )), ..panel.shape_parameters() }, ferese_layout::Rect::new( (f64::from(allocation[0]) - bounds.width) / 2., 0., bounds.width, bounds.height, ), &[], ferese_panel::shape::PixelGrid::try_new(1.5).unwrap(), ) .unwrap(); assert_eq!(scene.paint.len(), 3); for primitive in scene.paint { assert!(primitive.bounds.x >= -0.01); assert!( primitive.bounds.x + primitive.bounds.width <= f64::from(allocation[0]) + 0.01 ); } assert_eq!( runtime.allocation, allocation, "no resize during settlement" ); } } } } #[test] fn opening_closing_and_reopening_retain_the_native_surface() { let panel = Panel::from_preset(PanelStyle::Morph); let mut runtime = Runtime::new(&panel); assert_eq!(runtime.allocation, [286, 49]); runtime.retarget( Mode::Expanded(Content::Calendar), [1000., 700.], Some(status_ui::Menu::Calendar), ); let expanded_allocation = runtime.allocation; assert!(expanded_allocation[0] > 340 && expanded_allocation[1] > 230); runtime.hub.advance(Duration::from_millis(50), false); let before = runtime.hub.sample().geometry; runtime.retarget(Mode::Idle, [1000., 700.], None); // A reversal never commits a smaller buffer underneath an unfinished shape. assert_eq!(runtime.allocation, expanded_allocation); assert!(runtime.hub.sample().geometry.bounds.width >= before.bounds.width); runtime.hub.advance(Duration::from_secs(2), false); runtime.hub.advance(Duration::from_millis(120), false); assert_eq!(runtime.allocation, expanded_allocation); assert!(!runtime.hub.active()); runtime.retarget( Mode::Expanded(Content::Calendar), [1000., 700.], Some(status_ui::Menu::Calendar), ); assert_eq!(runtime.allocation, expanded_allocation); } #[test] fn hover_leave_cannot_close_an_expanded_hub_and_reload_clears_its_owner() { let mut shell = shell(); let id = shell.outputs[0].bar; let reservation = shell.config.panels[0].exclusive_zone(false); drop(shell.hub_hover(id, true)); assert_eq!(shell.hub_mode(id), Some(Mode::Peek)); drop(shell.hub_hover(id, false)); assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); drop(shell.update(Message::HubOpen(id, status_ui::Menu::Calendar))); assert!(shell.menu.is_none(), "expanded hub owns no popup surface"); drop(shell.hub_hover(id, false)); assert_eq!(shell.hub_mode(id), Some(Mode::Expanded(Content::Calendar))); assert_eq!(shell.config.panels[0].exclusive_zone(false), reservation); drop(shell.update(Message::HubClose(id))); assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); let mut config = shell.config.clone(); config.panels[0].style = PanelStyle::Floating; drop(shell.apply_config(config)); assert_eq!(shell.hub_mode(id), None); assert_eq!(shell.config.panels[0].style, PanelStyle::Floating); } #[test] fn fullscreen_collapses_immediately_and_old_toast_deadlines_do_not_close_new_content() { let mut shell = shell(); let id = shell.outputs[0].bar; drop(shell.set_hub_mode(id, Mode::Toast, None)); let old = shell.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .revision; drop(shell.open_hub(id, status_ui::Menu::Calendar)); drop(shell.hub_toast_expired(id, old)); assert_eq!(shell.hub_mode(id), Some(Mode::Expanded(Content::Calendar))); drop(shell.suspend_hub(id)); let runtime = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow(); assert_eq!(runtime.hub.mode(), Mode::Idle); assert_eq!(runtime.allocation, [286, 49]); assert!(!runtime.hub.active()); } #[test] fn losing_layer_focus_collapses_only_the_expanded_hub_on_that_output() { use cosmic::iced::event::{PlatformSpecific, wayland}; use wayland_client::Proxy; let mut shell = shell(); let id = shell.outputs[0].bar; let (socket, peer) = std::os::unix::net::UnixStream::pair().unwrap(); let connection = wayland_client::Connection::from_socket(socket).unwrap(); let surface = wayland_client::protocol::wl_surface::WlSurface::inert( connection.backend().downgrade(), ); drop(peer); let unfocused = |id| { cosmic::iced::Event::PlatformSpecific(PlatformSpecific::Wayland( wayland::Event::Layer(wayland::LayerEvent::Unfocused, surface.clone(), id), )) }; drop(shell.open_hub(id, status_ui::Menu::Calendar)); let other = window::Id::unique(); drop(shell.handle_event(unfocused(other), other)); assert_eq!(shell.hub_mode(id), Some(Mode::Expanded(Content::Calendar))); drop(shell.handle_event(unfocused(id), id)); assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); assert_eq!( shell.outputs[0].runtime.hub.as_ref().unwrap().borrow().menu, None ); drop(shell.hub_hover(id, true)); drop(shell.handle_event(unfocused(id), id)); assert_eq!( shell.hub_mode(id), Some(Mode::Peek), "focus changes must not interrupt hover peeks" ); } #[test] fn cursor_leave_closes_peek_and_escape_closes_expanded_content() { use cosmic::iced::keyboard::{Event as KeyEvent, Key, Location, Modifiers, key}; let mut shell = shell(); let id = shell.outputs[0].bar; drop(shell.hub_hover(id, true)); drop(shell.handle_event(Event::Mouse(cosmic::iced::mouse::Event::CursorLeft), id)); assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); drop(shell.open_hub(id, status_ui::Menu::Calendar)); drop(shell.handle_event(Event::Mouse(cosmic::iced::mouse::Event::CursorLeft), id)); assert_eq!(shell.hub_mode(id), Some(Mode::Expanded(Content::Calendar))); drop(shell.handle_event( Event::Keyboard(KeyEvent::KeyPressed { key: Key::Named(key::Named::Escape), modified_key: Key::Named(key::Named::Escape), physical_key: key::Physical::Code(key::Code::Escape), location: Location::Standard, modifiers: Modifiers::empty(), text: None, repeat: false, }), id, )); assert_eq!(shell.hub_mode(id), Some(Mode::Idle)); } #[test] #[ignore = "writes Morph review captures to /tmp/ferese-hub-review"] fn render_hub_review_states() { use cosmic::iced::advanced::renderer::{Headless, Renderer as _}; let backend = std::env::var("FERESE_REVIEW_BACKEND").unwrap_or_else(|_| "wgpu".into()); let mut renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( cosmic::iced::Font::default(), cosmic::iced::Pixels(14.), Some(&backend), )) .unwrap(); std::fs::create_dir_all("/tmp/ferese-hub-review").unwrap(); let mut shell = shell(); let id = shell.outputs[0].bar; std::sync::Arc::make_mut(&mut shell.media.snapshot).selected = Some(ferese_ipc::media::Player { title: "Losing My Mind".into(), artist: "London Grammar".into(), status: ferese_ipc::media::Playback::Playing, length_us: Some(240_000_000), position_us: Some(80_000_000), sampled_at_us: ferese_ipc::media::now_us(), can_control: true, can_pause: true, can_next: true, can_previous: true, can_seek: true, track_id: Some("/track/1".into()), ..Default::default() }); shell.snapshot.outputs = vec![crate::control::OutputSnapshot { id: 4, name: "test".into(), active_workspace: 1, focused: true, }]; shell.snapshot.workspaces = (1..=5) .map(|id| crate::control::WorkspaceSnapshot { id, name: format!("Desk {id}"), output: Some(4), index: id as u32, window_count: (id % 3) as u32, visible: id == 1, focused: id == 1, }) .collect(); shell.snapshot.windows = shell .snapshot .workspaces .iter() .flat_map(|workspace| { (0..workspace.window_count).map(move |n| crate::control::WindowSnapshot { id: workspace.id * 10 + u64::from(n), workspace: workspace.id, app_id: "test".into(), title: format!("Window {}", n + 1), geometry: Some([ f64::from(n) * 650., 0., if n == 0 { 640. } else { 320. }, 720., ]), focused: false, urgent: false, fullscreen: false, maximized: false, floating: false, }) }) .collect(); drop(shell.update_workspace_ui()); for scale in [1., 1.25, 1.5] { shell.outputs[0].scale = scale; let mut date_pixels = None; for (name, mode, menu) in [ ( "activities-battery-full", Mode::Expanded(Content::Activities), Some(status_ui::Menu::Activities), ), ( "activities-battery-low", Mode::Expanded(Content::Activities), Some(status_ui::Menu::Activities), ), ( "activities-battery-light", Mode::Expanded(Content::Activities), Some(status_ui::Menu::Activities), ), ("activities-light", Mode::Idle, None), ("activities-narrow", Mode::Idle, None), ("activities-single", Mode::Idle, None), ("activities-split", Mode::Idle, None), ("activities-translucent", Mode::Idle, None), ( "activities-list", Mode::Expanded(Content::Activities), Some(status_ui::Menu::Activities), ), ("idle", Mode::Idle, None), ("idle-attached", Mode::Idle, None), ("idle-attached-bottom", Mode::Idle, None), ("peek-attached", Mode::Peek, None), ("peek-attached-bottom", Mode::Peek, None), ( "calendar-attached", Mode::Expanded(Content::Calendar), Some(status_ui::Menu::Calendar), ), ( "calendar-attached-bottom", Mode::Expanded(Content::Calendar), Some(status_ui::Menu::Calendar), ), ("idle-translucent", Mode::Idle, None), ("idle-long-title", Mode::Idle, None), ("idle-recording", Mode::Idle, None), ("idle-low-battery", Mode::Idle, None), ("idle-recording-low-battery", Mode::Idle, None), ("idle-saving-low-battery", Mode::Idle, None), ("peek-recording", Mode::Peek, None), ("peek-low-battery", Mode::Peek, None), ("peek-recording-low-battery", Mode::Peek, None), ("peek-saving-low-battery", Mode::Peek, None), ("peek-date", Mode::Peek, None), ("peek-date-hover", Mode::Peek, None), ("peek-date-pressed", Mode::Peek, None), ("peek-media", Mode::Peek, None), ("peek-workspaces", Mode::Peek, None), ( "workspaces", Mode::Expanded(Content::Workspaces), Some(status_ui::Menu::Workspaces), ), ( "music", Mode::Expanded(Content::Media), Some(status_ui::Menu::Media), ), ( "wifi-connected", Mode::Expanded(Content::Controls), Some(status_ui::Menu::Network), ), ( "wifi-off", Mode::Expanded(Content::Controls), Some(status_ui::Menu::Network), ), ( "battery", Mode::Expanded(Content::Controls), Some(status_ui::Menu::Battery), ), ( "calendar", Mode::Expanded(Content::Calendar), Some(status_ui::Menu::Calendar), ), ( "alerts", Mode::Expanded(Content::Notifications), Some(status_ui::Menu::Notifications), ), ( "controls", Mode::Expanded(Content::Controls), Some(status_ui::Menu::Overflow), ), ( "controls-narrow", Mode::Expanded(Content::Controls), Some(status_ui::Menu::Overflow), ), ( "controls-wide", Mode::Expanded(Content::Controls), Some(status_ui::Menu::Overflow), ), ( "system", Mode::Expanded(Content::Controls), Some(status_ui::Menu::System), ), ( "spring-60", Mode::Expanded(Content::Calendar), Some(status_ui::Menu::Calendar), ), ( "spring-120", Mode::Expanded(Content::Calendar), Some(status_ui::Menu::Calendar), ), ( "spring-190", Mode::Expanded(Content::Calendar), Some(status_ui::Menu::Calendar), ), ( "spring-250", Mode::Expanded(Content::Calendar), Some(status_ui::Menu::Calendar), ), ( "spring-400", Mode::Expanded(Content::Calendar), Some(status_ui::Menu::Calendar), ), ( "spring-900", Mode::Expanded(Content::Calendar), Some(status_ui::Menu::Calendar), ), ] { std::sync::Arc::make_mut(&mut shell.media.snapshot) .selected .as_mut() .unwrap() .title = if name == "idle-long-title" { "A very long track title that must truncate before the equalizer and never hide its bars".into() } else { "Losing My Mind".into() }; shell.recorder.state = if name.contains("recording") { crate::recording::State::Recording( std::time::Instant::now() - Duration::from_secs(65), ) } else if name.contains("saving") { crate::recording::State::Saving } else { crate::recording::State::Idle }; shell.status.battery = name.contains("low-battery") .then(|| crate::status::Battery { percent: 12, status: "Discharging".into(), }); if name.starts_with("activities-battery-") { shell.status.battery = Some(crate::status::Battery { percent: if name.ends_with("full") { 100 } else if name.ends_with("low") { 9 } else { 87 }, status: if name.ends_with("full") { "Full" } else if name.ends_with("low") { "Discharging" } else { "Charging" } .into(), }); } let document = ferese_config::Document::parse( if matches!(name, "activities-light" | "activities-battery-light") { "theme { mode light; }" } else { "theme { mode dark; }" }, ) .unwrap(); let candidate = ferese_config::theme::resolve( &document, std::path::Path::new("/tmp"), jiff::Timestamp::now(), |_| Err("unexpected theme read".into()), ) .unwrap(); shell.config.apply_theme(&candidate.theme); shell.config.theme.bar_background[3] = if name.ends_with("translucent") { 128 } else { 255 }; let panel = &mut shell.config.panels[0]; panel.morph.attached = name.contains("attached"); panel.edge = if name.ends_with("bottom") { Edge::Bottom } else { Edge::Top }; panel.morph.expanded_width = match name { "controls-narrow" => 240., "controls-wide" => 400., _ => 340., }; if name.starts_with("wifi-") { shell.status.network = Some(crate::status::Network { enabled: name == "wifi-connected", connection: (name == "wifi-connected").then(|| "Home network".into()), signal: 80, }); } shell.outputs[0].size = Some(( if name == "activities-narrow" { 240 } else { 1200 }, 800, )); let workspace_width = shell.hub_workspace_width(id); let hub = shell.outputs[0].runtime.hub.as_ref().unwrap(); let (mut sample, mut allocation) = { let mut hub = hub.borrow_mut(); let animation_time = name .strip_prefix("spring-") .map(|ms| ms.parse::().unwrap()); *hub = Runtime::new(&shell.config.panels[0]); hub.workspace_width = if workspace_width > 0. { workspace_width + 10. } else { 0. }; hub.peek = match name { _ if shell.hub_priority().is_some() => shell.hub_priority().unwrap(), "peek-media" => Peek::Media, "peek-workspaces" => Peek::Workspaces, _ => Peek::Date, }; hub.peek_width = if shell.hub_priority().is_some() { shell.hub_peek_content(id).1 } else if name == "peek-media" { 260. } else { 200. }; hub.retarget( mode, [ if name == "activities-narrow" { 240. } else { 1200. }, 800., ], menu, ); hub.hub.advance( Duration::from_millis(animation_time.unwrap_or(0)), animation_time.is_none(), ); (hub.hub.sample(), hub.allocation) }; if name.starts_with("activities") { seed_activities(&mut shell, if name == "activities-single" { 1 } else { 3 }); drop(shell.sync_hub_activity()); let mut hub = shell.outputs[0].runtime.hub.as_ref().unwrap().borrow_mut(); hub.hub.advance(Duration::from_secs(2), true); hub.activities.advance(Duration::ZERO, true); sample = hub.hub.sample(); allocation = hub.allocation; } else { shell.activities.snapshot = std::sync::Arc::new(Default::default()); } let hub = shell.outputs[0].runtime.hub.as_ref().unwrap(); if mode == Mode::Idle || mode == Mode::Expanded(Content::Controls) || name == "peek-workspaces" { let mut measured = shell.view_hub(id); let mut measured_tree = widget::Tree::new(&measured); measured.as_widget_mut().layout( &mut measured_tree, &renderer, &layout::Limits::new( Size::ZERO, Size::new(allocation[0] as f32, allocation[1] as f32), ), ); let idle_width_limit = f64::from(shell.hub_idle_width_limit(id)); let mut runtime = hub.borrow_mut(); runtime.hub.advance(Duration::ZERO, true); sample = runtime.hub.sample(); if name == "peek-workspaces" { assert!( sample.geometry.bounds.width < 200., "short workspace feedback should fit its dots and name" ); } if mode == Mode::Idle { assert!( sample.geometry.bounds.width <= idle_width_limit, "idle should fit its content instead of retaining a fixed text allowance" ); } if name == "controls" || name.starts_with("wifi-") { assert!( sample.geometry.bounds.height < shell.config.panels[0].morph.controls_height, "{name} should shrink to its content" ); } } let mut view = shell.view_hub_at(id, sample, menu, None); let mut tree = widget::Tree::new(&view); let viewport = Rectangle::with_size(Size::new(allocation[0] as f32, allocation[1] as f32)); let node = view.as_widget_mut().layout( &mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()), ); if shell.hub_priority().is_some() && !mode.expanded() { #[derive(Default)] struct TextBounds(Vec<(String, Rectangle)>); impl widget::Operation for TextBounds { fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { visit(self); } fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) { self.0.push((text.to_owned(), bounds)); } } let mut bounds = TextBounds::default(); view.as_widget_mut().operate( &mut tree, Layout::new(&node), &renderer, &mut bounds, ); let left = (viewport.width - sample.geometry.bounds.width as f32) / 2.; let right = left + sample.geometry.bounds.width as f32; for (label, rect) in &bounds.0 { assert!( rect.x >= left && rect.x + rect.width <= right + 0.1, "{name}: {label} must fit inside shell: {rect:?}, {left}..{right}" ); assert!( rect.height <= 28., "{name}: {label} must stay on one line: {rect:?}" ); } assert!( !bounds.0.iter().any(|(text, _)| text == "Losing My Mind"), "media cannot displace priority status" ); // Exercise the nested battery button as well as its recording parent. if name == "peek-recording-low-battery" { for (label, expected) in [ ("12", status_ui::Menu::Battery), ("Recording", status_ui::Menu::Overflow), ] { let rect = bounds.0.iter().find(|(text, _)| text == label).unwrap().1; let cursor = mouse::Cursor::Available(rect.center()); let mut messages = Vec::new(); for event in [ mouse::Event::ButtonPressed(mouse::Button::Left), mouse::Event::ButtonReleased(mouse::Button::Left), ] { view.as_widget_mut().update( &mut tree, &Event::Mouse(event), Layout::new(&node), cursor, &renderer, &mut clipboard::Null, &mut Shell::new(&mut messages), &viewport, ); } let opened: Vec<_> = messages .iter() .filter_map(|message| match message { cosmic::Action::App(Message::HubOpen(_, menu)) => Some(*menu), _ => None, }) .collect(); assert_eq!( opened, vec![expected], "{label} must open exactly its own controls" ); } } } if name == "peek-date" || name == "peek-workspaces" { let mut messages = Vec::new(); view.as_widget_mut().update( &mut tree, &Event::Mouse(mouse::Event::WheelScrolled { delta: mouse::ScrollDelta::Lines { x: 0., y: -1. }, }), Layout::new(&node), mouse::Cursor::Available(cosmic::iced::Point::new( viewport.width / 2., 22., )), &renderer, &mut clipboard::Null, &mut Shell::new(&mut messages), &viewport, ); assert_eq!( messages .iter() .filter(|message| matches!( message, cosmic::Action::App(Message::HubWorkspaceScroll(_, _)) )) .count(), 1, "peek must handle a wheel event exactly once" ); } let cursor = if name == "peek-date-hover" || name == "peek-date-pressed" { let position = cosmic::iced::Point::new(viewport.width / 2., viewport.height / 2.); let cursor = mouse::Cursor::Available(position); let mut messages = Vec::new(); let event = if name == "peek-date-pressed" { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) } else { Event::Mouse(mouse::Event::CursorMoved { position }) }; view.as_widget_mut().update( &mut tree, &event, Layout::new(&node), cursor, &renderer, &mut clipboard::Null, &mut Shell::new(&mut messages), &viewport, ); cursor } else { mouse::Cursor::Unavailable }; let theme = shell.config.theme.palette().native_theme(); renderer.reset(viewport); view.as_widget().draw( &tree, &mut renderer, &theme, &cosmic::iced::advanced::renderer::Style { text_color: crate::color(shell.config.theme.text_primary), icon_color: crate::color(shell.config.theme.text_primary), scale_factor: scale, }, Layout::new(&node), cursor, &viewport, ); let physical = Size::new( (allocation[0] as f64 * scale).ceil() as u32, (allocation[1] as f64 * scale).ceil() as u32, ); let pixels = renderer.screenshot(physical, scale as f32, cosmic::iced::Color::TRANSPARENT); assert!(pixels.chunks_exact(4).any(|pixel| pixel[3] > 0)); if name == "idle-translucent" { let x = (((viewport.width + sample.geometry.bounds.width as f32) / 2. - 8.) * scale as f32) as usize; let y = (sample.geometry.bounds.height * scale / 2.) as usize; let alpha = pixels[(y * physical.width as usize + x) * 4 + 3]; assert!( alpha.abs_diff(128) <= 2, "Morph must inherit theme alpha, got {alpha}" ); } if name == "idle-long-title" { let right = (viewport.width + sample.geometry.bounds.width as f32) / 2.; let left_x = ((right - 28.) * scale as f32).floor() as usize; let right_x = ((right - 14.) * scale as f32).ceil() as usize; let accent = shell.config.theme.accent; assert!( pixels.chunks_exact(4).enumerate().any(|(index, pixel)| { let x = index % physical.width as usize; (left_x..right_x).contains(&x) && pixel[3] > 200 && pixel[..3] .iter() .zip(accent[..3].iter()) .all(|(a, b)| a.abs_diff(*b) <= 2) }), "long titles must leave the equalizer visible at the right edge" ); } if name == "peek-date" { date_pixels = Some(pixels.clone()); } else if name == "peek-date-hover" || name == "peek-date-pressed" { assert_eq!( Some(&pixels), date_pixels.as_ref(), "Peek must not paint a hover or pressed overlay" ); } image_codec::save_buffer( format!("/tmp/ferese-hub-review/{backend}-{name}-{scale}.png"), &pixels, physical.width, physical.height, image_codec::ColorType::Rgba8, ) .unwrap(); } } } #[test] fn content_layout_stays_fixed_through_spring_settlement_and_rebuilds() { use cosmic::iced::advanced::renderer::Headless; #[derive(Default)] struct Bounds { content: Option, text: Vec<(String, Rectangle)>, } impl widget::Operation for Bounds { fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { visit(self); } fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) { if id == Some(&widget::Id::new("ferese-hub-content")) { self.content = Some(bounds); } } fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) { self.text.push((text.to_owned(), bounds)); } } fn close(a: Rectangle, b: Rectangle) { for (a, b) in [ (a.x, b.x), (a.y, b.y), (a.width, b.width), (a.height, b.height), ] { assert!( (a - b).abs() < 0.001, "content moved during settlement: {a} vs {b}" ); } } let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( cosmic::iced::Font::default(), cosmic::iced::Pixels(14.), Some("tiny-skia"), )) .unwrap(); let mut shell = shell(); let id = shell.outputs[0].bar; for edge in [Edge::Top, Edge::Bottom] { shell.config.panels[0].edge = edge; for scale in [1., 1.25, 1.5] { shell.outputs[0].scale = scale; for mode in [Mode::Peek, Mode::Expanded(Content::Calendar), Mode::Idle] { let runtime = shell.outputs[0].runtime.hub.as_ref().unwrap(); runtime.borrow_mut().retarget(mode, [1200., 800.], None); let allocation = runtime.borrow().allocation; let mut baseline: Option = None; let mut elapsed = 0; for at in [180, 250, 400, 650, 1000, 1600, 1600] { let sample = { let mut runtime = runtime.borrow_mut(); runtime .hub .advance(Duration::from_millis(at - elapsed), false); elapsed = at; runtime.hub.sample() }; let mut view = shell.view_hub_at(id, sample, None, None); let mut tree = widget::Tree::new(&view); let node = view.as_widget_mut().layout( &mut tree, &renderer, &layout::Limits::new( Size::ZERO, Size::new(allocation[0] as f32, allocation[1] as f32), ), ); let mut bounds = Bounds::default(); view.as_widget_mut().operate( &mut tree, Layout::new(&node), &renderer, &mut bounds, ); assert!(bounds.content.is_some()); assert!(!bounds.text.is_empty()); if let Some(baseline) = &baseline { close(bounds.content.unwrap(), baseline.content.unwrap()); assert_eq!(bounds.text.len(), baseline.text.len()); for ((text, rect), (expected_text, expected_rect)) in bounds.text.iter().zip(&baseline.text) { assert_eq!(text, expected_text); close(*rect, *expected_rect); } } else { baseline = Some(bounds); } } } } } } #[test] fn hub_dimensions_snap_before_centering_on_odd_and_even_backing_surfaces() { use cosmic::iced::advanced::renderer::Headless; #[derive(Default)] struct Bounds(Option); impl widget::Operation for Bounds { fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) { children(self); } fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) { if id == Some(&widget::Id::new("ferese-panel-hub")) { self.0 = Some(bounds); } } } let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( cosmic::iced::Font::default(), cosmic::iced::Pixels(14.), Some("tiny-skia"), )) .unwrap(); let mut shell = shell(); let id = shell.outputs[0].bar; let sample = shell.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow() .hub .sample(); for scale in [1., 1.25, 1.5, 1.75] { shell.outputs[0].scale = scale; for backing_width in [329., 330., 401.] { for delta in [-0.02, 0., 0.02] { let mut sample = sample; sample.geometry.bounds.width = 320. + delta; sample.geometry.bounds.height = 48. + delta; let mut view = shell.view_hub_at(id, sample, None, None); let mut tree = widget::Tree::new(&view); let node = view.as_widget_mut().layout( &mut tree, &renderer, &layout::Limits::new(Size::ZERO, Size::new(backing_width, 230.)), ); let mut bounds = Bounds::default(); view.as_widget_mut().operate( &mut tree, Layout::new(&node), &renderer, &mut bounds, ); let bounds = bounds.0.unwrap(); assert_eq!( bounds.width, 320., "scale={scale}, backing={backing_width}, delta={delta}" ); assert_eq!( bounds.height, 48., "scale={scale}, backing={backing_width}, delta={delta}" ); } } } } #[test] fn native_input_follows_the_animated_hub_and_bottom_anchor_mirrors_it() { use cosmic::iced::advanced::renderer::Headless; let renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( cosmic::iced::Font::default(), cosmic::iced::Pixels(14.), Some("tiny-skia"), )) .unwrap(); let mut shell = shell(); let id = shell.outputs[0].bar; drop(shell.open_hub(id, status_ui::Menu::Calendar)); // Render the initial sample while the backing surface is already expanded. let hub = shell.outputs[0].runtime.hub.as_ref().unwrap(); let sample = hub.borrow().hub.sample(); for edge in [Edge::Top, Edge::Bottom] { shell.config.panels[0].edge = edge; let mut view = shell.view_hub_at(id, sample, Some(status_ui::Menu::Calendar), None); let mut tree = widget::Tree::new(&view); let viewport = Rectangle::with_size(Size::new(340., 230.)); let mut node = view.as_widget_mut().layout( &mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()), ); let mut messages = Vec::new(); let event = Event::Window(window::Event::RedrawRequested(Instant::now())); for _ in 0..2 { let mut update = Shell::new(&mut messages); view.as_widget_mut().update( &mut tree, &event, Layout::new(&node), mouse::Cursor::Unavailable, &renderer, &mut clipboard::Null, &mut update, &viewport, ); if update.is_layout_invalid() { node = view.as_widget_mut().layout( &mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()), ); } } let scene = messages .iter() .find_map(|message| match message { cosmic::Action::App(Message::PanelSurfaceChanged(_, scene)) => Some(scene), _ => None, }) .expect("native material and input scene"); assert!(scene.contains([170., if edge == Edge::Top { 17. } else { 213. }])); assert!( !scene.contains([20., 17.]), "unused backing space is click-through" ); assert!( !scene.contains([170., 100.]), "target geometry must not steal input before it is visible" ); assert_eq!(scene.damage.len(), 1); assert!(scene.damage[0].width <= 151.); } } #[test] fn each_animation_frame_publishes_its_measured_shape_without_event_replay() { use cosmic::iced::advanced::renderer::Headless; use cosmic::iced::runtime::UserInterface; let mut renderer = cosmic::iced::futures::executor::block_on(cosmic::Renderer::new( cosmic::iced::Font::default(), cosmic::iced::Pixels(14.), Some("tiny-skia"), )) .unwrap(); let mut shell = shell(); let id = shell.outputs[0].bar; drop(shell.open_hub(id, status_ui::Menu::Calendar)); let start = Instant::now() + Duration::from_secs(1); shell.outputs[0] .runtime .hub .as_ref() .unwrap() .borrow_mut() .sampled = start; let mut ui = UserInterface::build( shell.view_layer(id), Size::new(340., 248.), Default::default(), &mut renderer, ); let mut last_width = 0.; // Sample before the spring tail rounds to the same physical pixel. for frame in 1..=8 { let mut messages = Vec::new(); ui.update( &[Event::Window(window::Event::RedrawRequested( start + Duration::from_millis(frame * 8), ))], mouse::Cursor::Unavailable, &mut renderer, &mut clipboard::Null, &mut messages, ); let scene = messages .iter() .find_map(|message| match message { cosmic::Action::App(Message::PanelSurfaceChanged(_, scene)) => Some(scene), _ => None, }) .unwrap_or_else(|| { panic!("frame {frame} must publish its freshly laid-out native shape") }); let width = scene.paint[0].bounds.width; assert!( width > last_width, "frame {frame}: {width} must exceed {last_width}" ); last_width = width; } } }