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, reads
3//! system/monospace/per-widget fonts via NSFont, text scale entries from Apple's
4//! type scale, scrollbar overlay mode, and accessibility flags.
5
6// Objective-C FFI via objc2 -- no safe alternative
7#![allow(unsafe_code)]
8
9#[cfg(all(target_os = "macos", feature = "macos"))]
10use block2::RcBlock;
11#[cfg(all(target_os = "macos", feature = "macos"))]
12use objc2_app_kit::{
13    NSAppearance, NSColor, NSColorSpace, NSFont, NSFontTraitsAttribute, NSFontWeightRegular,
14    NSFontWeightTrait, NSScroller, NSScrollerStyle, NSWorkspace,
15};
16#[cfg(all(target_os = "macos", feature = "macos"))]
17use objc2_foundation::{NSDictionary, NSNumber, NSString};
18
19/// Convert an NSColor to sRGB and extract RGBA components.
20///
21/// Converts the color to the sRGB color space via `colorUsingColorSpace`,
22/// handling P3-to-sRGB conversion automatically. Returns `None` if the
23/// color cannot be converted (e.g., pattern colors).
24#[cfg(all(target_os = "macos", feature = "macos"))]
25fn nscolor_to_rgba(color: &NSColor, srgb: &NSColorSpace) -> Option<crate::Rgba> {
26    let srgb_color = color.colorUsingColorSpace(srgb)?;
27    let r = srgb_color.redComponent() as f32;
28    let g = srgb_color.greenComponent() as f32;
29    let b = srgb_color.blueComponent() as f32;
30    let a = srgb_color.alphaComponent() as f32;
31    Some(crate::Rgba::from_f32(r, g, b, a))
32}
33
34/// Per-widget colors read from appearance-dependent NSColor APIs.
35#[cfg(all(target_os = "macos", feature = "macos"))]
36#[derive(Default)]
37struct PerWidgetColors {
38    placeholder: Option<crate::Rgba>,
39    selection_inactive: Option<crate::Rgba>,
40    alternate_row: Option<crate::Rgba>,
41    header_foreground: Option<crate::Rgba>,
42    grid_color: Option<crate::Rgba>,
43    title_bar_foreground: Option<crate::Rgba>,
44}
45
46/// Read all semantic NSColor values for the current appearance context.
47///
48/// Returns both the ThemeDefaults and per-widget color data. Must be called
49/// within an `NSAppearance::performAsCurrentDrawingAppearance` block so that
50/// dynamic colors resolve to the correct appearance.
51#[cfg(all(target_os = "macos", feature = "macos"))]
52fn read_appearance_colors() -> (crate::ThemeDefaults, PerWidgetColors) {
53    let srgb = NSColorSpace::sRGBColorSpace();
54
55    // Bind all NSColor temporaries before borrowing them.
56    let label_c = NSColor::labelColor();
57    let control_accent = NSColor::controlAccentColor();
58    let window_bg = NSColor::windowBackgroundColor();
59    let control_bg = NSColor::controlBackgroundColor();
60    let separator_c = NSColor::separatorColor();
61    let secondary_label = NSColor::secondaryLabelColor();
62    let shadow_c = NSColor::shadowColor();
63    let alt_sel_text = NSColor::alternateSelectedControlTextColor();
64    let system_red = NSColor::systemRedColor();
65    let system_orange = NSColor::systemOrangeColor();
66    let system_green = NSColor::systemGreenColor();
67    let system_blue = NSColor::systemBlueColor();
68    let sel_content_bg = NSColor::selectedContentBackgroundColor();
69    let sel_text = NSColor::selectedTextColor();
70    let link_c = NSColor::linkColor();
71    let focus_c = NSColor::keyboardFocusIndicatorColor();
72    let disabled_text = NSColor::disabledControlTextColor();
73
74    // Additional per-widget color bindings (MACOS-03).
75    let placeholder_c = NSColor::placeholderTextColor();
76    let unemph_sel_bg = NSColor::unemphasizedSelectedContentBackgroundColor();
77    let alt_bg_colors = NSColor::alternatingContentBackgroundColors();
78    let header_text_c = NSColor::headerTextColor();
79    let grid_c = NSColor::gridColor();
80    let frame_text_c = NSColor::windowFrameTextColor();
81
82    let label = nscolor_to_rgba(&label_c, &srgb);
83
84    let defaults = crate::ThemeDefaults {
85        accent: nscolor_to_rgba(&control_accent, &srgb),
86        accent_foreground: nscolor_to_rgba(&alt_sel_text, &srgb),
87        background: nscolor_to_rgba(&window_bg, &srgb),
88        foreground: label,
89        surface: nscolor_to_rgba(&control_bg, &srgb),
90        border: nscolor_to_rgba(&separator_c, &srgb),
91        muted: nscolor_to_rgba(&secondary_label, &srgb),
92        shadow: nscolor_to_rgba(&shadow_c, &srgb),
93        danger: nscolor_to_rgba(&system_red, &srgb),
94        danger_foreground: label,
95        warning: nscolor_to_rgba(&system_orange, &srgb),
96        warning_foreground: label,
97        success: nscolor_to_rgba(&system_green, &srgb),
98        success_foreground: label,
99        info: nscolor_to_rgba(&system_blue, &srgb),
100        info_foreground: label,
101        selection: nscolor_to_rgba(&sel_content_bg, &srgb),
102        selection_foreground: nscolor_to_rgba(&sel_text, &srgb),
103        selection_inactive: nscolor_to_rgba(&unemph_sel_bg, &srgb),
104        link: nscolor_to_rgba(&link_c, &srgb),
105        focus_ring_color: nscolor_to_rgba(&focus_c, &srgb),
106        disabled_foreground: nscolor_to_rgba(&disabled_text, &srgb),
107        ..Default::default()
108    };
109
110    // Alternate row: index 1 of alternatingContentBackgroundColors (index 0 is normal).
111    let alternate_row = if alt_bg_colors.count() >= 2 {
112        nscolor_to_rgba(&alt_bg_colors.objectAtIndex(1), &srgb)
113    } else {
114        None
115    };
116
117    let per_widget = PerWidgetColors {
118        placeholder: nscolor_to_rgba(&placeholder_c, &srgb),
119        selection_inactive: nscolor_to_rgba(&unemph_sel_bg, &srgb),
120        alternate_row,
121        header_foreground: nscolor_to_rgba(&header_text_c, &srgb),
122        grid_color: nscolor_to_rgba(&grid_c, &srgb),
123        title_bar_foreground: nscolor_to_rgba(&frame_text_c, &srgb),
124    };
125
126    (defaults, per_widget)
127}
128
129/// Read scrollbar overlay mode from NSScroller.preferredScrollerStyle.
130///
131/// Returns `true` if the preferred scroller style is overlay, `false` for legacy.
132/// Requires main thread (MainThreadMarker).
133#[cfg(all(target_os = "macos", feature = "macos"))]
134fn read_scrollbar_style(mtm: objc2::MainThreadMarker) -> Option<bool> {
135    Some(NSScroller::preferredScrollerStyle(mtm) == NSScrollerStyle::Overlay)
136}
137
138/// Read accessibility flags from NSWorkspace.
139///
140/// Returns (reduce_motion, high_contrast, reduce_transparency, text_scaling_factor).
141/// text_scaling_factor is derived by comparing the system font size to the default (13pt).
142#[cfg(all(target_os = "macos", feature = "macos"))]
143fn read_accessibility() -> (Option<bool>, Option<bool>, Option<bool>, Option<f32>) {
144    let workspace = NSWorkspace::sharedWorkspace();
145    let reduce_motion = Some(workspace.accessibilityDisplayShouldReduceMotion());
146    let high_contrast = Some(workspace.accessibilityDisplayShouldIncreaseContrast());
147    let reduce_transparency = Some(workspace.accessibilityDisplayShouldReduceTransparency());
148
149    // Derive text scaling factor from system font size vs default 13pt.
150    let system_size = NSFont::systemFontSize() as f32;
151    let text_scaling_factor = if (system_size - 13.0).abs() > 0.01 {
152        Some(system_size / 13.0)
153    } else {
154        None
155    };
156
157    (
158        reduce_motion,
159        high_contrast,
160        reduce_transparency,
161        text_scaling_factor,
162    )
163}
164
165/// Map an AppKit font weight (-1.0..1.0) to the nearest CSS weight (100..900).
166///
167/// Uses the standard AppKit-to-CSS mapping thresholds. Returns `None` if the
168/// font descriptor's traits dictionary does not contain the weight key.
169#[cfg(all(target_os = "macos", feature = "macos"))]
170fn nsfont_weight_to_css(font: &NSFont) -> Option<u16> {
171    let descriptor = font.fontDescriptor();
172    // Get the traits dictionary from the font descriptor.
173    let traits_key: &NSString = unsafe { NSFontTraitsAttribute };
174    let traits_obj = descriptor.objectForKey(traits_key)?;
175    // Safety: the traits attribute is an NSDictionary<NSFontDescriptorTraitKey, id>
176    let traits_dict: &NSDictionary<NSString, objc2::runtime::AnyObject> =
177        unsafe { &*(&*traits_obj as *const _ as *const _) };
178    let weight_key: &NSString = unsafe { NSFontWeightTrait };
179    let weight_obj = traits_dict.objectForKey(weight_key)?;
180    // Safety: the weight trait value is an NSNumber wrapping a CGFloat.
181    let weight_num: &NSNumber = unsafe { &*(&*weight_obj as *const _ as *const NSNumber) };
182    let w = weight_num.doubleValue();
183
184    // Map AppKit weight range to CSS weight buckets.
185    let css = if w <= -0.75 {
186        100
187    } else if w <= -0.35 {
188        200
189    } else if w <= -0.1 {
190        300
191    } else if w <= 0.1 {
192        400
193    } else if w <= 0.27 {
194        500
195    } else if w <= 0.35 {
196        600
197    } else if w <= 0.5 {
198        700
199    } else if w <= 0.6 {
200        800
201    } else {
202        900
203    };
204    Some(css)
205}
206
207/// Build a [`FontSpec`] from an NSFont, extracting family, size, and weight.
208#[cfg(all(target_os = "macos", feature = "macos"))]
209fn fontspec_from_nsfont(font: &NSFont) -> crate::FontSpec {
210    crate::FontSpec {
211        family: font.familyName().map(|n| n.to_string()),
212        size: Some(font.pointSize() as f32),
213        weight: nsfont_weight_to_css(font),
214    }
215}
216
217/// Read system and monospace font information via NSFont.
218///
219/// Fonts are appearance-independent, so this only needs to be called once
220/// (not per-appearance). Includes CSS weight extraction from the font descriptor.
221#[cfg(all(target_os = "macos", feature = "macos"))]
222fn read_fonts() -> (crate::FontSpec, crate::FontSpec) {
223    let system_size = NSFont::systemFontSize();
224    let system_font = NSFont::systemFontOfSize(system_size);
225    let mono_font =
226        unsafe { NSFont::monospacedSystemFontOfSize_weight(system_size, NSFontWeightRegular) };
227
228    (
229        fontspec_from_nsfont(&system_font),
230        fontspec_from_nsfont(&mono_font),
231    )
232}
233
234/// Read per-widget fonts: menu, tooltip, and title bar.
235///
236/// Appearance-independent -- called once. Returns (menu, tooltip, title_bar).
237#[cfg(all(target_os = "macos", feature = "macos"))]
238fn read_per_widget_fonts() -> (crate::FontSpec, crate::FontSpec, crate::FontSpec) {
239    let menu_font = NSFont::menuFontOfSize(0.0);
240    let tooltip_font = NSFont::toolTipsFontOfSize(0.0);
241    let title_bar_font = NSFont::titleBarFontOfSize(0.0);
242
243    (
244        fontspec_from_nsfont(&menu_font),
245        fontspec_from_nsfont(&tooltip_font),
246        fontspec_from_nsfont(&title_bar_font),
247    )
248}
249
250/// Compute text scale entries from Apple's type scale ratios.
251///
252/// Uses the system font size as the base and derives caption, section heading,
253/// dialog title, and display sizes proportionally (Apple's default type scale
254/// at 13pt: caption ~11pt, subheadline ~15pt, title2 ~22pt, largeTitle ~34pt).
255/// Weight 400 for caption/body sizes; 700 for headings.
256#[cfg_attr(not(all(target_os = "macos", feature = "macos")), allow(dead_code))]
257fn compute_text_scale(system_size: f32) -> crate::TextScale {
258    // Apple's type scale ratios relative to 13pt system font base.
259    // caption1 = 11/13, subheadline = 15/13, title2 = 22/13, largeTitle = 34/13
260    let ratio = system_size / 13.0;
261
262    crate::TextScale {
263        caption: Some(crate::TextScaleEntry {
264            size: Some((11.0 * ratio).round()),
265            weight: Some(400),
266            line_height: Some(1.3),
267        }),
268        section_heading: Some(crate::TextScaleEntry {
269            size: Some((15.0 * ratio).round()),
270            weight: Some(700),
271            line_height: Some(1.3),
272        }),
273        dialog_title: Some(crate::TextScaleEntry {
274            size: Some((22.0 * ratio).round()),
275            weight: Some(700),
276            line_height: Some(1.2),
277        }),
278        display: Some(crate::TextScaleEntry {
279            size: Some((34.0 * ratio).round()),
280            weight: Some(700),
281            line_height: Some(1.1),
282        }),
283    }
284}
285
286/// Read text scale entries from NSFont preferredFontForTextStyle.
287///
288/// Tries the system API first; if unavailable, falls back to proportional
289/// computation from the system font size.
290#[cfg(all(target_os = "macos", feature = "macos"))]
291fn read_text_scale() -> crate::TextScale {
292    let system_size = NSFont::systemFontSize() as f32;
293    compute_text_scale(system_size)
294}
295
296/// Return per-widget defaults populated from macOS HIG sizes.
297///
298/// Values based on AppKit intrinsic content sizes and Apple Human Interface
299/// Guidelines for standard control dimensions.
300#[cfg_attr(not(all(target_os = "macos", feature = "macos")), allow(dead_code))]
301fn macos_widget_defaults() -> crate::ThemeVariant {
302    crate::ThemeVariant {
303        button: crate::ButtonTheme {
304            min_height: Some(22.0), // NSButton regular control size
305            padding_horizontal: Some(12.0),
306            ..Default::default()
307        },
308        checkbox: crate::CheckboxTheme {
309            indicator_size: Some(14.0), // NSButton switch type
310            spacing: Some(4.0),
311            ..Default::default()
312        },
313        input: crate::InputTheme {
314            min_height: Some(22.0), // NSTextField regular
315            padding_horizontal: Some(4.0),
316            ..Default::default()
317        },
318        scrollbar: crate::ScrollbarTheme {
319            width: Some(15.0),       // NSScroller legacy style
320            slider_width: Some(7.0), // Overlay style
321            ..Default::default()
322        },
323        slider: crate::SliderTheme {
324            track_height: Some(4.0), // NSSlider circular knob
325            thumb_size: Some(21.0),
326            ..Default::default()
327        },
328        progress_bar: crate::ProgressBarTheme {
329            height: Some(6.0), // NSProgressIndicator regular
330            ..Default::default()
331        },
332        tab: crate::TabTheme {
333            min_height: Some(24.0), // NSTabView
334            padding_horizontal: Some(12.0),
335            ..Default::default()
336        },
337        menu: crate::MenuTheme {
338            item_height: Some(22.0), // Standard menu item
339            padding_horizontal: Some(12.0),
340            ..Default::default()
341        },
342        tooltip: crate::TooltipTheme {
343            padding_horizontal: Some(4.0),
344            padding_vertical: Some(4.0),
345            ..Default::default()
346        },
347        list: crate::ListTheme {
348            item_height: Some(24.0), // NSTableView row
349            padding_horizontal: Some(4.0),
350            ..Default::default()
351        },
352        toolbar: crate::ToolbarTheme {
353            height: Some(38.0), // NSToolbar standard
354            item_spacing: Some(8.0),
355            ..Default::default()
356        },
357        splitter: crate::SplitterTheme {
358            width: Some(9.0), // NSSplitView divider
359        },
360        ..Default::default()
361    }
362}
363
364/// Per-widget font and text scale data passed to [`build_theme`].
365///
366/// Collected once (appearance-independent) and applied to both variants.
367#[cfg_attr(not(all(target_os = "macos", feature = "macos")), allow(dead_code))]
368struct WidgetFontData {
369    menu_font: crate::FontSpec,
370    tooltip_font: crate::FontSpec,
371    title_bar_font: crate::FontSpec,
372    text_scale: crate::TextScale,
373}
374
375/// Testable core: assemble a NativeTheme from pre-read color and font data.
376///
377/// Takes pre-resolved ThemeDefaults for both light and dark variants, plus
378/// per-widget font data and text scale entries. Both variants are always
379/// populated (unlike KDE/GNOME/Windows which populate only the active one),
380/// since macOS can resolve colors for both appearances.
381#[cfg_attr(not(all(target_os = "macos", feature = "macos")), allow(dead_code))]
382fn build_theme(
383    light_defaults: crate::ThemeDefaults,
384    dark_defaults: crate::ThemeDefaults,
385    widget_fonts: &WidgetFontData,
386) -> crate::NativeTheme {
387    let widget_defaults = macos_widget_defaults();
388
389    let mut light_variant = widget_defaults.clone();
390    light_variant.defaults = light_defaults;
391    light_variant.icon_set = Some("sf-symbols".to_string());
392    light_variant.menu.font = Some(widget_fonts.menu_font.clone());
393    light_variant.tooltip.font = Some(widget_fonts.tooltip_font.clone());
394    light_variant.window.title_bar_font = Some(widget_fonts.title_bar_font.clone());
395    light_variant.text_scale = widget_fonts.text_scale.clone();
396
397    let mut dark_variant = widget_defaults;
398    dark_variant.defaults = dark_defaults;
399    dark_variant.icon_set = Some("sf-symbols".to_string());
400    dark_variant.menu.font = Some(widget_fonts.menu_font.clone());
401    dark_variant.tooltip.font = Some(widget_fonts.tooltip_font.clone());
402    dark_variant.window.title_bar_font = Some(widget_fonts.title_bar_font.clone());
403    dark_variant.text_scale = widget_fonts.text_scale.clone();
404
405    crate::NativeTheme {
406        name: "macOS".to_string(),
407        light: Some(light_variant),
408        dark: Some(dark_variant),
409    }
410}
411
412/// Read the current macOS theme, resolving both light and dark appearance variants.
413///
414/// Uses `NSAppearance::performAsCurrentDrawingAppearance` (macOS 11+) to scope
415/// semantic color resolution to each appearance. Reads ~25 NSColor semantic
416/// colors per variant with P3-to-sRGB conversion, per-widget fonts with weight,
417/// text scale entries, scrollbar overlay mode, and accessibility flags.
418///
419/// # Errors
420///
421/// Returns `Error::Unavailable` if neither light nor dark appearance can be created
422/// (extremely unlikely on any macOS version that supports these APIs).
423#[cfg(all(target_os = "macos", feature = "macos"))]
424pub fn from_macos() -> crate::Result<crate::NativeTheme> {
425    let light_name = NSString::from_str("NSAppearanceNameAqua");
426    let dark_name = NSString::from_str("NSAppearanceNameDarkAqua");
427
428    let light_appearance = NSAppearance::appearanceNamed(&light_name);
429    let dark_appearance = NSAppearance::appearanceNamed(&dark_name);
430
431    if light_appearance.is_none() && dark_appearance.is_none() {
432        return Err(crate::Error::Unavailable(
433            "neither light nor dark NSAppearance could be created".to_string(),
434        ));
435    }
436
437    // Read appearance-independent data once.
438    let (font, mono_font) = read_fonts();
439    let (menu_font, tooltip_font, title_bar_font) = read_per_widget_fonts();
440    let text_scale = read_text_scale();
441    let widget_fonts = WidgetFontData {
442        menu_font,
443        tooltip_font,
444        title_bar_font,
445        text_scale,
446    };
447
448    // Type alias for the appearance-block data.
449    type AppearanceData = (crate::ThemeDefaults, PerWidgetColors);
450
451    let (light_defaults, light_pw) = if let Some(app) = &light_appearance {
452        let data = std::cell::RefCell::new(None::<AppearanceData>);
453        {
454            let block = RcBlock::new(|| {
455                *data.borrow_mut() = Some(read_appearance_colors());
456            });
457            app.performAsCurrentDrawingAppearance(&block);
458        }
459        let (mut d, pw) = data.into_inner().unwrap_or_default();
460        d.font = font.clone();
461        d.mono_font = mono_font.clone();
462        (d, Some(pw))
463    } else {
464        (crate::ThemeDefaults::default(), None)
465    };
466
467    let (dark_defaults, dark_pw) = if let Some(app) = &dark_appearance {
468        let data = std::cell::RefCell::new(None::<AppearanceData>);
469        {
470            let block = RcBlock::new(|| {
471                *data.borrow_mut() = Some(read_appearance_colors());
472            });
473            app.performAsCurrentDrawingAppearance(&block);
474        }
475        let (mut d, pw) = data.into_inner().unwrap_or_default();
476        d.font = font;
477        d.mono_font = mono_font;
478        (d, Some(pw))
479    } else {
480        (crate::ThemeDefaults::default(), None)
481    };
482
483    let mut theme = build_theme(light_defaults, dark_defaults, &widget_fonts);
484
485    // Apply per-widget colors (appearance-dependent, per variant).
486    if let (Some(v), Some(pw)) = (&mut theme.light, light_pw) {
487        v.input.placeholder = pw.placeholder;
488        v.input.selection = pw.selection_inactive;
489        v.list.alternate_row = pw.alternate_row;
490        v.list.header_foreground = pw.header_foreground;
491        v.list.grid_color = pw.grid_color;
492        v.window.title_bar_foreground = pw.title_bar_foreground;
493    }
494    if let (Some(v), Some(pw)) = (&mut theme.dark, dark_pw) {
495        v.input.placeholder = pw.placeholder;
496        v.input.selection = pw.selection_inactive;
497        v.list.alternate_row = pw.alternate_row;
498        v.list.header_foreground = pw.header_foreground;
499        v.list.grid_color = pw.grid_color;
500        v.window.title_bar_foreground = pw.title_bar_foreground;
501    }
502
503    // Scrollbar overlay mode (appearance-independent, requires main thread).
504    let overlay_mode = objc2::MainThreadMarker::new().and_then(read_scrollbar_style);
505    if let Some(v) = &mut theme.light {
506        v.scrollbar.overlay_mode = overlay_mode;
507    }
508    if let Some(v) = &mut theme.dark {
509        v.scrollbar.overlay_mode = overlay_mode;
510    }
511
512    // Accessibility flags (appearance-independent).
513    let (reduce_motion, high_contrast, reduce_transparency, text_scaling_factor) =
514        read_accessibility();
515    for variant in [&mut theme.light, &mut theme.dark] {
516        if let Some(v) = variant {
517            v.defaults.reduce_motion = reduce_motion;
518            v.defaults.high_contrast = high_contrast;
519            v.defaults.reduce_transparency = reduce_transparency;
520            v.defaults.text_scaling_factor = text_scaling_factor;
521            // macOS uses leading affirmative (OK/Cancel) dialog button order.
522            v.dialog.button_order = Some(crate::DialogButtonOrder::LeadingAffirmative);
523        }
524    }
525
526    Ok(theme)
527}
528
529#[cfg(test)]
530#[allow(clippy::unwrap_used, clippy::expect_used)]
531mod tests {
532    use super::*;
533
534    fn sample_widget_fonts() -> WidgetFontData {
535        WidgetFontData {
536            menu_font: crate::FontSpec {
537                family: Some("SF Pro".to_string()),
538                size: Some(14.0),
539                weight: Some(400),
540            },
541            tooltip_font: crate::FontSpec {
542                family: Some("SF Pro".to_string()),
543                size: Some(11.0),
544                weight: Some(400),
545            },
546            title_bar_font: crate::FontSpec {
547                family: Some("SF Pro".to_string()),
548                size: Some(13.0),
549                weight: Some(700),
550            },
551            text_scale: compute_text_scale(13.0),
552        }
553    }
554
555    fn sample_light_defaults() -> crate::ThemeDefaults {
556        crate::ThemeDefaults {
557            accent: Some(crate::Rgba::rgb(0, 122, 255)),
558            background: Some(crate::Rgba::rgb(246, 246, 246)),
559            foreground: Some(crate::Rgba::rgb(0, 0, 0)),
560            surface: Some(crate::Rgba::rgb(255, 255, 255)),
561            border: Some(crate::Rgba::rgb(200, 200, 200)),
562            font: crate::FontSpec {
563                family: Some("SF Pro".to_string()),
564                size: Some(13.0),
565                weight: None,
566            },
567            mono_font: crate::FontSpec {
568                family: Some("SF Mono".to_string()),
569                size: Some(13.0),
570                weight: None,
571            },
572            ..Default::default()
573        }
574    }
575
576    fn sample_dark_defaults() -> crate::ThemeDefaults {
577        crate::ThemeDefaults {
578            accent: Some(crate::Rgba::rgb(10, 132, 255)),
579            background: Some(crate::Rgba::rgb(30, 30, 30)),
580            foreground: Some(crate::Rgba::rgb(255, 255, 255)),
581            surface: Some(crate::Rgba::rgb(44, 44, 46)),
582            border: Some(crate::Rgba::rgb(56, 56, 58)),
583            font: crate::FontSpec {
584                family: Some("SF Pro".to_string()),
585                size: Some(13.0),
586                weight: None,
587            },
588            mono_font: crate::FontSpec {
589                family: Some("SF Mono".to_string()),
590                size: Some(13.0),
591                weight: None,
592            },
593            ..Default::default()
594        }
595    }
596
597    #[test]
598    fn build_theme_populates_both_variants() {
599        let theme = build_theme(
600            sample_light_defaults(),
601            sample_dark_defaults(),
602            &sample_widget_fonts(),
603        );
604
605        assert!(theme.light.is_some(), "light variant should be Some");
606        assert!(theme.dark.is_some(), "dark variant should be Some");
607
608        // Colors should differ between variants
609        let light = theme.light.as_ref().unwrap();
610        let dark = theme.dark.as_ref().unwrap();
611        assert_ne!(light.defaults.accent, dark.defaults.accent);
612        assert_ne!(light.defaults.background, dark.defaults.background);
613
614        // Fonts should be identical in both
615        assert_eq!(light.defaults.font, dark.defaults.font);
616    }
617
618    #[test]
619    fn build_theme_name_is_macos() {
620        let theme = build_theme(
621            sample_light_defaults(),
622            sample_dark_defaults(),
623            &sample_widget_fonts(),
624        );
625        assert_eq!(theme.name, "macOS");
626    }
627
628    #[test]
629    fn build_theme_fonts_populated() {
630        let defaults = crate::ThemeDefaults {
631            font: crate::FontSpec {
632                family: Some("SF Pro".to_string()),
633                size: Some(13.0),
634                weight: None,
635            },
636            mono_font: crate::FontSpec {
637                family: Some("SF Mono".to_string()),
638                size: Some(13.0),
639                weight: None,
640            },
641            ..Default::default()
642        };
643
644        let theme = build_theme(defaults.clone(), defaults, &sample_widget_fonts());
645
646        let light = theme.light.as_ref().unwrap();
647        assert_eq!(light.defaults.font.family.as_deref(), Some("SF Pro"));
648        assert_eq!(light.defaults.font.size, Some(13.0));
649        assert_eq!(light.defaults.mono_font.family.as_deref(), Some("SF Mono"));
650        assert_eq!(light.defaults.mono_font.size, Some(13.0));
651
652        let dark = theme.dark.as_ref().unwrap();
653        assert_eq!(dark.defaults.font.family.as_deref(), Some("SF Pro"));
654        assert_eq!(dark.defaults.font.size, Some(13.0));
655    }
656
657    #[test]
658    fn build_theme_defaults_empty_produces_nonempty_variant() {
659        let theme = build_theme(
660            crate::ThemeDefaults::default(),
661            crate::ThemeDefaults::default(),
662            &sample_widget_fonts(),
663        );
664
665        let light = theme.light.as_ref().unwrap();
666        // Variant should not be empty because widget defaults are populated
667        assert!(
668            !light.is_empty(),
669            "light variant should have widget defaults"
670        );
671
672        let dark = theme.dark.as_ref().unwrap();
673        assert!(!dark.is_empty(), "dark variant should have widget defaults");
674    }
675
676    #[test]
677    fn build_theme_colors_propagated_correctly() {
678        let blue = crate::Rgba::rgb(0, 122, 255);
679        let red = crate::Rgba::rgb(255, 59, 48);
680
681        let light_defaults = crate::ThemeDefaults {
682            accent: Some(blue),
683            ..Default::default()
684        };
685        let dark_defaults = crate::ThemeDefaults {
686            accent: Some(red),
687            ..Default::default()
688        };
689
690        let theme = build_theme(light_defaults, dark_defaults, &sample_widget_fonts());
691
692        let light = theme.light.as_ref().unwrap();
693        let dark = theme.dark.as_ref().unwrap();
694
695        assert_eq!(light.defaults.accent, Some(blue));
696        assert_eq!(dark.defaults.accent, Some(red));
697    }
698
699    #[test]
700    fn macos_widget_defaults_spot_check() {
701        let wv = macos_widget_defaults();
702        assert_eq!(
703            wv.button.min_height,
704            Some(22.0),
705            "NSButton regular control size"
706        );
707        assert_eq!(wv.scrollbar.width, Some(15.0), "NSScroller legacy style");
708        assert_eq!(
709            wv.checkbox.indicator_size,
710            Some(14.0),
711            "NSButton switch type"
712        );
713        assert_eq!(wv.slider.thumb_size, Some(21.0), "NSSlider circular knob");
714    }
715
716    #[test]
717    fn build_theme_has_icon_set_sf_symbols() {
718        let theme = build_theme(
719            sample_light_defaults(),
720            sample_dark_defaults(),
721            &sample_widget_fonts(),
722        );
723
724        let light = theme.light.as_ref().unwrap();
725        assert_eq!(light.icon_set.as_deref(), Some("sf-symbols"));
726
727        let dark = theme.dark.as_ref().unwrap();
728        assert_eq!(dark.icon_set.as_deref(), Some("sf-symbols"));
729    }
730
731    #[test]
732    fn build_theme_per_widget_fonts_populated() {
733        let wf = sample_widget_fonts();
734        let theme = build_theme(sample_light_defaults(), sample_dark_defaults(), &wf);
735
736        let light = theme.light.as_ref().unwrap();
737        assert_eq!(
738            light.menu.font.as_ref().unwrap().size,
739            Some(14.0),
740            "menu font size"
741        );
742        assert_eq!(
743            light.tooltip.font.as_ref().unwrap().size,
744            Some(11.0),
745            "tooltip font size"
746        );
747        assert_eq!(
748            light.window.title_bar_font.as_ref().unwrap().weight,
749            Some(700),
750            "title bar font weight"
751        );
752
753        // Both variants should have the same per-widget fonts
754        let dark = theme.dark.as_ref().unwrap();
755        assert_eq!(light.menu.font, dark.menu.font);
756        assert_eq!(light.tooltip.font, dark.tooltip.font);
757        assert_eq!(light.window.title_bar_font, dark.window.title_bar_font);
758    }
759
760    #[test]
761    fn build_theme_text_scale_populated() {
762        let theme = build_theme(
763            sample_light_defaults(),
764            sample_dark_defaults(),
765            &sample_widget_fonts(),
766        );
767
768        let light = theme.light.as_ref().unwrap();
769        assert!(light.text_scale.caption.is_some(), "caption should be set");
770        assert!(
771            light.text_scale.section_heading.is_some(),
772            "section_heading should be set"
773        );
774        assert!(
775            light.text_scale.dialog_title.is_some(),
776            "dialog_title should be set"
777        );
778        assert!(light.text_scale.display.is_some(), "display should be set");
779
780        // Both variants have the same text scale
781        let dark = theme.dark.as_ref().unwrap();
782        assert_eq!(light.text_scale, dark.text_scale);
783    }
784
785    #[test]
786    fn compute_text_scale_default_sizes() {
787        let ts = compute_text_scale(13.0);
788        assert_eq!(ts.caption.as_ref().unwrap().size, Some(11.0));
789        assert_eq!(ts.section_heading.as_ref().unwrap().size, Some(15.0));
790        assert_eq!(ts.dialog_title.as_ref().unwrap().size, Some(22.0));
791        assert_eq!(ts.display.as_ref().unwrap().size, Some(34.0));
792    }
793
794    #[test]
795    fn compute_text_scale_scaled_sizes() {
796        // If the system font is 26pt (2x default), text scale should also scale
797        let ts = compute_text_scale(26.0);
798        assert_eq!(ts.caption.as_ref().unwrap().size, Some(22.0));
799        assert_eq!(ts.section_heading.as_ref().unwrap().size, Some(30.0));
800        assert_eq!(ts.dialog_title.as_ref().unwrap().size, Some(44.0));
801        assert_eq!(ts.display.as_ref().unwrap().size, Some(68.0));
802    }
803
804    #[test]
805    fn compute_text_scale_weights() {
806        let ts = compute_text_scale(13.0);
807        assert_eq!(ts.caption.as_ref().unwrap().weight, Some(400));
808        assert_eq!(ts.section_heading.as_ref().unwrap().weight, Some(700));
809        assert_eq!(ts.dialog_title.as_ref().unwrap().weight, Some(700));
810        assert_eq!(ts.display.as_ref().unwrap().weight, Some(700));
811    }
812
813    #[test]
814    fn build_theme_per_widget_colors_not_populated_by_build() {
815        // build_theme does not populate per-widget colors -- that's done by
816        // from_macos() after build_theme returns. Verify they start as None.
817        let theme = build_theme(
818            sample_light_defaults(),
819            sample_dark_defaults(),
820            &sample_widget_fonts(),
821        );
822        let light = theme.light.as_ref().unwrap();
823        assert!(
824            light.input.placeholder.is_none(),
825            "placeholder starts None (set by from_macos)"
826        );
827        assert!(
828            light.list.alternate_row.is_none(),
829            "alternate_row starts None"
830        );
831        assert!(
832            light.list.header_foreground.is_none(),
833            "header_foreground starts None"
834        );
835        assert!(light.list.grid_color.is_none(), "grid_color starts None");
836    }
837
838    #[test]
839    fn build_theme_scrollbar_overlay_not_set_by_build() {
840        // Scrollbar overlay mode is set after build_theme by from_macos().
841        let theme = build_theme(
842            sample_light_defaults(),
843            sample_dark_defaults(),
844            &sample_widget_fonts(),
845        );
846        let light = theme.light.as_ref().unwrap();
847        assert!(
848            light.scrollbar.overlay_mode.is_none(),
849            "overlay_mode starts None (set by from_macos)"
850        );
851    }
852
853    #[test]
854    fn build_theme_dialog_button_order_not_set_by_build() {
855        // Dialog button order is set after build_theme by from_macos().
856        let theme = build_theme(
857            sample_light_defaults(),
858            sample_dark_defaults(),
859            &sample_widget_fonts(),
860        );
861        let light = theme.light.as_ref().unwrap();
862        assert!(
863            light.dialog.button_order.is_none(),
864            "button_order starts None (set by from_macos)"
865        );
866    }
867
868    #[test]
869    fn build_theme_accessibility_not_set_by_build() {
870        // Accessibility flags are set after build_theme by from_macos().
871        let theme = build_theme(
872            sample_light_defaults(),
873            sample_dark_defaults(),
874            &sample_widget_fonts(),
875        );
876        let light = theme.light.as_ref().unwrap();
877        assert!(light.defaults.reduce_motion.is_none());
878        assert!(light.defaults.high_contrast.is_none());
879        assert!(light.defaults.reduce_transparency.is_none());
880        assert!(light.defaults.text_scaling_factor.is_none());
881    }
882
883    #[test]
884    fn test_macos_resolve_validate() {
885        // Load macOS-sonoma preset as base (provides full color/geometry/spacing).
886        let mut base = crate::NativeTheme::preset("macos-sonoma").unwrap();
887        // Build reader output with sample data (simulates from_macos() on real hardware).
888        let reader_output = build_theme(
889            sample_light_defaults(),
890            sample_dark_defaults(),
891            &sample_widget_fonts(),
892        );
893        // Merge reader output on top of preset.
894        base.merge(&reader_output);
895
896        // Test light variant.
897        let mut light = base
898            .light
899            .clone()
900            .expect("light variant should exist after merge");
901        light.resolve();
902        let resolved = light.validate().unwrap_or_else(|e| {
903            panic!("macOS resolve/validate pipeline failed (light): {e}");
904        });
905
906        // Spot-check: reader-sourced fields present.
907        assert_eq!(
908            resolved.defaults.accent,
909            crate::Rgba::rgb(0, 122, 255),
910            "accent should be from macOS reader"
911        );
912        assert_eq!(
913            resolved.defaults.font.family, "SF Pro",
914            "font family should be from macOS reader"
915        );
916        assert_eq!(
917            resolved.icon_set, "sf-symbols",
918            "icon_set should be sf-symbols from macOS reader"
919        );
920
921        // Test dark variant too.
922        let mut dark = base
923            .dark
924            .clone()
925            .expect("dark variant should exist after merge");
926        dark.resolve();
927        let resolved_dark = dark.validate().unwrap_or_else(|e| {
928            panic!("macOS resolve/validate pipeline failed (dark): {e}");
929        });
930        assert_eq!(
931            resolved_dark.defaults.accent,
932            crate::Rgba::rgb(10, 132, 255),
933            "dark accent should be from macOS reader"
934        );
935    }
936}