311 lines
12 KiB
Rust
311 lines
12 KiB
Rust
use cosmic::iced::border::Shape as BorderShape;
|
|
use cosmic::iced::{Background, Color, gradient::Linear};
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct Palette {
|
|
pub appearance: ferese_theme_model::Appearance,
|
|
pub high_contrast: bool,
|
|
pub background: Color,
|
|
pub sidebar: Color,
|
|
pub card: Color,
|
|
pub accent: Color,
|
|
pub accent_gradient: Option<Linear>,
|
|
pub on_accent: Color,
|
|
pub text: Color,
|
|
pub muted: Color,
|
|
pub error: Color,
|
|
pub radius: f32,
|
|
}
|
|
|
|
impl Palette {
|
|
pub fn from_resolved(theme: &ferese_theme_model::ResolvedTheme) -> Self {
|
|
let color = |value: &str| parse_color(value).expect("validated resolved theme color");
|
|
Self {
|
|
appearance: theme.appearance,
|
|
high_contrast: theme.accessibility.increase_contrast,
|
|
background: color(&theme.tokens.colors.application_background),
|
|
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),
|
|
error: mix(
|
|
color(&theme.tokens.colors.surface_base),
|
|
Color::from_rgb8(210, 80, 80),
|
|
0.2,
|
|
),
|
|
radius: theme.tokens.geometry.shell_radius as f32,
|
|
}
|
|
}
|
|
|
|
pub fn flat(mut self) -> Self {
|
|
self.background = self.sidebar;
|
|
self.card = self.sidebar;
|
|
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;
|
|
use ferese_theme_model::Appearance;
|
|
let builder = match (self.appearance, self.high_contrast) {
|
|
(Appearance::Light, false) => ThemeBuilder::light(),
|
|
(Appearance::Dark, false) => ThemeBuilder::dark(),
|
|
(Appearance::Light, true) => ThemeBuilder::light_high_contrast(),
|
|
(Appearance::Dark, true) => ThemeBuilder::dark_high_contrast(),
|
|
};
|
|
let corners = cosmic::cosmic_theme::CornerRadii {
|
|
radius_xs: [self.radius.min(4.); 4],
|
|
radius_s: [self.radius.min(8.); 4],
|
|
radius_m: [self.radius; 4],
|
|
radius_l: [self.radius; 4],
|
|
radius_xl: [self.radius; 4],
|
|
radius_0: Default::default(),
|
|
};
|
|
let mut native = builder
|
|
.corner_radii(corners)
|
|
.bg_color(rgba(self.background))
|
|
.primary_container_bg(rgba(self.card))
|
|
.text_tint(rgba(self.text).color)
|
|
.accent(rgba(self.accent).color)
|
|
.build();
|
|
|
|
crate::apply(&mut native, self.on_accent);
|
|
cosmic::Theme::custom(std::sync::Arc::new(native)).corner_shape(BorderShape::Continuous)
|
|
}
|
|
|
|
pub fn application_style(self, background: Color) -> cosmic::iced::theme::Style {
|
|
cosmic::iced::theme::Style {
|
|
background_color: background,
|
|
text_color: self.text,
|
|
icon_color: self.text,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn parse_color(value: &str) -> Option<Color> {
|
|
let hex = value.strip_prefix('#')?;
|
|
let packed = u32::from_str_radix(hex, 16).ok()?;
|
|
let rgba = match hex.len() {
|
|
6 => (packed << 8) | 255,
|
|
8 => packed,
|
|
_ => return None,
|
|
};
|
|
Some(Color::from_rgba8(
|
|
(rgba >> 24) as u8,
|
|
(rgba >> 16) as u8,
|
|
(rgba >> 8) as u8,
|
|
(rgba & 255) as f32 / 255.,
|
|
))
|
|
}
|
|
|
|
pub fn mix(a: Color, b: Color, t: f32) -> Color {
|
|
Color::from_rgb(a.r + (b.r - a.r) * t, a.g + (b.g - a.g) * t, a.b + (b.b - a.b) * t)
|
|
}
|
|
|
|
pub fn surface_shade(base: Color) -> Color {
|
|
if crate::luminance(base) > 0.5 {
|
|
Color::BLACK
|
|
} else {
|
|
Color::WHITE
|
|
}
|
|
}
|
|
|
|
pub fn material_opacity(theme: &ferese_theme_model::ResolvedTheme) -> f32 {
|
|
if theme.tokens.material.style == "translucent" {
|
|
theme.tokens.material.opacity as f32
|
|
} else {
|
|
1.
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn native_theme_preserves_the_resolved_accessibility_mode() {
|
|
for appearance in [
|
|
ferese_theme_model::Appearance::Light,
|
|
ferese_theme_model::Appearance::Dark,
|
|
] {
|
|
for high_contrast in [false, true] {
|
|
let mut resolved = ferese_theme_model::ResolvedTheme {
|
|
appearance,
|
|
..ferese_config::theme::default_theme()
|
|
};
|
|
resolved.accessibility.increase_contrast = high_contrast;
|
|
let palette = Palette::from_resolved(&resolved);
|
|
assert_eq!(palette.high_contrast, high_contrast);
|
|
let native = palette.native_theme();
|
|
assert_eq!(native.cosmic().is_high_contrast, high_contrast);
|
|
assert_eq!(native.corner_shape, BorderShape::Continuous);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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 {
|
|
let mut resolved = ferese_theme_model::ResolvedTheme {
|
|
appearance: preset.appearance,
|
|
tokens: ferese_config::theme::preset(preset.id, preset.appearance).unwrap(),
|
|
..ferese_config::theme::default_theme()
|
|
};
|
|
resolved.tokens.material.style = "translucent".into();
|
|
resolved.tokens.material.opacity = 0.25;
|
|
resolved.tokens.geometry.shell_radius = 0.;
|
|
let palette = Palette::from_resolved(&resolved);
|
|
let native = palette.native_theme();
|
|
assert_eq!(crate::material_opacity(&resolved), 0.25);
|
|
assert_eq!(native.cosmic().primary(false).base.alpha, 1.);
|
|
assert_eq!(native.cosmic().corner_radii.radius_m, [0.; 4]);
|
|
for component in [&native.cosmic().accent, &native.cosmic().accent_button] {
|
|
for fill in [component.base, component.hover, component.pressed] {
|
|
assert!(
|
|
crate::contrast(fill.into(), component.on.into()) >= 4.5,
|
|
"{}",
|
|
preset.name
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn solid_materials_ignore_opacity_and_translucent_materials_bound_it() {
|
|
for (style, opacity, expected) in [
|
|
("solid", 0.4, 1.),
|
|
("translucent", 0.4, 0.4),
|
|
("translucent", 1., 1.),
|
|
("translucent", 0., 0.),
|
|
] {
|
|
let mut theme = ferese_config::theme::default_theme();
|
|
theme.tokens.material.style = style.into();
|
|
theme.tokens.material.opacity = opacity;
|
|
assert_eq!(crate::material_opacity(&theme), expected);
|
|
}
|
|
}
|
|
}
|