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 = if dpi == 0 {
113 0
114 } else {
115 (lf_height.unsigned_abs() * 72) / dpi
116 };
117 let weight = (lf_weight.clamp(100, 900)) as u16;
118 FontSpec {
119 family: Some(family),
120 size: Some(points as f32),
121 weight: Some(weight),
122 }
123}
124
125#[cfg(all(target_os = "windows", feature = "windows"))]
130#[allow(unsafe_code)]
131fn read_all_system_fonts(dpi: u32) -> AllFonts {
132 let mut ncm = NONCLIENTMETRICSW::default();
133 ncm.cbSize = std::mem::size_of::<NONCLIENTMETRICSW>() as u32;
134
135 let success = unsafe {
136 SystemParametersInfoW(
137 SPI_GETNONCLIENTMETRICS,
138 ncm.cbSize,
139 Some(&mut ncm as *mut _ as *mut _),
140 SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0),
141 )
142 };
143
144 if success.is_ok() {
145 AllFonts {
146 msg: logfont_to_fontspec(&ncm.lfMessageFont, dpi),
147 caption: logfont_to_fontspec(&ncm.lfCaptionFont, dpi),
148 menu: logfont_to_fontspec(&ncm.lfMenuFont, dpi),
149 status: logfont_to_fontspec(&ncm.lfStatusFont, dpi),
150 }
151 } else {
152 AllFonts {
153 msg: FontSpec::default(),
154 caption: FontSpec::default(),
155 menu: FontSpec::default(),
156 status: FontSpec::default(),
157 }
158 }
159}
160
161fn compute_text_scale(base_size: f32) -> crate::TextScale {
166 crate::TextScale {
167 caption: Some(crate::TextScaleEntry {
168 size: Some(base_size * 0.85),
169 weight: Some(400),
170 line_height: None,
171 }),
172 section_heading: Some(crate::TextScaleEntry {
173 size: Some(base_size * 1.15),
174 weight: Some(600),
175 line_height: None,
176 }),
177 dialog_title: Some(crate::TextScaleEntry {
178 size: Some(base_size * 1.35),
179 weight: Some(600),
180 line_height: None,
181 }),
182 display: Some(crate::TextScaleEntry {
183 size: Some(base_size * 1.80),
184 weight: Some(300),
185 line_height: None,
186 }),
187 }
188}
189
190fn winui3_spacing() -> crate::ThemeSpacing {
195 crate::ThemeSpacing {
196 xxs: Some(2.0),
197 xs: Some(4.0),
198 s: Some(8.0),
199 m: Some(12.0),
200 l: Some(16.0),
201 xl: Some(24.0),
202 xxl: Some(32.0),
203 }
204}
205
206#[cfg(all(target_os = "windows", feature = "windows"))]
210#[allow(unsafe_code)]
211fn read_dpi() -> u32 {
212 unsafe { GetDpiForSystem() }
213}
214
215#[cfg(all(target_os = "windows", feature = "windows"))]
217#[allow(unsafe_code)]
218fn read_frame_width(dpi: u32) -> f32 {
219 unsafe { GetSystemMetricsForDpi(SM_CXBORDER, dpi) as f32 }
220}
221
222#[cfg(all(target_os = "windows", feature = "windows"))]
224#[allow(unsafe_code)]
225fn read_widget_sizing(dpi: u32, variant: &mut crate::ThemeVariant) {
226 unsafe {
227 variant.scrollbar.width = Some(GetSystemMetricsForDpi(SM_CXVSCROLL, dpi) as f32);
228 variant.scrollbar.min_thumb_height = Some(GetSystemMetricsForDpi(SM_CYVTHUMB, dpi) as f32);
229 variant.menu.item_height = Some(GetSystemMetricsForDpi(SM_CYMENU, dpi) as f32);
230 variant.defaults.focus_ring_width =
231 Some(GetSystemMetricsForDpi(SM_CXFOCUSBORDER, dpi) as f32);
232 }
233 variant.button.min_height = Some(32.0);
235 variant.button.padding_horizontal = Some(12.0);
236 variant.checkbox.indicator_size = Some(20.0);
237 variant.checkbox.spacing = Some(8.0);
238 variant.input.min_height = Some(32.0);
239 variant.input.padding_horizontal = Some(12.0);
240 variant.slider.track_height = Some(4.0);
241 variant.slider.thumb_size = Some(22.0);
242 variant.progress_bar.height = Some(4.0);
243 variant.tab.min_height = Some(32.0);
244 variant.tab.padding_horizontal = Some(12.0);
245 variant.menu.padding_horizontal = Some(12.0);
246 variant.tooltip.padding_horizontal = Some(8.0);
247 variant.tooltip.padding_vertical = Some(8.0);
248 variant.list.item_height = Some(40.0);
249 variant.list.padding_horizontal = Some(12.0);
250 variant.toolbar.height = Some(48.0);
251 variant.toolbar.item_spacing = Some(4.0);
252 variant.splitter.width = Some(4.0);
253}
254
255#[cfg(not(all(target_os = "windows", feature = "windows")))]
257fn read_widget_sizing(_dpi: u32, variant: &mut crate::ThemeVariant) {
258 variant.scrollbar.width = Some(17.0);
259 variant.scrollbar.min_thumb_height = Some(40.0);
260 variant.menu.item_height = Some(32.0);
261 variant.defaults.focus_ring_width = Some(1.0); variant.button.min_height = Some(32.0);
263 variant.button.padding_horizontal = Some(12.0);
264 variant.checkbox.indicator_size = Some(20.0);
265 variant.checkbox.spacing = Some(8.0);
266 variant.input.min_height = Some(32.0);
267 variant.input.padding_horizontal = Some(12.0);
268 variant.slider.track_height = Some(4.0);
269 variant.slider.thumb_size = Some(22.0);
270 variant.progress_bar.height = Some(4.0);
271 variant.tab.min_height = Some(32.0);
272 variant.tab.padding_horizontal = Some(12.0);
273 variant.menu.padding_horizontal = Some(12.0);
274 variant.tooltip.padding_horizontal = Some(8.0);
275 variant.tooltip.padding_vertical = Some(8.0);
276 variant.list.item_height = Some(40.0);
277 variant.list.padding_horizontal = Some(12.0);
278 variant.toolbar.height = Some(48.0);
279 variant.toolbar.item_spacing = Some(4.0);
280 variant.splitter.width = Some(4.0);
281}
282
283pub(crate) fn colorref_to_rgba(c: u32) -> crate::Rgba {
288 let r = (c & 0xFF) as u8;
289 let g = ((c >> 8) & 0xFF) as u8;
290 let b = ((c >> 16) & 0xFF) as u8;
291 crate::Rgba::rgb(r, g, b)
292}
293
294#[cfg(all(target_os = "windows", feature = "windows"))]
296#[allow(unsafe_code)]
297fn read_sys_colors() -> SysColors {
298 use ::windows::Win32::Graphics::Gdi::*;
299
300 fn sys_color(index: SYS_COLOR_INDEX) -> crate::Rgba {
301 let c = unsafe { GetSysColor(index) };
302 colorref_to_rgba(c)
303 }
304
305 SysColors {
306 btn_face: sys_color(COLOR_BTNFACE),
307 btn_text: sys_color(COLOR_BTNTEXT),
308 menu_bg: sys_color(COLOR_MENU),
309 menu_text: sys_color(COLOR_MENUTEXT),
310 info_bg: sys_color(COLOR_INFOBK),
311 info_text: sys_color(COLOR_INFOTEXT),
312 window_bg: sys_color(COLOR_WINDOW),
313 window_text: sys_color(COLOR_WINDOWTEXT),
314 highlight: sys_color(COLOR_HIGHLIGHT),
315 highlight_text: sys_color(COLOR_HIGHLIGHTTEXT),
316 caption_text: sys_color(COLOR_CAPTIONTEXT),
317 inactive_caption_text: sys_color(COLOR_INACTIVECAPTIONTEXT),
318 gray_text: sys_color(COLOR_GRAYTEXT),
319 }
320}
321
322fn apply_sys_colors(variant: &mut crate::ThemeVariant, colors: &SysColors) {
324 variant.button.background = Some(colors.btn_face);
325 variant.button.foreground = Some(colors.btn_text);
326 variant.menu.background = Some(colors.menu_bg);
327 variant.menu.foreground = Some(colors.menu_text);
328 variant.tooltip.background = Some(colors.info_bg);
329 variant.tooltip.foreground = Some(colors.info_text);
330 variant.input.background = Some(colors.window_bg);
331 variant.input.foreground = Some(colors.window_text);
332 variant.input.placeholder = Some(colors.gray_text);
333 variant.list.selection = Some(colors.highlight);
334 variant.list.selection_foreground = Some(colors.highlight_text);
335 variant.window.title_bar_foreground = Some(colors.caption_text);
336 variant.window.inactive_title_bar_foreground = Some(colors.inactive_caption_text);
337}
338
339#[cfg(all(target_os = "windows", feature = "windows"))]
341#[allow(unsafe_code)]
342fn read_dwm_colorization() -> Option<crate::Rgba> {
343 use ::windows::Win32::Graphics::Dwm::DwmGetColorizationColor;
344 let mut colorization: u32 = 0;
345 let mut opaque_blend = ::windows::core::BOOL::default();
346 unsafe { DwmGetColorizationColor(&mut colorization, &mut opaque_blend) }.ok()?;
347 let a = ((colorization >> 24) & 0xFF) as u8;
349 let r = ((colorization >> 16) & 0xFF) as u8;
350 let g = ((colorization >> 8) & 0xFF) as u8;
351 let b = (colorization & 0xFF) as u8;
352 Some(crate::Rgba::rgba(r, g, b, a))
353}
354
355fn dwm_color_to_rgba(c: u32) -> crate::Rgba {
357 let a = ((c >> 24) & 0xFF) as u8;
358 let r = ((c >> 16) & 0xFF) as u8;
359 let g = ((c >> 8) & 0xFF) as u8;
360 let b = (c & 0xFF) as u8;
361 crate::Rgba::rgba(r, g, b, a)
362}
363
364#[cfg(all(target_os = "windows", feature = "windows"))]
366#[allow(unsafe_code)]
367fn read_inactive_caption_color() -> crate::Rgba {
368 use ::windows::Win32::Graphics::Gdi::{COLOR_INACTIVECAPTION, GetSysColor};
369 let c = unsafe { GetSysColor(COLOR_INACTIVECAPTION) };
370 colorref_to_rgba(c)
371}
372
373#[cfg(all(target_os = "windows", feature = "windows"))]
375#[allow(unsafe_code)]
376fn read_accessibility(settings: &UISettings) -> AccessibilityData {
377 let text_scaling_factor = settings.TextScaleFactor().ok().map(|f| f as f32);
379
380 let high_contrast = {
382 use ::windows::Win32::UI::Accessibility::{HCF_HIGHCONTRASTON, HIGHCONTRASTW};
383 use ::windows::Win32::UI::WindowsAndMessaging::*;
384 let mut hc = HIGHCONTRASTW::default();
385 hc.cbSize = std::mem::size_of::<HIGHCONTRASTW>() as u32;
386 let success = unsafe {
387 SystemParametersInfoW(
388 SPI_GETHIGHCONTRAST,
389 hc.cbSize,
390 Some(&mut hc as *mut _ as *mut _),
391 SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0),
392 )
393 };
394 if success.is_ok() {
395 Some(hc.dwFlags.contains(HCF_HIGHCONTRASTON))
396 } else {
397 None
398 }
399 };
400
401 let reduce_motion = {
403 let mut animation_enabled = ::windows::core::BOOL(1);
404 let success = unsafe {
405 SystemParametersInfoW(
406 ::windows::Win32::UI::WindowsAndMessaging::SPI_GETCLIENTAREAANIMATION,
407 0,
408 Some(&mut animation_enabled as *mut _ as *mut _),
409 SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0),
410 )
411 };
412 if success.is_ok() {
413 Some(!animation_enabled.as_bool())
415 } else {
416 None
417 }
418 };
419
420 AccessibilityData {
421 text_scaling_factor,
422 high_contrast,
423 reduce_motion,
424 }
425}
426
427#[cfg(all(target_os = "windows", feature = "windows"))]
429#[allow(unsafe_code)]
430fn read_icon_sizes(dpi: u32) -> (f32, f32) {
431 let small = unsafe { GetSystemMetricsForDpi(SM_CXSMICON, dpi) } as f32;
432 let large = unsafe { GetSystemMetricsForDpi(SM_CXICON, dpi) } as f32;
433 (small, large)
434}
435
436#[allow(clippy::too_many_arguments)]
444fn build_theme(
445 accent: crate::Rgba,
446 fg: crate::Rgba,
447 bg: crate::Rgba,
448 accent_shades: [Option<crate::Rgba>; 6],
449 fonts: AllFonts,
450 sys_colors: Option<&SysColors>,
451 dwm_title_bar: Option<crate::Rgba>,
452 inactive_title_bar: Option<crate::Rgba>,
453 icon_sizes: Option<(f32, f32)>,
454 accessibility: Option<&AccessibilityData>,
455 dpi: u32,
456) -> crate::ThemeSpec {
457 let dark = is_dark_mode(&fg);
458
459 let primary_bg = if dark {
462 accent_shades[3].unwrap_or(accent)
463 } else {
464 accent_shades[0].unwrap_or(accent)
465 };
466
467 let mut variant = crate::ThemeVariant::default();
468
469 variant.defaults.accent = Some(accent);
471 variant.defaults.foreground = Some(fg);
472 variant.defaults.background = Some(bg);
473 variant.defaults.selection = Some(accent);
474 variant.defaults.focus_ring_color = Some(accent);
475 variant.defaults.surface = Some(bg);
476 variant.button.primary_background = Some(primary_bg);
477 variant.button.primary_foreground = Some(fg);
478
479 let disabled_r = ((fg.r as u16 + bg.r as u16) / 2) as u8;
481 let disabled_g = ((fg.g as u16 + bg.g as u16) / 2) as u8;
482 let disabled_b = ((fg.b as u16 + bg.b as u16) / 2) as u8;
483 variant.defaults.disabled_foreground =
484 Some(crate::Rgba::rgb(disabled_r, disabled_g, disabled_b));
485
486 variant.defaults.font = fonts.msg;
488
489 variant.window.title_bar_font = Some(fonts.caption);
491 variant.menu.font = Some(fonts.menu);
492 variant.status_bar.font = Some(fonts.status);
493
494 if let Some(base_size) = variant.defaults.font.size {
496 variant.text_scale = compute_text_scale(base_size);
497 }
498
499 variant.defaults.spacing = winui3_spacing();
501
502 variant.defaults.radius = Some(4.0);
504 variant.defaults.radius_lg = Some(8.0);
505 variant.defaults.shadow_enabled = Some(true);
506
507 read_widget_sizing(dpi, &mut variant);
509
510 variant.dialog.button_order = Some(crate::model::DialogButtonOrder::TrailingAffirmative);
512
513 if let Some(color) = dwm_title_bar {
515 variant.window.title_bar_background = Some(color);
516 }
517 if let Some(color) = inactive_title_bar {
518 variant.window.inactive_title_bar_background = Some(color);
519 }
520
521 if let Some(colors) = sys_colors {
523 apply_sys_colors(&mut variant, colors);
524 }
525
526 if let Some((small, large)) = icon_sizes {
528 variant.defaults.icon_sizes.small = Some(small);
529 variant.defaults.icon_sizes.large = Some(large);
530 }
531
532 if let Some(a) = accessibility {
534 variant.defaults.text_scaling_factor = a.text_scaling_factor;
535 variant.defaults.high_contrast = a.high_contrast;
536 variant.defaults.reduce_motion = a.reduce_motion;
537 }
538
539 if dark {
540 crate::ThemeSpec {
541 name: "Windows".to_string(),
542 light: None,
543 dark: Some(variant),
544 }
545 } else {
546 crate::ThemeSpec {
547 name: "Windows".to_string(),
548 light: Some(variant),
549 dark: None,
550 }
551 }
552}
553
554#[cfg(all(target_os = "windows", feature = "windows"))]
564#[must_use = "this returns the detected Windows theme; it does not apply it"]
565pub fn from_windows() -> crate::Result<crate::ThemeSpec> {
566 let settings = UISettings::new()
567 .map_err(|e| crate::Error::Unavailable(format!("UISettings unavailable: {e}")))?;
568
569 let accent = settings
570 .GetColorValue(UIColorType::Accent)
571 .map(win_color_to_rgba)
572 .map_err(|e| crate::Error::Unavailable(format!("GetColorValue(Accent) failed: {e}")))?;
573 let fg = settings
574 .GetColorValue(UIColorType::Foreground)
575 .map(win_color_to_rgba)
576 .map_err(|e| crate::Error::Unavailable(format!("GetColorValue(Foreground) failed: {e}")))?;
577 let bg = settings
578 .GetColorValue(UIColorType::Background)
579 .map(win_color_to_rgba)
580 .map_err(|e| crate::Error::Unavailable(format!("GetColorValue(Background) failed: {e}")))?;
581
582 let accent_shades = read_accent_shades(&settings);
583 let dpi = read_dpi();
584 let fonts = read_all_system_fonts(dpi);
585 let sys_colors = read_sys_colors();
586 let dwm_title_bar = read_dwm_colorization();
587 let inactive_title_bar = Some(read_inactive_caption_color());
588 let (small, large) = read_icon_sizes(dpi);
589 let accessibility = read_accessibility(&settings);
590
591 Ok(build_theme(
592 accent,
593 fg,
594 bg,
595 accent_shades,
596 fonts,
597 Some(&sys_colors),
598 dwm_title_bar,
599 inactive_title_bar,
600 Some((small, large)),
601 Some(&accessibility),
602 dpi,
603 ))
604}
605
606#[cfg(test)]
607#[allow(clippy::unwrap_used, clippy::expect_used)]
608mod tests {
609 use super::*;
610
611 fn default_fonts() -> AllFonts {
613 AllFonts {
614 msg: FontSpec::default(),
615 caption: FontSpec::default(),
616 menu: FontSpec::default(),
617 status: FontSpec::default(),
618 }
619 }
620
621 fn named_fonts() -> AllFonts {
623 AllFonts {
624 msg: FontSpec {
625 family: Some("Segoe UI".to_string()),
626 size: Some(9.0),
627 weight: Some(400),
628 },
629 caption: FontSpec {
630 family: Some("Segoe UI".to_string()),
631 size: Some(9.0),
632 weight: Some(700),
633 },
634 menu: FontSpec {
635 family: Some("Segoe UI".to_string()),
636 size: Some(9.0),
637 weight: Some(400),
638 },
639 status: FontSpec {
640 family: Some("Segoe UI".to_string()),
641 size: Some(8.0),
642 weight: Some(400),
643 },
644 }
645 }
646
647 fn light_theme() -> crate::ThemeSpec {
649 build_theme(
650 crate::Rgba::rgb(0, 120, 215),
651 crate::Rgba::rgb(0, 0, 0), crate::Rgba::rgb(255, 255, 255),
653 [None; 6],
654 default_fonts(),
655 None,
656 None,
657 None,
658 None,
659 None,
660 96,
661 )
662 }
663
664 fn dark_theme() -> crate::ThemeSpec {
666 build_theme(
667 crate::Rgba::rgb(0, 120, 215),
668 crate::Rgba::rgb(255, 255, 255), crate::Rgba::rgb(0, 0, 0),
670 [None; 6],
671 default_fonts(),
672 None,
673 None,
674 None,
675 None,
676 None,
677 96,
678 )
679 }
680
681 #[test]
684 fn is_dark_mode_white_foreground_returns_true() {
685 let fg = crate::Rgba::rgb(255, 255, 255);
686 assert!(is_dark_mode(&fg));
687 }
688
689 #[test]
690 fn is_dark_mode_black_foreground_returns_false() {
691 let fg = crate::Rgba::rgb(0, 0, 0);
692 assert!(!is_dark_mode(&fg));
693 }
694
695 #[test]
696 fn is_dark_mode_mid_gray_boundary_returns_false() {
697 let fg = crate::Rgba::rgb(128, 128, 128);
698 assert!(!is_dark_mode(&fg));
699 }
700
701 #[test]
704 fn logfont_to_fontspec_extracts_family_size_weight() {
705 let mut face: [u16; 32] = [0; 32];
707 for (i, ch) in "Segoe UI".encode_utf16().enumerate() {
708 face[i] = ch;
709 }
710 let fs = logfont_to_fontspec_raw(&face, -16, 400, 96);
711 assert_eq!(fs.family.as_deref(), Some("Segoe UI"));
712 assert_eq!(fs.size, Some(12.0)); assert_eq!(fs.weight, Some(400));
714 }
715
716 #[test]
717 fn logfont_to_fontspec_bold_weight_700() {
718 let face: [u16; 32] = [0; 32];
719 let fs = logfont_to_fontspec_raw(&face, -16, 700, 96);
720 assert_eq!(fs.weight, Some(700));
721 }
722
723 #[test]
724 fn logfont_to_fontspec_weight_clamped_to_range() {
725 let face: [u16; 32] = [0; 32];
726 let fs = logfont_to_fontspec_raw(&face, -16, 0, 96);
728 assert_eq!(fs.weight, Some(100));
729 let fs = logfont_to_fontspec_raw(&face, -16, 1000, 96);
731 assert_eq!(fs.weight, Some(900));
732 }
733
734 #[test]
737 fn colorref_to_rgba_correct_rgb_extraction() {
738 let rgba = colorref_to_rgba(0x00AABBCC);
740 assert_eq!(rgba.r, 0xCC);
741 assert_eq!(rgba.g, 0xBB);
742 assert_eq!(rgba.b, 0xAA);
743 assert_eq!(rgba.a, 255); }
745
746 #[test]
747 fn colorref_to_rgba_black() {
748 let rgba = colorref_to_rgba(0x00000000);
749 assert_eq!(rgba, crate::Rgba::rgb(0, 0, 0));
750 }
751
752 #[test]
753 fn colorref_to_rgba_white() {
754 let rgba = colorref_to_rgba(0x00FFFFFF);
755 assert_eq!(rgba, crate::Rgba::rgb(255, 255, 255));
756 }
757
758 #[test]
761 fn dwm_color_to_rgba_extracts_argb() {
762 let rgba = dwm_color_to_rgba(0xCC112233);
764 assert_eq!(rgba.r, 0x11);
765 assert_eq!(rgba.g, 0x22);
766 assert_eq!(rgba.b, 0x33);
767 assert_eq!(rgba.a, 0xCC);
768 }
769
770 #[test]
773 fn build_theme_dark_mode_populates_dark_variant_only() {
774 let theme = dark_theme();
775 assert!(theme.dark.is_some(), "dark variant should be Some");
776 assert!(theme.light.is_none(), "light variant should be None");
777 }
778
779 #[test]
780 fn build_theme_light_mode_populates_light_variant_only() {
781 let theme = light_theme();
782 assert!(theme.light.is_some(), "light variant should be Some");
783 assert!(theme.dark.is_none(), "dark variant should be None");
784 }
785
786 #[test]
787 fn build_theme_sets_defaults_accent_fg_bg_selection() {
788 let accent = crate::Rgba::rgb(0, 120, 215);
789 let fg = crate::Rgba::rgb(0, 0, 0);
790 let bg = crate::Rgba::rgb(255, 255, 255);
791 let theme = build_theme(
792 accent,
793 fg,
794 bg,
795 [None; 6],
796 default_fonts(),
797 None,
798 None,
799 None,
800 None,
801 None,
802 96,
803 );
804 let variant = theme.light.as_ref().expect("light variant");
805 assert_eq!(variant.defaults.accent, Some(accent));
806 assert_eq!(variant.defaults.foreground, Some(fg));
807 assert_eq!(variant.defaults.background, Some(bg));
808 assert_eq!(variant.defaults.selection, Some(accent));
809 assert_eq!(variant.defaults.focus_ring_color, Some(accent));
810 }
811
812 #[test]
813 fn build_theme_name_is_windows() {
814 assert_eq!(light_theme().name, "Windows");
815 }
816
817 #[test]
818 fn build_theme_accent_shades_light_mode() {
819 let accent = crate::Rgba::rgb(0, 120, 215);
820 let dark1 = crate::Rgba::rgb(0, 90, 170);
821 let mut shades = [None; 6];
822 shades[0] = Some(dark1);
823 let theme = build_theme(
824 accent,
825 crate::Rgba::rgb(0, 0, 0),
826 crate::Rgba::rgb(255, 255, 255),
827 shades,
828 default_fonts(),
829 None,
830 None,
831 None,
832 None,
833 None,
834 96,
835 );
836 let variant = theme.light.as_ref().expect("light variant");
841 assert_eq!(variant.defaults.accent, Some(accent));
842 }
843
844 #[test]
845 fn build_theme_accent_shades_dark_mode() {
846 let accent = crate::Rgba::rgb(0, 120, 215);
847 let light1 = crate::Rgba::rgb(60, 160, 240);
848 let mut shades = [None; 6];
849 shades[3] = Some(light1);
850 let theme = build_theme(
851 accent,
852 crate::Rgba::rgb(255, 255, 255),
853 crate::Rgba::rgb(0, 0, 0),
854 shades,
855 default_fonts(),
856 None,
857 None,
858 None,
859 None,
860 None,
861 96,
862 );
863 let variant = theme.dark.as_ref().expect("dark variant");
864 assert_eq!(variant.defaults.accent, Some(accent));
865 }
866
867 #[test]
870 fn build_theme_sets_title_bar_font() {
871 let fonts = named_fonts();
872 let theme = build_theme(
873 crate::Rgba::rgb(0, 120, 215),
874 crate::Rgba::rgb(0, 0, 0),
875 crate::Rgba::rgb(255, 255, 255),
876 [None; 6],
877 fonts,
878 None,
879 None,
880 None,
881 None,
882 None,
883 96,
884 );
885 let variant = theme.light.as_ref().expect("light variant");
886 let title_font = variant
887 .window
888 .title_bar_font
889 .as_ref()
890 .expect("title_bar_font");
891 assert_eq!(title_font.family.as_deref(), Some("Segoe UI"));
892 assert_eq!(title_font.weight, Some(700));
893 }
894
895 #[test]
896 fn build_theme_sets_menu_and_status_bar_fonts() {
897 let fonts = named_fonts();
898 let theme = build_theme(
899 crate::Rgba::rgb(0, 120, 215),
900 crate::Rgba::rgb(0, 0, 0),
901 crate::Rgba::rgb(255, 255, 255),
902 [None; 6],
903 fonts,
904 None,
905 None,
906 None,
907 None,
908 None,
909 96,
910 );
911 let variant = theme.light.as_ref().expect("light variant");
912 let menu_font = variant.menu.font.as_ref().expect("menu.font");
913 assert_eq!(menu_font.family.as_deref(), Some("Segoe UI"));
914 let status_font = variant.status_bar.font.as_ref().expect("status_bar.font");
915 assert_eq!(status_font.size, Some(8.0));
916 }
917
918 #[test]
919 fn build_theme_sets_defaults_font_from_msg_font() {
920 let fonts = named_fonts();
921 let theme = build_theme(
922 crate::Rgba::rgb(0, 120, 215),
923 crate::Rgba::rgb(0, 0, 0),
924 crate::Rgba::rgb(255, 255, 255),
925 [None; 6],
926 fonts,
927 None,
928 None,
929 None,
930 None,
931 None,
932 96,
933 );
934 let variant = theme.light.as_ref().expect("light variant");
935 assert_eq!(variant.defaults.font.family.as_deref(), Some("Segoe UI"));
936 assert_eq!(variant.defaults.font.size, Some(9.0));
937 }
938
939 fn sample_sys_colors() -> SysColors {
943 SysColors {
944 btn_face: crate::Rgba::rgb(240, 240, 240),
945 btn_text: crate::Rgba::rgb(0, 0, 0),
946 menu_bg: crate::Rgba::rgb(255, 255, 255),
947 menu_text: crate::Rgba::rgb(0, 0, 0),
948 info_bg: crate::Rgba::rgb(255, 255, 225),
949 info_text: crate::Rgba::rgb(0, 0, 0),
950 window_bg: crate::Rgba::rgb(255, 255, 255),
951 window_text: crate::Rgba::rgb(0, 0, 0),
952 highlight: crate::Rgba::rgb(0, 120, 215),
953 highlight_text: crate::Rgba::rgb(255, 255, 255),
954 caption_text: crate::Rgba::rgb(0, 0, 0),
955 inactive_caption_text: crate::Rgba::rgb(128, 128, 128),
956 gray_text: crate::Rgba::rgb(109, 109, 109),
957 }
958 }
959
960 #[test]
961 fn build_theme_with_sys_colors_populates_widgets() {
962 let colors = sample_sys_colors();
963 let theme = build_theme(
964 crate::Rgba::rgb(0, 120, 215),
965 crate::Rgba::rgb(0, 0, 0),
966 crate::Rgba::rgb(255, 255, 255),
967 [None; 6],
968 default_fonts(),
969 Some(&colors),
970 None,
971 None,
972 None,
973 None,
974 96,
975 );
976 let variant = theme.light.as_ref().expect("light variant");
977 assert_eq!(
978 variant.button.background,
979 Some(crate::Rgba::rgb(240, 240, 240))
980 );
981 assert_eq!(variant.button.foreground, Some(crate::Rgba::rgb(0, 0, 0)));
982 assert_eq!(
983 variant.menu.background,
984 Some(crate::Rgba::rgb(255, 255, 255))
985 );
986 assert_eq!(variant.menu.foreground, Some(crate::Rgba::rgb(0, 0, 0)));
987 assert_eq!(
988 variant.tooltip.background,
989 Some(crate::Rgba::rgb(255, 255, 225))
990 );
991 assert_eq!(variant.tooltip.foreground, Some(crate::Rgba::rgb(0, 0, 0)));
992 assert_eq!(
993 variant.input.background,
994 Some(crate::Rgba::rgb(255, 255, 255))
995 );
996 assert_eq!(variant.input.foreground, Some(crate::Rgba::rgb(0, 0, 0)));
997 assert_eq!(variant.list.selection, Some(crate::Rgba::rgb(0, 120, 215)));
998 assert_eq!(
999 variant.list.selection_foreground,
1000 Some(crate::Rgba::rgb(255, 255, 255))
1001 );
1002 assert_eq!(
1003 variant.window.title_bar_foreground,
1004 Some(crate::Rgba::rgb(0, 0, 0)),
1005 "caption_text -> window.title_bar_foreground"
1006 );
1007 assert_eq!(
1008 variant.window.inactive_title_bar_foreground,
1009 Some(crate::Rgba::rgb(128, 128, 128)),
1010 "inactive_caption_text -> window.inactive_title_bar_foreground"
1011 );
1012 assert_eq!(
1013 variant.input.placeholder,
1014 Some(crate::Rgba::rgb(109, 109, 109)),
1015 "gray_text -> input.placeholder"
1016 );
1017 }
1018
1019 #[test]
1022 fn build_theme_sets_focus_ring_width() {
1023 let theme = light_theme();
1024 let variant = theme.light.as_ref().expect("light variant");
1025 assert!(
1026 variant.defaults.focus_ring_width.is_some(),
1027 "focus_ring_width should be set from SM_CXFOCUSBORDER"
1028 );
1029 }
1030
1031 #[test]
1034 fn build_theme_text_scale_from_font_size() {
1035 let fonts = named_fonts(); let theme = build_theme(
1037 crate::Rgba::rgb(0, 120, 215),
1038 crate::Rgba::rgb(0, 0, 0),
1039 crate::Rgba::rgb(255, 255, 255),
1040 [None; 6],
1041 fonts,
1042 None,
1043 None,
1044 None,
1045 None,
1046 None,
1047 96,
1048 );
1049 let variant = theme.light.as_ref().expect("light variant");
1050 let caption = variant.text_scale.caption.as_ref().expect("caption");
1051 assert!((caption.size.unwrap_or(0.0) - 9.0 * 0.85).abs() < 0.01);
1052 assert_eq!(caption.weight, Some(400));
1053
1054 let heading = variant
1055 .text_scale
1056 .section_heading
1057 .as_ref()
1058 .expect("section_heading");
1059 assert!((heading.size.unwrap_or(0.0) - 9.0 * 1.15).abs() < 0.01);
1060 assert_eq!(heading.weight, Some(600));
1061
1062 let title = variant
1063 .text_scale
1064 .dialog_title
1065 .as_ref()
1066 .expect("dialog_title");
1067 assert!((title.size.unwrap_or(0.0) - 9.0 * 1.35).abs() < 0.01);
1068 assert_eq!(title.weight, Some(600));
1069
1070 let display = variant.text_scale.display.as_ref().expect("display");
1071 assert!((display.size.unwrap_or(0.0) - 9.0 * 1.80).abs() < 0.01);
1072 assert_eq!(display.weight, Some(300));
1073 }
1074
1075 #[test]
1076 fn compute_text_scale_values() {
1077 let ts = compute_text_scale(10.0);
1078 let cap = ts.caption.as_ref().unwrap();
1079 assert!((cap.size.unwrap() - 8.5).abs() < 0.01);
1080 assert_eq!(cap.weight, Some(400));
1081 assert!(cap.line_height.is_none());
1082
1083 let sh = ts.section_heading.as_ref().unwrap();
1084 assert!((sh.size.unwrap() - 11.5).abs() < 0.01);
1085 assert_eq!(sh.weight, Some(600));
1086
1087 let dt = ts.dialog_title.as_ref().unwrap();
1088 assert!((dt.size.unwrap() - 13.5).abs() < 0.01);
1089 assert_eq!(dt.weight, Some(600));
1090
1091 let d = ts.display.as_ref().unwrap();
1092 assert!((d.size.unwrap() - 18.0).abs() < 0.01);
1093 assert_eq!(d.weight, Some(300));
1094 }
1095
1096 #[test]
1099 fn build_theme_with_dwm_color_sets_title_bar_background() {
1100 let dwm_color = crate::Rgba::rgba(0, 120, 215, 200);
1101 let theme = build_theme(
1102 crate::Rgba::rgb(0, 120, 215),
1103 crate::Rgba::rgb(0, 0, 0),
1104 crate::Rgba::rgb(255, 255, 255),
1105 [None; 6],
1106 default_fonts(),
1107 None,
1108 Some(dwm_color),
1109 None,
1110 None,
1111 None,
1112 96,
1113 );
1114 let variant = theme.light.as_ref().expect("light variant");
1115 assert_eq!(variant.window.title_bar_background, Some(dwm_color));
1116 }
1117
1118 #[test]
1119 fn build_theme_with_inactive_title_bar() {
1120 let inactive = crate::Rgba::rgb(200, 200, 200);
1121 let theme = build_theme(
1122 crate::Rgba::rgb(0, 120, 215),
1123 crate::Rgba::rgb(0, 0, 0),
1124 crate::Rgba::rgb(255, 255, 255),
1125 [None; 6],
1126 default_fonts(),
1127 None,
1128 None,
1129 Some(inactive),
1130 None,
1131 None,
1132 96,
1133 );
1134 let variant = theme.light.as_ref().expect("light variant");
1135 assert_eq!(variant.window.inactive_title_bar_background, Some(inactive));
1136 }
1137
1138 #[test]
1141 fn build_theme_with_icon_sizes() {
1142 let theme = build_theme(
1143 crate::Rgba::rgb(0, 120, 215),
1144 crate::Rgba::rgb(0, 0, 0),
1145 crate::Rgba::rgb(255, 255, 255),
1146 [None; 6],
1147 default_fonts(),
1148 None,
1149 None,
1150 None,
1151 Some((16.0, 32.0)),
1152 None,
1153 96,
1154 );
1155 let variant = theme.light.as_ref().expect("light variant");
1156 assert_eq!(variant.defaults.icon_sizes.small, Some(16.0));
1157 assert_eq!(variant.defaults.icon_sizes.large, Some(32.0));
1158 }
1159
1160 #[test]
1163 fn build_theme_with_accessibility() {
1164 let accessibility = AccessibilityData {
1165 text_scaling_factor: Some(1.5),
1166 high_contrast: Some(true),
1167 reduce_motion: Some(false),
1168 };
1169 let theme = build_theme(
1170 crate::Rgba::rgb(0, 120, 215),
1171 crate::Rgba::rgb(0, 0, 0),
1172 crate::Rgba::rgb(255, 255, 255),
1173 [None; 6],
1174 default_fonts(),
1175 None,
1176 None,
1177 None,
1178 None,
1179 Some(&accessibility),
1180 96,
1181 );
1182 let variant = theme.light.as_ref().expect("light variant");
1183 assert_eq!(variant.defaults.text_scaling_factor, Some(1.5));
1184 assert_eq!(variant.defaults.high_contrast, Some(true));
1185 assert_eq!(variant.defaults.reduce_motion, Some(false));
1186 }
1187
1188 #[test]
1191 fn build_theme_sets_dialog_trailing_affirmative() {
1192 let theme = light_theme();
1193 let variant = theme.light.as_ref().expect("light variant");
1194 assert_eq!(
1195 variant.dialog.button_order,
1196 Some(crate::model::DialogButtonOrder::TrailingAffirmative)
1197 );
1198 }
1199
1200 #[test]
1203 fn build_theme_sets_geometry_defaults() {
1204 let theme = light_theme();
1205 let variant = theme.light.as_ref().expect("light variant");
1206 assert_eq!(variant.defaults.radius, Some(4.0));
1207 assert_eq!(variant.defaults.radius_lg, Some(8.0));
1208 assert_eq!(variant.defaults.shadow_enabled, Some(true));
1209 }
1210
1211 #[test]
1214 fn winui3_spacing_values() {
1215 let spacing = winui3_spacing();
1216 assert_eq!(spacing.xxs, Some(2.0));
1217 assert_eq!(spacing.xs, Some(4.0));
1218 assert_eq!(spacing.s, Some(8.0));
1219 assert_eq!(spacing.m, Some(12.0));
1220 assert_eq!(spacing.l, Some(16.0));
1221 assert_eq!(spacing.xl, Some(24.0));
1222 assert_eq!(spacing.xxl, Some(32.0));
1223 }
1224
1225 #[test]
1228 fn build_theme_includes_widget_sizing() {
1229 let theme = light_theme();
1230 let variant = theme.light.as_ref().expect("light variant");
1231 assert_eq!(variant.button.min_height, Some(32.0));
1232 assert_eq!(variant.checkbox.indicator_size, Some(20.0));
1233 assert_eq!(variant.input.min_height, Some(32.0));
1234 assert_eq!(variant.slider.thumb_size, Some(22.0));
1235 assert!(variant.scrollbar.width.is_some());
1236 assert!(variant.menu.item_height.is_some());
1237 assert_eq!(variant.splitter.width, Some(4.0));
1238 }
1239
1240 #[test]
1243 fn build_theme_surface_equals_bg() {
1244 let bg = crate::Rgba::rgb(255, 255, 255);
1245 let theme = build_theme(
1246 crate::Rgba::rgb(0, 120, 215),
1247 crate::Rgba::rgb(0, 0, 0),
1248 bg,
1249 [None; 6],
1250 default_fonts(),
1251 None,
1252 None,
1253 None,
1254 None,
1255 None,
1256 96,
1257 );
1258 let variant = theme.light.as_ref().expect("light variant");
1259 assert_eq!(variant.defaults.surface, Some(bg));
1260 }
1261
1262 #[test]
1263 fn build_theme_disabled_foreground_is_midpoint() {
1264 let theme = build_theme(
1265 crate::Rgba::rgb(0, 120, 215),
1266 crate::Rgba::rgb(0, 0, 0), crate::Rgba::rgb(255, 255, 255), [None; 6],
1269 default_fonts(),
1270 None,
1271 None,
1272 None,
1273 None,
1274 None,
1275 96,
1276 );
1277 let variant = theme.light.as_ref().expect("light variant");
1278 assert_eq!(
1280 variant.defaults.disabled_foreground,
1281 Some(crate::Rgba::rgb(127, 127, 127))
1282 );
1283 }
1284
1285 #[test]
1288 fn build_theme_returns_native_theme_with_theme_variant() {
1289 let theme = light_theme();
1291 let variant: &crate::ThemeVariant = theme.light.as_ref().unwrap();
1292 let _ = variant.defaults.accent;
1294 let _ = variant.window.title_bar_font;
1295 let _ = variant.menu.font;
1296 let _ = variant.status_bar.font;
1297 let _ = variant.button.background;
1298 let _ = variant.defaults.icon_sizes.small;
1299 let _ = variant.defaults.reduce_motion;
1300 let _ = variant.dialog.button_order;
1301 }
1302
1303 #[test]
1304 fn test_windows_resolve_validate() {
1305 let mut base = crate::ThemeSpec::preset("windows-11").unwrap();
1307 let reader_output = light_theme();
1309 base.merge(&reader_output);
1311
1312 let mut light = base
1314 .light
1315 .clone()
1316 .expect("light variant should exist after merge");
1317 light.resolve_all();
1318 let resolved = light.validate().unwrap_or_else(|e| {
1319 panic!("Windows resolve/validate pipeline failed: {e}");
1320 });
1321
1322 assert_eq!(
1324 resolved.defaults.accent,
1325 crate::Rgba::rgb(0, 120, 215),
1326 "accent should be from Windows reader"
1327 );
1328 assert_eq!(
1329 resolved.defaults.font.family, "Segoe UI",
1330 "font family should be from Windows reader"
1331 );
1332 assert_eq!(
1333 resolved.dialog.button_order,
1334 crate::DialogButtonOrder::TrailingAffirmative,
1335 "dialog button order should be trailing affirmative for Windows"
1336 );
1337 assert_eq!(
1338 resolved.icon_set,
1339 crate::IconSet::SegoeIcons,
1340 "icon_set should be SegoeIcons from Windows preset"
1341 );
1342 }
1343}