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