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

@@ -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"));
}
}