#[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, }; use crate::panel::{Availability, Item, ItemKind, Panel, Zone}; #[cfg(test)] use ferese_panel::PanelStyle; use ferese_panel::{GroupSurface, PanelSurface}; use ferese_theme::panel as adaptive; pub(super) mod presentation; use crate::panel::Representation; use crate::panel_layout::Placement; #[cfg(test)] mod binding_mode_tests; impl FereseShell { pub(super) fn binding_mode_transition(&mut self, was_active: bool) -> cosmic::app::Task { match (was_active, self.snapshot.binding_mode.is_some()) { (false, true) => { let mut tasks = vec![self.destroy_menu(), self.dismiss_workspace_tooltip()]; let hubs: Vec<_> = self .outputs .iter() .filter(|entry| entry.runtime.hub.is_some()) .map(|entry| entry.bar) .collect(); for id in hubs { // Release keyboard ownership and return to the collapsed // backing allocation before displaying the mode indicator. tasks.push(self.suspend_hub(id)); } cosmic::app::Task::batch(tasks) } (true, false) => self.sync_hub_activity(), _ => cosmic::app::Task::none(), } } pub(super) fn output_for_bar(&self, id: window::Id) -> Option<&control::OutputSnapshot> { let name = self.outputs.iter().find(|entry| entry.bar == id)?.name.as_deref()?; self.snapshot.outputs.iter().find(|output| output.name == name) } pub(super) fn active_bar(&self) -> Option { self.outputs .iter() .find(|entry| { self.snapshot .outputs .iter() .any(|output| output.focused && Some(output.name.as_str()) == entry.name.as_deref()) }) .or_else(|| self.outputs.first()) .map(|output| output.bar) } pub(super) fn bar_hidden(&self, id: window::Id) -> bool { output_bar_hidden(&self.snapshot, self.output_for_bar(id).map(|output| output.id)) } pub(super) fn view_layer(&self, id: window::Id) -> Element<'_, cosmic::Action> { if self.bar_hidden(id) { return container(self.render.text("")) .width(Length::Fill) .height(Length::Fill) .into(); } let panel = self.panel_for_bar(id); if let Some(name) = &self.snapshot.binding_mode { return self.view_binding_mode(id, name); } if panel.style == ferese_panel::PanelStyle::Morph { return self.view_hub(id); } let mut shell_theme = self.config.theme.for_bar(); shell_theme.bar_background[3] = (shell_theme.panel_opacity(panel.background_opacity) * 255.).round() as u8; let output = self.outputs.iter().find(|output| output.bar == id); let effects = output.and_then(|output| output.effects.as_ref()); let compositor_material = effects.is_some(); let availability = Availability { activities: self.activity_available(id), media: self.media.snapshot.selected.is_some(), network: self.status.network.is_some(), audio: self.status.audio.is_some(), notifications: self.status.notifications.is_some(), battery: self.status.battery.is_some(), external_display: self.display_mode.external_connected(), }; let mut samples = Vec::new(); for zone in [&panel.center, &panel.start, &panel.end] { for group in &zone.groups { for item in group.items.iter().filter(|item| item.available(availability)) { if matches!(item.kind, ItemKind::FocusedWindow) && focused_bar_title(&self.snapshot, self.output_for_bar(id)).is_empty() { continue; } let alternatives = item.representations(); for representation in alternatives { let view = self .view_panel_item( id, item, None, panel.group_surface() == GroupSurface::Island, representation, ) .0; 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, }, view, }); } } } } let overflow_group = panel.overflow_group(); samples.push(adaptive::Sample { id: overflow_group.items[0].id.clone(), representation: Representation::Icon, minimum: None, view: self .view_panel_item( id, &overflow_group.items[0], None, panel.group_surface() == GroupSurface::Island, Representation::Icon, ) .0, }); // Measure the control itself; reserve group decoration separately. let overflow_width = 2.0 * f32::from(overflow_group.padding[1]) + if panel.group_surface() == GroupSurface::Island { 2.0 * overflow_group.island_padding } else { 0.0 }; let groups = [ (ferese_panel::shape::ZonePosition::Start, &panel.start), (ferese_panel::shape::ZonePosition::Center, &panel.center), (ferese_panel::shape::ZonePosition::End, &panel.end), ] .into_iter() .flat_map(|(position, zone)| { zone.groups .iter() .map(move |group| (group_material_id(panel, group), position)) }) .chain(std::iter::once(( group_material_id(panel, &overflow_group), ferese_panel::shape::ZonePosition::End, ))) .collect(); let content = adaptive::frame( std::borrow::Cow::Borrowed(panel), samples, overflow_width, output .and_then(|output| output.panel_resolution.clone()) .unwrap_or_default(), move |resolution| { let zone = |zone: &Zone| { view_zone( 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) else { unreachable!() }; self.view_panel_item( id, item, Some(*width), surface == GroupSurface::Island, *representation, ) }, ) }; let start = zone(&panel.start); let center = zone(&panel.center); let end = zone(&panel.end); let end: Element<'_, cosmic::Action> = if resolution.overflow.is_empty() { end } else { let trigger = view_zone( panel, &Zone { groups: vec![overflow_group.clone()], spacing: 0.0, }, availability, shell_theme, compositor_material, |_| true, |item, surface| { self.view_panel_item( id, item, Some(resolution.overflow_trigger_width), surface == GroupSurface::Island, Representation::Icon, ) }, ); let end_has_items = panel.end.groups.iter().flat_map(|group| &group.items) .any(|item| matches!(resolution.items.get(&item.id), Some(Placement::Visible { width, .. }) if *width > 0.0)); if end_has_items { row![trigger, end] .spacing(panel.zone_spacing(&panel.end)) .align_y(cosmic::iced::Alignment::Center) .into() } else { trigger } }; cosmic::iced::widget::stack![ row![start, cosmic::iced::widget::Space::new().width(Length::Fill), end] .width(Length::Fill) .height(Length::Fill) .align_y(cosmic::iced::Alignment::Center), container(center).center_x(Length::Fill).center_y(Length::Fill), ] .into() }, move |resolution| cosmic::Action::App(Message::PanelResolved(id, resolution)), ); let content = container(content) .width(Length::Fill) .height(Length::Fill) .padding([0, panel.resolved_geometry().inner_padding.round() as u16]) .into(); let content = ferese_theme::panel_surface::frame( content, ferese_theme::panel_surface::Options { parameters: panel.shape_parameters(), bounds: None, groups, scale: output.map_or(1., |entry| entry.scale), high_contrast: shell_theme.high_contrast, background: color(shell_theme.bar_background), border: if panel.border { color(shell_theme.border) } else { Color::TRANSPARENT }, divider: color(shell_theme.border), native: compositor_material, }, move |scene| { if let Some(effects) = effects && let Err(error) = effects.set_panel_presentation(scene) { eprintln!("ferese-shell: could not update panel material: {error}"); } }, Some(Box::new(move |scene| { cosmic::Action::App(Message::PanelSurfaceChanged(id, Box::new(scene.clone()))) })), ); self.with_workspace_hover(id, content) } fn view_binding_mode<'a>(&'a self, id: window::Id, name: &'a str) -> Element<'a, cosmic::Action> { let panel = self.panel_for_bar(id); let output = self.outputs.iter().find(|entry| entry.bar == id).expect("panel output"); let effects = output.effects.as_ref(); let width = output .runtime .hub .as_ref() .map(|hub| hub.borrow().allocation[0]) .unwrap_or_else(|| output.size.map_or(320, |size| size.0 as u32)) .min(320); let badge_id = cosmic::iced::advanced::widget::Id::new("ferese-binding-mode"); let title = self .render .text(format!("Mode: {name}")) .size(14) .width(Length::Fill) .ellipsize(cosmic::iced::widget::text::Ellipsize::End( cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1), )); let badge = container( row![title, self.render.text("Esc to exit").size(12)] .spacing(12) .align_y(cosmic::iced::Alignment::Center), ) .id(badge_id.clone()) .padding([0, 12]) .width(width as f32) .height(panel.resolved_geometry().height) .align_y(alignment::Vertical::Center); let content = container(badge).center_x(Length::Fill).center_y(Length::Fill); let palette = self.config.theme.for_bar(); let mut background = color(palette.bar_background); background.a = palette.panel_opacity(panel.background_opacity); // Replace the native paint/input scene as well as the text. Retaining // the previous bar or Morph scene can clip a newly displayed mode. ferese_theme::panel_surface::frame( content.into(), ferese_theme::panel_surface::Options { parameters: panel.shape_parameters(), bounds: Some(badge_id.clone()), groups: vec![(badge_id, ferese_panel::shape::ZonePosition::Center)], scale: output.scale, high_contrast: palette.high_contrast, background, border: if panel.border { color(palette.border) } else { Color::TRANSPARENT }, divider: 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 binding mode material: {error}"); } }, Some(Box::new(move |scene| { cosmic::Action::App(Message::PanelSurfaceChanged(id, Box::new(scene.clone()))) })), ) } pub(super) fn with_workspace_hover<'a>( &'a self, id: window::Id, content: Element<'a, cosmic::Action>, ) -> Element<'a, cosmic::Action> { presentation::hover( content, self.workspace_ui .hover_target(id) .map(|(workspace, target)| presentation::HoverTarget { id: target.clone(), exited: Box::new(move || { cosmic::Action::App(Message::HoverWorkspace(id, workspace, target.clone(), false)) }), }), ) } fn view_panel_item( &self, id: window::Id, item: &Item, width: Option, islands: bool, representation: Representation, ) -> (Element<'_, cosmic::Action>, bool) { let shell_theme = self.config.theme.for_bar(); let bar = BarMetrics::from(self.panel_for_bar(id).resolved_geometry()); let control_height = if islands { bar.group_item_height } else { bar.control_height }; let foreground = color(shell_theme.text_primary); let focused_output = self.output_for_bar(id); let selected = self.menu.as_ref().is_some_and(|menu| { menu.anchor.parent == id && menu.anchor.panel == self.config.panels[0].id && menu.anchor.item.as_ref() == Some(&item.id) }); let element = match item.kind { ItemKind::Overview => motion::button( self.render, button::custom(overview_control( BarMetrics { control_height, ..bar }, color(shell_theme.accent), )) .height(control_height) .padding([0, 7]) .on_press(cosmic::Action::App(Message::ToggleOverview)), foreground, self.overview_active, 1.0, ), ItemKind::Workspaces { style } => { let workspaces = self.view_workspace_item(id, style, &item.id.0, 1.); if let Some(width) = width { cosmic::iced::widget::scrollable(workspaces) .direction(cosmic::iced::widget::scrollable::Direction::Horizontal( cosmic::iced::widget::scrollable::Scrollbar::default() .width(0) .scroller_width(0), )) .width(width) .into() } else { workspaces } } ItemKind::FocusedWindow => { let title = if item.visible && width.is_none_or(|width| width > 0.) { focused_bar_title(&self.snapshot, focused_output) } else { "" }; let element: Element<'_, cosmic::Action> = self .render .text(title) .size(bar.text_size) .width(width.map_or(Length::Shrink, Length::Fixed)) .height(control_height) .align_x(alignment::Horizontal::Center) .align_y(alignment::Vertical::Center) .wrapping(cosmic::iced::widget::text::Wrapping::None) .ellipsize(cosmic::iced::widget::text::Ellipsize::End( cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(1), )) .class(theme::Text::Color(foreground)) .into(); return (container(element).max_width(360).into(), !title.is_empty()); } ItemKind::Activities => { return self.view_activity_item(id, item, representation, selected, width, control_height); } ItemKind::Media => self.view_media_item(representation, selected, width), ItemKind::QuickSettings => self.view_status_item(status_ui::Menu::System, false, selected), ItemKind::Network => self.view_status_item(status_ui::Menu::Network, false, selected), ItemKind::Audio => self.view_status_item(status_ui::Menu::Audio, false, selected), ItemKind::Recording => self.view_status_item(status_ui::Menu::Recording, false, selected), ItemKind::Notifications => self.view_status_item(status_ui::Menu::Notifications, false, selected), ItemKind::Battery { percentage } => self.view_status_item( status_ui::Menu::Battery, percentage && representation != Representation::Icon, selected, ), ItemKind::Clock if representation == Representation::Icon => { self.view_status_item(status_ui::Menu::Calendar, false, selected) } ItemKind::Overflow => self.view_status_item(status_ui::Menu::Overflow, false, selected), ItemKind::Clock => { let (date, time) = self.clock.split_once(", ").unwrap_or(("", &self.clock)); let mut content = row::with_capacity(2) .spacing(8) .align_y(cosmic::iced::Alignment::Center); if representation == Representation::Wide && !date.is_empty() { content = content.push( self.render .text(date) .size(12) .wrapping(cosmic::iced::widget::text::Wrapping::None) .class(theme::Text::Color(foreground)), ); } let content = content.push( self.render .text(time) .size(bar.text_size) .wrapping(cosmic::iced::widget::text::Wrapping::None) .class(theme::Text::Color(foreground)), ); motion::button( self.render, button::custom(container(content).center_y(bar.group_item_height)) .padding([0, 4]) .height(bar.group_item_height) .name("Open calendar") .on_press_with_rectangle(move |offset, bounds| { cosmic::Action::App(Message::OpenMenu( status_ui::Menu::Calendar, cosmic::iced::Rectangle { x: (bounds.x - offset.x).round() as i32, y: (bounds.y - offset.y).round() as i32, width: bounds.width.round() as i32, height: bounds.height.round() as i32, }, )) }), foreground, selected, 1.0, ) } ItemKind::DisplayMode => motion::button( self.render, button::custom(bar_content( bar_icon(ferese_theme::icons::DISPLAY, bar.icon_size, foreground), control_height, )) .name("Display mode") .height(control_height) .padding([0.0, ((bar.height - f32::from(bar.icon_size)) * 0.5).max(4.0)]) .on_press(cosmic::Action::App(Message::OpenDisplays( self.outputs .iter() .find(|output| output.bar == id) .and_then(|output| output.name.clone()), ))), foreground, self.display_mode.open, 1.0, ), }; let panel = self.config.panels[0].id.clone(); let item = item.id.clone(); let element: Element<'_, cosmic::Action> = container(element) .width(width.map_or(Length::Shrink, Length::Fixed)) .into(); ( element.map(move |action| match action { cosmic::Action::App(Message::OpenMenu(kind, anchor)) => cosmic::Action::App(Message::OpenPopover( kind, status_ui::PopoverAnchor { parent: id, panel: panel.clone(), item: Some(item.clone()), rectangle: anchor, }, )), other => other, }), true, ) } pub(crate) fn view_panel_overflow<'a>( &'a self, mut rows: cosmic::widget::Column<'a, cosmic::Action, cosmic::Theme, cosmic::Renderer>, ) -> cosmic::widget::Column<'a, cosmic::Action, cosmic::Theme, cosmic::Renderer> { let Some(menu) = &self.menu else { return rows }; let Some(output) = self.outputs.iter().find(|output| output.bar == menu.anchor.parent) else { return rows; }; let panel = self.panel_for_bar(output.bar); let Some(resolution) = &output.panel_resolution else { return rows; }; for id in &resolution.overflow { let Some(item) = panel.item(id) else { continue; }; let element = self .view_panel_item( output.bar, item, Some(menu.kind.width() - 32.0), false, Representation::Wide, ) .0; let anchor = menu.anchor.clone(); let element = element.map(move |action| match action { cosmic::Action::App(Message::OpenPopover(kind, mut target)) => { target.rectangle = anchor.rectangle; cosmic::Action::App(Message::OpenPopover(kind, target)) } cosmic::Action::App( message @ (Message::ToggleOverview | Message::ActivateWorkspace(_) | Message::StartRecording | Message::StopRecording), ) => cosmic::Action::App(Message::PanelAction(Box::new(message))), other => other, }); rows = rows.push(cosmic::widget::column![self.render.text(item.kind.label()).size(12), element].spacing(4)); } rows } pub(super) fn view_workspace_item( &self, id: window::Id, style: ferese_panel::WorkspaceStyle, instance: &str, opacity: f32, ) -> Element<'_, cosmic::Action> { let focused_output = self.output_for_bar(id); let shell_theme = self.config.theme.for_bar(); let mut palette = shell_theme.palette(); palette.text.a *= opacity; palette.muted.a *= opacity; palette.accent.a *= opacity; let bar = BarMetrics::from(self.panel_for_bar(id).resolved_geometry()); let mut workspace_buttons = row::with_capacity(self.snapshot.workspaces.len()) .spacing(1) .align_y(cosmic::iced::Alignment::Center); for workspace in self .snapshot .workspaces_for_output(focused_output.map(|output| output.id)) { let active = workspace_active_on_bar(workspace.id, focused_output); let owner = workspace .output .and_then(|id| self.snapshot.outputs.iter().find(|output| output.id == id)); let transition = self.workspace_ui.transition(workspace.id, active); let apps = if style == ferese_panel::WorkspaceStyle::AppIcons { self.workspace_ui.apps(workspace.id, &self.snapshot) } else { Vec::new() }; let details = ferese_theme::workspaces::Workspace { name: (&workspace.name).into(), index: workspace.index, window_count: workspace.window_count, active, visible_elsewhere: workspace.visible && !active, }; let accessible_name = ferese_theme::workspaces::tooltip_label(&details); let indicator = motion::frame_driven( transition.revision(), move |now| { ferese_theme::workspaces::indicator( style, details.clone(), palette, self.render.font, bar.group_item_height, transition.progress(now), apps.iter() .map(|handle| { cosmic::widget::icon::icon(handle.clone()) .size(16) .opacity(opacity) .into() }) .collect(), ) }, move |now| transition.active(now), |_| {}, |_| None, ); let workspace_button = button::custom(indicator) .name(format!( "{accessible_name}{}{}", if workspace.focused { ", focused" } else { "" }, owner.map_or(String::new(), |output| format!(", on {}", output.name)) )) .height(bar.group_item_height) .padding(0) .on_press(cosmic::Action::App(Message::ActivateWorkspace(workspace.id))); let selector = motion::button( self.render, workspace_button, color(if active { shell_theme.accent } else { shell_theme.text_primary }), false, opacity, ); let target = cosmic::iced::advanced::widget::Id::new(format!("workspace:{id:?}:{instance}:{}", workspace.id)); let selector = cosmic::widget::mouse_area(container(selector).id(target.clone())) .on_enter(cosmic::Action::App(Message::HoverWorkspace( id, workspace.id, target.clone(), true, ))) .on_exit(cosmic::Action::App(Message::HoverWorkspace( id, workspace.id, target.clone(), false, ))); // A surface leave may not reset MouseArea's hover state. Recover // on reentry without publishing a message for every pointer move. let selector = if self.workspace_ui.hovering(id, &target) { selector } else { selector.on_move(move |_| { cosmic::Action::App(Message::HoverWorkspace(id, workspace.id, target.clone(), true)) }) }; workspace_buttons = workspace_buttons.push(selector); } workspace_buttons.into() } } fn group_material_id(panel: &Panel, group: &crate::panel::Group) -> cosmic::iced::advanced::widget::Id { cosmic::iced::advanced::widget::Id::new(format!("panel:{}:group:{}:material", panel.id.0, group.id.0)) } /// Render ordered instances; item rendering does not choose its neighbors or surface. 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> { let bar = BarMetrics::from(panel.resolved_geometry()); let mut groups = row::with_capacity(zone.groups.len()) .spacing(panel.zone_spacing(zone)) .align_y(cosmic::iced::Alignment::Center); for group in &zone.groups { let surface = panel.group_surface(); let mut controls = row::with_capacity(group.items.len()).align_y(cosmic::iced::Alignment::Center); let mut count = 0; for item in group .items .iter() .filter(|item| item.available(availability) && visible(item)) { if count > 0 { controls = 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; } if count == 0 { continue; } let horizontal = f32::from(group.padding[1]) + if panel.group_surface() == GroupSurface::Island { group.island_padding } else { 0. }; let content = container(controls) .padding(cosmic::iced::Padding { top: f32::from(group.padding[0]), bottom: f32::from(group.padding[0]), left: horizontal, right: horizontal, }) .height(bar.height) .align_y(alignment::Vertical::Center) .id(format!("panel:{}:group:{}", panel.id.0, group.id.0)); let content: Element<'_, cosmic::Action> = container(content) .id(group_material_id(panel, group)) .height(bar.height) .into(); groups = groups.push(content); } groups.into() } pub(super) fn bar_content<'a>( content: impl Into>>, height: f32, ) -> cosmic::widget::Container<'a, cosmic::Action, cosmic::Theme, cosmic::Renderer> { // A one-pixel optical offset below the geometric center. Compact bars // reduce the offset so the 20 px icons keep their full size. container(content) .padding(cosmic::iced::Padding { top: (height - 20.0).clamp(0.0, 2.0), ..Default::default() }) .center_y(height) } pub(super) fn overview_control(bar: BarMetrics, foreground: Color) -> Element<'static, cosmic::Action> { bar_content( bar_icon(ferese_theme::icons::FERESE, bar.overview_icon_size, foreground), bar.control_height, ) .width(bar.overview_icon_size) .align_x(alignment::Horizontal::Center) .into() } #[cfg(test)] pub(super) fn bar_hidden(snapshot: &ShellSnapshot) -> bool { let output = snapshot .outputs .iter() .find(|output| output.focused) .or_else(|| snapshot.outputs.first()) .map(|output| output.id); output_bar_hidden(snapshot, output) } pub(super) fn output_bar_hidden(snapshot: &ShellSnapshot, output: Option) -> bool { if snapshot.binding_mode.is_some() { return false; } let active_workspace = snapshot .outputs .iter() .find(|candidate| Some(candidate.id) == output) .map(|output| output.active_workspace); active_workspace.is_some_and(|workspace| { snapshot .windows .iter() .any(|window| window.workspace == workspace && window.fullscreen) }) } pub(super) fn focused_bar_title<'a>(snapshot: &'a ShellSnapshot, output: Option<&control::OutputSnapshot>) -> &'a str { let Some(output) = output else { return ""; }; snapshot .windows .iter() .find(|window| window.focused && window.workspace == output.active_workspace) .map_or("", |window| { if window.title.trim().is_empty() { &window.app_id } else { &window.title } }) } pub(super) fn workspace_active_on_bar(workspace: u64, output: Option<&control::OutputSnapshot>) -> bool { 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, regions: &[[f32; 5]], ) -> Option> { if hidden { return Some(Vec::new()); } if mode == PanelSurface::Solid { return None; } Some( regions .iter() .map(|r| cosmic::iced::Rectangle { x: r[0].floor(), y: r[1].floor(), width: (r[0] + r[2]).ceil() - r[0].floor(), height: (r[1] + r[3]).ceil() - r[1].floor(), }) .collect(), ) } #[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::*; #[test] fn mode_indicator_stays_visible_on_fullscreen_outputs() { let mut snapshot = super::super::tests::snapshot_with_fullscreen_window(7); assert!(output_bar_hidden(&snapshot, Some(1))); snapshot.binding_mode = Some("resize".into()); assert!(!output_bar_hidden(&snapshot, Some(1))); snapshot.binding_mode = None; assert!(output_bar_hidden(&snapshot, Some(1))); } use crate::panel::{ItemId, Panel}; use cosmic::iced::advanced::{Layout, layout, renderer::Headless, widget}; use cosmic::iced::{Font, Pixels, Rectangle, Size}; #[derive(Default)] struct Bounds { targets: Vec<(widget::Id, String)>, items: Vec<(String, Rectangle)>, islands: Vec, groups: Vec, group_ids: Vec, material_ids: Vec, } 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 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) { self.islands.push(bounds); } if let Some((_, name)) = self.targets.iter().find(|(target, _)| Some(target) == id) { self.items.push((name.clone(), bounds)); } } } #[test] fn media_label_grows_with_allocation_and_keeps_transport_inside_the_item() { #[derive(Default)] struct ContentBounds { labels: Vec, buttons: Vec, } impl widget::Operation for ContentBounds { fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) { children(self); } fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, _: &str) { self.labels.push(bounds); } fn focusable( &mut self, _: Option<&widget::Id>, bounds: Rectangle, _: &mut dyn widget::operation::Focusable, ) { self.buttons.push(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 mut shell = crate::tests::shell_with_measured_panel(); std::sync::Arc::make_mut(&mut shell.media.snapshot).selected = Some(ferese_ipc::media::Player { title: "A long song title that should use the room between navigation and the centered window title".into(), status: ferese_ipc::media::Playback::Playing, can_control: true, can_pause: true, ..Default::default() }); let item = Item::new("media", ItemKind::Media); let mut previous_label = 0.; for width in [120., 200., 320.] { let mut view = shell .view_panel_item(shell.outputs[0].bar, &item, Some(width), true, Representation::Wide) .0; let mut tree = widget::Tree::new(&view); let node = view.as_widget_mut().layout( &mut tree, &renderer, &layout::Limits::new(Size::ZERO, Size::new(800., 28.)), ); let mut content = ContentBounds::default(); view.as_widget_mut() .operate(&mut tree, Layout::new(&node), &renderer, &mut content); assert_eq!(content.labels.len(), 1); assert_eq!(content.buttons.len(), 2); let label = content.labels[0]; assert!(label.width > previous_label); previous_label = label.width; for bounds in content.labels.iter().chain(&content.buttons) { assert!( bounds.x >= 0. && bounds.x + bounds.width <= width + 0.01, "media control exceeds its allocation: {bounds:?}, width={width}" ); } } } #[test] fn clock_content_has_balanced_horizontal_padding() { #[derive(Default)] struct Labels(Vec); impl widget::Operation for Labels { fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) { children(self); } fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, _: &str) { self.0.push(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 mut shell = crate::tests::shell_with_measured_panel(); shell.clock = "10 oct, 4:35 pm".into(); let item = Item::new("clock", ItemKind::Clock); for height in [28., 31., 48.] { shell.config.panels[0].geometry.height = Some(height); for representation in [Representation::Wide, Representation::Compact] { let mut view = shell .view_panel_item(shell.outputs[0].bar, &item, None, true, representation) .0; let mut tree = widget::Tree::new(&view); let node = view.as_widget_mut().layout( &mut tree, &renderer, &layout::Limits::new(Size::ZERO, Size::new(800., height)), ); let mut labels = Labels::default(); view.as_widget_mut() .operate(&mut tree, Layout::new(&node), &renderer, &mut labels); let left = labels.0.first().unwrap().x; let last = labels.0.last().unwrap(); let right = node.size().width - last.x - last.width; assert!((left - right).abs() < 0.01); assert!(right <= 4.01, "panel height must not add horizontal space to the clock"); } } } 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() .unwrap(); let renderer = runtime .block_on(::new( Font::default(), Pixels(14.), Some("tiny-skia"), )) .unwrap(); let view = view_zone( panel, zone, availability, theme, true, |item| title_content || !matches!(item.kind, ItemKind::FocusedWindow), |item, _| { let width = match item.kind { ItemKind::Overview => 28., ItemKind::Workspaces { .. } => 72., ItemKind::FocusedWindow => 80., ItemKind::Clock => 60., _ => 20., }; let height = if matches!(item.kind, ItemKind::FocusedWindow) { 24. } else { 20. }; ( container(cosmic::iced::widget::Space::new().width(width).height(height)).into(), !matches!(item.kind, ItemKind::FocusedWindow) || title_content, ) }, ); let mut view: Element<'_, cosmic::Action> = container(view) .height(panel.resolved_geometry().height) .align_y(alignment::Vertical::Center) .into(); let mut tree = widget::Tree::new(&view); let node = view.as_widget_mut().layout( &mut tree, &renderer, &layout::Limits::new(Size::ZERO, Size::new(1200., panel.resolved_geometry().height)), ); let mut bounds = Bounds { group_ids: zone .groups .iter() .map(|group| widget::Id::from(format!("panel:{}:group:{}", panel.id.0, group.id.0))) .collect(), material_ids: zone .groups .iter() .map(|group| group_material_id(panel, group)) .collect(), targets: zone .groups .iter() .flat_map(|group| &group.items) .map(|item| { ( widget::Id::from(format!("panel:{}:item:{}", panel.id.0, item.id.0)), item.id.0.clone(), ) }) .collect(), ..Default::default() }; view.as_widget_mut() .operate(&mut tree, Layout::new(&node), &renderer, &mut bounds); bounds } #[test] 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); assert_eq!(bounds.islands[0].y, 0.); assert_eq!(bounds.islands[0].height, 36.); assert_eq!( bounds.groups[0].height, 36., "group layout follows the same baseline as its shared surface" ); } } } #[test] fn composition_preserves_default_group_gaps_and_material_geometry() { for padding in [0., 4., 13.5] { for background in [PanelStyle::Flat, PanelStyle::Floating] { let mut panel = Panel::from_preset(background); for group in [&mut panel.start, &mut panel.center, &mut panel.end] .into_iter() .flat_map(|zone| &mut zone.groups) { group.island_padding = padding; } let start = measure_zone(&panel, &panel.start, Availability::default(), true); assert_eq!( start.items.iter().map(|(id, _)| id.as_str()).collect::>(), ["overview", "workspaces"] ); let islands = background == PanelStyle::Floating; assert_eq!(start.items[0].1.x, if islands { padding + 3. } else { 0. }); assert_eq!(start.items[1].1.x - start.items[0].1.x, if islands { 29. } else { 34. }); assert_eq!(start.islands.len(), panel.start.groups.len()); if islands { assert_eq!( start.islands[0], Rectangle::new( (0., 0.).into(), (107. + 2. * padding, panel.resolved_geometry().height).into() ) ); } let end = measure_zone(&panel, &panel.end, Availability::default(), true); assert_eq!( end.items.iter().map(|(id, _)| id.as_str()).collect::>(), ["quick-settings", "recording", "clock"] ); assert_eq!(end.items[1].1.x - end.items[0].1.x, 21.); assert_eq!(end.items[2].1.x - end.items[1].1.x, if islands { 21. } else { 34. }); if islands { assert_eq!( end.islands[0], Rectangle::new( (0., 0.).into(), (108. + 2. * padding, panel.resolved_geometry().height).into() ) ); } let center = measure_zone(&panel, &panel.center, Availability::default(), true); assert_eq!(center.islands.len(), panel.center.groups.len()); if islands { assert_eq!(center.islands[0].width, 80. + 2. * padding); } let empty_title = measure_zone(&panel, &panel.center, Availability::default(), false); assert!(empty_title.islands.is_empty()); assert!( empty_title.items.is_empty(), "an unavailable title leaves no group behind" ); } } } #[test] fn group_renderer_obeys_order_and_instance_identity_and_skips_unavailable_groups() { let mut panel = Panel::from_preset(PanelStyle::Floating); let group = &mut panel.end.groups[0]; let mut clock = group .items .iter() .find(|item| item.kind == ItemKind::Clock) .unwrap() .clone(); let battery = group .items .iter() .find(|item| matches!(item.kind, ItemKind::Battery { .. })) .unwrap() .clone(); clock.gap_before = None; let mut second = clock.clone(); second.id = ItemId("other-clock".into()); group.items = vec![battery, clock, second]; let mut unavailable = group.clone(); unavailable.id = crate::panel::GroupId("unavailable".into()); unavailable.items = vec![unavailable.items.remove(0)]; panel.end.groups.insert(0, unavailable); let result = measure_zone(&panel, &panel.end, Availability::default(), true); assert_eq!( result.items.iter().map(|(id, _)| id.as_str()).collect::>(), ["clock", "other-clock"] ); assert_eq!(result.items[0].1.x, 7., "an unavailable group adds no space"); assert_eq!(result.items[1].1.x, 68.); assert_eq!(result.islands.len(), 1); panel.end.groups[1].items.reverse(); let reordered = measure_zone(&panel, &panel.end, Availability::default(), true); assert_eq!( reordered.items.iter().map(|(id, _)| id.as_str()).collect::>(), ["other-clock", "clock"] ); } #[test] fn island_gaps_are_click_through_and_fullscreen_disables_every_input_region() { let regions = [[12.25, 2.5, 80.5, 24., 14.], [400., 2., 160., 24., 14.]]; let input = input_region(PanelSurface::None, false, ®ions).unwrap(); assert_eq!(input.len(), 2); assert_eq!( input[0], cosmic::iced::Rectangle::new((12., 2.).into(), (81., 25.).into()) ); assert!(!input.iter().any(|region| region.contains((240., 14.).into()))); assert_eq!(input_region(PanelSurface::None, true, ®ions), Some(Vec::new())); assert_eq!(input_region(PanelSurface::Solid, false, ®ions), None); 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); panel.geometry.height = Some(36.); for zone in [&panel.start, &panel.center, &panel.end] { let measured = measure_zone(&panel, zone, Availability::default(), true); assert_eq!(measured.islands.len(), measured.groups.len()); for (island, group) in measured.islands.iter().zip(&measured.groups) { assert_eq!(island.height, 36.); assert_eq!(island.width, group.width); } } panel.apply_preset(PanelStyle::Flat); for zone in [&panel.start, &panel.center, &panel.end] { let measured = measure_zone(&panel, zone, Availability::default(), true); assert_eq!( measured.islands.len(), measured.groups.len(), "changing the shared shape preserves layout bounds" ); } } }