1use crate::Rgba;
4use serde::{Deserialize, Serialize};
5
6#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum FontStyle {
10 #[default]
12 Normal,
13 Italic,
15 Oblique,
17}
18
19#[derive(Clone, Copy, Debug, PartialEq)]
29pub enum FontSize {
30 Pt(f32),
34 Px(f32),
37}
38
39impl FontSize {
40 pub fn to_px(self, dpi: f32) -> f32 {
45 match self {
46 Self::Pt(v) => v * dpi / 72.0,
47 Self::Px(v) => v,
48 }
49 }
50
51 pub fn raw(self) -> f32 {
55 match self {
56 Self::Pt(v) | Self::Px(v) => v,
57 }
58 }
59
60 pub fn is_pt(self) -> bool {
62 matches!(self, Self::Pt(_))
63 }
64}
65
66impl Default for FontSize {
67 fn default() -> Self {
68 Self::Px(0.0)
69 }
70}
71
72#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
77#[serde(try_from = "FontSpecRaw", into = "FontSpecRaw")]
78pub struct FontSpec {
79 pub family: Option<String>,
81 pub size: Option<FontSize>,
87 pub weight: Option<u16>,
89 pub style: Option<FontStyle>,
91 pub color: Option<Rgba>,
93}
94
95impl FontSpec {
96 pub const FIELD_NAMES: &[&str] = &["family", "size_pt", "size_px", "weight", "style", "color"];
98}
99
100#[serde_with::skip_serializing_none]
102#[derive(Default, Serialize, Deserialize)]
103#[serde(default)]
104struct FontSpecRaw {
105 family: Option<String>,
106 size_pt: Option<f32>,
107 size_px: Option<f32>,
108 weight: Option<u16>,
109 style: Option<FontStyle>,
110 color: Option<Rgba>,
111}
112
113impl TryFrom<FontSpecRaw> for FontSpec {
114 type Error = String;
115 fn try_from(raw: FontSpecRaw) -> Result<Self, Self::Error> {
116 let size = match (raw.size_pt, raw.size_px) {
117 (Some(v), None) => Some(FontSize::Pt(v)),
118 (None, Some(v)) => Some(FontSize::Px(v)),
119 (None, None) => None,
120 (Some(_), Some(_)) => return Err("font: set `size_pt` or `size_px`, not both".into()),
121 };
122 Ok(FontSpec {
123 family: raw.family,
124 size,
125 weight: raw.weight,
126 style: raw.style,
127 color: raw.color,
128 })
129 }
130}
131
132impl From<FontSpec> for FontSpecRaw {
133 fn from(fs: FontSpec) -> Self {
134 let (size_pt, size_px) = match fs.size {
135 Some(FontSize::Pt(v)) => (Some(v), None),
136 Some(FontSize::Px(v)) => (None, Some(v)),
137 None => (None, None),
138 };
139 FontSpecRaw {
140 family: fs.family,
141 size_pt,
142 size_px,
143 weight: fs.weight,
144 style: fs.style,
145 color: fs.color,
146 }
147 }
148}
149
150impl_merge!(FontSpec {
151 option { family, size, weight, style, color }
152});
153
154#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
159pub struct ResolvedFontSpec {
160 pub family: String,
162 pub size: f32,
165 pub weight: u16,
167 pub style: FontStyle,
169 pub color: Rgba,
171}
172
173#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
178#[serde(try_from = "TextScaleEntryRaw", into = "TextScaleEntryRaw")]
179pub struct TextScaleEntry {
180 pub size: Option<FontSize>,
185 pub weight: Option<u16>,
187 pub line_height: Option<FontSize>,
190}
191
192impl TextScaleEntry {
193 pub const FIELD_NAMES: &[&str] = &[
195 "size_pt",
196 "size_px",
197 "weight",
198 "line_height_pt",
199 "line_height_px",
200 ];
201}
202
203#[serde_with::skip_serializing_none]
205#[derive(Default, Serialize, Deserialize)]
206#[serde(default)]
207struct TextScaleEntryRaw {
208 size_pt: Option<f32>,
209 size_px: Option<f32>,
210 weight: Option<u16>,
211 line_height_pt: Option<f32>,
212 line_height_px: Option<f32>,
213}
214
215impl TryFrom<TextScaleEntryRaw> for TextScaleEntry {
216 type Error = String;
217 fn try_from(raw: TextScaleEntryRaw) -> Result<Self, Self::Error> {
218 let size = match (raw.size_pt, raw.size_px) {
219 (Some(v), None) => Some(FontSize::Pt(v)),
220 (None, Some(v)) => Some(FontSize::Px(v)),
221 (None, None) => None,
222 (Some(_), Some(_)) => {
223 return Err("text_scale: set `size_pt` or `size_px`, not both".into());
224 }
225 };
226 let line_height = match (raw.line_height_pt, raw.line_height_px) {
227 (Some(v), None) => Some(FontSize::Pt(v)),
228 (None, Some(v)) => Some(FontSize::Px(v)),
229 (None, None) => None,
230 (Some(_), Some(_)) => {
231 return Err(
232 "text_scale: set `line_height_pt` or `line_height_px`, not both".into(),
233 );
234 }
235 };
236 if let (Some(s), Some(lh)) = (&size, &line_height)
237 && s.is_pt() != lh.is_pt()
238 {
239 return Err(
240 "text_scale: size and line_height must use the same unit suffix (_pt or _px)"
241 .into(),
242 );
243 }
244 Ok(TextScaleEntry {
245 size,
246 weight: raw.weight,
247 line_height,
248 })
249 }
250}
251
252impl From<TextScaleEntry> for TextScaleEntryRaw {
253 fn from(e: TextScaleEntry) -> Self {
254 let (size_pt, size_px) = match e.size {
255 Some(FontSize::Pt(v)) => (Some(v), None),
256 Some(FontSize::Px(v)) => (None, Some(v)),
257 None => (None, None),
258 };
259 let (line_height_pt, line_height_px) = match e.line_height {
260 Some(FontSize::Pt(v)) => (Some(v), None),
261 Some(FontSize::Px(v)) => (None, Some(v)),
262 None => (None, None),
263 };
264 TextScaleEntryRaw {
265 size_pt,
266 size_px,
267 weight: e.weight,
268 line_height_pt,
269 line_height_px,
270 }
271 }
272}
273
274impl_merge!(TextScaleEntry {
275 option { size, weight, line_height }
276});
277
278#[serde_with::skip_serializing_none]
283#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
284#[serde(default)]
285pub struct TextScale {
286 pub caption: Option<TextScaleEntry>,
288 pub section_heading: Option<TextScaleEntry>,
290 pub dialog_title: Option<TextScaleEntry>,
292 pub display: Option<TextScaleEntry>,
294}
295
296impl TextScale {
297 pub const FIELD_NAMES: &[&str] = &["caption", "section_heading", "dialog_title", "display"];
299}
300
301impl_merge!(TextScale {
302 optional_nested { caption, section_heading, dialog_title, display }
303});
304
305#[cfg(test)]
306#[allow(clippy::unwrap_used, clippy::expect_used)]
307mod tests {
308 use super::FontSize;
309 use super::*;
310
311 #[test]
314 fn font_spec_default_is_empty() {
315 assert!(FontSpec::default().is_empty());
316 }
317
318 #[test]
319 fn font_spec_not_empty_when_family_set() {
320 let fs = FontSpec {
321 family: Some("Inter".into()),
322 ..Default::default()
323 };
324 assert!(!fs.is_empty());
325 }
326
327 #[test]
328 fn font_spec_not_empty_when_size_set() {
329 let fs = FontSpec {
330 size: Some(FontSize::Px(14.0)),
331 ..Default::default()
332 };
333 assert!(!fs.is_empty());
334 }
335
336 #[test]
337 fn font_spec_not_empty_when_weight_set() {
338 let fs = FontSpec {
339 weight: Some(700),
340 ..Default::default()
341 };
342 assert!(!fs.is_empty());
343 }
344
345 #[test]
346 fn font_spec_toml_round_trip() {
347 let fs = FontSpec {
348 family: Some("Inter".into()),
349 size: Some(FontSize::Px(14.0)),
350 weight: Some(400),
351 ..Default::default()
352 };
353 let toml_str = toml::to_string(&fs).unwrap();
354 let deserialized: FontSpec = toml::from_str(&toml_str).unwrap();
355 assert_eq!(deserialized, fs);
356 }
357
358 #[test]
359 fn font_spec_toml_round_trip_partial() {
360 let fs = FontSpec {
361 family: Some("Inter".into()),
362 size: None,
363 weight: None,
364 ..Default::default()
365 };
366 let toml_str = toml::to_string(&fs).unwrap();
367 let deserialized: FontSpec = toml::from_str(&toml_str).unwrap();
368 assert_eq!(deserialized, fs);
369 assert!(deserialized.size.is_none());
370 assert!(deserialized.weight.is_none());
371 }
372
373 #[test]
374 fn font_spec_merge_overlay_family_replaces_base() {
375 let mut base = FontSpec {
376 family: Some("Noto Sans".into()),
377 size: Some(FontSize::Px(12.0)),
378 weight: None,
379 ..Default::default()
380 };
381 let overlay = FontSpec {
382 family: Some("Inter".into()),
383 size: None,
384 weight: None,
385 ..Default::default()
386 };
387 base.merge(&overlay);
388 assert_eq!(base.family.as_deref(), Some("Inter"));
389 assert_eq!(base.size, Some(FontSize::Px(12.0)));
391 }
392
393 #[test]
394 fn font_spec_merge_none_preserves_base() {
395 let mut base = FontSpec {
396 family: Some("Noto Sans".into()),
397 size: Some(FontSize::Px(12.0)),
398 weight: Some(400),
399 ..Default::default()
400 };
401 let overlay = FontSpec::default();
402 base.merge(&overlay);
403 assert_eq!(base.family.as_deref(), Some("Noto Sans"));
404 assert_eq!(base.size, Some(FontSize::Px(12.0)));
405 assert_eq!(base.weight, Some(400));
406 }
407
408 #[test]
411 fn text_scale_entry_default_is_empty() {
412 assert!(TextScaleEntry::default().is_empty());
413 }
414
415 #[test]
416 fn text_scale_entry_toml_round_trip() {
417 let entry = TextScaleEntry {
418 size: Some(FontSize::Px(12.0)),
419 weight: Some(400),
420 line_height: Some(FontSize::Px(1.4)),
421 };
422 let toml_str = toml::to_string(&entry).unwrap();
423 let deserialized: TextScaleEntry = toml::from_str(&toml_str).unwrap();
424 assert_eq!(deserialized, entry);
425 }
426
427 #[test]
428 fn text_scale_entry_merge_overlay_wins() {
429 let mut base = TextScaleEntry {
430 size: Some(FontSize::Px(12.0)),
431 weight: Some(400),
432 line_height: None,
433 };
434 let overlay = TextScaleEntry {
435 size: None,
436 weight: Some(700),
437 line_height: Some(FontSize::Px(1.5)),
438 };
439 base.merge(&overlay);
440 assert_eq!(base.size, Some(FontSize::Px(12.0))); assert_eq!(base.weight, Some(700)); assert_eq!(base.line_height, Some(FontSize::Px(1.5))); }
444
445 #[test]
448 fn text_scale_default_is_empty() {
449 assert!(TextScale::default().is_empty());
450 }
451
452 #[test]
453 fn text_scale_not_empty_when_entry_set() {
454 let ts = TextScale {
455 caption: Some(TextScaleEntry {
456 size: Some(FontSize::Px(11.0)),
457 ..Default::default()
458 }),
459 ..Default::default()
460 };
461 assert!(!ts.is_empty());
462 }
463
464 #[test]
465 fn text_scale_toml_round_trip() {
466 let ts = TextScale {
467 caption: Some(TextScaleEntry {
468 size: Some(FontSize::Px(11.0)),
469 weight: Some(400),
470 line_height: Some(FontSize::Px(1.3)),
471 }),
472 section_heading: Some(TextScaleEntry {
473 size: Some(FontSize::Px(14.0)),
474 weight: Some(600),
475 line_height: Some(FontSize::Px(1.4)),
476 }),
477 dialog_title: Some(TextScaleEntry {
478 size: Some(FontSize::Px(16.0)),
479 weight: Some(700),
480 line_height: Some(FontSize::Px(1.2)),
481 }),
482 display: Some(TextScaleEntry {
483 size: Some(FontSize::Px(24.0)),
484 weight: Some(300),
485 line_height: Some(FontSize::Px(1.1)),
486 }),
487 };
488 let toml_str = toml::to_string(&ts).unwrap();
489 let deserialized: TextScale = toml::from_str(&toml_str).unwrap();
490 assert_eq!(deserialized, ts);
491 }
492
493 #[test]
494 fn text_scale_merge_some_plus_some_merges_inner() {
495 let mut base = TextScale {
496 caption: Some(TextScaleEntry {
497 size: Some(FontSize::Px(11.0)),
498 weight: Some(400),
499 line_height: None,
500 }),
501 ..Default::default()
502 };
503 let overlay = TextScale {
504 caption: Some(TextScaleEntry {
505 size: None,
506 weight: Some(600),
507 line_height: Some(FontSize::Px(1.3)),
508 }),
509 ..Default::default()
510 };
511 base.merge(&overlay);
512 let cap = base.caption.as_ref().unwrap();
513 assert_eq!(cap.size, Some(FontSize::Px(11.0))); assert_eq!(cap.weight, Some(600)); assert_eq!(cap.line_height, Some(FontSize::Px(1.3))); }
517
518 #[test]
519 fn text_scale_merge_none_plus_some_clones_overlay() {
520 let mut base = TextScale::default();
521 let overlay = TextScale {
522 section_heading: Some(TextScaleEntry {
523 size: Some(FontSize::Px(14.0)),
524 ..Default::default()
525 }),
526 ..Default::default()
527 };
528 base.merge(&overlay);
529 assert!(base.section_heading.is_some());
530 assert_eq!(base.section_heading.unwrap().size, Some(FontSize::Px(14.0)));
531 }
532
533 #[test]
534 fn text_scale_merge_none_preserves_base_entry() {
535 let mut base = TextScale {
536 display: Some(TextScaleEntry {
537 size: Some(FontSize::Px(24.0)),
538 ..Default::default()
539 }),
540 ..Default::default()
541 };
542 let overlay = TextScale::default();
543 base.merge(&overlay);
544 assert!(base.display.is_some());
545 assert_eq!(base.display.unwrap().size, Some(FontSize::Px(24.0)));
546 }
547
548 #[test]
551 fn font_style_default_is_normal() {
552 assert_eq!(FontStyle::default(), FontStyle::Normal);
553 }
554
555 #[test]
556 fn font_style_serde_round_trip() {
557 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
559 struct Wrapper {
560 style: FontStyle,
561 }
562
563 for (variant, expected_str) in [
564 (FontStyle::Normal, "normal"),
565 (FontStyle::Italic, "italic"),
566 (FontStyle::Oblique, "oblique"),
567 ] {
568 let original = Wrapper { style: variant };
569 let serialized = toml::to_string(&original).unwrap();
570 assert!(serialized.contains(expected_str), "got: {serialized}");
571 let deserialized: Wrapper = toml::from_str(&serialized).unwrap();
572 assert_eq!(deserialized, original);
573 }
574 }
575
576 #[test]
577 fn font_spec_with_style_and_color_round_trip() {
578 let fs = FontSpec {
579 family: Some("Inter".into()),
580 size: Some(FontSize::Px(14.0)),
581 weight: Some(400),
582 style: Some(FontStyle::Italic),
583 color: Some(crate::Rgba::rgb(255, 0, 0)),
584 };
585 let toml_str = toml::to_string(&fs).unwrap();
586 let deserialized: FontSpec = toml::from_str(&toml_str).unwrap();
587 assert_eq!(deserialized, fs);
588 }
589
590 #[test]
591 fn font_spec_style_none_preserved() {
592 let fs = FontSpec {
593 family: Some("Inter".into()),
594 style: None,
595 ..Default::default()
596 };
597 let toml_str = toml::to_string(&fs).unwrap();
598 let deserialized: FontSpec = toml::from_str(&toml_str).unwrap();
599 assert!(deserialized.style.is_none());
600 }
601
602 #[test]
603 fn font_spec_merge_includes_style_and_color() {
604 let mut base = FontSpec {
605 family: Some("Noto Sans".into()),
606 style: Some(FontStyle::Normal),
607 color: Some(crate::Rgba::rgb(0, 0, 0)),
608 ..Default::default()
609 };
610 let overlay = FontSpec {
611 style: Some(FontStyle::Italic),
612 ..Default::default()
613 };
614 base.merge(&overlay);
615 assert_eq!(base.style, Some(FontStyle::Italic)); assert_eq!(base.color, Some(crate::Rgba::rgb(0, 0, 0))); assert_eq!(base.family.as_deref(), Some("Noto Sans")); }
619
620 #[test]
623 fn pt_to_px_at_96_dpi() {
624 assert_eq!(FontSize::Pt(10.0).to_px(96.0), 10.0 * 96.0 / 72.0);
625 }
626
627 #[test]
628 fn px_ignores_dpi() {
629 assert_eq!(FontSize::Px(14.0).to_px(96.0), 14.0);
630 assert_eq!(FontSize::Px(14.0).to_px(144.0), 14.0);
631 }
632
633 #[test]
634 fn pt_to_px_at_72_dpi_is_identity() {
635 assert_eq!(FontSize::Pt(10.0).to_px(72.0), 10.0);
636 }
637
638 #[test]
639 fn raw_extracts_value() {
640 assert_eq!(FontSize::Pt(10.0).raw(), 10.0);
641 assert_eq!(FontSize::Px(14.0).raw(), 14.0);
642 }
643
644 #[test]
645 fn font_size_default_is_px_zero() {
646 assert_eq!(FontSize::default(), FontSize::Px(0.0));
647 }
648
649 #[test]
652 fn fontspec_toml_round_trip_size_pt() {
653 let fs = FontSpec {
654 family: Some("Inter".into()),
655 size: Some(FontSize::Pt(10.0)),
656 weight: Some(400),
657 ..Default::default()
658 };
659 let toml_str = toml::to_string(&fs).expect("serialize");
660 assert!(
661 toml_str.contains("size_pt"),
662 "should contain size_pt: {toml_str}"
663 );
664 assert!(
665 !toml_str.contains("size_px"),
666 "should not contain size_px: {toml_str}"
667 );
668 let deserialized: FontSpec = toml::from_str(&toml_str).expect("deserialize");
669 assert_eq!(deserialized, fs);
670 }
671
672 #[test]
673 fn fontspec_toml_round_trip_size_px() {
674 let fs = FontSpec {
675 size: Some(FontSize::Px(14.0)),
676 ..Default::default()
677 };
678 let toml_str = toml::to_string(&fs).expect("serialize");
679 assert!(
680 toml_str.contains("size_px"),
681 "should contain size_px: {toml_str}"
682 );
683 assert!(
684 !toml_str.contains("size_pt"),
685 "should not contain size_pt: {toml_str}"
686 );
687 let deserialized: FontSpec = toml::from_str(&toml_str).expect("deserialize");
688 assert_eq!(deserialized, fs);
689 }
690
691 #[test]
692 fn fontspec_toml_rejects_both_pt_and_px() {
693 let toml_str = "size_pt = 10.0\nsize_px = 14.0\n";
694 assert!(toml::from_str::<FontSpec>(toml_str).is_err());
695 }
696
697 #[test]
698 fn fontspec_toml_rejects_bare_size() {
699 let toml_str = "size = 10.0\n";
700 let result: FontSpec = toml::from_str(toml_str).expect("deserialize");
704 assert!(
705 result.size.is_none(),
706 "bare 'size' should not set FontSpec.size"
707 );
708 }
709
710 #[test]
711 fn fontspec_toml_no_size_is_valid() {
712 let fs: FontSpec = toml::from_str(r#"family = "Inter""#).expect("deserialize");
713 assert!(fs.size.is_none());
714 }
715
716 #[test]
717 fn text_scale_entry_toml_round_trip_size_pt() {
718 let entry = TextScaleEntry {
719 size: Some(FontSize::Pt(9.0)),
720 weight: Some(400),
721 line_height: Some(FontSize::Pt(12.6)),
722 };
723 let toml_str = toml::to_string(&entry).expect("serialize");
724 assert!(toml_str.contains("size_pt"));
725 let deserialized: TextScaleEntry = toml::from_str(&toml_str).expect("deserialize");
726 assert_eq!(deserialized, entry);
727 }
728
729 #[test]
730 fn text_scale_entry_toml_round_trip_size_px() {
731 let entry = TextScaleEntry {
732 size: Some(FontSize::Px(14.0)),
733 weight: Some(400),
734 line_height: Some(FontSize::Px(18.0)),
735 };
736 let toml_str = toml::to_string(&entry).expect("serialize");
737 assert!(toml_str.contains("size_px"));
738 let deserialized: TextScaleEntry = toml::from_str(&toml_str).expect("deserialize");
739 assert_eq!(deserialized, entry);
740 }
741}