Skip to main content

native_theme/kde/
fonts.rs

1// Qt font string parsing with weight extraction -> populate per-widget fonts on ThemeMode
2
3/// Convert a Qt5 weight (0-100 scale) to CSS weight (100-900).
4///
5/// Mapping from research (KDE Qt5 integer weights to standard CSS ranges):
6/// 0-12->100, 13-24->200, 25-37->300, 38-56->400, 57-62->500,
7/// 63-74->600, 75-81->700, 82-87->800, 88+->900.
8pub(crate) fn qt5_to_css_weight(qt5: i32) -> u16 {
9    match qt5 {
10        0..=12 => 100,
11        13..=24 => 200,
12        25..=37 => 300,
13        38..=56 => 400,
14        57..=62 => 500,
15        63..=74 => 600,
16        75..=81 => 700,
17        82..=87 => 800,
18        _ => 900, // 88+
19    }
20}
21
22/// Parse a Qt `QFont::toString()` string into a FontSpec with weight.
23///
24/// Handles both Qt5 (<16 fields, field[4] is 0-100 scale) and
25/// Qt6 (>=16 fields, field[4] is CSS 100-900 scale) formats.
26///
27/// Extracts: family (field 0), point size (field 1), weight (field 4).
28/// Returns None if fewer than 5 fields, empty family, or an invalid,
29/// non-finite or non-positive size.
30pub(crate) fn parse_qt_font_with_weight(font_str: &str) -> Option<crate::FontSpec> {
31    let fields: Vec<&str> = font_str.split(',').collect();
32    let family_str = fields.first()?.trim();
33    if family_str.is_empty() {
34        return None;
35    }
36    let size = fields.get(1)?.trim().parse::<f32>().ok()?;
37    if !size.is_finite() || size <= 0.0 {
38        return None;
39    }
40    let raw_weight = fields.get(4)?.trim().parse::<i32>().ok()?;
41
42    // Reject corrupted font entries with negative weight
43    if raw_weight < 0 {
44        return None;
45    }
46
47    // Qt6 format has >= 16 fields and uses CSS weight scale (100-900) directly.
48    // Qt5 format has < 16 fields and uses a 0-100 scale.
49    let css_weight = if fields.len() >= 16 {
50        raw_weight as u16 // Qt6: already CSS scale
51    } else {
52        qt5_to_css_weight(raw_weight)
53    };
54
55    Some(crate::FontSpec {
56        family: Some(family_str.into()),
57        size: Some(crate::model::font::FontSize::Pt(size)),
58        weight: Some(css_weight),
59        ..Default::default()
60    })
61}
62
63/// Kirigami `Heading` level 1 (the default level): the body font's point size
64/// times this (kirigami `8319acc`, `src/controls/Heading.qml:17-18,32`).
65const HEADING_LEVEL_1_FACTOR: f32 = 1.35;
66
67/// Kirigami `Heading` level 2: the body font's point size times this
68/// (kirigami `8319acc`, `src/controls/Heading.qml:20-21,32`).
69const HEADING_LEVEL_2_FACTOR: f32 = 1.20;
70
71/// Kirigami `Heading`'s weight for its default `type: Heading.Type.Normal`
72/// (kirigami `8319acc`, `src/templates/Heading.qml:89`): `Font.Normal`, CSS
73/// 400 (`src/controls/Heading.qml:35`).
74const HEADING_WEIGHT: u16 = 400;
75
76/// A Kirigami heading of the body font `body` at a level's factor.
77fn heading(body: crate::model::font::FontSize, factor: f32) -> crate::TextScaleEntry {
78    use crate::model::font::FontSize;
79    let size = match body {
80        FontSize::Pt(v) => FontSize::Pt(v * factor),
81        FontSize::Px(v) => FontSize::Px(v * factor),
82    };
83    crate::TextScaleEntry {
84        size: Some(size),
85        weight: Some(HEADING_WEIGHT),
86        line_height: None,
87    }
88}
89
90/// Populate per-widget font fields on a ThemeMode from KDE INI.
91///
92/// Reads font keys from [General] and [WM] sections:
93/// - defaults.font from [General] font, and from its size the Kirigami
94///   heading sizes of text_scale.section_heading (level 2) and
95///   text_scale.dialog_title (level 1), `docs/platform-facts.md:1436-1437`
96/// - defaults.mono_font from [General] fixed
97/// - text_scale.caption from [General] smallestReadableFont
98///   (`docs/platform-facts.md:1435`)
99/// - menu.font from [General] menuFont (KDE-03)
100/// - toolbar.font from [General] toolBarFont (KDE-03)
101/// - window.title_bar_font from [WM] activeFont (KDE-01)
102///
103/// Missing keys result in None fields (no hardcoded fallbacks).
104pub(crate) fn populate_fonts(ini: &configparser::ini::Ini, variant: &mut crate::ThemeMode) {
105    if let Some(font_str) = ini.get("General", "font")
106        && let Some(spec) = parse_qt_font_with_weight(&font_str)
107    {
108        if let Some(body) = spec.size {
109            variant.text_scale.section_heading = Some(heading(body, HEADING_LEVEL_2_FACTOR));
110            variant.text_scale.dialog_title = Some(heading(body, HEADING_LEVEL_1_FACTOR));
111        }
112        variant.defaults.font = spec;
113    }
114
115    if let Some(smallest_str) = ini.get("General", "smallestReadableFont")
116        && let Some(spec) = parse_qt_font_with_weight(&smallest_str)
117    {
118        variant.text_scale.caption = Some(crate::TextScaleEntry {
119            size: spec.size,
120            weight: spec.weight,
121            line_height: None,
122        });
123    }
124
125    if let Some(fixed_str) = ini.get("General", "fixed")
126        && let Some(spec) = parse_qt_font_with_weight(&fixed_str)
127    {
128        variant.defaults.mono_font = spec;
129    }
130
131    // KDE-03: Per-widget fonts
132    if let Some(menu_str) = ini.get("General", "menuFont")
133        && let Some(spec) = parse_qt_font_with_weight(&menu_str)
134    {
135        variant.menu.font = Some(spec);
136    }
137
138    if let Some(toolbar_str) = ini.get("General", "toolBarFont")
139        && let Some(spec) = parse_qt_font_with_weight(&toolbar_str)
140    {
141        variant.toolbar.font = Some(spec);
142    }
143
144    // KDE-01: Title bar font from WM section
145    // Merge into existing FontSpec to preserve .color set by populate_colors.
146    if let Some(active_font_str) = ini.get("WM", "activeFont")
147        && let Some(spec) = parse_qt_font_with_weight(&active_font_str)
148    {
149        let font = variant.window.title_bar_font.get_or_insert_default();
150        font.family = spec.family;
151        font.size = spec.size;
152        font.weight = spec.weight;
153        font.style = spec.style;
154    }
155}
156
157#[cfg(test)]
158#[allow(clippy::unwrap_used, clippy::expect_used)]
159mod tests {
160    use super::*;
161    use crate::ThemeMode;
162    use crate::model::font::FontSize;
163
164    // === qt5_to_css_weight boundary tests ===
165
166    #[test]
167    fn qt5_weight_0_maps_to_100() {
168        assert_eq!(qt5_to_css_weight(0), 100);
169    }
170
171    #[test]
172    fn qt5_weight_12_maps_to_100() {
173        assert_eq!(qt5_to_css_weight(12), 100);
174    }
175
176    #[test]
177    fn qt5_weight_50_maps_to_400() {
178        assert_eq!(qt5_to_css_weight(50), 400);
179    }
180
181    #[test]
182    fn qt5_weight_75_maps_to_700_bold() {
183        assert_eq!(qt5_to_css_weight(75), 700);
184    }
185
186    #[test]
187    fn qt5_weight_88_maps_to_900() {
188        assert_eq!(qt5_to_css_weight(88), 900);
189    }
190
191    #[test]
192    fn qt5_weight_100_maps_to_900() {
193        assert_eq!(qt5_to_css_weight(100), 900);
194    }
195
196    #[test]
197    fn qt5_weight_25_maps_to_300() {
198        assert_eq!(qt5_to_css_weight(25), 300);
199    }
200
201    #[test]
202    fn qt5_weight_63_maps_to_600() {
203        assert_eq!(qt5_to_css_weight(63), 600);
204    }
205
206    // === parse_qt_font_with_weight tests ===
207
208    #[test]
209    fn parse_qt5_format_extracts_weight_50_to_400() {
210        // Qt5 format: 10 fields, field[4]=50 -> CSS 400
211        let result = parse_qt_font_with_weight("Noto Sans,10,-1,5,50,0,0,0,0,0");
212        let spec = result.unwrap();
213        assert_eq!(spec.family.as_deref(), Some("Noto Sans"));
214        assert_eq!(spec.size, Some(FontSize::Pt(10.0)));
215        assert_eq!(spec.weight, Some(400));
216    }
217
218    #[test]
219    fn parse_qt6_format_weight_stays_400() {
220        // Qt6 format: 16 fields, field[4]=400 stays 400
221        let result = parse_qt_font_with_weight("Noto Sans,10,-1,5,400,0,0,0,0,0,0,0,0,0,0,1");
222        let spec = result.unwrap();
223        assert_eq!(spec.weight, Some(400));
224    }
225
226    #[test]
227    fn parse_qt5_weight_75_converts_to_700() {
228        let result = parse_qt_font_with_weight("Noto Sans,10,-1,5,75,0,0,0,0,0");
229        let spec = result.unwrap();
230        assert_eq!(spec.weight, Some(700));
231    }
232
233    #[test]
234    fn parse_qt6_weight_700_stays_700() {
235        let result = parse_qt_font_with_weight("Noto Sans,10,-1,5,700,0,0,0,0,0,0,0,0,0,0,1");
236        let spec = result.unwrap();
237        assert_eq!(spec.weight, Some(700));
238    }
239
240    #[test]
241    fn parse_empty_string_returns_none() {
242        assert!(parse_qt_font_with_weight("").is_none());
243    }
244
245    #[test]
246    fn parse_too_few_fields_returns_none() {
247        assert!(parse_qt_font_with_weight("Noto Sans,10,-1,5").is_none());
248    }
249
250    #[test]
251    fn parse_empty_family_returns_none() {
252        assert!(parse_qt_font_with_weight(",10,-1,5,400").is_none());
253    }
254
255    #[test]
256    fn parse_negative_size_returns_none() {
257        assert!(parse_qt_font_with_weight("Noto Sans,-1,-1,5,400,0,0,0,0,0").is_none());
258    }
259
260    #[test]
261    fn parse_zero_size_returns_none() {
262        assert!(parse_qt_font_with_weight("Noto Sans,0,-1,5,400,0,0,0,0,0").is_none());
263    }
264
265    #[test]
266    fn parse_non_finite_size_returns_none() {
267        for size in ["NaN", "nan", "inf", "-inf", "infinity"] {
268            let font = format!("Noto Sans,{size},-1,5,400,0,0,0,0,0,0,0,0,0,0,1");
269            assert!(parse_qt_font_with_weight(&font).is_none(), "{size}");
270        }
271    }
272
273    #[test]
274    fn parse_negative_weight_returns_none() {
275        assert!(parse_qt_font_with_weight("Noto Sans,10,-1,5,-1,0,0,0,0,0").is_none());
276    }
277
278    // === populate_fonts tests ===
279
280    #[test]
281    fn populate_fonts_sets_defaults_font_from_general() {
282        let mut ini = super::super::create_kde_parser();
283        ini.read("[General]\nfont=Noto Sans,10,-1,5,400,0,0,0,0,0,0,0,0,0,0,1\n".to_string())
284            .unwrap();
285        let mut variant = ThemeMode::default();
286        populate_fonts(&ini, &mut variant);
287        assert_eq!(variant.defaults.font.family.as_deref(), Some("Noto Sans"));
288        assert_eq!(variant.defaults.font.size, Some(FontSize::Pt(10.0)));
289        assert_eq!(variant.defaults.font.weight, Some(400));
290    }
291
292    #[test]
293    fn populate_fonts_sets_mono_font_from_fixed() {
294        let mut ini = super::super::create_kde_parser();
295        ini.read("[General]\nfixed=Hack,10,-1,5,400,0,0,0,0,0,0,0,0,0,0,1\n".to_string())
296            .unwrap();
297        let mut variant = ThemeMode::default();
298        populate_fonts(&ini, &mut variant);
299        assert_eq!(variant.defaults.mono_font.family.as_deref(), Some("Hack"));
300        assert_eq!(variant.defaults.mono_font.size, Some(FontSize::Pt(10.0)));
301    }
302
303    #[test]
304    fn populate_fonts_sets_menu_font_from_menufont_key() {
305        let mut ini = super::super::create_kde_parser();
306        ini.read("[General]\nmenuFont=Noto Sans,9,-1,5,400,0,0,0,0,0,0,0,0,0,0,1\n".to_string())
307            .unwrap();
308        let mut variant = ThemeMode::default();
309        populate_fonts(&ini, &mut variant);
310        let menu_font = variant.menu.font.unwrap();
311        assert_eq!(menu_font.family.as_deref(), Some("Noto Sans"));
312        assert_eq!(menu_font.size, Some(FontSize::Pt(9.0)));
313    }
314
315    #[test]
316    fn populate_fonts_sets_toolbar_font_from_toolbarfont_key() {
317        let mut ini = super::super::create_kde_parser();
318        ini.read("[General]\ntoolBarFont=Noto Sans,9,-1,5,400,0,0,0,0,0,0,0,0,0,0,1\n".to_string())
319            .unwrap();
320        let mut variant = ThemeMode::default();
321        populate_fonts(&ini, &mut variant);
322        let toolbar_font = variant.toolbar.font.unwrap();
323        assert_eq!(toolbar_font.family.as_deref(), Some("Noto Sans"));
324        assert_eq!(toolbar_font.size, Some(FontSize::Pt(9.0)));
325    }
326
327    #[test]
328    fn populate_fonts_sets_title_bar_font_from_wm_activefont() {
329        let mut ini = super::super::create_kde_parser();
330        ini.read("[WM]\nactiveFont=Noto Sans,10,-1,5,75,0,0,0,0,0\n".to_string())
331            .unwrap();
332        let mut variant = ThemeMode::default();
333        populate_fonts(&ini, &mut variant);
334        let tbf = variant.window.title_bar_font.unwrap();
335        assert_eq!(tbf.family.as_deref(), Some("Noto Sans"));
336        assert_eq!(tbf.size, Some(FontSize::Pt(10.0)));
337        assert_eq!(tbf.weight, Some(700)); // Qt5 75 -> CSS 700
338    }
339
340    /// A non-default body (11pt) and smallest readable font (9pt, Qt6 weight 300).
341    const NON_DEFAULT_FONTS: &str = "[General]\n\
342         font=Noto Sans,11,-1,5,400,0,0,0,0,0,0,0,0,0,0,1\n\
343         smallestReadableFont=Noto Sans,9,-1,5,300,0,0,0,0,0,0,0,0,0,0,1\n";
344
345    fn size_pt(entry: Option<&crate::TextScaleEntry>) -> Option<f32> {
346        match entry?.size? {
347            FontSize::Pt(v) => Some(v),
348            FontSize::Px(_) => None,
349        }
350    }
351
352    #[test]
353    fn populate_fonts_sets_caption_from_smallest_readable_font() {
354        let mut ini = super::super::create_kde_parser();
355        ini.read(NON_DEFAULT_FONTS.to_string()).unwrap();
356        let mut variant = ThemeMode::default();
357        populate_fonts(&ini, &mut variant);
358        let caption = variant.text_scale.caption.as_ref();
359        assert_eq!(size_pt(caption), Some(9.0));
360        assert_eq!(caption.and_then(|c| c.weight), Some(300));
361        assert_eq!(caption.and_then(|c| c.line_height), None);
362    }
363
364    /// Kirigami's heading levels of an 11pt body: level 2 is 13.2pt, level 1
365    /// 14.85pt, both `Font.Normal`.
366    #[test]
367    fn populate_fonts_derives_headings_from_body_font() {
368        let mut ini = super::super::create_kde_parser();
369        ini.read(NON_DEFAULT_FONTS.to_string()).unwrap();
370        let mut variant = ThemeMode::default();
371        populate_fonts(&ini, &mut variant);
372        let ts = &variant.text_scale;
373        let section = size_pt(ts.section_heading.as_ref()).unwrap();
374        let title = size_pt(ts.dialog_title.as_ref()).unwrap();
375        assert!((section - 13.2).abs() < 1e-4, "section_heading {section}");
376        assert!((title - 14.85).abs() < 1e-4, "dialog_title {title}");
377        for entry in [&ts.section_heading, &ts.dialog_title] {
378            let entry = entry.as_ref().unwrap();
379            assert_eq!(entry.weight, Some(400));
380            assert_eq!(entry.line_height, None);
381        }
382        assert!(ts.display.is_none());
383    }
384
385    #[test]
386    fn populate_fonts_without_fonts_states_no_text_scale() {
387        let mut ini = super::super::create_kde_parser();
388        ini.read("[General]\nColorScheme=BreezeLight\n".to_string())
389            .unwrap();
390        let mut variant = ThemeMode::default();
391        populate_fonts(&ini, &mut variant);
392        assert_eq!(variant.text_scale, crate::TextScale::default());
393    }
394
395    #[test]
396    fn populate_fonts_missing_section_leaves_none() {
397        let ini = super::super::create_kde_parser();
398        let mut variant = ThemeMode::default();
399        populate_fonts(&ini, &mut variant);
400        assert!(variant.defaults.font.family.is_none());
401        assert!(variant.defaults.font.size.is_none());
402        assert!(variant.defaults.mono_font.family.is_none());
403        assert!(variant.menu.font.is_none());
404        assert!(variant.toolbar.font.is_none());
405        assert!(variant.window.title_bar_font.is_none());
406    }
407
408    #[test]
409    fn populate_fonts_all_keys_present() {
410        let mut ini = super::super::create_kde_parser();
411        ini.read(
412            "[General]\n\
413             font=Noto Sans,10,-1,5,400,0,0,0,0,0,0,0,0,0,0,1\n\
414             fixed=Hack,10,-1,5,400,0,0,0,0,0,0,0,0,0,0,1\n\
415             menuFont=Noto Sans,9,-1,5,400,0,0,0,0,0,0,0,0,0,0,1\n\
416             toolBarFont=Noto Sans,8,-1,5,400,0,0,0,0,0,0,0,0,0,0,1\n\
417             [WM]\n\
418             activeFont=Noto Sans,10,-1,5,700,0,0,0,0,0,0,0,0,0,0,1\n"
419                .to_string(),
420        )
421        .unwrap();
422        let mut variant = ThemeMode::default();
423        populate_fonts(&ini, &mut variant);
424
425        assert_eq!(variant.defaults.font.family.as_deref(), Some("Noto Sans"));
426        assert_eq!(variant.defaults.font.size, Some(FontSize::Pt(10.0)));
427        assert_eq!(variant.defaults.mono_font.family.as_deref(), Some("Hack"));
428        assert_eq!(
429            variant.menu.font.as_ref().unwrap().family.as_deref(),
430            Some("Noto Sans")
431        );
432        assert_eq!(
433            variant.menu.font.as_ref().unwrap().size,
434            Some(FontSize::Pt(9.0))
435        );
436        assert_eq!(
437            variant.toolbar.font.as_ref().unwrap().size,
438            Some(FontSize::Pt(8.0))
439        );
440        assert_eq!(
441            variant.window.title_bar_font.as_ref().unwrap().weight,
442            Some(700)
443        );
444    }
445}