From 305f49a62fd1cee63e74e0904bd3b78f4732e3f0 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Tue, 13 Oct 2026 10:35:43 +0100 Subject: [PATCH] Fix panel review findings and polkit library lifetime --- crates/ferese-panel/src/morph.rs | 6 +- crates/ferese-panel/src/panel.rs | 10 + crates/ferese-panel/src/panel/layout.rs | 75 +++ crates/ferese-theme/src/panel_surface.rs | 99 ++++ shell/ferese-polkit-agent/src/session.rs | 33 +- shell/ferese-settings/src/panel_preview.rs | 16 +- shell/ferese-shell/src/activities.rs | 20 +- shell/ferese-shell/src/bar.rs | 138 +----- shell/ferese-shell/src/bar/presentation.rs | 469 +----------------- shell/ferese-shell/src/main.rs | 35 +- shell/ferese-shell/src/panel_hub.rs | 189 ++++++- .../ferese-shell/src/panel_hub/workspaces.rs | 15 +- shell/ferese-shell/src/surfaces.rs | 7 + 13 files changed, 436 insertions(+), 676 deletions(-) diff --git a/crates/ferese-panel/src/morph.rs b/crates/ferese-panel/src/morph.rs index 565dc79..98abd7c 100644 --- a/crates/ferese-panel/src/morph.rs +++ b/crates/ferese-panel/src/morph.rs @@ -178,9 +178,9 @@ impl Hub { pub fn pin_radii(&mut self, pins: Option<[f64; 4]>) -> Result<(), &'static str> { if pins.is_some_and(|pins| { pins.iter() - .any(|value| !value.is_finite() || !(0. ..=128.).contains(value)) + .any(|value| !value.is_finite() || !(0. ..=f64::from(f32::MAX)).contains(value)) }) { - return Err("hub radius pins must be finite values between 0 and 128"); + return Err("hub radius pins must be finite, nonnegative pixel values representable as f32"); } self.pins = pins; let mut target = self.motion.target(); @@ -307,7 +307,7 @@ impl Hub { let mut geometry = self.motion.current(); geometry.bounds.width = geometry.bounds.width.clamp(0., self.available[0]); geometry.bounds.height = geometry.bounds.height.clamp(0., self.available[1]); - geometry.radii = geometry.radii.map(|radius| radius.max(0.)); + geometry.radii = geometry.radii.map(|radius| radius.clamp(0., f64::from(f32::MAX))); Sample { geometry, previous: self.previous, diff --git a/crates/ferese-panel/src/panel.rs b/crates/ferese-panel/src/panel.rs index 5133b21..52bd216 100644 --- a/crates/ferese-panel/src/panel.rs +++ b/crates/ferese-panel/src/panel.rs @@ -136,6 +136,16 @@ pub enum ItemKind { } impl ItemKind { + /// Smallest useful allocation before a flexible control moves into overflow. + pub fn minimum_width(self) -> Option { + match self { + Self::FocusedWindow => Some(48.), + Self::Workspaces { .. } => Some(24.), + Self::Media => Some(112.), + _ => None, + } + } + pub fn label(self) -> &'static str { match self { Self::Overview => "Overview", diff --git a/crates/ferese-panel/src/panel/layout.rs b/crates/ferese-panel/src/panel/layout.rs index a47825b..41d75c6 100644 --- a/crates/ferese-panel/src/panel/layout.rs +++ b/crates/ferese-panel/src/panel/layout.rs @@ -222,6 +222,41 @@ pub fn resolve_with_gap( } } } + // Removing the larger side may make an earlier eviction unnecessary. Give + // mandatory items first refusal, then restore by descending priority. + for mandatory in [true, false] { + for (_, item) in candidates.iter().rev() { + if item.overflow == OverflowPolicy::Always + || (item.overflow == OverflowPolicy::Never) != mandatory + || !matches!(result.items.get(&item.id), Some(Placement::Overflow)) + { + continue; + } + let Some(measured) = measurements.get(&item.id) else { + continue; + }; + let Some(&(representation, width)) = measured.alternatives.iter().min_by(|a, b| a.1.total_cmp(&b.1)) else { + continue; + }; + result.items.insert( + item.id.clone(), + Placement::Visible { + representation, + width: measured.minimum.map_or(width, |minimum| minimum.min(width)), + }, + ); + result.overflow.retain(|id| id != &item.id); + result.update_widths(panel, overflow_width); + if !fits(&result) { + result.items.insert(item.id.clone(), Placement::Overflow); + result.overflow.push(item.id.clone()); + result.update_widths(panel, overflow_width); + } + } + } + result.forced_overflow = ordered.iter().any(|item| { + item.overflow == OverflowPolicy::Never && matches!(result.items.get(&item.id), Some(Placement::Overflow)) + }); // A reduction on the shorter side can leave unused room after the other // side shrinks. Restore preferred representations before expanding titles. for (_, item) in candidates.iter().rev() { @@ -294,6 +329,46 @@ pub fn resolve_with_gap( mod tests { use super::*; + #[test] + fn asymmetric_overflow_restores_items_that_fit_after_the_larger_side_yields() { + use super::super::{Group, Item, ItemKind}; + for mirrored in [false, true] { + for policy in [OverflowPolicy::Auto, OverflowPolicy::Never, OverflowPolicy::Always] { + let group = |id: &str, priority, overflow| { + let mut item = Item::new(id, ItemKind::Clock); + item.priority = priority; + item.overflow = overflow; + let mut group = Group::new(id, vec![item], [0, 0], 0.); + group.island_padding = 0.; + group + }; + let mut panel = Panel::default(); + panel.start.groups = vec![group("small", 0, policy)]; + panel.center.groups = vec![group("center", 100, OverflowPolicy::Never)]; + panel.end.groups = vec![group("large", 50, policy)]; + if mirrored { + std::mem::swap(&mut panel.start, &mut panel.end); + } + let measurements: Vec<_> = [("small", 40.), ("center", 40.), ("large", 200.)] + .into_iter() + .map(|(id, width)| Measurement { + id: ItemId(id.into()), + alternatives: vec![(Representation::Wide, width)], + minimum: None, + }) + .collect(); + let resolution = resolve(&panel, &measurements, 300., 32.); + assert!(resolution.fits(300.)); + assert_eq!( + matches!(resolution.items[&ItemId("small".into())], Placement::Visible { .. }), + policy != OverflowPolicy::Always + ); + assert!(resolution.overflow.contains(&ItemId("large".into()))); + assert_eq!(resolution.forced_overflow, policy == OverflowPolicy::Never); + } + } + } + #[test] fn zone_width_ignores_empty_groups_and_gaps_before_hidden_items() { use super::super::{Group, Item, ItemKind}; diff --git a/crates/ferese-theme/src/panel_surface.rs b/crates/ferese-theme/src/panel_surface.rs index ca1ece0..309890e 100644 --- a/crates/ferese-theme/src/panel_surface.rs +++ b/crates/ferese-theme/src/panel_surface.rs @@ -349,6 +349,105 @@ mod tests { use cosmic::iced::{Font, Pixels}; use ferese_panel::shape::Style; + #[test] + fn shared_surface_publishes_layout_changes_and_leaves_native_fill_to_the_compositor() { + use cosmic::iced::advanced::clipboard; + use std::cell::RefCell; + let mut renderer = cosmic::iced::futures::executor::block_on(::new( + Font::default(), + Pixels(14.), + Some("tiny-skia"), + )) + .unwrap(); + let ids = [widget::Id::unique(), widget::Id::unique()]; + let mut tree = None; + let viewport = Rectangle::with_size(Size::new(200., 32.)); + for (style, width, native) in [ + (Style::Floating, 40., false), + (Style::Floating, 40., true), + (Style::Floating, 60., true), + (Style::Flat, 60., true), + ] { + let content = cosmic::widget::row::with_children([ + cosmic::widget::container(cosmic::widget::Space::new().width(width).height(32)) + .id(ids[0].clone()) + .into(), + cosmic::widget::container(cosmic::widget::Space::new().width(40).height(32)) + .id(ids[1].clone()) + .into(), + ]) + .spacing(20); + let published = RefCell::new(Vec::new()); + let mut view = frame( + cosmic::widget::container(content).width(200).height(32).into(), + Options { + parameters: Parameters { + style, + height: 32., + ..Default::default() + }, + bounds: None, + groups: vec![ + (ids[0].clone(), ZonePosition::Start), + (ids[1].clone(), ZonePosition::End), + ], + scale: 1.25, + high_contrast: false, + background: Color::WHITE, + border: Color::TRANSPARENT, + divider: Color::TRANSPARENT, + native, + }, + |scene| published.borrow_mut().push(scene.clone()), + Some(Box::new(Clone::clone)), + ); + let tree = tree.get_or_insert_with(|| widget::Tree::new(&view)); + tree.diff(&mut view); + let node = view + .as_widget_mut() + .layout(tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size())); + let mut messages = Vec::new(); + for _ in 0..3 { + view.as_widget_mut().update( + tree, + &Event::Window(cosmic::iced::window::Event::RedrawRequested(std::time::Instant::now())), + Layout::new(&node), + mouse::Cursor::Unavailable, + &renderer, + &mut clipboard::Null, + &mut Shell::new(&mut messages), + &viewport, + ); + } + assert_eq!(published.borrow().len(), 3); + assert_eq!( + messages.len(), + usize::from(!(style == Style::Floating && width == 40. && native)) + ); + let scene = published.borrow()[0].clone(); + assert_eq!(scene.input.len(), if style == Style::Floating { 2 } else { 1 }); + if style == Style::Floating { + assert!(scene.distance([width as f64 + 10., 16.]) > 0., "the gap stays empty"); + } + renderer.reset(viewport); + view.as_widget().draw( + tree, + &mut renderer, + &Theme::default(), + &renderer::Style { + text_color: Color::WHITE, + icon_color: Color::WHITE, + scale_factor: 1.25, + }, + Layout::new(&node), + mouse::Cursor::Unavailable, + &viewport, + ); + let pixels = renderer.screenshot(Size::new(250, 40), 1.25, Color::TRANSPARENT); + assert_eq!(pixels.chunks_exact(4).any(|pixel| pixel[3] > 0), !native); + } + } + #[test] fn border_coverage_tracks_physical_scale_and_high_contrast() { let mut renderer = cosmic::iced::futures::executor::block_on(::new( diff --git a/shell/ferese-polkit-agent/src/session.rs b/shell/ferese-polkit-agent/src/session.rs index 3353947..34cb8db 100644 --- a/shell/ferese-polkit-agent/src/session.rs +++ b/shell/ferese-polkit-agent/src/session.rs @@ -3,7 +3,7 @@ use std::io::{BufRead, BufReader, BufWriter, Write}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::Sender; -use std::sync::{Arc, mpsc}; +use std::sync::{Arc, OnceLock, mpsc}; use std::time::Duration; use glib::gobject_ffi; @@ -22,8 +22,6 @@ enum SessionEvent { type Object = *mut c_void; struct Api { - _agent: Library, - _polkit: Library, user_new: unsafe extern "C" fn(c_int) -> Object, session_new: unsafe extern "C" fn(Object, *const c_char) -> Object, initiate: unsafe extern "C" fn(Object), @@ -34,8 +32,17 @@ struct Api { impl Api { fn load() -> Result { unsafe { - let agent = Library::new("libpolkit-agent-1.so.0").map_err(|e| e.to_string())?; - let polkit = Library::new("libpolkit-gobject-1.so.0").map_err(|e| e.to_string())?; + // GLib keeps registered types and their function pointers for the + // lifetime of the process, including between authentication sessions. + static LIBRARIES: OnceLock> = OnceLock::new(); + let (agent, polkit) = LIBRARIES + .get_or_init(|| { + let agent = Library::new("libpolkit-agent-1.so.0").map_err(|e| e.to_string())?; + let polkit = Library::new("libpolkit-gobject-1.so.0").map_err(|e| e.to_string())?; + Ok((agent, polkit)) + }) + .as_ref() + .map_err(Clone::clone)?; let user_new = *polkit.get(b"polkit_unix_user_new\0").map_err(|e| e.to_string())?; let session_new = *agent.get(b"polkit_agent_session_new\0").map_err(|e| e.to_string())?; let initiate = *agent @@ -47,8 +54,6 @@ impl Api { let cancel = *agent.get(b"polkit_agent_session_cancel\0").map_err(|e| e.to_string())?; Ok(Self { - _agent: agent, - _polkit: polkit, user_new, session_new, initiate, @@ -460,6 +465,20 @@ mod tests { } } + #[test] + fn registered_identity_types_survive_between_api_loads() { + for _ in 0..3 { + let api = Api::load().unwrap(); + // SAFETY: the identity constructor accepts the current uid and + // returns an owned GObject reference. + unsafe { + let identity = (api.user_new)(libc::getuid() as c_int); + assert!(!identity.is_null()); + gobject_ffi::g_object_unref(identity.cast()); + } + } + } + #[test] fn retries_stop_after_three_failures_even_when_the_account_changes() { let mut api = Api::load().unwrap(); diff --git a/shell/ferese-settings/src/panel_preview.rs b/shell/ferese-settings/src/panel_preview.rs index 23a331c..e9450fb 100644 --- a/shell/ferese-settings/src/panel_preview.rs +++ b/shell/ferese-settings/src/panel_preview.rs @@ -157,11 +157,7 @@ impl App { samples.push(Sample { id: item.id.clone(), representation, - minimum: match item.kind { - ItemKind::Workspaces { .. } => Some(24.), - ItemKind::Media => Some(112.), - _ => None, - }, + minimum: item.kind.minimum_width(), view: self.preview_control(item, representation, None), }); } @@ -731,7 +727,15 @@ mod tests { .expect("sample title remains visible") .1; assert!((center.center_x() - width / 2.).abs() < 0.1, "{center:?} at {width}"); - assert!((center.width - natural).abs() < 0.1, "sample title must not be clipped"); + // Match the shell: titles yield space down to their shared minimum. + assert!(center.width >= title.kind.minimum_width().unwrap().min(natural) - 0.1); + assert!(center.width <= natural + 0.1); + if width == 1200. { + assert!( + (center.width - natural).abs() < 0.1, + "restore the title when space permits" + ); + } for (id, side) in &bounds.items { if id == &title.id { continue; diff --git a/shell/ferese-shell/src/activities.rs b/shell/ferese-shell/src/activities.rs index a0e0299..513af87 100644 --- a/shell/ferese-shell/src/activities.rs +++ b/shell/ferese-shell/src/activities.rs @@ -315,9 +315,7 @@ impl FereseShell { let entries = list_entries(&self.activities.snapshot, &self.activities.native, now, name); let workspace_width = self.hub_workspace_width(output.bar) as f32; let panel = self.panel_for_bar(output.bar); - let available = output.size.map_or(1280., |size| size.0 as f32) - - 2. * panel.resolved_geometry().side_margins as f32 - - if panel.morph.attached { 24. } else { 0. } + let available = super::panel_hub::content_width(panel, output.size) as f32 - if workspace_width > 0. { workspace_width + 10. } else { @@ -331,7 +329,7 @@ impl FereseShell { .copied() .filter(|entry| entry.eligible(now, dnd) && entry.data.source != ferese_activities::Source::Media) .collect(); - let compact = if self.activities_configured(output.bar) { + let compact = if self.compact_activities_configured(output.bar) { compact } else { Vec::new() @@ -421,7 +419,7 @@ impl FereseShell { routed && (list || self.activities.flat.get(&output.bar).is_some_and(|presentation| { - self.activities_configured(output.bar) + self.compact_activities_configured(output.bar) && presentation.selection.visible.contains(&activity.id) })) } @@ -484,6 +482,14 @@ impl FereseShell { .any(|entry| entry.data.source != ferese_activities::Source::Media) } pub fn activities_configured(&self, id: window::Id) -> bool { + self.has_activities_item(id, false) + } + + pub fn compact_activities_configured(&self, id: window::Id) -> bool { + self.has_activities_item(id, true) + } + + fn has_activities_item(&self, id: window::Id, compact: bool) -> bool { let panel = self.panel_for_bar(id); [&panel.start, &panel.center, &panel.end] .into_iter() @@ -492,7 +498,7 @@ impl FereseShell { .any(|item| { item.visible && item.kind == ferese_panel::ItemKind::Activities - && item.overflow != ferese_panel::OverflowPolicy::Always + && (!compact || item.overflow != ferese_panel::OverflowPolicy::Always) }) } pub fn activity_hover(&mut self, id: window::Id, entered: bool) { @@ -612,7 +618,7 @@ impl FereseShell { ) } pub fn has_compact_activities(&self, id: window::Id) -> bool { - if !self.activities_configured(id) { + if !self.compact_activities_configured(id) { return false; } self.outputs diff --git a/shell/ferese-shell/src/bar.rs b/shell/ferese-shell/src/bar.rs index dae2e26..3120b83 100644 --- a/shell/ferese-shell/src/bar.rs +++ b/shell/ferese-shell/src/bar.rs @@ -1,8 +1,6 @@ -#[cfg(test)] -use super::Background; use super::{ - BarMetrics, Color, Element, FereseShell, Length, Message, ShellSnapshot, ShellTheme, alignment, bar_icon, button, - color, container, control, motion, row, status_ui, theme, window, + BarMetrics, Color, Element, FereseShell, Length, Message, ShellSnapshot, alignment, bar_icon, button, color, + container, control, motion, row, status_ui, theme, window, }; use crate::panel::{Availability, Item, ItemKind, Panel, Zone}; #[cfg(test)] @@ -113,12 +111,7 @@ impl FereseShell { samples.push(adaptive::Sample { id: item.id.clone(), representation, - minimum: match item.kind { - ItemKind::FocusedWindow => Some(48.0), - ItemKind::Workspaces { .. } => Some(24.0), - ItemKind::Media => Some(112.0), - _ => None, - }, + minimum: item.kind.minimum_width(), view, }); } @@ -176,8 +169,6 @@ impl FereseShell { panel, zone, availability, - shell_theme, - compositor_material, |item| matches!(resolution.items.get(&item.id), Some(Placement::Visible { width, .. }) if *width > 0.0), |item, surface| { let Some(Placement::Visible { representation, width }) = resolution.items.get(&item.id) @@ -207,8 +198,6 @@ impl FereseShell { spacing: 0.0, }, availability, - shell_theme, - compositor_material, |_| true, |item, surface| { self.view_panel_item( @@ -705,8 +694,6 @@ fn view_zone<'a>( panel: &Panel, zone: &Zone, availability: Availability, - _shell_theme: ShellTheme, - _compositor_material: bool, visible: impl Fn(&Item) -> bool, render: impl Fn(&Item, GroupSurface) -> (Element<'a, cosmic::Action>, bool), ) -> Element<'a, cosmic::Action> { @@ -728,11 +715,6 @@ fn view_zone<'a>( controls.push(cosmic::iced::widget::Space::new().width(item.gap_before.unwrap_or(group.spacing))); } let (element, _) = render(item, surface); - let element: Element<'_, cosmic::Action> = if matches!(item.kind, ItemKind::FocusedWindow) { - element - } else { - container(element).id(presentation::input_id()).into() - }; controls = controls.push(container(element).id(format!("panel:{}:item:{}", panel.id.0, item.id.0))); count += 1; } @@ -838,63 +820,6 @@ pub(super) fn workspace_active_on_bar(workspace: u64, output: Option<&control::O output.is_some_and(|output| output.active_workspace == workspace) } -#[cfg(test)] -pub(super) fn bar_style(theme: ShellTheme, compositor_material: bool, islands: bool) -> container::Style { - let mut style = if islands { - container::Style::default() - } else { - ferese_theme::controls::surface_appearance(color(theme.bar_background), theme.bar_radius) - }; - if compositor_material { - style.background = None; - } - style.text_color = Some(color(theme.text_primary)); - style.icon_color = style.text_color; - style.snap = true; - style -} - -#[cfg(test)] -pub(super) fn island<'a>( - content: Element<'a, cosmic::Action>, - theme: ShellTheme, - geometry: crate::panel::PanelGeometry, - horizontal_padding: f32, - islands: bool, - compositor_material: bool, - material: Option<(cosmic::iced::advanced::widget::Id, f32, [f32; 4])>, -) -> Element<'a, cosmic::Action> { - if !islands { - return content; - } - - let bar = BarMetrics::from(geometry); - let fallback = ferese_panel::CornerRadii::uniform(theme.bar_radius) - .at_top_edge(geometry.edge_margin == 0) - .0; - let (id, opacity, corners) = - material.unwrap_or((presentation::island_id(), color(theme.bar_background).a, fallback)); - container(content) - .id(id) - .padding(cosmic::iced::Padding { - top: (bar.height - bar.group_height) * 0.5, - bottom: (bar.height - bar.group_height) * 0.5, - left: horizontal_padding, - right: horizontal_padding, - }) - .height(bar.height) - .align_y(alignment::Vertical::Center) - .class(cosmic::theme::Container::custom(move |_| { - let mut style = bar_style(theme, compositor_material, false); - style.border.radius = corners.into(); - if let Some(Background::Color(fill)) = style.background { - style.background = Some(Background::Color(Color { a: opacity, ..fill })); - } - style - })) - .into() -} - pub(super) fn input_region( mode: PanelSurface, hidden: bool, @@ -921,11 +846,6 @@ pub(super) fn input_region( ) } -#[cfg(test)] -pub(super) fn bar_group_style(theme: ShellTheme) -> container::Style { - ferese_theme::panel::group_border(color(theme.border), theme.material_radius.min(16.), true) -} - #[cfg(test)] mod island_tests { use super::*; @@ -963,7 +883,7 @@ mod island_tests { if self.group_ids.iter().any(|target| Some(target) == id) { self.groups.push(bounds); } - if id == Some(&presentation::island_id()) || self.material_ids.iter().any(|target| Some(target) == id) { + if self.material_ids.iter().any(|target| Some(target) == id) { self.islands.push(bounds); } if let Some((_, name)) = self.targets.iter().find(|(target, _)| Some(target) == id) { @@ -1094,16 +1014,6 @@ mod island_tests { } fn measure_zone(panel: &Panel, zone: &Zone, availability: Availability, title_content: bool) -> Bounds { - measure_zone_with_theme(panel, zone, availability, title_content, ShellTheme::default()) - } - - fn measure_zone_with_theme( - panel: &Panel, - zone: &Zone, - availability: Availability, - title_content: bool, - theme: ShellTheme, - ) -> Bounds { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -1119,8 +1029,6 @@ mod island_tests { panel, zone, availability, - theme, - true, |item| title_content || !matches!(item.kind, ItemKind::FocusedWindow), |item, _| { let width = match item.kind { @@ -1184,12 +1092,11 @@ mod island_tests { fn islands_cover_the_full_panel_height_independently_of_group_decoration() { let mut panel = Panel::from_preset(PanelStyle::Floating); for margin in [0, 6] { - let theme = ShellTheme::default(); panel.geometry.height = Some(36.); panel.geometry.edge_margin = Some(margin); for border in [false, true] { panel.border = border; - let bounds = measure_zone_with_theme(&panel, &panel.start, Availability::default(), true, theme); + let bounds = measure_zone(&panel, &panel.start, Availability::default(), true); assert_eq!(bounds.islands[0].y, 0.); assert_eq!(bounds.islands[0].height, 36.); assert_eq!( @@ -1315,41 +1222,6 @@ mod island_tests { assert_eq!(input_region(PanelSurface::Solid, true, ®ions), Some(Vec::new())); } - #[test] - fn default_groups_have_no_fill_or_outline_in_either_arrangement() { - let theme = ShellTheme::default(); - for background in [PanelStyle::Flat, PanelStyle::Floating] { - let panel = Panel::from_preset(background); - let group = ferese_theme::panel::group_border(color(theme.border), theme.bar_radius, panel.border); - assert!(group.background.is_none()); - assert_eq!(group.border.width, 0.); - let background_style = bar_style(theme, false, false); - assert_eq!( - background_style.background, - Some(Background::Color(color(theme.bar_background))) - ); - assert_eq!(background_style.border.width, 0.); - } - } - - #[test] - fn outlined_groups_leave_the_bar_as_the_only_background_owner() { - let theme = ShellTheme { - bar_background: [12, 24, 36, 153], - ..Default::default() - }; - let frame = bar_style(theme, false, true); - assert!(frame.background.is_none()); - assert_eq!(frame.border.width, 0.); - assert_eq!(frame.shadow.color.a, 0.); - let outer = bar_style(theme, false, false); - assert_eq!(outer.background, Some(Background::Color(color(theme.bar_background)))); - assert_eq!(outer.border.radius, theme.bar_radius.into()); - assert!(bar_style(theme, true, false).background.is_none()); - let inner = bar_group_style(theme); - assert!(inner.background.is_none()); - assert_eq!(inner.border.width, 1.); - } #[test] fn panel_surface_policy_applies_to_every_occupied_group() { let mut panel = Panel::from_preset(PanelStyle::Floating); diff --git a/shell/ferese-shell/src/bar/presentation.rs b/shell/ferese-shell/src/bar/presentation.rs index bfe0d73..5d9ef8a 100644 --- a/shell/ferese-shell/src/bar/presentation.rs +++ b/shell/ferese-shell/src/bar/presentation.rs @@ -1,60 +1,9 @@ use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget}; use cosmic::iced::{Event, Length, Rectangle, Size, Vector}; use cosmic::{Element, Theme}; -use ferese_panel::PanelSurface; - -pub(crate) fn island_id() -> widget::Id { - static ID: std::sync::OnceLock = std::sync::OnceLock::new(); - ID.get_or_init(widget::Id::unique).clone() -} - -pub(crate) fn input_id() -> widget::Id { - static ID: std::sync::OnceLock = std::sync::OnceLock::new(); - ID.get_or_init(widget::Id::unique).clone() -} - -#[cfg(test)] -pub(super) fn frame<'a, M: 'a>( - content: Element<'a, M>, - mode: PanelSurface, - radius: f32, - materials: Vec<(widget::Id, f32)>, - hover: Option>, - present: impl Fn(&[[f32; 5]], &[f32]) + 'a, - changed: impl Fn(Vec<[f32; 5]>) -> M + 'a, -) -> Element<'a, M> { - Element::new(Presentation { - content, - mode, - radius, - materials, - hover, - present: Box::new(present), - changed: Some(Box::new(changed)), - }) -} pub(super) fn hover<'a, M: 'a>(content: Element<'a, M>, hover: Option>) -> Element<'a, M> { - Element::new(Presentation { - content, - mode: PanelSurface::None, - radius: 0., - materials: Vec::new(), - hover, - present: Box::new(|_, _| {}), - changed: None, - }) -} - -#[derive(Default)] -struct State { - mode: Option, - current: Vec<[f32; 5]>, - scratch: Vec<[f32; 5]>, - input: Vec<[f32; 5]>, - input_scratch: Vec<[f32; 5]>, - scroll_starts: Vec<(usize, usize)>, - opacity_scratch: Vec, + Element::new(Hover { content, hover }) } pub(super) struct HoverTarget<'a, M> { @@ -79,29 +28,12 @@ impl widget::Operation for HoverBounds<'_> { } } -type Changed<'a, M> = Box) -> M + 'a>; - -type Present<'a> = Box; - -struct Presentation<'a, M> { +struct Hover<'a, M> { content: Element<'a, M>, - mode: PanelSurface, - radius: f32, - materials: Vec<(widget::Id, f32)>, hover: Option>, - present: Present<'a>, - changed: Option>, } -impl Widget for Presentation<'_, M> { - fn tag(&self) -> widget::tree::Tag { - widget::tree::Tag::of::() - } - - fn state(&self) -> widget::tree::State { - widget::tree::State::new(State::default()) - } - +impl Widget for Hover<'_, M> { fn children(&self) -> Vec { vec![widget::Tree::new(&self.content)] } @@ -181,54 +113,6 @@ impl Widget for Presentation<'_, M> { shell, viewport, ); - if self.changed.is_none() || !matches!(event, Event::Window(cosmic::iced::window::Event::RedrawRequested(_))) { - return; - } - - let state = tree.state.downcast_mut::(); - let mut collector = Collect { - regions: std::mem::take(&mut state.scratch), - input: std::mem::take(&mut state.input_scratch), - scroll_starts: std::mem::take(&mut state.scroll_starts), - radius: self.radius, - materials: &self.materials, - opacities: std::mem::take(&mut state.opacity_scratch), - }; - collector.regions.clear(); - collector.opacities.clear(); - collector.input.clear(); - collector.scroll_starts.clear(); - if self.mode == PanelSurface::None { - self.content - .as_widget_mut() - .operate(&mut tree.children[0], layout, renderer, &mut collector); - } else { - let bounds = layout.bounds(); - collector - .regions - .push([bounds.x, bounds.y, bounds.width, bounds.height, self.radius]); - collector.opacities.push(1.); - } - - collector.clip_from(0, layout.bounds(), Vector::ZERO); - collector.clip_input_from(0, layout.bounds(), Vector::ZERO); - collector.discard_empty(); - (self.present)(&collector.regions, &collector.opacities); - if state.mode != Some(self.mode) || state.current != collector.regions || state.input != collector.input { - state.mode = Some(self.mode); - state.current.clone_from(&collector.regions); - state.input.clone_from(&collector.input); - shell.publish((self.changed.as_ref().unwrap())(if self.mode == PanelSurface::Solid { - collector.regions.clone() - } else { - collector.input.clone() - })); - } - - state.opacity_scratch = collector.opacities; - state.scratch = collector.regions; - state.input_scratch = collector.input; - state.scroll_starts = collector.scroll_starts; } fn draw( @@ -284,350 +168,3 @@ impl Widget for Presentation<'_, M> { .overlay(&mut tree.children[0], layout, renderer, viewport, translation) } } - -#[derive(Default)] -struct Collect<'a> { - regions: Vec<[f32; 5]>, - input: Vec<[f32; 5]>, - scroll_starts: Vec<(usize, usize)>, - radius: f32, - materials: &'a [(widget::Id, f32)], - opacities: Vec, -} - -impl Collect<'_> { - fn discard_empty(&mut self) { - let mut index = 0; - self.opacities.retain(|_| { - let keep = self.regions[index][2] > 0. && self.regions[index][3] > 0.; - index += 1; - keep - }); - self.regions.retain(|region| region[2] > 0. && region[3] > 0.); - self.input.retain(|region| region[2] > 0. && region[3] > 0.); - } - - fn clip_input_from(&mut self, start: usize, viewport: Rectangle, translation: Vector) { - std::mem::swap(&mut self.regions, &mut self.input); - self.clip_from(start, viewport, translation); - std::mem::swap(&mut self.regions, &mut self.input); - } - - fn clip_from(&mut self, start: usize, viewport: Rectangle, translation: Vector) { - for region in &mut self.regions[start..] { - let bounds = Rectangle { - x: region[0] - translation.x, - y: region[1] - translation.y, - width: region[2], - height: region[3], - }; - let bounds = bounds.intersection(&viewport).unwrap_or_default(); - region[..4].copy_from_slice(&[bounds.x, bounds.y, bounds.width, bounds.height]); - } - } -} - -impl widget::Operation for Collect<'_> { - fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) { - children(self); - } - - fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) { - let opacity = self - .materials - .iter() - .find(|(target, _)| Some(target) == id) - .map(|(_, opacity)| *opacity); - if opacity.is_some() || id == Some(&island_id()) || id == Some(&input_id()) { - self.input - .push([bounds.x, bounds.y, bounds.width, bounds.height, self.radius]); - } - if opacity.is_some() || id == Some(&island_id()) { - self.regions - .push([bounds.x, bounds.y, bounds.width, bounds.height, self.radius]); - self.opacities.push(opacity.unwrap_or(1.)); - } - } - - fn pre_operation(&mut self, _id: Option<&widget::Id>) { - // Iced visits scrollable content before its scrollable callback. Record - // the range so only that subtree receives its offset and viewport clip. - self.scroll_starts.push((self.regions.len(), self.input.len())); - } - - fn scrollable( - &mut self, - _id: Option<&widget::Id>, - bounds: Rectangle, - _content: Rectangle, - translation: Vector, - _state: &mut dyn widget::operation::Scrollable, - ) { - if let Some((start, input_start)) = self.scroll_starts.pop() { - self.clip_from(start, bounds, translation); - self.clip_input_from(input_start, bounds, translation); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use cosmic::iced::advanced::{clipboard, renderer::Headless}; - use cosmic::iced::{Font, Pixels, widget as widgets}; - use cosmic::widget::{container, row}; - - #[test] - fn clipping_preserves_group_opacity_and_transparent_groups_keep_input() { - use widget::Operation; - let ids = [widget::Id::unique(), widget::Id::unique(), widget::Id::unique()]; - let materials = [(ids[0].clone(), 0.2), (ids[1].clone(), 0.), (ids[2].clone(), 0.7)]; - let mut collector = Collect { - materials: &materials, - radius: 12., - ..Default::default() - }; - for (id, x) in ids.iter().zip([-80., 20., 80.]) { - collector.container(Some(id), Rectangle::new((x, 0.).into(), (30., 28.).into())); - } - let viewport = Rectangle::new((0., 0.).into(), (100., 28.).into()); - collector.clip_from(0, viewport, Vector::ZERO); - collector.clip_input_from(0, viewport, Vector::ZERO); - collector.discard_empty(); - assert_eq!(collector.regions, [[20., 0., 30., 28., 12.], [80., 0., 20., 28., 12.]]); - assert_eq!(collector.opacities, [0., 0.7]); - assert_eq!( - collector.input, collector.regions, - "background opacity does not disable controls" - ); - } - - #[test] - fn opacity_only_changes_publish_material_without_rebuilding_input() { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - let renderer = runtime - .block_on(::new( - Font::default(), - Pixels(14.), - Some("tiny-skia"), - )) - .unwrap(); - let viewport = Rectangle::new((0., 0.).into(), (100., 28.).into()); - let id = widget::Id::unique(); - let mut tree = None; - for (index, opacity) in [1., 0.5, 0.].into_iter().enumerate() { - let published = std::cell::RefCell::new(Vec::new()); - let content = container(widgets::Space::new().width(60).height(28)) - .id(id.clone()) - .into(); - let mut view = frame( - content, - PanelSurface::None, - 12., - vec![(id.clone(), opacity)], - None, - |regions, opacities| published.borrow_mut().push((regions.to_vec(), opacities.to_vec())), - |regions| regions, - ); - let tree = tree.get_or_insert_with(|| widget::Tree::new(&view)); - tree.diff(&mut view); - let node = view - .as_widget_mut() - .layout(tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size())); - let mut messages = Vec::new(); - view.as_widget_mut().update( - tree, - &Event::Window(cosmic::iced::window::Event::RedrawRequested(std::time::Instant::now())), - Layout::new(&node), - mouse::Cursor::Unavailable, - &renderer, - &mut clipboard::Null, - &mut Shell::new(&mut messages), - &viewport, - ); - assert_eq!(*published.borrow(), [(vec![[0., 0., 60., 28., 12.]], vec![opacity])]); - assert_eq!(messages.len(), usize::from(index == 0)); - } - } - - #[test] - fn measured_islands_and_live_layout_switches_publish_only_changed_bounds() { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - let renderer = runtime - .block_on(::new( - Font::default(), - Pixels(14.), - Some("tiny-skia"), - )) - .unwrap(); - let viewport = Rectangle::new((0., 0.).into(), (600., 28.).into()); - let mut tree = None; - let theme = crate::ShellTheme { - bar_radius: 14., - ..Default::default() - }; - - for (mode, padding) in [ - (PanelSurface::None, 12.), - (PanelSurface::None, 4.), - (PanelSurface::None, 0.), - (PanelSurface::Solid, 32.), - (PanelSurface::None, 12.), - ] { - let section = |width| { - let inner = container(widgets::Space::new().width(width).height(24)) - .class(cosmic::theme::Container::custom(move |_| { - crate::bar::bar_group_style(theme) - })) - .into(); - crate::bar::island( - inner, - theme, - crate::panel::PanelGeometry::default(), - padding, - mode == PanelSurface::None, - true, - None, - ) - }; - let islands = row![section(100), section(200),].spacing(60); - let content: Element<'_, cosmic::Action> = container(islands) - .padding([0, 12]) - .width(600) - .height(28) - .align_y(cosmic::iced::alignment::Vertical::Center) - .into(); - let mut view = frame( - content.map(|_| Vec::<[f32; 5]>::new()), - mode, - 14., - vec![], - None, - |_, _| {}, - |regions| regions, - ); - let tree = tree.get_or_insert_with(|| widget::Tree::new(&view)); - tree.diff(&mut view); - let node = view - .as_widget_mut() - .layout(tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size())); - let mut messages = Vec::new(); - - for _ in 0..5 { - view.as_widget_mut().update( - tree, - &Event::Window(cosmic::iced::window::Event::RedrawRequested(std::time::Instant::now())), - Layout::new(&node), - mouse::Cursor::Unavailable, - &renderer, - &mut clipboard::Null, - &mut Shell::new(&mut messages), - &viewport, - ); - } - - assert_eq!(messages.len(), 1, "unchanged frames must not rebuild input regions"); - let expected = if mode == PanelSurface::None { - vec![ - [12., 0., 100. + 2. * padding, 28., 14.], - [172. + 2. * padding, 0., 200. + 2. * padding, 28., 14.], - ] - } else { - vec![[0., 0., 600., 28., 14.]] - }; - let regions = &messages[0]; - assert_eq!(*regions, expected); - } - } - - #[test] - fn scrolling_clips_only_its_own_islands_and_preserves_fractional_positions() { - let mut collector = Collect { - regions: vec![[400., 2., 80., 24., 14.], [10.25, 2., 180., 24., 14.]], - input: Vec::new(), - scroll_starts: Vec::new(), - radius: 14., - ..Default::default() - }; - collector.clip_from( - 1, - Rectangle::new((20., 0.).into(), (100., 28.).into()), - Vector::new(40.5, 0.), - ); - assert_eq!( - collector.regions, - vec![[400., 2., 80., 24., 14.], [20., 2., 100., 24., 14.]] - ); - collector.clip_from(0, Rectangle::new((0., 0.).into(), (200., 28.).into()), Vector::ZERO); - collector.regions.retain(|region| region[2] > 0. && region[3] > 0.); - assert_eq!(collector.regions, vec![[20., 2., 100., 24., 14.]]); - } - #[test] - fn undecorated_controls_have_input_regions_without_creating_material_or_gap_regions() { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - let renderer = runtime - .block_on(::new( - Font::default(), - Pixels(14.), - Some("tiny-skia"), - )) - .unwrap(); - let content = row![ - container(widgets::Space::new().width(30).height(24)).id(input_id()), - container(widgets::Space::new().width(40).height(24)).id(input_id()), - ] - .spacing(20) - .into(); - let materials = std::cell::RefCell::new(Vec::new()); - let mut view = frame( - content, - PanelSurface::None, - 14., - vec![], - None, - |regions, _| *materials.borrow_mut() = regions.to_vec(), - |regions| regions, - ); - let mut tree = widget::Tree::new(&view); - let viewport = Rectangle::new((0., 0.).into(), (100., 28.).into()); - let node = view - .as_widget_mut() - .layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size())); - let mut messages = Vec::new(); - view.as_widget_mut().update( - &mut tree, - &Event::Window(cosmic::iced::window::Event::RedrawRequested(std::time::Instant::now())), - Layout::new(&node), - mouse::Cursor::Unavailable, - &renderer, - &mut clipboard::Null, - &mut Shell::new(&mut messages), - &viewport, - ); - assert!(materials.borrow().is_empty()); - assert_eq!(messages, vec![vec![[0., 0., 30., 24., 14.], [50., 0., 40., 24., 14.]]]); - let mut collector = Collect { - regions: vec![[300., 0., 20., 24., 14.]], - input: messages.remove(0), - scroll_starts: Vec::new(), - radius: 14., - ..Default::default() - }; - collector.clip_input_from( - 1, - Rectangle::new((40., 0.).into(), (20., 28.).into()), - Vector::new(10., 0.), - ); - assert_eq!(collector.input, vec![[0., 0., 30., 24., 14.], [40., 0., 20., 24., 14.]]); - assert_eq!(collector.regions, vec![[300., 0., 20., 24., 14.]]); - } -} diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 53e4f0f..361b316 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -73,8 +73,6 @@ struct BarMetrics { icon_size: u16, overview_icon_size: u16, control_height: f32, - #[cfg(test)] - group_height: f32, group_item_height: f32, } @@ -94,8 +92,6 @@ impl From for BarMetrics { icon_size: 20, overview_icon_size: 20, control_height, - #[cfg(test)] - group_height, group_item_height: (group_height - 4.0).max(0.0), } } @@ -820,7 +816,7 @@ impl cosmic::Application for FereseShell { { entry.scale = f64::from(scale); } - self.sync_hub_workspaces() + Task::batch([self.sync_hub_workspaces(), self.resize_hub(id)]) } Message::AdaptiveDeadline(id, ticket) => self.adaptive_deadline(id, ticket), Message::ControlReady => { @@ -1502,8 +1498,9 @@ mod tests { text_muted: [255, 255, 255, 255], ..theme }; - let before = bar_group_style(theme); - let after = bar_group_style(contrasted); + let before = ferese_theme::panel::group_border(color(theme.border), theme.material_radius.min(16.), true); + let after = + ferese_theme::panel::group_border(color(contrasted.border), contrasted.material_radius.min(16.), true); assert_eq!(before.border, after.border); assert_eq!(before.background, after.background); assert_eq!(before.border.color, color(theme.border)); @@ -1526,34 +1523,12 @@ mod tests { fn startup_surface_clear_is_transparent_and_bar_fallback_is_dark() { let theme = ShellTheme::default(); assert_eq!(shell_surface_style(theme).background_color, Color::TRANSPARENT); - let Some(Background::Color(background)) = bar_style(theme.for_bar(), false, false).background else { - panic!("first frame needs a fallback fill before material attachment"); - }; + let background = color(theme.for_bar().bar_background); assert!(background.r < 0.15 && background.g < 0.15 && background.b < 0.20); assert!(background.a > 0.9); assert!(theme.bar_text_primary[..3].iter().all(|channel| *channel > 220)); } - #[test] - fn bar_fallback_uses_the_matching_dark_palette_and_geometry() { - let theme = ShellTheme::default().for_bar(); - let style = bar_style(theme, false, false); - assert_eq!(style.background, Some(Background::Color(color(theme.bar_background)))); - assert_eq!(style.text_color, Some(color(theme.bar_text_primary))); - assert_eq!(style.border.radius, theme.bar_radius.into()); - assert_eq!(style.shadow, cosmic::iced::Shadow::default()); - } - - #[test] - fn bar_does_not_cover_the_selected_compositor_material() { - let theme = ShellTheme::default().for_bar(); - let style = bar_style(theme, true, false); - assert_eq!(style.background, None); - assert_eq!(style.text_color, Some(color(theme.bar_text_primary))); - assert_eq!(style.border.radius, theme.bar_radius.into()); - assert_eq!(style.shadow, cosmic::iced::Shadow::default()); - } - #[test] fn compact_bar_preserves_logical_text_and_icon_sizes() { for height in [24.0, 26.0, 32.0, 38.0, 44.0] { diff --git a/shell/ferese-shell/src/panel_hub.rs b/shell/ferese-shell/src/panel_hub.rs index a596687..21344d7 100644 --- a/shell/ferese-shell/src/panel_hub.rs +++ b/shell/ferese-shell/src/panel_hub.rs @@ -23,6 +23,20 @@ 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, @@ -190,7 +204,12 @@ impl Runtime { .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])); + 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; } } @@ -361,24 +380,23 @@ impl FereseShell { 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 geometry = panel.resolved_geometry(); 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 (width, height) = entry.size.unwrap_or((1280, 720)); - let available = [ - (f64::from(width) - 2. * f64::from(geometry.side_margins)).max(24.), - (f64::from(height) - f64::from(geometry.edge_margin)).max(24.), - ]; + let available = available_size(panel, entry.size); let (resize, mode) = { let mut hub = hub.borrow_mut(); hub.motion = self.render.motion; @@ -415,7 +433,7 @@ impl FereseShell { .and_then(|entry| entry.runtime.hub.as_ref()) .expect("Morph runtime"); let runtime = runtime.borrow(); - if !runtime.activities.selection.visible.is_empty() && self.activities_configured(id) { + 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().is_some() { @@ -1107,14 +1125,13 @@ impl FereseShell { fn hub_shortcuts(&self, id: window::Id) -> Vec { let panel = self.panel_for_bar(id); - let available = self - .outputs - .iter() - .find(|entry| entry.bar == id) - .and_then(|entry| entry.size) - .map_or(panel.morph.expanded_width, |(width, _)| { - (f64::from(width) - 2. * f64::from(panel.resolved_geometry().side_margins)).max(0.) - }); + 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 = 5. + f64::from(self.media.snapshot.selected.is_some()) + f64::from(self.activities_configured(id)); @@ -1237,6 +1254,146 @@ mod tests { shell } + #[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(); diff --git a/shell/ferese-shell/src/panel_hub/workspaces.rs b/shell/ferese-shell/src/panel_hub/workspaces.rs index aeb7931..77e3955 100644 --- a/shell/ferese-shell/src/panel_hub/workspaces.rs +++ b/shell/ferese-shell/src/panel_hub/workspaces.rs @@ -119,14 +119,13 @@ impl FereseShell { hidden: 0, }; }; - let available = self - .outputs - .iter() - .find(|entry| entry.bar == id) - .and_then(|entry| entry.size) - .map_or(panel.morph.expanded_width, |(width, _)| { - (f64::from(width) - 2. * f64::from(panel.resolved_geometry().side_margins)).max(0.) - }); + let available = super::content_width( + panel, + self.outputs + .iter() + .find(|entry| entry.bar == id) + .and_then(|entry| entry.size), + ); select( self.snapshot.workspaces_for_output(Some(output.id)).collect(), output.active_workspace, diff --git a/shell/ferese-shell/src/surfaces.rs b/shell/ferese-shell/src/surfaces.rs index c476496..4dc8326 100644 --- a/shell/ferese-shell/src/surfaces.rs +++ b/shell/ferese-shell/src/surfaces.rs @@ -84,7 +84,14 @@ impl FereseShell { } else { Task::none() }; + let hub = if resized { + self.sync_activity_presentations(); + self.resize_hub(bar) + } else { + Task::none() + }; return Task::batch([ + hub, margin, popup, tooltip,