use cosmic::iced::Color; 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 } } /// Prefer the theme's foreground for filled controls by adjusting the accent's /// brightness until the text has enough contrast. pub fn accent_pair(background: Color, preferred: Color) -> (Color, Color) { let on = composite(preferred, background); let shade = if luminance(on) > luminance(background) { Color::BLACK } else { Color::WHITE }; for step in 0..=65 { let fill = composite( Color { a: step as f32 / 100., ..shade }, background, ); if contrast(fill, on) >= 4.5 { return (fill, on); } } (background, foreground(background, preferred)) } /// 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, on) = accent_pair(composite(component.base.into(), surface), 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(readable_fill( composite(component.selected.into(), surface), base, on, )); 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) } #[cfg(test)] mod tests { use super::*; use crate::button::{State, accent_button_style}; use cosmic::iced::Background; #[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 (fill, on) = accent_pair(fill, Color::from_rgb8(120, 130, 140)); assert!(contrast(fill, on) >= 4.5, "{fill:?}: {on:?}"); } } } } } } #[test] fn blue_accent_preserves_light_theme_text_in_every_state() { let preferred = Color::from_rgb8(244, 247, 251); let mut native = cosmic::cosmic_theme::ThemeBuilder::dark() .accent(rgba(Color::from_rgb8(61, 123, 230)).color) .build(); apply(&mut native, preferred); let theme = cosmic::Theme::custom(std::sync::Arc::new(native)); for state in [ State::Active, State::Hovered, State::Pressed, State::Disabled, ] { let actual = accent_button_style(&theme, state, false); assert_eq!(actual.text_color, Some(preferred)); assert_eq!(actual.icon_color, Some(preferred)); } } #[test] fn gruvbox_orange_button_keeps_its_light_theme_text_when_enabled() { let preferred = Color::from_rgb8(235, 219, 178); let accent = Color::from_rgb8(254, 128, 25); let mut native = cosmic::cosmic_theme::ThemeBuilder::dark() .accent(rgba(accent).color) .build(); apply(&mut native, preferred); let theme = cosmic::Theme::custom(std::sync::Arc::new(native)); for state in [State::Active, State::Hovered, State::Pressed] { let actual = accent_button_style(&theme, state, false); assert_eq!(actual.text_color, Some(preferred)); let Some(Background::Color(fill)) = actual.background else { panic!("No button fill") }; assert!(contrast(fill, preferred) >= 4.5); } } #[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 = accent_button_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); } } } } }