1456 lines
54 KiB
Rust
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 ¢ers.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"
|
|
);
|
|
}
|