Derive theme gradients and add solid paint controls
This commit is contained in:
1 parent
20395007a5
commit
831ac3a77f
18 files changed
+604
-20
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user