Skip to main content

native_theme/kde/
colors.rs

1// KDE color group parsing -> populate per-widget fields on ThemeMode
2// Maps semantic color roles from KDE INI color groups directly to ThemeMode.
3
4use crate::Rgba;
5
6/// Look up a color key from a KDE INI section and parse it as RGB.
7fn get_color(ini: &configparser::ini::Ini, section: &str, key: &str) -> Option<Rgba> {
8    let value = ini.get(section, key)?;
9    super::parse_rgb(&value)
10}
11
12/// Breeze's `Metrics::Blend_Value`, the alpha of the selection colour over a
13/// checked indicator's fill (breezemetrics.h:176 and breezehelper.cpp:40 at
14/// breeze v6.7.5; docs/platform-facts.md §2.5).
15const BLEND_VALUE: f32 = 0.3;
16
17/// `kdeglobals` `[KDE] frameContrast` where it is unset, KColorScheme's own
18/// default (kcolorscheme.cpp:529-538 at 27066d47; docs/platform-facts.md §2.11).
19const FRAME_CONTRAST_DEFAULT: f32 = 0.2;
20
21/// `color` at `alpha`, held in 8 bits as `Rgba` holds every alpha.
22fn translucent(color: Rgba, alpha: f32) -> Rgba {
23    let [r, g, b, _] = color.to_f32_array();
24    Rgba::from_f32(r, g, b, alpha)
25}
26
27/// `top` painted over the opaque `bottom` (source over).
28fn over(top: Rgba, bottom: Rgba) -> Rgba {
29    let [tr, tg, tb, alpha] = top.to_f32_array();
30    let [br, bg, bb, _] = bottom.to_f32_array();
31    let channel = |t: f32, b: f32| t * alpha + b * (1.0 - alpha);
32    Rgba::from_f32(channel(tr, br), channel(tg, bg), channel(tb, bb), 1.0)
33}
34
35/// `KColorUtils::mix(a, b, bias)`: each channel moved from `a` towards `b` by
36/// `bias` (kcolorutils.cpp:144-165 at kguiaddons 7c766f6f).
37fn mix(a: Rgba, b: Rgba, bias: f32) -> Rgba {
38    let [ar, ag, ab, _] = a.to_f32_array();
39    let [br, bg, bb, _] = b.to_f32_array();
40    let channel = |from: f32, to: f32| from + (to - from) * bias;
41    Rgba::from_f32(channel(ar, br), channel(ag, bg), channel(ab, bb), 1.0)
42}
43
44/// How far a checked, non-flat Breeze button's fill moves from `Button`
45/// towards `ButtonText`: `KColorUtils::mix(palette.button(),
46/// palette.buttonText(), 0.125)` (breezehelper.cpp:695-699 at breeze f0b1d75;
47/// docs/platform-facts.md §2.3).
48const CHECKED_BUTTON_MIX: f32 = 0.125;
49
50/// The alpha of the accent over the window in a Breeze progress bar's
51/// contents, `alphaColor(fg, 0.7)` (breezehelper.cpp:1216 at breeze f0b1d75;
52/// docs/platform-facts.md §2.10).
53const PROGRESS_FILL_ALPHA: f32 = 0.7;
54
55/// `(x * a + 0x7f) / 0xff` as Qt's raster engine rounds it, `BYTE_MUL`
56/// (qtbase `qdrawhelper_p.h`), for 8-bit `x` and `a`.
57fn byte_mul(x: u64, a: u64) -> u64 {
58    let t = x.saturating_mul(a);
59    t.saturating_add(t.wrapping_shr(8))
60        .saturating_add(0x80)
61        .wrapping_shr(8)
62}
63
64/// `x * a / 0xffff` rounded, for 16-bit `x` and `a` (qtbase `qt_div_65535`).
65fn mul_65535(x: u64, a: u64) -> u64 {
66    let t = x.saturating_mul(a);
67    t.saturating_add(t.wrapping_shr(16))
68        .saturating_add(0x8000)
69        .wrapping_shr(16)
70}
71
72/// A 16-bit value to 8 bits, `x / 257` rounded to nearest.
73fn div_257(x: u64) -> u64 {
74    let t = x.saturating_add(0x80);
75    t.saturating_sub(t.wrapping_shr(8)).wrapping_shr(8)
76}
77
78/// `KColorUtils::overlayColors(base, paint)` for an opaque `paint` colour
79/// given the alpha `alpha` by `QColor::setAlphaF`: `paint` filled
80/// source-over onto the opaque `base` in a 1×1 `ARGB32_Premultiplied` image
81/// (kcolorutils.cpp:168-181 at kguiaddons 7c766f6f). Qt premultiplies the
82/// colour in 16 bits, stores it in 8, and blends the 8-bit destination by the
83/// 8-bit inverse alpha; this arithmetic reproduces Qt 6.11.2's pixel on
84/// 20 000 random inputs, so the result is Breeze's to the unit.
85fn overlay_colors(base: Rgba, paint: Rgba, alpha: f32) -> Rgba {
86    // QColor keeps alpha in 16 bits: qRound(alpha * 65535), in f32.
87    let alpha16 = (alpha.clamp(0.0, 1.0) * 65535.0 + 0.5).floor() as u64;
88    let alpha8 = div_257(alpha16);
89    let inverse = 255_u64.saturating_sub(alpha8);
90    let channel = |p: u8, b: u8| {
91        let premultiplied = div_257(mul_65535(u64::from(p).saturating_mul(257), alpha16));
92        let v = premultiplied.saturating_add(byte_mul(u64::from(b), inverse));
93        u8::try_from(v).unwrap_or(u8::MAX)
94    };
95    Rgba::rgb(
96        channel(paint.r, base.r),
97        channel(paint.g, base.g),
98        channel(paint.b, base.b),
99    )
100}
101
102/// `KColorScheme::frameContrast()`: `[KDE] frameContrast`, clamped to
103/// 0.0..=1.0, or its default where unset or not a number.
104fn frame_contrast(ini: &configparser::ini::Ini) -> f32 {
105    ini.get("KDE", "frameContrast")
106        .and_then(|v| v.trim().parse::<f32>().ok())
107        .filter(|v| v.is_finite())
108        .map_or(FRAME_CONTRAST_DEFAULT, |v| v.clamp(0.0, 1.0))
109}
110
111/// Populate a ThemeMode with colors from KDE INI color groups.
112///
113/// Maps all standard KDE color groups (View, Window, Button, Selection,
114/// Tooltip, Complementary, Header, WM) to per-widget fields on the variant.
115/// Missing INI keys result in None fields (no hardcoded fallbacks).
116pub(crate) fn populate_colors(ini: &configparser::ini::Ini, variant: &mut crate::ThemeMode) {
117    let window_fg = get_color(ini, "Colors:Window", "ForegroundNormal");
118
119    // === defaults-level colors ===
120    variant.defaults.accent_color = get_color(ini, "Colors:View", "DecorationFocus");
121    variant.defaults.background_color = get_color(ini, "Colors:Window", "BackgroundNormal");
122    variant.defaults.text_color = window_fg;
123    variant.defaults.surface_color = get_color(ini, "Colors:View", "BackgroundNormal");
124    // border: not set by reader -- KDE has no native border color API.
125    // The preset provides the correct neutral gray (platform-facts: "(preset)").
126    variant.defaults.muted_color = get_color(ini, "Colors:Window", "ForegroundInactive");
127    // KDE does not expose shadow color in kdeglobals
128    variant.defaults.link_color = get_color(ini, "Colors:View", "ForegroundLink");
129    variant.defaults.focus_ring_color = get_color(ini, "Colors:View", "DecorationFocus");
130
131    // Selection
132    variant.defaults.selection_background = get_color(ini, "Colors:Selection", "BackgroundNormal");
133    variant.defaults.selection_text_color = get_color(ini, "Colors:Selection", "ForegroundNormal");
134    // accent_text_color: foreground on accent-colored backgrounds (platform-facts 2.1.3)
135    variant.defaults.accent_text_color = get_color(ini, "Colors:Selection", "ForegroundNormal");
136
137    // Status colors
138    variant.defaults.danger_color = get_color(ini, "Colors:View", "ForegroundNegative");
139    variant.defaults.danger_text_color = window_fg;
140    variant.defaults.warning_color = get_color(ini, "Colors:View", "ForegroundNeutral");
141    variant.defaults.warning_text_color = window_fg;
142    variant.defaults.success_color = get_color(ini, "Colors:View", "ForegroundPositive");
143    variant.defaults.success_text_color = window_fg;
144    variant.defaults.info_color = get_color(ini, "Colors:View", "ForegroundActive");
145    variant.defaults.info_text_color = window_fg;
146
147    // Disabled
148    variant.defaults.disabled_text_color = get_color(ini, "Colors:View", "ForegroundInactive");
149
150    // === per-widget colors ===
151
152    // Button
153    variant.button.background_color = get_color(ini, "Colors:Button", "BackgroundNormal");
154    if let Some(color) = get_color(ini, "Colors:Button", "ForegroundNormal") {
155        variant.button.font.get_or_insert_default().color = Some(color);
156    }
157    // A checked button (docs/platform-facts.md §2.3): the button colour moved
158    // an eighth towards the button text, lettered in the button text.
159    let button_fg = get_color(ini, "Colors:Button", "ForegroundNormal");
160    variant.button.checked_background = variant
161        .button
162        .background_color
163        .zip(button_fg)
164        .map(|(button, text)| mix(button, text, CHECKED_BUTTON_MIX));
165    variant.button.checked_text_color = button_fg;
166
167    // Tooltip
168    variant.tooltip.background_color = get_color(ini, "Colors:Tooltip", "BackgroundNormal");
169    if let Some(color) = get_color(ini, "Colors:Tooltip", "ForegroundNormal") {
170        variant.tooltip.font.get_or_insert_default().color = Some(color);
171    }
172
173    // Sidebar (from Complementary group)
174    variant.sidebar.background_color = get_color(ini, "Colors:Complementary", "BackgroundNormal");
175    if let Some(color) = get_color(ini, "Colors:Complementary", "ForegroundNormal") {
176        variant.sidebar.font.get_or_insert_default().color = Some(color);
177    }
178
179    // Input
180    variant.input.background_color = get_color(ini, "Colors:View", "BackgroundNormal");
181    if let Some(color) = get_color(ini, "Colors:View", "ForegroundNormal") {
182        variant.input.font.get_or_insert_default().color = Some(color);
183    }
184    // KDE-02: placeholder from View/ForegroundInactive
185    variant.input.placeholder_color = get_color(ini, "Colors:View", "ForegroundInactive");
186    // input.caret from View/DecorationFocus (the focus decoration color)
187    variant.input.caret_color = get_color(ini, "Colors:View", "DecorationFocus");
188
189    // Checkbox and radio (docs/platform-facts.md §2.5): Breeze fills an
190    // unchecked indicator with the button colour and outlines it in
191    // `separatorColor()`; a checked one is that fill under the selection
192    // colour at `Metrics::Blend_Value`, outlined in the selection colour; the
193    // mark is the text colour.
194    let button_bg = get_color(ini, "Colors:Button", "BackgroundNormal");
195    let selection_bg = get_color(ini, "Colors:Selection", "BackgroundNormal");
196    variant.checkbox.unchecked_background = button_bg;
197    variant.checkbox.checked_background = button_bg
198        .zip(selection_bg)
199        .map(|(button, selection)| over(translucent(selection, BLEND_VALUE), button));
200    variant.checkbox.indicator_color = get_color(ini, "Colors:View", "ForegroundNormal");
201    if let Some(color) = selection_bg {
202        variant.checkbox.border.get_or_insert_default().color = Some(color);
203    }
204    variant.checkbox.unchecked_border_color = get_color(ini, "Colors:Window", "BackgroundNormal")
205        .zip(window_fg)
206        .map(|(window, text)| mix(window, text, frame_contrast(ini)));
207
208    // Tab (docs/platform-facts.md §2.11): Breeze marks the selected tab with a
209    // strip of `QPalette::Highlight`, the selection background.
210    variant.tab.active_indicator_color = selection_bg;
211
212    // Progress bar (docs/platform-facts.md §2.10): Breeze fills the contents
213    // with `QPalette::Accent`, the selection background
214    // (kcolorscheme.cpp:681 at 27066d47), at alpha 0.7 over the window, and
215    // strokes the groove in the window text at alpha `frameContrast`.
216    let window_bg = get_color(ini, "Colors:Window", "BackgroundNormal");
217    variant.progress_bar.fill_color = selection_bg
218        .zip(window_bg)
219        .map(|(accent, window)| overlay_colors(window, accent, PROGRESS_FILL_ALPHA));
220    if let Some(text) = window_fg {
221        variant.progress_bar.border.get_or_insert_default().color =
222            Some(translucent(text, frame_contrast(ini)));
223    }
224
225    // Popover (from View)
226    variant.popover.background_color = get_color(ini, "Colors:View", "BackgroundNormal");
227    if let Some(color) = get_color(ini, "Colors:View", "ForegroundNormal") {
228        variant.popover.font.get_or_insert_default().color = Some(color);
229    }
230
231    // Separator
232    variant.separator.line_color = get_color(ini, "Colors:Window", "ForegroundInactive");
233
234    // KDE-02: list fields (Colors:View is the native source for list/table content areas)
235    variant.list.background_color = get_color(ini, "Colors:View", "BackgroundNormal");
236    if let Some(color) = get_color(ini, "Colors:View", "ForegroundNormal") {
237        variant.list.item_font.get_or_insert_default().color = Some(color);
238    }
239    variant.list.alternate_row_background = get_color(ini, "Colors:View", "BackgroundAlternate");
240    variant.list.header_background = get_color(ini, "Colors:Header", "BackgroundNormal");
241    if let Some(color) = get_color(ini, "Colors:Header", "ForegroundNormal") {
242        variant.list.header_font.get_or_insert_default().color = Some(color);
243    }
244
245    // KDE-02: link.visited
246    variant.link.visited_text_color = get_color(ini, "Colors:View", "ForegroundVisited");
247    // link.font.color from View/ForegroundLink
248    if let Some(color) = get_color(ini, "Colors:View", "ForegroundLink") {
249        variant.link.font.get_or_insert_default().color = Some(color);
250    }
251
252    // === KDE-01: Window Manager title bar colors ===
253    variant.window.title_bar_background = get_color(ini, "WM", "activeBackground");
254    if let Some(color) = get_color(ini, "WM", "activeForeground") {
255        variant.window.title_bar_font.get_or_insert_default().color = Some(color);
256    }
257    variant.window.inactive_title_bar_background = get_color(ini, "WM", "inactiveBackground");
258    variant.window.inactive_title_bar_text_color = get_color(ini, "WM", "inactiveForeground");
259}
260
261#[cfg(test)]
262#[allow(clippy::unwrap_used, clippy::expect_used)]
263mod tests {
264    use super::*;
265    use crate::ThemeMode;
266
267    /// Full Breeze Dark kdeglobals fixture with all 6 color groups + WM + Header.
268    const BREEZE_DARK: &str = "\
269[Colors:View]
270BackgroundNormal=35,38,41
271BackgroundAlternate=30,33,36
272ForegroundNormal=252,252,252
273ForegroundInactive=161,169,177
274ForegroundActive=61,174,233
275ForegroundLink=29,153,243
276ForegroundNegative=218,68,83
277ForegroundNeutral=246,116,0
278ForegroundPositive=39,174,96
279ForegroundVisited=155,89,182
280DecorationFocus=61,174,233
281DecorationHover=29,153,243
282
283[Colors:Window]
284BackgroundNormal=49,54,59
285BackgroundAlternate=44,49,54
286ForegroundNormal=239,240,241
287ForegroundInactive=161,169,177
288ForegroundActive=61,174,233
289ForegroundLink=29,153,243
290ForegroundNegative=218,68,83
291ForegroundNeutral=246,116,0
292ForegroundPositive=39,174,96
293DecorationFocus=61,174,233
294DecorationHover=29,153,243
295
296[Colors:Button]
297BackgroundNormal=49,54,59
298BackgroundAlternate=44,49,54
299ForegroundNormal=239,240,241
300ForegroundInactive=161,169,177
301
302[Colors:Selection]
303BackgroundNormal=61,174,233
304BackgroundAlternate=29,153,243
305ForegroundNormal=252,252,252
306ForegroundInactive=161,169,177
307
308[Colors:Tooltip]
309BackgroundNormal=49,54,59
310ForegroundNormal=252,252,252
311
312[Colors:Complementary]
313BackgroundNormal=42,46,50
314ForegroundNormal=239,240,241
315
316[Colors:Header]
317BackgroundNormal=35,38,41
318ForegroundNormal=252,252,252
319
320[WM]
321activeBackground=49,54,59
322activeForeground=239,240,241
323inactiveBackground=42,46,50
324inactiveForeground=161,169,177
325";
326
327    fn populate_fixture(content: &str) -> ThemeMode {
328        let mut ini = super::super::create_kde_parser();
329        ini.read(content.to_string()).unwrap();
330        let mut variant = ThemeMode::default();
331        populate_colors(&ini, &mut variant);
332        variant
333    }
334
335    // === defaults-level color mapping ===
336
337    #[test]
338    fn test_accent_from_view_decoration_focus() {
339        let v = populate_fixture(BREEZE_DARK);
340        assert_eq!(v.defaults.accent_color, Some(Rgba::rgb(61, 174, 233)));
341    }
342
343    #[test]
344    fn test_background_from_window() {
345        let v = populate_fixture(BREEZE_DARK);
346        assert_eq!(v.defaults.background_color, Some(Rgba::rgb(49, 54, 59)));
347    }
348
349    #[test]
350    fn test_text_color_from_window() {
351        let v = populate_fixture(BREEZE_DARK);
352        assert_eq!(v.defaults.text_color, Some(Rgba::rgb(239, 240, 241)));
353    }
354
355    #[test]
356    fn test_surface_from_view() {
357        let v = populate_fixture(BREEZE_DARK);
358        assert_eq!(v.defaults.surface_color, Some(Rgba::rgb(35, 38, 41)));
359    }
360
361    #[test]
362    fn test_border_not_set_by_reader() {
363        // Border is a preset value, not reader-provided (platform-facts: "(preset)")
364        let v = populate_fixture(BREEZE_DARK);
365        assert!(v.defaults.border.is_empty());
366    }
367
368    #[test]
369    fn test_muted_from_window_foreground_inactive() {
370        let v = populate_fixture(BREEZE_DARK);
371        assert_eq!(v.defaults.muted_color, Some(Rgba::rgb(161, 169, 177)));
372    }
373
374    // === Status colors ===
375
376    #[test]
377    fn test_status_colors() {
378        let v = populate_fixture(BREEZE_DARK);
379        assert_eq!(v.defaults.danger_color, Some(Rgba::rgb(218, 68, 83)));
380        assert_eq!(v.defaults.warning_color, Some(Rgba::rgb(246, 116, 0)));
381        assert_eq!(v.defaults.success_color, Some(Rgba::rgb(39, 174, 96)));
382        assert_eq!(v.defaults.info_color, Some(Rgba::rgb(61, 174, 233)));
383    }
384
385    // === Selection ===
386
387    #[test]
388    fn test_selection_colors() {
389        let v = populate_fixture(BREEZE_DARK);
390        assert_eq!(
391            v.defaults.selection_background,
392            Some(Rgba::rgb(61, 174, 233))
393        );
394        assert_eq!(
395            v.defaults.selection_text_color,
396            Some(Rgba::rgb(252, 252, 252))
397        );
398    }
399
400    // === Per-widget: Checkbox and radio ===
401
402    /// The groups Breeze's checkbox reads, from BreezeLight.colors and
403    /// BreezeDark.colors at breeze v6.7.5 (:29-39, :85-95, :113-137).
404    fn breeze_checkbox_groups(
405        window: &str,
406        window_fg: &str,
407        button: &str,
408        view_fg: &str,
409    ) -> String {
410        format!(
411            "[Colors:Window]\nBackgroundNormal={window}\nForegroundNormal={window_fg}\n\n\
412             [Colors:Button]\nBackgroundNormal={button}\n\n\
413             [Colors:View]\nForegroundNormal={view_fg}\n\n\
414             [Colors:Selection]\nBackgroundNormal=61,174,233\n"
415        )
416    }
417
418    /// docs/platform-facts.md §2.5's KDE cells, which the kde-breeze preset states.
419    #[test]
420    fn test_checkbox_colors_are_breezes() {
421        let cases = [
422            (
423                breeze_checkbox_groups("239,240,241", "35,38,41", "252,252,252", "35,38,41"),
424                [
425                    (194, 228, 246),
426                    (252, 252, 252),
427                    (35, 38, 41),
428                    (198, 200, 201),
429                ],
430            ),
431            (
432                breeze_checkbox_groups("32,35,38", "252,252,252", "41,44,48", "252,252,252"),
433                [(47, 83, 104), (41, 44, 48), (252, 252, 252), (76, 78, 81)],
434            ),
435        ];
436        for (content, [checked, unchecked, mark, unchecked_border]) in cases {
437            let v = populate_fixture(&content);
438            let rgb = |(r, g, b): (u8, u8, u8)| Some(Rgba::rgb(r, g, b));
439            assert_eq!(v.checkbox.checked_background, rgb(checked));
440            assert_eq!(v.checkbox.unchecked_background, rgb(unchecked));
441            assert_eq!(v.checkbox.indicator_color, rgb(mark));
442            assert_eq!(v.checkbox.unchecked_border_color, rgb(unchecked_border));
443            assert_eq!(
444                v.checkbox.border.as_ref().and_then(|b| b.color),
445                rgb((61, 174, 233))
446            );
447        }
448    }
449
450    #[test]
451    fn test_checkbox_unchecked_border_follows_frame_contrast() {
452        let content = breeze_checkbox_groups("0,0,0", "200,100,0", "0,0,0", "0,0,0");
453        let with = |contrast: &str| {
454            populate_fixture(&format!("{content}\n[KDE]\nframeContrast={contrast}\n"))
455                .checkbox
456                .unchecked_border_color
457        };
458        assert_eq!(with("0.5"), Some(Rgba::rgb(100, 50, 0)));
459        assert_eq!(with("2"), Some(Rgba::rgb(200, 100, 0)), "clamped to 1");
460        assert_eq!(with("none"), Some(Rgba::rgb(40, 20, 0)), "the 0.2 default");
461    }
462
463    /// Pixels `KColorUtils::overlayColors` returned under Qt 6.11.2 and KF6
464    /// KGuiAddons for a colour `setAlphaF`'d to the alpha, over an opaque base
465    /// (scratchpad program `ovtest.cpp`, 20 000 random inputs, of which these
466    /// are six); `overlay_colors` matched all of them.
467    #[test]
468    fn overlay_colors_is_qts_pixel() {
469        let cases = [
470            ((220, 4, 101), (170, 31, 173), 0.29, (205, 12, 122)),
471            ((90, 218, 229), (172, 27, 30), 0.95, (168, 37, 40)),
472            ((209, 238, 57), (16, 203, 72), 0.48, (117, 221, 65)),
473            ((116, 55, 154), (111, 144, 12), 0.19, (115, 72, 127)),
474            ((164, 249, 31), (161, 98, 185), 0.75, (162, 135, 147)),
475            ((148, 102, 144), (95, 160, 85), 0.04, (146, 104, 141)),
476        ];
477        for (base, paint, alpha, expected) in cases {
478            let rgb = |(r, g, b): (u8, u8, u8)| Rgba::rgb(r, g, b);
479            assert_eq!(
480                overlay_colors(rgb(base), rgb(paint), alpha),
481                rgb(expected),
482                "{base:?} under {paint:?} at {alpha}"
483            );
484        }
485    }
486
487    /// docs/platform-facts.md §2.10's KDE cells, which the kde-breeze preset
488    /// states: the accent at 0.7 over the window, and the window text at the
489    /// frame contrast for the outline.
490    #[test]
491    fn test_progress_bar_colors_are_breezes() {
492        let cases = [
493            (
494                breeze_checkbox_groups("239,240,241", "35,38,41", "252,252,252", "35,38,41"),
495                Rgba::rgb(0x72, 0xc2, 0xeb),
496                Rgba::new(0x23, 0x26, 0x29, 0x33),
497            ),
498            (
499                breeze_checkbox_groups("32,35,38", "252,252,252", "41,44,48", "252,252,252"),
500                Rgba::rgb(0x35, 0x84, 0xae),
501                Rgba::new(0xfc, 0xfc, 0xfc, 0x33),
502            ),
503        ];
504        for (content, fill, outline) in cases {
505            let v = populate_fixture(&content);
506            assert_eq!(v.progress_bar.fill_color, Some(fill));
507            assert_eq!(
508                v.progress_bar.border.as_ref().and_then(|b| b.color),
509                Some(outline)
510            );
511        }
512    }
513
514    /// docs/platform-facts.md §2.3's KDE cells, which the kde-breeze preset
515    /// states: a checked button is `Button` mixed an eighth towards
516    /// `ButtonText`, lettered in `ButtonText`.
517    #[test]
518    fn test_checked_button_colors_are_breezes() {
519        let cases = [
520            ("252,252,252", "35,38,41", Rgba::rgb(0xe1, 0xe1, 0xe2)),
521            ("41,44,48", "252,252,252", Rgba::rgb(0x43, 0x46, 0x4a)),
522        ];
523        for (button, text, checked) in cases {
524            let v = populate_fixture(&format!(
525                "[Colors:Button]\nBackgroundNormal={button}\nForegroundNormal={text}\n"
526            ));
527            assert_eq!(v.button.checked_background, Some(checked), "{button}");
528            assert_eq!(
529                v.button.checked_text_color,
530                v.button.font.as_ref().and_then(|f| f.color)
531            );
532        }
533    }
534
535    // === Per-widget: Button ===
536
537    #[test]
538    fn test_button_background_from_colors_button() {
539        let v = populate_fixture(BREEZE_DARK);
540        assert_eq!(v.button.background_color, Some(Rgba::rgb(49, 54, 59)));
541    }
542
543    #[test]
544    fn test_button_font_color_from_colors_button() {
545        let v = populate_fixture(BREEZE_DARK);
546        assert_eq!(
547            v.button.font.as_ref().and_then(|f| f.color),
548            Some(Rgba::rgb(239, 240, 241))
549        );
550    }
551
552    // === Per-widget: Tooltip ===
553
554    #[test]
555    fn test_tooltip_background_from_colors_tooltip() {
556        let v = populate_fixture(BREEZE_DARK);
557        assert_eq!(v.tooltip.background_color, Some(Rgba::rgb(49, 54, 59)));
558    }
559
560    #[test]
561    fn test_tooltip_font_color_from_colors_tooltip() {
562        let v = populate_fixture(BREEZE_DARK);
563        assert_eq!(
564            v.tooltip.font.as_ref().and_then(|f| f.color),
565            Some(Rgba::rgb(252, 252, 252))
566        );
567    }
568
569    // === Per-widget: Sidebar (Complementary) ===
570
571    #[test]
572    fn test_sidebar_background_from_complementary() {
573        let v = populate_fixture(BREEZE_DARK);
574        assert_eq!(v.sidebar.background_color, Some(Rgba::rgb(42, 46, 50)));
575    }
576
577    #[test]
578    fn test_sidebar_font_color_from_complementary() {
579        let v = populate_fixture(BREEZE_DARK);
580        assert_eq!(
581            v.sidebar.font.as_ref().and_then(|f| f.color),
582            Some(Rgba::rgb(239, 240, 241))
583        );
584    }
585
586    // === KDE-01: Title bar from WM ===
587
588    #[test]
589    fn test_title_bar_background_from_wm_active() {
590        let v = populate_fixture(BREEZE_DARK);
591        assert_eq!(v.window.title_bar_background, Some(Rgba::rgb(49, 54, 59)));
592    }
593
594    #[test]
595    fn test_title_bar_font_color_from_wm_active() {
596        let v = populate_fixture(BREEZE_DARK);
597        assert_eq!(
598            v.window.title_bar_font.as_ref().and_then(|f| f.color),
599            Some(Rgba::rgb(239, 240, 241))
600        );
601    }
602
603    #[test]
604    fn test_inactive_title_bar_background_from_wm() {
605        let v = populate_fixture(BREEZE_DARK);
606        assert_eq!(
607            v.window.inactive_title_bar_background,
608            Some(Rgba::rgb(42, 46, 50))
609        );
610    }
611
612    #[test]
613    fn test_inactive_title_bar_text_color_from_wm() {
614        let v = populate_fixture(BREEZE_DARK);
615        assert_eq!(
616            v.window.inactive_title_bar_text_color,
617            Some(Rgba::rgb(161, 169, 177))
618        );
619    }
620
621    // === KDE-02: Input placeholder and caret ===
622
623    #[test]
624    fn test_input_placeholder_from_view_foreground_inactive() {
625        let v = populate_fixture(BREEZE_DARK);
626        assert_eq!(v.input.placeholder_color, Some(Rgba::rgb(161, 169, 177)));
627    }
628
629    #[test]
630    fn test_input_caret_from_view_decoration_focus() {
631        let v = populate_fixture(BREEZE_DARK);
632        assert_eq!(v.input.caret_color, Some(Rgba::rgb(61, 174, 233)));
633    }
634
635    // === accent_text_color from Selection ===
636
637    #[test]
638    fn test_accent_text_color_from_selection() {
639        let v = populate_fixture(BREEZE_DARK);
640        assert_eq!(v.defaults.accent_text_color, Some(Rgba::rgb(252, 252, 252)));
641    }
642
643    // === KDE-02: List background/item_font from View ===
644
645    #[test]
646    fn test_list_background_from_view() {
647        let v = populate_fixture(BREEZE_DARK);
648        assert_eq!(v.list.background_color, Some(Rgba::rgb(35, 38, 41)));
649    }
650
651    #[test]
652    fn test_list_item_font_color_from_view() {
653        let v = populate_fixture(BREEZE_DARK);
654        assert_eq!(
655            v.list.item_font.as_ref().and_then(|f| f.color),
656            Some(Rgba::rgb(252, 252, 252))
657        );
658    }
659
660    // === KDE-02: List alternate_row ===
661
662    #[test]
663    fn test_list_alternate_row_from_view() {
664        let v = populate_fixture(BREEZE_DARK);
665        assert_eq!(v.list.alternate_row_background, Some(Rgba::rgb(30, 33, 36)));
666    }
667
668    // === KDE-02: List header from Header group ===
669
670    #[test]
671    fn test_list_header_background_from_header() {
672        let v = populate_fixture(BREEZE_DARK);
673        assert_eq!(v.list.header_background, Some(Rgba::rgb(35, 38, 41)));
674    }
675
676    #[test]
677    fn test_list_header_font_color_from_header() {
678        let v = populate_fixture(BREEZE_DARK);
679        assert_eq!(
680            v.list.header_font.as_ref().and_then(|f| f.color),
681            Some(Rgba::rgb(252, 252, 252))
682        );
683    }
684
685    // === KDE-02: Link visited ===
686
687    #[test]
688    fn test_link_visited_from_view() {
689        let v = populate_fixture(BREEZE_DARK);
690        assert_eq!(v.link.visited_text_color, Some(Rgba::rgb(155, 89, 182)));
691    }
692
693    // === Sidebar None when Complementary missing ===
694
695    #[test]
696    fn test_sidebar_none_without_complementary() {
697        let content = "\
698[Colors:View]
699BackgroundNormal=35,38,41
700ForegroundNormal=252,252,252
701";
702        let v = populate_fixture(content);
703        assert!(v.sidebar.background_color.is_none());
704        assert!(v.sidebar.font.is_none());
705    }
706
707    // === Empty INI ===
708
709    #[test]
710    fn test_empty_ini() {
711        let ini = super::super::create_kde_parser();
712        let mut variant = ThemeMode::default();
713        populate_colors(&ini, &mut variant);
714        assert!(variant.defaults.accent_color.is_none());
715        assert!(variant.defaults.background_color.is_none());
716        assert!(variant.defaults.text_color.is_none());
717        assert!(variant.button.background_color.is_none());
718    }
719
720    // === Partial sections ===
721
722    #[test]
723    fn test_partial_sections() {
724        let content = "\
725[Colors:Window]
726BackgroundNormal=49,54,59
727ForegroundNormal=239,240,241
728ForegroundInactive=161,169,177
729DecorationFocus=61,174,233
730";
731        let v = populate_fixture(content);
732        assert_eq!(v.defaults.background_color, Some(Rgba::rgb(49, 54, 59)));
733        assert_eq!(v.defaults.text_color, Some(Rgba::rgb(239, 240, 241)));
734        assert_eq!(v.defaults.muted_color, Some(Rgba::rgb(161, 169, 177)));
735        assert!(v.defaults.border.is_empty()); // border not set by reader
736        assert!(v.defaults.accent_color.is_none());
737        assert!(v.defaults.surface_color.is_none());
738        assert!(v.button.background_color.is_none());
739    }
740
741    // === Malformed values ===
742
743    #[test]
744    fn test_malformed_values() {
745        let content = "\
746[Colors:View]
747BackgroundNormal=abc,def,ghi
748ForegroundNormal=252,252,252
749DecorationFocus=61,174,233
750";
751        let v = populate_fixture(content);
752        assert!(v.defaults.surface_color.is_none()); // BackgroundNormal was malformed
753        assert_eq!(v.defaults.accent_color, Some(Rgba::rgb(61, 174, 233)));
754    }
755
756    // === Full mapping: all non-None fields populated ===
757
758    #[test]
759    fn test_full_mapping_completeness() {
760        let v = populate_fixture(BREEZE_DARK);
761
762        // defaults-level
763        assert!(v.defaults.accent_color.is_some(), "accent_color missing");
764        assert!(
765            v.defaults.background_color.is_some(),
766            "background_color missing"
767        );
768        assert!(v.defaults.text_color.is_some(), "text_color missing");
769        assert!(v.defaults.surface_color.is_some(), "surface_color missing");
770        // border is not set by reader (preset value)
771        assert!(v.defaults.muted_color.is_some(), "muted_color missing");
772        assert!(v.defaults.link_color.is_some(), "link_color missing");
773        assert!(
774            v.defaults.selection_background.is_some(),
775            "selection_background missing"
776        );
777        assert!(
778            v.defaults.selection_text_color.is_some(),
779            "selection_text_color missing"
780        );
781        assert!(
782            v.defaults.accent_text_color.is_some(),
783            "accent_text_color missing"
784        );
785        assert!(v.defaults.danger_color.is_some(), "danger_color missing");
786        assert!(v.defaults.warning_color.is_some(), "warning_color missing");
787        assert!(v.defaults.success_color.is_some(), "success_color missing");
788        assert!(v.defaults.info_color.is_some(), "info_color missing");
789
790        // per-widget
791        assert!(
792            v.button.background_color.is_some(),
793            "button.background_color missing"
794        );
795        assert!(
796            v.button.font.as_ref().and_then(|f| f.color).is_some(),
797            "button.font.color missing"
798        );
799        assert!(
800            v.tooltip.background_color.is_some(),
801            "tooltip.background_color missing"
802        );
803        assert!(
804            v.tooltip.font.as_ref().and_then(|f| f.color).is_some(),
805            "tooltip.font.color missing"
806        );
807        assert!(
808            v.sidebar.background_color.is_some(),
809            "sidebar.background_color missing"
810        );
811        assert!(
812            v.sidebar.font.as_ref().and_then(|f| f.color).is_some(),
813            "sidebar.font.color missing"
814        );
815        assert!(
816            v.input.background_color.is_some(),
817            "input.background_color missing"
818        );
819        assert!(
820            v.input.font.as_ref().and_then(|f| f.color).is_some(),
821            "input.font.color missing"
822        );
823        assert!(
824            v.input.placeholder_color.is_some(),
825            "input.placeholder_color missing"
826        );
827        assert!(v.input.caret_color.is_some(), "input.caret_color missing");
828        assert!(
829            v.separator.line_color.is_some(),
830            "separator.line_color missing"
831        );
832        assert!(
833            v.list.background_color.is_some(),
834            "list.background_color missing"
835        );
836        assert!(
837            v.list.item_font.as_ref().and_then(|f| f.color).is_some(),
838            "list.item_font.color missing"
839        );
840        assert!(
841            v.list.alternate_row_background.is_some(),
842            "list.alternate_row_background missing"
843        );
844        assert!(
845            v.list.header_background.is_some(),
846            "list.header_background missing"
847        );
848        assert!(
849            v.list.header_font.as_ref().and_then(|f| f.color).is_some(),
850            "list.header_font.color missing"
851        );
852        assert!(
853            v.link.visited_text_color.is_some(),
854            "link.visited_text_color missing"
855        );
856
857        // WM title bar
858        assert!(
859            v.window.title_bar_background.is_some(),
860            "window.title_bar_background missing"
861        );
862        assert!(
863            v.window
864                .title_bar_font
865                .as_ref()
866                .and_then(|f| f.color)
867                .is_some(),
868            "window.title_bar_font.color missing"
869        );
870        assert!(
871            v.window.inactive_title_bar_background.is_some(),
872            "window.inactive_title_bar_background missing"
873        );
874        assert!(
875            v.window.inactive_title_bar_text_color.is_some(),
876            "window.inactive_title_bar_text_color missing"
877        );
878    }
879}