Update branding

Update the Ferese symbols, appearance-specific wallpapers, and rounded wallpaper and theme previews. Bundle Figtree and add the Ferese name to the website header.

Verified configuration, theme, Settings, wallpaper and installer tests, both UI renderers, the static site build, and browser layouts.
This commit is contained in:
Samuel Onoja committed 2026-10-04 11:11:14 +01:00
1 parent f81d8d50ea
commit 3533fac521
35 files changed
+1212 -309

No files matched your search

@@ -312,7 +312,7 @@ mod tests {
(
"/unused/connections-test.kdl".into(),
crate::store::Snapshot::parse(String::new()),
Some(super::super::Tab::Bluetooth),
Some(crate::InitialPage::Connections(super::super::Tab::Bluetooth)),
),
);
assert_eq!(app.page, crate::schema::Page::Connections);
+2 -2
View File
@@ -176,7 +176,7 @@ impl App {
.into()
}
Kind::Choice { default, choices } => {
let value = self.draft.string(&path, default);
let value = self.wallpaper_field_value(&path, default);
let mut options = row([]).spacing(2);
for (key, label) in choices {
options = options.push(
@@ -196,7 +196,7 @@ impl App {
if argv {
self.draft.argv(&path)
} else {
self.draft.string(&path, default)
self.wallpaper_field_value(&path, default)
}
});
let commit = field.clone();
+133 -99
View File
@@ -12,6 +12,7 @@ mod fonts;
mod schema;
mod store;
mod visuals;
mod wallpaper_controls;
mod watch;
use std::collections::HashMap;
@@ -28,7 +29,7 @@ use store::{Edit, Snapshot, set};
fn main() -> cosmic::iced::Result {
let mut args = std::env::args_os().skip(1);
let mut path = store::config_path();
let mut connection_tab = None;
let mut initial_page = None;
while let Some(arg) = args.next() {
match arg.to_str() {
Some("--config") => {
@@ -39,17 +40,18 @@ fn main() -> cosmic::iced::Result {
path = PathBuf::from(value);
}
Some("--page") => {
connection_tab = match args.next().as_deref().and_then(|s| s.to_str()) {
Some("connections" | "wifi") => Some(connections::Tab::Wifi),
Some("bluetooth") => Some(connections::Tab::Bluetooth),
initial_page = match args.next().as_deref().and_then(|s| s.to_str()) {
Some("connections" | "wifi") => Some(InitialPage::Connections(connections::Tab::Wifi)),
Some("bluetooth") => Some(InitialPage::Connections(connections::Tab::Bluetooth)),
Some("wallpaper") => Some(InitialPage::Wallpaper),
_ => {
eprintln!("--page accepts connections, wifi, or bluetooth");
eprintln!("--page accepts connections, wifi, bluetooth, or wallpaper");
return Ok(());
}
};
}
_ => {
eprintln!("Usage: ferese-settings [--config PATH] [--page connections|wifi|bluetooth]");
eprintln!("Usage: ferese-settings [--config PATH] [--page connections|wifi|bluetooth|wallpaper]");
return Ok(());
}
}
@@ -65,10 +67,16 @@ fn main() -> cosmic::iced::Result {
.is_daemon(false)
.antialiasing(true)
.default_text_size(14.),
(path, initial, connection_tab),
(path, initial, initial_page),
)
}
#[derive(Clone, Copy)]
enum InitialPage {
Connections(connections::Tab),
Wallpaper,
}
#[derive(Clone, Debug)]
enum Message {
Connections(connections::Input),
@@ -109,12 +117,16 @@ enum Message {
ThemeImported(Result<Option<(String, String)>, String>),
ExpireUndo(u64),
PickWallpaper,
WallpaperTarget(wallpaper_controls::Target),
UseDefaultWallpaper(ferese_config::theme::Appearance),
RestoreDefaultWallpapers,
DefaultWallpaperThumbnail(ferese_config::theme::Appearance, Result<widget::image::Handle, String>),
PreviewLock,
LockPreviewStarted(Result<(), String>),
AddNote,
NoteAction(String, widget::text_editor::Action),
SaveNote(String, u64),
WallpaperPicked(Result<Option<String>, String>),
WallpaperPicked(wallpaper_controls::Target, Result<Option<String>, String>),
NewCommand(String),
AddCommand,
AddSwipe(&'static str),
@@ -157,10 +169,7 @@ struct App {
auto_details: bool,
advanced_theme: bool,
theme_file_target: usize,
thumbnail: Option<widget::image::Handle>,
thumbnail_path: String,
thumbnail_loading: bool,
thumbnail_error: Option<String>,
wallpaper: wallpaper_controls::State,
}
fn initial_visible_rows() -> std::collections::HashSet<(&'static str, usize)> {
@@ -197,7 +206,7 @@ struct NoteEditor {
impl cosmic::Application for App {
type Executor = cosmic::executor::Default;
type Flags = (PathBuf, Result<Snapshot, String>, Option<connections::Tab>);
type Flags = (PathBuf, Result<Snapshot, String>, Option<InitialPage>);
type Message = Message;
const APP_ID: &'static str = "dev.ferese.Settings";
@@ -209,7 +218,7 @@ impl cosmic::Application for App {
&mut self.core
}
fn init(mut core: Core, (path, initial, connection_tab): Self::Flags) -> (Self, Task<Message>) {
fn init(mut core: Core, (path, initial, initial_page): Self::Flags) -> (Self, Task<Message>) {
core.window.show_headerbar = false;
let status = if path == store::config_path() {
@@ -226,7 +235,10 @@ impl cosmic::Application for App {
let family_sections = theme_controls::FamilySections::new(&resolved.families, &current);
let mut app = Self {
connections: connections::State {
tab: connection_tab.unwrap_or_default(),
tab: match initial_page {
Some(InitialPage::Connections(tab)) => tab,
_ => connections::Tab::default(),
},
..connections::State::default()
},
displays: vec![],
@@ -243,10 +255,10 @@ impl cosmic::Application for App {
saving_previous: String::new(),
error,
status,
page: if connection_tab.is_some() {
Page::Connections
} else {
Page::Appearance
page: match initial_page {
Some(InitialPage::Connections(_)) => Page::Connections,
Some(InitialPage::Wallpaper) => Page::Wallpaper,
None => Page::Appearance,
},
profile_pages: std::env::var_os("FERESE_PROFILE_SETTINGS").is_some(),
page_transition: None,
@@ -266,10 +278,7 @@ impl cosmic::Application for App {
auto_details: false,
advanced_theme: false,
theme_file_target: 0,
thumbnail: None,
thumbnail_path: String::new(),
thumbnail_loading: false,
thumbnail_error: None,
wallpaper: Default::default(),
};
let task = app
.core
@@ -283,7 +292,8 @@ impl cosmic::Application for App {
} else {
Task::none()
};
(app, Task::batch([task, connections]))
let thumbnails = app.load_thumbnail();
(app, Task::batch([task, connections, thumbnails]))
}
fn update(&mut self, message: Message) -> Task<Message> {
@@ -321,6 +331,8 @@ impl cosmic::Application for App {
return self.edit_many(displays::edits(&prefix, mode, automatic));
}
Message::ThemeChanged(snapshot) => {
let wallpaper_changed =
self.resolved.presented.tokens.background.path != snapshot.presented.tokens.background.path;
let font_changed = self.resolved.presented.tokens.typography.font_family
!= snapshot.presented.tokens.typography.font_family;
if self.resolved.families != snapshot.families {
@@ -333,7 +345,12 @@ impl cosmic::Application for App {
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
}
return self.update_theme();
let theme = self.update_theme();
return if wallpaper_changed {
Task::batch([theme, self.load_thumbnail()])
} else {
theme
};
}
Message::NoteAction(id, action) => {
if let Some(editor) = self.note_editors.get_mut(&id) {
@@ -739,7 +756,8 @@ impl cosmic::Application for App {
Message::ThemeImported(Err(error)) => self.error = Some(error),
Message::ExpireUndo(revision) if revision == self.undo_revision => self.undo = None,
Message::PickWallpaper => {
return cosmic::task::future(async {
let target = self.wallpaper.target;
return cosmic::task::future(async move {
let result = tokio::task::spawn_blocking(|| {
store::pick_file(
"Choose a wallpaper",
@@ -748,34 +766,31 @@ impl cosmic::Application for App {
})
.await
.unwrap_or_else(|e| Err(e.to_string()));
Message::WallpaperPicked(result)
Message::WallpaperPicked(target, result)
});
}
Message::WallpaperPicked(Ok(Some(path))) => {
return self.change(set("theme.background.path", path));
Message::WallpaperPicked(target, Ok(Some(path))) => {
return self.apply_wallpaper_edits(wallpaper_controls::edits(target, "path", path));
}
Message::WallpaperPicked(Err(error)) => self.error = Some(error),
Message::WallpaperPicked(_, Err(error)) => self.error = Some(error),
Message::Thumbnail(path, result) => {
self.thumbnail_loading = false;
if path
== self
.current
.string("theme.background.path", ferese_config::default_wallpaper())
{
self.thumbnail_path = path;
match result {
Ok(handle) => {
self.thumbnail = Some(handle);
self.thumbnail_error = None;
}
Err(error) => {
self.thumbnail = None;
self.thumbnail_error = Some(error);
}
}
}
self.wallpaper_thumbnail(path, result);
return self.load_thumbnail();
}
Message::DefaultWallpaperThumbnail(appearance, result) => {
self.default_wallpaper_thumbnail(appearance, result);
}
Message::WallpaperTarget(target) => self.wallpaper.target = target,
Message::UseDefaultWallpaper(appearance) => {
return self.apply_wallpaper_edits(wallpaper_controls::edits(
self.wallpaper.target,
"path",
ferese_config::default_wallpaper_for(appearance),
));
}
Message::RestoreDefaultWallpapers => {
return self.apply_wallpaper_edits(wallpaper_controls::restore_defaults());
}
Message::NewCommand(value) => self.new_command = value,
Message::AddCommand if !self.saving => match shlex::split(&self.new_command).filter(|a| !a.is_empty()) {
Some(args) => {
@@ -887,57 +902,6 @@ impl App {
self.note_editors.retain(|id, _| ids.contains(id));
}
fn load_thumbnail(&mut self) -> Task<Message> {
let path = self
.current
.string("theme.background.path", ferese_config::default_wallpaper());
if self.page != Page::Wallpaper || self.thumbnail_loading || path == self.thumbnail_path {
return Task::none();
}
if path.is_empty() {
self.thumbnail = None;
self.thumbnail_path.clear();
self.thumbnail_error = None;
return Task::none();
}
self.thumbnail_loading = true;
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 pixels = reader
.decode()
.map_err(|e| e.to_string())?
.thumbnail(1000, 500)
.into_rgba8();
Ok(widget::image::Handle::from_rgba(
pixels.width(),
pixels.height(),
pixels.into_raw(),
))
})();
let _ = send.send(result);
});
Message::Thumbnail(
path,
receive
.await
.unwrap_or_else(|_| Err("Could not load wallpaper preview.".into())),
)
})
}
fn edit_many(&mut self, edits: Vec<Edit>) -> Task<Message> {
for edit in edits {
if let Err(error) = self.draft.edit(&edit) {
@@ -960,6 +924,16 @@ impl App {
}
fn change(&mut self, edit: Edit) -> Task<Message> {
if let Edit::Set(path, value) = &edit
&& matches!(path.as_str(), "theme.background.path" | "theme.background.mode")
{
return self.apply_wallpaper_edits(wallpaper_controls::edits(
wallpaper_controls::Target::Both,
path.rsplit('.').next().unwrap(),
value.clone(),
));
}
match self.draft.edit(&edit) {
Ok(()) => {
// A shared style selection applies to both appearances while
@@ -1061,6 +1035,66 @@ mod tests {
1 + tree.children.iter().map(tree_nodes).sum::<usize>()
}
#[test]
fn wallpaper_preview_uses_presented_state_and_tracks_mode_changes() {
let mut app = app();
app.path = store::config_path();
app.current = Snapshot::parse("theme { background { path \"/saved.png\"; }; }".into()).unwrap();
app.resolved.presented.tokens.background.path = Some("/active.png".into());
assert_eq!(app.active_wallpaper_path(), "/active.png");
app.page = Page::Wallpaper;
let mut snapshot = app.resolved.clone();
snapshot.presented.tokens.background.path =
Some(ferese_config::default_wallpaper_for(ferese_config::theme::Appearance::Light).into());
let task = app.update(Message::ThemeChanged(Box::new(snapshot)));
assert!(task.units() > 0);
assert_eq!(
app.active_wallpaper_path(),
ferese_config::default_wallpaper_for(ferese_config::theme::Appearance::Light)
);
}
#[test]
fn wallpaper_preview_config_preserves_its_mode_and_relative_paths() {
let mut app = app();
app.path = PathBuf::from("/preview/config.kdl");
app.current = Snapshot::parse("theme { mode \"light\"; background { path \"shared.png\"; }; light { background { path \"light.png\"; }; }; }".into()).unwrap();
assert_eq!(app.active_wallpaper_path(), "/preview/light.png");
app.wallpaper.target = wallpaper_controls::Target::Dark;
assert_eq!(app.active_wallpaper_path(), "/preview/light.png");
}
#[test]
fn choosing_a_default_clears_old_input_and_keeps_the_other_mode() {
let mut app = app();
app.saving = true;
app.wallpaper.target = wallpaper_controls::Target::Light;
app.draft = Snapshot::parse("theme { dark { background { path \"/custom-dark.png\"; }; }; }".into()).unwrap();
app.inputs
.insert("theme.light.background.path".into(), "/unfinished.png".into());
let _ = app.update(Message::UseDefaultWallpaper(ferese_config::theme::Appearance::Light));
assert!(!app.inputs.contains_key("theme.light.background.path"));
assert_eq!(
app.draft.string("theme.light.background.path", ""),
ferese_config::default_wallpaper_for(ferese_config::theme::Appearance::Light)
);
assert_eq!(app.draft.string("theme.dark.background.path", ""), "/custom-dark.png");
}
#[test]
fn wallpaper_picker_keeps_its_target_when_selection_changes() {
let mut app = app();
app.saving = true;
app.wallpaper.target = wallpaper_controls::Target::Dark;
let _ = app.update(Message::WallpaperPicked(
wallpaper_controls::Target::Light,
Ok(Some("/chosen.png".into())),
));
assert_eq!(app.draft.string("theme.light.background.path", ""), "/chosen.png");
assert!(app.draft.item("theme.dark.background.path").is_none());
}
#[test]
fn shortcuts_build_controls_only_for_mounted_rows() {
let mut app = shortcuts(100);
+6 -23
View File
@@ -179,30 +179,13 @@ impl App {
}
if self.page == Page::Wallpaper {
if let Some(handle) = &self.thumbnail {
body = body.push(
widget::image(handle.clone())
.width(Length::Fill)
.height(160)
.content_fit(cosmic::iced::ContentFit::Contain),
);
} else {
body = body.push(self.note(if self.thumbnail_loading {
"Loading wallpaper preview…"
} else {
self.thumbnail_error
.as_deref()
.unwrap_or("Choose an image to preview your wallpaper.")
}));
}
body = body.push(self.settings_button(
"Choose image…",
"M3 4h18v16H3z M3 15l5-5 5 5 3-3 5 5 M15 8h.01",
Some(Message::PickWallpaper),
false,
));
body = body.push(self.wallpaper_controls());
}
let fields = schema::fields(self.page);
let fields = if self.page == Page::Wallpaper {
self.wallpaper_fields()
} else {
schema::fields(self.page)
};
if !fields.is_empty() {
let mut group = column([]).spacing(1);
+2 -2
View File
@@ -249,8 +249,8 @@ impl App {
let list = crate::navigation::gallery_list(appearance, custom);
for chunk in 0..self.family_sections.indices(custom).len().div_ceil(3) {
let content = widget::responsive(move |size| {
// Three previews: 200:126 image, caption, and tile padding.
let height = ((size.width - 12.) / 3. - 16.).max(0.) * 126. / 200. + 34.;
// Three borderless previews, with names overlaid inside each image.
let height = ((size.width - 12.) / 3.).max(0.) * 126. / 200.;
let content: Element<'_, Message> = if self.visible_rows.contains(&(list, chunk)) {
self.theme_gallery_row(chunk, appearance, custom)
} else {
@@ -0,0 +1,591 @@
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 self.requested.is_some() || self.path == path {
return false;
}
self.handle = None;
self.error = None;
if path.is_empty() {
self.path.clear();
return false;
}
self.path.clear();
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.));
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),
);
} 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.")
}));
}
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 {
widget::container(self.note(preview.error.as_deref().unwrap_or("Loading…")))
.width(Length::Fill)
.height(110)
.into()
};
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()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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_restarts_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"));
}
#[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"));
}
}