1#[cfg(all(target_os = "windows", feature = "windows"))]
8use ::windows::UI::ViewManagement::{UIColorType, UISettings};
9#[cfg(all(target_os = "windows", feature = "windows"))]
10use ::windows::Win32::UI::HiDpi::{GetDpiForSystem, GetSystemMetricsForDpi};
11#[cfg(all(target_os = "windows", feature = "windows"))]
12use ::windows::Win32::UI::WindowsAndMessaging::{
13 NONCLIENTMETRICSW, SM_CXBORDER, SM_CXFOCUSBORDER, SM_CXICON, SM_CXSMICON, SM_CXVSCROLL,
14 SM_CYMENU, SM_CYVTHUMB, SPI_GETNONCLIENTMETRICS, SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS,
15 SystemParametersInfoW,
16};
17
18use crate::model::FontSpec;
19
20struct AllFonts {
28 msg: FontSpec,
29 caption: FontSpec,
30 menu: FontSpec,
31 status: FontSpec,
32}
33
34struct SysColors {
38 btn_face: crate::Rgba,
39 btn_text: crate::Rgba,
40 menu_bg: crate::Rgba,
41 menu_text: crate::Rgba,
42 info_bg: crate::Rgba,
43 info_text: crate::Rgba,
44 window_bg: crate::Rgba,
45 window_text: crate::Rgba,
46 highlight: crate::Rgba,
47 highlight_text: crate::Rgba,
48 caption_text: crate::Rgba,
49 inactive_caption_text: crate::Rgba,
50 gray_text: crate::Rgba,
51}
52
53struct AccessibilityData {
55 text_scaling_factor: Option<f32>,
56 high_contrast: Option<bool>,
57 reduce_motion: Option<bool>,
58}
59
60#[cfg(all(target_os = "windows", feature = "windows"))]
62fn win_color_to_rgba(c: ::windows::UI::Color) -> crate::Rgba {
63 crate::Rgba::rgba(c.R, c.G, c.B, c.A)
64}
65
66fn is_dark_mode(fg: &crate::Rgba) -> bool {
71 let luma = 0.299 * (fg.r as f32) + 0.587 * (fg.g as f32) + 0.114 * (fg.b as f32);
72 luma > 128.0
73}
74
75#[cfg(all(target_os = "windows", feature = "windows"))]
81fn read_accent_shades(settings: &UISettings) -> [Option<crate::Rgba>; 6] {
82 let variants = [
83 UIColorType::AccentDark1,
84 UIColorType::AccentDark2,
85 UIColorType::AccentDark3,
86 UIColorType::AccentLight1,
87 UIColorType::AccentLight2,
88 UIColorType::AccentLight3,
89 ];
90 variants.map(|ct| settings.GetColorValue(ct).ok().map(win_color_to_rgba))
91}
92
93#[cfg(all(target_os = "windows", feature = "windows"))]
99fn logfont_to_fontspec(lf: &::windows::Win32::Graphics::Gdi::LOGFONTW, dpi: u32) -> FontSpec {
100 logfont_to_fontspec_raw(&lf.lfFaceName, lf.lfHeight, lf.lfWeight, dpi)
101}
102
103fn logfont_to_fontspec_raw(
105 face_name: &[u16; 32],
106 lf_height: i32,
107 lf_weight: i32,
108 dpi: u32,
109) -> FontSpec {
110 let face_end = face_name.iter().position(|&c| c == 0).unwrap_or(32);
111 let family = String::from_utf16_lossy(&face_name[..face_end]);
112 let points = (lf_height.unsigned_abs() * 72) / dpi;
113 let weight = (lf_weight.clamp(100, 900)) as u16;
114 FontSpec {
115 family: Some(family),
116 size: Some(points as f32),
117 weight: Some(weight),
118 }
119}
120
121#[cfg(all(target_os = "windows", feature = "windows"))]
126#[allow(unsafe_code)]
127fn read_all_system_fonts(dpi: u32) -> AllFonts {
128 let mut ncm = NONCLIENTMETRICSW::default();
129 ncm.cbSize = std::mem::size_of::<NONCLIENTMETRICSW>() as u32;
130
131 let success = unsafe {
132 SystemParametersInfoW(
133 SPI_GETNONCLIENTMETRICS,
134 ncm.cbSize,
135 Some(&mut ncm as *mut _ as *mut _),
136 SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0),
137 )
138 };
139
140 if success.is_ok() {
141 AllFonts {
142 msg: logfont_to_fontspec(&ncm.lfMessageFont, dpi),
143 caption: logfont_to_fontspec(&ncm.lfCaptionFont, dpi),
144 menu: logfont_to_fontspec(&ncm.lfMenuFont, dpi),
145 status: logfont_to_fontspec(&ncm.lfStatusFont, dpi),
146 }
147 } else {
148 AllFonts {
149 msg: FontSpec::default(),
150 caption: FontSpec::default(),
151 menu: FontSpec::default(),
152 status: FontSpec::default(),
153 }
154 }
155}
156
157fn compute_text_scale(base_size: f32) -> crate::TextScale {
162 crate::TextScale {
163 caption: Some(crate::TextScaleEntry {
164 size: Some(base_size * 0.85),
165 weight: Some(400),
166 line_height: None,
167 }),
168 section_heading: Some(crate::TextScaleEntry {
169 size: Some(base_size * 1.15),
170 weight: Some(600),
171 line_height: None,
172 }),
173 dialog_title: Some(crate::TextScaleEntry {
174 size: Some(base_size * 1.35),
175 weight: Some(600),
176 line_height: None,
177 }),
178 display: Some(crate::TextScaleEntry {
179 size: Some(base_size * 1.80),
180 weight: Some(300),
181 line_height: None,
182 }),
183 }
184}
185
186fn winui3_spacing() -> crate::ThemeSpacing {
191 crate::ThemeSpacing {
192 xxs: Some(2.0),
193 xs: Some(4.0),
194 s: Some(8.0),
195 m: Some(12.0),
196 l: Some(16.0),
197 xl: Some(24.0),
198 xxl: Some(32.0),
199 }
200}
201
202#[cfg(all(target_os = "windows", feature = "windows"))]
206#[allow(unsafe_code)]
207fn read_dpi() -> u32 {
208 unsafe { GetDpiForSystem() }
209}
210
211#[cfg(all(target_os = "windows", feature = "windows"))]
213#[allow(unsafe_code)]
214fn read_frame_width(dpi: u32) -> f32 {
215 unsafe { GetSystemMetricsForDpi(SM_CXBORDER, dpi) as f32 }
216}
217
218#[cfg(all(target_os = "windows", feature = "windows"))]
220#[allow(unsafe_code)]
221fn read_widget_sizing(dpi: u32, variant: &mut crate::ThemeVariant) {
222 unsafe {
223 variant.scrollbar.width = Some(GetSystemMetricsForDpi(SM_CXVSCROLL, dpi) as f32);
224 variant.scrollbar.min_thumb_height = Some(GetSystemMetricsForDpi(SM_CYVTHUMB, dpi) as f32);
225 variant.menu.item_height = Some(GetSystemMetricsForDpi(SM_CYMENU, dpi) as f32);
226 variant.defaults.focus_ring_width =
227 Some(GetSystemMetricsForDpi(SM_CXFOCUSBORDER, dpi) as f32);
228 }
229 variant.button.min_height = Some(32.0);
231 variant.button.padding_horizontal = Some(12.0);
232 variant.checkbox.indicator_size = Some(20.0);
233 variant.checkbox.spacing = Some(8.0);
234 variant.input.min_height = Some(32.0);
235 variant.input.padding_horizontal = Some(12.0);
236 variant.slider.track_height = Some(4.0);
237 variant.slider.thumb_size = Some(22.0);
238 variant.progress_bar.height = Some(4.0);
239 variant.tab.min_height = Some(32.0);
240 variant.tab.padding_horizontal = Some(12.0);
241 variant.menu.padding_horizontal = Some(12.0);
242 variant.tooltip.padding_horizontal = Some(8.0);
243 variant.tooltip.padding_vertical = Some(8.0);
244 variant.list.item_height = Some(40.0);
245 variant.list.padding_horizontal = Some(12.0);
246 variant.toolbar.height = Some(48.0);
247 variant.toolbar.item_spacing = Some(4.0);
248 variant.splitter.width = Some(4.0);
249}
250
251#[cfg(not(all(target_os = "windows", feature = "windows")))]
253fn read_widget_sizing(_dpi: u32, variant: &mut crate::ThemeVariant) {
254 variant.scrollbar.width = Some(17.0);
255 variant.scrollbar.min_thumb_height = Some(40.0);
256 variant.menu.item_height = Some(32.0);
257 variant.defaults.focus_ring_width = Some(1.0); variant.button.min_height = Some(32.0);
259 variant.button.padding_horizontal = Some(12.0);
260 variant.checkbox.indicator_size = Some(20.0);
261 variant.checkbox.spacing = Some(8.0);
262 variant.input.min_height = Some(32.0);
263 variant.input.padding_horizontal = Some(12.0);
264 variant.slider.track_height = Some(4.0);
265 variant.slider.thumb_size = Some(22.0);
266 variant.progress_bar.height = Some(4.0);
267 variant.tab.min_height = Some(32.0);
268 variant.tab.padding_horizontal = Some(12.0);
269 variant.menu.padding_horizontal = Some(12.0);
270 variant.tooltip.padding_horizontal = Some(8.0);
271 variant.tooltip.padding_vertical = Some(8.0);
272 variant.list.item_height = Some(40.0);
273 variant.list.padding_horizontal = Some(12.0);
274 variant.toolbar.height = Some(48.0);
275 variant.toolbar.item_spacing = Some(4.0);
276 variant.splitter.width = Some(4.0);
277}
278
279pub(crate) fn colorref_to_rgba(c: u32) -> crate::Rgba {
284 let r = (c & 0xFF) as u8;
285 let g = ((c >> 8) & 0xFF) as u8;
286 let b = ((c >> 16) & 0xFF) as u8;
287 crate::Rgba::rgb(r, g, b)
288}
289
290#[cfg(all(target_os = "windows", feature = "windows"))]
292#[allow(unsafe_code)]
293fn read_sys_colors() -> SysColors {
294 use ::windows::Win32::Graphics::Gdi::*;
295
296 fn sys_color(index: SYS_COLOR_INDEX) -> crate::Rgba {
297 let c = unsafe { GetSysColor(index) };
298 colorref_to_rgba(c)
299 }
300
301 SysColors {
302 btn_face: sys_color(COLOR_BTNFACE),
303 btn_text: sys_color(COLOR_BTNTEXT),
304 menu_bg: sys_color(COLOR_MENU),
305 menu_text: sys_color(COLOR_MENUTEXT),
306 info_bg: sys_color(COLOR_INFOBK),
307 info_text: sys_color(COLOR_INFOTEXT),
308 window_bg: sys_color(COLOR_WINDOW),
309 window_text: sys_color(COLOR_WINDOWTEXT),
310 highlight: sys_color(COLOR_HIGHLIGHT),
311 highlight_text: sys_color(COLOR_HIGHLIGHTTEXT),
312 caption_text: sys_color(COLOR_CAPTIONTEXT),
313 inactive_caption_text: sys_color(COLOR_INACTIVECAPTIONTEXT),
314 gray_text: sys_color(COLOR_GRAYTEXT),
315 }
316}
317
318fn apply_sys_colors(variant: &mut crate::ThemeVariant, colors: &SysColors) {
320 variant.button.background = Some(colors.btn_face);
321 variant.button.foreground = Some(colors.btn_text);
322 variant.menu.background = Some(colors.menu_bg);
323 variant.menu.foreground = Some(colors.menu_text);
324 variant.tooltip.background = Some(colors.info_bg);
325 variant.tooltip.foreground = Some(colors.info_text);
326 variant.input.background = Some(colors.window_bg);
327 variant.input.foreground = Some(colors.window_text);
328 variant.input.placeholder = Some(colors.gray_text);
329 variant.list.selection = Some(colors.highlight);
330 variant.list.selection_foreground = Some(colors.highlight_text);
331 variant.window.title_bar_foreground = Some(colors.caption_text);
332 variant.window.inactive_title_bar_foreground = Some(colors.inactive_caption_text);
333}
334
335#[cfg(all(target_os = "windows", feature = "windows"))]
337#[allow(unsafe_code)]
338fn read_dwm_colorization() -> Option<crate::Rgba> {
339 use ::windows::Win32::Graphics::Dwm::DwmGetColorizationColor;
340 let mut colorization: u32 = 0;
341 let mut opaque_blend = ::windows::core::BOOL::default();
342 unsafe { DwmGetColorizationColor(&mut colorization, &mut opaque_blend) }.ok()?;
343 let a = ((colorization >> 24) & 0xFF) as u8;
345 let r = ((colorization >> 16) & 0xFF) as u8;
346 let g = ((colorization >> 8) & 0xFF) as u8;
347 let b = (colorization & 0xFF) as u8;
348 Some(crate::Rgba::rgba(r, g, b, a))
349}
350
351fn dwm_color_to_rgba(c: u32) -> crate::Rgba {
353 let a = ((c >> 24) & 0xFF) as u8;
354 let r = ((c >> 16) & 0xFF) as u8;
355 let g = ((c >> 8) & 0xFF) as u8;
356 let b = (c & 0xFF) as u8;
357 crate::Rgba::rgba(r, g, b, a)
358}
359
360#[cfg(all(target_os = "windows", feature = "windows"))]
362#[allow(unsafe_code)]
363fn read_inactive_caption_color() -> crate::Rgba {
364 use ::windows::Win32::Graphics::Gdi::{COLOR_INACTIVECAPTION, GetSysColor};
365 let c = unsafe { GetSysColor(COLOR_INACTIVECAPTION) };
366 colorref_to_rgba(c)
367}
368
369#[cfg(all(target_os = "windows", feature = "windows"))]
371#[allow(unsafe_code)]
372fn read_accessibility(settings: &UISettings) -> AccessibilityData {
373 let text_scaling_factor = settings.TextScaleFactor().ok().map(|f| f as f32);
375
376 let high_contrast = {
378 use ::windows::Win32::UI::Accessibility::{HCF_HIGHCONTRASTON, HIGHCONTRASTW};
379 use ::windows::Win32::UI::WindowsAndMessaging::*;
380 let mut hc = HIGHCONTRASTW::default();
381 hc.cbSize = std::mem::size_of::<HIGHCONTRASTW>() as u32;
382 let success = unsafe {
383 SystemParametersInfoW(
384 SPI_GETHIGHCONTRAST,
385 hc.cbSize,
386 Some(&mut hc as *mut _ as *mut _),
387 SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0),
388 )
389 };
390 if success.is_ok() {
391 Some(hc.dwFlags.contains(HCF_HIGHCONTRASTON))
392 } else {
393 None
394 }
395 };
396
397 let reduce_motion = {
399 let mut animation_enabled = ::windows::core::BOOL(1);
400 let success = unsafe {
401 SystemParametersInfoW(
402 ::windows::Win32::UI::WindowsAndMessaging::SPI_GETCLIENTAREAANIMATION,
403 0,
404 Some(&mut animation_enabled as *mut _ as *mut _),
405 SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0),
406 )
407 };
408 if success.is_ok() {
409 Some(!animation_enabled.as_bool())
411 } else {
412 None
413 }
414 };
415
416 AccessibilityData {
417 text_scaling_factor,
418 high_contrast,
419 reduce_motion,
420 }
421}
422
423#[cfg(all(target_os = "windows", feature = "windows"))]
425#[allow(unsafe_code)]
426fn read_icon_sizes(dpi: u32) -> (f32, f32) {
427 let small = unsafe { GetSystemMetricsForDpi(SM_CXSMICON, dpi) } as f32;
428 let large = unsafe { GetSystemMetricsForDpi(SM_CXICON, dpi) } as f32;
429 (small, large)
430}
431
432#[allow(clippy::too_many_arguments)]
440fn build_theme(
441 accent: crate::Rgba,
442 fg: crate::Rgba,
443 bg: crate::Rgba,
444 accent_shades: [Option<crate::Rgba>; 6],
445 fonts: AllFonts,
446 sys_colors: Option<&SysColors>,
447 dwm_title_bar: Option<crate::Rgba>,
448 inactive_title_bar: Option<crate::Rgba>,
449 icon_sizes: Option<(f32, f32)>,
450 accessibility: Option<&AccessibilityData>,
451 dpi: u32,
452) -> crate::NativeTheme {
453 let dark = is_dark_mode(&fg);
454
455 let primary_bg = if dark {
458 accent_shades[3].unwrap_or(accent)
459 } else {
460 accent_shades[0].unwrap_or(accent)
461 };
462
463 let mut variant = crate::ThemeVariant::default();
464
465 variant.defaults.accent = Some(accent);
467 variant.defaults.foreground = Some(fg);
468 variant.defaults.background = Some(bg);
469 variant.defaults.selection = Some(accent);
470 variant.defaults.focus_ring_color = Some(accent);
471 variant.defaults.surface = Some(bg);
472 variant.button.primary_bg = Some(primary_bg);
473 variant.button.primary_fg = Some(fg);
474
475 let disabled_r = ((fg.r as u16 + bg.r as u16) / 2) as u8;
477 let disabled_g = ((fg.g as u16 + bg.g as u16) / 2) as u8;
478 let disabled_b = ((fg.b as u16 + bg.b as u16) / 2) as u8;
479 variant.defaults.disabled_foreground =
480 Some(crate::Rgba::rgb(disabled_r, disabled_g, disabled_b));
481
482 variant.defaults.font = fonts.msg;
484
485 variant.window.title_bar_font = Some(fonts.caption);
487 variant.menu.font = Some(fonts.menu);
488 variant.status_bar.font = Some(fonts.status);
489
490 if let Some(base_size) = variant.defaults.font.size {
492 variant.text_scale = compute_text_scale(base_size);
493 }
494
495 variant.defaults.spacing = winui3_spacing();
497
498 variant.defaults.radius = Some(4.0);
500 variant.defaults.radius_lg = Some(8.0);
501 variant.defaults.shadow_enabled = Some(true);
502
503 read_widget_sizing(dpi, &mut variant);
505
506 variant.dialog.button_order = Some(crate::model::DialogButtonOrder::TrailingAffirmative);
508
509 if let Some(color) = dwm_title_bar {
511 variant.window.title_bar_background = Some(color);
512 }
513 if let Some(color) = inactive_title_bar {
514 variant.window.inactive_title_bar_background = Some(color);
515 }
516
517 if let Some(colors) = sys_colors {
519 apply_sys_colors(&mut variant, colors);
520 }
521
522 if let Some((small, large)) = icon_sizes {
524 variant.defaults.icon_sizes.small = Some(small);
525 variant.defaults.icon_sizes.large = Some(large);
526 }
527
528 if let Some(a) = accessibility {
530 variant.defaults.text_scaling_factor = a.text_scaling_factor;
531 variant.defaults.high_contrast = a.high_contrast;
532 variant.defaults.reduce_motion = a.reduce_motion;
533 }
534
535 if dark {
536 crate::NativeTheme {
537 name: "Windows".to_string(),
538 light: None,
539 dark: Some(variant),
540 }
541 } else {
542 crate::NativeTheme {
543 name: "Windows".to_string(),
544 light: Some(variant),
545 dark: None,
546 }
547 }
548}
549
550#[cfg(all(target_os = "windows", feature = "windows"))]
560pub fn from_windows() -> crate::Result<crate::NativeTheme> {
561 let settings = UISettings::new()
562 .map_err(|e| crate::Error::Unavailable(format!("UISettings unavailable: {e}")))?;
563
564 let accent = settings
565 .GetColorValue(UIColorType::Accent)
566 .map(win_color_to_rgba)
567 .map_err(|e| crate::Error::Unavailable(format!("GetColorValue(Accent) failed: {e}")))?;
568 let fg = settings
569 .GetColorValue(UIColorType::Foreground)
570 .map(win_color_to_rgba)
571 .map_err(|e| crate::Error::Unavailable(format!("GetColorValue(Foreground) failed: {e}")))?;
572 let bg = settings
573 .GetColorValue(UIColorType::Background)
574 .map(win_color_to_rgba)
575 .map_err(|e| crate::Error::Unavailable(format!("GetColorValue(Background) failed: {e}")))?;
576
577 let accent_shades = read_accent_shades(&settings);
578 let dpi = read_dpi();
579 let fonts = read_all_system_fonts(dpi);
580 let sys_colors = read_sys_colors();
581 let dwm_title_bar = read_dwm_colorization();
582 let inactive_title_bar = Some(read_inactive_caption_color());
583 let (small, large) = read_icon_sizes(dpi);
584 let accessibility = read_accessibility(&settings);
585
586 Ok(build_theme(
587 accent,
588 fg,
589 bg,
590 accent_shades,
591 fonts,
592 Some(&sys_colors),
593 dwm_title_bar,
594 inactive_title_bar,
595 Some((small, large)),
596 Some(&accessibility),
597 dpi,
598 ))
599}
600
601#[cfg(test)]
602#[allow(clippy::unwrap_used, clippy::expect_used)]
603mod tests {
604 use super::*;
605
606 fn default_fonts() -> AllFonts {
608 AllFonts {
609 msg: FontSpec::default(),
610 caption: FontSpec::default(),
611 menu: FontSpec::default(),
612 status: FontSpec::default(),
613 }
614 }
615
616 fn named_fonts() -> AllFonts {
618 AllFonts {
619 msg: FontSpec {
620 family: Some("Segoe UI".to_string()),
621 size: Some(9.0),
622 weight: Some(400),
623 },
624 caption: FontSpec {
625 family: Some("Segoe UI".to_string()),
626 size: Some(9.0),
627 weight: Some(700),
628 },
629 menu: FontSpec {
630 family: Some("Segoe UI".to_string()),
631 size: Some(9.0),
632 weight: Some(400),
633 },
634 status: FontSpec {
635 family: Some("Segoe UI".to_string()),
636 size: Some(8.0),
637 weight: Some(400),
638 },
639 }
640 }
641
642 fn light_theme() -> crate::NativeTheme {
644 build_theme(
645 crate::Rgba::rgb(0, 120, 215),
646 crate::Rgba::rgb(0, 0, 0), crate::Rgba::rgb(255, 255, 255),
648 [None; 6],
649 default_fonts(),
650 None,
651 None,
652 None,
653 None,
654 None,
655 96,
656 )
657 }
658
659 fn dark_theme() -> crate::NativeTheme {
661 build_theme(
662 crate::Rgba::rgb(0, 120, 215),
663 crate::Rgba::rgb(255, 255, 255), crate::Rgba::rgb(0, 0, 0),
665 [None; 6],
666 default_fonts(),
667 None,
668 None,
669 None,
670 None,
671 None,
672 96,
673 )
674 }
675
676 #[test]
679 fn is_dark_mode_white_foreground_returns_true() {
680 let fg = crate::Rgba::rgb(255, 255, 255);
681 assert!(is_dark_mode(&fg));
682 }
683
684 #[test]
685 fn is_dark_mode_black_foreground_returns_false() {
686 let fg = crate::Rgba::rgb(0, 0, 0);
687 assert!(!is_dark_mode(&fg));
688 }
689
690 #[test]
691 fn is_dark_mode_mid_gray_boundary_returns_false() {
692 let fg = crate::Rgba::rgb(128, 128, 128);
693 assert!(!is_dark_mode(&fg));
694 }
695
696 #[test]
699 fn logfont_to_fontspec_extracts_family_size_weight() {
700 let mut face: [u16; 32] = [0; 32];
702 for (i, ch) in "Segoe UI".encode_utf16().enumerate() {
703 face[i] = ch;
704 }
705 let fs = logfont_to_fontspec_raw(&face, -16, 400, 96);
706 assert_eq!(fs.family.as_deref(), Some("Segoe UI"));
707 assert_eq!(fs.size, Some(12.0)); assert_eq!(fs.weight, Some(400));
709 }
710
711 #[test]
712 fn logfont_to_fontspec_bold_weight_700() {
713 let face: [u16; 32] = [0; 32];
714 let fs = logfont_to_fontspec_raw(&face, -16, 700, 96);
715 assert_eq!(fs.weight, Some(700));
716 }
717
718 #[test]
719 fn logfont_to_fontspec_weight_clamped_to_range() {
720 let face: [u16; 32] = [0; 32];
721 let fs = logfont_to_fontspec_raw(&face, -16, 0, 96);
723 assert_eq!(fs.weight, Some(100));
724 let fs = logfont_to_fontspec_raw(&face, -16, 1000, 96);
726 assert_eq!(fs.weight, Some(900));
727 }
728
729 #[test]
732 fn colorref_to_rgba_correct_rgb_extraction() {
733 let rgba = colorref_to_rgba(0x00AABBCC);
735 assert_eq!(rgba.r, 0xCC);
736 assert_eq!(rgba.g, 0xBB);
737 assert_eq!(rgba.b, 0xAA);
738 assert_eq!(rgba.a, 255); }
740
741 #[test]
742 fn colorref_to_rgba_black() {
743 let rgba = colorref_to_rgba(0x00000000);
744 assert_eq!(rgba, crate::Rgba::rgb(0, 0, 0));
745 }
746
747 #[test]
748 fn colorref_to_rgba_white() {
749 let rgba = colorref_to_rgba(0x00FFFFFF);
750 assert_eq!(rgba, crate::Rgba::rgb(255, 255, 255));
751 }
752
753 #[test]
756 fn dwm_color_to_rgba_extracts_argb() {
757 let rgba = dwm_color_to_rgba(0xCC112233);
759 assert_eq!(rgba.r, 0x11);
760 assert_eq!(rgba.g, 0x22);
761 assert_eq!(rgba.b, 0x33);
762 assert_eq!(rgba.a, 0xCC);
763 }
764
765 #[test]
768 fn build_theme_dark_mode_populates_dark_variant_only() {
769 let theme = dark_theme();
770 assert!(theme.dark.is_some(), "dark variant should be Some");
771 assert!(theme.light.is_none(), "light variant should be None");
772 }
773
774 #[test]
775 fn build_theme_light_mode_populates_light_variant_only() {
776 let theme = light_theme();
777 assert!(theme.light.is_some(), "light variant should be Some");
778 assert!(theme.dark.is_none(), "dark variant should be None");
779 }
780
781 #[test]
782 fn build_theme_sets_defaults_accent_fg_bg_selection() {
783 let accent = crate::Rgba::rgb(0, 120, 215);
784 let fg = crate::Rgba::rgb(0, 0, 0);
785 let bg = crate::Rgba::rgb(255, 255, 255);
786 let theme = build_theme(
787 accent,
788 fg,
789 bg,
790 [None; 6],
791 default_fonts(),
792 None,
793 None,
794 None,
795 None,
796 None,
797 96,
798 );
799 let variant = theme.light.as_ref().expect("light variant");
800 assert_eq!(variant.defaults.accent, Some(accent));
801 assert_eq!(variant.defaults.foreground, Some(fg));
802 assert_eq!(variant.defaults.background, Some(bg));
803 assert_eq!(variant.defaults.selection, Some(accent));
804 assert_eq!(variant.defaults.focus_ring_color, Some(accent));
805 }
806
807 #[test]
808 fn build_theme_name_is_windows() {
809 assert_eq!(light_theme().name, "Windows");
810 }
811
812 #[test]
813 fn build_theme_accent_shades_light_mode() {
814 let accent = crate::Rgba::rgb(0, 120, 215);
815 let dark1 = crate::Rgba::rgb(0, 90, 170);
816 let mut shades = [None; 6];
817 shades[0] = Some(dark1);
818 let theme = build_theme(
819 accent,
820 crate::Rgba::rgb(0, 0, 0),
821 crate::Rgba::rgb(255, 255, 255),
822 shades,
823 default_fonts(),
824 None,
825 None,
826 None,
827 None,
828 None,
829 96,
830 );
831 let variant = theme.light.as_ref().expect("light variant");
836 assert_eq!(variant.defaults.accent, Some(accent));
837 }
838
839 #[test]
840 fn build_theme_accent_shades_dark_mode() {
841 let accent = crate::Rgba::rgb(0, 120, 215);
842 let light1 = crate::Rgba::rgb(60, 160, 240);
843 let mut shades = [None; 6];
844 shades[3] = Some(light1);
845 let theme = build_theme(
846 accent,
847 crate::Rgba::rgb(255, 255, 255),
848 crate::Rgba::rgb(0, 0, 0),
849 shades,
850 default_fonts(),
851 None,
852 None,
853 None,
854 None,
855 None,
856 96,
857 );
858 let variant = theme.dark.as_ref().expect("dark variant");
859 assert_eq!(variant.defaults.accent, Some(accent));
860 }
861
862 #[test]
865 fn build_theme_sets_title_bar_font() {
866 let fonts = named_fonts();
867 let theme = build_theme(
868 crate::Rgba::rgb(0, 120, 215),
869 crate::Rgba::rgb(0, 0, 0),
870 crate::Rgba::rgb(255, 255, 255),
871 [None; 6],
872 fonts,
873 None,
874 None,
875 None,
876 None,
877 None,
878 96,
879 );
880 let variant = theme.light.as_ref().expect("light variant");
881 let title_font = variant
882 .window
883 .title_bar_font
884 .as_ref()
885 .expect("title_bar_font");
886 assert_eq!(title_font.family.as_deref(), Some("Segoe UI"));
887 assert_eq!(title_font.weight, Some(700));
888 }
889
890 #[test]
891 fn build_theme_sets_menu_and_status_bar_fonts() {
892 let fonts = named_fonts();
893 let theme = build_theme(
894 crate::Rgba::rgb(0, 120, 215),
895 crate::Rgba::rgb(0, 0, 0),
896 crate::Rgba::rgb(255, 255, 255),
897 [None; 6],
898 fonts,
899 None,
900 None,
901 None,
902 None,
903 None,
904 96,
905 );
906 let variant = theme.light.as_ref().expect("light variant");
907 let menu_font = variant.menu.font.as_ref().expect("menu.font");
908 assert_eq!(menu_font.family.as_deref(), Some("Segoe UI"));
909 let status_font = variant.status_bar.font.as_ref().expect("status_bar.font");
910 assert_eq!(status_font.size, Some(8.0));
911 }
912
913 #[test]
914 fn build_theme_sets_defaults_font_from_msg_font() {
915 let fonts = named_fonts();
916 let theme = build_theme(
917 crate::Rgba::rgb(0, 120, 215),
918 crate::Rgba::rgb(0, 0, 0),
919 crate::Rgba::rgb(255, 255, 255),
920 [None; 6],
921 fonts,
922 None,
923 None,
924 None,
925 None,
926 None,
927 96,
928 );
929 let variant = theme.light.as_ref().expect("light variant");
930 assert_eq!(variant.defaults.font.family.as_deref(), Some("Segoe UI"));
931 assert_eq!(variant.defaults.font.size, Some(9.0));
932 }
933
934 fn sample_sys_colors() -> SysColors {
938 SysColors {
939 btn_face: crate::Rgba::rgb(240, 240, 240),
940 btn_text: crate::Rgba::rgb(0, 0, 0),
941 menu_bg: crate::Rgba::rgb(255, 255, 255),
942 menu_text: crate::Rgba::rgb(0, 0, 0),
943 info_bg: crate::Rgba::rgb(255, 255, 225),
944 info_text: crate::Rgba::rgb(0, 0, 0),
945 window_bg: crate::Rgba::rgb(255, 255, 255),
946 window_text: crate::Rgba::rgb(0, 0, 0),
947 highlight: crate::Rgba::rgb(0, 120, 215),
948 highlight_text: crate::Rgba::rgb(255, 255, 255),
949 caption_text: crate::Rgba::rgb(0, 0, 0),
950 inactive_caption_text: crate::Rgba::rgb(128, 128, 128),
951 gray_text: crate::Rgba::rgb(109, 109, 109),
952 }
953 }
954
955 #[test]
956 fn build_theme_with_sys_colors_populates_widgets() {
957 let colors = sample_sys_colors();
958 let theme = build_theme(
959 crate::Rgba::rgb(0, 120, 215),
960 crate::Rgba::rgb(0, 0, 0),
961 crate::Rgba::rgb(255, 255, 255),
962 [None; 6],
963 default_fonts(),
964 Some(&colors),
965 None,
966 None,
967 None,
968 None,
969 96,
970 );
971 let variant = theme.light.as_ref().expect("light variant");
972 assert_eq!(
973 variant.button.background,
974 Some(crate::Rgba::rgb(240, 240, 240))
975 );
976 assert_eq!(variant.button.foreground, Some(crate::Rgba::rgb(0, 0, 0)));
977 assert_eq!(
978 variant.menu.background,
979 Some(crate::Rgba::rgb(255, 255, 255))
980 );
981 assert_eq!(variant.menu.foreground, Some(crate::Rgba::rgb(0, 0, 0)));
982 assert_eq!(
983 variant.tooltip.background,
984 Some(crate::Rgba::rgb(255, 255, 225))
985 );
986 assert_eq!(variant.tooltip.foreground, Some(crate::Rgba::rgb(0, 0, 0)));
987 assert_eq!(
988 variant.input.background,
989 Some(crate::Rgba::rgb(255, 255, 255))
990 );
991 assert_eq!(variant.input.foreground, Some(crate::Rgba::rgb(0, 0, 0)));
992 assert_eq!(variant.list.selection, Some(crate::Rgba::rgb(0, 120, 215)));
993 assert_eq!(
994 variant.list.selection_foreground,
995 Some(crate::Rgba::rgb(255, 255, 255))
996 );
997 assert_eq!(
998 variant.window.title_bar_foreground,
999 Some(crate::Rgba::rgb(0, 0, 0)),
1000 "caption_text -> window.title_bar_foreground"
1001 );
1002 assert_eq!(
1003 variant.window.inactive_title_bar_foreground,
1004 Some(crate::Rgba::rgb(128, 128, 128)),
1005 "inactive_caption_text -> window.inactive_title_bar_foreground"
1006 );
1007 assert_eq!(
1008 variant.input.placeholder,
1009 Some(crate::Rgba::rgb(109, 109, 109)),
1010 "gray_text -> input.placeholder"
1011 );
1012 }
1013
1014 #[test]
1017 fn build_theme_sets_focus_ring_width() {
1018 let theme = light_theme();
1019 let variant = theme.light.as_ref().expect("light variant");
1020 assert!(
1021 variant.defaults.focus_ring_width.is_some(),
1022 "focus_ring_width should be set from SM_CXFOCUSBORDER"
1023 );
1024 }
1025
1026 #[test]
1029 fn build_theme_text_scale_from_font_size() {
1030 let fonts = named_fonts(); let theme = build_theme(
1032 crate::Rgba::rgb(0, 120, 215),
1033 crate::Rgba::rgb(0, 0, 0),
1034 crate::Rgba::rgb(255, 255, 255),
1035 [None; 6],
1036 fonts,
1037 None,
1038 None,
1039 None,
1040 None,
1041 None,
1042 96,
1043 );
1044 let variant = theme.light.as_ref().expect("light variant");
1045 let caption = variant.text_scale.caption.as_ref().expect("caption");
1046 assert!((caption.size.unwrap_or(0.0) - 9.0 * 0.85).abs() < 0.01);
1047 assert_eq!(caption.weight, Some(400));
1048
1049 let heading = variant
1050 .text_scale
1051 .section_heading
1052 .as_ref()
1053 .expect("section_heading");
1054 assert!((heading.size.unwrap_or(0.0) - 9.0 * 1.15).abs() < 0.01);
1055 assert_eq!(heading.weight, Some(600));
1056
1057 let title = variant
1058 .text_scale
1059 .dialog_title
1060 .as_ref()
1061 .expect("dialog_title");
1062 assert!((title.size.unwrap_or(0.0) - 9.0 * 1.35).abs() < 0.01);
1063 assert_eq!(title.weight, Some(600));
1064
1065 let display = variant.text_scale.display.as_ref().expect("display");
1066 assert!((display.size.unwrap_or(0.0) - 9.0 * 1.80).abs() < 0.01);
1067 assert_eq!(display.weight, Some(300));
1068 }
1069
1070 #[test]
1071 fn compute_text_scale_values() {
1072 let ts = compute_text_scale(10.0);
1073 let cap = ts.caption.as_ref().unwrap();
1074 assert!((cap.size.unwrap() - 8.5).abs() < 0.01);
1075 assert_eq!(cap.weight, Some(400));
1076 assert!(cap.line_height.is_none());
1077
1078 let sh = ts.section_heading.as_ref().unwrap();
1079 assert!((sh.size.unwrap() - 11.5).abs() < 0.01);
1080 assert_eq!(sh.weight, Some(600));
1081
1082 let dt = ts.dialog_title.as_ref().unwrap();
1083 assert!((dt.size.unwrap() - 13.5).abs() < 0.01);
1084 assert_eq!(dt.weight, Some(600));
1085
1086 let d = ts.display.as_ref().unwrap();
1087 assert!((d.size.unwrap() - 18.0).abs() < 0.01);
1088 assert_eq!(d.weight, Some(300));
1089 }
1090
1091 #[test]
1094 fn build_theme_with_dwm_color_sets_title_bar_background() {
1095 let dwm_color = crate::Rgba::rgba(0, 120, 215, 200);
1096 let theme = build_theme(
1097 crate::Rgba::rgb(0, 120, 215),
1098 crate::Rgba::rgb(0, 0, 0),
1099 crate::Rgba::rgb(255, 255, 255),
1100 [None; 6],
1101 default_fonts(),
1102 None,
1103 Some(dwm_color),
1104 None,
1105 None,
1106 None,
1107 96,
1108 );
1109 let variant = theme.light.as_ref().expect("light variant");
1110 assert_eq!(variant.window.title_bar_background, Some(dwm_color));
1111 }
1112
1113 #[test]
1114 fn build_theme_with_inactive_title_bar() {
1115 let inactive = crate::Rgba::rgb(200, 200, 200);
1116 let theme = build_theme(
1117 crate::Rgba::rgb(0, 120, 215),
1118 crate::Rgba::rgb(0, 0, 0),
1119 crate::Rgba::rgb(255, 255, 255),
1120 [None; 6],
1121 default_fonts(),
1122 None,
1123 None,
1124 Some(inactive),
1125 None,
1126 None,
1127 96,
1128 );
1129 let variant = theme.light.as_ref().expect("light variant");
1130 assert_eq!(variant.window.inactive_title_bar_background, Some(inactive));
1131 }
1132
1133 #[test]
1136 fn build_theme_with_icon_sizes() {
1137 let theme = build_theme(
1138 crate::Rgba::rgb(0, 120, 215),
1139 crate::Rgba::rgb(0, 0, 0),
1140 crate::Rgba::rgb(255, 255, 255),
1141 [None; 6],
1142 default_fonts(),
1143 None,
1144 None,
1145 None,
1146 Some((16.0, 32.0)),
1147 None,
1148 96,
1149 );
1150 let variant = theme.light.as_ref().expect("light variant");
1151 assert_eq!(variant.defaults.icon_sizes.small, Some(16.0));
1152 assert_eq!(variant.defaults.icon_sizes.large, Some(32.0));
1153 }
1154
1155 #[test]
1158 fn build_theme_with_accessibility() {
1159 let accessibility = AccessibilityData {
1160 text_scaling_factor: Some(1.5),
1161 high_contrast: Some(true),
1162 reduce_motion: Some(false),
1163 };
1164 let theme = build_theme(
1165 crate::Rgba::rgb(0, 120, 215),
1166 crate::Rgba::rgb(0, 0, 0),
1167 crate::Rgba::rgb(255, 255, 255),
1168 [None; 6],
1169 default_fonts(),
1170 None,
1171 None,
1172 None,
1173 None,
1174 Some(&accessibility),
1175 96,
1176 );
1177 let variant = theme.light.as_ref().expect("light variant");
1178 assert_eq!(variant.defaults.text_scaling_factor, Some(1.5));
1179 assert_eq!(variant.defaults.high_contrast, Some(true));
1180 assert_eq!(variant.defaults.reduce_motion, Some(false));
1181 }
1182
1183 #[test]
1186 fn build_theme_sets_dialog_trailing_affirmative() {
1187 let theme = light_theme();
1188 let variant = theme.light.as_ref().expect("light variant");
1189 assert_eq!(
1190 variant.dialog.button_order,
1191 Some(crate::model::DialogButtonOrder::TrailingAffirmative)
1192 );
1193 }
1194
1195 #[test]
1198 fn build_theme_sets_geometry_defaults() {
1199 let theme = light_theme();
1200 let variant = theme.light.as_ref().expect("light variant");
1201 assert_eq!(variant.defaults.radius, Some(4.0));
1202 assert_eq!(variant.defaults.radius_lg, Some(8.0));
1203 assert_eq!(variant.defaults.shadow_enabled, Some(true));
1204 }
1205
1206 #[test]
1209 fn winui3_spacing_values() {
1210 let spacing = winui3_spacing();
1211 assert_eq!(spacing.xxs, Some(2.0));
1212 assert_eq!(spacing.xs, Some(4.0));
1213 assert_eq!(spacing.s, Some(8.0));
1214 assert_eq!(spacing.m, Some(12.0));
1215 assert_eq!(spacing.l, Some(16.0));
1216 assert_eq!(spacing.xl, Some(24.0));
1217 assert_eq!(spacing.xxl, Some(32.0));
1218 }
1219
1220 #[test]
1223 fn build_theme_includes_widget_sizing() {
1224 let theme = light_theme();
1225 let variant = theme.light.as_ref().expect("light variant");
1226 assert_eq!(variant.button.min_height, Some(32.0));
1227 assert_eq!(variant.checkbox.indicator_size, Some(20.0));
1228 assert_eq!(variant.input.min_height, Some(32.0));
1229 assert_eq!(variant.slider.thumb_size, Some(22.0));
1230 assert!(variant.scrollbar.width.is_some());
1231 assert!(variant.menu.item_height.is_some());
1232 assert_eq!(variant.splitter.width, Some(4.0));
1233 }
1234
1235 #[test]
1238 fn build_theme_surface_equals_bg() {
1239 let bg = crate::Rgba::rgb(255, 255, 255);
1240 let theme = build_theme(
1241 crate::Rgba::rgb(0, 120, 215),
1242 crate::Rgba::rgb(0, 0, 0),
1243 bg,
1244 [None; 6],
1245 default_fonts(),
1246 None,
1247 None,
1248 None,
1249 None,
1250 None,
1251 96,
1252 );
1253 let variant = theme.light.as_ref().expect("light variant");
1254 assert_eq!(variant.defaults.surface, Some(bg));
1255 }
1256
1257 #[test]
1258 fn build_theme_disabled_foreground_is_midpoint() {
1259 let theme = build_theme(
1260 crate::Rgba::rgb(0, 120, 215),
1261 crate::Rgba::rgb(0, 0, 0), crate::Rgba::rgb(255, 255, 255), [None; 6],
1264 default_fonts(),
1265 None,
1266 None,
1267 None,
1268 None,
1269 None,
1270 96,
1271 );
1272 let variant = theme.light.as_ref().expect("light variant");
1273 assert_eq!(
1275 variant.defaults.disabled_foreground,
1276 Some(crate::Rgba::rgb(127, 127, 127))
1277 );
1278 }
1279
1280 #[test]
1283 fn build_theme_returns_native_theme_with_theme_variant() {
1284 let theme = light_theme();
1286 let variant: &crate::ThemeVariant = theme.light.as_ref().unwrap();
1287 let _ = variant.defaults.accent;
1289 let _ = variant.window.title_bar_font;
1290 let _ = variant.menu.font;
1291 let _ = variant.status_bar.font;
1292 let _ = variant.button.background;
1293 let _ = variant.defaults.icon_sizes.small;
1294 let _ = variant.defaults.reduce_motion;
1295 let _ = variant.dialog.button_order;
1296 }
1297
1298 #[test]
1299 fn test_windows_resolve_validate() {
1300 let mut base = crate::NativeTheme::preset("windows-11").unwrap();
1302 let reader_output = light_theme();
1304 base.merge(&reader_output);
1306
1307 let mut light = base
1309 .light
1310 .clone()
1311 .expect("light variant should exist after merge");
1312 light.resolve();
1313 let resolved = light.validate().unwrap_or_else(|e| {
1314 panic!("Windows resolve/validate pipeline failed: {e}");
1315 });
1316
1317 assert_eq!(
1319 resolved.defaults.accent,
1320 crate::Rgba::rgb(0, 120, 215),
1321 "accent should be from Windows reader"
1322 );
1323 assert_eq!(
1324 resolved.defaults.font.family, "Segoe UI",
1325 "font family should be from Windows reader"
1326 );
1327 assert_eq!(
1328 resolved.dialog.button_order,
1329 crate::DialogButtonOrder::TrailingAffirmative,
1330 "dialog button order should be trailing affirmative for Windows"
1331 );
1332 assert_eq!(
1333 resolved.icon_set, "segoe-fluent",
1334 "icon_set should be segoe-fluent from Windows preset"
1335 );
1336 }
1337}