1#![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#[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#[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#[cfg(all(target_os = "macos", feature = "macos"))]
52fn read_appearance_colors() -> (crate::ThemeDefaults, PerWidgetColors) {
53 let srgb = NSColorSpace::sRGBColorSpace();
54
55 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 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 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#[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#[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 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#[cfg(all(target_os = "macos", feature = "macos"))]
170fn nsfont_weight_to_css(font: &NSFont) -> Option<u16> {
171 let descriptor = font.fontDescriptor();
172 let traits_key: &NSString = unsafe { NSFontTraitsAttribute };
174 let traits_obj = descriptor.objectForKey(traits_key)?;
175 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 let weight_num: &NSNumber = unsafe { &*(&*weight_obj as *const _ as *const NSNumber) };
182 let w = weight_num.doubleValue();
183
184 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#[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#[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#[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#[cfg_attr(not(all(target_os = "macos", feature = "macos")), allow(dead_code))]
257fn compute_text_scale(system_size: f32) -> crate::TextScale {
258 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#[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#[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), padding_horizontal: Some(12.0),
306 ..Default::default()
307 },
308 checkbox: crate::CheckboxTheme {
309 indicator_size: Some(14.0), spacing: Some(4.0),
311 ..Default::default()
312 },
313 input: crate::InputTheme {
314 min_height: Some(22.0), padding_horizontal: Some(4.0),
316 ..Default::default()
317 },
318 scrollbar: crate::ScrollbarTheme {
319 width: Some(15.0), slider_width: Some(7.0), ..Default::default()
322 },
323 slider: crate::SliderTheme {
324 track_height: Some(4.0), thumb_size: Some(21.0),
326 ..Default::default()
327 },
328 progress_bar: crate::ProgressBarTheme {
329 height: Some(6.0), ..Default::default()
331 },
332 tab: crate::TabTheme {
333 min_height: Some(24.0), padding_horizontal: Some(12.0),
335 ..Default::default()
336 },
337 menu: crate::MenuTheme {
338 item_height: Some(22.0), 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), padding_horizontal: Some(4.0),
350 ..Default::default()
351 },
352 toolbar: crate::ToolbarTheme {
353 height: Some(38.0), item_spacing: Some(8.0),
355 ..Default::default()
356 },
357 splitter: crate::SplitterTheme {
358 width: Some(9.0), },
360 ..Default::default()
361 }
362}
363
364#[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#[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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let mut base = crate::NativeTheme::preset("macos-sonoma").unwrap();
887 let reader_output = build_theme(
889 sample_light_defaults(),
890 sample_dark_defaults(),
891 &sample_widget_fonts(),
892 );
893 base.merge(&reader_output);
895
896 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 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 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}