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 = 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/// Read all system fonts from NONCLIENTMETRICSW (WIN-01).
126///
127/// Extracts lfMessageFont, lfCaptionFont, lfMenuFont, and lfStatusFont
128/// as FontSpec values. Returns default fonts if the system call fails.
129#[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
161/// Compute text scale entries from the system default font size.
162///
163/// Derives caption, section heading, dialog title, and display sizes
164/// using Fluent Design type scale ratios relative to the base font.
165fn 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
190/// Return the WinUI3 Fluent Design spacing scale.
191///
192/// These are the standard spacing values from Microsoft Fluent Design guidelines,
193/// in effective pixels (epx). Pure function with no OS API calls.
194fn 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/// Read DPI-aware system DPI value.
207///
208/// Returns the system DPI (96 = standard 100% scaling).
209#[cfg(all(target_os = "windows", feature = "windows"))]
210#[allow(unsafe_code)]
211fn read_dpi() -> u32 {
212    unsafe { GetDpiForSystem() }
213}
214
215/// Read DPI-aware frame width.
216#[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/// Read DPI-aware scrollbar and widget metrics.
223#[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    // WinUI3 Fluent Design constants (not from OS APIs)
234    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/// Apply WinUI3 Fluent Design widget sizing constants (non-Windows testable version).
256#[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); // SM_CXFOCUSBORDER typical value
262    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
283/// Convert a Win32 COLORREF (0x00BBGGRR) to Rgba.
284///
285/// COLORREF stores colors as blue in the high byte, red in the low byte.
286/// This is the inverse of typical RGB ordering.
287pub(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/// Read GetSysColor widget colors (WIN-03).
295#[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
322/// Apply SysColors to the per-widget fields on a ThemeVariant.
323fn 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/// Read DwmGetColorizationColor for title bar background (WIN-02).
340#[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    // DWM colorization is 0xAARRGGBB (NOT COLORREF format)
348    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
355/// Convert a DWM colorization u32 (0xAARRGGBB) to Rgba. Testable helper.
356fn 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/// Read inactive title bar colors from GetSysColor.
365#[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/// Read accessibility settings (WIN-04).
374#[cfg(all(target_os = "windows", feature = "windows"))]
375#[allow(unsafe_code)]
376fn read_accessibility(settings: &UISettings) -> AccessibilityData {
377    // TextScaleFactor from UISettings
378    let text_scaling_factor = settings.TextScaleFactor().ok().map(|f| f as f32);
379
380    // SPI_GETHIGHCONTRAST
381    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    // SPI_GETCLIENTAREAANIMATION
402    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            // If animation is disabled, reduce_motion is true
414            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/// Read icon sizes from GetSystemMetricsForDpi (WIN-05).
428#[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/// Testable core: given raw color values, accent shades, fonts, and sizing data,
437/// build a `ThemeSpec` with a sparse `ThemeVariant`.
438///
439/// Determines light/dark variant based on foreground luminance, then populates
440/// the appropriate variant with defaults-level colors, per-widget fonts, spacing,
441/// geometry, and sizing. Only one variant is ever populated (matching KDE/GNOME
442/// reader pattern).
443#[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    // Primary button background: In light mode use AccentDark1 (shades[0]), in dark mode
460    // use AccentLight1 (shades[3]). Fall back to accent if shade unavailable.
461    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    // --- Defaults-level colors ---
470    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    // Disabled foreground: midpoint between fg and bg
480    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    // --- Defaults-level font (message font) ---
487    variant.defaults.font = fonts.msg;
488
489    // --- Per-widget fonts (WIN-01) ---
490    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    // --- Text scale (derived from defaults.font.size) ---
495    if let Some(base_size) = variant.defaults.font.size {
496        variant.text_scale = compute_text_scale(base_size);
497    }
498
499    // --- Spacing (WinUI3 Fluent) ---
500    variant.defaults.spacing = winui3_spacing();
501
502    // --- Geometry (Windows 11 defaults) ---
503    variant.defaults.radius = Some(4.0);
504    variant.defaults.radius_lg = Some(8.0);
505    variant.defaults.shadow_enabled = Some(true);
506
507    // --- Widget sizing ---
508    read_widget_sizing(dpi, &mut variant);
509
510    // --- Dialog button order (Windows convention) ---
511    variant.dialog.button_order = Some(crate::model::DialogButtonOrder::TrailingAffirmative);
512
513    // --- DWM title bar color (WIN-02) ---
514    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    // --- GetSysColor per-widget colors (WIN-03) ---
522    if let Some(colors) = sys_colors {
523        apply_sys_colors(&mut variant, colors);
524    }
525
526    // --- Icon sizes (WIN-05) ---
527    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    // --- Accessibility (WIN-04) ---
533    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/// Read the current Windows theme from UISettings, SystemParametersInfoW,
555/// GetSystemMetricsForDpi, DwmGetColorizationColor, and GetSysColor.
556///
557/// Reads accent, foreground, and background colors plus 6 accent shade colors
558/// from `UISettings` (WinRT), per-widget fonts from `NONCLIENTMETRICSW` (Win32),
559/// DWM colorization for title bar, GetSysColor for per-widget colors, accessibility
560/// settings, and icon sizes.
561///
562/// Returns `Error::Unavailable` if UISettings cannot be created (pre-Windows 10).
563#[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    /// Helper: create default AllFonts for tests that don't care about fonts.
612    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    /// Helper: create AllFonts with named fonts for testing per-widget placement.
622    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    /// Helper: build a theme in light mode with minimal args.
648    fn light_theme() -> crate::ThemeSpec {
649        build_theme(
650            crate::Rgba::rgb(0, 120, 215),
651            crate::Rgba::rgb(0, 0, 0), // black fg = light mode
652            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    /// Helper: build a theme in dark mode with minimal args.
665    fn dark_theme() -> crate::ThemeSpec {
666        build_theme(
667            crate::Rgba::rgb(0, 120, 215),
668            crate::Rgba::rgb(255, 255, 255), // white fg = dark mode
669            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    // === is_dark_mode tests ===
682
683    #[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    // === logfont_to_fontspec_raw tests ===
702
703    #[test]
704    fn logfont_to_fontspec_extracts_family_size_weight() {
705        // "Segoe UI" in UTF-16 + null terminator
706        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)); // abs(16) * 72 / 96 = 12
713        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        // Weight below 100 gets clamped
727        let fs = logfont_to_fontspec_raw(&face, -16, 0, 96);
728        assert_eq!(fs.weight, Some(100));
729        // Weight above 900 gets clamped
730        let fs = logfont_to_fontspec_raw(&face, -16, 1000, 96);
731        assert_eq!(fs.weight, Some(900));
732    }
733
734    // === colorref_to_rgba tests ===
735
736    #[test]
737    fn colorref_to_rgba_correct_rgb_extraction() {
738        // COLORREF 0x00BBGGRR: blue=0xAA, green=0xBB, red=0xCC
739        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); // Rgba::rgb sets alpha to 255
744    }
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    // === dwm_color_to_rgba tests ===
759
760    #[test]
761    fn dwm_color_to_rgba_extracts_argb() {
762        // 0xAARRGGBB format
763        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    // === build_theme tests ===
771
772    #[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        // In light mode, AccentDark1 is not directly used in ThemeVariant (old primary_background
837        // is no longer a field). But the logic still selects primary_background -- which is not set on the
838        // new model. This is fine: the resolve() pipeline handles it.
839        // Just verify the core defaults are set.
840        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    // === Per-widget font tests (WIN-01) ===
868
869    #[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    // === SysColors per-widget tests (WIN-03) ===
940
941    /// Helper: create sample SysColors for tests.
942    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    // === Focus ring width test ===
1020
1021    #[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    // === Text scale tests ===
1032
1033    #[test]
1034    fn build_theme_text_scale_from_font_size() {
1035        let fonts = named_fonts(); // msg font size = 9.0
1036        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    // === DWM title bar color test (WIN-02) ===
1097
1098    #[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    // === Icon sizes test (WIN-05) ===
1139
1140    #[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    // === Accessibility tests (WIN-04) ===
1161
1162    #[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    // === Dialog button order test ===
1189
1190    #[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    // === Geometry tests ===
1201
1202    #[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    // === Spacing test ===
1212
1213    #[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    // === Widget sizing test ===
1226
1227    #[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    // === Surface and disabled_foreground tests ===
1241
1242    #[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),       // fg
1267            crate::Rgba::rgb(255, 255, 255), // bg
1268            [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        // midpoint of (0,0,0) and (255,255,255) = (127,127,127)
1279        assert_eq!(
1280            variant.defaults.disabled_foreground,
1281            Some(crate::Rgba::rgb(127, 127, 127))
1282        );
1283    }
1284
1285    // === No old model references verification ===
1286
1287    #[test]
1288    fn build_theme_returns_native_theme_with_theme_variant() {
1289        // Verify the output type is correct (ThemeSpec with ThemeVariant, not old types)
1290        let theme = light_theme();
1291        let variant: &crate::ThemeVariant = theme.light.as_ref().unwrap();
1292        // Access new per-widget fields to prove they exist
1293        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        // Load windows-11 preset as base (provides full color/geometry/spacing).
1306        let mut base = crate::ThemeSpec::preset("windows-11").unwrap();
1307        // Build reader output (light mode, sample data).
1308        let reader_output = light_theme();
1309        // Merge reader output on top of preset.
1310        base.merge(&reader_output);
1311
1312        // Extract light variant.
1313        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        // Spot-check: reader-sourced fields present.
1323        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}