Files
feresaria/crates/ferese-theme-model/src/color.rs
T

379 lines
13 KiB
Rust

use crate::{Gradient, Paint, PaintStyle, 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)
}
#[must_use]
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
)
}
#[must_use]
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])
}
#[must_use]
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)
}
#[must_use]
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
}
#[must_use]
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)
}
#[must_use]
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
}
#[must_use]
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
}
#[must_use]
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
}
}
fn transition_paint(from: &Paint, to: &Paint, from_solid: &str, to_solid: &str, p: f64) -> Paint {
if from.effective_gradient().is_none() && to.effective_gradient().is_none() {
return to.clone();
}
let endpoints = |paint: &Paint, solid: &str| {
paint.effective_gradient().map_or_else(
|| (solid.to_owned(), solid.to_owned()),
|gradient| (gradient.from.clone(), gradient.to.clone()),
)
};
let (a, b) = endpoints(from, from_solid);
let (c, d) = endpoints(to, to_solid);
let color = |a: &str, b: &str| {
let mut a = rgba(a).unwrap();
let mut b = rgba(b).unwrap();
for channel in 0..3 {
a[channel] *= a[3];
b[channel] *= b[3];
}
let mut c = blend(a, b, p);
if c[3] > 0. {
for channel in 0..3 {
c[channel] /= c[3];
}
}
hex(c)
};
let from_angle = from
.effective_gradient()
.or(to.effective_gradient())
.unwrap()
.angle
.rem_euclid(360.);
let to_angle = to
.effective_gradient()
.or(from.effective_gradient())
.unwrap()
.angle
.rem_euclid(360.);
let delta = (to_angle - from_angle + 180.).rem_euclid(360.) - 180.;
Paint {
style: PaintStyle::Auto,
gradient: Some(Gradient {
from: color(&a, &c),
to: color(&b, &d),
angle: (from_angle + delta * p).rem_euclid(360.),
}),
}
}
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.focus_ring = transition_paint(
&self.tokens.focus_ring,
&to.tokens.focus_ring,
&self.tokens.colors.accent,
&to.tokens.colors.accent,
p,
);
frame.tokens.border = transition_paint(
&self.tokens.border,
&to.tokens.border,
&self.tokens.colors.border,
&to.tokens.colors.border,
p,
);
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
}
}