Skip to main content

native_theme/
macos.rs

1//! macOS theme reader: reads semantic NSColor values with P3-to-sRGB conversion,
2//! resolves both light and dark appearance variants via NSAppearance, and reads
3//! system and monospace fonts via NSFont.
4
5#[cfg(all(target_os = "macos", feature = "macos"))]
6use block2::RcBlock;
7#[cfg(all(target_os = "macos", feature = "macos"))]
8use objc2_app_kit::{NSAppearance, NSColor, NSColorSpace, NSFont, NSFontWeightRegular};
9#[cfg(all(target_os = "macos", feature = "macos"))]
10use objc2_foundation::NSString;
11
12/// Convert an NSColor to sRGB and extract RGBA components.
13///
14/// Converts the color to the sRGB color space via `colorUsingColorSpace`,
15/// handling P3-to-sRGB conversion automatically. Returns `None` if the
16/// color cannot be converted (e.g., pattern colors).
17#[cfg(all(target_os = "macos", feature = "macos"))]
18fn nscolor_to_rgba(color: &NSColor, srgb: &NSColorSpace) -> Option<crate::Rgba> {
19    let srgb_color = unsafe { color.colorUsingColorSpace(srgb) }?;
20    let r = unsafe { srgb_color.redComponent() } as f32;
21    let g = unsafe { srgb_color.greenComponent() } as f32;
22    let b = unsafe { srgb_color.blueComponent() } as f32;
23    let a = unsafe { srgb_color.alphaComponent() } as f32;
24    Some(crate::Rgba::from_f32(r, g, b, a))
25}
26
27/// Read all semantic NSColor values for the current appearance context.
28///
29/// Must be called within an `NSAppearance::performAsCurrentDrawingAppearance`
30/// block so that dynamic colors resolve to the correct appearance.
31#[cfg(all(target_os = "macos", feature = "macos"))]
32fn read_semantic_colors() -> crate::ThemeColors {
33    let srgb = unsafe { NSColorSpace::sRGBColorSpace() };
34
35    // Bind all NSColor temporaries before borrowing them.
36    // Rust 2024 edition drops temporaries inside `unsafe {}` blocks at the
37    // end of the block, so `nscolor_to_rgba(unsafe { &NSColor::foo() }, …)`
38    // would reference a dropped value.
39    let label_c = unsafe { NSColor::labelColor() };
40    let control_accent = unsafe { NSColor::controlAccentColor() };
41    let window_bg = unsafe { NSColor::windowBackgroundColor() };
42    let control_bg = unsafe { NSColor::controlBackgroundColor() };
43    let separator_c = unsafe { NSColor::separatorColor() };
44    let secondary_label = unsafe { NSColor::secondaryLabelColor() };
45    let shadow_c = unsafe { NSColor::shadowColor() };
46    let alt_sel_text = unsafe { NSColor::alternateSelectedControlTextColor() };
47    let control_c = unsafe { NSColor::controlColor() };
48    let control_text = unsafe { NSColor::controlTextColor() };
49    let system_red = unsafe { NSColor::systemRedColor() };
50    let system_orange = unsafe { NSColor::systemOrangeColor() };
51    let system_green = unsafe { NSColor::systemGreenColor() };
52    let system_blue = unsafe { NSColor::systemBlueColor() };
53    let sel_content_bg = unsafe { NSColor::selectedContentBackgroundColor() };
54    let sel_text = unsafe { NSColor::selectedTextColor() };
55    let link_c = unsafe { NSColor::linkColor() };
56    let focus_c = unsafe { NSColor::keyboardFocusIndicatorColor() };
57    let under_page_bg = unsafe { NSColor::underPageBackgroundColor() };
58    let text_bg = unsafe { NSColor::textBackgroundColor() };
59    let text_c = unsafe { NSColor::textColor() };
60    let disabled_text = unsafe { NSColor::disabledControlTextColor() };
61
62    let label = nscolor_to_rgba(&label_c, &srgb);
63
64    crate::ThemeColors {
65        // Core (7)
66        accent: nscolor_to_rgba(&control_accent, &srgb),
67        background: nscolor_to_rgba(&window_bg, &srgb),
68        foreground: label,
69        surface: nscolor_to_rgba(&control_bg, &srgb),
70        border: nscolor_to_rgba(&separator_c, &srgb),
71        muted: nscolor_to_rgba(&secondary_label, &srgb),
72        shadow: nscolor_to_rgba(&shadow_c, &srgb),
73        // Primary (2)
74        primary_background: nscolor_to_rgba(&control_accent, &srgb),
75        primary_foreground: nscolor_to_rgba(&alt_sel_text, &srgb),
76        // Secondary (2)
77        secondary_background: nscolor_to_rgba(&control_c, &srgb),
78        secondary_foreground: nscolor_to_rgba(&control_text, &srgb),
79        // Status (8)
80        danger: nscolor_to_rgba(&system_red, &srgb),
81        danger_foreground: label,
82        warning: nscolor_to_rgba(&system_orange, &srgb),
83        warning_foreground: label,
84        success: nscolor_to_rgba(&system_green, &srgb),
85        success_foreground: label,
86        info: nscolor_to_rgba(&system_blue, &srgb),
87        info_foreground: label,
88        // Interactive (4)
89        selection: nscolor_to_rgba(&sel_content_bg, &srgb),
90        selection_foreground: nscolor_to_rgba(&sel_text, &srgb),
91        link: nscolor_to_rgba(&link_c, &srgb),
92        focus_ring: nscolor_to_rgba(&focus_c, &srgb),
93        // Panel (6)
94        sidebar: nscolor_to_rgba(&under_page_bg, &srgb),
95        sidebar_foreground: label,
96        tooltip: nscolor_to_rgba(&window_bg, &srgb),
97        tooltip_foreground: label,
98        popover: nscolor_to_rgba(&window_bg, &srgb),
99        popover_foreground: label,
100        // Component (7)
101        button: nscolor_to_rgba(&control_c, &srgb),
102        button_foreground: nscolor_to_rgba(&control_text, &srgb),
103        input: nscolor_to_rgba(&text_bg, &srgb),
104        input_foreground: nscolor_to_rgba(&text_c, &srgb),
105        disabled: nscolor_to_rgba(&disabled_text, &srgb),
106        separator: nscolor_to_rgba(&separator_c, &srgb),
107        alternate_row: nscolor_to_rgba(&control_bg, &srgb),
108    }
109}
110
111/// Read system and monospace font information via NSFont.
112///
113/// Fonts are appearance-independent, so this only needs to be called once
114/// (not per-appearance).
115#[cfg(all(target_os = "macos", feature = "macos"))]
116fn read_fonts() -> crate::ThemeFonts {
117    let system_size = unsafe { NSFont::systemFontSize() };
118    let system_font = unsafe { NSFont::systemFontOfSize(system_size) };
119    let mono_font =
120        unsafe { NSFont::monospacedSystemFontOfSize_weight(system_size, NSFontWeightRegular) };
121
122    crate::ThemeFonts {
123        family: system_font.familyName().map(|n| n.to_string()),
124        size: Some(unsafe { system_font.pointSize() } as f32),
125        mono_family: mono_font.familyName().map(|n| n.to_string()),
126        mono_size: Some(unsafe { mono_font.pointSize() } as f32),
127    }
128}
129
130/// Return widget metrics populated from macOS HIG defaults.
131///
132/// Values based on AppKit intrinsic content sizes and Apple Human Interface
133/// Guidelines for standard control dimensions.
134#[cfg_attr(not(all(target_os = "macos", feature = "macos")), allow(dead_code))]
135fn macos_widget_metrics() -> crate::model::widget_metrics::WidgetMetrics {
136    use crate::model::widget_metrics::*;
137
138    WidgetMetrics {
139        button: ButtonMetrics {
140            min_height: Some(22.0), // NSButton regular control size
141            padding_horizontal: Some(12.0),
142            ..Default::default()
143        },
144        checkbox: CheckboxMetrics {
145            indicator_size: Some(14.0), // NSButton switch type
146            spacing: Some(4.0),
147            ..Default::default()
148        },
149        input: InputMetrics {
150            min_height: Some(22.0), // NSTextField regular
151            padding_horizontal: Some(4.0),
152            ..Default::default()
153        },
154        scrollbar: ScrollbarMetrics {
155            width: Some(15.0),       // NSScroller legacy style
156            slider_width: Some(7.0), // Overlay style
157            ..Default::default()
158        },
159        slider: SliderMetrics {
160            track_height: Some(4.0), // NSSlider circular knob
161            thumb_size: Some(21.0),
162            ..Default::default()
163        },
164        progress_bar: ProgressBarMetrics {
165            height: Some(6.0), // NSProgressIndicator regular
166            ..Default::default()
167        },
168        tab: TabMetrics {
169            min_height: Some(24.0), // NSTabView
170            padding_horizontal: Some(12.0),
171            ..Default::default()
172        },
173        menu_item: MenuItemMetrics {
174            height: Some(22.0), // Standard menu item
175            padding_horizontal: Some(12.0),
176            ..Default::default()
177        },
178        tooltip: TooltipMetrics {
179            padding: Some(4.0),
180            ..Default::default()
181        },
182        list_item: ListItemMetrics {
183            height: Some(24.0), // NSTableView row
184            padding_horizontal: Some(4.0),
185            ..Default::default()
186        },
187        toolbar: ToolbarMetrics {
188            height: Some(38.0), // NSToolbar standard
189            item_spacing: Some(8.0),
190            ..Default::default()
191        },
192        splitter: SplitterMetrics {
193            width: Some(9.0), // NSSplitView divider
194        },
195    }
196}
197
198/// Testable core: assemble a NativeTheme from pre-read color and font data.
199///
200/// Takes pre-resolved colors for both light and dark variants and fonts,
201/// then constructs the complete NativeTheme. Both variants are always
202/// populated (unlike KDE/GNOME/Windows which populate only the active one),
203/// since macOS can resolve colors for both appearances.
204#[cfg_attr(not(all(target_os = "macos", feature = "macos")), allow(dead_code))]
205fn build_theme(
206    light_colors: crate::ThemeColors,
207    dark_colors: crate::ThemeColors,
208    fonts: crate::ThemeFonts,
209) -> crate::NativeTheme {
210    let wm = macos_widget_metrics();
211
212    crate::NativeTheme {
213        name: "macOS".to_string(),
214        light: Some(crate::ThemeVariant {
215            colors: light_colors,
216            fonts: fonts.clone(),
217            geometry: Default::default(),
218            spacing: Default::default(),
219            widget_metrics: Some(wm.clone()),
220            icon_set: None,
221        }),
222        dark: Some(crate::ThemeVariant {
223            colors: dark_colors,
224            fonts,
225            geometry: Default::default(),
226            spacing: Default::default(),
227            widget_metrics: Some(wm),
228            icon_set: None,
229        }),
230    }
231}
232
233/// Read the current macOS theme, resolving both light and dark appearance variants.
234///
235/// Uses `NSAppearance::performAsCurrentDrawingAppearance` (macOS 11+) to scope
236/// semantic color resolution to each appearance. Reads ~20 NSColor semantic
237/// colors per variant with P3-to-sRGB conversion, plus system and monospace fonts.
238///
239/// # Errors
240///
241/// Returns `Error::Unavailable` if neither light nor dark appearance can be created
242/// (extremely unlikely on any macOS version that supports these APIs).
243#[cfg(all(target_os = "macos", feature = "macos"))]
244pub fn from_macos() -> crate::Result<crate::NativeTheme> {
245    let light_name = NSString::from_str("NSAppearanceNameAqua");
246    let dark_name = NSString::from_str("NSAppearanceNameDarkAqua");
247
248    let light_appearance = unsafe { NSAppearance::appearanceNamed(&light_name) };
249    let dark_appearance = unsafe { NSAppearance::appearanceNamed(&dark_name) };
250
251    if light_appearance.is_none() && dark_appearance.is_none() {
252        return Err(crate::Error::Unavailable(
253            "neither light nor dark NSAppearance could be created".to_string(),
254        ));
255    }
256
257    let light_colors = if let Some(app) = &light_appearance {
258        let colors = std::cell::RefCell::new(crate::ThemeColors::default());
259        {
260            let block = RcBlock::new(|| {
261                *colors.borrow_mut() = read_semantic_colors();
262            });
263            app.performAsCurrentDrawingAppearance(&block);
264        }
265        colors.into_inner()
266    } else {
267        crate::ThemeColors::default()
268    };
269
270    let dark_colors = if let Some(app) = &dark_appearance {
271        let colors = std::cell::RefCell::new(crate::ThemeColors::default());
272        {
273            let block = RcBlock::new(|| {
274                *colors.borrow_mut() = read_semantic_colors();
275            });
276            app.performAsCurrentDrawingAppearance(&block);
277        }
278        colors.into_inner()
279    } else {
280        crate::ThemeColors::default()
281    };
282
283    let fonts = read_fonts();
284
285    Ok(build_theme(light_colors, dark_colors, fonts))
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    fn sample_light_colors() -> crate::ThemeColors {
293        crate::ThemeColors {
294            accent: Some(crate::Rgba::rgb(0, 122, 255)),
295            background: Some(crate::Rgba::rgb(246, 246, 246)),
296            foreground: Some(crate::Rgba::rgb(0, 0, 0)),
297            surface: Some(crate::Rgba::rgb(255, 255, 255)),
298            border: Some(crate::Rgba::rgb(200, 200, 200)),
299            ..Default::default()
300        }
301    }
302
303    fn sample_dark_colors() -> crate::ThemeColors {
304        crate::ThemeColors {
305            accent: Some(crate::Rgba::rgb(10, 132, 255)),
306            background: Some(crate::Rgba::rgb(30, 30, 30)),
307            foreground: Some(crate::Rgba::rgb(255, 255, 255)),
308            surface: Some(crate::Rgba::rgb(44, 44, 46)),
309            border: Some(crate::Rgba::rgb(56, 56, 58)),
310            ..Default::default()
311        }
312    }
313
314    fn sample_fonts() -> crate::ThemeFonts {
315        crate::ThemeFonts {
316            family: Some("SF Pro".to_string()),
317            size: Some(13.0),
318            mono_family: Some("SF Mono".to_string()),
319            mono_size: Some(13.0),
320        }
321    }
322
323    #[test]
324    fn build_theme_populates_both_variants() {
325        let theme = build_theme(sample_light_colors(), sample_dark_colors(), sample_fonts());
326
327        assert!(theme.light.is_some(), "light variant should be Some");
328        assert!(theme.dark.is_some(), "dark variant should be Some");
329
330        // Colors should differ between variants
331        let light = theme.light.as_ref().unwrap();
332        let dark = theme.dark.as_ref().unwrap();
333        assert_ne!(light.colors.accent, dark.colors.accent);
334        assert_ne!(light.colors.background, dark.colors.background);
335
336        // Fonts should be identical in both
337        assert_eq!(light.fonts, dark.fonts);
338    }
339
340    #[test]
341    fn build_theme_name_is_macos() {
342        let theme = build_theme(sample_light_colors(), sample_dark_colors(), sample_fonts());
343        assert_eq!(theme.name, "macOS");
344    }
345
346    #[test]
347    fn build_theme_fonts_populated() {
348        let fonts = crate::ThemeFonts {
349            family: Some("SF Pro".to_string()),
350            size: Some(13.0),
351            mono_family: Some("SF Mono".to_string()),
352            mono_size: Some(13.0),
353        };
354
355        let theme = build_theme(
356            crate::ThemeColors::default(),
357            crate::ThemeColors::default(),
358            fonts,
359        );
360
361        let light = theme.light.as_ref().unwrap();
362        assert_eq!(light.fonts.family.as_deref(), Some("SF Pro"));
363        assert_eq!(light.fonts.size, Some(13.0));
364        assert_eq!(light.fonts.mono_family.as_deref(), Some("SF Mono"));
365        assert_eq!(light.fonts.mono_size, Some(13.0));
366
367        let dark = theme.dark.as_ref().unwrap();
368        assert_eq!(dark.fonts.family.as_deref(), Some("SF Pro"));
369        assert_eq!(dark.fonts.size, Some(13.0));
370    }
371
372    #[test]
373    fn build_theme_geometry_and_spacing_default() {
374        let theme = build_theme(sample_light_colors(), sample_dark_colors(), sample_fonts());
375
376        let light = theme.light.as_ref().unwrap();
377        assert!(
378            light.geometry.is_empty(),
379            "light geometry should be default"
380        );
381        assert!(light.spacing.is_empty(), "light spacing should be default");
382
383        let dark = theme.dark.as_ref().unwrap();
384        assert!(dark.geometry.is_empty(), "dark geometry should be default");
385        assert!(dark.spacing.is_empty(), "dark spacing should be default");
386    }
387
388    #[test]
389    fn build_theme_colors_propagated_correctly() {
390        let blue = crate::Rgba::rgb(0, 122, 255);
391        let red = crate::Rgba::rgb(255, 59, 48);
392
393        let light_colors = crate::ThemeColors {
394            accent: Some(blue),
395            ..Default::default()
396        };
397        let dark_colors = crate::ThemeColors {
398            accent: Some(red),
399            ..Default::default()
400        };
401
402        let theme = build_theme(light_colors, dark_colors, crate::ThemeFonts::default());
403
404        let light = theme.light.as_ref().unwrap();
405        let dark = theme.dark.as_ref().unwrap();
406
407        assert_eq!(light.colors.accent, Some(blue));
408        assert_eq!(dark.colors.accent, Some(red));
409    }
410
411    #[test]
412    fn macos_widget_metrics_spot_check() {
413        let wm = macos_widget_metrics();
414        assert_eq!(
415            wm.button.min_height,
416            Some(22.0),
417            "NSButton regular control size"
418        );
419        assert_eq!(wm.scrollbar.width, Some(15.0), "NSScroller legacy style");
420        assert_eq!(
421            wm.checkbox.indicator_size,
422            Some(14.0),
423            "NSButton switch type"
424        );
425        assert_eq!(wm.slider.thumb_size, Some(21.0), "NSSlider circular knob");
426    }
427
428    #[test]
429    fn build_theme_populates_widget_metrics() {
430        let theme = build_theme(sample_light_colors(), sample_dark_colors(), sample_fonts());
431
432        let light = theme.light.as_ref().unwrap();
433        assert!(
434            light.widget_metrics.is_some(),
435            "light widget_metrics should be Some"
436        );
437
438        let dark = theme.dark.as_ref().unwrap();
439        assert!(
440            dark.widget_metrics.is_some(),
441            "dark widget_metrics should be Some"
442        );
443    }
444}