Separate resolved theme values from configuration resolution

This commit is contained in:
Samuel Onoja committed 2026-10-02 23:57:02 +01:00
1 parent 4c3e884438
commit 8195b82073
13 files changed
+509 -477

No files matched your search

Generated
+8
View File
@@ -1681,6 +1681,7 @@ dependencies = [
name = "ferese-config" name = "ferese-config"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ferese-theme-model",
"jiff", "jiff",
"kdl", "kdl",
"serde", "serde",
@@ -1811,6 +1812,13 @@ dependencies = [
"wayland-client", "wayland-client",
] ]
[[package]]
name = "ferese-theme-model"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "feresectl" name = "feresectl"
version = "0.1.0" version = "0.1.0"
+1
View File
@@ -6,6 +6,7 @@ members = [
"crates/ferese-core", "crates/ferese-core",
"crates/ferese-config", "crates/ferese-config",
"crates/ferese-theme", "crates/ferese-theme",
"crates/ferese-theme-model",
"crates/ferese-ipc", "crates/ferese-ipc",
"crates/ferese-layout", "crates/ferese-layout",
"crates/ferese-protocols", "crates/ferese-protocols",
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
rust-version.workspace = true rust-version.workspace = true
[dependencies] [dependencies]
ferese-theme-model = { path = "../ferese-theme-model" }
jiff.workspace = true jiff.workspace = true
kdl = "6.7.1" kdl = "6.7.1"
serde.workspace = true serde.workspace = true
+2 -51
View File
@@ -1,54 +1,5 @@
use serde::{Deserialize, Serialize}; use crate::theme::Appearance;
pub use ferese_theme_model::families::{Family, Palette};
use crate::theme::{Appearance, Tokens};
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Palette {
pub background: String,
pub bar: String,
pub surface: String,
pub text: String,
pub muted: String,
pub accent: String,
}
impl From<&Tokens> for Palette {
fn from(tokens: &Tokens) -> Self {
Self {
background: tokens.colors.surface_base.clone(),
bar: tokens.surface.bar.background.clone(),
surface: tokens.colors.surface_raised.clone(),
text: tokens.colors.text_primary.clone(),
muted: tokens.colors.text_muted.clone(),
accent: tokens.colors.accent.clone(),
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Family {
pub id: String,
pub name: String,
pub light: Option<Palette>,
pub dark: Option<Palette>,
}
impl Family {
pub fn palette(&self, appearance: Appearance) -> Option<&Palette> {
match appearance {
Appearance::Light => self.light.as_ref(),
Appearance::Dark => self.dark.as_ref(),
}
}
pub fn availability(&self) -> &'static str {
match (self.light.is_some(), self.dark.is_some()) {
(true, true) => "light and dark",
(true, false) => "light only",
_ => "dark only",
}
}
}
pub const BUILTINS: [(&str, &str, &str, &str); 6] = [ pub const BUILTINS: [(&str, &str, &str, &str); 6] = [
("ferese-blue", "Ferese Blue", "ferese-blue-light", "ferese-blue"), ("ferese-blue", "Ferese Blue", "ferese-blue-light", "ferese-blue"),
+15 -418
View File
@@ -8,34 +8,11 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use crate::Document; use crate::Document;
pub use ferese_theme_model::*;
pub const SCHEMA_VERSION: u32 = 2; pub const SCHEMA_VERSION: u32 = 2;
pub const TRANSITION_MS: u64 = 250; pub const TRANSITION_MS: u64 = 250;
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Appearance {
Light,
#[default]
Dark,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Mode {
Light,
#[default]
Dark,
Auto,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default)]
pub struct Accessibility {
pub increase_contrast: bool,
pub reduce_transparency: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(default)] #[serde(default)]
pub struct Selection { pub struct Selection {
@@ -225,105 +202,8 @@ impl Default for Policy {
} }
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub fn default_tokens() -> Tokens {
pub struct Colors { serde_json::from_value(json!({
pub surface_base: String,
pub surface_raised: String,
pub application_background: String,
pub text_primary: String,
pub text_muted: String,
pub accent: String,
pub on_accent: String,
pub border: String,
pub shadow: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Material {
pub style: String,
pub opacity: f64,
pub blur_radius: f64,
pub tint_strength: f64,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Geometry {
pub border_width: f64,
pub focus_ring_width: f64,
pub window_radius: f64,
pub shell_radius: f64,
pub top_bar_height: f64,
pub top_bar_margin_top: i32,
pub top_bar_margin_horizontal: i32,
pub top_bar_window_gap: i32,
pub panel_padding: f64,
pub control_gap: f64,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Typography {
pub font_family: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Background {
pub path: Option<PathBuf>,
pub lock_path: Option<PathBuf>,
pub mode: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct BarSurface {
pub background: String,
pub text_primary: String,
pub text_muted: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Surfaces {
pub bar: BarSurface,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct SoftShadow {
pub offset_y: f64,
pub blur: f64,
pub opacity: f64,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Shadows {
pub soft: SoftShadow,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Gradient {
pub from: String,
pub to: String,
pub angle: f64,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
pub struct Paint {
pub gradient: Option<Gradient>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Tokens {
pub colors: Colors,
pub material: Material,
pub geometry: Geometry,
pub typography: Typography,
pub background: Background,
pub surface: Surfaces,
pub shadow: Shadows,
pub border: Paint,
pub focus_ring: Paint,
}
impl Default for Tokens {
fn default() -> Self {
serde_json::from_value(json!({
"colors": {"surface_base":"#111821", "surface_raised":"#1E2530", "application_background":"#171E27", "text_primary":"#F4F7FB", "text_muted":"#8793A2", "colors": {"surface_base":"#111821", "surface_raised":"#1E2530", "application_background":"#171E27", "text_primary":"#F4F7FB", "text_muted":"#8793A2",
"accent":"#3D7BE6", "on_accent":"#FFFFFF", "border":"#FFFFFF18", "shadow":"#00000055"}, "accent":"#3D7BE6", "on_accent":"#FFFFFF", "border":"#FFFFFF18", "shadow":"#00000055"},
"material":{"style":"solid","opacity":0.78,"blur_radius":12.0,"tint_strength":0.5}, "material":{"style":"solid","opacity":0.78,"blur_radius":12.0,"tint_strength":0.5},
@@ -337,27 +217,15 @@ impl Default for Tokens {
"border":{"gradient":null},"focus_ring":{"gradient":null} "border":{"gradient":null},"focus_ring":{"gradient":null}
})) }))
.expect("valid built-in theme") .expect("valid built-in theme")
}
} }
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub fn default_theme() -> ResolvedTheme {
pub struct ResolvedTheme { ResolvedTheme {
pub appearance: Appearance, appearance: Appearance::Dark,
pub tokens: Tokens, requested_accent: "#3D7BE6".into(),
pub requested_accent: String, tokens: default_tokens(),
pub accessibility: Accessibility, accessibility: Accessibility::default(),
pub reduced_motion: bool, reduced_motion: false,
}
impl Default for ResolvedTheme {
fn default() -> Self {
Self {
appearance: Appearance::Dark,
requested_accent: "#3D7BE6".into(),
tokens: Tokens::default(),
accessibility: Accessibility::default(),
reduced_motion: false,
}
} }
} }
@@ -382,8 +250,8 @@ impl Default for Snapshot {
version: SCHEMA_VERSION, version: SCHEMA_VERSION,
revision: 0, revision: 0,
mode: Mode::Dark, mode: Mode::Dark,
theme: ResolvedTheme::default(), theme: default_theme(),
presented: ResolvedTheme::default(), presented: default_theme(),
warnings: vec![], warnings: vec![],
error: None, error: None,
families: crate::families::builtins(), families: crate::families::builtins(),
@@ -711,7 +579,7 @@ pub fn preset(name: &str, appearance: Appearance) -> Result<Tokens, String> {
if preset.appearance != appearance { if preset.appearance != appearance {
return Err(format!("Preset {name} does not support {appearance:?}")); return Err(format!("Preset {name} does not support {appearance:?}"));
} }
let mut tokens = Tokens::default(); let mut tokens = default_tokens();
tokens.colors.surface_base = preset.base.into(); tokens.colors.surface_base = preset.base.into();
tokens.colors.surface_raised = preset.raised.into(); tokens.colors.surface_raised = preset.raised.into();
tokens.colors.application_background = preset.application_background.into(); tokens.colors.application_background = preset.application_background.into();
@@ -737,7 +605,7 @@ pub fn preset(name: &str, appearance: Appearance) -> Result<Tokens, String> {
tokens.shadow.soft.opacity = preset.shadow_opacity; tokens.shadow.soft.opacity = preset.shadow_opacity;
return Ok(tokens); return Ok(tokens);
} }
let mut t = Tokens::default(); let mut t = default_tokens();
let (base, text, muted, accent, border) = match name { let (base, text, muted, accent, border) = match name {
"monochrome" => ("#101012", "#EDEDF0", "#97979F", "#E5E5E5", "#FFFFFF18"), "monochrome" => ("#101012", "#EDEDF0", "#97979F", "#E5E5E5", "#FFFFFF18"),
"dracula" => ("#282A36", "#F8F8F2", "#A4ADCD", "#BD93F9", "#44475A"), "dracula" => ("#282A36", "#F8F8F2", "#A4ADCD", "#BD93F9", "#44475A"),
@@ -863,103 +731,6 @@ fn validate(tokens: &Tokens) -> Result<(), String> {
Ok(()) Ok(())
} }
pub fn rgba(s: &str) -> Result<[f64; 4], String> {
let hex = s.strip_prefix('#').ok_or_else(|| format!("Invalid color: {s}"))?;
if !hex.is_ascii() || !matches!(hex.len(), 6 | 8) {
return Err(format!("Invalid color: {s}"));
}
let mut out = [1.; 4];
for (i, c) in out.iter_mut().enumerate().take(hex.len() / 2) {
*c = f64::from(u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).map_err(|_| format!("Invalid color: {s}"))?)
/ 255.;
}
Ok(out)
}
pub fn hex(c: [f64; 4]) -> String {
format!(
"#{:02X}{:02X}{:02X}{:02X}",
(c[0].clamp(0., 1.) * 255.).round() as u8,
(c[1].clamp(0., 1.) * 255.).round() as u8,
(c[2].clamp(0., 1.) * 255.).round() as u8,
(c[3].clamp(0., 1.) * 255.).round() as u8
)
}
pub fn luminance(c: [f64; 4]) -> f64 {
let linear = |v: f64| {
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * linear(c[0]) + 0.7152 * linear(c[1]) + 0.0722 * linear(c[2])
}
pub fn contrast(a: [f64; 4], b: [f64; 4]) -> f64 {
let (a, b) = (luminance(a), luminance(b));
(a.max(b) + 0.05) / (a.min(b) + 0.05)
}
pub fn composite(fg: [f64; 4], bg: [f64; 4]) -> [f64; 4] {
let mut c = [0.; 4];
for i in 0..3 {
c[i] = fg[i] * fg[3] + bg[i] * (1. - fg[3]);
}
c[3] = 1.;
c
}
fn blend(a: [f64; 4], b: [f64; 4], p: f64) -> [f64; 4] {
std::array::from_fn(|i| a[i] + (b[i] - a[i]) * p)
}
pub fn readable(preferred: [f64; 4], background: [f64; 4], minimum: f64) -> [f64; 4] {
let mut preferred = preferred;
preferred[3] = 1.;
if contrast(preferred, background) >= minimum {
return preferred;
}
let black = [0., 0., 0., 1.];
let white = [1., 1., 1., 1.];
let target = if contrast(black, background) > contrast(white, background) {
black
} else {
white
};
for step in 1..=100 {
let c = blend(preferred, target, f64::from(step) / 100.);
if contrast(rgba(&hex(c)).expect("generated color"), background) >= minimum {
return c;
}
}
target
}
fn readable_across(mut preferred: [f64; 4], backgrounds: &[[f64; 4]], minimum: f64) -> [f64; 4] {
preferred[3] = 1.;
let score = |color| {
backgrounds
.iter()
.map(|background| contrast(color, *background))
.fold(f64::INFINITY, f64::min)
};
if score(preferred) >= minimum {
return preferred;
}
let black = [0., 0., 0., 1.];
let white = [1.; 4];
let target = if score(black) > score(white) { black } else { white };
for step in 1..=100 {
let color = rgba(&hex(blend(preferred, target, f64::from(step) / 100.))).unwrap();
if score(color) >= minimum {
return color;
}
}
target
}
fn transform(t: &mut Tokens, accessibility: &Accessibility, warnings: &mut Vec<String>) { fn transform(t: &mut Tokens, accessibility: &Accessibility, warnings: &mut Vec<String>) {
let surface = rgba(&t.colors.surface_base).expect("validated surface"); let surface = rgba(&t.colors.surface_base).expect("validated surface");
let mut requested = rgba(&t.colors.accent).expect("validated accent"); let mut requested = rgba(&t.colors.accent).expect("validated accent");
@@ -1075,180 +846,6 @@ fn resolve_material_contrast(tokens: &mut Tokens, minimum: f64) {
} }
} }
fn material_bounds(mut surface: [f64; 4], opacity: f64) -> [[f64; 4]; 2] {
surface[3] = opacity;
[composite(surface, [0., 0., 0., 1.]), composite(surface, [1.; 4])]
}
fn material_foreground(preferred: [f64; 4], backgrounds: [[f64; 4]; 2]) -> [f64; 4] {
let black = [0., 0., 0., 1.];
let white = [1.; 4];
let (target, worst) = if contrast(black, backgrounds[0]) >= contrast(white, backgrounds[1]) {
(black, backgrounds[0])
} else {
(white, backgrounds[1])
};
let result = readable(preferred, worst, 4.5);
let outside = luminance(result) < luminance(backgrounds[0]) || luminance(result) > luminance(backgrounds[1]);
if outside
&& backgrounds
.iter()
.all(|background| contrast(rgba(&hex(result)).unwrap(), *background) >= 4.5)
{
result
} else {
target
}
}
impl ResolvedTheme {
/// Bound contrast across all backdrops instead of interpolating foregrounds.
pub fn transition(&self, to: &Self, progress: f64) -> Self {
if to.reduced_motion || self.accessibility != to.accessibility || progress >= 1.0 {
return to.clone();
}
if progress <= 0.0 || self == to {
return self.clone();
}
let p = progress.clamp(0., 1.);
let p = p * p * (3. - 2. * p);
let mut frame = to.clone();
let mix = |a: &str, b: &str| hex(blend(rgba(a).unwrap(), rgba(b).unwrap(), p));
frame.tokens.colors.surface_base = mix(&self.tokens.colors.surface_base, &to.tokens.colors.surface_base);
frame.tokens.colors.surface_raised = mix(&self.tokens.colors.surface_raised, &to.tokens.colors.surface_raised);
frame.tokens.colors.application_background = mix(
&self.tokens.colors.application_background,
&to.tokens.colors.application_background,
);
frame.tokens.colors.accent = mix(&self.tokens.colors.accent, &to.tokens.colors.accent);
frame.tokens.colors.border = mix(&self.tokens.colors.border, &to.tokens.colors.border);
frame.tokens.colors.shadow = mix(&self.tokens.colors.shadow, &to.tokens.colors.shadow);
frame.tokens.surface.bar.background =
mix(&self.tokens.surface.bar.background, &to.tokens.surface.bar.background);
frame.tokens.material.opacity =
self.tokens.material.opacity + (to.tokens.material.opacity - self.tokens.material.opacity) * p;
frame.tokens.material.tint_strength = self.tokens.material.tint_strength
+ (to.tokens.material.tint_strength - self.tokens.material.tint_strength) * p;
frame.tokens.shadow.soft.opacity =
self.tokens.shadow.soft.opacity + (to.tokens.shadow.soft.opacity - self.tokens.shadow.soft.opacity) * p;
let preferred = if p < 0.5 { self } else { to };
frame.appearance = preferred.appearance;
if self.appearance == to.appearance {
let body = [
rgba(&frame.tokens.colors.surface_base).unwrap(),
rgba(&frame.tokens.colors.surface_raised).unwrap(),
rgba(&frame.tokens.colors.application_background).unwrap(),
];
let minimum = if to.accessibility.increase_contrast { 7.0 } else { 4.5 };
frame.tokens.colors.text_primary = hex(readable_across(
rgba(&preferred.tokens.colors.text_primary).unwrap(),
&body,
minimum,
));
frame.tokens.colors.text_muted = hex(readable_across(
rgba(&preferred.tokens.colors.text_muted).unwrap(),
&body,
minimum,
));
let bar = rgba(&frame.tokens.surface.bar.background).unwrap();
frame.tokens.surface.bar.text_primary = hex(readable(
rgba(&preferred.tokens.surface.bar.text_primary).unwrap(),
bar,
minimum,
));
frame.tokens.surface.bar.text_muted = hex(readable(
rgba(&preferred.tokens.surface.bar.text_muted).unwrap(),
bar,
minimum,
));
frame.tokens.colors.on_accent = hex(readable(
rgba(&preferred.tokens.colors.on_accent).unwrap(),
rgba(&frame.tokens.colors.accent).unwrap(),
4.5,
));
return frame;
}
let base = rgba(&frame.tokens.colors.surface_base).unwrap();
let raised = rgba(&frame.tokens.colors.surface_raised).unwrap();
let app = rgba(&frame.tokens.colors.application_background).unwrap();
let minimum = [base, raised, app]
.into_iter()
.map(luminance)
.fold(f64::INFINITY, f64::min);
let maximum = [base, raised, app].into_iter().map(luminance).fold(0., f64::max);
if ((minimum + 0.05) / 0.05).max(1.05 / (maximum + 0.05)) < 4.5 {
frame.tokens.colors.surface_raised = frame.tokens.colors.surface_base.clone();
frame.tokens.colors.application_background = frame.tokens.colors.surface_base.clone();
}
let surface = rgba(&frame.tokens.colors.surface_base).unwrap();
let bar = rgba(&frame.tokens.surface.bar.background).unwrap();
let mut opacity = if frame.tokens.material.style == "solid" {
1.
} else {
frame.tokens.material.opacity * frame.tokens.material.tint_strength
};
for step in 0..=100 {
let candidate = opacity + (1. - opacity) * f64::from(step) / 100.;
let backgrounds = material_bounds(surface, candidate);
let raised = rgba(&frame.tokens.colors.surface_raised).unwrap();
let app = rgba(&frame.tokens.colors.application_background).unwrap();
let all = [backgrounds[0], backgrounds[1], raised, app];
let minimum = all.into_iter().map(luminance).fold(f64::INFINITY, f64::min);
let maximum = all.into_iter().map(luminance).fold(0., f64::max);
let bar = material_bounds(bar, candidate);
let readable = ((minimum + 0.05) / 0.05).max(1.05 / (maximum + 0.05)) >= 4.5
&& contrast([0., 0., 0., 1.], bar[0]).max(contrast([1.; 4], bar[1])) >= 4.5;
if readable {
opacity = candidate;
break;
}
}
if opacity > frame.tokens.material.opacity {
frame.tokens.material.opacity = opacity;
frame.tokens.material.tint_strength = 1.;
} else if frame.tokens.material.opacity > 0. {
frame.tokens.material.tint_strength = opacity / frame.tokens.material.opacity;
}
let surface = material_bounds(surface, opacity);
let bar = material_bounds(bar, opacity);
let raised = rgba(&frame.tokens.colors.surface_raised).unwrap();
let app = rgba(&frame.tokens.colors.application_background).unwrap();
let body_backgrounds = [surface[0], surface[1], raised, app];
let body_bounds = [
*body_backgrounds
.iter()
.min_by(|a, b| luminance(**a).total_cmp(&luminance(**b)))
.unwrap(),
*body_backgrounds
.iter()
.max_by(|a, b| luminance(**a).total_cmp(&luminance(**b)))
.unwrap(),
];
frame.tokens.colors.text_primary = hex(material_foreground(
rgba(&preferred.tokens.colors.text_primary).unwrap(),
body_bounds,
));
frame.tokens.colors.text_muted = hex(material_foreground(
rgba(&preferred.tokens.colors.text_muted).unwrap(),
body_bounds,
));
frame.tokens.surface.bar.text_primary = hex(material_foreground(
rgba(&preferred.tokens.surface.bar.text_primary).unwrap(),
bar,
));
frame.tokens.surface.bar.text_muted = hex(material_foreground(
rgba(&preferred.tokens.surface.bar.text_muted).unwrap(),
bar,
));
frame.tokens.colors.on_accent = hex(readable(
rgba(&preferred.tokens.colors.on_accent).unwrap(),
rgba(&frame.tokens.colors.accent).unwrap(),
4.5,
));
frame
}
}
pub type ImportedFamily = (crate::families::Family, (Option<Tokens>, Option<Tokens>), Vec<String>); pub type ImportedFamily = (crate::families::Family, (Option<Tokens>, Option<Tokens>), Vec<String>);
pub fn import_family(id: &str, source: &str) -> Result<ImportedFamily, String> { pub fn import_family(id: &str, source: &str) -> Result<ImportedFamily, String> {
@@ -1758,6 +1355,6 @@ mod tests {
.theme; .theme;
assert_eq!(light.appearance, Appearance::Light); assert_eq!(light.appearance, Appearance::Light);
light.reduced_motion = true; light.reduced_motion = true;
assert_eq!(ResolvedTheme::default().transition(&light, 0.), light); assert_eq!(default_theme().transition(&light, 0.), light);
} }
} }
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "ferese-theme-model"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[dependencies]
serde.workspace = true
+272
View File
@@ -0,0 +1,272 @@
use crate::ResolvedTheme;
pub fn rgba(s: &str) -> Result<[f64; 4], String> {
let hex = s.strip_prefix('#').ok_or_else(|| format!("Invalid color: {s}"))?;
if !hex.is_ascii() || !matches!(hex.len(), 6 | 8) {
return Err(format!("Invalid color: {s}"));
}
let mut out = [1.; 4];
for (i, c) in out.iter_mut().enumerate().take(hex.len() / 2) {
*c = f64::from(u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).map_err(|_| format!("Invalid color: {s}"))?)
/ 255.;
}
Ok(out)
}
pub fn hex(c: [f64; 4]) -> String {
format!(
"#{:02X}{:02X}{:02X}{:02X}",
(c[0].clamp(0., 1.) * 255.).round() as u8,
(c[1].clamp(0., 1.) * 255.).round() as u8,
(c[2].clamp(0., 1.) * 255.).round() as u8,
(c[3].clamp(0., 1.) * 255.).round() as u8
)
}
pub fn luminance(c: [f64; 4]) -> f64 {
let linear = |v: f64| {
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * linear(c[0]) + 0.7152 * linear(c[1]) + 0.0722 * linear(c[2])
}
pub fn contrast(a: [f64; 4], b: [f64; 4]) -> f64 {
let (a, b) = (luminance(a), luminance(b));
(a.max(b) + 0.05) / (a.min(b) + 0.05)
}
pub fn composite(fg: [f64; 4], bg: [f64; 4]) -> [f64; 4] {
let mut c = [0.; 4];
for i in 0..3 {
c[i] = fg[i] * fg[3] + bg[i] * (1. - fg[3]);
}
c[3] = 1.;
c
}
pub fn blend(a: [f64; 4], b: [f64; 4], p: f64) -> [f64; 4] {
std::array::from_fn(|i| a[i] + (b[i] - a[i]) * p)
}
pub fn readable(preferred: [f64; 4], background: [f64; 4], minimum: f64) -> [f64; 4] {
let mut preferred = preferred;
preferred[3] = 1.;
if contrast(preferred, background) >= minimum {
return preferred;
}
let black = [0., 0., 0., 1.];
let white = [1., 1., 1., 1.];
let target = if contrast(black, background) > contrast(white, background) {
black
} else {
white
};
for step in 1..=100 {
let c = blend(preferred, target, f64::from(step) / 100.);
if contrast(rgba(&hex(c)).expect("generated color"), background) >= minimum {
return c;
}
}
target
}
pub fn readable_across(mut preferred: [f64; 4], backgrounds: &[[f64; 4]], minimum: f64) -> [f64; 4] {
preferred[3] = 1.;
let score = |color| {
backgrounds
.iter()
.map(|background| contrast(color, *background))
.fold(f64::INFINITY, f64::min)
};
if score(preferred) >= minimum {
return preferred;
}
let black = [0., 0., 0., 1.];
let white = [1.; 4];
let target = if score(black) > score(white) { black } else { white };
for step in 1..=100 {
let color = rgba(&hex(blend(preferred, target, f64::from(step) / 100.))).unwrap();
if score(color) >= minimum {
return color;
}
}
target
}
pub fn material_bounds(mut surface: [f64; 4], opacity: f64) -> [[f64; 4]; 2] {
surface[3] = opacity;
[composite(surface, [0., 0., 0., 1.]), composite(surface, [1.; 4])]
}
fn material_foreground(preferred: [f64; 4], backgrounds: [[f64; 4]; 2]) -> [f64; 4] {
let black = [0., 0., 0., 1.];
let white = [1.; 4];
let (target, worst) = if contrast(black, backgrounds[0]) >= contrast(white, backgrounds[1]) {
(black, backgrounds[0])
} else {
(white, backgrounds[1])
};
let result = readable(preferred, worst, 4.5);
let outside = luminance(result) < luminance(backgrounds[0]) || luminance(result) > luminance(backgrounds[1]);
if outside
&& backgrounds
.iter()
.all(|background| contrast(rgba(&hex(result)).unwrap(), *background) >= 4.5)
{
result
} else {
target
}
}
impl ResolvedTheme {
/// Bound contrast across all backdrops instead of interpolating foregrounds.
pub fn transition(&self, to: &Self, progress: f64) -> Self {
if to.reduced_motion || self.accessibility != to.accessibility || progress >= 1.0 {
return to.clone();
}
if progress <= 0.0 || self == to {
return self.clone();
}
let p = progress.clamp(0., 1.);
let p = p * p * (3. - 2. * p);
let mut frame = to.clone();
let mix = |a: &str, b: &str| hex(blend(rgba(a).unwrap(), rgba(b).unwrap(), p));
frame.tokens.colors.surface_base = mix(&self.tokens.colors.surface_base, &to.tokens.colors.surface_base);
frame.tokens.colors.surface_raised = mix(&self.tokens.colors.surface_raised, &to.tokens.colors.surface_raised);
frame.tokens.colors.application_background = mix(
&self.tokens.colors.application_background,
&to.tokens.colors.application_background,
);
frame.tokens.colors.accent = mix(&self.tokens.colors.accent, &to.tokens.colors.accent);
frame.tokens.colors.border = mix(&self.tokens.colors.border, &to.tokens.colors.border);
frame.tokens.colors.shadow = mix(&self.tokens.colors.shadow, &to.tokens.colors.shadow);
frame.tokens.surface.bar.background =
mix(&self.tokens.surface.bar.background, &to.tokens.surface.bar.background);
frame.tokens.material.opacity =
self.tokens.material.opacity + (to.tokens.material.opacity - self.tokens.material.opacity) * p;
frame.tokens.material.tint_strength = self.tokens.material.tint_strength
+ (to.tokens.material.tint_strength - self.tokens.material.tint_strength) * p;
frame.tokens.shadow.soft.opacity =
self.tokens.shadow.soft.opacity + (to.tokens.shadow.soft.opacity - self.tokens.shadow.soft.opacity) * p;
let preferred = if p < 0.5 { self } else { to };
frame.appearance = preferred.appearance;
if self.appearance == to.appearance {
let body = [
rgba(&frame.tokens.colors.surface_base).unwrap(),
rgba(&frame.tokens.colors.surface_raised).unwrap(),
rgba(&frame.tokens.colors.application_background).unwrap(),
];
let minimum = if to.accessibility.increase_contrast { 7.0 } else { 4.5 };
frame.tokens.colors.text_primary = hex(readable_across(
rgba(&preferred.tokens.colors.text_primary).unwrap(),
&body,
minimum,
));
frame.tokens.colors.text_muted = hex(readable_across(
rgba(&preferred.tokens.colors.text_muted).unwrap(),
&body,
minimum,
));
let bar = rgba(&frame.tokens.surface.bar.background).unwrap();
frame.tokens.surface.bar.text_primary = hex(readable(
rgba(&preferred.tokens.surface.bar.text_primary).unwrap(),
bar,
minimum,
));
frame.tokens.surface.bar.text_muted = hex(readable(
rgba(&preferred.tokens.surface.bar.text_muted).unwrap(),
bar,
minimum,
));
frame.tokens.colors.on_accent = hex(readable(
rgba(&preferred.tokens.colors.on_accent).unwrap(),
rgba(&frame.tokens.colors.accent).unwrap(),
4.5,
));
return frame;
}
let base = rgba(&frame.tokens.colors.surface_base).unwrap();
let raised = rgba(&frame.tokens.colors.surface_raised).unwrap();
let app = rgba(&frame.tokens.colors.application_background).unwrap();
let minimum = [base, raised, app]
.into_iter()
.map(luminance)
.fold(f64::INFINITY, f64::min);
let maximum = [base, raised, app].into_iter().map(luminance).fold(0., f64::max);
if ((minimum + 0.05) / 0.05).max(1.05 / (maximum + 0.05)) < 4.5 {
frame.tokens.colors.surface_raised = frame.tokens.colors.surface_base.clone();
frame.tokens.colors.application_background = frame.tokens.colors.surface_base.clone();
}
let surface = rgba(&frame.tokens.colors.surface_base).unwrap();
let bar = rgba(&frame.tokens.surface.bar.background).unwrap();
let mut opacity = if frame.tokens.material.style == "solid" {
1.
} else {
frame.tokens.material.opacity * frame.tokens.material.tint_strength
};
for step in 0..=100 {
let candidate = opacity + (1. - opacity) * f64::from(step) / 100.;
let backgrounds = material_bounds(surface, candidate);
let raised = rgba(&frame.tokens.colors.surface_raised).unwrap();
let app = rgba(&frame.tokens.colors.application_background).unwrap();
let all = [backgrounds[0], backgrounds[1], raised, app];
let minimum = all.into_iter().map(luminance).fold(f64::INFINITY, f64::min);
let maximum = all.into_iter().map(luminance).fold(0., f64::max);
let bar = material_bounds(bar, candidate);
let readable = ((minimum + 0.05) / 0.05).max(1.05 / (maximum + 0.05)) >= 4.5
&& contrast([0., 0., 0., 1.], bar[0]).max(contrast([1.; 4], bar[1])) >= 4.5;
if readable {
opacity = candidate;
break;
}
}
if opacity > frame.tokens.material.opacity {
frame.tokens.material.opacity = opacity;
frame.tokens.material.tint_strength = 1.;
} else if frame.tokens.material.opacity > 0. {
frame.tokens.material.tint_strength = opacity / frame.tokens.material.opacity;
}
let surface = material_bounds(surface, opacity);
let bar = material_bounds(bar, opacity);
let raised = rgba(&frame.tokens.colors.surface_raised).unwrap();
let app = rgba(&frame.tokens.colors.application_background).unwrap();
let body_backgrounds = [surface[0], surface[1], raised, app];
let body_bounds = [
*body_backgrounds
.iter()
.min_by(|a, b| luminance(**a).total_cmp(&luminance(**b)))
.unwrap(),
*body_backgrounds
.iter()
.max_by(|a, b| luminance(**a).total_cmp(&luminance(**b)))
.unwrap(),
];
frame.tokens.colors.text_primary = hex(material_foreground(
rgba(&preferred.tokens.colors.text_primary).unwrap(),
body_bounds,
));
frame.tokens.colors.text_muted = hex(material_foreground(
rgba(&preferred.tokens.colors.text_muted).unwrap(),
body_bounds,
));
frame.tokens.surface.bar.text_primary = hex(material_foreground(
rgba(&preferred.tokens.surface.bar.text_primary).unwrap(),
bar,
));
frame.tokens.surface.bar.text_muted = hex(material_foreground(
rgba(&preferred.tokens.surface.bar.text_muted).unwrap(),
bar,
));
frame.tokens.colors.on_accent = hex(readable(
rgba(&preferred.tokens.colors.on_accent).unwrap(),
rgba(&frame.tokens.colors.accent).unwrap(),
4.5,
));
frame
}
}
+51
View File
@@ -0,0 +1,51 @@
use serde::{Deserialize, Serialize};
use crate::{Appearance, Tokens};
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Palette {
pub background: String,
pub bar: String,
pub surface: String,
pub text: String,
pub muted: String,
pub accent: String,
}
impl From<&Tokens> for Palette {
fn from(tokens: &Tokens) -> Self {
Self {
background: tokens.colors.surface_base.clone(),
bar: tokens.surface.bar.background.clone(),
surface: tokens.colors.surface_raised.clone(),
text: tokens.colors.text_primary.clone(),
muted: tokens.colors.text_muted.clone(),
accent: tokens.colors.accent.clone(),
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Family {
pub id: String,
pub name: String,
pub light: Option<Palette>,
pub dark: Option<Palette>,
}
impl Family {
pub fn palette(&self, appearance: Appearance) -> Option<&Palette> {
match appearance {
Appearance::Light => self.light.as_ref(),
Appearance::Dark => self.dark.as_ref(),
}
}
pub fn availability(&self) -> &'static str {
match (self.light.is_some(), self.dark.is_some()) {
(true, true) => "light and dark",
(true, false) => "light only",
_ => "dark only",
}
}
}
+135
View File
@@ -0,0 +1,135 @@
//! Resolved theme values and pure color operations. No configuration or runtime access.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
mod color;
pub mod families;
pub use color::*;
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Appearance {
Light,
#[default]
Dark,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Mode {
Light,
#[default]
Dark,
Auto,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default)]
pub struct Accessibility {
pub increase_contrast: bool,
pub reduce_transparency: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Colors {
pub surface_base: String,
pub surface_raised: String,
pub application_background: String,
pub text_primary: String,
pub text_muted: String,
pub accent: String,
pub on_accent: String,
pub border: String,
pub shadow: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Material {
pub style: String,
pub opacity: f64,
pub blur_radius: f64,
pub tint_strength: f64,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Geometry {
pub border_width: f64,
pub focus_ring_width: f64,
pub window_radius: f64,
pub shell_radius: f64,
pub top_bar_height: f64,
pub top_bar_margin_top: i32,
pub top_bar_margin_horizontal: i32,
pub top_bar_window_gap: i32,
pub panel_padding: f64,
pub control_gap: f64,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Typography {
pub font_family: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Background {
pub path: Option<PathBuf>,
pub lock_path: Option<PathBuf>,
pub mode: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct BarSurface {
pub background: String,
pub text_primary: String,
pub text_muted: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Surfaces {
pub bar: BarSurface,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct SoftShadow {
pub offset_y: f64,
pub blur: f64,
pub opacity: f64,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Shadows {
pub soft: SoftShadow,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Gradient {
pub from: String,
pub to: String,
pub angle: f64,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
pub struct Paint {
pub gradient: Option<Gradient>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Tokens {
pub colors: Colors,
pub material: Material,
pub geometry: Geometry,
pub typography: Typography,
pub background: Background,
pub surface: Surfaces,
pub shadow: Shadows,
pub border: Paint,
pub focus_ring: Paint,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct ResolvedTheme {
pub appearance: Appearance,
pub tokens: Tokens,
pub requested_accent: String,
pub accessibility: Accessibility,
pub reduced_motion: bool,
}
+1 -1
View File
@@ -168,7 +168,7 @@ mod tests {
("translucent", 1., 1.), ("translucent", 1., 1.),
("translucent", 0., 0.), ("translucent", 0., 0.),
] { ] {
let mut theme = ferese_config::theme::ResolvedTheme::default(); let mut theme = ferese_config::theme::default_theme();
theme.tokens.material.style = style.into(); theme.tokens.material.style = style.into();
theme.tokens.material.opacity = opacity; theme.tokens.material.opacity = opacity;
assert_eq!(crate::service::opacity(&theme), expected); assert_eq!(crate::service::opacity(&theme), expected);
+4 -4
View File
@@ -162,7 +162,7 @@ fn initial_appearance(theme: Option<&ResolvedTheme>, load: impl FnOnce() -> Opti
theme theme
.map(Appearance::from_resolved) .map(Appearance::from_resolved)
.or_else(load) .or_else(load)
.unwrap_or_else(|| Appearance::from_resolved(&ResolvedTheme::default())) .unwrap_or_else(|| Appearance::from_resolved(&ferese_config::theme::default_theme()))
} }
fn read_source(path: &Path) -> Result<String, String> { fn read_source(path: &Path) -> Result<String, String> {
@@ -186,7 +186,7 @@ fn load_path(path: &Path) -> Result<Appearance, String> {
let source = match read_source(path) { let source = match read_source(path) {
Ok(source) => source, Ok(source) => source,
Err(_) if !path.try_exists().map_err(|error| error.to_string())? => { Err(_) if !path.try_exists().map_err(|error| error.to_string())? => {
return Ok(Appearance::from_resolved(&ResolvedTheme::default())); return Ok(Appearance::from_resolved(&ferese_config::theme::default_theme()));
} }
Err(error) => return Err(error), Err(error) => return Err(error),
}; };
@@ -222,7 +222,7 @@ mod tests {
assert_eq!(appearance.contrast, 1); assert_eq!(appearance.contrast, 1);
assert_ne!( assert_ne!(
appearance.accent, appearance.accent,
Appearance::from_resolved(&ResolvedTheme::default()).accent Appearance::from_resolved(&ferese_config::theme::default_theme()).accent
); );
std::fs::write(&path, r#"theme { mode "dark"; }; animations { enabled #false; }"#).unwrap(); std::fs::write(&path, r#"theme { mode "dark"; }; animations { enabled #false; }"#).unwrap();
@@ -248,7 +248,7 @@ mod tests {
fn disk_fallback_rejects_invalid_config_and_uses_defaults_when_absent() { fn disk_fallback_rejects_invalid_config_and_uses_defaults_when_absent() {
let root = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap();
let path = root.path().join("config.kdl"); let path = root.path().join("config.kdl");
let default = Appearance::from_resolved(&ResolvedTheme::default()); let default = Appearance::from_resolved(&ferese_config::theme::default_theme());
assert_eq!(load_path(&path).unwrap(), default); assert_eq!(load_path(&path).unwrap(), default);
for source in [ for source in [
r#"theme { broken"#, r#"theme { broken"#,
@@ -33,6 +33,13 @@ class ComponentBoundaries(unittest.TestCase):
def test_shell_does_not_depend_on_core(self): def test_shell_does_not_depend_on_core(self):
self.assertNotIn("ferese-core", reachable(dependency_graph(), "ferese-shell")) self.assertNotIn("ferese-core", reachable(dependency_graph(), "ferese-shell"))
def test_theme_model_has_no_configuration_or_runtime_dependencies(self):
dependencies = reachable(dependency_graph(), "ferese-theme-model")
self.assertTrue(dependencies.isdisjoint({
"ferese-config", "ferese-ipc", "ferese-theme", "ferese-core",
"libcosmic", "wayland-client", "tokio", "kdl", "jiff",
}), dependencies)
def test_transitive_dependencies_are_checked(self): def test_transitive_dependencies_are_checked(self):
self.assertIn("core", reachable({"shell": {"helper"}, "helper": {"core"}}, "shell")) self.assertIn("core", reachable({"shell": {"helper"}, "helper": {"core"}}, "shell"))
+3 -3
View File
@@ -609,7 +609,7 @@ mod tests {
drop(writer); drop(writer);
reader_thread.join().unwrap(); reader_thread.join().unwrap();
assert!(matches!(incoming.lock().unwrap().back(), Some(PromptEvent::Cancel))); assert!(matches!(incoming.lock().unwrap().back(), Some(PromptEvent::Cancel)));
let appearance = Appearance::from_resolved(&ferese_config::theme::ResolvedTheme::default()); let appearance = Appearance::from_resolved(&ferese_config::theme::default_theme());
let (mut prompt, _) = Prompt::init(Core::default(), (incoming, request_fixture(), appearance)); let (mut prompt, _) = Prompt::init(Core::default(), (incoming, request_fixture(), appearance));
*prompt.answer = "test-password".into(); *prompt.answer = "test-password".into();
prompt.waiting = false; prompt.waiting = false;
@@ -625,7 +625,7 @@ mod tests {
use crate::{Generation, PromptEvent}; use crate::{Generation, PromptEvent};
let incoming = Arc::new(Mutex::new(VecDeque::new())); let incoming = Arc::new(Mutex::new(VecDeque::new()));
let appearance = Appearance::from_resolved(&ferese_config::theme::ResolvedTheme::default()); let appearance = Appearance::from_resolved(&ferese_config::theme::default_theme());
let (mut prompt, _) = Prompt::init(Core::default(), (incoming.clone(), request_fixture(), appearance)); let (mut prompt, _) = Prompt::init(Core::default(), (incoming.clone(), request_fixture(), appearance));
assert_eq!(prompt.select_identity(1), Some(1001)); assert_eq!(prompt.select_identity(1), Some(1001));
let older = prompt.request.generation; let older = prompt.request.generation;
@@ -719,7 +719,7 @@ mod tests {
#[test] #[test]
fn selecting_another_account_clears_password_and_waits_for_a_fresh_prompt() { fn selecting_another_account_clears_password_and_waits_for_a_fresh_prompt() {
let appearance = Appearance::from_resolved(&ferese_config::theme::ResolvedTheme::default()); let appearance = Appearance::from_resolved(&ferese_config::theme::default_theme());
let (mut prompt, _) = <Prompt as cosmic::Application>::init( let (mut prompt, _) = <Prompt as cosmic::Application>::init(
Core::default(), Core::default(),
(Arc::new(Mutex::new(VecDeque::new())), request_fixture(), appearance), (Arc::new(Mutex::new(VecDeque::new())), request_fixture(), appearance),