Skip to main content

egui_macview/theme/
contrast.rs

1//! macOS AppKit Dynamic Vibrancy, Contrast & Background Style Engine.
2//!
3//! Grounded in W3C WCAG 2.1 §1.4.3 (Level AA), Apple CoreGraphics `kCGBlendModePlusDarker` /
4//! `kCGBlendModePlusLighter`, and Apple AppKit `NSBackgroundStyle` / `NSAppearance` vibrancy.
5//! Automatically resolves foreground typography tints against dynamic or recessed container
6//! surfaces (such as `MacBox` and `MacCard`) to guarantee WCAG 2.1 AA contrast with zero
7//! per-frame heap allocations.
8
9use crate::theme::{MacAppearance, MacTheme};
10use egui::{Color32, Ui};
11use std::cell::Cell;
12
13thread_local! {
14    static ACTIVE_SURFACE_OVERRIDE: Cell<Option<Color32>> = const { Cell::new(None) };
15}
16
17struct SurfaceScopeGuard {
18    prev: Option<Color32>,
19}
20
21impl Drop for SurfaceScopeGuard {
22    fn drop(&mut self) {
23        ACTIVE_SURFACE_OVERRIDE.with(|cell| cell.set(self.prev));
24    }
25}
26
27/// Converts an 8-bit sRGB channel component in `[0..255]` to linear luminance in `[0.0..1.0]`.
28pub fn srgb_channel_to_linear(c: u8) -> f32 {
29    let s = c as f32 / 255.0;
30    if s <= 0.04045 {
31        s / 12.92
32    } else {
33        ((s + 0.055) / 1.055).powf(2.4)
34    }
35}
36
37/// Computes the relative luminance of an sRGB color according to WCAG 2.1:
38/// `L = 0.2126 * R_lin + 0.7152 * G_lin + 0.0722 * B_lin`.
39pub fn relative_luminance(color: Color32) -> f32 {
40    let r = srgb_channel_to_linear(color.r());
41    let g = srgb_channel_to_linear(color.g());
42    let b = srgb_channel_to_linear(color.b());
43    0.2126 * r + 0.7152 * g + 0.0722 * b
44}
45
46/// Alpha-composites a potentially translucent `fg` over `bg` in unmultiplied sRGB space,
47/// returning an opaque [`Color32`].
48pub fn alpha_composite(fg: Color32, bg: Color32) -> Color32 {
49    let [fr, fg_c, fb, fa] = fg.to_srgba_unmultiplied();
50    if fa == 255 {
51        return Color32::from_rgb(fr, fg_c, fb);
52    }
53    let alpha = fa as f32 / 255.0;
54    let inv = 1.0 - alpha;
55    let r = (fr as f32 * alpha + bg.r() as f32 * inv).round() as u8;
56    let g = (fg_c as f32 * alpha + bg.g() as f32 * inv).round() as u8;
57    let b = (fb as f32 * alpha + bg.b() as f32 * inv).round() as u8;
58    Color32::from_rgb(r, g, b)
59}
60
61/// Computes the WCAG 2.1 contrast ratio between foreground and background colors.
62/// If foreground has alpha < 255, it is alpha-blended over the background first.
63pub fn contrast_ratio(fg: Color32, bg: Color32) -> f32 {
64    let effective_fg = alpha_composite(fg, bg);
65    let l1 = relative_luminance(effective_fg);
66    let l2 = relative_luminance(bg);
67
68    let lighter = l1.max(l2);
69    let darker = l1.min(l2);
70
71    (lighter + 0.05) / (darker + 0.05)
72}
73
74/// Apple CoreGraphics / AppKit `PlusDarker` (`kCGBlendModePlusDarker`) surface-relative vibrancy
75/// compositing for light surfaces.
76///
77/// Shifts the alpha-composited `fg` channels darker in proportion to the per-channel surface delta
78/// between `surface_bg` and `reference_bg` scaled by `gain`.
79pub fn plus_darker(fg: Color32, surface_bg: Color32, reference_bg: Color32, gain: f32) -> Color32 {
80    let base = alpha_composite(fg, surface_bg);
81    let dr = ((reference_bg.r() as i16 - surface_bg.r() as i16).unsigned_abs() as f32 * gain).round() as u8;
82    let dg = ((reference_bg.g() as i16 - surface_bg.g() as i16).unsigned_abs() as f32 * gain).round() as u8;
83    let db = ((reference_bg.b() as i16 - surface_bg.b() as i16).unsigned_abs() as f32 * gain).round() as u8;
84    Color32::from_rgb(
85        base.r().saturating_sub(dr),
86        base.g().saturating_sub(dg),
87        base.b().saturating_sub(db),
88    )
89}
90
91/// Apple CoreGraphics / AppKit `PlusLighter` (`kCGBlendModePlusLighter`) surface-relative vibrancy
92/// compositing for dark surfaces.
93///
94/// Shifts the alpha-composited `fg` channels lighter in proportion to the per-channel surface delta
95/// between `surface_bg` and `reference_bg` scaled by `gain`.
96pub fn plus_lighter(fg: Color32, surface_bg: Color32, reference_bg: Color32, gain: f32) -> Color32 {
97    let base = alpha_composite(fg, surface_bg);
98    let dr = ((reference_bg.r() as i16 - surface_bg.r() as i16).unsigned_abs() as f32 * gain).round() as u8;
99    let dg = ((reference_bg.g() as i16 - surface_bg.g() as i16).unsigned_abs() as f32 * gain).round() as u8;
100    let db = ((reference_bg.b() as i16 - surface_bg.b() as i16).unsigned_abs() as f32 * gain).round() as u8;
101    Color32::from_rgb(
102        base.r().saturating_add(dr),
103        base.g().saturating_add(dg),
104        base.b().saturating_add(db),
105    )
106}
107
108/// Clamps `fg` against `bg` so that the resulting opaque [`Color32`] satisfies a WCAG 2.1 contrast
109/// ratio of at least `min_ratio` (e.g. `4.5` for normal text, `3.0` for tertiary/UI components),
110/// using a 10-step allocation-free binary search toward the high-contrast pole (`#000000` or `#FFFFFF`).
111pub fn ensure_wcag_contrast(fg: Color32, bg: Color32, min_ratio: f32) -> Color32 {
112    let bg_opaque = Color32::from_rgb(bg.r(), bg.g(), bg.b());
113    let effective_fg = alpha_composite(fg, bg_opaque);
114    if contrast_ratio(effective_fg, bg_opaque) >= min_ratio {
115        return effective_fg;
116    }
117
118    let preferred_pole = match MacBackgroundStyle::from_color(bg_opaque) {
119        MacBackgroundStyle::Light => Color32::BLACK,
120        MacBackgroundStyle::Dark => Color32::WHITE,
121    };
122    let pole = if contrast_ratio(preferred_pole, bg_opaque) >= min_ratio {
123        preferred_pole
124    } else if relative_luminance(bg_opaque) > 0.179 {
125        Color32::BLACK
126    } else {
127        Color32::WHITE
128    };
129
130    let mut low = 0.0_f32;
131    let mut high = 1.0_f32;
132    let mut best = pole;
133    for _ in 0..10 {
134        let mid = (low + high) * 0.5;
135        let r = (effective_fg.r() as f32 + (pole.r() as f32 - effective_fg.r() as f32) * mid).round() as u8;
136        let g = (effective_fg.g() as f32 + (pole.g() as f32 - effective_fg.g() as f32) * mid).round() as u8;
137        let b = (effective_fg.b() as f32 + (pole.b() as f32 - effective_fg.b() as f32) * mid).round() as u8;
138        let candidate = Color32::from_rgb(r, g, b);
139        if contrast_ratio(candidate, bg_opaque) >= min_ratio {
140            best = candidate;
141            high = mid;
142        } else {
143            low = mid;
144        }
145    }
146    best
147}
148
149impl MacTheme {
150    /// Executes `f` inside a zero-allocation surface vibrancy scope where [`Self::active_surface`]
151    /// returns `surface_bg`, automatically restoring the enclosing surface on scope exit.
152    pub fn with_surface<R>(&self, surface_bg: Color32, f: impl FnOnce() -> R) -> R {
153        let prev = ACTIVE_SURFACE_OVERRIDE.with(|cell| {
154            let old = cell.get();
155            cell.set(Some(surface_bg));
156            old
157        });
158        let _guard = SurfaceScopeGuard { prev };
159        f()
160    }
161
162    /// Resolves the active container surface background color for `ui`:
163    ///
164    /// 1. Returns the active container surface pushed by [` crate::MacBox `], [` crate::MacCard `],
165    ///    or [`Self::with_surface`] if inside a container scope.
166    /// 2. Otherwise returns `ui.visuals().panel_fill` if customized away from `egui`'s default.
167    /// 3. Falls back to [`Self::window_background`].
168    pub fn active_surface(&self, ui: &Ui) -> Color32 {
169        if let Some(scoped) = ACTIVE_SURFACE_OVERRIDE.with(|cell| cell.get()) {
170            return scoped;
171        }
172        let panel_fill = ui.visuals().panel_fill;
173        let is_egui_untouched_default = panel_fill == Color32::from_rgb(248, 248, 248)
174            || panel_fill == Color32::from_rgb(27, 27, 27);
175        if !is_egui_untouched_default {
176            panel_fill
177        } else {
178            self.window_background()
179        }
180    }
181
182    fn resolve_vibrant_on(&self, surface_bg: Color32, tier: u8, min_ratio: f32) -> Color32 {
183        let gain = self.label_metrics().vibrancy_gain.points();
184        let style = MacBackgroundStyle::from_color(surface_bg);
185        let vibrant = match style {
186            MacBackgroundStyle::Light => {
187                let pal = &self.tokens().colors().aqua;
188                let ref_bg = pal.window_background.to_color32();
189                let base = match tier {
190                    0 => pal.label.to_color32(),
191                    1 => pal.secondary_label.to_color32(),
192                    _ => pal.tertiary_label.to_color32(),
193                };
194                plus_darker(base, surface_bg, ref_bg, gain)
195            }
196            MacBackgroundStyle::Dark => {
197                let pal = match self.appearance {
198                    MacAppearance::Anthracite => &self.tokens().colors().anthracite,
199                    MacAppearance::Aqua | MacAppearance::DarkAqua => {
200                        &self.tokens().colors().dark_aqua
201                    }
202                };
203                let ref_bg = pal.window_background.to_color32();
204                let base = match tier {
205                    0 => pal.label.to_color32(),
206                    1 => pal.secondary_label.to_color32(),
207                    _ => pal.tertiary_label.to_color32(),
208                };
209                plus_lighter(base, surface_bg, ref_bg, gain)
210            }
211        };
212        ensure_wcag_contrast(vibrant, surface_bg, min_ratio)
213    }
214
215    /// Resolves primary `labelColor` (`NSColor.labelColor`) on `surface_bg` using AppKit vibrancy
216    /// (`PlusDarker` / `PlusLighter`) and WCAG 2.1 AA contrast clamping (`>= 4.5:1`).
217    pub fn label_on(&self, surface_bg: Color32) -> Color32 {
218        let min_ratio = self.label_metrics().wcag_aa_normal_ratio.points();
219        self.resolve_vibrant_on(surface_bg, 0, min_ratio)
220    }
221
222    /// Resolves `secondaryLabelColor` (`NSColor.secondaryLabelColor`) on `surface_bg` using AppKit
223    /// vibrancy (`PlusDarker` / `PlusLighter`) and WCAG 2.1 AA contrast clamping (`>= 4.5:1`).
224    pub fn secondary_label_on(&self, surface_bg: Color32) -> Color32 {
225        let min_ratio = self.label_metrics().wcag_aa_normal_ratio.points();
226        self.resolve_vibrant_on(surface_bg, 1, min_ratio)
227    }
228
229    /// Resolves `tertiaryLabelColor` (`NSColor.tertiaryLabelColor`) on `surface_bg` using AppKit
230    /// vibrancy (`PlusDarker` / `PlusLighter`) and WCAG 2.1 AA contrast clamping (`>= 3.0:1`).
231    pub fn tertiary_label_on(&self, surface_bg: Color32) -> Color32 {
232        let min_ratio = self.label_metrics().wcag_aa_large_ratio.points();
233        self.resolve_vibrant_on(surface_bg, 2, min_ratio)
234    }
235}
236
237/// macOS AppKit background style (`NSBackgroundStyle`).
238///
239/// Indicates whether an element's background is light or dark, allowing child
240/// text, icons, and accessories to select high-contrast tints that satisfy WCAG 2.1 AA.
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
242pub enum MacBackgroundStyle {
243    /// Background is light (relative luminance `L > 0.179`). Use dark foreground text.
244    #[default]
245    Light,
246    /// Background is dark (relative luminance `L <= 0.179`). Use light/white foreground text.
247    Dark,
248}
249
250impl MacBackgroundStyle {
251    /// Resolves `MacBackgroundStyle` from a background color's WCAG 2.1 relative luminance.
252    ///
253    /// For neutral / low-saturation backgrounds, applies the normative WCAG 2.1 AA threshold `L = 0.179`:
254    /// - `L <= 0.179` => `Dark` style (white text guarantees >= 4.5:1 contrast)
255    /// - `L > 0.179` => `Light` style (dark text guarantees >= 4.5:1 contrast)
256    ///
257    /// For saturated chromatic fills (such as Apple System Blue `#007AFF` / `#0A84FF`),
258    /// macOS AppKit filled controls use high-contrast white text (`Dark` style),
259    /// satisfying WCAG 2.1 §1.4.11 / §1.4.3 UI Component / Control AA (>= 3.0:1) with `L <= 0.26`.
260    pub fn from_color(bg: Color32) -> Self {
261        let lum = relative_luminance(bg);
262        let max_c = bg.r().max(bg.g()).max(bg.b());
263        let min_c = bg.r().min(bg.g()).min(bg.b());
264        let is_chromatic = (max_c - min_c) > 30;
265        let threshold = if is_chromatic { 0.26 } else { 0.179 };
266        if lum <= threshold {
267            Self::Dark
268        } else {
269            Self::Light
270        }
271    }
272
273    /// Primary text color guaranteeing >= 4.5:1 WCAG contrast against this background.
274    ///
275    /// - `Dark` => 100% white (`#FFFFFF`)
276    /// - `Light` => dark primary label color from Aqua tokens (`#262626`)
277    pub fn primary_text_color(&self, theme: &MacTheme) -> Color32 {
278        match self {
279            Self::Dark => Color32::from_rgb(255, 255, 255),
280            Self::Light => theme.tokens().colors().aqua.label.to_color32(),
281        }
282    }
283
284    /// Secondary text color (subtitles, captions, metadata).
285    ///
286    /// - `Dark` => 85% white (`rgba(255, 255, 255, 0.85)`)
287    /// - `Light` => dark secondary label color from Aqua tokens (`#5A5A5F`)
288    pub fn secondary_text_color(&self, theme: &MacTheme) -> Color32 {
289        match self {
290            Self::Dark => Color32::from_white_alpha(217),
291            Self::Light => theme.tokens().colors().aqua.secondary_label.to_color32(),
292        }
293    }
294
295    /// Tertiary text color (placeholders, disabled hints).
296    ///
297    /// - `Dark` => 70% white (`rgba(255, 255, 255, 0.70)`)
298    /// - `Light` => dark placeholder text color from Aqua tokens
299    pub fn tertiary_text_color(&self, theme: &MacTheme) -> Color32 {
300        match self {
301            Self::Dark => Color32::from_white_alpha(179),
302            Self::Light => theme.tokens().colors().aqua.placeholder_text.to_color32(),
303        }
304    }
305
306    /// Standalone primary text color without requiring a `MacTheme` reference.
307    pub fn primary_text(&self) -> Color32 {
308        match self {
309            Self::Dark => Color32::from_rgb(255, 255, 255),
310            Self::Light => Color32::from_rgb(0, 0, 0),
311        }
312    }
313
314    /// Standalone secondary text color without requiring a `MacTheme` reference.
315    pub fn secondary_text(&self) -> Color32 {
316        match self {
317            Self::Dark => Color32::from_white_alpha(217),
318            Self::Light => Color32::from_black_alpha(166),
319        }
320    }
321
322    /// Standalone tertiary text color without requiring a `MacTheme` reference.
323    pub fn tertiary_text(&self) -> Color32 {
324        match self {
325            Self::Dark => Color32::from_white_alpha(179),
326            Self::Light => Color32::from_black_alpha(115),
327        }
328    }
329}