diff --git a/Cargo.lock b/Cargo.lock index 6ebdef0..5316ae2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1681,6 +1681,7 @@ dependencies = [ name = "ferese-config" version = "0.1.0" dependencies = [ + "ferese-theme-model", "jiff", "kdl", "serde", @@ -1811,6 +1812,13 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "ferese-theme-model" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "feresectl" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 8dd2f74..ba17ad2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/ferese-core", "crates/ferese-config", "crates/ferese-theme", + "crates/ferese-theme-model", "crates/ferese-ipc", "crates/ferese-layout", "crates/ferese-protocols", diff --git a/crates/ferese-config/Cargo.toml b/crates/ferese-config/Cargo.toml index 1490f75..584e6fd 100644 --- a/crates/ferese-config/Cargo.toml +++ b/crates/ferese-config/Cargo.toml @@ -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 diff --git a/crates/ferese-config/src/families.rs b/crates/ferese-config/src/families.rs index 0ef5058..020d402 100644 --- a/crates/ferese-config/src/families.rs +++ b/crates/ferese-config/src/families.rs @@ -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, - pub dark: Option, -} - -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"), diff --git a/crates/ferese-config/src/theme.rs b/crates/ferese-config/src/theme.rs index 6166f5f..ba3e9ea 100644 --- a/crates/ferese-config/src/theme.rs +++ b/crates/ferese-config/src/theme.rs @@ -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, - pub lock_path: Option, - 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, -} - -#[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 { 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.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) { 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, Option), Vec); pub fn import_family(id: &str, source: &str) -> Result { @@ -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); } } diff --git a/crates/ferese-theme-model/Cargo.toml b/crates/ferese-theme-model/Cargo.toml new file mode 100644 index 0000000..748b4eb --- /dev/null +++ b/crates/ferese-theme-model/Cargo.toml @@ -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 diff --git a/crates/ferese-theme-model/src/color.rs b/crates/ferese-theme-model/src/color.rs new file mode 100644 index 0000000..9cacd2d --- /dev/null +++ b/crates/ferese-theme-model/src/color.rs @@ -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 + } +} diff --git a/crates/ferese-theme-model/src/families.rs b/crates/ferese-theme-model/src/families.rs new file mode 100644 index 0000000..5375abb --- /dev/null +++ b/crates/ferese-theme-model/src/families.rs @@ -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, + pub dark: Option, +} + +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", + } + } +} diff --git a/crates/ferese-theme-model/src/lib.rs b/crates/ferese-theme-model/src/lib.rs new file mode 100644 index 0000000..cb68099 --- /dev/null +++ b/crates/ferese-theme-model/src/lib.rs @@ -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, + pub lock_path: Option, + 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, +} + +#[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, +} diff --git a/crates/ferese-theme/src/palette.rs b/crates/ferese-theme/src/palette.rs index 1ac8a32..54f2138 100644 --- a/crates/ferese-theme/src/palette.rs +++ b/crates/ferese-theme/src/palette.rs @@ -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); diff --git a/portal/src/settings.rs b/portal/src/settings.rs index 2600b18..6be146d 100644 --- a/portal/src/settings.rs +++ b/portal/src/settings.rs @@ -162,7 +162,7 @@ fn initial_appearance(theme: Option<&ResolvedTheme>, load: impl FnOnce() -> Opti theme .map(Appearance::from_resolved) .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 { @@ -186,7 +186,7 @@ fn load_path(path: &Path) -> Result { let source = match read_source(path) { Ok(source) => source, 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), }; @@ -222,7 +222,7 @@ mod tests { assert_eq!(appearance.contrast, 1); assert_ne!( 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(); @@ -248,7 +248,7 @@ mod tests { fn disk_fallback_rejects_invalid_config_and_uses_defaults_when_absent() { let root = tempfile::tempdir().unwrap(); 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); for source in [ r#"theme { broken"#, diff --git a/scripts/tests/test_component_boundaries.py b/scripts/tests/test_component_boundaries.py index 9f31f00..b248230 100644 --- a/scripts/tests/test_component_boundaries.py +++ b/scripts/tests/test_component_boundaries.py @@ -33,6 +33,13 @@ class ComponentBoundaries(unittest.TestCase): def test_shell_does_not_depend_on_core(self): 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): self.assertIn("core", reachable({"shell": {"helper"}, "helper": {"core"}}, "shell")) diff --git a/shell/ferese-polkit-agent/src/prompt.rs b/shell/ferese-polkit-agent/src/prompt.rs index 591cb12..b79d230 100644 --- a/shell/ferese-polkit-agent/src/prompt.rs +++ b/shell/ferese-polkit-agent/src/prompt.rs @@ -609,7 +609,7 @@ mod tests { drop(writer); reader_thread.join().unwrap(); 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)); *prompt.answer = "test-password".into(); prompt.waiting = false; @@ -625,7 +625,7 @@ mod tests { use crate::{Generation, PromptEvent}; 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)); assert_eq!(prompt.select_identity(1), Some(1001)); let older = prompt.request.generation; @@ -719,7 +719,7 @@ mod tests { #[test] 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, _) = ::init( Core::default(), (Arc::new(Mutex::new(VecDeque::new())), request_fixture(), appearance),