Skip to main content

native_theme/
windows.rs

1//! Windows theme reader: reads accent color, accent shades, foreground/background,
2//! per-widget fonts from NONCLIENTMETRICSW, DwmGetColorizationColor title bar colors,
3//! GetSysColor per-widget colors, accessibility from UISettings and SystemParametersInfoW,
4//! icon sizes from GetSystemMetricsForDpi, WinUI3 spacing defaults, and DPI-aware
5//! geometry metrics from UISettings (WinRT) and Win32 APIs.
6
7#[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
20/// Per-widget fonts extracted from NONCLIENTMETRICSW.
21///
22/// Windows exposes four named LOGFONTW fields:
23/// - `lfMessageFont` -- default UI font (messages, dialogs)
24/// - `lfCaptionFont` -- title bar font
25/// - `lfMenuFont` -- menu item font
26/// - `lfStatusFont` -- status bar font
27struct AllFonts {
28    msg: FontSpec,
29    caption: FontSpec,
30    menu: FontSpec,
31    status: FontSpec,
32}
33
34/// System color values extracted from GetSysColor.
35///
36/// COLORREF format: 0x00BBGGRR (blue in high byte, red in low byte).
37struct 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
53/// Accessibility data from UISettings and SystemParametersInfoW.
54struct AccessibilityData {
55    text_scaling_factor: Option<f32>,
56    high_contrast: Option<bool>,
57    reduce_motion: Option<bool>,
58}
59
60/// Convert a `windows::UI::Color` to our `Rgba` type.
61#[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
66/// Detect dark mode from the system foreground color luminance.
67///
68/// Uses BT.601 luminance coefficients. A light foreground (luminance > 128)
69/// indicates a dark background, i.e., dark mode.
70fn 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/// Read accent shade colors from UISettings with graceful per-shade fallback.
76///
77/// Returns `[AccentDark1, AccentDark2, AccentDark3, AccentLight1, AccentLight2, AccentLight3]`.
78/// Each shade is individually wrapped in `.ok()` so a failure on one shade does not
79/// prevent reading the others (PLAT-05 graceful fallback).
80#[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/// Convert a LOGFONTW to a FontSpec.
94///
95/// Extracts font family from `lfFaceName` (null-terminated UTF-16),
96/// size in points from `abs(lfHeight) * 72 / dpi`, and weight from `lfWeight`
97/// (already CSS 100-900 scale, clamped).
98#[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
103/// Testable core of logfont_to_fontspec: takes raw field values.
104fn 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/// Read all system fonts from NONCLIENTMETRICSW (WIN-01).
122///
123/// Extracts lfMessageFont, lfCaptionFont, lfMenuFont, and lfStatusFont
124/// as FontSpec values. Returns default fonts if the system call fails.
125#[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
157/// Compute text scale entries from the system default font size.
158///
159/// Derives caption, section heading, dialog title, and display sizes
160/// using Fluent Design type scale ratios relative to the base font.
161fn 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
186/// Return the WinUI3 Fluent Design spacing scale.
187///
188/// These are the standard spacing values from Microsoft Fluent Design guidelines,
189/// in effective pixels (epx). Pure function with no OS API calls.
190fn 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/// Read DPI-aware system DPI value.
203///
204/// Returns the system DPI (96 = standard 100% scaling).
205#[cfg(all(target_os = "windows", feature = "windows"))]
206#[allow(unsafe_code)]
207fn read_dpi() -> u32 {
208    unsafe { GetDpiForSystem() }
209}
210
211/// Read DPI-aware frame width.
212#[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/// Read DPI-aware scrollbar and widget metrics.
219#[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    // WinUI3 Fluent Design constants (not from OS APIs)
230    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/// Apply WinUI3 Fluent Design widget sizing constants (non-Windows testable version).
252#[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); // SM_CXFOCUSBORDER typical value
258    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
279/// Convert a Win32 COLORREF (0x00BBGGRR) to Rgba.
280///
281/// COLORREF stores colors as blue in the high byte, red in the low byte.
282/// This is the inverse of typical RGB ordering.
283pub(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/// Read GetSysColor widget colors (WIN-03).
291#[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
318/// Apply SysColors to the per-widget fields on a ThemeVariant.
319fn 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/// Read DwmGetColorizationColor for title bar background (WIN-02).
336#[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    // DWM colorization is 0xAARRGGBB (NOT COLORREF format)
344    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
351/// Convert a DWM colorization u32 (0xAARRGGBB) to Rgba. Testable helper.
352fn 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/// Read inactive title bar colors from GetSysColor.
361#[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/// Read accessibility settings (WIN-04).
370#[cfg(all(target_os = "windows", feature = "windows"))]
371#[allow(unsafe_code)]
372fn read_accessibility(settings: &UISettings) -> AccessibilityData {
373    // TextScaleFactor from UISettings
374    let text_scaling_factor = settings.TextScaleFactor().ok().map(|f| f as f32);
375
376    // SPI_GETHIGHCONTRAST
377    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    // SPI_GETCLIENTAREAANIMATION
398    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            // If animation is disabled, reduce_motion is true
410            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/// Read icon sizes from GetSystemMetricsForDpi (WIN-05).
424#[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/// Testable core: given raw color values, accent shades, fonts, and sizing data,
433/// build a `NativeTheme` with a sparse `ThemeVariant`.
434///
435/// Determines light/dark variant based on foreground luminance, then populates
436/// the appropriate variant with defaults-level colors, per-widget fonts, spacing,
437/// geometry, and sizing. Only one variant is ever populated (matching KDE/GNOME
438/// reader pattern).
439#[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    // Primary button background: In light mode use AccentDark1 (shades[0]), in dark mode
456    // use AccentLight1 (shades[3]). Fall back to accent if shade unavailable.
457    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    // --- Defaults-level colors ---
466    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    // Disabled foreground: midpoint between fg and bg
476    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    // --- Defaults-level font (message font) ---
483    variant.defaults.font = fonts.msg;
484
485    // --- Per-widget fonts (WIN-01) ---
486    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    // --- Text scale (derived from defaults.font.size) ---
491    if let Some(base_size) = variant.defaults.font.size {
492        variant.text_scale = compute_text_scale(base_size);
493    }
494
495    // --- Spacing (WinUI3 Fluent) ---
496    variant.defaults.spacing = winui3_spacing();
497
498    // --- Geometry (Windows 11 defaults) ---
499    variant.defaults.radius = Some(4.0);
500    variant.defaults.radius_lg = Some(8.0);
501    variant.defaults.shadow_enabled = Some(true);
502
503    // --- Widget sizing ---
504    read_widget_sizing(dpi, &mut variant);
505
506    // --- Dialog button order (Windows convention) ---
507    variant.dialog.button_order = Some(crate::model::DialogButtonOrder::TrailingAffirmative);
508
509    // --- DWM title bar color (WIN-02) ---
510    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    // --- GetSysColor per-widget colors (WIN-03) ---
518    if let Some(colors) = sys_colors {
519        apply_sys_colors(&mut variant, colors);
520    }
521
522    // --- Icon sizes (WIN-05) ---
523    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    // --- Accessibility (WIN-04) ---
529    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/// Read the current Windows theme from UISettings, SystemParametersInfoW,
551/// GetSystemMetricsForDpi, DwmGetColorizationColor, and GetSysColor.
552///
553/// Reads accent, foreground, and background colors plus 6 accent shade colors
554/// from `UISettings` (WinRT), per-widget fonts from `NONCLIENTMETRICSW` (Win32),
555/// DWM colorization for title bar, GetSysColor for per-widget colors, accessibility
556/// settings, and icon sizes.
557///
558/// Returns `Error::Unavailable` if UISettings cannot be created (pre-Windows 10).
559#[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    /// Helper: create default AllFonts for tests that don't care about fonts.
607    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    /// Helper: create AllFonts with named fonts for testing per-widget placement.
617    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    /// Helper: build a theme in light mode with minimal args.
643    fn light_theme() -> crate::NativeTheme {
644        build_theme(
645            crate::Rgba::rgb(0, 120, 215),
646            crate::Rgba::rgb(0, 0, 0), // black fg = light mode
647            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    /// Helper: build a theme in dark mode with minimal args.
660    fn dark_theme() -> crate::NativeTheme {
661        build_theme(
662            crate::Rgba::rgb(0, 120, 215),
663            crate::Rgba::rgb(255, 255, 255), // white fg = dark mode
664            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    // === is_dark_mode tests ===
677
678    #[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    // === logfont_to_fontspec_raw tests ===
697
698    #[test]
699    fn logfont_to_fontspec_extracts_family_size_weight() {
700        // "Segoe UI" in UTF-16 + null terminator
701        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)); // abs(16) * 72 / 96 = 12
708        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        // Weight below 100 gets clamped
722        let fs = logfont_to_fontspec_raw(&face, -16, 0, 96);
723        assert_eq!(fs.weight, Some(100));
724        // Weight above 900 gets clamped
725        let fs = logfont_to_fontspec_raw(&face, -16, 1000, 96);
726        assert_eq!(fs.weight, Some(900));
727    }
728
729    // === colorref_to_rgba tests ===
730
731    #[test]
732    fn colorref_to_rgba_correct_rgb_extraction() {
733        // COLORREF 0x00BBGGRR: blue=0xAA, green=0xBB, red=0xCC
734        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); // Rgba::rgb sets alpha to 255
739    }
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    // === dwm_color_to_rgba tests ===
754
755    #[test]
756    fn dwm_color_to_rgba_extracts_argb() {
757        // 0xAARRGGBB format
758        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    // === build_theme tests ===
766
767    #[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        // In light mode, AccentDark1 is not directly used in ThemeVariant (old primary_background
832        // is no longer a field). But the logic still selects primary_bg -- which is not set on the
833        // new model. This is fine: the resolve() pipeline handles it.
834        // Just verify the core defaults are set.
835        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    // === Per-widget font tests (WIN-01) ===
863
864    #[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    // === SysColors per-widget tests (WIN-03) ===
935
936    /// Helper: create sample SysColors for tests.
937    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    // === Focus ring width test ===
1015
1016    #[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    // === Text scale tests ===
1027
1028    #[test]
1029    fn build_theme_text_scale_from_font_size() {
1030        let fonts = named_fonts(); // msg font size = 9.0
1031        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    // === DWM title bar color test (WIN-02) ===
1092
1093    #[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    // === Icon sizes test (WIN-05) ===
1134
1135    #[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    // === Accessibility tests (WIN-04) ===
1156
1157    #[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    // === Dialog button order test ===
1184
1185    #[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    // === Geometry tests ===
1196
1197    #[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    // === Spacing test ===
1207
1208    #[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    // === Widget sizing test ===
1221
1222    #[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    // === Surface and disabled_foreground tests ===
1236
1237    #[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),       // fg
1262            crate::Rgba::rgb(255, 255, 255), // bg
1263            [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        // midpoint of (0,0,0) and (255,255,255) = (127,127,127)
1274        assert_eq!(
1275            variant.defaults.disabled_foreground,
1276            Some(crate::Rgba::rgb(127, 127, 127))
1277        );
1278    }
1279
1280    // === No old model references verification ===
1281
1282    #[test]
1283    fn build_theme_returns_native_theme_with_theme_variant() {
1284        // Verify the output type is correct (NativeTheme with ThemeVariant, not old types)
1285        let theme = light_theme();
1286        let variant: &crate::ThemeVariant = theme.light.as_ref().unwrap();
1287        // Access new per-widget fields to prove they exist
1288        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        // Load windows-11 preset as base (provides full color/geometry/spacing).
1301        let mut base = crate::NativeTheme::preset("windows-11").unwrap();
1302        // Build reader output (light mode, sample data).
1303        let reader_output = light_theme();
1304        // Merge reader output on top of preset.
1305        base.merge(&reader_output);
1306
1307        // Extract light variant.
1308        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        // Spot-check: reader-sourced fields present.
1318        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}