Centralize accent foreground contrast across Ferese UI

This commit is contained in:
Samuel Onoja committed 2026-09-28 08:58:51 +01:00
1 parent 299f60e671
commit 24e15f76cb
14 files changed
+327 -64

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
[package]
name = "ferese-theme"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version = "1.95"
[dependencies]
libcosmic.workspace = true
+226
View File
@@ -0,0 +1,226 @@
//! Ferese owns foreground contrast; toolkit defaults must not choose it separately.
use cosmic::{
iced::{Background, Color},
theme,
widget::button,
};
pub fn luminance(color: Color) -> f32 {
let linear = |v: f32| {
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * linear(color.r) + 0.7152 * linear(color.g) + 0.0722 * linear(color.b)
}
pub fn contrast(a: Color, b: Color) -> f32 {
let (a, b) = (luminance(a), luminance(b));
(a.max(b) + 0.05) / (a.min(b) + 0.05)
}
/// Resolve a control fill against its surface before selecting its foreground.
/// The returned fill is opaque, so wallpaper and translucent panels cannot change
/// the contrast of a small, filled control.
pub fn composite(fill: Color, surface: Color) -> Color {
let a = fill.a.clamp(0., 1.);
Color::from_rgb(
fill.r * a + surface.r * (1. - a),
fill.g * a + surface.g * (1. - a),
fill.b * a + surface.b * (1. - a),
)
}
/// Preserve the user's preferred color when it passes normal-text contrast.
/// Otherwise black or white always provides at least 4.5:1 on an opaque sRGB fill.
pub fn foreground(background: Color, preferred: Color) -> Color {
let preferred = composite(preferred, background);
if contrast(background, preferred) >= 4.5 {
return preferred;
}
if contrast(background, Color::BLACK) >= contrast(background, Color::WHITE) {
Color::BLACK
} else {
Color::WHITE
}
}
/// Resolve configured accent transparency before passing an opaque color to a toolkit.
pub fn accent_color(accent: Color, surface: Color) -> cosmic::cosmic_theme::palette::Srgb {
rgba(composite(accent, surface)).color
}
/// Supply the same semantic on-accent colors to native toolkit controls.
pub fn apply(native: &mut cosmic::cosmic_theme::Theme, preferred: Color) {
let surface: Color = native.primary(false).base.into();
for component in [&mut native.accent, &mut native.accent_button] {
let base = composite(component.base.into(), surface);
let on = foreground(base, preferred);
component.hover = rgba(readable_fill(
composite(component.hover.into(), surface),
base,
on,
));
component.pressed = rgba(readable_fill(
composite(component.pressed.into(), surface),
base,
on,
));
component.selected = rgba(composite(component.selected.into(), surface));
component.base = rgba(base);
component.on = rgba(on);
component.selected_text = rgba(foreground(
composite(component.selected.into(), surface),
on,
));
component.disabled = rgba(composite(Color { a: 0.5, ..base }, surface));
component.on_disabled = rgba(foreground(composite(Color { a: 0.5, ..base }, surface), on));
}
}
// Native controls reuse one foreground for normal/hover/pressed. Keep their
// hover feedback, but pull an unreadable fill toward the readable normal fill.
fn readable_fill(candidate: Color, base: Color, on: Color) -> Color {
for step in 0..=20 {
let t = step as f32 / 20.;
let fill = Color::from_rgb(
candidate.r + (base.r - candidate.r) * t,
candidate.g + (base.g - candidate.g) * t,
candidate.b + (base.b - candidate.b) * t,
);
if contrast(fill, on) >= 4.5 {
return fill;
}
}
base
}
fn rgba(c: Color) -> cosmic::cosmic_theme::palette::Srgba {
cosmic::cosmic_theme::palette::Srgba::new(c.r, c.g, c.b, c.a)
}
#[derive(Clone, Copy)]
enum State {
Active,
Hovered,
Pressed,
Disabled,
}
fn style(theme: &cosmic::Theme, state: State, focused: bool) -> button::Style {
let native = theme.cosmic();
let component = &native.accent_button;
let fill: Color = match state {
State::Active => component.base.into(),
State::Hovered => component.hover.into(),
State::Pressed => component.pressed.into(),
State::Disabled => Color {
a: 0.5,
..component.base.into()
},
};
let background = composite(fill, native.primary(false).base.into());
let foreground = foreground(background, component.on.into());
button::Style {
background: Some(Background::Color(background)),
text_color: Some(foreground),
icon_color: Some(foreground),
border_radius: native.corner_radii.radius_xl.into(),
outline_width: if focused { 1. } else { 0. },
outline_color: foreground,
..Default::default()
}
}
/// Filled accent buttons and selected rows share this per-state contrast policy.
pub fn accent_button() -> theme::Button {
theme::Button::Custom {
active: Box::new(|focused, theme| style(theme, State::Active, focused)),
hovered: Box::new(|focused, theme| style(theme, State::Hovered, focused)),
pressed: Box::new(|focused, theme| style(theme, State::Pressed, focused)),
disabled: Box::new(|theme| style(theme, State::Disabled, false)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reference_contrast_values_use_linear_srgb() {
assert!((contrast(Color::BLACK, Color::WHITE) - 21.).abs() < 0.00001);
assert!((contrast(Color::from_rgb8(128, 128, 128), Color::BLACK) - 5.31721).abs() < 0.0001);
}
#[test]
fn foreground_preserves_readable_theme_text_and_rejects_low_contrast() {
let preferred = Color::from_rgb8(240, 242, 246);
assert_eq!(foreground(Color::BLACK, preferred), preferred);
assert_eq!(foreground(Color::WHITE, preferred), Color::BLACK);
assert_eq!(
foreground(Color::from_rgb8(61, 123, 230), preferred),
Color::BLACK
);
}
#[test]
fn all_accent_hues_and_opacities_have_readable_text() {
for r in (0..=255).step_by(17) {
for g in (0..=255).step_by(17) {
for b in (0..=255).step_by(17) {
for surface in [Color::BLACK, Color::WHITE] {
for alpha in [0., 0.2, 0.5, 1.] {
let fill = composite(Color::from_rgba8(r, g, b, alpha), surface);
let on = foreground(fill, Color::from_rgb8(120, 130, 140));
assert!(contrast(fill, on) >= 4.5, "{fill:?}: {on:?}");
}
}
}
}
}
}
#[test]
fn native_button_states_keep_text_and_icons_readable() {
for accent in [
Color::BLACK,
Color::WHITE,
Color::from_rgb8(61, 123, 230),
Color::from_rgb8(255, 220, 0),
] {
for mut native in [
cosmic::cosmic_theme::ThemeBuilder::dark()
.accent(rgba(accent).color)
.build(),
cosmic::cosmic_theme::ThemeBuilder::light()
.accent(rgba(accent).color)
.build(),
] {
apply(&mut native, Color::from_rgb8(244, 247, 251));
for component in [&native.accent, &native.accent_button] {
for fill in [component.base, component.hover, component.pressed] {
assert!(contrast(fill.into(), component.on.into()) >= 4.5);
}
assert!(
contrast(component.selected.into(), component.selected_text.into()) >= 4.5
);
assert!(
contrast(component.disabled.into(), component.on_disabled.into()) >= 4.5
);
}
let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
for state in [
State::Active,
State::Hovered,
State::Pressed,
State::Disabled,
] {
let style = style(&theme, state, false);
let Some(Background::Color(fill)) = style.background else {
panic!("No fill")
};
assert_eq!(style.text_color, style.icon_color);
assert!(contrast(fill, style.text_color.unwrap()) >= 4.5);
}
}
}
}
}