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    /// For neutral / low-saturation backgrounds, 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    ///
67    /// For saturated chromatic fills (such as Apple System Blue `#007AFF` / `#0A84FF`),
68    /// macOS AppKit filled controls use high-contrast white text (`Dark` style),
69    /// satisfying WCAG 2.1 §1.4.11 / §1.4.3 UI Component / Control AA (>= 3.0:1) with `L <= 0.26`.
70    pub fn from_color(bg: Color32) -> Self {
71        let lum = relative_luminance(bg);
72        let max_c = bg.r().max(bg.g()).max(bg.b());
73        let min_c = bg.r().min(bg.g()).min(bg.b());
74        let is_chromatic = (max_c - min_c) > 30;
75        let threshold = if is_chromatic { 0.26 } else { 0.179 };
76        if lum <= threshold {
77            Self::Dark
78        } else {
79            Self::Light
80        }
81    }
82
83    /// Primary text color guaranteeing >= 4.5:1 WCAG contrast against this background.
84    ///
85    /// - `Dark` => 100% white (`#FFFFFF`)
86    /// - `Light` => dark primary label color from Aqua tokens (`#262626`)
87    pub fn primary_text_color(&self, theme: &MacTheme) -> Color32 {
88        match self {
89            Self::Dark => Color32::from_rgb(255, 255, 255),
90            Self::Light => theme.tokens().colors().aqua.label.to_color32(),
91        }
92    }
93
94    /// Secondary text color (subtitles, captions, metadata).
95    ///
96    /// - `Dark` => 85% white (`rgba(255, 255, 255, 0.85)`)
97    /// - `Light` => dark secondary label color from Aqua tokens (`#6E6E73`)
98    pub fn secondary_text_color(&self, theme: &MacTheme) -> Color32 {
99        match self {
100            Self::Dark => Color32::from_white_alpha(217),
101            Self::Light => theme.tokens().colors().aqua.secondary_label.to_color32(),
102        }
103    }
104
105    /// Tertiary text color (placeholders, disabled hints).
106    ///
107    /// - `Dark` => 70% white (`rgba(255, 255, 255, 0.70)`)
108    /// - `Light` => dark placeholder text color from Aqua tokens
109    pub fn tertiary_text_color(&self, theme: &MacTheme) -> Color32 {
110        match self {
111            Self::Dark => Color32::from_white_alpha(179),
112            Self::Light => theme.tokens().colors().aqua.placeholder_text.to_color32(),
113        }
114    }
115
116    /// Standalone primary text color without requiring a `MacTheme` reference.
117    pub fn primary_text(&self) -> Color32 {
118        match self {
119            Self::Dark => Color32::from_rgb(255, 255, 255),
120            Self::Light => Color32::from_rgb(0, 0, 0),
121        }
122    }
123
124    /// Standalone secondary text color without requiring a `MacTheme` reference.
125    pub fn secondary_text(&self) -> Color32 {
126        match self {
127            Self::Dark => Color32::from_white_alpha(217),
128            Self::Light => Color32::from_black_alpha(166),
129        }
130    }
131
132    /// Standalone tertiary text color without requiring a `MacTheme` reference.
133    pub fn tertiary_text(&self) -> Color32 {
134        match self {
135            Self::Dark => Color32::from_white_alpha(179),
136            Self::Light => Color32::from_black_alpha(115),
137        }
138    }
139}