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

+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
rust-version.workspace = true
[dependencies]
ferese-theme-model = { path = "../ferese-theme-model" }
jiff.workspace = true
kdl = "6.7.1"
serde.workspace = true
+2 -51
View File
@@ -1,54 +1,5 @@
use serde::{Deserialize, Serialize};
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",
}
}
}
use crate::theme::Appearance;
pub use ferese_theme_model::families::{Family, Palette};
pub const BUILTINS: [(&str, &str, &str, &str); 6] = [
("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 crate::Document;
pub use ferese_theme_model::*;
pub const SCHEMA_VERSION: u32 = 2;
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)]
#[serde(default)]
pub struct Selection {
@@ -225,105 +202,8 @@ impl Default for Policy {
}
}
#[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,
}
impl Default for Tokens {
fn default() -> Self {
serde_json::from_value(json!({
pub fn default_tokens() -> Tokens {
serde_json::from_value(json!({
"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"},
"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}
}))
.expect("valid built-in theme")
}
}
#[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,
}
impl Default for ResolvedTheme {
fn default() -> Self {
Self {
appearance: Appearance::Dark,
requested_accent: "#3D7BE6".into(),
tokens: Tokens::default(),
accessibility: Accessibility::default(),
reduced_motion: false,
}
pub fn default_theme() -> ResolvedTheme {
ResolvedTheme {
appearance: Appearance::Dark,
requested_accent: "#3D7BE6".into(),
tokens: default_tokens(),
accessibility: Accessibility::default(),
reduced_motion: false,
}
}
@@ -382,8 +250,8 @@ impl Default for Snapshot {
version: SCHEMA_VERSION,
revision: 0,
mode: Mode::Dark,
theme: ResolvedTheme::default(),
presented: ResolvedTheme::default(),
theme: default_theme(),
presented: default_theme(),
warnings: vec![],
error: None,
families: crate::families::builtins(),
@@ -711,7 +579,7 @@ pub fn preset(name: &str, appearance: Appearance) -> Result<Tokens, String> {
if preset.appearance != 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_raised = preset.raised.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;
return Ok(tokens);
}
let mut t = Tokens::default();
let mut t = default_tokens();
let (base, text, muted, accent, border) = match name {
"monochrome" => ("#101012", "#EDEDF0", "#97979F", "#E5E5E5", "#FFFFFF18"),
"dracula" => ("#282A36", "#F8F8F2", "#A4ADCD", "#BD93F9", "#44475A"),
@@ -863,103 +731,6 @@ fn validate(tokens: &Tokens) -> Result<(), String> {
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>) {
let surface = rgba(&t.colors.surface_base).expect("validated surface");
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 fn import_family(id: &str, source: &str) -> Result<ImportedFamily, String> {
@@ -1758,6 +1355,6 @@ mod tests {
.theme;
assert_eq!(light.appearance, Appearance::Light);
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", 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.opacity = opacity;
assert_eq!(crate::service::opacity(&theme), expected);