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::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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let mut base = crate::ThemeSpec::preset("macos-sonoma").unwrap();
888 let reader_output = build_theme(
890 sample_light_defaults(),
891 sample_dark_defaults(),
892 &sample_widget_fonts(),
893 );
894 base.merge(&reader_output);
896
897 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 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 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}