Add resolved appearance engine and paired theme settings

Resolve light, dark and automatic appearance centrally and publish live theme snapshots to native surfaces and the appearance portal. Add paired preset families, custom theme imports, accessibility transforms and contrast-safe transitions.

Refine the Settings gallery and Auto controls, move overrides under advanced customization, and share palette-aware switches and circular slider thumbs. Document configuration and include theme licenses and integration coverage.
This commit is contained in:
Samuel Onoja committed 2026-09-30 12:51:49 +01:00
1 parent 2bb4c086f5
commit 3d950a0411
58 files changed
+5556 -644

No files matched your search

+22 -14
View File
@@ -29,6 +29,7 @@ pub(crate) struct ShortcutField {
#[derive(Clone, Debug)]
enum Message {
ThemeChanged(ferese_config::theme::Snapshot),
Opened(cosmic::iced::window::Id),
Attached(Result<(Option<Arc<crate::parent::Parent>>, Result<ModalMaterial, String>), String>),
Accept,
@@ -100,12 +101,7 @@ impl cosmic::Application for Consent {
prompt,
material: None,
parent: None,
palette: {
let document = ferese_config::config_path()
.and_then(|path| std::fs::read_to_string(path).ok())
.and_then(|source| ferese_config::Document::parse(&source).ok());
Palette::from_document(document.as_ref())
},
palette: Palette::from_resolved(&ferese_theme::service::current().presented),
font,
background,
};
@@ -113,18 +109,30 @@ impl cosmic::Application for Consent {
}
fn subscription(&self) -> cosmic::iced::Subscription<Message> {
cosmic::iced::event::listen_with(|event, _, id| match event {
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => Some(Message::Opened(id)),
cosmic::iced::Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape),
..
}) => Some(Message::Cancel),
_ => None,
})
cosmic::iced::Subscription::batch([
ferese_theme::service::subscription().map(Message::ThemeChanged),
cosmic::iced::event::listen_with(|event, _, id| match event {
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => Some(Message::Opened(id)),
cosmic::iced::Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape),
..
}) => Some(Message::Cancel),
_ => None,
}),
])
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::ThemeChanged(snapshot) => {
self.palette = Palette::from_resolved(&snapshot.presented).flat();
self.font = ferese_theme::font(Some(&snapshot.presented.tokens.typography.font_family));
self.background = cosmic::iced::Color {
a: ferese_theme::service::opacity(&snapshot.presented),
..self.palette.sidebar
};
return cosmic::command::set_theme(self.palette.native_theme());
}
Message::Opened(id) => {
let parent = self.prompt.parent.clone();
return cosmic::iced::window::run(id, move |window| {
+23 -14
View File
@@ -52,6 +52,7 @@ pub(crate) struct Selection {
#[derive(Clone, Debug)]
enum Message {
ThemeChanged(ferese_config::theme::Snapshot),
WindowOpened(cosmic::iced::window::Id),
Attached(
Result<
@@ -159,14 +160,28 @@ impl cosmic::Application for Picker {
}
fn subscription(&self) -> cosmic::iced::Subscription<Message> {
cosmic::iced::event::listen_with(|event, _, id| match event {
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => Some(Message::WindowOpened(id)),
_ => None,
})
cosmic::iced::Subscription::batch([
ferese_theme::service::subscription().map(Message::ThemeChanged),
cosmic::iced::event::listen_with(|event, _, id| match event {
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => {
Some(Message::WindowOpened(id))
}
_ => None,
}),
])
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::ThemeChanged(snapshot) => {
self.palette = ferese_theme::Palette::from_resolved(&snapshot.presented).flat();
self.font = ferese_theme::font(Some(&snapshot.presented.tokens.typography.font_family));
self.background = cosmic::iced::Color {
a: ferese_theme::service::opacity(&snapshot.presented),
..self.palette.sidebar
};
return cosmic::command::set_theme(self.palette.native_theme());
}
Message::WindowOpened(id) => {
let parent = self.prompt.parent.clone();
return cosmic::iced::window::run(id, move |window| {
@@ -381,20 +396,14 @@ pub(crate) fn appearance() -> (
cosmic::iced::Color,
ferese_theme::Palette,
) {
let document = ferese_config::config_path()
.and_then(|path| std::fs::read_to_string(path).ok())
.and_then(|source| ferese_config::Document::parse(&source).ok());
let palette = ferese_theme::Palette::from_document(document.as_ref()).flat();
let family = document
.as_ref()
.and_then(|document| document.get("theme.typography.font_family"))
.and_then(|value| value.as_str())
.unwrap_or("Inter");
let snapshot = ferese_theme::service::current();
let palette = ferese_theme::Palette::from_resolved(&snapshot.presented).flat();
let family = &snapshot.presented.tokens.typography.font_family;
(
palette.native_theme(),
ferese_theme::font(Some(family)),
cosmic::iced::Color {
a: ferese_theme::material_opacity(document.as_ref()),
a: ferese_theme::service::opacity(&snapshot.presented),
..palette.sidebar
},
palette,
+55 -55
View File
@@ -2,8 +2,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use ferese_config::Document;
use ferese_theme::Palette;
use ferese_config::theme::{Appearance as ThemeAppearance, ResolvedTheme};
use tokio::sync::RwLock;
use zbus::Connection;
use zbus::object_server::SignalEmitter;
@@ -18,33 +17,21 @@ struct Appearance {
scheme: u32,
accent: (f64, f64, f64),
reduced_motion: u32,
contrast: u32,
}
impl Appearance {
fn from_document(document: Option<&Document>) -> Self {
let palette = Palette::from_document(document);
let animations = document
.and_then(|document| document.get("animations.enabled"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(true);
fn from_resolved(theme: &ResolvedTheme) -> Self {
let accent = ferese_config::theme::rgba(&theme.tokens.colors.accent).expect("validated accent");
Self {
scheme: if ferese_theme::luminance(palette.sidebar) > 0.5 {
scheme: if theme.appearance == ThemeAppearance::Light {
2
} else {
1
},
accent: (
palette.accent.r as f64,
palette.accent.g as f64,
palette.accent.b as f64,
),
reduced_motion: u32::from(
!animations
|| document
.and_then(|doc| doc.get("animations.reduced_motion"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
),
accent: (accent[0], accent[1], accent[2]),
reduced_motion: u32::from(theme.reduced_motion),
contrast: u32::from(theme.accessibility.increase_contrast),
}
}
@@ -52,7 +39,7 @@ impl Appearance {
HashMap::from([
("color-scheme".into(), self.scheme.into()),
("accent-color".into(), Value::from(self.accent).try_to_owned().unwrap()),
("contrast".into(), 0u32.into()),
("contrast".into(), self.contrast.into()),
("reduced-motion".into(), self.reduced_motion.into()),
])
}
@@ -63,28 +50,53 @@ pub(crate) struct Settings(Arc<RwLock<Appearance>>);
impl Settings {
pub(crate) fn new() -> Self {
Self(Arc::new(RwLock::new(
load().unwrap_or_else(|| Appearance::from_document(None)),
)))
Self(Arc::new(RwLock::new(Appearance::from_resolved(
&ferese_ipc::theme::current().theme,
))))
}
pub(crate) async fn watch(self, connection: Connection) {
let emitter = SignalEmitter::new(&connection, PATH).unwrap();
loop {
tokio::time::sleep(Duration::from_secs(1)).await;
let Some(next) = load() else { continue };
let mut current = self.0.write().await;
if *current == next {
let connection = tokio::task::spawn_blocking(ferese_ipc::theme::Connection::connect).await;
let Ok(Ok(mut connection)) = connection else {
tokio::time::sleep(Duration::from_secs(1)).await;
continue;
}
let previous = current.values();
*current = next;
drop(current);
for (key, value) in next.values() {
if previous.get(&key) != Some(&value) {
let _ = Self::setting_changed(&emitter, APPEARANCE, &key, value).await;
};
let Ok(cancellation) = connection.cancellation() else {
return;
};
let (send, mut receive) = tokio::sync::mpsc::channel(1);
tokio::task::spawn_blocking(move || {
let Ok(mut snapshot) = connection.get() else { return };
loop {
let revision = snapshot.revision;
if send.blocking_send(snapshot).is_err() {
return;
}
match connection.watch(revision) {
Ok(next) => snapshot = next,
Err(_) => return,
}
}
});
while let Some(snapshot) = receive.recv().await {
let next = Appearance::from_resolved(&snapshot.theme);
let mut current = self.0.write().await;
if *current == next {
continue;
}
let previous = current.values();
*current = next;
drop(current);
for (key, value) in next.values() {
if previous.get(&key) != Some(&value) {
let _ = Self::setting_changed(&emitter, APPEARANCE, &key, value).await;
}
}
}
drop(cancellation);
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
@@ -135,19 +147,6 @@ fn matches_namespace(filters: &[String], namespace: &str) -> bool {
})
}
fn load() -> Option<Appearance> {
let path = ferese_config::config_path()?;
let source = match std::fs::read_to_string(path) {
Ok(source) if source.len() <= 1024 * 1024 => source,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Some(Appearance::from_document(None));
}
_ => return None,
};
let document = Document::parse(&source).ok()?;
Some(Appearance::from_document(Some(&document)))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -170,18 +169,19 @@ mod tests {
#[test]
fn reports_ferese_theme_and_reduced_motion() {
let doc = Document::parse(
"theme { colors { surface-base \"#ffffff\"; accent \"#ff8000\"; }; }; animations { enabled #false; }",
)
.unwrap();
let appearance = Appearance::from_document(Some(&doc));
let mut resolved = ResolvedTheme::default();
resolved.appearance = ThemeAppearance::Light;
resolved.tokens.colors.accent = "#ff8000".into();
resolved.reduced_motion = true;
resolved.accessibility.increase_contrast = true;
let appearance = Appearance::from_resolved(&resolved);
assert_eq!(appearance.scheme, 2);
assert_eq!(appearance.reduced_motion, 1);
assert_eq!(appearance.accent.0, 1.);
assert_eq!(appearance.accent.2, 0.);
assert_eq!(
u32::try_from(appearance.values().remove("contrast").unwrap()).unwrap(),
0
1
);
}
}