Files
feresaria/shell/ferese-settings/src/wallpaper_controls.rs
T

718 lines
27 KiB
Rust

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<String>,
handle: Option<widget::image::Handle>,
error: Option<String>,
}
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<widget::image::Handle, String>) {
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<serde_json::Value>) -> Vec<Edit> {
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<Edit> {
[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<Appearance>) -> Task<Message> {
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<Edit>) -> Task<Message> {
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<Message> {
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<widget::image::Handle, String>) {
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<widget::image::Handle, String>,
) {
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<Field> {
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(<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() {
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(<cosmic::Renderer as Headless>::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"));
}
}