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

1456 lines
54 KiB
Rust

use super::*;
use cosmic::Application;
use cosmic::iced::advanced::renderer::{Headless, Renderer as _};
use cosmic::iced::advanced::widget::{Operation, Tree};
use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, mouse, renderer};
use cosmic::iced::{Color, Event, Font, Pixels, Point, Rectangle, Size, Vector, window};
#[derive(Debug, Default)]
struct ScrollPosition {
offset: f32,
content_height: f32,
viewport_height: f32,
}
impl Operation for ScrollPosition {
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) {
operate(self);
}
fn scrollable(
&mut self,
id: Option<&cosmic::iced::advanced::widget::Id>,
bounds: Rectangle,
content: Rectangle,
translation: Vector,
_: &mut dyn cosmic::iced::advanced::widget::operation::Scrollable,
) {
if id == Some(&widget::Id::new("settings-content")) {
self.offset = translation.y;
self.content_height = content.height;
self.viewport_height = bounds.height;
}
}
}
#[test]
fn panel_preview_stays_visible_while_the_items_editor_scrolls() {
#[derive(Default)]
struct Bounds {
preview: Option<Rectangle>,
viewport: Option<Rectangle>,
}
impl Operation for Bounds {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("panel-composition-preview")) {
self.preview = Some(bounds);
}
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: Rectangle,
_: Vector,
_: &mut dyn cosmic::iced::advanced::widget::operation::Scrollable,
) {
if id == Some(&widget::Id::new("settings-content")) {
self.viewport = Some(bounds);
}
}
}
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(
Core::default(),
(
"/unused/editor.kdl".into(),
Snapshot::parse(String::new()),
None,
None,
),
)
.0;
app.page = Page::Bar;
app.panel_page = panel_controls::PanelPage::Arrange;
app.draft
.edit(&panel_controls::initialize(&app.draft).unwrap())
.unwrap();
let mut view = app.page_view();
let mut tree = Tree::new(&view);
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(740., 650.)),
);
let mut before = Bounds::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut before);
let mut scroll = cosmic::iced::advanced::widget::operation::scrollable::scroll_to::<()>(
widget::Id::new("settings-content"),
cosmic::iced::widget::scrollable::AbsoluteOffset {
x: None,
y: Some(200.),
},
);
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut scroll);
let mut position = ScrollPosition::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut position);
assert!(position.offset > 0. && position.content_height > position.viewport_height);
let mut after = Bounds::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut after);
assert_eq!(before.preview, after.preview);
let preview = after.preview.unwrap();
let viewport = after.viewport.unwrap();
assert!(preview.y + preview.height <= viewport.y);
assert!(preview.height > 0. && viewport.height > 0.);
}
#[test]
#[ignore = "release scroll/render sample; requires headless renderer backends"]
fn appearance_scroll_preserves_progress_and_settles_visibility() {
#[derive(Default)]
struct Labels {
labels: Vec<Rectangle>,
viewport: Option<Rectangle>,
translation: Vector,
}
impl Operation for Labels {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if [
"theme.accent",
"theme.focus_ring.style",
"theme.material.blur_radius",
"theme.typography.font_family",
]
.iter()
.any(|path| id == Some(&widget::Id::new(format!("setting-label-{path}"))))
{
self.labels.push(bounds);
}
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: Rectangle,
translation: Vector,
_: &mut dyn cosmic::iced::advanced::widget::operation::Scrollable,
) {
if id == Some(&widget::Id::new("settings-content")) {
self.viewport = Some(bounds);
self.translation = translation;
}
}
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let bounds = Rectangle::with_size(Size::new(960., 600.));
let scale = 1.75;
let physical = Size::new(1680, 1050);
for backend in ["wgpu", "tiny-skia"] {
let mut renderer = runtime
.block_on(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.),
Some(backend),
))
.expect("requested renderer");
for split in [false, true] {
let source = format!("theme {{ split #{split}; family \"ferese-blue\"; }}");
let mut app = App::init(
Core::default(),
(
PathBuf::from("/unused/scroll-test.kdl"),
Snapshot::parse(source),
None,
None,
),
)
.0;
app.page = Page::Theme;
let theme = app.native_palette.native_theme();
let mut tree: Option<Tree> = None;
let mut previous = 0.;
let mut render_times = Vec::new();
let mut visibility_messages = 0;
let mut checked_labels = 0;
for frame in 0..60 {
let mut messages = Vec::new();
let mut clipboard = clipboard::Null;
let mut shell = Shell::new(&mut messages);
let cursor = mouse::Cursor::Available(Point::new(680., 300.));
let mut view = app.page_view();
let tree = tree.get_or_insert_with(|| Tree::new(view.as_widget()));
tree.diff(view.as_widget_mut());
let node = view.as_widget_mut().layout(
tree,
&renderer,
&layout::Limits::new(Size::ZERO, bounds.size()),
);
let event = if !(4..52).contains(&frame) {
Event::Window(window::Event::RedrawRequested(std::time::Instant::now()))
} else {
Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Pixels {
x: 0.,
y: if frame < 28 { -48. } else { 48. },
},
})
};
view.as_widget_mut().update(
tree,
&event,
Layout::new(&node),
cursor,
&renderer,
&mut clipboard,
&mut shell,
&bounds,
);
// Sensors observe real redraw events as well as wheel input.
if (4..52).contains(&frame) {
view.as_widget_mut().update(
tree,
&Event::Window(window::Event::RedrawRequested(std::time::Instant::now())),
Layout::new(&node),
cursor,
&renderer,
&mut clipboard,
&mut shell,
&bounds,
);
}
let mut position = ScrollPosition::default();
view.as_widget_mut()
.operate(tree, Layout::new(&node), &renderer, &mut position);
assert!(position.content_height > position.viewport_height);
if (4..28).contains(&frame) {
assert!(
position.offset > previous
|| position.offset
>= position.content_height - position.viewport_height - 0.5,
"scroll stopped at {position:?}"
);
}
if (28..52).contains(&frame) {
assert!(
position.offset < previous || position.offset == 0.,
"reverse scroll stopped at {position:?}"
);
}
previous = position.offset;
renderer.reset(bounds);
view.as_widget().draw(
tree,
&mut renderer,
&theme,
&renderer::Style {
text_color: Color::WHITE,
icon_color: Color::WHITE,
scale_factor: scale as f64,
},
Layout::new(&node),
cursor,
&bounds,
);
let mut labels = Labels::default();
view.as_widget_mut()
.operate(tree, Layout::new(&node), &renderer, &mut labels);
let started = std::time::Instant::now();
let pixels = Headless::screenshot(
&mut renderer,
physical,
scale,
theme.cosmic().bg_color().into(),
);
if (4..52).contains(&frame) {
render_times.push(started.elapsed().as_micros());
}
if let Some(viewport) = labels.viewport {
for bounds in labels.labels {
let bounds = bounds - labels.translation;
if bounds.y < viewport.y + 2.
|| bounds.y + bounds.height > viewport.y + viewport.height - 2.
{
continue;
}
let mut ink = 0;
for y in (bounds.y * scale).ceil() as u32
..((bounds.y + bounds.height) * scale).floor() as u32
{
for x in (bounds.x * scale).ceil() as u32
..((bounds.x + bounds.width) * scale).floor() as u32
{
let index = ((y * physical.width + x) * 4) as usize;
if pixels[index..index + 3]
.iter()
.any(|channel| *channel > 140)
{
ink += 1;
}
}
}
assert!(
ink > 12,
"missing field text: backend={backend} split={split} frame={frame} bounds={bounds:?}"
);
checked_labels += 1;
}
}
drop(view);
for message in messages {
if matches!(message, Message::RowVisibility(..)) {
visibility_messages += 1;
assert!(
frame < 56,
"visibility kept changing after scrolling stopped"
);
}
let _ = app.update(message);
}
}
render_times.sort_unstable();
assert!(
checked_labels > 20,
"scroll check must include visible field labels"
);
eprintln!(
"backend={backend} split={split} render_median_us={} p95_us={} visibility_messages={visibility_messages}",
render_times[render_times.len() / 2],
render_times[render_times.len() * 95 / 100]
);
}
}
}
#[test]
fn gpu_panel_text_survives_preview_clipping_and_scrolling() {
let matched = std::process::Command::new("fc-match")
.args(["--format=%{family}\n%{file}", "sans-serif"])
.output()
.expect("rendering tests require fontconfig");
assert!(matched.status.success());
let matched = String::from_utf8(matched.stdout).unwrap();
let (family, path) = matched
.split_once('\n')
.expect("rendering tests require a system font");
let font = ferese_theme::font(Some(family.split(',').next().unwrap()));
cosmic::iced::advanced::graphics::text::font_system()
.write()
.unwrap()
.load_font(std::borrow::Cow::Owned(std::fs::read(path).unwrap()));
#[derive(Default)]
struct Labels {
labels: Vec<(String, Rectangle, Option<Rectangle>)>,
traversal_start: usize,
}
impl Operation for Labels {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
let start = self.labels.len();
children(self);
self.traversal_start = start;
}
fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) {
if matches!(
text,
"Top panel"
| "Shown on all displays"
| "Position"
| "Background"
| "Background opacity"
| "Group borders"
| "Corner radius"
| "Size & spacing"
| "Side margins"
| "Window clearance"
| "Arrangement"
| "Start"
| "Center"
| "End"
| "Add group"
| "Add item"
| "Overview"
| "Workspaces"
| "Morph"
| "Collapsed height"
| "Battery"
| "Placement"
| "Show item"
| "Overflow"
| "Preferred size"
| "Battery percentage"
| "Make it your own"
| "Surface"
| "Item spacing"
| "Vertical padding"
| "Horizontal padding"
| "Undo"
| "Reload configuration"
) {
self.labels.push((text.into(), bounds, None));
}
}
fn scrollable(
&mut self,
_: Option<&widget::Id>,
viewport: Rectangle,
_: Rectangle,
translation: Vector,
_: &mut dyn cosmic::iced::advanced::widget::operation::Scrollable,
) {
// Scrollable reports its offset after traversing its content.
for (_, bounds, clip) in &mut self.labels[self.traversal_start..] {
*bounds = *bounds - translation;
*clip = Some(clip.map_or(viewport, |clip| {
(clip - translation)
.intersection(&viewport)
.unwrap_or_default()
}));
}
}
}
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("wgpu"),
))
.expect("GPU panel rendering requires a hardware or software WGPU adapter");
for width in [740., 1100.] {
for scale in [1., 1.25, 1.5, 1.75] {
for selection in ["panels", "morph", "items", "battery", "status"] {
let mut app = App::init(
Core::default(),
(
"/unused/panel-text.kdl".into(),
Snapshot::parse("theme { mode dark; family \"ferese-blue\"; }".into()),
Some(InitialPage::Bar),
None,
),
)
.0;
app.font = font;
app.render_scale = f64::from(scale);
if selection == "morph" {
for edit in crate::panel_edit::plan(
&app.draft,
crate::panel_edit::Action::ApplyPreset(ferese_panel::PanelStyle::Morph),
)
.unwrap()
{
app.draft.edit(&edit).unwrap();
}
}
app.panel_page = if matches!(selection, "panels" | "morph") {
panel_controls::PanelPage::Panels
} else {
panel_controls::PanelPage::Arrange
};
app.panel_selection =
(selection == "battery").then(|| ferese_panel::ItemId("battery".into()));
app.panel_group_selection =
(selection == "status").then(|| ferese_panel::GroupId("status".into()));
let bounds = Rectangle::with_size(Size::new(width, 650.));
let physical = Size::new((width * scale) as u32, (650. * scale) as u32);
let theme = app.native_palette.native_theme();
let mut view = app.page_view();
let mut tree = Tree::new(&view);
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, bounds.size()),
);
let mut checked = 0;
for offset in [0., 180., 400.] {
for id in ["settings-content", "panel-inspector-scroll"] {
let mut scroll =
cosmic::iced::advanced::widget::operation::scrollable::scroll_to::<()>(
widget::Id::new(id),
cosmic::iced::widget::scrollable::AbsoluteOffset {
x: None,
y: Some(offset),
},
);
view.as_widget_mut().operate(
&mut tree,
Layout::new(&node),
&renderer,
&mut scroll,
);
}
renderer.reset(bounds);
view.as_widget().draw(
&tree,
&mut renderer,
&theme,
&renderer::Style {
text_color: app.native_palette.text,
icon_color: app.native_palette.text,
scale_factor: scale as f64,
},
Layout::new(&node),
mouse::Cursor::Unavailable,
&bounds,
);
if let Some(mut overlay) = view.as_widget_mut().overlay(
&mut tree,
Layout::new(&node),
&renderer,
&bounds,
Vector::ZERO,
) {
let overlay = overlay.as_overlay_mut();
let node = overlay.layout(&renderer, bounds.size());
renderer.with_layer(bounds, |renderer| {
overlay.draw(
renderer,
&theme,
&renderer::Style {
text_color: app.native_palette.text,
icon_color: app.native_palette.text,
scale_factor: scale as f64,
},
Layout::new(&node),
mouse::Cursor::Unavailable,
);
});
}
let mut labels = Labels::default();
view.as_widget_mut().operate(
&mut tree,
Layout::new(&node),
&renderer,
&mut labels,
);
let pixels = Headless::screenshot(
&mut renderer,
physical,
scale,
theme.cosmic().bg_color().into(),
);
let palette = visuals::Palette::from_resolved(&app.resolved.presented);
for (label, rect, clip) in labels.labels {
let clip = clip.unwrap_or(bounds);
if rect.width <= 0.
|| rect.height <= 0.
|| rect.x < clip.x
|| rect.y < clip.y + 2.
|| rect.x + rect.width > clip.x + clip.width
|| rect.y + rect.height > clip.y + clip.height - 2.
{
continue;
}
let mut colors = std::collections::HashMap::<[u8; 3], usize>::new();
for y in (rect.y * scale).ceil() as u32
..((rect.y + rect.height) * scale).floor() as u32
{
for x in (rect.x * scale).ceil() as u32
..((rect.x + rect.width) * scale).floor() as u32
{
let index = ((y * physical.width + x) * 4) as usize;
let rgb = pixels[index..index + 3].try_into().unwrap();
*colors.entry(rgb).or_default() += 1;
}
}
// The most common color is the local background. Compare
// against the actual text palette so muted glyphs count,
// while a blank label fails on both dark and light surfaces.
let background = *colors.iter().max_by_key(|(_, count)| *count).unwrap().0;
let distance = |color: [u8; 3]| {
color
.into_iter()
.zip(background)
.map(|(a, b)| a.abs_diff(b))
.max()
.unwrap()
};
let threshold = [palette.text, palette.muted]
.into_iter()
.map(|color| {
let [r, g, b, _] = color.into_rgba8();
distance([r, g, b])
})
.min()
.unwrap()
.saturating_div(2)
.max(1);
let ink: usize = colors
.into_iter()
.filter(|(color, _)| distance(*color) >= threshold)
.map(|(_, count)| count)
.sum();
assert!(
ink > 12,
"missing panel text {label:?}: state={selection} width={width} scale={scale} offset={offset} bounds={rect:?}"
);
checked += 1;
}
}
assert!(
checked >= 6,
"state={selection} must exercise visible panel labels"
);
}
}
}
}
/// Reproducible sketches rendered from the actual widgets, with no live services.
#[test]
#[ignore = "writes Panel & Shell review images to /tmp/ferese-panel-review-{backend}"]
fn render_panel_review_states() {
let backend = std::env::var("FERESE_REVIEW_BACKEND").unwrap_or_else(|_| "tiny-skia".into());
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(&backend),
))
.unwrap();
let directory = format!("/tmp/ferese-panel-review-{backend}");
std::fs::create_dir_all(&directory).unwrap();
let mut app = App::init(
Core::default(),
(
"/unused/review.kdl".into(),
Snapshot::parse(String::new()),
Some(InitialPage::Bar),
None,
),
)
.0;
app.status = "Changes save automatically".into();
for appearance in ["light", "dark"] {
let theme_config = Snapshot::parse(format!("theme {{ mode \"{appearance}\"; }}")).unwrap();
let theme = ferese_config::theme::resolve(
&theme_config.doc,
std::path::Path::new("/tmp"),
"2026-10-07T12:00:00Z".parse().unwrap(),
|_| unreachable!(),
)
.unwrap()
.theme;
app.resolved.presented = theme.clone();
app.resolved.theme = theme;
app.font = fonts::interface_font(&app.resolved.presented.tokens.typography.font_family);
app.native_palette = visuals::Palette::from_resolved(&app.resolved.presented);
for (name, tab, item, group) in [
("panels", panel_controls::PanelPage::Panels, None, None),
(
"floating-groups",
panel_controls::PanelPage::Panels,
None,
None,
),
(
"group-spacing-inspector",
panel_controls::PanelPage::Arrange,
None,
Some("title"),
),
("corners", panel_controls::PanelPage::Panels, None, None),
("morph", panel_controls::PanelPage::Panels, None, None),
(
"preview-hover",
panel_controls::PanelPage::Panels,
None,
None,
),
("navigation", panel_controls::PanelPage::Arrange, None, None),
("shortcuts", panel_controls::PanelPage::Panels, None, None),
("motion", panel_controls::PanelPage::Panels, None, None),
("items", panel_controls::PanelPage::Arrange, None, None),
(
"workspace-styles",
panel_controls::PanelPage::Arrange,
Some("workspaces"),
None,
),
(
"selected-item",
panel_controls::PanelPage::Arrange,
Some("battery"),
None,
),
(
"selected-group",
panel_controls::PanelPage::Arrange,
None,
Some("status"),
),
] {
app.page = match name {
"shortcuts" => Page::Shortcuts,
"motion" => Page::Motion,
_ => Page::Bar,
};
app.draft = Snapshot::parse(String::new()).unwrap();
if matches!(name, "floating-groups" | "group-spacing-inspector") {
let mut panel =
ferese_panel::Panel::from_preset(ferese_panel::PanelStyle::Floating);
panel.geometry.edge_margin = Some(6);
app.draft
.edit(&crate::store::set(
"panels",
serde_json::to_value(vec![panel]).unwrap(),
))
.unwrap();
}
if name == "morph" {
for edit in crate::panel_edit::plan(
&app.draft,
crate::panel_edit::Action::ApplyPreset(ferese_panel::PanelStyle::Morph),
)
.unwrap()
{
app.draft.edit(&edit).unwrap();
}
}
if name == "corners" {
for edit in crate::panel_edit::plan(
&app.draft,
crate::panel_edit::Action::SetRadius("4px 8px 12px 16px".into()),
)
.unwrap()
{
app.draft.edit(&edit).unwrap();
}
app.draft = Snapshot::parse(app.draft.source.clone()).unwrap();
}
app.panel_page = tab;
app.sidebar_open = name == "navigation";
app.panel_selection = item.map(|id| ferese_panel::ItemId(id.into()));
app.panel_group_selection = group.map(|id| ferese_panel::GroupId(id.into()));
for (width, height) in [(1100, 860), (740, 650)] {
let bounds = Rectangle::with_size(Size::new(width as f32, height as f32));
let theme = app.native_palette.native_theme();
let mut view = app.page_view();
let mut tree = Tree::new(&view);
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, bounds.size()),
);
#[derive(Default)]
struct HoverTarget(Option<Rectangle>);
impl Operation for HoverTarget {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("preview-item:battery")) {
self.0 = Some(bounds);
}
}
}
let cursor = if name == "preview-hover" {
let mut target = HoverTarget::default();
view.as_widget_mut().operate(
&mut tree,
Layout::new(&node),
&renderer,
&mut target,
);
mouse::Cursor::Available(
target
.0
.expect("battery is visible in the review preview")
.center(),
)
} else {
mouse::Cursor::Unavailable
};
// Responsive content is laid out before drawing, as it is in the app.
renderer.reset(bounds);
view.as_widget().draw(
&tree,
&mut renderer,
&theme,
&renderer::Style {
text_color: Color::WHITE,
icon_color: Color::WHITE,
scale_factor: 1.,
},
Layout::new(&node),
cursor,
&bounds,
);
if let Some(mut overlay) = view.as_widget_mut().overlay(
&mut tree,
Layout::new(&node),
&renderer,
&bounds,
Vector::ZERO,
) {
let overlay = overlay.as_overlay_mut();
let layout = overlay.layout(&renderer, bounds.size());
renderer.with_layer(bounds, |renderer| {
overlay.draw(
renderer,
&theme,
&renderer::Style {
text_color: app.native_palette.text,
icon_color: app.native_palette.text,
scale_factor: 1.,
},
Layout::new(&layout),
mouse::Cursor::Unavailable,
);
});
}
let pixels = Headless::screenshot(
&mut renderer,
Size::new(width, height),
1.,
theme.cosmic().bg_color().into(),
);
let path = format!("{directory}/{appearance}-{name}-{width}.png");
image::save_buffer(&path, &pixels, width, height, image::ColorType::Rgba8).unwrap();
eprintln!("{path}");
}
}
}
}
#[test]
fn selecting_panel_items_preserves_editor_scroll_and_pane_bounds() {
#[derive(Default, Debug, PartialEq)]
struct Panes {
preview: Option<Rectangle>,
editor: Option<Rectangle>,
inspector: Option<Rectangle>,
}
impl Operation for Panes {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("panel-composition-preview")) {
self.preview = Some(bounds);
}
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: Rectangle,
_: Vector,
_: &mut dyn cosmic::iced::advanced::widget::operation::Scrollable,
) {
if id == Some(&widget::Id::new("settings-content")) {
self.editor = Some(bounds);
}
if id == Some(&widget::Id::new("panel-inspector-scroll")) {
self.inspector = Some(bounds);
}
}
}
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();
for width in [740., 1100.] {
let mut app = App::init(
Core::default(),
(
"/unused/layout.kdl".into(),
Snapshot::parse(String::new()),
Some(InitialPage::Bar),
None,
),
)
.0;
app.panel_page = panel_controls::PanelPage::Arrange;
let mut tree = None;
let mut initial = None;
for selection in [
None,
Some(Message::PanelSelect(ferese_panel::ItemId("battery".into()))),
Some(Message::PanelGroupSelect(ferese_panel::GroupId(
"status".into(),
))),
Some(Message::PanelClearSelection),
] {
if let Some(selection) = selection {
let _ = app.update(selection);
}
let mut view = app.page_view();
let tree = tree.get_or_insert_with(|| Tree::new(&view));
tree.diff(view.as_widget_mut());
let node = view.as_widget_mut().layout(
tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(width, 650.)),
);
if initial.is_none() {
let mut scroll =
cosmic::iced::advanced::widget::operation::scrollable::scroll_to::<()>(
widget::Id::new("settings-content"),
cosmic::iced::widget::scrollable::AbsoluteOffset {
x: None,
y: Some(180.),
},
);
view.as_widget_mut()
.operate(tree, Layout::new(&node), &renderer, &mut scroll);
}
let mut panes = Panes::default();
view.as_widget_mut()
.operate(tree, Layout::new(&node), &renderer, &mut panes);
let mut position = ScrollPosition::default();
view.as_widget_mut()
.operate(tree, Layout::new(&node), &renderer, &mut position);
assert_eq!(position.offset, 180., "selection moved the arrangement");
let editor = panes.editor.unwrap();
let inspector = panes.inspector.unwrap();
assert!(editor.x + editor.width < inspector.x, "panes overlap");
assert!(
inspector.x + inspector.width <= width,
"inspector leaves window"
);
assert!(editor.height > 100. && inspector.height > 100.);
if let Some(initial) = &initial {
assert_eq!(&panes, initial);
} else {
initial = Some(panes);
}
assert!(app.pending.is_empty() && !app.saving && app.draft.item("panels").is_none());
}
}
}
#[test]
fn sidebar_collapses_at_the_breakpoint_and_compact_navigation_dismisses() {
#[derive(Default)]
struct Bounds {
sidebar: Option<Rectangle>,
page: Option<Rectangle>,
toggle: Option<Rectangle>,
}
impl Operation for Bounds {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("settings-sidebar")) {
self.sidebar = Some(bounds);
}
if id == Some(&widget::Id::new("settings-page")) {
self.page = Some(bounds);
}
if id == Some(&widget::Id::new("settings-navigation-toggle")) {
self.toggle = Some(bounds);
}
}
}
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(
Core::default(),
(
"/unused/sidebar.kdl".into(),
Snapshot::parse(String::new()),
Some(InitialPage::Bar),
None,
),
)
.0;
app.panel_page = panel_controls::PanelPage::Arrange;
let mut tree = None;
for width in [1100., 959., 740., 960., 1100.] {
let mut view = app.page_view();
let tree = tree.get_or_insert_with(|| Tree::new(&view));
tree.diff(view.as_widget_mut());
let node = view.as_widget_mut().layout(
tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(width, 650.)),
);
let mut bounds = Bounds::default();
view.as_widget_mut()
.operate(tree, Layout::new(&node), &renderer, &mut bounds);
let compact = width < 960.;
assert_eq!(bounds.sidebar.is_none(), compact);
assert_eq!(bounds.toggle.is_some(), compact);
assert_eq!(bounds.page.unwrap().x, if compact { 0. } else { 208. });
assert_eq!(
bounds.page.unwrap().width,
if compact { width } else { width - 208. }
);
}
let _ = app.update(Message::Sidebar(true));
assert!(app.sidebar_open);
let mut view = app.page_view();
let mut tree = Tree::new(&view);
let size = Size::new(740., 650.);
let node =
view.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, size));
let mut messages = Vec::new();
{
let viewport = Rectangle::with_size(size);
let mut overlay = view
.as_widget_mut()
.overlay(
&mut tree,
Layout::new(&node),
&renderer,
&viewport,
Vector::ZERO,
)
.expect("compact navigation overlay");
let overlay = overlay.as_overlay_mut();
let layout = overlay.layout(&renderer, size);
let mut bounds = Bounds::default();
overlay.operate(Layout::new(&layout), &renderer, &mut bounds);
assert_eq!(bounds.sidebar.unwrap().width, 244.);
assert!(
bounds.page.is_none(),
"background must be excluded from modal focus navigation"
);
overlay.update(
&Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Layout::new(&layout),
mouse::Cursor::Available(Point::new(500., 300.)),
&renderer,
&mut clipboard::Null,
&mut Shell::new(&mut messages),
);
assert!(
messages
.iter()
.any(|message| matches!(message, Message::Sidebar(false)))
);
}
drop(view);
for message in messages {
let _ = app.update(message);
}
assert!(!app.sidebar_open);
let _ = app.update(Message::Sidebar(true));
let _ = app.update(Message::Page(Page::Bar));
assert!(
!app.sidebar_open,
"reselecting the current page closes navigation too"
);
assert!(app.pending.is_empty() && !app.saving);
}
#[test]
fn appearance_cards_reflow_without_overlap_and_keep_every_destination() {
#[derive(Default)]
struct Cards {
bounds: std::collections::HashMap<usize, Rectangle>,
}
impl Operation for Cards {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
for page in Page::ALL {
if id == Some(&widget::Id::new(format!("settings-hub-{}", page as usize))) {
self.bounds.insert(page as usize, bounds);
}
}
}
}
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(
Core::default(),
(
"/unused/overview.kdl".into(),
Snapshot::parse(String::new()),
None,
None,
),
)
.0;
for width in [480., 720., 960., 1280., 1800.] {
let mut view = app.page_view();
let mut tree = Tree::new(&view);
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(width, 760.)),
);
let mut cards = Cards::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut cards);
assert_eq!(
cards.bounds.len(),
15,
"{width}: all settings destinations must be reachable"
);
for bounds in cards.bounds.values() {
assert!(
bounds.x >= 0. && bounds.x + bounds.width <= width + 0.5,
"{width}: {bounds:?}"
);
assert!(bounds.width > 200. && bounds.height > 40.);
}
let general = cards.bounds[&(Page::General as usize)];
let complete = cards.bounds[&(Page::Configuration as usize)];
if width <= 720. {
assert!((general.x - complete.x).abs() < 0.5);
assert!(complete.y >= general.y + general.height);
} else {
assert!((general.y - complete.y).abs() < 0.5);
assert!(complete.x >= general.x + general.width + 8.);
}
let rectangles = cards.bounds.values().collect::<Vec<_>>();
for (i, a) in rectangles.iter().enumerate() {
for b in &rectangles[i + 1..] {
assert!(
a.intersection(*b).is_none(),
"{width}: cards overlap: {a:?} {b:?}"
);
}
}
}
}
#[test]
fn sidebar_rows_center_inside_the_full_button_hit_area() {
#[derive(Default)]
struct Centers {
buttons: std::collections::HashMap<usize, Rectangle>,
parts: Vec<(usize, Rectangle)>,
sidebar: Option<Rectangle>,
}
impl Operation for Centers {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
children(self);
}
fn focusable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: &mut dyn cosmic::iced::advanced::widget::operation::Focusable,
) {
for page in Page::ALL {
if id
== Some(&widget::Id::new(format!(
"settings-nav-button-{}",
page as usize
)))
{
self.buttons.insert(page as usize, bounds);
}
}
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("settings-sidebar")) {
self.sidebar = Some(bounds);
}
for page in Page::ALL {
for part in ["content", "icon", "label", "marker"] {
if id
== Some(&widget::Id::new(format!(
"settings-nav-{part}-{}",
page as usize
)))
{
self.parts.push((page as usize, bounds));
}
}
}
}
}
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(
Core::default(),
(
"/unused/navigation-centers.kdl".into(),
Snapshot::parse(String::new()),
None,
None,
),
)
.0;
for selected in [Page::Windows, Page::Keyboard, Page::Accessibility] {
app.page = selected;
let mut view = app.page_view();
let mut tree = Tree::new(&view);
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(1280., 760.)),
);
let mut centers = Centers::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut centers);
assert_eq!(centers.buttons.len(), 16);
assert_eq!(centers.parts.len(), 64);
let sidebar = centers.sidebar.unwrap();
assert!(
centers
.buttons
.values()
.all(|button| button.x + button.width <= sidebar.x + sidebar.width - 24. + 0.5),
"navigation hitboxes must leave a separate scrollbar lane"
);
for (page, part) in &centers.parts {
let button = centers.buttons[page];
assert_eq!(button.height, 36.);
assert!(
((part.y + part.height / 2.) - (button.y + button.height / 2.)).abs() < 0.51,
"page {page}: content {part:?} is not centered in {button:?}"
);
}
}
}
#[test]
fn double_clicking_blank_edges_toggles_maximize_without_changing_settings() {
#[derive(Default)]
struct Blank {
bounds: Option<Rectangle>,
label: Option<Rectangle>,
}
impl Operation for Blank {
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn Operation)) {
children(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("settings-right-blank")) {
self.bounds = Some(bounds);
}
if id == Some(&widget::Id::new("setting-label-ui.language")) {
self.label = Some(bounds);
}
}
}
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(
Core::default(),
(
"/unused/edge-gesture.kdl".into(),
Snapshot::parse(String::new()),
Some(InitialPage::Settings(Page::General)),
None,
),
)
.0;
let before = app.draft.source.clone();
let mut view = app.page_view();
let mut tree = Tree::new(&view);
let viewport = Rectangle::with_size(Size::new(1280., 760.));
let node = view.as_widget_mut().layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, viewport.size()),
);
let mut blank = Blank::default();
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut blank);
let bounds = blank.bounds.unwrap();
let cursor = mouse::Cursor::Available(bounds.center());
let mut messages = Vec::new();
for _ in 0..2 {
for event in [
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
view.as_widget_mut().update(
&mut tree,
&Event::Mouse(event),
Layout::new(&node),
cursor,
&renderer,
&mut clipboard::Null,
&mut Shell::new(&mut messages),
&viewport,
);
}
}
assert_eq!(
messages
.iter()
.filter(|m| matches!(m, Message::ToggleMaximize))
.count(),
1
);
assert!(
!messages
.iter()
.any(|m| matches!(m, Message::Change(_) | Message::DragWindow))
);
assert_eq!(app.draft.source, before);
messages.clear();
let cursor = mouse::Cursor::Available(blank.label.unwrap().center());
for _ in 0..2 {
for event in [
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
view.as_widget_mut().update(
&mut tree,
&Event::Mouse(event),
Layout::new(&node),
cursor,
&renderer,
&mut clipboard::Null,
&mut Shell::new(&mut messages),
&viewport,
);
}
}
assert!(
messages.is_empty(),
"field labels must not act as window chrome"
);
}
#[test]
fn holiday_function_editor_keeps_state_when_focus_and_text_rebuild_the_page() {
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(
Core::default(),
(
"/unused/calendar-editor.kdl".into(),
Snapshot::parse(String::new()),
Some(InitialPage::Settings(Page::Calendar)),
None,
),
)
.0;
let _ = app.update(Message::CalendarFunction(
calendar_controls::FunctionInput::Toggle,
));
let mut tree = None;
for action in [
cosmic::widget::text_editor::Action::Click(Point::new(20., 20.)),
cosmic::widget::text_editor::Action::SelectAll,
cosmic::widget::text_editor::Action::Edit(cosmic::widget::text_editor::Edit::Paste(
std::sync::Arc::new("fn japanese_holidays(year) { #{} }".into()),
)),
cosmic::widget::text_editor::Action::Move(
cosmic::widget::text_editor::Motion::DocumentStart,
),
] {
let mut view = app.page_view();
let mounted = tree.is_some();
let tree = tree.get_or_insert_with(|| Tree::new(&view));
if mounted {
cosmic::iced::advanced::widget::tree::NAMED
.with(|named| *named.borrow_mut() = tree.take_all_named());
}
tree.diff(view.as_widget_mut());
let node = view.as_widget_mut().layout(
tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(1280., 760.)),
);
cosmic::iced::advanced::widget::tree::NAMED.with(|named| named.borrow_mut().clear());
let mut focus = cosmic::iced::advanced::widget::operation::focusable::focus::<()>(
widget::Id::new("calendar-function-editor"),
);
view.as_widget_mut()
.operate(tree, Layout::new(&node), &renderer, &mut focus);
drop(view);
let _ = app.update(Message::CalendarFunction(
calendar_controls::FunctionInput::Edit(action),
));
}
assert!(
app.pending.is_empty(),
"editor changes require explicit Apply"
);
}