use cosmic::app::Task; use cosmic::widget::{button, column, row}; use ferese_config::theme::Appearance; use super::{Alignment, App, Element, Field, Kind, Length, Message, Page, schema, visuals, widget}; use crate::store::{Edit, Snapshot, set}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(super) enum Target { #[default] Both, Light, Dark, } impl Target { const ALL: [Self; 3] = [Self::Both, Self::Light, Self::Dark]; fn prefix(self) -> &'static str { match self { Self::Both => "theme.background", Self::Light => "theme.light.background", Self::Dark => "theme.dark.background", } } fn appearance(self, active: Appearance) -> Appearance { match self { Self::Both => active, Self::Light => Appearance::Light, Self::Dark => Appearance::Dark, } } } impl std::fmt::Display for Target { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { Self::Both => "Both modes", Self::Light => "Light mode", Self::Dark => "Dark mode", }) } } #[derive(Default)] pub(super) struct State { pub target: Target, active: Preview, defaults: [Preview; 2], } #[derive(Default)] struct Preview { path: String, requested: Option, handle: Option, error: Option, } 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; } // 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.requested = Some(path.to_owned()); true } fn finish(&mut self, path: String, desired: &str, result: Result) { if self.requested.as_deref() != Some(&path) { return; } self.requested = None; if path != desired { return; } self.path = path; match result { Ok(handle) => { self.handle = Some(handle); self.error = None; } Err(error) => { self.handle = None; self.error = Some(error); } } } } fn index(appearance: Appearance) -> usize { usize::from(appearance == Appearance::Dark) } pub(super) fn edits(target: Target, token: &str, value: impl Into) -> Vec { let mut edits = vec![set(&format!("{}.{token}", target.prefix()), value)]; if target == Target::Both { for kind in ["light", "dark"] { edits.push(Edit::Unset(format!("theme.{kind}.background.{token}"))); } } edits } pub(super) fn restore_defaults() -> Vec { [Target::Both, Target::Light, Target::Dark] .map(|target| Edit::Unset(format!("{}.path", target.prefix()))) .into() } fn setting(snapshot: &Snapshot, target: Target, token: &str, fallback: &str) -> String { snapshot .item(&format!("{}.{token}", target.prefix())) .or_else(|| snapshot.item(&format!("theme.background.{token}"))) .and_then(serde_json::Value::as_str) .unwrap_or(fallback) .to_owned() } fn image_button_style(palette: visuals::Palette, selected: bool) -> cosmic::theme::Button { let paint = move |hovered: bool, focused: bool| button::Style { shape: Some(cosmic::iced::border::Shape::Continuous), background: None, border_radius: palette.radius.min(10.).into(), border_width: if selected || focused { 2. } else if hovered { 1. } else { 0. }, border_color: palette.accent, ..Default::default() }; cosmic::theme::Button::Custom { active: Box::new(move |focused, _| paint(false, focused)), hovered: Box::new(move |focused, _| paint(true, focused)), pressed: Box::new(move |focused, _| paint(true, focused)), disabled: Box::new(move |_| paint(false, false)), } } fn thumbnail(path: String, builtin: Option) -> Task { cosmic::task::future(async move { let (send, receive) = cosmic::iced::futures::channel::oneshot::channel(); let file = path.clone(); std::thread::spawn(move || { let result = (|| -> Result<_, String> { let mut reader = image::ImageReader::open(&file) .map_err(|e| e.to_string())? .with_guessed_format() .map_err(|e| e.to_string())?; let mut limits = image::Limits::default(); limits.max_alloc = Some(256 * 1024 * 1024); limits.max_image_width = Some(16384); limits.max_image_height = Some(16384); reader.limits(limits); let (width, height) = if builtin.is_some() { (400, 225) } else { (1000, 500) }; let pixels = reader .decode() .map_err(|e| e.to_string())? .thumbnail(width, height) .into_rgba8(); Ok(widget::image::Handle::from_rgba( pixels.width(), pixels.height(), pixels.into_raw(), )) })(); let _ = send.send(result); }); let result = receive .await .unwrap_or_else(|_| Err("Could not load wallpaper preview.".into())); match builtin { Some(appearance) => Message::DefaultWallpaperThumbnail(appearance, result), None => Message::Thumbnail(path, result), } }) } impl App { pub(super) fn apply_wallpaper_edits(&mut self, edits: Vec) -> Task { for edit in &edits { if let Edit::Set(path, _) | Edit::Unset(path) = edit { self.inputs.remove(path); } } self.edit_many(edits) } pub(super) fn active_wallpaper_path(&self) -> String { if self.path == crate::store::config_path() { return self .resolved .presented .tokens .background .path .as_ref() .map(|path| path.to_string_lossy().into_owned()) .unwrap_or_default(); } // A separate config preview must not borrow the live desktop's path. let appearance = match self.current.string("theme.mode", "auto").as_str() { "light" => Appearance::Light, "dark" => Appearance::Dark, _ => self.resolved.theme.appearance, }; let target = if appearance == Appearance::Light { Target::Light } else { Target::Dark }; let path = setting( &self.current, target, "path", ferese_config::default_wallpaper_for(appearance), ); if path.is_empty() { return path; } ferese_config::theme::theme_path(self.path.parent().unwrap_or(std::path::Path::new(".")), path.as_ref()) .to_string_lossy() .into_owned() } pub(super) fn load_thumbnail(&mut self) -> Task { if self.page != Page::Wallpaper { return Task::none(); } let mut tasks = Vec::with_capacity(3); let path = self.active_wallpaper_path(); if self.wallpaper.active.request(&path) { tasks.push(thumbnail(path, None)); } for appearance in [Appearance::Light, Appearance::Dark] { let path = ferese_config::default_wallpaper_for(appearance); if self.wallpaper.defaults[index(appearance)].request(path) { tasks.push(thumbnail(path.into(), Some(appearance))); } } Task::batch(tasks) } pub(super) fn wallpaper_thumbnail(&mut self, path: String, result: Result) { let desired = self.active_wallpaper_path(); self.wallpaper.active.finish(path, &desired, result); } pub(super) fn default_wallpaper_thumbnail( &mut self, appearance: Appearance, result: Result, ) { let path = ferese_config::default_wallpaper_for(appearance); self.wallpaper.defaults[index(appearance)].finish(path.into(), path, result); } pub(super) fn wallpaper_fields(&self) -> Vec { let active = if self.path == crate::store::config_path() { self.resolved.presented.appearance } else { match self.current.string("theme.mode", "auto").as_str() { "light" => Appearance::Light, "dark" => Appearance::Dark, _ => self.resolved.theme.appearance, } }; schema::fields(Page::Wallpaper) .into_iter() .map(|mut field| { field.path = field.path.replace("theme.background", self.wallpaper.target.prefix()); if let Kind::Text { default, .. } = &mut field.kind { *default = ferese_config::default_wallpaper_for(self.wallpaper.target.appearance(active)); } field }) .collect() } pub(super) fn wallpaper_field_value(&self, path: &str, fallback: &str) -> String { if path.starts_with(self.wallpaper.target.prefix()) { return setting( &self.draft, self.wallpaper.target, path.rsplit('.').next().unwrap(), fallback, ); } self.draft.string(path, fallback) } pub(super) fn wallpaper_controls(&self) -> Element<'_, Message> { 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.)); 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 { 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([]) .spacing(8) .align_y(Alignment::Center) .push(self.label("Apply to", 12.)) .push(ferese_theme::controls::select( cosmic::iced::widget::pick_list(Target::ALL, Some(self.wallpaper.target), Message::WallpaperTarget) .font(self.font) .text_size(12), palette, )) .push(widget::Space::new().width(Length::Fill)) .push(self.settings_button( "Choose image…", "M3 4h18v16H3z M3 15l5-5 5 5 3-3 5 5 M15 8h.01", Some(Message::PickWallpaper), false, )), ); let mut defaults = row([]).spacing(8); let active = self.active_wallpaper_path(); for appearance in [Appearance::Light, Appearance::Dark] { let name = if appearance == Appearance::Light { "Light" } else { "Dark" }; let preview = &self.wallpaper.defaults[index(appearance)]; let image: 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::Fill) .height(110) .content_fit(cosmic::iced::ContentFit::Cover) .into() } else { self.wallpaper_placeholder(preview.error.as_deref().unwrap_or("Loading preview…"), palette, 110.) }; let label_color = if preview.handle.is_none() { palette.text } else if appearance == Appearance::Light { cosmic::iced::Color::from_rgb8(24, 33, 44) } else { cosmic::iced::Color::WHITE }; let label = widget::container(self.label(name, 12.).class(cosmic::theme::Text::Color(label_color))) .padding(8) .width(Length::Fill) .height(Length::Fill); let tile = cosmic::iced::widget::stack([image, label.into()]); defaults = defaults.push( button::custom(tile) .name(format!("Use default {name} wallpaper")) .padding(0) .width(Length::Fill) .class(image_button_style( palette, active == ferese_config::default_wallpaper_for(appearance), )) .on_press(Message::UseDefaultWallpaper(appearance)), ); } body.push( row([]) .spacing(8) .align_y(Alignment::Center) .push(self.label("Default wallpapers", 13.)) .push(widget::Space::new().width(Length::Fill)) .push(self.settings_button( "Match appearance", "M20 7v5h-5 M4 17v-5h5 M6 7a7 7 0 0 1 12-2l2 2 M18 17a7 7 0 0 1-12 2l-2-2", Some(Message::RestoreDefaultWallpapers), false, )), ) .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() { use cosmic::Application; use cosmic::iced::advanced::renderer::{Headless, Renderer as _}; use cosmic::iced::advanced::{Layout, layout, mouse, renderer, widget::Tree}; use cosmic::iced::{Color, Font, Pixels, Rectangle, Size}; let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); let mut app = App::init( cosmic::app::Core::default(), ("/preview/config.kdl".into(), Snapshot::parse(String::new()), None), ) .0; for appearance in [Appearance::Light, Appearance::Dark] { let path = ferese_config::default_wallpaper_for(appearance); let pixels = image::open(path).unwrap().thumbnail(400, 225).into_rgba8(); let handle = widget::image::Handle::from_rgba(pixels.width(), pixels.height(), pixels.into_raw()); assert!(app.wallpaper.defaults[index(appearance)].request(path)); app.default_wallpaper_thumbnail(appearance, Ok(handle)); } for backend in ["wgpu", "tiny-skia"] { let mut renderer = runtime .block_on(::new( Font::default(), Pixels(14.), Some(backend), )) .expect("requested renderer"); for appearance in [Appearance::Light, Appearance::Dark] { let name = if appearance == Appearance::Light { "light" } else { "dark" }; app.current = Snapshot::parse(format!("theme {{ mode \"{name}\"; }}")).unwrap(); app.draft = app.current.clone(); app.visible_rows = (0..app.resolved.families.len().div_ceil(3)) .map(|row| ("theme", row)) .collect(); let pixels = image::open(ferese_config::default_wallpaper_for(appearance)) .unwrap() .thumbnail(1000, 500) .into_rgba8(); app.wallpaper.active.handle = Some(widget::image::Handle::from_rgba( pixels.width(), pixels.height(), pixels.into_raw(), )); for page in [Page::Wallpaper, Page::Appearance] { app.page = page; let bounds = Rectangle::with_size(Size::new(1000., 780.)); let theme = app.native_palette.native_theme(); let mut view = app.page_view(); let mut tree = Tree::new(view.as_widget()); let node = view.as_widget_mut().layout( &mut tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size()), ); renderer.reset(bounds); view.as_widget().draw( &tree, &mut renderer, &theme, &renderer::Style { text_color: Color::WHITE, icon_color: Color::WHITE, scale_factor: 1.25, }, Layout::new(&node), mouse::Cursor::Unavailable, &bounds, ); let pixels = Headless::screenshot( &mut renderer, Size::new(1250, 975), 1.25, theme.cosmic().bg_color().into(), ); let preview = if page == Page::Wallpaper { "wallpaper" } else { "theme" }; let path = format!("/tmp/ferese-{preview}-{backend}-{name}.png"); image::save_buffer(&path, &pixels, 1250, 975, image::ColorType::Rgba8).unwrap(); eprintln!("Preview page: {path}"); } } } } #[test] fn selecting_both_removes_mode_overrides_without_changing_lock_wallpaper() { let mut snapshot = Snapshot::parse( "theme { background { lock-path \"/lock.png\"; }; light { background { path \"/light.png\"; }; }; dark { background { path \"/dark.png\"; }; }; }".into(), ).unwrap(); for edit in edits(Target::Both, "path", "/shared.png") { snapshot.edit(&edit).unwrap(); } assert_eq!(snapshot.string("theme.background.path", ""), "/shared.png"); assert!(snapshot.item("theme.light.background.path").is_none()); assert!(snapshot.item("theme.dark.background.path").is_none()); assert_eq!(snapshot.string("theme.background.lock_path", ""), "/lock.png"); } #[test] fn selecting_one_mode_preserves_the_other_and_inherits_shared_settings() { let mut snapshot = Snapshot::parse("theme { background { path \"/shared.png\"; mode \"fit\"; }; }".into()).unwrap(); for edit in edits(Target::Light, "path", "/light.png") { snapshot.edit(&edit).unwrap(); } assert_eq!(setting(&snapshot, Target::Light, "path", ""), "/light.png"); assert_eq!(setting(&snapshot, Target::Dark, "path", ""), "/shared.png"); assert_eq!(setting(&snapshot, Target::Light, "mode", "fill"), "fit"); } #[test] fn restoring_defaults_keeps_placement_and_lock_image() { let mut snapshot = Snapshot::parse("theme { background { path \"/custom.png\"; lock-path \"/lock.png\"; mode \"fit\"; }; light { background { path \"/light.png\"; }; }; }".into()).unwrap(); for edit in restore_defaults() { snapshot.edit(&edit).unwrap(); } for target in Target::ALL { assert!(snapshot.item(&format!("{}.path", target.prefix())).is_none()); } assert_eq!(snapshot.string("theme.background.mode", ""), "fit"); assert_eq!(snapshot.string("theme.background.lock_path", ""), "/lock.png"); } #[test] 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] fn stale_preview_completion_does_not_replace_the_active_wallpaper() { let mut preview = Preview::default(); assert!(preview.request("/old.png")); let image = widget::image::Handle::from_rgba(1, 1, vec![0, 0, 0, 255]); preview.finish("/old.png".into(), "/new.png", Ok(image.clone())); assert!(preview.handle.is_none()); assert!(preview.request("/new.png")); preview.finish("/old.png".into(), "/new.png", Ok(image.clone())); assert_eq!(preview.requested.as_deref(), Some("/new.png")); preview.finish("/new.png".into(), "/new.png", Ok(image)); assert!(preview.handle.is_some()); assert!(!preview.request("/new.png")); } }