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
+454 -31

No files matched your search

+3
View File
@@ -15,6 +15,9 @@ mod visuals;
mod wallpaper_controls; mod wallpaper_controls;
mod watch; mod watch;
#[cfg(test)]
mod scroll_tests;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; 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 { let kinds: &[&str] = match appearance {
Some(ferese_config::theme::Appearance::Light) => &["light"], Some(ferese_config::theme::Appearance::Light) => &["light"],
Some(ferese_config::theme::Appearance::Dark) => &["dark"], Some(ferese_config::theme::Appearance::Dark) => &["dark"],
None => &[], None => &["light", "dark"],
}; };
let mut edits = vec![set( let mut edits = vec![set(
&appearance.map_or_else( &appearance.map_or_else(
@@ -333,6 +333,63 @@ mod tests {
assert_eq!(family_selection(&snapshot, Some(Dark)), "catppuccin"); 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] #[test]
fn the_first_split_starts_with_both_variants_of_the_single_family() { fn the_first_split_starts_with_both_variants_of_the_single_family() {
use ferese_config::theme::Appearance::{Dark, Light}; use ferese_config::theme::Appearance::{Dark, Light};
+149 -23
View File
@@ -60,18 +60,21 @@ struct Preview {
impl Preview { impl Preview {
fn request(&mut self, path: &str) -> bool { 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 { if self.requested.is_some() || self.path == path {
return false; 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; self.error = None;
if path.is_empty() {
self.path.clear();
return false;
}
self.path.clear();
self.requested = Some(path.to_owned()); self.requested = Some(path.to_owned());
true true
} }
@@ -317,25 +320,26 @@ impl App {
let palette = visuals::Palette::from_resolved(&self.resolved.presented); let palette = visuals::Palette::from_resolved(&self.resolved.presented);
let preview = &self.wallpaper.active; let preview = &self.wallpaper.active;
let mut body = column([]).spacing(8).push(self.label("Current wallpaper", 13.)); let mut body = column([]).spacing(8).push(self.label("Current wallpaper", 13.));
if let Some(handle) = &preview.handle { let current: Element<'_, Message> = if let Some(handle) = &preview.handle {
body = body.push(
widget::container(
widget::image(handle.clone()) widget::image(handle.clone())
.border_radius(palette.radius.min(10.)) .border_radius(palette.radius.min(10.))
.shape(cosmic::iced::border::Shape::Continuous) .shape(cosmic::iced::border::Shape::Continuous)
.width(Length::Shrink) .width(Length::Shrink)
.height(180) .height(180)
.content_fit(cosmic::iced::ContentFit::Contain), .content_fit(cosmic::iced::ContentFit::Contain)
) .into()
.center_x(Length::Fill),
);
} else { } else {
body = body.push(self.note(if preview.requested.is_some() { self.wallpaper_placeholder(
"Loading wallpaper preview…" if preview.requested.is_some() {
"Loading preview…"
} else { } else {
preview.error.as_deref().unwrap_or("No wallpaper image selected.") 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( body = body.push(
row([]) row([])
@@ -374,10 +378,7 @@ impl App {
.content_fit(cosmic::iced::ContentFit::Cover) .content_fit(cosmic::iced::ContentFit::Cover)
.into() .into()
} else { } else {
widget::container(self.note(preview.error.as_deref().unwrap_or("Loading…"))) self.wallpaper_placeholder(preview.error.as_deref().unwrap_or("Loading preview…"), palette, 110.)
.width(Length::Fill)
.height(110)
.into()
}; };
let label_color = if preview.handle.is_none() { let label_color = if preview.handle.is_none() {
palette.text palette.text
@@ -421,12 +422,104 @@ impl App {
.push(defaults) .push(defaults)
.into() .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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
#[ignore = "release UI preview; requires headless renderer backends"] #[ignore = "release UI preview; requires headless renderer backends"]
fn render_preview_pages_on_both_backends() { fn render_preview_pages_on_both_backends() {
@@ -564,14 +657,47 @@ mod tests {
} }
#[test] #[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 mut preview = Preview::default();
let image = widget::image::Handle::from_rgba(1, 1, vec![0, 0, 0, 255]); let image = widget::image::Handle::from_rgba(1, 1, vec![0, 0, 0, 255]);
assert!(preview.request("/first.png")); assert!(preview.request("/first.png"));
preview.finish("/first.png".into(), "/first.png", Ok(image.clone())); preview.finish("/first.png".into(), "/first.png", Ok(image.clone()));
assert!(preview.request("/second.png")); assert!(preview.request("/second.png"));
preview.finish("/second.png".into(), "/first.png", Ok(image)); 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")); 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] #[test]
+2 -7
View File
@@ -16,6 +16,7 @@ mod note_store;
mod notification_ui; mod notification_ui;
mod notifications; mod notifications;
mod recording; mod recording;
mod renderer;
mod status; mod status;
mod status_ui; mod status_ui;
mod system_modal; mod system_modal;
@@ -109,13 +110,7 @@ impl From<ShellTheme> for BarMetrics {
} }
fn main() -> cosmic::iced::Result { fn main() -> cosmic::iced::Result {
// Small shell surfaces do not need a second GPU device and shader setup. renderer::configure_shell();
// Keep wgpu as a fallback and honor an explicit renderer preference.
if std::env::var_os("ICED_BACKEND").is_none() {
// SAFETY: this is the process entry point, before any worker or
// toolkit threads are started.
unsafe { std::env::set_var("ICED_BACKEND", "tiny-skia,wgpu") };
}
cosmic::iced::advanced::graphics::text::font_system() cosmic::iced::advanced::graphics::text::font_system()
.write() .write()
+61
View File
@@ -0,0 +1,61 @@
use std::ffi::{OsStr, OsString};
use std::process::Command;
use std::sync::OnceLock;
static APP_BACKEND: OnceLock<Option<OsString>> = OnceLock::new();
/// Called at process entry, before the toolkit or workers start threads.
pub(crate) fn configure_shell() {
let backend = std::env::var_os("ICED_BACKEND");
let use_shell_default = backend.is_none();
APP_BACKEND.set(backend).expect("renderer configured once");
if use_shell_default {
// Small shell surfaces do not need a second GPU device and shader setup.
// SAFETY: main calls this before starting any threads.
unsafe { std::env::set_var("ICED_BACKEND", "tiny-skia,wgpu") };
}
}
pub(crate) fn configure_app(command: &mut Command) {
if let Some(backend) = APP_BACKEND.get() {
apply_app_backend(command, backend.as_deref());
}
}
fn apply_app_backend(command: &mut Command, backend: Option<&OsStr>) {
if let Some(backend) = backend {
command.env("ICED_BACKEND", backend);
} else {
// The shell's automatic software preference must not leak into Settings.
command.env_remove("ICED_BACKEND");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_does_not_inherit_the_shells_automatic_software_renderer() {
let mut command = Command::new("ferese-settings");
command.env("ICED_BACKEND", "tiny-skia,wgpu");
apply_app_backend(&mut command, None);
assert_eq!(
command.get_envs().find(|(key, _)| *key == "ICED_BACKEND").unwrap().1,
None
);
}
#[test]
fn settings_preserves_explicit_renderer_preferences() {
for backend in ["tiny-skia", "wgpu", "wgpu,tiny-skia"] {
let mut command = Command::new("ferese-settings");
apply_app_backend(&mut command, Some(OsStr::new(backend)));
assert_eq!(
command.get_envs().find(|(key, _)| *key == "ICED_BACKEND").unwrap().1,
Some(OsStr::new(backend))
);
}
}
}
+1
View File
@@ -632,6 +632,7 @@ fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> {
command.env("WAYLAND_DISPLAY", display); command.env("WAYLAND_DISPLAY", display);
} }
command.env_remove("FERESE_PUBLIC_WAYLAND_DISPLAY"); command.env_remove("FERESE_PUBLIC_WAYLAND_DISPLAY");
crate::renderer::configure_app(&mut command);
let mut child = command.spawn().map_err(|e| e.to_string())?; let mut child = command.spawn().map_err(|e| e.to_string())?;
thread::spawn(move || { thread::spawn(move || {
let _ = child.wait(); let _ = child.wait();