Skip to main content

egui_macview/theme/
contrast.rs

1//! macOS AppKit Dynamic Contrast & Background Style Engine.
2//!
3//! Grounded in W3C WCAG 2.1 ยง1.4.3 (Level AA) and Apple AppKit `NSBackgroundStyle`.
4//! Automatically resolves foreground typography tints against dynamic or custom
5//! background fills (such as custom accent colors) to guarantee >= 4.5:1 contrast.
6
7use crate::theme::MacTheme;
8use egui::Color32;
9
10/// Converts an 8-bit sRGB channel component in `[0..255]` to linear luminance in `[0.0..1.0]`.
11pub fn srgb_channel_to_linear(c: u8) -> f32 {
12    let s = c as f32 / 255.0;
13    if s <= 0.04045 {
14        s / 12.92
15    } else {
16        ((s + 0.055) / 1.055).powf(2.4)
17    }
18}
19
20/// Computes the relative luminance of an sRGB color according to WCAG 2.1:
21/// `L = 0.2126 * R_lin + 0.7152 * G_lin + 0.0722 * B_lin`.
22pub fn relative_luminance(color: Color32) -> f32 {
23    let r = srgb_channel_to_linear(color.r());
24    let g = srgb_channel_to_linear(color.g());
25    let b = srgb_channel_to_linear(color.b());
26    0.2126 * r + 0.7152 * g + 0.0722 * b
27}
28
29/// Computes the WCAG 2.1 contrast ratio between foreground and background colors.
30/// If foreground has alpha < 255, it is alpha-blended over the background first.
31pub fn contrast_ratio(fg: Color32, bg: Color32) -> f32 {
32    let alpha = fg.a() as f32 / 255.0;
33    let blended_r = ((fg.r() as f32 * alpha) + (bg.r() as f32 * (1.0 - alpha))).round() as u8;
34    let blended_g = ((fg.g() as f32 * alpha) + (bg.g() as f32 * (1.0 - alpha))).round() as u8;
35    let blended_b = ((fg.b() as f32 * alpha) + (bg.b() as f32 * (1.0 - alpha))).round() as u8;
36    let effective_fg = Color32::from_rgb(blended_r, blended_g, blended_b);
37
38    let l1 = relative_luminance(effective_fg);
39    let l2 = relative_luminance(bg);
40
41    let lighter = l1.max(l2);
42    let darker = l1.min(l2);
43
44    (lighter + 0.05) / (darker + 0.05)
45}
46
47/// macOS AppKit background style (`NSBackgroundStyle`).
48///
49/// Indicates whether an element's background is light or dark, allowing child
50/// text, icons, and accessories to select high-contrast tints that satisfy WCAG 2.1 AA.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum MacBackgroundStyle {
53    /// Background is light (relative luminance `L > 0.179`). Use dark foreground text.
54    #[default]
55    Light,
56    /// Background is dark (relative luminance `L <= 0.179`). Use light/white foreground text.
57    Dark,
58}
59
60impl MacBackgroundStyle {
61    /// Resolves `MacBackgroundStyle` from a background color's WCAG 2.1 relative luminance.
62    ///
63    /// Applies the normative WCAG 2.1 AA threshold `L = 0.179`:
64    /// - `L <= 0.179` => `Dark` style (white text guarantees >= 4.5:1 contrast)
65    /// - `L > 0.179` => `Light` style (dark text guarantees >= 4.5:1 contrast)
66    pub fn from_color(bg: Color32) -> Self {
67        let lum = relative_luminance(bg);
68        if lum <= 0.179 {
69            Self::Dark
70        } else {
71            Self::Light
72        }
73    }
74
75    /// Primary text color guaranteeing >= 4.5:1 WCAG contrast against this background.
76    ///
77    /// - `Dark` => 100% white (`#FFFFFF`)
78    /// - `Light` => dark primary label color from Aqua tokens (`#262626`)
79    pub fn primary_text_color(&self, theme: &MacTheme) -> Color32 {
80        match self {
81            Self::Dark => Color32::from_rgb(255, 255, 255),
82            Self::Light => theme.tokens().colors().aqua.label.to_color32(),
83        }
84    }
85
86    /// Secondary text color (subtitles, captions, metadata).
87    ///
88    /// - `Dark` => 85% white (`rgba(255, 255, 255, 0.85)`)
89    /// - `Light` => dark secondary label color from Aqua tokens (`#6E6E73`)
90    pub fn secondary_text_color(&self, theme: &MacTheme) -> Color32 {
91        match self {
92            Self::Dark => Color32::from_white_alpha(217),
93            Self::Light => theme.tokens().colors().aqua.secondary_label.to_color32(),
94        }
95    }
96
97    /// Tertiary text color (placeholders, disabled hints).
98    ///
99    /// - `Dark` => 70% white (`rgba(255, 255, 255, 0.70)`)
100    /// - `Light` => dark placeholder text color from Aqua tokens
101    pub fn tertiary_text_color(&self, theme: &MacTheme) -> Color32 {
102        match self {
103            Self::Dark => Color32::from_white_alpha(179),
104            Self::Light => theme.tokens().colors().aqua.placeholder_text.to_color32(),
105        }
106    }
107
108    /// Standalone primary text color without requiring a `MacTheme` reference.
109    pub fn primary_text(&self) -> Color32 {
110        match self {
111            Self::Dark => Color32::from_rgb(255, 255, 255),
112            Self::Light => Color32::from_rgb(0, 0, 0),
113        }
114    }
115
116    /// Standalone secondary text color without requiring a `MacTheme` reference.
117    pub fn secondary_text(&self) -> Color32 {
118        match self {
119            Self::Dark => Color32::from_white_alpha(217),
120            Self::Light => Color32::from_black_alpha(166),
121        }
122    }
123
124    /// Standalone tertiary text color without requiring a `MacTheme` reference.
125    pub fn tertiary_text(&self) -> Color32 {
126        match self {
127            Self::Dark => Color32::from_white_alpha(179),
128            Self::Light => Color32::from_black_alpha(115),
129        }
130    }
131}