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;
12use std::sync::OnceLock;
13
14const MAX_PASS_SURFACE_RECTS: usize = 32;
15
16#[derive(Clone, Copy)]
17struct PassSurfaceRects {
18    pass_nr: u64,
19    ctx_token: u64,
20    len: usize,
21    entries: [(egui::Rect, Color32); MAX_PASS_SURFACE_RECTS],
22}
23
24impl PassSurfaceRects {
25    const EMPTY: Self = Self {
26        pass_nr: u64::MAX,
27        ctx_token: 0,
28        len: 0,
29        entries: [(egui::Rect::NOTHING, Color32::TRANSPARENT); MAX_PASS_SURFACE_RECTS],
30    };
31}
32
33thread_local! {
34    static PASS_SURFACE_RECTS: Cell<PassSurfaceRects> = const { Cell::new(PassSurfaceRects::EMPTY) };
35    static NEXT_PASS_TOKEN: Cell<u64> = const { Cell::new(1) };
36    static EXPLICIT_SURFACE_CELL: Cell<Option<Color32>> = const { Cell::new(None) };
37}
38
39static EGUI_DEFAULT_PANEL_FILLS: OnceLock<(Color32, Color32)> = OnceLock::new();
40
41struct ExplicitSurfaceGuard {
42    prev: Option<Color32>,
43}
44
45impl Drop for ExplicitSurfaceGuard {
46    fn drop(&mut self) {
47        EXPLICIT_SURFACE_CELL.with(|cell| cell.set(self.prev));
48    }
49}
50
51/// Converts an 8-bit sRGB channel component in `[0..255]` to linear luminance in `[0.0..1.0]`.
52pub fn srgb_channel_to_linear(c: u8) -> f32 {
53    let s = c as f32 / 255.0;
54    if s <= 0.04045 {
55        s / 12.92
56    } else {
57        ((s + 0.055) / 1.055).powf(2.4)
58    }
59}
60
61/// Computes the relative luminance of an sRGB color according to WCAG 2.1:
62/// `L = 0.2126 * R_lin + 0.7152 * G_lin + 0.0722 * B_lin`.
63pub fn relative_luminance(color: Color32) -> f32 {
64    let r = srgb_channel_to_linear(color.r());
65    let g = srgb_channel_to_linear(color.g());
66    let b = srgb_channel_to_linear(color.b());
67    0.2126 * r + 0.7152 * g + 0.0722 * b
68}
69
70/// Alpha-composites a potentially translucent `fg` over `bg` in unmultiplied sRGB space,
71/// returning an opaque [`Color32`].
72pub fn alpha_composite(fg: Color32, bg: Color32) -> Color32 {
73    let [fr, fg_c, fb, fa] = fg.to_srgba_unmultiplied();
74    if fa == 255 {
75        return Color32::from_rgb(fr, fg_c, fb);
76    }
77    let alpha = fa as f32 / 255.0;
78    let inv = 1.0 - alpha;
79    let r = (fr as f32 * alpha + bg.r() as f32 * inv).round() as u8;
80    let g = (fg_c as f32 * alpha + bg.g() as f32 * inv).round() as u8;
81    let b = (fb as f32 * alpha + bg.b() as f32 * inv).round() as u8;
82    Color32::from_rgb(r, g, b)
83}
84
85/// Computes the WCAG 2.1 contrast ratio between foreground and background colors.
86/// If foreground has alpha < 255, it is alpha-blended over the background first.
87pub fn contrast_ratio(fg: Color32, bg: Color32) -> f32 {
88    let effective_fg = alpha_composite(fg, bg);
89    let l1 = relative_luminance(effective_fg);
90    let l2 = relative_luminance(bg);
91
92    let lighter = l1.max(l2);
93    let darker = l1.min(l2);
94
95    (lighter + 0.05) / (darker + 0.05)
96}
97
98/// Apple CoreGraphics / AppKit `PlusDarker` (`kCGBlendModePlusDarker`) surface-relative vibrancy
99/// compositing for light surfaces.
100///
101/// Shifts the alpha-composited `fg` channels darker in proportion to the per-channel surface delta
102/// between `surface_bg` and `reference_bg` scaled by `gain`.
103pub fn plus_darker(fg: Color32, surface_bg: Color32, reference_bg: Color32, gain: f32) -> Color32 {
104    let base = alpha_composite(fg, surface_bg);
105    let dr = ((reference_bg.r() as i16 - surface_bg.r() as i16).unsigned_abs() as f32 * gain).round() as u8;
106    let dg = ((reference_bg.g() as i16 - surface_bg.g() as i16).unsigned_abs() as f32 * gain).round() as u8;
107    let db = ((reference_bg.b() as i16 - surface_bg.b() as i16).unsigned_abs() as f32 * gain).round() as u8;
108    Color32::from_rgb(
109        base.r().saturating_sub(dr),
110        base.g().saturating_sub(dg),
111        base.b().saturating_sub(db),
112    )
113}
114
115/// Apple CoreGraphics / AppKit `PlusLighter` (`kCGBlendModePlusLighter`) surface-relative vibrancy
116/// compositing for dark surfaces.
117///
118/// Shifts the alpha-composited `fg` channels lighter in proportion to the per-channel surface delta
119/// between `surface_bg` and `reference_bg` scaled by `gain`.
120pub fn plus_lighter(fg: Color32, surface_bg: Color32, reference_bg: Color32, gain: f32) -> Color32 {
121    let base = alpha_composite(fg, surface_bg);
122    let dr = ((reference_bg.r() as i16 - surface_bg.r() as i16).unsigned_abs() as f32 * gain).round() as u8;
123    let dg = ((reference_bg.g() as i16 - surface_bg.g() as i16).unsigned_abs() as f32 * gain).round() as u8;
124    let db = ((reference_bg.b() as i16 - surface_bg.b() as i16).unsigned_abs() as f32 * gain).round() as u8;
125    Color32::from_rgb(
126        base.r().saturating_add(dr),
127        base.g().saturating_add(dg),
128        base.b().saturating_add(db),
129    )
130}
131
132/// Clamps `fg` against `bg` so that the resulting opaque [`Color32`] satisfies a WCAG 2.1 contrast
133/// ratio of at least `min_ratio` (e.g. `4.5` for normal text, `3.0` for tertiary/UI components),
134/// using a 10-step allocation-free binary search toward the high-contrast pole (`#000000` or `#FFFFFF`).
135pub fn ensure_wcag_contrast(fg: Color32, bg: Color32, min_ratio: f32) -> Color32 {
136    let bg_opaque = Color32::from_rgb(bg.r(), bg.g(), bg.b());
137    let effective_fg = alpha_composite(fg, bg_opaque);
138    if contrast_ratio(effective_fg, bg_opaque) >= min_ratio {
139        return effective_fg;
140    }
141
142    let preferred_pole = match MacBackgroundStyle::from_color(bg_opaque) {
143        MacBackgroundStyle::Light => Color32::BLACK,
144        MacBackgroundStyle::Dark => Color32::WHITE,
145    };
146    let pole = if contrast_ratio(preferred_pole, bg_opaque) >= min_ratio {
147        preferred_pole
148    } else if relative_luminance(bg_opaque) > 0.179 {
149        Color32::BLACK
150    } else {
151        Color32::WHITE
152    };
153
154    let mut low = 0.0_f32;
155    let mut high = 1.0_f32;
156    let mut best = pole;
157    for _ in 0..10 {
158        let mid = (low + high) * 0.5;
159        let r = (effective_fg.r() as f32 + (pole.r() as f32 - effective_fg.r() as f32) * mid).round() as u8;
160        let g = (effective_fg.g() as f32 + (pole.g() as f32 - effective_fg.g() as f32) * mid).round() as u8;
161        let b = (effective_fg.b() as f32 + (pole.b() as f32 - effective_fg.b() as f32) * mid).round() as u8;
162        let candidate = Color32::from_rgb(r, g, b);
163        if contrast_ratio(candidate, bg_opaque) >= min_ratio {
164            best = candidate;
165            high = mid;
166        } else {
167            low = mid;
168        }
169    }
170    best
171}
172
173impl MacTheme {
174    /// Executes `f` inside a zero-allocation surface vibrancy scope where [`Self::active_surface`]
175    /// falls back to `surface_bg`, automatically restoring the previous override on scope exit.
176    pub fn with_surface<R>(&self, surface_bg: Color32, f: impl FnOnce() -> R) -> R {
177        let prev = EXPLICIT_SURFACE_CELL.with(|cell| {
178            let old = cell.get();
179            cell.set(Some(surface_bg));
180            old
181        });
182        let _guard = ExplicitSurfaceGuard { prev };
183        f()
184    }
185
186    fn is_default_panel_fill(&self, panel_fill: Color32) -> bool {
187        let (egui_light, egui_dark) = *EGUI_DEFAULT_PANEL_FILLS
188            .get_or_init(|| (egui::Visuals::light().panel_fill, egui::Visuals::dark().panel_fill));
189        let colors = self.tokens().colors();
190        panel_fill == egui_light
191            || panel_fill == egui_dark
192            || panel_fill == colors.aqua.window_background.to_color32()
193            || panel_fill == colors.anthracite.window_background.to_color32()
194            || panel_fill == colors.dark_aqua.window_background.to_color32()
195    }
196
197    /// Registers a painted container surface `(rect, surface_bg)` for the current `egui::Context`
198    /// pass so flat NIB peer controls rendered after the container (such as via `.fixed_rect(rect)`)
199    /// automatically detect `surface_bg` when their geometry lies within `rect`.
200    pub fn register_surface_rect(
201        &self,
202        ctx: &egui::Context,
203        rect: egui::Rect,
204        surface_bg: Color32,
205    ) {
206        let _ = self.register_surface_rect_slot(ctx, rect, surface_bg);
207    }
208
209    pub(crate) fn register_surface_rect_slot(
210        &self,
211        ctx: &egui::Context,
212        rect: egui::Rect,
213        surface_bg: Color32,
214    ) -> usize {
215        let pass_nr = ctx.cumulative_pass_nr();
216        let pass_id = egui::Id::unique("macview_surface_pass_token");
217        PASS_SURFACE_RECTS.with(|cell| {
218            let mut state = cell.get();
219            let same_pass = state.pass_nr == pass_nr
220                && state.ctx_token != 0
221                && ctx.data(|d| d.get_temp::<(u64, u64)>(pass_id))
222                    == Some((pass_nr, state.ctx_token));
223            if !same_pass {
224                let token = NEXT_PASS_TOKEN.with(|t| {
225                    let next = t.get().wrapping_add(1).max(1);
226                    t.set(next);
227                    next
228                });
229                ctx.data_mut(|d| d.insert_temp(pass_id, (pass_nr, token)));
230                state.pass_nr = pass_nr;
231                state.ctx_token = token;
232                state.len = 0;
233            }
234            let slot = if state.len < MAX_PASS_SURFACE_RECTS {
235                let s = state.len;
236                state.entries[s] = (rect, surface_bg);
237                state.len += 1;
238                s
239            } else {
240                state.entries.copy_within(1..MAX_PASS_SURFACE_RECTS, 0);
241                state.entries[MAX_PASS_SURFACE_RECTS - 1] = (rect, surface_bg);
242                MAX_PASS_SURFACE_RECTS - 1
243            };
244            cell.set(state);
245            slot
246        })
247    }
248
249    pub(crate) fn update_surface_rect_slot(
250        &self,
251        ctx: &egui::Context,
252        slot: usize,
253        rect: egui::Rect,
254    ) {
255        let pass_nr = ctx.cumulative_pass_nr();
256        PASS_SURFACE_RECTS.with(|cell| {
257            let mut state = cell.get();
258            if state.pass_nr == pass_nr && slot < state.len {
259                state.entries[slot].0 = rect;
260                cell.set(state);
261            }
262        });
263    }
264
265    fn lookup_registered_surface(ctx: &egui::Context, pos: egui::Pos2) -> Option<Color32> {
266        let pass_nr = ctx.cumulative_pass_nr();
267        let (token, bg) = PASS_SURFACE_RECTS.with(|cell| {
268            let state = cell.get();
269            if state.pass_nr != pass_nr || state.len == 0 {
270                return None;
271            }
272            for idx in (0..state.len).rev() {
273                let (rect, bg) = state.entries[idx];
274                if rect == egui::Rect::NOTHING {
275                    return None;
276                }
277                if rect.contains(pos) {
278                    return Some((state.ctx_token, bg));
279                }
280            }
281            None
282        })?;
283        let pass_id = egui::Id::unique("macview_surface_pass_token");
284        (ctx.data(|d| d.get_temp::<(u64, u64)>(pass_id)) == Some((pass_nr, token))).then_some(bg)
285    }
286
287    /// Resolves the active container surface background color at `pos` inside `ui`:
288    ///
289    /// 1. Returns the last registered `(rect, surface_bg)` in the current pass where `rect.contains(pos)`
290    ///    (for flat NIB layouts where `MacBox::paint_at` or `MacBox::show` precedes `.fixed_rect` peers).
291    /// 2. Returns `ui.visuals().panel_fill` when set by an enclosing container `Ui` (such as `MacBox::show`
292    ///    or `MacCard::show`) or customized away from default window/panel fills.
293    /// 3. Returns any active [`Self::with_surface`] override.
294    /// 4. Falls back to [`Self::window_background`].
295    pub fn active_surface_at(&self, ui: &Ui, pos: egui::Pos2) -> Color32 {
296        if let Some(bg) = Self::lookup_registered_surface(ui.ctx(), pos) {
297            return bg;
298        }
299        let panel_fill = ui.visuals().panel_fill;
300        if !self.is_default_panel_fill(panel_fill) {
301            return panel_fill;
302        }
303        if let Some(override_bg) = EXPLICIT_SURFACE_CELL.with(|cell| cell.get()) {
304            return override_bg;
305        }
306        self.window_background()
307    }
308
309    /// Resolves the active container surface background color at `ui.next_widget_position()`.
310    pub fn active_surface(&self, ui: &Ui) -> Color32 {
311        self.active_surface_at(ui, ui.next_widget_position())
312    }
313
314    fn resolve_vibrant_on(&self, surface_bg: Color32, tier: u8, min_ratio: f32) -> Color32 {
315        let gain = self.label_metrics().vibrancy_gain.points();
316        let style = MacBackgroundStyle::from_color(surface_bg);
317        let vibrant = match style {
318            MacBackgroundStyle::Light => {
319                let pal = &self.tokens().colors().aqua;
320                let ref_bg = pal.window_background.to_color32();
321                let base = match tier {
322                    0 => pal.label.to_color32(),
323                    1 => pal.secondary_label.to_color32(),
324                    _ => pal.tertiary_label.to_color32(),
325                };
326                plus_darker(base, surface_bg, ref_bg, gain)
327            }
328            MacBackgroundStyle::Dark => {
329                let pal = match self.appearance {
330                    MacAppearance::Anthracite => &self.tokens().colors().anthracite,
331                    MacAppearance::Aqua | MacAppearance::DarkAqua => {
332                        &self.tokens().colors().dark_aqua
333                    }
334                };
335                let ref_bg = pal.window_background.to_color32();
336                let base = match tier {
337                    0 => pal.label.to_color32(),
338                    1 => pal.secondary_label.to_color32(),
339                    _ => pal.tertiary_label.to_color32(),
340                };
341                plus_lighter(base, surface_bg, ref_bg, gain)
342            }
343        };
344        ensure_wcag_contrast(vibrant, surface_bg, min_ratio)
345    }
346
347    /// Resolves primary `labelColor` (`NSColor.labelColor`) on `surface_bg` using AppKit vibrancy
348    /// (`PlusDarker` / `PlusLighter`) and WCAG 2.1 AA contrast clamping (`>= 4.5:1`).
349    pub fn label_on(&self, surface_bg: Color32) -> Color32 {
350        let min_ratio = self.label_metrics().wcag_aa_normal_ratio.points();
351        self.resolve_vibrant_on(surface_bg, 0, min_ratio)
352    }
353
354    /// Resolves `secondaryLabelColor` (`NSColor.secondaryLabelColor`) on `surface_bg` using AppKit
355    /// vibrancy (`PlusDarker` / `PlusLighter`) and WCAG 2.1 AA contrast clamping (`>= 4.5:1`).
356    pub fn secondary_label_on(&self, surface_bg: Color32) -> Color32 {
357        let min_ratio = self.label_metrics().wcag_aa_normal_ratio.points();
358        self.resolve_vibrant_on(surface_bg, 1, min_ratio)
359    }
360
361    /// Resolves `tertiaryLabelColor` (`NSColor.tertiaryLabelColor`) on `surface_bg` using AppKit
362    /// vibrancy (`PlusDarker` / `PlusLighter`) and WCAG 2.1 AA contrast clamping (`>= 3.0:1`).
363    pub fn tertiary_label_on(&self, surface_bg: Color32) -> Color32 {
364        let min_ratio = self.label_metrics().wcag_aa_large_ratio.points();
365        self.resolve_vibrant_on(surface_bg, 2, min_ratio)
366    }
367}
368
369/// macOS AppKit background style (`NSBackgroundStyle`).
370///
371/// Indicates whether an element's background is light or dark, allowing child
372/// text, icons, and accessories to select high-contrast tints that satisfy WCAG 2.1 AA.
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
374pub enum MacBackgroundStyle {
375    /// Background is light (relative luminance `L > 0.179`). Use dark foreground text.
376    #[default]
377    Light,
378    /// Background is dark (relative luminance `L <= 0.179`). Use light/white foreground text.
379    Dark,
380}
381
382impl MacBackgroundStyle {
383    /// Resolves `MacBackgroundStyle` from a background color's WCAG 2.1 relative luminance.
384    ///
385    /// For neutral / low-saturation backgrounds, applies the normative WCAG 2.1 AA threshold `L = 0.179`:
386    /// - `L <= 0.179` => `Dark` style (white text guarantees >= 4.5:1 contrast)
387    /// - `L > 0.179` => `Light` style (dark text guarantees >= 4.5:1 contrast)
388    ///
389    /// For saturated chromatic fills (such as Apple System Blue `#007AFF` / `#0A84FF`),
390    /// macOS AppKit filled controls use high-contrast white text (`Dark` style),
391    /// satisfying WCAG 2.1 §1.4.11 / §1.4.3 UI Component / Control AA (>= 3.0:1) with `L <= 0.26`.
392    pub fn from_color(bg: Color32) -> Self {
393        let lum = relative_luminance(bg);
394        let max_c = bg.r().max(bg.g()).max(bg.b());
395        let min_c = bg.r().min(bg.g()).min(bg.b());
396        let is_chromatic = (max_c - min_c) > 30;
397        let threshold = if is_chromatic { 0.26 } else { 0.179 };
398        if lum <= threshold {
399            Self::Dark
400        } else {
401            Self::Light
402        }
403    }
404
405    /// Primary text color guaranteeing >= 4.5:1 WCAG contrast against this background.
406    ///
407    /// - `Dark` => 100% white (`#FFFFFF`)
408    /// - `Light` => dark primary label color from Aqua tokens (`#262626`)
409    pub fn primary_text_color(&self, theme: &MacTheme) -> Color32 {
410        match self {
411            Self::Dark => Color32::from_rgb(255, 255, 255),
412            Self::Light => theme.tokens().colors().aqua.label.to_color32(),
413        }
414    }
415
416    /// Secondary text color (subtitles, captions, metadata).
417    ///
418    /// - `Dark` => 85% white (`rgba(255, 255, 255, 0.85)`)
419    /// - `Light` => dark secondary label color from Aqua tokens (`#5A5A5F`)
420    pub fn secondary_text_color(&self, theme: &MacTheme) -> Color32 {
421        match self {
422            Self::Dark => Color32::from_white_alpha(217),
423            Self::Light => theme.tokens().colors().aqua.secondary_label.to_color32(),
424        }
425    }
426
427    /// Tertiary text color (placeholders, disabled hints).
428    ///
429    /// - `Dark` => 70% white (`rgba(255, 255, 255, 0.70)`)
430    /// - `Light` => dark placeholder text color from Aqua tokens
431    pub fn tertiary_text_color(&self, theme: &MacTheme) -> Color32 {
432        match self {
433            Self::Dark => Color32::from_white_alpha(179),
434            Self::Light => theme.tokens().colors().aqua.placeholder_text.to_color32(),
435        }
436    }
437
438    /// Standalone primary text color without requiring a `MacTheme` reference.
439    pub fn primary_text(&self) -> Color32 {
440        match self {
441            Self::Dark => Color32::from_rgb(255, 255, 255),
442            Self::Light => Color32::from_rgb(0, 0, 0),
443        }
444    }
445
446    /// Standalone secondary text color without requiring a `MacTheme` reference.
447    pub fn secondary_text(&self) -> Color32 {
448        match self {
449            Self::Dark => Color32::from_white_alpha(217),
450            Self::Light => Color32::from_black_alpha(166),
451        }
452    }
453
454    /// Standalone tertiary text color without requiring a `MacTheme` reference.
455    pub fn tertiary_text(&self) -> Color32 {
456        match self {
457            Self::Dark => Color32::from_white_alpha(179),
458            Self::Light => Color32::from_black_alpha(115),
459        }
460    }
461}