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}