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 ThemeSpec 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::ThemeSpec {
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(crate::IconSet::SfSymbols);
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(crate::IconSet::SfSymbols);
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::ThemeSpec {
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"))]
424#[must_use = "this returns the detected macOS theme; it does not apply it"]
425pub fn from_macos() -> crate::Result<crate::ThemeSpec> {
426    let light_name = NSString::from_str("NSAppearanceNameAqua");
427    let dark_name = NSString::from_str("NSAppearanceNameDarkAqua");
428
429    let light_appearance = NSAppearance::appearanceNamed(&light_name);
430    let dark_appearance = NSAppearance::appearanceNamed(&dark_name);
431
432    if light_appearance.is_none() && dark_appearance.is_none() {
433        return Err(crate::Error::Unavailable(
434            "neither light nor dark NSAppearance could be created".to_string(),
435        ));
436    }
437
438    // Read appearance-independent data once.
439    let (font, mono_font) = read_fonts();
440    let (menu_font, tooltip_font, title_bar_font) = read_per_widget_fonts();
441    let text_scale = read_text_scale();
442    let widget_fonts = WidgetFontData {
443        menu_font,
444        tooltip_font,
445        title_bar_font,
446        text_scale,
447    };
448
449    // Type alias for the appearance-block data.
450    type AppearanceData = (crate::ThemeDefaults, PerWidgetColors);
451
452    let (light_defaults, light_pw) = if let Some(app) = &light_appearance {
453        let data = std::cell::RefCell::new(None::<AppearanceData>);
454        {
455            let block = RcBlock::new(|| {
456                *data.borrow_mut() = Some(read_appearance_colors());
457            });
458            app.performAsCurrentDrawingAppearance(&block);
459        }
460        let (mut d, pw) = data.into_inner().unwrap_or_default();
461        d.font = font.clone();
462        d.mono_font = mono_font.clone();
463        (d, Some(pw))
464    } else {
465        (crate::ThemeDefaults::default(), None)
466    };
467
468    let (dark_defaults, dark_pw) = if let Some(app) = &dark_appearance {
469        let data = std::cell::RefCell::new(None::<AppearanceData>);
470        {
471            let block = RcBlock::new(|| {
472                *data.borrow_mut() = Some(read_appearance_colors());
473            });
474            app.performAsCurrentDrawingAppearance(&block);
475        }
476        let (mut d, pw) = data.into_inner().unwrap_or_default();
477        d.font = font;
478        d.mono_font = mono_font;
479        (d, Some(pw))
480    } else {
481        (crate::ThemeDefaults::default(), None)
482    };
483
484    let mut theme = build_theme(light_defaults, dark_defaults, &widget_fonts);
485
486    // Apply per-widget colors (appearance-dependent, per variant).
487    if let (Some(v), Some(pw)) = (&mut theme.light, light_pw) {
488        v.input.placeholder = pw.placeholder;
489        v.input.selection = pw.selection_inactive;
490        v.list.alternate_row = pw.alternate_row;
491        v.list.header_foreground = pw.header_foreground;
492        v.list.grid_color = pw.grid_color;
493        v.window.title_bar_foreground = pw.title_bar_foreground;
494    }
495    if let (Some(v), Some(pw)) = (&mut theme.dark, dark_pw) {
496        v.input.placeholder = pw.placeholder;
497        v.input.selection = pw.selection_inactive;
498        v.list.alternate_row = pw.alternate_row;
499        v.list.header_foreground = pw.header_foreground;
500        v.list.grid_color = pw.grid_color;
501        v.window.title_bar_foreground = pw.title_bar_foreground;
502    }
503
504    // Scrollbar overlay mode (appearance-independent, requires main thread).
505    let overlay_mode = objc2::MainThreadMarker::new().and_then(read_scrollbar_style);
506    if let Some(v) = &mut theme.light {
507        v.scrollbar.overlay_mode = overlay_mode;
508    }
509    if let Some(v) = &mut theme.dark {
510        v.scrollbar.overlay_mode = overlay_mode;
511    }
512
513    // Accessibility flags (appearance-independent).
514    let (reduce_motion, high_contrast, reduce_transparency, text_scaling_factor) =
515        read_accessibility();
516    for variant in [&mut theme.light, &mut theme.dark] {
517        if let Some(v) = variant {
518            v.defaults.reduce_motion = reduce_motion;
519            v.defaults.high_contrast = high_contrast;
520            v.defaults.reduce_transparency = reduce_transparency;
521            v.defaults.text_scaling_factor = text_scaling_factor;
522            // macOS uses leading affirmative (OK/Cancel) dialog button order.
523            v.dialog.button_order = Some(crate::DialogButtonOrder::LeadingAffirmative);
524        }
525    }
526
527    Ok(theme)
528}
529
530#[cfg(test)]
531#[allow(clippy::unwrap_used, clippy::expect_used)]
532mod tests {
533    use super::*;
534
535    fn sample_widget_fonts() -> WidgetFontData {
536        WidgetFontData {
537            menu_font: crate::FontSpec {
538                family: Some("SF Pro".to_string()),
539                size: Some(14.0),
540                weight: Some(400),
541            },
542            tooltip_font: crate::FontSpec {
543                family: Some("SF Pro".to_string()),
544                size: Some(11.0),
545                weight: Some(400),
546            },
547            title_bar_font: crate::FontSpec {
548                family: Some("SF Pro".to_string()),
549                size: Some(13.0),
550                weight: Some(700),
551            },
552            text_scale: compute_text_scale(13.0),
553        }
554    }
555
556    fn sample_light_defaults() -> crate::ThemeDefaults {
557        crate::ThemeDefaults {
558            accent: Some(crate::Rgba::rgb(0, 122, 255)),
559            background: Some(crate::Rgba::rgb(246, 246, 246)),
560            foreground: Some(crate::Rgba::rgb(0, 0, 0)),
561            surface: Some(crate::Rgba::rgb(255, 255, 255)),
562            border: Some(crate::Rgba::rgb(200, 200, 200)),
563            font: crate::FontSpec {
564                family: Some("SF Pro".to_string()),
565                size: Some(13.0),
566                weight: None,
567            },
568            mono_font: crate::FontSpec {
569                family: Some("SF Mono".to_string()),
570                size: Some(13.0),
571                weight: None,
572            },
573            ..Default::default()
574        }
575    }
576
577    fn sample_dark_defaults() -> crate::ThemeDefaults {
578        crate::ThemeDefaults {
579            accent: Some(crate::Rgba::rgb(10, 132, 255)),
580            background: Some(crate::Rgba::rgb(30, 30, 30)),
581            foreground: Some(crate::Rgba::rgb(255, 255, 255)),
582            surface: Some(crate::Rgba::rgb(44, 44, 46)),
583            border: Some(crate::Rgba::rgb(56, 56, 58)),
584            font: crate::FontSpec {
585                family: Some("SF Pro".to_string()),
586                size: Some(13.0),
587                weight: None,
588            },
589            mono_font: crate::FontSpec {
590                family: Some("SF Mono".to_string()),
591                size: Some(13.0),
592                weight: None,
593            },
594            ..Default::default()
595        }
596    }
597
598    #[test]
599    fn build_theme_populates_both_variants() {
600        let theme = build_theme(
601            sample_light_defaults(),
602            sample_dark_defaults(),
603            &sample_widget_fonts(),
604        );
605
606        assert!(theme.light.is_some(), "light variant should be Some");
607        assert!(theme.dark.is_some(), "dark variant should be Some");
608
609        // Colors should differ between variants
610        let light = theme.light.as_ref().unwrap();
611        let dark = theme.dark.as_ref().unwrap();
612        assert_ne!(light.defaults.accent, dark.defaults.accent);
613        assert_ne!(light.defaults.background, dark.defaults.background);
614
615        // Fonts should be identical in both
616        assert_eq!(light.defaults.font, dark.defaults.font);
617    }
618
619    #[test]
620    fn build_theme_name_is_macos() {
621        let theme = build_theme(
622            sample_light_defaults(),
623            sample_dark_defaults(),
624            &sample_widget_fonts(),
625        );
626        assert_eq!(theme.name, "macOS");
627    }
628
629    #[test]
630    fn build_theme_fonts_populated() {
631        let defaults = crate::ThemeDefaults {
632            font: crate::FontSpec {
633                family: Some("SF Pro".to_string()),
634                size: Some(13.0),
635                weight: None,
636            },
637            mono_font: crate::FontSpec {
638                family: Some("SF Mono".to_string()),
639                size: Some(13.0),
640                weight: None,
641            },
642            ..Default::default()
643        };
644
645        let theme = build_theme(defaults.clone(), defaults, &sample_widget_fonts());
646
647        let light = theme.light.as_ref().unwrap();
648        assert_eq!(light.defaults.font.family.as_deref(), Some("SF Pro"));
649        assert_eq!(light.defaults.font.size, Some(13.0));
650        assert_eq!(light.defaults.mono_font.family.as_deref(), Some("SF Mono"));
651        assert_eq!(light.defaults.mono_font.size, Some(13.0));
652
653        let dark = theme.dark.as_ref().unwrap();
654        assert_eq!(dark.defaults.font.family.as_deref(), Some("SF Pro"));
655        assert_eq!(dark.defaults.font.size, Some(13.0));
656    }
657
658    #[test]
659    fn build_theme_defaults_empty_produces_nonempty_variant() {
660        let theme = build_theme(
661            crate::ThemeDefaults::default(),
662            crate::ThemeDefaults::default(),
663            &sample_widget_fonts(),
664        );
665
666        let light = theme.light.as_ref().unwrap();
667        // Variant should not be empty because widget defaults are populated
668        assert!(
669            !light.is_empty(),
670            "light variant should have widget defaults"
671        );
672
673        let dark = theme.dark.as_ref().unwrap();
674        assert!(!dark.is_empty(), "dark variant should have widget defaults");
675    }
676
677    #[test]
678    fn build_theme_colors_propagated_correctly() {
679        let blue = crate::Rgba::rgb(0, 122, 255);
680        let red = crate::Rgba::rgb(255, 59, 48);
681
682        let light_defaults = crate::ThemeDefaults {
683            accent: Some(blue),
684            ..Default::default()
685        };
686        let dark_defaults = crate::ThemeDefaults {
687            accent: Some(red),
688            ..Default::default()
689        };
690
691        let theme = build_theme(light_defaults, dark_defaults, &sample_widget_fonts());
692
693        let light = theme.light.as_ref().unwrap();
694        let dark = theme.dark.as_ref().unwrap();
695
696        assert_eq!(light.defaults.accent, Some(blue));
697        assert_eq!(dark.defaults.accent, Some(red));
698    }
699
700    #[test]
701    fn macos_widget_defaults_spot_check() {
702        let wv = macos_widget_defaults();
703        assert_eq!(
704            wv.button.min_height,
705            Some(22.0),
706            "NSButton regular control size"
707        );
708        assert_eq!(wv.scrollbar.width, Some(15.0), "NSScroller legacy style");
709        assert_eq!(
710            wv.checkbox.indicator_size,
711            Some(14.0),
712            "NSButton switch type"
713        );
714        assert_eq!(wv.slider.thumb_size, Some(21.0), "NSSlider circular knob");
715    }
716
717    #[test]
718    fn build_theme_has_icon_set_sf_symbols() {
719        let theme = build_theme(
720            sample_light_defaults(),
721            sample_dark_defaults(),
722            &sample_widget_fonts(),
723        );
724
725        let light = theme.light.as_ref().unwrap();
726        assert_eq!(light.icon_set, Some(crate::IconSet::SfSymbols));
727
728        let dark = theme.dark.as_ref().unwrap();
729        assert_eq!(dark.icon_set, Some(crate::IconSet::SfSymbols));
730    }
731
732    #[test]
733    fn build_theme_per_widget_fonts_populated() {
734        let wf = sample_widget_fonts();
735        let theme = build_theme(sample_light_defaults(), sample_dark_defaults(), &wf);
736
737        let light = theme.light.as_ref().unwrap();
738        assert_eq!(
739            light.menu.font.as_ref().unwrap().size,
740            Some(14.0),
741            "menu font size"
742        );
743        assert_eq!(
744            light.tooltip.font.as_ref().unwrap().size,
745            Some(11.0),
746            "tooltip font size"
747        );
748        assert_eq!(
749            light.window.title_bar_font.as_ref().unwrap().weight,
750            Some(700),
751            "title bar font weight"
752        );
753
754        // Both variants should have the same per-widget fonts
755        let dark = theme.dark.as_ref().unwrap();
756        assert_eq!(light.menu.font, dark.menu.font);
757        assert_eq!(light.tooltip.font, dark.tooltip.font);
758        assert_eq!(light.window.title_bar_font, dark.window.title_bar_font);
759    }
760
761    #[test]
762    fn build_theme_text_scale_populated() {
763        let theme = build_theme(
764            sample_light_defaults(),
765            sample_dark_defaults(),
766            &sample_widget_fonts(),
767        );
768
769        let light = theme.light.as_ref().unwrap();
770        assert!(light.text_scale.caption.is_some(), "caption should be set");
771        assert!(
772            light.text_scale.section_heading.is_some(),
773            "section_heading should be set"
774        );
775        assert!(
776            light.text_scale.dialog_title.is_some(),
777            "dialog_title should be set"
778        );
779        assert!(light.text_scale.display.is_some(), "display should be set");
780
781        // Both variants have the same text scale
782        let dark = theme.dark.as_ref().unwrap();
783        assert_eq!(light.text_scale, dark.text_scale);
784    }
785
786    #[test]
787    fn compute_text_scale_default_sizes() {
788        let ts = compute_text_scale(13.0);
789        assert_eq!(ts.caption.as_ref().unwrap().size, Some(11.0));
790        assert_eq!(ts.section_heading.as_ref().unwrap().size, Some(15.0));
791        assert_eq!(ts.dialog_title.as_ref().unwrap().size, Some(22.0));
792        assert_eq!(ts.display.as_ref().unwrap().size, Some(34.0));
793    }
794
795    #[test]
796    fn compute_text_scale_scaled_sizes() {
797        // If the system font is 26pt (2x default), text scale should also scale
798        let ts = compute_text_scale(26.0);
799        assert_eq!(ts.caption.as_ref().unwrap().size, Some(22.0));
800        assert_eq!(ts.section_heading.as_ref().unwrap().size, Some(30.0));
801        assert_eq!(ts.dialog_title.as_ref().unwrap().size, Some(44.0));
802        assert_eq!(ts.display.as_ref().unwrap().size, Some(68.0));
803    }
804
805    #[test]
806    fn compute_text_scale_weights() {
807        let ts = compute_text_scale(13.0);
808        assert_eq!(ts.caption.as_ref().unwrap().weight, Some(400));
809        assert_eq!(ts.section_heading.as_ref().unwrap().weight, Some(700));
810        assert_eq!(ts.dialog_title.as_ref().unwrap().weight, Some(700));
811        assert_eq!(ts.display.as_ref().unwrap().weight, Some(700));
812    }
813
814    #[test]
815    fn build_theme_per_widget_colors_not_populated_by_build() {
816        // build_theme does not populate per-widget colors -- that's done by
817        // from_macos() after build_theme returns. Verify they start as None.
818        let theme = build_theme(
819            sample_light_defaults(),
820            sample_dark_defaults(),
821            &sample_widget_fonts(),
822        );
823        let light = theme.light.as_ref().unwrap();
824        assert!(
825            light.input.placeholder.is_none(),
826            "placeholder starts None (set by from_macos)"
827        );
828        assert!(
829            light.list.alternate_row.is_none(),
830            "alternate_row starts None"
831        );
832        assert!(
833            light.list.header_foreground.is_none(),
834            "header_foreground starts None"
835        );
836        assert!(light.list.grid_color.is_none(), "grid_color starts None");
837    }
838
839    #[test]
840    fn build_theme_scrollbar_overlay_not_set_by_build() {
841        // Scrollbar overlay mode is set after build_theme by from_macos().
842        let theme = build_theme(
843            sample_light_defaults(),
844            sample_dark_defaults(),
845            &sample_widget_fonts(),
846        );
847        let light = theme.light.as_ref().unwrap();
848        assert!(
849            light.scrollbar.overlay_mode.is_none(),
850            "overlay_mode starts None (set by from_macos)"
851        );
852    }
853
854    #[test]
855    fn build_theme_dialog_button_order_not_set_by_build() {
856        // Dialog button order is set after build_theme by from_macos().
857        let theme = build_theme(
858            sample_light_defaults(),
859            sample_dark_defaults(),
860            &sample_widget_fonts(),
861        );
862        let light = theme.light.as_ref().unwrap();
863        assert!(
864            light.dialog.button_order.is_none(),
865            "button_order starts None (set by from_macos)"
866        );
867    }
868
869    #[test]
870    fn build_theme_accessibility_not_set_by_build() {
871        // Accessibility flags are set after build_theme by from_macos().
872        let theme = build_theme(
873            sample_light_defaults(),
874            sample_dark_defaults(),
875            &sample_widget_fonts(),
876        );
877        let light = theme.light.as_ref().unwrap();
878        assert!(light.defaults.reduce_motion.is_none());
879        assert!(light.defaults.high_contrast.is_none());
880        assert!(light.defaults.reduce_transparency.is_none());
881        assert!(light.defaults.text_scaling_factor.is_none());
882    }
883
884    #[test]
885    fn test_macos_resolve_validate() {
886        // Load macOS-sonoma preset as base (provides full color/geometry/spacing).
887        let mut base = crate::ThemeSpec::preset("macos-sonoma").unwrap();
888        // Build reader output with sample data (simulates from_macos() on real hardware).
889        let reader_output = build_theme(
890            sample_light_defaults(),
891            sample_dark_defaults(),
892            &sample_widget_fonts(),
893        );
894        // Merge reader output on top of preset.
895        base.merge(&reader_output);
896
897        // Test light variant.
898        let mut light = base
899            .light
900            .clone()
901            .expect("light variant should exist after merge");
902        light.resolve_all();
903        let resolved = light.validate().unwrap_or_else(|e| {
904            panic!("macOS resolve/validate pipeline failed (light): {e}");
905        });
906
907        // Spot-check: reader-sourced fields present.
908        assert_eq!(
909            resolved.defaults.accent,
910            crate::Rgba::rgb(0, 122, 255),
911            "accent should be from macOS reader"
912        );
913        assert_eq!(
914            resolved.defaults.font.family, "SF Pro",
915            "font family should be from macOS reader"
916        );
917        assert_eq!(
918            resolved.icon_set,
919            crate::IconSet::SfSymbols,
920            "icon_set should be SfSymbols from macOS reader"
921        );
922
923        // Test dark variant too.
924        let mut dark = base
925            .dark
926            .clone()
927            .expect("dark variant should exist after merge");
928        dark.resolve_all();
929        let resolved_dark = dark.validate().unwrap_or_else(|e| {
930            panic!("macOS resolve/validate pipeline failed (dark): {e}");
931        });
932        assert_eq!(
933            resolved_dark.defaults.accent,
934            crate::Rgba::rgb(10, 132, 255),
935            "dark accent should be from macOS reader"
936        );
937    }
938}