Fix Settings rendering, theme accents and wallpaper loading

This commit is contained in:
Samuel Onoja committed 2026-10-04 12:30:54 +01:00
1 parent 950ba723ab
commit 49e54f9a63
7 files changed
+461 -38

No files matched your search

+3
View File
@@ -15,6 +15,9 @@ mod visuals;
mod wallpaper_controls;
mod watch;
#[cfg(test)]
mod scroll_tests;
use std::collections::HashMap;
use std::path::PathBuf;
+180
View File
@@ -0,0 +1,180 @@
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]
#[ignore = "release scroll/render sample; requires headless renderer backends"]
fn appearance_scroll_preserves_progress_and_settles_visibility() {
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),
)
.0;
app.page = Page::Appearance;
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;
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 { -12. } else { 12. },
},
})
};
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,
);
drop(view);
let started = std::time::Instant::now();
std::hint::black_box(Headless::screenshot(
&mut renderer,
physical,
scale,
theme.cosmic().bg_color().into(),
));
if (4..52).contains(&frame) {
render_times.push(started.elapsed().as_micros());
}
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();
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]
);
}
}
}
+58 -1
View File
@@ -84,7 +84,7 @@ pub fn choose_family(id: &str, appearance: Option<ferese_config::theme::Appearan
let kinds: &[&str] = match appearance {
Some(ferese_config::theme::Appearance::Light) => &["light"],
Some(ferese_config::theme::Appearance::Dark) => &["dark"],
None => &[],
None => &["light", "dark"],
};
let mut edits = vec![set(
&appearance.map_or_else(
@@ -333,6 +333,63 @@ mod tests {
assert_eq!(family_selection(&snapshot, Some(Dark)), "catppuccin");
}
#[test]
fn shared_family_selection_clears_stale_scoped_gradients() {
let source = r##"theme {
family "ferese-blue"
border { style solid; }
light { family "gruvbox"; focus-ring { gradient { from "#3D7BE6"; to "#3D7BE6"; }; }; }
dark {
family "catppuccin"
colors { accent "#3D7BE6"; }
focus-ring { gradient { from "#3D7BE6"; to "#3D7BE6"; }; }
background { path "/wallpaper.png"; }
}
}"##;
for (id, _, _, _) in ferese_config::families::BUILTINS {
let mut snapshot = Snapshot::parse(source.into()).unwrap();
for edit in choose_family(id, None) {
snapshot.edit(&edit).unwrap();
}
assert_eq!(
family_selection(&snapshot, Some(ferese_config::theme::Appearance::Light)),
"gruvbox"
);
assert_eq!(
family_selection(&snapshot, Some(ferese_config::theme::Appearance::Dark)),
"catppuccin"
);
assert_eq!(snapshot.string("theme.dark.background.path", ""), "/wallpaper.png");
assert_eq!(snapshot.string("theme.border.style", ""), "solid");
for mode in ["light", "dark"] {
assert!(snapshot.item(&format!("theme.{mode}.focus_ring")).is_none());
snapshot.edit(&set("theme.mode", mode)).unwrap();
let reference = Snapshot::parse(format!(
"theme {{ family \"{id}\"; mode \"{mode}\"; border {{ style solid; }}; }}"
))
.unwrap();
let resolve = |snapshot: &Snapshot| {
ferese_config::theme::resolve(
&snapshot.doc,
std::path::Path::new("/config"),
"2026-10-04T12:00:00Z".parse().unwrap(),
|_| unreachable!(),
)
.unwrap()
.theme
};
let actual = resolve(&snapshot);
let expected = resolve(&reference);
assert_eq!(actual.tokens.focus_ring, expected.tokens.focus_ring, "{id}/{mode}");
assert_eq!(
Palette::from_resolved(&actual).accent_gradient,
Palette::from_resolved(&expected).accent_gradient
);
}
}
}
#[test]
fn the_first_split_starts_with_both_variants_of_the_single_family() {
use ferese_config::theme::Appearance::{Dark, Light};
+156 -30
View File
@@ -60,18 +60,21 @@ struct Preview {
impl Preview {
fn request(&mut self, path: &str) -> bool {
if path.is_empty() {
self.path.clear();
self.requested = None;
self.handle = None;
self.error = None;
return false;
}
if self.requested.is_some() || self.path == path {
return false;
}
self.handle = None;
// Keep the last decoded image while its replacement loads. A stale
// completion can then also return to that image without decoding again.
self.error = None;
if path.is_empty() {
self.path.clear();
return false;
}
self.path.clear();
self.requested = Some(path.to_owned());
true
}
@@ -317,25 +320,26 @@ impl App {
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
let preview = &self.wallpaper.active;
let mut body = column([]).spacing(8).push(self.label("Current wallpaper", 13.));
if let Some(handle) = &preview.handle {
body = body.push(
widget::container(
widget::image(handle.clone())
.border_radius(palette.radius.min(10.))
.shape(cosmic::iced::border::Shape::Continuous)
.width(Length::Shrink)
.height(180)
.content_fit(cosmic::iced::ContentFit::Contain),
)
.center_x(Length::Fill),
);
let current: Element<'_, Message> = if let Some(handle) = &preview.handle {
widget::image(handle.clone())
.border_radius(palette.radius.min(10.))
.shape(cosmic::iced::border::Shape::Continuous)
.width(Length::Shrink)
.height(180)
.content_fit(cosmic::iced::ContentFit::Contain)
.into()
} else {
body = body.push(self.note(if preview.requested.is_some() {
"Loading wallpaper preview…"
} else {
preview.error.as_deref().unwrap_or("No wallpaper image selected.")
}));
}
self.wallpaper_placeholder(
if preview.requested.is_some() {
"Loading preview…"
} else {
preview.error.as_deref().unwrap_or("No wallpaper image selected.")
},
palette,
180.,
)
};
body = body.push(widget::container(current).height(180).center_x(Length::Fill));
body = body.push(
row([])
@@ -374,10 +378,7 @@ impl App {
.content_fit(cosmic::iced::ContentFit::Cover)
.into()
} else {
widget::container(self.note(preview.error.as_deref().unwrap_or("Loading…")))
.width(Length::Fill)
.height(110)
.into()
self.wallpaper_placeholder(preview.error.as_deref().unwrap_or("Loading preview…"), palette, 110.)
};
let label_color = if preview.handle.is_none() {
palette.text
@@ -421,12 +422,104 @@ impl App {
.push(defaults)
.into()
}
fn wallpaper_placeholder<'a>(
&'a self,
message: &'a str,
palette: visuals::Palette,
height: f32,
) -> Element<'a, Message> {
widget::container(
column([])
.spacing(8)
.align_x(Alignment::Center)
.push(visuals::action_icon(
"M3 4h18v16H3z M3 15l5-5 5 5 3-3 5 5 M15 8h.01",
palette.muted,
))
.push(self.note(message)),
)
.padding(16)
.width(Length::Fill)
.height(height)
.center_x(Length::Fill)
.center_y(height)
.class(visuals::surface(palette.card, palette.radius.min(10.)))
.into()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preview_slots_keep_their_size_while_loading_and_on_failure() {
use cosmic::Application;
use cosmic::iced::advanced::renderer::Headless;
use cosmic::iced::advanced::{layout, widget::Tree};
use cosmic::iced::{Font, Pixels, Size};
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 [420., 760.] {
let mut app = App::init(
cosmic::app::Core::default(),
("/preview/config.kdl".into(), Snapshot::parse(String::new()), None),
)
.0;
let dimensions = |app: &App| {
let mut view = app.wallpaper_controls();
let mut tree = Tree::new(view.as_widget());
view.as_widget_mut()
.layout(
&mut tree,
&renderer,
&layout::Limits::new(Size::ZERO, Size::new(width, f32::INFINITY)),
)
.size()
};
let initial = dimensions(&app);
assert!(app.wallpaper.active.request("/active.png"));
for preview in &mut app.wallpaper.defaults {
assert!(preview.request("/default.png"));
}
assert_eq!(dimensions(&app), initial);
let handle = widget::image::Handle::from_rgba(2, 1, vec![255; 8]);
app.wallpaper
.active
.finish("/active.png".into(), "/active.png", Ok(handle.clone()));
for index in 0..app.wallpaper.defaults.len() {
app.wallpaper.defaults[index].finish("/default.png".into(), "/default.png", Ok(handle.clone()));
assert_eq!(dimensions(&app), initial);
}
assert!(app.wallpaper.active.request("/missing.png"));
assert_eq!(dimensions(&app), initial);
app.wallpaper.active.finish(
"/missing.png".into(),
"/missing.png",
Err("Could not load this image.".into()),
);
assert_eq!(dimensions(&app), initial);
assert!(!app.wallpaper.active.request(""));
assert_eq!(dimensions(&app), initial);
}
}
#[test]
#[ignore = "release UI preview; requires headless renderer backends"]
fn render_preview_pages_on_both_backends() {
@@ -564,14 +657,47 @@ mod tests {
}
#[test]
fn returning_to_a_previous_path_during_a_load_restarts_its_preview() {
fn returning_to_a_previous_path_during_a_load_reuses_its_preview() {
let mut preview = Preview::default();
let image = widget::image::Handle::from_rgba(1, 1, vec![0, 0, 0, 255]);
assert!(preview.request("/first.png"));
preview.finish("/first.png".into(), "/first.png", Ok(image.clone()));
assert!(preview.request("/second.png"));
preview.finish("/second.png".into(), "/first.png", Ok(image));
assert!(!preview.request("/first.png"));
assert!(preview.handle.is_some());
assert_eq!(preview.path, "/first.png");
}
#[test]
fn replacement_keeps_the_previous_image_until_it_is_ready() {
let mut preview = Preview::default();
let first = widget::image::Handle::from_rgba(1, 1, vec![0, 0, 0, 255]);
let second = widget::image::Handle::from_rgba(1, 1, vec![255, 255, 255, 255]);
assert!(preview.request("/first.png"));
preview.finish("/first.png".into(), "/first.png", Ok(first.clone()));
assert!(preview.request("/second.png"));
assert_eq!(preview.handle.as_ref().unwrap().id(), first.id());
assert_eq!(preview.path, "/first.png");
assert!(!preview.request("/second.png"));
preview.finish("/second.png".into(), "/second.png", Ok(second.clone()));
assert_eq!(preview.handle.as_ref().unwrap().id(), second.id());
assert_eq!(preview.path, "/second.png");
}
#[test]
fn clearing_wallpaper_during_a_load_does_not_restore_the_old_image() {
let mut preview = Preview::default();
let image = widget::image::Handle::from_rgba(1, 1, vec![0, 0, 0, 255]);
assert!(preview.request("/first.png"));
preview.finish("/first.png".into(), "/first.png", Ok(image.clone()));
assert!(preview.request("/second.png"));
assert!(!preview.request(""));
assert!(preview.handle.is_none());
assert!(preview.requested.is_none());
preview.finish("/second.png".into(), "", Ok(image));
assert!(preview.handle.is_none());
assert!(preview.path.is_empty());
}
#[test]