diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index 6aadc6a..208e78f 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -15,6 +15,9 @@ mod visuals; mod wallpaper_controls; mod watch; +#[cfg(test)] +mod scroll_tests; + use std::collections::HashMap; use std::path::PathBuf; diff --git a/shell/ferese-settings/src/scroll_tests.rs b/shell/ferese-settings/src/scroll_tests.rs new file mode 100644 index 0000000..830aa62 --- /dev/null +++ b/shell/ferese-settings/src/scroll_tests.rs @@ -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(::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 = 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] + ); + } + } +} diff --git a/shell/ferese-settings/src/visuals.rs b/shell/ferese-settings/src/visuals.rs index 63c7bbc..9389420 100644 --- a/shell/ferese-settings/src/visuals.rs +++ b/shell/ferese-settings/src/visuals.rs @@ -84,7 +84,7 @@ pub fn choose_family(id: &str, appearance: Option &["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}; diff --git a/shell/ferese-settings/src/wallpaper_controls.rs b/shell/ferese-settings/src/wallpaper_controls.rs index 1141413..7d3243e 100644 --- a/shell/ferese-settings/src/wallpaper_controls.rs +++ b/shell/ferese-settings/src/wallpaper_controls.rs @@ -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(::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] diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 6aae5ac..1152e13 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -16,6 +16,7 @@ mod note_store; mod notification_ui; mod notifications; mod recording; +mod renderer; mod status; mod status_ui; mod system_modal; @@ -109,13 +110,7 @@ impl From for BarMetrics { } fn main() -> cosmic::iced::Result { - // Small shell surfaces do not need a second GPU device and shader setup. - // 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") }; - } + renderer::configure_shell(); cosmic::iced::advanced::graphics::text::font_system() .write() diff --git a/shell/ferese-shell/src/renderer.rs b/shell/ferese-shell/src/renderer.rs new file mode 100644 index 0000000..993ac56 --- /dev/null +++ b/shell/ferese-shell/src/renderer.rs @@ -0,0 +1,61 @@ +use std::ffi::{OsStr, OsString}; +use std::process::Command; +use std::sync::OnceLock; + +static APP_BACKEND: OnceLock> = 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)) + ); + } + } +} diff --git a/shell/ferese-shell/src/status.rs b/shell/ferese-shell/src/status.rs index 18e63ab..dcbbd38 100644 --- a/shell/ferese-shell/src/status.rs +++ b/shell/ferese-shell/src/status.rs @@ -632,6 +632,7 @@ fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> { command.env("WAYLAND_DISPLAY", 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())?; thread::spawn(move || { let _ = child.wait();