use cosmic::iced::{Alignment, Length}; use cosmic::widget::{button, column, container, row}; use ferese_panel::layout::{Placement, Resolution}; use ferese_panel::{Availability, Group, Item, ItemId, ItemKind, Panel, Representation, Zone}; use ferese_theme::panel::{Sample, Spacing, frame}; use crate::{App, Element, Message, visuals}; const SAMPLE_AVAILABILITY: Availability = Availability { tray: true, capture: true, activities: true, media: true, network: true, audio: true, notifications: true, battery: true, external_display: true, }; fn available(item: &Item) -> bool { item.available(SAMPLE_AVAILABILITY) } fn panel_background( theme: &ferese_config::theme::ResolvedTheme, panel: &Panel, ) -> cosmic::iced::Color { let fill = if panel.style == ferese_panel::PanelStyle::Morph { &theme.tokens.colors.surface_raised } else { &theme.tokens.surface.bar.background }; cosmic::iced::Color { a: theme.panel_opacity(panel.background_opacity), ..visuals::color(fill, cosmic::iced::Color::TRANSPARENT) } } impl App { pub(super) fn panel_preview(&self) -> Element<'_, Message> { let panel = match self.preview_panel() { Ok(panel) => panel, Err(error) => return self.note(&format!("Panel preview unavailable: {error}")), }; let mut rows = column([]) .spacing(12) .push( row([]) .align_y(Alignment::Center) .push( self.panel_heading(ferese_config::tr!("Composition preview"), 14.) .width(Length::Fill), ) .push(self.note(ferese_config::tr!("Sample content"))), ) .push( container(self.panel_preview_frame(panel.clone())) .padding(cosmic::iced::Padding { top: if panel.edge == ferese_panel::Edge::Top { 10. } else { 26. }, bottom: if panel.edge == ferese_panel::Edge::Top { 26. } else { 10. }, left: 12., right: 12., }) .width(Length::Fill) .class(visuals::surface( visuals::Palette::from_resolved(&self.resolved.presented).sidebar, 10., )), ); if self.panel_preview_overflow { let mut items = column([]).spacing(2); for id in &self.panel_preview_resolution.overflow { if let Some(item) = panel.item(id).filter(|item| available(item)) { items = items.push(self.settings_button( &format!("{} · {}", item.kind.label(), item.id.0), "M6 9l6 6 6-6", Some(Message::PanelSelect(item.id.clone())), self.panel_selection.as_ref() == Some(id), )); } } if !self.panel_preview_resolution.overflow.is_empty() { rows = rows.push( container(crate::scrollable(items).height(Length::Shrink)).max_height(160.), ); } } let palette = visuals::Palette::from_resolved(&self.resolved.presented); container(rows) .id("panel-composition-preview") .padding(16) .width(Length::Fill) .class(visuals::surface(palette.card, 14.)) .into() } pub(super) fn preview_panel(&self) -> Result { self.draft.preview_panel() } fn panel_preview_frame(&self, panel: Panel) -> Element<'_, Message> { if panel.style == ferese_panel::PanelStyle::Morph { let palette = visuals::Palette::from_resolved(&self.resolved.presented); let height = panel.resolved_geometry().height; let hub = container(self.label(ferese_config::tr!("● 10:09"), 13.)) .id("morph-preview-body") .center_x(panel.morph.idle_width as f32) .center_y(height); let hub = ferese_theme::panel_surface::frame( container(hub) .padding([0, if panel.morph.attached { 12 } else { 0 }]) .into(), ferese_theme::panel_surface::Options { parameters: panel.shape_parameters(), bounds: Some(cosmic::iced::advanced::widget::Id::new( "morph-preview-body", )), groups: Vec::new(), scale: self.render_scale, high_contrast: palette.high_contrast, background: panel_background(&self.resolved.presented, &panel), border: if panel.border { visuals::color( &self.resolved.presented.tokens.colors.border, cosmic::iced::Color::TRANSPARENT, ) } else { cosmic::iced::Color::TRANSPARENT }, divider: cosmic::iced::Color::TRANSPARENT, native: false, }, |_| {}, None, ); return container(hub).center_x(Length::Fill).height(height).into(); } let mut samples = Vec::new(); for zone in [&panel.center, &panel.start, &panel.end] { for item in zone .groups .iter() .flat_map(|group| &group.items) .filter(|item| available(item)) { for representation in item.representations() { samples.push(Sample { id: item.id.clone(), representation, minimum: item.kind.minimum_width(), view: self.preview_control(item, representation, None), }); } } } let overflow = panel.overflow_group(); samples.push(Sample { id: ItemId("_overflow".into()), representation: Representation::Icon, minimum: None, view: self.preview_control(&overflow.items[0], Representation::Icon, None), }); let overflow_width = 2. * f32::from(overflow.padding[1]) + if panel.group_surface() == ferese_panel::GroupSurface::Island { 2. * overflow.island_padding } else { 0. }; let parameters = panel.shape_parameters(); 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| { ( cosmic::iced::advanced::widget::Id::new(format!( "preview-group:{}", group.id.0 )), position, ) }) }) .chain(std::iter::once(( cosmic::iced::advanced::widget::Id::new("preview-group:_overflow"), ferese_panel::shape::ZonePosition::End, ))) .collect(); let background = panel_background(&self.resolved.presented, &panel); let height = panel.resolved_geometry().height; let padding = panel.resolved_geometry().inner_padding; let border = panel.border; let measured_panel = panel.clone(); let content = frame( std::borrow::Cow::Owned(measured_panel), samples, Spacing { overflow_decoration: overflow_width, zone_gap: 8., }, self.panel_preview_resolution.clone(), move |resolution| { let start = self.preview_zone(&panel, &panel.start, resolution); let center = self.preview_zone(&panel, &panel.center, resolution); let mut end = self.preview_zone(&panel, &panel.end, resolution); if !resolution.overflow.is_empty() { let mut trigger = Resolution::default(); trigger.items.insert( ItemId("_overflow".into()), Placement::Visible { representation: Representation::Icon, width: resolution.overflow_trigger_width, }, ); let trigger = self.preview_zone( &panel, &Zone { groups: vec![overflow.clone()], spacing: 0., }, &trigger, ); let has_end = panel.end.groups.iter().flat_map(|group| &group.items) .any(|item| matches!(resolution.items.get(&item.id), Some(Placement::Visible { width, .. }) if *width > 0.)); end = row([]) .spacing(if has_end { panel.zone_spacing(&panel.end) } else { 0. }) .align_y(Alignment::Center) .push(trigger) .push(end) .into(); } cosmic::iced::widget::stack![ row([]) .width(Length::Fill) .height(Length::Fill) .align_y(Alignment::Center) .push(start) .push(cosmic::widget::Space::new().width(Length::Fill)) .push(end), container(center) .center_x(Length::Fill) .center_y(Length::Fill), ] .into() }, Message::PanelPreviewResolved, ); let palette = visuals::Palette::from_resolved(&self.resolved.presented); ferese_theme::panel_surface::frame( container(content) .width(Length::Fill) .height(height) .padding([0., padding]) .clip(true) .into(), ferese_theme::panel_surface::Options { parameters, bounds: None, groups, scale: self.render_scale, high_contrast: palette.high_contrast, background, border: if border { visuals::color( &self.resolved.presented.tokens.colors.border, cosmic::iced::Color::TRANSPARENT, ) } else { cosmic::iced::Color::TRANSPARENT }, divider: visuals::color( &self.resolved.presented.tokens.colors.border, cosmic::iced::Color::TRANSPARENT, ), native: false, }, |_| {}, None, ) } fn preview_zone( &self, panel: &Panel, zone: &Zone, resolution: &Resolution, ) -> Element<'static, Message> { let mut groups = row([]) .spacing(panel.zone_spacing(zone)) .align_y(Alignment::Center); for group in &zone.groups { let mut controls = row([]).align_y(Alignment::Center); let mut count = 0; for item in &group.items { let Some(Placement::Visible { representation, width, }) = resolution.items.get(&item.id) else { continue; }; if *width <= 0. { continue; } if count > 0 { controls = controls.push( cosmic::widget::Space::new() .width(item.gap_before.unwrap_or(group.spacing)), ); } controls = controls.push(self.preview_control(item, *representation, Some(*width))); count += 1; } if count > 0 { groups = groups.push(self.preview_group(panel, group, controls.into())); } } groups.into() } fn preview_group( &self, panel: &Panel, group: &Group, controls: Element<'static, Message>, ) -> Element<'static, Message> { let horizontal = f32::from(group.padding[1]) + if panel.group_surface() == ferese_panel::GroupSurface::Island { group.island_padding } else { 0. }; container(controls) .id(format!("preview-group:{}", group.id.0)) .padding(cosmic::iced::Padding { top: group.padding[0].into(), bottom: group.padding[0].into(), left: horizontal, right: horizontal, }) .height(panel.resolved_geometry().height) .align_y(Alignment::Center) .into() } fn preview_control( &self, item: &Item, representation: Representation, width: Option, ) -> Element<'static, Message> { use ferese_theme::icons; let control_height = (self.draft.number("panels.0.geometry.height", 36.) as f32 - 4.).max(20.); let palette = visuals::Palette::from_resolved(&self.resolved.presented); if item.kind == ItemKind::Notifications { let mark = container(cosmic::widget::Space::new().width(3).height(13)) .id(format!("preview-icon:{}", item.id.0)) .class(visuals::surface(cosmic::iced::Color::WHITE, 1.5)); let control = button::custom(container(mark).center_y(control_height)) .name(ferese_config::tr!("Edit Notifications")) .padding([0, 4]) .height(control_height) .on_press(Message::PanelSelect(item.id.clone())) .class(preview_button( palette, self.panel_selection.as_ref() == Some(&item.id), )); return container(control) .id(format!("preview-item:{}", item.id.0)) .width(width.map_or(Length::Shrink, Length::Fixed)) .clip(true) .into(); } if let ItemKind::Workspaces { style } = item.kind { let sample = ferese_theme::workspaces::sample(style, palette, self.font, 20.); let control = button::custom(container(sample).id(format!("preview-icon:{}", item.id.0))) .name(ferese_config::tr!("Edit Workspaces")) .padding([0, 2]) .height(control_height) .on_press(Message::PanelSelect(item.id.clone())) .class(preview_button( palette, self.panel_selection.as_ref() == Some(&item.id), )); return container(control) .id(format!("preview-item:{}", item.id.0)) .width(width.map_or(Length::Shrink, Length::Fixed)) .clip(true) .into(); } let source = match item.kind { ItemKind::Overview => icons::FERESE, ItemKind::Workspaces { .. } => icons::OVERVIEW, ItemKind::FocusedWindow => icons::DISPLAY, ItemKind::WindowTray => icons::APPLICATION, ItemKind::Media => icons::MEDIA, ItemKind::Activities => icons::FERESE, ItemKind::QuickSettings => icons::CONTROL_CENTER, ItemKind::NightLight => icons::MOON, ItemKind::Network => icons::WIFI_FULL, ItemKind::Audio => icons::VOLUME_HIGH, ItemKind::Recording => icons::RECORD, ItemKind::Tray => icons::APPLICATION, ItemKind::CaptureStatus => icons::DISPLAY, ItemKind::Notifications => icons::NOTIFICATIONS, ItemKind::Battery { .. } => icons::BATTERY_75, ItemKind::Clock => icons::CALENDAR, ItemKind::DisplayMode => icons::DISPLAY, ItemKind::Overflow => icons::CHEVRON_DOWN, }; let label = match (item.kind, representation) { (ItemKind::Workspaces { .. }, _) => "1 2 3", (ItemKind::FocusedWindow, _) => "Settings", (ItemKind::Activities, Representation::Wide) => { if self.draft.boolean("activities.show_badge", true) { "Build · 62% +1" } else { "Build · 62%" } } (ItemKind::Activities, Representation::Compact) => "Build", (ItemKind::Media, Representation::Wide) => "Song title · Artist ▷", (ItemKind::Media, Representation::Compact) => "Song title", (ItemKind::Battery { percentage: true }, Representation::Wide) => "75%", (ItemKind::Clock, Representation::Wide) => "Wed, 7 Oct · 14:35", (ItemKind::Clock, Representation::Compact) => "14:35", _ => "", }; let mut content = row([]) .height(Length::Fill) .spacing(5) .align_y(Alignment::Center) .push( container(icons::tinted(source, 16, palette.text)) .id(format!("preview-icon:{}", item.id.0)), ); if item.kind == ItemKind::WindowTray { for source in [icons::DISPLAY, icons::FERESE] { content = content.push(icons::tinted(source, 16, palette.text)); } } if !label.is_empty() { content = content.push( self.label(ferese_config::locale::text(label), 12.) .wrapping(cosmic::iced::widget::text::Wrapping::None), ); } let action = if item.kind == ItemKind::Overflow { Message::PanelPreviewOverflow } else { Message::PanelSelect(item.id.clone()) }; let control: Element<'static, Message> = button::custom(content) .name(format!("Edit {}", item.id.0)) .padding([0, 7]) .height(control_height) .on_press(action) .class(preview_button( palette, if item.kind == ItemKind::Overflow { self.panel_preview_overflow } else { self.panel_selection.as_ref() == Some(&item.id) }, )) .into(); container(control) .id(format!("preview-item:{}", item.id.0)) .width(width.map_or(Length::Shrink, Length::Fixed)) .clip(true) .into() } } /// Preview controls sit on a shared surface. Only interaction paints a background; /// keep their hit area and border geometry identical in every state. fn preview_button(palette: visuals::Palette, selected: bool) -> cosmic::theme::Button { let paint = move |hovered: bool, pressed: bool, focused: bool| { let background = if selected { Some( palette .accent .scale_alpha(if hovered || pressed { 0.26 } else { 0.16 }) .into(), ) } else if pressed { Some(palette.text.scale_alpha(0.14).into()) } else if hovered || focused { Some(palette.text.scale_alpha(0.08).into()) } else { None }; cosmic::widget::button::Style { shape: Some(cosmic::iced::border::Shape::Continuous), background, text_color: Some(palette.text), icon_color: Some(palette.text), border_radius: 6.into(), border_width: 1., border_color: if focused { palette.accent } else if selected { palette.accent.scale_alpha(0.6) } else { cosmic::iced::Color::TRANSPARENT }, outline: None, outline_width: 0., ..Default::default() } }; cosmic::theme::Button::Custom { active: Box::new(move |focused, _| paint(false, false, focused)), hovered: Box::new(move |focused, _| paint(true, false, focused)), pressed: Box::new(move |focused, _| paint(true, true, focused)), disabled: Box::new(move |_| paint(false, false, false)), } } #[cfg(test)] mod tests { use super::*; use cosmic::Application; use cosmic::iced::advanced::renderer::Headless; use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, mouse, widget}; use cosmic::iced::{Event, Font, Pixels, Point, Rectangle, Size}; #[test] fn preview_uses_bar_tokens_and_inherited_opacity_for_every_available_style() { let mut theme = ferese_config::theme::default_theme(); theme.tokens.colors.surface_raised = "#112233".into(); theme.tokens.surface.bar.background = "#abcdef".into(); theme.tokens.material.style = "translucent".into(); theme.tokens.material.opacity = 0.35; for style in ferese_panel::PanelStyle::AVAILABLE { let mut panel = Panel::from_preset(style); let expected = if style == ferese_panel::PanelStyle::Morph { cosmic::iced::Color::from_rgb8(0x11, 0x22, 0x33) } else { cosmic::iced::Color::from_rgb8(0xab, 0xcd, 0xef) }; for (override_opacity, opacity) in [(None, 0.35), (Some(0.), 0.), (Some(0.7), 0.7)] { panel.background_opacity = override_opacity; for reduce in [false, true] { theme.accessibility.reduce_transparency = reduce; assert_eq!( panel_background(&theme, &panel), cosmic::iced::Color { a: if reduce { 1. } else { opacity }, ..expected }, "{style:?}, opacity={override_opacity:?}, reduce={reduce}", ); } } } } #[derive(Default)] struct Bounds { targets: Vec<(widget::Id, ItemId)>, items: Vec<(ItemId, Rectangle)>, } 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 let Some((_, item)) = self.targets.iter().find(|(target, _)| Some(target) == id) { self.items.push((item.clone(), bounds)); } } } #[test] fn preview_measures_adapts_and_targets_exact_instances_without_writing_config() { 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 app = App::init( crate::Core::default(), ( "/unused/preview-test.kdl".into(), crate::store::Snapshot::parse(String::new()), None, None, ), ) .0; let panel = app.preview_panel().unwrap(); let source = app.draft.source.clone(); let mut tree = None; for width in [2400., 400., 180., 2400.] { let mut view = app.panel_preview_frame(panel.clone()); let tree = tree.get_or_insert_with(|| widget::Tree::new(&view)); tree.diff(view.as_widget_mut()); let viewport = Rectangle::with_size(Size::new(width, 80.)); 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!(messages.len(), 1, "unchanged allocation is published once"); let Message::PanelPreviewResolved(resolution) = &messages[0] else { panic!("expected resolution") }; assert!(resolution.fits(width)); let clock = ItemId("clock".into()); if width == 2400. { assert!(matches!( resolution.items.get(&clock), Some(Placement::Visible { representation: Representation::Wide, .. }) )); assert!(resolution.overflow.is_empty()); } else { assert!(!resolution.overflow.is_empty()); } let mut bounds = Bounds { targets: resolution .items .keys() .cloned() .chain(std::iter::once(ItemId("_overflow".into()))) .map(|id| (widget::Id::new(format!("preview-item:{}", id.0)), id)) .collect(), ..Default::default() }; view.as_widget_mut() .operate(tree, Layout::new(&node), &renderer, &mut bounds); for (id, bounds) in &bounds.items { let expected = if id.0 == "_overflow" { resolution.overflow_trigger_width } else { let Placement::Visible { width, .. } = resolution.items[id] else { panic!("overflowed item was rendered") }; width }; assert!( (bounds.width - expected).abs() < 0.1, "{id:?}: {bounds:?}, expected {expected}" ); assert!( bounds.x >= -0.1 && bounds.x + bounds.width <= width + 0.1, "{bounds:?} outside {width}" ); } if width == 2400. { let target = bounds .items .iter() .find(|(id, _)| id == &clock) .unwrap() .1 .center(); for event in [ mouse::Event::ButtonPressed(mouse::Button::Left), mouse::Event::ButtonReleased(mouse::Button::Left), ] { view.as_widget_mut().update( tree, &Event::Mouse(event), Layout::new(&node), mouse::Cursor::Available(Point::new(target.x, target.y)), &renderer, &mut clipboard::Null, &mut Shell::new(&mut messages), &viewport, ); } assert_eq!( messages .iter() .filter( |message| matches!(message, Message::PanelSelect(id) if id == &clock) ) .count(), 1 ); } } assert_eq!(app.draft.source, source); assert!(app.pending.is_empty()); } #[test] fn preview_title_stays_centered_and_separate_from_asymmetric_side_groups() { 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 app = App::init( crate::Core::default(), ( "/unused/preview-centering.kdl".into(), crate::store::Snapshot::parse(String::new()), None, None, ), ) .0; for background in [ ferese_panel::PanelStyle::Flat, ferese_panel::PanelStyle::Floating, ] { let mut panel = Panel::from_preset(ferese_panel::PanelStyle::Floating); panel.apply_preset(background); // Match a three-group layout with media in navigation and a larger status group. let media = panel.end.groups[0].items.remove(0); panel.start.groups[0].items.push(media); let title = &panel.center.groups[0].items[0]; let mut sample = app.preview_control(title, Representation::Wide, None); let mut sample_tree = widget::Tree::new(&sample); let natural = sample .as_widget_mut() .layout( &mut sample_tree, &renderer, &layout::Limits::new(Size::ZERO, Size::new(2400., 80.)), ) .size() .width; for width in [560., 668., 800., 1200.] { let mut view = app.panel_preview_frame(panel.clone()); 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, 80.)), ); let mut bounds = Bounds { targets: [&panel.start, &panel.center, &panel.end] .into_iter() .flat_map(|zone| &zone.groups) .flat_map(|group| &group.items) .map(|item| { ( widget::Id::new(format!("preview-item:{}", item.id.0)), item.id.clone(), ) }) .chain(std::iter::once(( widget::Id::new("preview-item:_overflow"), ItemId("_overflow".into()), ))) .collect(), ..Default::default() }; view.as_widget_mut() .operate(&mut tree, Layout::new(&node), &renderer, &mut bounds); let center = bounds .items .iter() .find(|(id, _)| id == &title.id) .expect("sample title remains visible") .1; assert!( (center.center_x() - width / 2.).abs() < 0.1, "{center:?} at {width}" ); // 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; } let gap = if side.center_x() < center.center_x() { center.x - side.x - side.width } else { side.x - center.x - center.width }; assert!( gap >= 7.9, "{background:?} at {width}: {} crowds title ({gap}px)", id.0 ); } } } } #[test] fn preview_icons_are_centered_in_their_hit_areas_and_across_groups() { 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 app = App::init( crate::Core::default(), ( "/unused/alignment.kdl".into(), crate::store::Snapshot::parse(String::new()), None, None, ), ) .0; for background in [ ferese_panel::PanelStyle::Flat, ferese_panel::PanelStyle::Floating, ] { let panel = Panel::from_preset(background); let ids: Vec<_> = [&panel.start, &panel.center, &panel.end] .into_iter() .flat_map(|zone| &zone.groups) .flat_map(|group| &group.items) .map(|item| item.id.clone()) .chain(std::iter::once(ItemId("_overflow".into()))) .collect(); for width in [180., 400., 2400.] { for selected in [None, Some(ItemId("battery".into()))] { app.panel_selection = selected; let mut view = app.panel_preview_frame(panel.clone()); 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, 80.)), ); let mut controls = Bounds { targets: ids .iter() .map(|id| { ( widget::Id::new(format!("preview-item:{}", id.0)), id.clone(), ) }) .collect(), ..Default::default() }; let mut icons = Bounds { targets: ids .iter() .map(|id| { ( widget::Id::new(format!("preview-icon:{}", id.0)), id.clone(), ) }) .collect(), ..Default::default() }; view.as_widget_mut().operate( &mut tree, Layout::new(&node), &renderer, &mut controls, ); view.as_widget_mut().operate( &mut tree, Layout::new(&node), &renderer, &mut icons, ); assert!(!controls.items.is_empty()); assert_eq!(controls.items.len(), icons.items.len()); for (id, icon) in &icons.items { let control = controls .items .iter() .find(|(item, _)| item == id) .unwrap() .1; assert!( (icon.center_y() - control.center_y()).abs() < 0.01, "{background:?} at {width}: {} icon {icon:?} is off-center in {control:?}", id.0 ); assert!( (icon.center_y() - node.size().height / 2.).abs() < 0.01, "{} is not on the panel centerline", id.0 ); } } } } } #[test] fn preview_hover_and_selection_stay_inside_the_control_and_idle_is_transparent() { use cosmic::iced::Color; use cosmic::iced::advanced::renderer::{self, Renderer as _}; let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); let mut renderer = runtime .block_on(::new( Font::default(), Pixels(14.), Some("tiny-skia"), )) .unwrap(); let mut app = App::init( crate::Core::default(), ( "/unused/hover.kdl".into(), crate::store::Snapshot::parse(String::new()), None, None, ), ) .0; let item = Item::new("battery", ItemKind::Battery { percentage: false }); let viewport = Rectangle::with_size(Size::new(96., 48.)); let backdrop = Color::from_rgb8(35, 47, 61); let theme = app.native_palette.native_theme(); let mut baseline: Option> = None; let mut baseline_bounds = None; for state in ["idle", "hovered", "pressed", "selected"] { app.panel_selection = (state == "selected").then(|| item.id.clone()); let mut view: Element<'_, Message> = container(app.preview_control(&item, Representation::Icon, None)) .center_x(Length::Fill) .center_y(Length::Fill) .into(); let mut tree = widget::Tree::new(&view); let node = view.as_widget_mut().layout( &mut tree, &renderer, &layout::Limits::new(Size::ZERO, viewport.size()), ); let mut bounds = Bounds { targets: vec![(widget::Id::new("preview-item:battery"), item.id.clone())], ..Default::default() }; view.as_widget_mut() .operate(&mut tree, Layout::new(&node), &renderer, &mut bounds); let target = bounds.items[0].1; if let Some(baseline) = baseline_bounds { assert_eq!(target, baseline); } else { baseline_bounds = Some(target); } let cursor = if state == "hovered" || state == "pressed" { mouse::Cursor::Available(target.center()) } else { mouse::Cursor::Unavailable }; if state == "pressed" { view.as_widget_mut().update( &mut tree, &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), Layout::new(&node), cursor, &renderer, &mut clipboard::Null, &mut Shell::new(&mut Vec::new()), &viewport, ); } renderer.reset(viewport); view.as_widget().draw( &tree, &mut renderer, &theme, &renderer::Style { text_color: app.native_palette.text, icon_color: app.native_palette.text, scale_factor: 1., }, Layout::new(&node), cursor, &viewport, ); let pixels = Headless::screenshot(&mut renderer, Size::new(96, 48), 1., backdrop); if let Some(baseline) = &baseline { let mut changed = 0; for (index, (before, after)) in baseline .chunks_exact(4) .zip(pixels.chunks_exact(4)) .enumerate() { if before != after { changed += 1; assert!( target.contains(Point::new( (index % 96) as f32 + 0.5, (index / 96) as f32 + 0.5 )), "{state} paints outside the hit area" ); } } assert!(changed > 40, "{state} has no visible feedback"); } else { // Above the glyph but inside the button: untouched backdrop, not // an opaque rectangle that hides the panel's chosen opacity. let index = ((target.y as usize + 2) * 96 + target.center_x() as usize) * 4; assert_eq!(&pixels[index..index + 4], &pixels[..4]); baseline = Some(pixels); } } } #[test] fn invalid_source_is_reported_before_preview_layout() { let app = App::init( crate::Core::default(), ( "/unused/preview-test.kdl".into(), crate::store::Snapshot::parse("panel main { group-surface unknown; }".into()), None, None, ), ) .0; assert!(app.error.is_some()); assert!(app.preview_panel().is_ok()); let _ = app.panel_preview(); assert!(app.pending.is_empty()); } }