Derive theme gradients and add solid paint controls

This commit is contained in:
Samuel Onoja committed 2026-10-04 09:03:28 +01:00
1 parent 20395007a5
commit 831ac3a77f
18 files changed
+604 -20

No files matched your search

+212 -6
View File
@@ -213,16 +213,19 @@ pub fn default_tokens() -> Tokens {
"background":{"path":crate::default_wallpaper(),"lock_path":null,"mode":"fill"},
"surface":{"bar":{"background":"#111821","text_primary":"#F4F7FB","text_muted":"#8793A2"}},
"shadow":{"soft":{"offset_y":4.0,"blur":18.0,"opacity":0.2}},
"border":{"gradient":null},"focus_ring":{"gradient":null}
"border":{"style":"auto","gradient":null},"focus_ring":{"style":"auto","gradient":null}
}))
.expect("valid built-in theme")
}
pub fn default_theme() -> ResolvedTheme {
let mut tokens = default_tokens();
resolve_gradients(&mut tokens, None);
ResolvedTheme {
appearance: Appearance::Dark,
requested_accent: "#3D7BE6".into(),
tokens: default_tokens(),
tokens,
accessibility: Accessibility::default(),
reduced_motion: false,
}
@@ -323,6 +326,7 @@ pub fn resolve_with_context(
pair.1.clone()
}
});
let custom_variant = variant.is_some();
let mut tokens = if let Some(tokens) = variant {
tokens
} else if let Some(id) = crate::families::variant(family_id, kind) {
@@ -366,6 +370,19 @@ pub fn resolve_with_context(
}
let authored_base = tokens.colors.surface_base.clone();
let authored_accent = tokens.colors.accent.clone();
let preset_end = (!custom_variant)
.then(|| {
let id = crate::families::variant(family_id, kind).unwrap_or(if kind == Appearance::Light {
"ferese-blue-light"
} else {
"ferese-blue"
});
crate::presets::PRESETS.iter().find(|preset| preset.id == id)
})
.flatten()
.map(|preset| preset.gradient_end)
.filter(|end| *end != authored_accent);
let mut explicit_surfaces = std::collections::HashSet::new();
if root
.get("geometry")
@@ -378,7 +395,7 @@ pub fn resolve_with_context(
{
tokens.geometry.shell_radius = radius;
}
let mut value = serde_json::to_value(&tokens).map_err(|e| e.to_string())?;
let mut value = token_value(&tokens)?;
for file in [&policy.file, &selection.file].into_iter().flatten() {
let path = theme_path(directory, file);
if !all_files.contains(&path) {
@@ -406,7 +423,7 @@ pub fn resolve_with_context(
merge(&mut value, &root, "", &mut warnings, true)?;
let mut overrides = selection.overrides.clone();
if !split && policy.family.is_some() {
for key in ["colors", "surface", "border", "focus_ring"] {
for key in ["colors", "surface"] {
overrides.remove(key);
}
}
@@ -443,6 +460,7 @@ pub fn resolve_with_context(
validate(&tokens)?;
let requested_accent = tokens.colors.accent.clone();
transform(&mut tokens, &policy.accessibility, &mut warnings);
resolve_gradients(&mut tokens, preset_end.filter(|_| requested_accent == authored_accent));
let theme = ResolvedTheme {
appearance: kind,
tokens,
@@ -489,6 +507,15 @@ pub fn theme_path(directory: &Path, path: &Path) -> PathBuf {
path
}
fn token_value(tokens: &Tokens) -> Result<Value, String> {
let mut value = serde_json::to_value(tokens).map_err(|error| error.to_string())?;
// Keep authoring defaults available for merging. The wire format omits
// automatic styles so older snapshots still round-trip unchanged.
value["focus_ring"]["style"] = serde_json::to_value(tokens.focus_ring.style).unwrap();
value["border"]["style"] = serde_json::to_value(tokens.border.style).unwrap();
Ok(value)
}
fn merge(
target: &mut Value,
layer: &Value,
@@ -664,7 +691,7 @@ fn validate(tokens: &Tokens) -> Result<(), String> {
}
Value::Number(n) => {
let n = n.as_f64().ok_or("invalid theme number")?;
if !n.is_finite() || (path != ".shadow.soft.offset_y" && n < 0.) {
if !n.is_finite() || (path != ".shadow.soft.offset_y" && !path.ends_with(".angle") && n < 0.) {
return Err(format!("Invalid theme value {path}"));
}
if (path.ends_with("opacity") || path.ends_with("tint_strength")) && n > 1. {
@@ -776,6 +803,51 @@ fn transform(t: &mut Tokens, accessibility: &Accessibility, warnings: &mut Vec<S
}
}
fn resolve_gradients(tokens: &mut Tokens, preset_end: Option<&str>) {
let shade = |color: &str| {
let source = rgba(color).expect("validated paint color");
let endpoint = if luminance(source) > 0.4 {
[0., 0., 0., source[3]]
} else {
[1., 1., 1., source[3]]
};
hex(blend(source, endpoint, 0.18))
};
let backgrounds = [
rgba(&tokens.colors.surface_base).unwrap(),
rgba(&tokens.colors.surface_raised).unwrap(),
rgba(&tokens.colors.application_background).unwrap(),
rgba(&tokens.surface.bar.background).unwrap(),
];
for (paint, color, endpoint, focus) in [
(&mut tokens.focus_ring, &tokens.colors.accent, preset_end, true),
(&mut tokens.border, &tokens.colors.border, None, false),
] {
if paint.style == PaintStyle::Solid {
paint.gradient = None;
continue;
}
if let Some(gradient) = &mut paint.gradient {
gradient.angle = gradient.angle.rem_euclid(360.);
continue;
}
let to = endpoint.map_or_else(|| shade(color), str::to_owned);
let to = if focus {
hex(readable_across(rgba(&to).unwrap(), &backgrounds, 3.))
} else {
to
};
paint.gradient = Some(Gradient {
from: color.clone(),
to,
angle: 135.,
});
}
}
fn resolve_material_contrast(tokens: &mut Tokens, minimum: f64) {
if tokens.material.style == "solid" {
return;
@@ -835,7 +907,7 @@ pub fn import_family(id: &str, source: &str) -> Result<ImportedFamily, String> {
},
appearance,
)?;
let mut value = serde_json::to_value(base).map_err(|e| e.to_string())?;
let mut value = token_value(&base)?;
merge(&mut value, layer, "", &mut warnings, false)?;
let tokens: Tokens = serde_json::from_value(value).map_err(|e| e.to_string())?;
validate(&tokens)?;
@@ -890,6 +962,140 @@ mod tests {
)
}
#[test]
fn automatic_gradients_follow_presets_and_preserve_border_alpha() {
for preset in crate::presets::PRESETS {
let mode = if preset.appearance == Appearance::Light {
"light"
} else {
"dark"
};
let theme = candidate(&format!(
"theme {{ mode {mode}; family {}; }}",
crate::families::family_id(preset.id)
))
.unwrap()
.theme;
let gradient = theme.tokens.focus_ring.gradient.as_ref().unwrap();
assert_eq!(gradient.from, theme.tokens.colors.accent);
if preset.gradient_end != preset.accent {
let backgrounds = [
rgba(&theme.tokens.colors.surface_base).unwrap(),
rgba(&theme.tokens.colors.surface_raised).unwrap(),
rgba(&theme.tokens.colors.application_background).unwrap(),
rgba(&theme.tokens.surface.bar.background).unwrap(),
];
assert_eq!(
gradient.to,
hex(readable_across(rgba(preset.gradient_end).unwrap(), &backgrounds, 3.))
);
assert!(
backgrounds
.iter()
.all(|background| contrast(rgba(&gradient.to).unwrap(), *background) >= 3.)
);
} else {
assert_ne!(rgba(&gradient.from).unwrap(), rgba(&gradient.to).unwrap());
}
let border = theme.tokens.border.gradient.as_ref().unwrap();
let alpha = rgba(&theme.tokens.colors.border).unwrap()[3];
assert_eq!(rgba(&border.from).unwrap()[3], alpha);
assert_eq!(rgba(&border.to).unwrap()[3], alpha);
}
}
#[test]
fn accent_overrides_and_imported_themes_do_not_keep_old_preset_endpoints() {
let theme = candidate("theme { family catppuccin; accent \"#E17B38\"; }")
.unwrap()
.theme;
let gradient = theme.tokens.focus_ring.gradient.as_ref().unwrap();
assert_eq!(gradient.from, theme.tokens.colors.accent);
assert_ne!(gradient.to, "#B4BEFE");
let from = rgba(&gradient.from).unwrap();
let to = rgba(&gradient.to).unwrap();
assert!(from[0] > from[1] && from[1] > from[2]);
assert!(to[0] > to[1] && to[1] > to[2]);
let document =
Document::parse("theme { family custom; custom-themes { custom { file \"custom.kdl\"; }; }; }").unwrap();
let theme = resolve(
&document,
Path::new("/config"),
"2026-09-30T12:00:00Z".parse().unwrap(),
|_| Ok("theme { dark { colors { accent \"#E17B38\"; }; }; }".into()),
)
.unwrap()
.theme;
let gradient = theme.tokens.focus_ring.gradient.as_ref().unwrap();
assert_eq!(gradient.from, theme.tokens.colors.accent);
assert_eq!(
gradient.to,
candidate("theme { accent \"#E17B38\"; }")
.unwrap()
.theme
.tokens
.focus_ring
.gradient
.unwrap()
.to
);
}
#[test]
fn explicit_paints_win_with_paired_families_and_solid_can_disable_them() {
let source = r##"theme {
family catppuccin
border { style solid; }
dark { focus-ring { gradient { from "#123456"; to "#ABCDEF80"; angle -45; }; }; }
}"##;
let theme = candidate(source).unwrap().theme;
let gradient = theme.tokens.focus_ring.gradient.unwrap();
assert_eq!(
(gradient.from.as_str(), gradient.to.as_str(), gradient.angle),
("#123456", "#ABCDEF80", 315.)
);
assert!(theme.tokens.border.gradient.is_none());
let source = source.replace("family catppuccin", "family catppuccin\nfocus-ring { style solid; }");
let theme = candidate(&source).unwrap().theme;
assert!(theme.tokens.focus_ring.gradient.is_none());
assert_eq!(theme.tokens.focus_ring.style, PaintStyle::Solid);
assert!(candidate("theme { focus-ring { style rainbow; }; }").is_err());
assert!(candidate("theme { focus-ring { gradient { from \"bad\"; to \"#FFFFFF\"; }; }; }").is_err());
}
#[test]
fn gradients_interpolate_stops_alpha_angles_and_solid_transitions() {
let mut from = default_theme();
let mut to = from.clone();
from.tokens.focus_ring.gradient = Some(Gradient {
from: "#FF000000".into(),
to: "#112233".into(),
angle: 350.,
});
to.tokens.focus_ring.gradient = Some(Gradient {
from: "#0000FFFF".into(),
to: "#ABCDEF".into(),
angle: 10.,
});
let frame = from.transition(&to, 0.5);
let gradient = frame.tokens.focus_ring.gradient.unwrap();
assert_eq!(gradient.from, "#0000FF80");
assert!(gradient.angle.abs() < 0.001);
assert_eq!(from.transition(&to, 0.), from);
assert_eq!(from.transition(&to, 1.), to);
to.tokens.focus_ring.style = PaintStyle::Solid;
to.tokens.focus_ring.gradient = None;
let middle = from.transition(&to, 0.5);
assert_eq!(middle.tokens.focus_ring.style, PaintStyle::Auto);
assert!(middle.tokens.focus_ring.gradient.is_some());
assert_eq!(from.transition(&to, 1.), to);
assert!(to.transition(&from, 0.5).tokens.focus_ring.gradient.is_some());
}
#[test]
fn every_bundled_pair_has_authored_materials_and_readable_tokens() {
use crate::presets::PRESETS;
+25 -1
View File
@@ -7,16 +7,40 @@ fn fixture() -> Value {
}
#[test]
fn default_snapshot_preserves_wire_values_and_environment_wallpaper() {
fn default_snapshot_resolves_gradients_and_preserves_other_wire_values() {
let mut snapshot = fallback();
let wallpaper = Some(ferese_config::default_wallpaper().into());
assert_eq!(snapshot.theme.tokens.background.path, wallpaper);
assert_eq!(snapshot.presented.tokens.background.path, wallpaper);
snapshot.theme.tokens.background.path = Some("<wallpaper>".into());
snapshot.presented.tokens.background.path = Some("<wallpaper>".into());
for theme in [&mut snapshot.theme, &mut snapshot.presented] {
let focus = theme.tokens.focus_ring.gradient.take().unwrap();
assert_eq!(focus.from, theme.tokens.colors.accent);
assert_ne!(focus.from, focus.to);
assert_eq!(focus.angle, 135.);
let border = theme.tokens.border.gradient.take().unwrap();
assert_eq!(border.from, theme.tokens.colors.border);
assert_eq!(
ferese_config::theme::rgba(&border.from).unwrap()[3],
ferese_config::theme::rgba(&border.to).unwrap()[3],
);
}
assert_eq!(serde_json::to_value(snapshot).unwrap(), fixture());
}
#[test]
fn explicit_solid_style_round_trips_without_resolving_published_values() {
let mut value = fixture();
for key in ["theme", "presented"] {
value[key]["tokens"]["focus_ring"]["style"] = json!("solid");
}
let snapshot = Snapshot::decode(value.clone(), || panic!("unexpected catalog resolution")).unwrap();
assert_eq!(serde_json::to_value(snapshot).unwrap(), value);
}
#[test]
fn older_snapshots_supply_catalog_but_explicit_empty_catalog_stays_empty() {
let mut value = fixture();
+68 -1
View File
@@ -1,4 +1,4 @@
use crate::ResolvedTheme;
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}"))?;
@@ -123,6 +123,59 @@ fn material_foreground(preferred: [f64; 4], backgrounds: [[f64; 4]; 2]) -> [f64;
}
}
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 {
@@ -145,6 +198,20 @@ impl ResolvedTheme {
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 =
+26
View File
@@ -107,11 +107,37 @@ pub struct Gradient {
pub angle: f64,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PaintStyle {
#[default]
Auto,
Solid,
}
impl PaintStyle {
fn is_auto(&self) -> bool {
*self == Self::Auto
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
pub struct Paint {
#[serde(default, skip_serializing_if = "PaintStyle::is_auto")]
pub style: PaintStyle,
pub gradient: Option<Gradient>,
}
impl Paint {
pub fn effective_gradient(&self) -> Option<&Gradient> {
if self.style == PaintStyle::Solid {
None
} else {
self.gradient.as_ref()
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Tokens {
pub colors: Colors,
+8 -1
View File
@@ -176,10 +176,16 @@ fn styled_button(p: Palette, selected: bool, navigation: bool, opacity: f32, foc
opacity
};
let fill = if selected {
p.selected_background(if hover { 0.24 } else { 0.17 }, background.a, on, background)
} else {
Background::Color(background)
};
button::Style {
shape: Some(BorderShape::Continuous),
outline: None,
background: Some(Background::Color(background)),
background: Some(fill),
text_color: Some(on),
icon_color: Some(on),
border_radius: p.radius.min(9.).into(),
@@ -445,6 +451,7 @@ mod tests {
sidebar: Color::from_rgb8(17, 24, 33),
card: Color::from_rgb8(17, 24, 33),
accent: Color::from_rgb8(61, 123, 230),
accent_gradient: None,
on_accent: Color::from_rgb8(17, 24, 33),
text: Color::from_rgb8(244, 247, 251),
muted: Color::from_rgb8(135, 147, 162),
+122 -1
View File
@@ -1,5 +1,5 @@
use cosmic::iced::Color;
use cosmic::iced::border::Shape as BorderShape;
use cosmic::iced::{Background, Color, gradient::Linear};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Palette {
@@ -9,6 +9,7 @@ pub struct Palette {
pub sidebar: Color,
pub card: Color,
pub accent: Color,
pub accent_gradient: Option<Linear>,
pub on_accent: Color,
pub text: Color,
pub muted: Color,
@@ -26,6 +27,12 @@ impl Palette {
sidebar: color(&theme.tokens.colors.surface_base),
card: color(&theme.tokens.colors.surface_raised),
accent: color(&theme.tokens.colors.accent),
accent_gradient: theme.tokens.focus_ring.effective_gradient().map(|gradient| {
// Iced's zero angle points up; theme angles start left-to-right.
Linear::new((gradient.angle.rem_euclid(360.) as f32 + 90.).to_radians())
.add_stop(0., color(&gradient.from))
.add_stop(1., color(&gradient.to))
}),
on_accent: color(&theme.tokens.colors.on_accent),
text: color(&theme.tokens.colors.text_primary),
muted: color(&theme.tokens.colors.text_muted),
@@ -44,6 +51,52 @@ impl Palette {
self
}
pub(crate) fn selected_background(
self,
strength: f32,
opacity: f32,
foreground: Color,
fallback: Color,
) -> Background {
let Some(mut gradient) = self.accent_gradient else {
return Background::Color(fallback);
};
for stop in gradient.stops.iter_mut().flatten() {
stop.color = mix(self.sidebar, crate::composite(stop.color, self.sidebar), strength);
}
// Bound every interpolated color, rather than sampling a few points.
// Adjust both stops together so one foreground stays readable.
let minimum = if self.high_contrast { 7. } else { 4.5 };
let shade = if crate::contrast(Color::BLACK, foreground) > crate::contrast(Color::WHITE, foreground) {
Color::BLACK
} else {
Color::WHITE
};
for step in 0..=20 {
let mut candidate = gradient;
for stop in candidate.stops.iter_mut().flatten() {
stop.color = mix(stop.color, shade, step as f32 / 20.);
stop.color.a = opacity;
}
let from = crate::composite(candidate.stops[0].unwrap().color, self.sidebar);
let to = crate::composite(candidate.stops[1].unwrap().color, self.sidebar);
let lower = Color::from_rgb(from.r.min(to.r), from.g.min(to.g), from.b.min(to.b));
let upper = Color::from_rgb(from.r.max(to.r), from.g.max(to.g), from.b.max(to.b));
let lightness = crate::luminance(foreground);
if (lightness < crate::luminance(lower) || lightness > crate::luminance(upper))
&& crate::contrast(lower, foreground) >= minimum
&& crate::contrast(upper, foreground) >= minimum
{
return candidate.into();
}
}
Background::Color(fallback)
}
pub fn native_theme(self) -> cosmic::Theme {
let rgba = |c: Color| cosmic::cosmic_theme::palette::Srgba::new(c.r, c.g, c.b, 1.);
use cosmic::cosmic_theme::ThemeBuilder;
@@ -144,6 +197,74 @@ mod tests {
}
}
#[test]
fn selected_gradients_preserve_material_alpha_and_text_contrast() {
use cosmic::widget::button::Catalog;
for preset in ferese_config::presets::PRESETS {
let mode = if preset.appearance == ferese_theme_model::Appearance::Light {
"light"
} else {
"dark"
};
let document = ferese_config::Document::parse(&format!(
"theme {{ mode {mode}; family {}; }}",
ferese_config::families::family_id(preset.id)
))
.unwrap();
let resolved = ferese_config::theme::resolve(
&document,
std::path::Path::new("/config"),
"2026-09-30T12:00:00Z".parse().unwrap(),
|_| unreachable!(),
)
.unwrap()
.theme;
let palette = Palette::from_resolved(&resolved);
let native = palette.native_theme();
for opacity in [1., 0.5] {
let class = crate::controls::material_button_style(palette, true, opacity);
for style in [native.active(false, true, &class), native.hovered(false, true, &class)] {
let Some(Background::Gradient(cosmic::iced::Gradient::Linear(gradient))) = style.background else {
panic!("{} selected control lost its gradient", preset.id);
};
let from = gradient.stops[0].unwrap().color;
let to = gradient.stops[1].unwrap().color;
assert_eq!(from.a, to.a);
assert!(if opacity == 1. { from.a == 1. } else { from.a < 1. });
for step in 0..=32 {
let fill = crate::composite(
Color {
a: from.a,
..mix(from, to, step as f32 / 32.)
},
palette.sidebar,
);
assert!(crate::contrast(fill, style.text_color.unwrap()) >= 4.5, "{}", preset.id);
}
}
}
let class = crate::controls::button_style(palette, false);
assert!(matches!(
native.active(false, false, &class).background,
Some(Background::Color(_))
));
}
}
#[test]
fn gradient_angles_and_solid_paints_reach_the_control_palette() {
let mut resolved = ferese_config::theme::default_theme();
resolved.tokens.focus_ring.gradient.as_mut().unwrap().angle = 0.;
let palette = Palette::from_resolved(&resolved);
assert!((palette.accent_gradient.unwrap().angle.0 - std::f32::consts::FRAC_PI_2).abs() < 0.001);
resolved.tokens.focus_ring.style = ferese_theme_model::PaintStyle::Solid;
assert!(Palette::from_resolved(&resolved).accent_gradient.is_none());
resolved.tokens.focus_ring.style = ferese_theme_model::PaintStyle::Auto;
resolved.tokens.focus_ring.gradient = None;
assert!(Palette::from_resolved(&resolved).accent_gradient.is_none());
}
#[test]
fn every_preset_builds_an_opaque_control_palette_over_translucent_materials() {
for preset in ferese_config::presets::PRESETS {