1pub(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, }
20}
21
22pub(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 if raw_weight < 0 {
44 return None;
45 }
46
47 let css_weight = if fields.len() >= 16 {
50 raw_weight as u16 } 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
63const HEADING_LEVEL_1_FACTOR: f32 = 1.35;
66
67const HEADING_LEVEL_2_FACTOR: f32 = 1.20;
70
71const HEADING_WEIGHT: u16 = 400;
75
76fn 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
90pub(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 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 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 #[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 #[test]
209 fn parse_qt5_format_extracts_weight_50_to_400() {
210 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 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 #[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)); }
339
340 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 #[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}