Separate resolved theme values from configuration resolution
This commit is contained in:
1 parent
4c3e884438
commit
8195b82073
13 files changed
+504
-472
No files matched your search
Generated
+8
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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,104 +202,7 @@ impl Default for Policy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
pub fn default_tokens() -> Tokens {
|
||||||
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!({
|
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"},
|
||||||
@@ -337,28 +217,16 @@ 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,
|
|
||||||
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,
|
appearance: Appearance::Dark,
|
||||||
requested_accent: "#3D7BE6".into(),
|
requested_accent: "#3D7BE6".into(),
|
||||||
tokens: Tokens::default(),
|
tokens: default_tokens(),
|
||||||
accessibility: Accessibility::default(),
|
accessibility: Accessibility::default(),
|
||||||
reduced_motion: false,
|
reduced_motion: false,
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in new issue
Block a user