Files
feresaria/shell/ferese-settings/src/panel_preview.rs
T

1082 lines
42 KiB
Rust

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<Panel, String> {
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<f32>,
) -> 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(<cosmic::Renderer as Headless>::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(<cosmic::Renderer as Headless>::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(<cosmic::Renderer as Headless>::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(<cosmic::Renderer as Headless>::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<Vec<u8>> = 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());
}
}